From ed932e6c933cb6df19c9f1e78157037f3f0894f6 Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sat, 7 Nov 2026 11:56:06 -0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(guard):=20executable=20identity=20?= =?UTF-8?q?=E2=80=94=20pin=20tool:X=20to=20one=20file=20by=20path+sha256?= =?UTF-8?q?=20(#679)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run matched basename(command[0]) against --tool, then spawned command[0] verbatim: a file named X at any path, via a planted PATH entry, or a repointed symlink passed as X. The guard disclosed this honestly, but a skeptic breaks it in one line. Add an opt-in executables pin that binds a tool to one resolved path + sha256, verified INDEPENDENTLY of the --script interpreter-script pin. - guard allow|deny tool: --exe pins/unpins; deny tool:X drops its exe pin too. - run resolves the real file (PATH + symlinks), denies substitution / PATH hijack / symlink repoint / byte change at the pinned path, and on allow spawns the RESOLVED absolute path so PATH cannot be re-pointed between the check and the spawn. - unpinned = name-only (legacy, weaker): still runs any file by that basename; the run card labels it the weaker check and points at --exe. Executable recorded by sha256 only. - reuses ScriptPin, resolveScript and the pinReason/decide pattern; a policy with no executables key hashes exactly as before. TOCTOU residual (hash->spawn window) is the same bound scripts already carry; --sandbox stays the stronger boundary for untrusted code. test-first: tests/run-executable-identity.test.ts — bypass repro (red->green), digest, name-only label, PATH hijack + symlink (POSIX, Linux CI), Windows .exe substitution, allow --exe end-to-end, parsePolicy validation. All 15 guard suites green. MEDIUM (owner-approved); draft, not merged — independent security review + fresh CI first. Co-Authored-By: Claude Opus 4.8 --- dist/cli/guard.d.ts | 2 +- dist/cli/guard.js | 66 ++++++++- dist/lib/guardrails.d.ts | 41 +++++- dist/lib/guardrails.js | 108 ++++++++++++++- src/cli/guard.ts | 59 +++++++- src/lib/guardrails.ts | 114 +++++++++++++++- tests/run-executable-identity.test.ts | 190 ++++++++++++++++++++++++++ 7 files changed, 568 insertions(+), 12 deletions(-) create mode 100644 tests/run-executable-identity.test.ts diff --git a/dist/cli/guard.d.ts b/dist/cli/guard.d.ts index fe261811..5cba4d41 100644 --- a/dist/cli/guard.d.ts +++ b/dist/cli/guard.d.ts @@ -63,7 +63,7 @@ export declare const RUN_USAGE = "trustshell run --tool [--class read|oth export declare const RUN_POWERSHELL = "In Windows PowerShell write the -- quoted, '--' (trustshell run --tool git '--' git status), or call trustshell.cmd: PowerShell drops a bare -- before trustshell sees it."; /** `trustshell run`. The command's own output goes to the terminal; the card goes to stderr (or --json to stdout). */ export declare function runRun(argv: string[], io: IO, env?: NodeJS.ProcessEnv, spawn?: Spawner, sandboxExec?: Exec): number; -export declare const GUARD_USAGE = "trustshell guard init [--force] [--days N]\ntrustshell guard show [--json]\ntrustshell guard check --tool [--class read|other|post|spend] [--amount ] [--json]\ntrustshell guard allow \u00B7 guard deny (spend | post | tool | tool:)\ntrustshell guard allow|deny tool: --script (pin or unpin one script by sha256: node, python, py, sh, bash, dash, zsh, ksh, powershell, pwsh)\ntrustshell guard limit per-tx|daily|total \u00B7 guard expire \u00B7 guard kill \u00B7 guard sign\ntrustshell guard require-sandbox on|off (on: trustshell run refuses without --sandbox)\ntrustshell guard level [--json] \u00B7 guard level shadow|enforce|off \u00B7 guard level after L1|L2|L3 \ntrustshell guard level record [--json] \u00B7 guard level revoke (SHADOW by default)"; +export declare const GUARD_USAGE = "trustshell guard init [--force] [--days N]\ntrustshell guard show [--json]\ntrustshell guard check --tool [--class read|other|post|spend] [--amount ] [--json]\ntrustshell guard allow \u00B7 guard deny (spend | post | tool | tool:)\ntrustshell guard allow|deny tool: --script (pin or unpin one script by sha256: node, python, py, sh, bash, dash, zsh, ksh, powershell, pwsh)\ntrustshell guard allow|deny tool: --exe (pin or unpin the tool's executable by path+sha256: only that file starts)\ntrustshell guard limit per-tx|daily|total \u00B7 guard expire \u00B7 guard kill \u00B7 guard sign\ntrustshell guard require-sandbox on|off (on: trustshell run refuses without --sandbox)\ntrustshell guard level [--json] \u00B7 guard level shadow|enforce|off \u00B7 guard level after L1|L2|L3 \ntrustshell guard level record [--json] \u00B7 guard level revoke (SHADOW by default)"; /** The disclaimer every `guard level` reading carries. */ export declare const LEVEL_IS_NOT = "Local guardrails for cooperative agents, not a security boundary: anything running as you can edit the policy or autonomy.jsonl, or hand guard level record a record.json it made up (a record is unsigned)."; /** diff --git a/dist/cli/guard.js b/dist/cli/guard.js index e894f01e..6b24cc9a 100644 --- a/dist/cli/guard.js +++ b/dist/cli/guard.js @@ -118,10 +118,20 @@ function formatRunCard(decision, outcome, doc = null, own = null) { const signed = by ? (by === own ? 'owner-signed' : `signed by ${by.slice(0, 16)}…, NOT this install's key`) : doc?.signature ? guardrails_1.SIGNATURE_DOES_NOT_VERIFY : 'unsigned'; const ex = outcome?.executed; const executed = ex && ex !== 'not run' ? `started; exited ${ex.exit_code ?? `on signal ${ex.signal}`} after ${ex.duration_ms} ms` : 'not run'; + const exe = d.requested.executable; + const pinnedExe = exe !== undefined && (doc?.policy.executables ?? []).some((p) => p.tool === exe); + const identity = exe === undefined + ? [] + : [ + pinnedExe + ? ` executable identity pinned by path+sha256: only that file starts (a substitute, a planted PATH entry or a repointed symlink is refused)` + : ` executable identity name-only (weaker): any file named ${exe} that resolves first would start; pin it with trustshell guard allow tool:${exe} --exe `, + ]; return [ `trustshell run — ${d.authorized.decision}`, ` requested ${d.requested.action}${d.requested.executable ? ` (starts ${d.requested.executable})` : ''}${d.requested.amount_raw ? ` amount ${d.requested.amount_raw}` : ''}; --class is the caller's label`, ` authorized ${d.authorized.decision}${d.authorized.reasons.length ? `: ${d.authorized.reasons.join('; ')}` : ''} (policy ${String(d.authorized.policy_sha256).slice(0, 12)}…, nonce ${d.authorized.policy_nonce}, ${signed})`, + ...identity, ` executed ${executed}`, ' independently observed nothing: no outside party watched this command', ` unknown ${d.assurance_scope.not_mediated.join('; ')}`, @@ -203,6 +213,13 @@ function runRun(argv, io, env = process.env, spawn = nodeSpawner, sandboxExec = // C5: an interpreter starts only with a script the policy pins. The file it would open is hashed here; whether that // hash is pinned is decided under the policy lock. An inline, stdin, module or loader form is refused outright. const c5 = (0, guardrails_1.interpreterCheck)(executable, command.slice(1), env); + // #679: resolve the actual file command[0] would start (through PATH and symlinks) and hash it, so a pinned tool is + // matched against the real executable, not just its basename. A tool with no pin stays name-only (decided below). + // ponytail: only a PINNED tool is hashed — a cheap policy peek avoids reading a large interpreter binary on every + // name-only run. A pin added in the microsecond between this peek and the decision's own load only fails closed. + const peek = (0, guardrails_1.loadPolicy)(dir); + const isPinned = peek.state === 'ok' && (peek.doc.policy.executables ?? []).some((p) => p.tool === executable); + const exe = isPinned ? (0, guardrails_1.resolveExecutable)(String(command[0]), env) : { ok: false, why: 'name-only: the tool is not pinned, so its executable is not hashed' }; const req = { tool, class: cls, @@ -210,6 +227,7 @@ function runRun(argv, io, env = process.env, spawn = nodeSpawner, sandboxExec = command_sha256: sha256(JSON.stringify(command)), ...(amount !== undefined ? { amount_raw: amount } : {}), ...(c5.script ? { script: c5.script } : {}), + ...(exe.ok ? { executable_file: { path: exe.path, sha256: exe.sha256 } } : {}), sandbox, }; const g = (0, guardrails_1.guardDecide)(dir, req, sandbox ? exports.SANDBOX_RUN_SCOPE : exports.RUN_SCOPE, { extraReasons: [...mismatch, ...c5.reasons] }); @@ -232,7 +250,12 @@ function runRun(argv, io, env = process.env, spawn = nodeSpawner, sandboxExec = if (sandbox) return runInSandbox(dir, g, own, json, io, command, runtime ?? 'runc', sandboxExec); const t0 = Date.now(); - const r = spawn(command[0], command.slice(1)); + // #679: when the tool is pinned, the decision above already proved the resolved file matches the pin, so start that + // exact resolved path — a PATH entry cannot be re-pointed between the check and the spawn. Name-only (unpinned) + // keeps the legacy behaviour: spawn command[0] verbatim. + const pol = g.loaded.state === 'ok' ? g.loaded.doc.policy : null; + const pinnedExe = exe.ok && (pol?.executables ?? []).some((p) => p.tool === executable); + const r = spawn(pinnedExe ? exe.path : command[0], command.slice(1)); const outcome = (0, guardrails_1.appendRecord)(dir, { kind: 'outcome', requested: g.record.requested, @@ -298,6 +321,7 @@ trustshell guard show [--json] trustshell guard check --tool [--class read|other|post|spend] [--amount ] [--json] trustshell guard allow · guard deny (spend | post | tool | tool:) trustshell guard allow|deny tool: --script (pin or unpin one script by sha256: ${guardrails_1.INTERPRETER_NAMES}) +trustshell guard allow|deny tool: --exe (pin or unpin the tool's executable by path+sha256: only that file starts) trustshell guard limit per-tx|daily|total · guard expire · guard kill · guard sign trustshell guard require-sandbox on|off (on: trustshell run refuses without --sandbox) trustshell guard level [--json] · guard level shadow|enforce|off · guard level after L1|L2|L3 @@ -421,6 +445,7 @@ function showLines(doc, path, ledgerText, defaulted) { `subject ${(0, action_evidence_1.cleanCodePoints)(p.subject)}`, // policy.json is text someone else may have written (an imported, signed policy): print it as text `actions ${p.actions.length ? p.actions.join(', ') : 'NONE: everything is denied'} (anything unlisted is denied)`, `scripts ${p.scripts?.length ? p.scripts.map((s) => `${s.tool} ${(0, action_evidence_1.cleanCodePoints)(s.path)} (sha256 ${s.sha256.slice(0, 12)}…)`).join('; ') : 'none pinned'} (an allowed interpreter runs only these)`, + `executables ${p.executables?.length ? p.executables.map((s) => `${s.tool} ${(0, action_evidence_1.cleanCodePoints)(s.path)} (sha256 ${s.sha256.slice(0, 12)}…)`).join('; ') : 'none pinned'} (a pinned tool starts only this file; unpinned tools are name-only)`, 'always denied whatever the actions list: init --pai, learn check run, learn probe run (each starts a process the guard cannot see)', `limits per_tx ${p.per_tx_raw} · daily ${p.daily_raw} · total ${p.total_raw} (smallest units)${defaulted.length ? `; left out, so 0: ${defaulted.join(', ')}` : ''}; allowed so far: ${spent}`, `expires ${new Date(p.expires_at * 1000).toISOString()}`, @@ -582,9 +607,15 @@ function runGuard(argv, io, env = process.env) { if (verb === 'allow' || verb === 'deny') { const si = words.indexOf('--script'); const scriptArg = si === -1 ? undefined : words[si + 1]; - const action = si === -1 ? words : words.filter((_, i) => i !== si && i !== si + 1); + const ei = words.indexOf('--exe'); + const exeArg = ei === -1 ? undefined : words[ei + 1]; + const action = words.filter((_, i) => !(si !== -1 && (i === si || i === si + 1)) && !(ei !== -1 && (i === ei || i === ei + 1))); if (si !== -1 && (scriptArg === undefined || scriptArg.startsWith('-'))) return bad('--script takes the path of one script'); + if (ei !== -1 && (exeArg === undefined || exeArg.startsWith('-'))) + return bad('--exe takes the path of one executable'); + if (si !== -1 && ei !== -1) + return bad('give --script or --exe, not both: a script pin and an executable pin are set one at a time'); if (action.length !== 1) return bad(`guard ${verb} takes one action: spend, post, tool or tool:`); const a = action[0]; @@ -595,6 +626,7 @@ function runGuard(argv, io, env = process.env) { // C5: pins belong to the executable's own name, the one `run` derives (lower case). const exe = a.startsWith('tool:') ? a.slice(5).toLowerCase() : ''; const pins = p.scripts ?? []; + const exePins = p.executables ?? []; if (scriptArg !== undefined) { if (!(0, guardrails_1.isInterpreter)(exe)) return bad(`--script pins a script for an interpreter (${guardrails_1.INTERPRETER_NAMES}), and ${a} is not one, so nothing was written`); @@ -619,17 +651,47 @@ function runGuard(argv, io, env = process.env) { via = `guard deny ${a} --script ${target}`; } } + else if (exeArg !== undefined) { + // #679: pin (or unpin) a tool's executable by resolved path + sha256. Applies to any named tool. + if (!a.startsWith('tool:')) + return bad(`--exe pins the executable for a named tool (tool:), and ${a} is not one, so nothing was written`); + const r = (0, guardrails_1.resolveExecutable)(exeArg, env); + // Unpinning an executable that is gone still works, by the path it was pinned under. + const target = r.ok ? r.path : verb === 'deny' ? (0, node_path_1.resolve)(exeArg) : null; + if (target === null) { + io.err(`not written: ${r.ok ? '' : r.why}`); + return 2; + } + const others = exePins.filter((s) => !(s.tool === exe && s.path === target)); + if (verb === 'deny' && others.length === exePins.length) + return bad(`no executable is pinned for ${exe} at ${target}, so nothing was written`); + if (verb === 'allow' && r.ok) { + if (!p.actions.includes(a)) + p.actions = [...p.actions, a]; + p.executables = [...others, { tool: exe, path: target, sha256: r.sha256 }]; + via = `guard allow ${a} --exe ${target} sha256 ${r.sha256}`; + } + else { + p.executables = others; + via = `guard deny ${a} --exe ${target}`; + } + } else { p.actions = verb === 'allow' ? (p.actions.includes(a) ? p.actions : [...p.actions, a]) : p.actions.filter((x) => x !== a); // Denying an interpreter drops its pins too: allowing it again must not quietly revive old ones. if (verb === 'deny' && (0, guardrails_1.isInterpreter)(exe)) p.scripts = pins.filter((s) => s.tool !== exe); + // #679: denying a tool drops its executable pin too, for the same reason. + if (verb === 'deny' && exe) + p.executables = exePins.filter((s) => s.tool !== exe); if (verb === 'allow' && (0, guardrails_1.isInterpreter)(exe) && !pins.some((s) => s.tool === exe)) { note = `note ${exe} is an interpreter: run starts it only with a script the policy pins by sha256, and none is pinned yet (trustshell guard allow ${a} --script )`; } } if (p.scripts && !p.scripts.length) delete p.scripts; + if (p.executables && !p.executables.length) + delete p.executables; } else if (verb === 'limit') { const key = { 'per-tx': 'per_tx_raw', daily: 'daily_raw', total: 'total_raw' }[words[0] ?? '']; diff --git a/dist/lib/guardrails.d.ts b/dist/lib/guardrails.d.ts index 5468ff73..7a8b3d7b 100644 --- a/dist/lib/guardrails.d.ts +++ b/dist/lib/guardrails.d.ts @@ -20,6 +20,13 @@ export interface LocalPolicy { autonomy?: AutonomyConfig; /** Local only (not in the kernel): the scripts an allowed interpreter may run (C5). Absent = none. */ scripts?: ScriptPin[]; + /** + * Local only (not in the kernel): executable identity (#679). A pin binds a tool to ONE file by resolved path and + * sha256, so `run --tool X` starts only that file — a substitute with the same basename, a planted PATH entry or a + * repointed symlink is refused. Absent for a tool = name-only (the weaker, legacy check: any file by that basename). + * Reuses ScriptPin's shape; verified INDEPENDENTLY of `scripts` (an interpreter can pin both its binary and its script). + */ + executables?: ScriptPin[]; /** Local only (not in the kernel): `trustshell run` must use --sandbox (C2). Absent = it need not. */ run?: RunRules; } @@ -55,6 +62,23 @@ export declare function resolveScript(arg: string, cwd?: string): { ok: false; why: string; }; +/** The rule every executable-identity denial states. */ +export declare const EXECUTABLE_RULE = "a pinned tool starts only the executable the policy pins by path and sha256"; +/** + * The executable `run` would actually start, resolved to one regular file through PATH and symlinks, and hashed — + * the file identity the policy pins (#679). Reuses resolveScript for the realpath+regular-file+sha256 step, so a + * binary and a script are resolved the same way. TOCTOU: hashed here, spawned a moment later; a swap in between is + * not caught, the same bound as scripts (anything that can swap it runs as the owner). Spawn the resolved path, not + * the bare name, so PATH cannot be re-pointed between the check and the spawn. + */ +export declare function resolveExecutable(command0: string, env?: NodeJS.ProcessEnv, cwd?: string): { + ok: true; + path: string; + sha256: string; +} | { + ok: false; + why: string; +}; /** * C5, for `run`: the script an interpreter would open, resolved and hashed, and every reason it must not start. Not an * interpreter: nothing. Whether the script is PINNED is decided under the policy lock, in decide(). @@ -139,6 +163,11 @@ export interface GuardRequest { path: string; sha256: string; }; + /** The file `command[0]` resolves to (#679): resolved path and sha256 of the actual executable. Recorded by hash only. */ + executable_file?: { + path: string; + sha256: string; + }; /** `trustshell run` only: whether it was asked to run the command in a sandbox mission (--sandbox). */ sandbox?: boolean; } @@ -147,6 +176,13 @@ export interface GuardRequest { * resolved path AND sha256 the policy pins for that executable; no script, an unpinned one or a changed one: denied. */ export declare function pinReason(policy: LocalPolicy, req: GuardRequest): string | null; +/** + * PURE. #679: why this request may not start its executable, or null. A tool with an executable pin starts ONLY the + * file whose resolved path AND sha256 the policy pins: a substitute (different file), a planted PATH entry or a + * repointed symlink all resolve to a path/hash the pin does not list, and are denied. A tool with NO pin returns null + * here — it stays name-only (the weaker legacy check), which the run card labels as such; pinning is opt-in. + */ +export declare function execPinReason(policy: LocalPolicy, req: GuardRequest): string | null; /** The action string the policy must list for this request (the kernel's actionPermitted). */ export declare function actionFor(req: GuardRequest): string; export interface GuardDecision { @@ -179,10 +215,11 @@ export interface GuardRecord { * that enforced a carried policy), with the hash before and after. A rule change is never silent. */ kind: 'decision' | 'check' | 'outcome' | 'policy-change'; - /** What was asked. A script is recorded by its sha256 only, never by its path (the command is never recorded). */ - requested: Omit & { + /** What was asked. A script/executable is recorded by its sha256 only, never by its path (the command is never recorded). */ + requested: Omit & { action: string; script_sha256?: string; + executable_sha256?: string; }; authorized: GuardDecision & { policy_sha256: string | null; diff --git a/dist/lib/guardrails.js b/dist/lib/guardrails.js index 13438721..6b50a60a 100644 --- a/dist/lib/guardrails.js +++ b/dist/lib/guardrails.js @@ -1,8 +1,9 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.GUARD_DOES_NOT_COVER = exports.GuardDeniedError = exports.nodeGuardFs = exports.GUARD_LOCK_FILE = exports.GUARD_CHECKPOINT_FILE = exports.SIGNATURE_DOES_NOT_VERIFY = exports.isPolicyAction = exports.LOCAL_TOOLS = exports.isInterpreter = exports.INTERPRETER_RULE = exports.INTERPRETER_NAMES = exports.TOOL_CLASSES = exports.GUARD_RECORD_SCHEMA = exports.GUARD_RECEIPTS_FILE = exports.SUGGESTED_POLICY_FILE = exports.POLICY_FILE = void 0; +exports.GUARD_DOES_NOT_COVER = exports.GuardDeniedError = exports.nodeGuardFs = exports.GUARD_LOCK_FILE = exports.GUARD_CHECKPOINT_FILE = exports.SIGNATURE_DOES_NOT_VERIFY = exports.isPolicyAction = exports.LOCAL_TOOLS = exports.EXECUTABLE_RULE = exports.isInterpreter = exports.INTERPRETER_RULE = exports.INTERPRETER_NAMES = exports.TOOL_CLASSES = exports.GUARD_RECORD_SCHEMA = exports.GUARD_RECEIPTS_FILE = exports.SUGGESTED_POLICY_FILE = exports.POLICY_FILE = void 0; exports.interpreterScript = interpreterScript; exports.resolveScript = resolveScript; +exports.resolveExecutable = resolveExecutable; exports.interpreterCheck = interpreterCheck; exports.defaultPolicy = defaultPolicy; exports.trustshellHome = trustshellHome; @@ -14,6 +15,7 @@ exports.policySha256 = policySha256; exports.policySigner = policySigner; exports.policySignedBy = policySignedBy; exports.pinReason = pinReason; +exports.execPinReason = execPinReason; exports.actionFor = actionFor; exports.decide = decide; exports.recordHash = recordHash; @@ -152,6 +154,48 @@ function resolveScript(arg, cwd = process.cwd()) { return { ok: false, why: `the script cannot be resolved to one regular file (${e.code ?? 'unreadable'})` }; } } +/** The rule every executable-identity denial states. */ +exports.EXECUTABLE_RULE = 'a pinned tool starts only the executable the policy pins by path and sha256'; +/** + * Where `command[0]` would actually resolve, the way the OS spawner does: a path-ish name against the cwd; a bare name + * along PATH (on Windows also the cwd first, with each PATHEXT). The FIRST match wins, as spawn would pick it. Returns + * the candidate's path (not yet realpath'd — resolveExecutable does that); null when nothing matches. + */ +function whichExecutable(name, env, cwd) { + const fs = require('node:fs'); + if (name.includes('/') || name.includes('\\')) + return (0, node_path_1.resolve)(cwd, name); + const isWin = process.platform === 'win32'; + const dirs = (env.PATH ?? env.Path ?? '').split(isWin ? ';' : ':').filter(Boolean); + const search = isWin ? [cwd, ...dirs] : dirs; + const exts = isWin ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) : ['']; + const hasExt = isWin && /\.[^.\\/]+$/.test(name); + for (const d of search) { + for (const c of hasExt ? [(0, node_path_1.join)(d, name)] : exts.map((e) => (0, node_path_1.join)(d, name + e))) { + try { + if (fs.statSync(c).isFile()) + return c; + } + catch { + /* next candidate */ + } + } + } + return null; +} +/** + * The executable `run` would actually start, resolved to one regular file through PATH and symlinks, and hashed — + * the file identity the policy pins (#679). Reuses resolveScript for the realpath+regular-file+sha256 step, so a + * binary and a script are resolved the same way. TOCTOU: hashed here, spawned a moment later; a swap in between is + * not caught, the same bound as scripts (anything that can swap it runs as the owner). Spawn the resolved path, not + * the bare name, so PATH cannot be re-pointed between the check and the spawn. + */ +function resolveExecutable(command0, env = process.env, cwd = process.cwd()) { + const cand = whichExecutable(command0, env, cwd); + if (cand === null) + return { ok: false, why: `the executable ${command0.slice(0, 80)} was not found on PATH` }; + return resolveScript(cand, cwd); +} /** * C5, for `run`: the script an interpreter would open, resolved and hashed, and every reason it must not start. Not an * interpreter: nothing. Whether the script is PINNED is decided under the policy lock, in decide(). @@ -176,7 +220,9 @@ const ACTION = /^(spend|post|tool|tool:[A-Za-z0-9_.:-]{1,128})$/; const isPolicyAction = (a) => ACTION.test(a); exports.isPolicyAction = isPolicyAction; const CANONICAL_UINT = /^(?:0|[1-9][0-9]{0,77})$/; -const POLICY_KEYS = ['subject', 'actions', 'per_tx_raw', 'daily_raw', 'total_raw', 'expires_at', 'nonce', 'autonomy', 'scripts', 'run']; +const POLICY_KEYS = ['subject', 'actions', 'per_tx_raw', 'daily_raw', 'total_raw', 'expires_at', 'nonce', 'autonomy', 'scripts', 'executables', 'run']; +/** A pinned tool's own name (as `run` derives the executable: letters, digits, _ . -). Not `spend`/`post`/`tool`. */ +const EXEC_PIN_TOOL = /^[A-Za-z0-9_.-]{1,128}$/; const SHA256_HEX = /^[0-9a-f]{64}$/; /** Absolute on either platform: a carried policy is parsed on the host it was carried to. */ const ABSOLUTE = /^(\/|[A-Za-z]:[\\/]|\\\\)/; @@ -206,6 +252,33 @@ function parseScripts(v, errors) { } return pins; } +/** Like parseScripts, but for executable-identity pins: any tool name (#679), not only interpreters. */ +function parseExecutables(v, errors) { + if (!Array.isArray(v) || v.length > 64) { + errors.push('executables must be a list of at most 64 { tool, path, sha256 }'); + return []; + } + const pins = []; + for (const s of v) { + const ok = isObject(s) && + Object.keys(s).every((k) => k === 'tool' || k === 'path' || k === 'sha256') && + typeof s.tool === 'string' && + EXEC_PIN_TOOL.test(s.tool) && + typeof s.path === 'string' && + s.path.length <= 4096 && + ABSOLUTE.test(s.path) && + !s.path.includes('\0') && + typeof s.sha256 === 'string' && + SHA256_HEX.test(s.sha256); + if (!ok) + errors.push('executables holds an entry that is not { tool: an executable name, path: an absolute path, sha256: 64 lower-case hex }'); + else if (pins.some((p) => p.tool === s.tool && p.path === s.path)) + errors.push(`executables pins ${s.tool} ${String(s.path).slice(0, 80)} twice`); + else + pins.push({ tool: s.tool, path: s.path, sha256: s.sha256 }); + } + return pins; +} const UINT256_MAX = BigInt('0x' + 'f'.repeat(64)); /** The policy `guard init` writes: local commands only, every limit 0, expires in `days` days. */ function defaultPolicy(nowS = Math.floor(Date.now() / 1000), days = 30) { @@ -289,6 +362,7 @@ function parsePolicy(input) { if (auto && !auto.ok) errors.push(...auto.errors); const scripts = input.scripts === undefined ? null : parseScripts(input.scripts, errors); + const executables = input.executables === undefined ? null : parseExecutables(input.executables, errors); const run = input.run; const runOk = run === undefined || (isObject(run) && Object.keys(run).length === 1 && typeof run.require_sandbox === 'boolean'); if (!runOk) @@ -309,6 +383,7 @@ function parsePolicy(input) { // Only when present: a policy without it hashes exactly as it did before autonomy existed. ...(auto?.ok ? { autonomy: auto.config } : {}), ...(scripts ? { scripts } : {}), + ...(executables ? { executables } : {}), ...(run !== undefined ? { run: { require_sandbox: run.require_sandbox } } : {}), }, }; @@ -384,6 +459,30 @@ function pinReason(policy, req) { ? `the script changed since it was pinned for ${exe} (sha256 now ${s.sha256.slice(0, 12)}…); ${exports.INTERPRETER_RULE} (${how} pins it again)` : `the script (sha256 ${s.sha256.slice(0, 12)}…) is not pinned for ${exe}; ${exports.INTERPRETER_RULE} (${how} pins it)`; } +/** + * PURE. #679: why this request may not start its executable, or null. A tool with an executable pin starts ONLY the + * file whose resolved path AND sha256 the policy pins: a substitute (different file), a planted PATH entry or a + * repointed symlink all resolve to a path/hash the pin does not list, and are denied. A tool with NO pin returns null + * here — it stays name-only (the weaker legacy check), which the run card labels as such; pinning is opt-in. + */ +function execPinReason(policy, req) { + const exe = req.executable; + if (exe === undefined) + return null; + const pins = (policy.executables ?? []).filter((p) => p.tool === exe); + if (!pins.length) + return null; + const how = `trustshell guard allow tool:${exe} --exe `; + const f = req.executable_file; + if (!f) + return `${exe} is pinned to an approved executable, but the file that would run could not be resolved; ${exports.EXECUTABLE_RULE} (${how})`; + const atPath = pins.filter((p) => p.path === f.path); + if (atPath.some((p) => p.sha256 === f.sha256)) + return null; + return atPath.length + ? `the executable for ${exe} changed since it was pinned (sha256 now ${f.sha256.slice(0, 12)}…); ${exports.EXECUTABLE_RULE} (${how} pins it again)` + : `the executable that would run (sha256 ${f.sha256.slice(0, 12)}…) is not the one pinned for ${exe}: a different file, PATH entry or symlink target; ${exports.EXECUTABLE_RULE} (${how})`; +} /** The action string the policy must list for this request (the kernel's actionPermitted). */ function actionFor(req) { return req.class === 'spend' ? 'spend' : req.class === 'post' ? 'post' : `tool:${req.tool}`; @@ -411,6 +510,9 @@ function decide(policy, req, spent, nowS, problems = []) { const pin = pinReason(policy, req); if (pin) reasons.push(pin); + const expin = execPinReason(policy, req); + if (expin) + reasons.push(expin); if (policy.run?.require_sandbox && req.sandbox === false) { reasons.push('the owner policy requires --sandbox for trustshell run (run.require_sandbox; trustshell guard require-sandbox off clears it)'); } @@ -712,6 +814,8 @@ function decideLocked(dir, req, scope, ctx, loaded) { requested.executable = req.executable; if (req.script !== undefined) requested.script_sha256 = req.script.sha256; + if (req.executable_file !== undefined) + requested.executable_sha256 = req.executable_file.sha256; if (req.sandbox === true) requested.sandbox = true; const kind = ctx.kind ?? 'decision'; diff --git a/src/cli/guard.ts b/src/cli/guard.ts index 1cab6c34..4203f5a8 100644 --- a/src/cli/guard.ts +++ b/src/cli/guard.ts @@ -43,6 +43,7 @@ import { readLedger, renderPolicyDocument, requirePolicy, + resolveExecutable, resolveScript, trustshellHome, type AssuranceScope, @@ -179,10 +180,21 @@ export function formatRunCard(decision: GuardRecord, outcome: GuardRecord | null const ex = outcome?.executed; const executed = ex && ex !== 'not run' ? `started; exited ${ex.exit_code ?? `on signal ${ex.signal}`} after ${ex.duration_ms} ms` : 'not run'; + const exe = d.requested.executable; + const pinnedExe = exe !== undefined && (doc?.policy.executables ?? []).some((p) => p.tool === exe); + const identity = + exe === undefined + ? [] + : [ + pinnedExe + ? ` executable identity pinned by path+sha256: only that file starts (a substitute, a planted PATH entry or a repointed symlink is refused)` + : ` executable identity name-only (weaker): any file named ${exe} that resolves first would start; pin it with trustshell guard allow tool:${exe} --exe `, + ]; return [ `trustshell run — ${d.authorized.decision}`, ` requested ${d.requested.action}${d.requested.executable ? ` (starts ${d.requested.executable})` : ''}${d.requested.amount_raw ? ` amount ${d.requested.amount_raw}` : ''}; --class is the caller's label`, ` authorized ${d.authorized.decision}${d.authorized.reasons.length ? `: ${d.authorized.reasons.join('; ')}` : ''} (policy ${String(d.authorized.policy_sha256).slice(0, 12)}…, nonce ${d.authorized.policy_nonce}, ${signed})`, + ...identity, ` executed ${executed}`, ' independently observed nothing: no outside party watched this command', ` unknown ${d.assurance_scope.not_mediated.join('; ')}`, @@ -261,6 +273,13 @@ export function runRun(argv: string[], io: IO, env: NodeJS.ProcessEnv = process. // C5: an interpreter starts only with a script the policy pins. The file it would open is hashed here; whether that // hash is pinned is decided under the policy lock. An inline, stdin, module or loader form is refused outright. const c5 = interpreterCheck(executable, command.slice(1), env); + // #679: resolve the actual file command[0] would start (through PATH and symlinks) and hash it, so a pinned tool is + // matched against the real executable, not just its basename. A tool with no pin stays name-only (decided below). + // ponytail: only a PINNED tool is hashed — a cheap policy peek avoids reading a large interpreter binary on every + // name-only run. A pin added in the microsecond between this peek and the decision's own load only fails closed. + const peek = loadPolicy(dir); + const isPinned = peek.state === 'ok' && (peek.doc.policy.executables ?? []).some((p) => p.tool === executable); + const exe = isPinned ? resolveExecutable(String(command[0]), env) : ({ ok: false, why: 'name-only: the tool is not pinned, so its executable is not hashed' } as const); const req: GuardRequest = { tool, class: cls, @@ -268,6 +287,7 @@ export function runRun(argv: string[], io: IO, env: NodeJS.ProcessEnv = process. command_sha256: sha256(JSON.stringify(command)), ...(amount !== undefined ? { amount_raw: amount } : {}), ...(c5.script ? { script: c5.script } : {}), + ...(exe.ok ? { executable_file: { path: exe.path, sha256: exe.sha256 } } : {}), sandbox, }; const g = guardDecide(dir, req, sandbox ? SANDBOX_RUN_SCOPE : RUN_SCOPE, { extraReasons: [...mismatch, ...c5.reasons] }); @@ -288,7 +308,12 @@ export function runRun(argv: string[], io: IO, env: NodeJS.ProcessEnv = process. } if (sandbox) return runInSandbox(dir, g, own, json, io, command, runtime ?? 'runc', sandboxExec); const t0 = Date.now(); - const r = spawn(command[0], command.slice(1)); + // #679: when the tool is pinned, the decision above already proved the resolved file matches the pin, so start that + // exact resolved path — a PATH entry cannot be re-pointed between the check and the spawn. Name-only (unpinned) + // keeps the legacy behaviour: spawn command[0] verbatim. + const pol = g.loaded.state === 'ok' ? g.loaded.doc.policy : null; + const pinnedExe = exe.ok && (pol?.executables ?? []).some((p) => p.tool === executable); + const r = spawn(pinnedExe ? (exe as { path: string }).path : command[0], command.slice(1)); const outcome = appendRecord(dir, { kind: 'outcome', requested: g.record.requested, @@ -365,6 +390,7 @@ trustshell guard show [--json] trustshell guard check --tool [--class read|other|post|spend] [--amount ] [--json] trustshell guard allow · guard deny (spend | post | tool | tool:) trustshell guard allow|deny tool: --script (pin or unpin one script by sha256: ${INTERPRETER_NAMES}) +trustshell guard allow|deny tool: --exe (pin or unpin the tool's executable by path+sha256: only that file starts) trustshell guard limit per-tx|daily|total · guard expire · guard kill · guard sign trustshell guard require-sandbox on|off (on: trustshell run refuses without --sandbox) trustshell guard level [--json] · guard level shadow|enforce|off · guard level after L1|L2|L3 @@ -482,6 +508,7 @@ function showLines(doc: PolicyDocument, path: string, ledgerText: string, defaul `subject ${cleanCodePoints(p.subject)}`, // policy.json is text someone else may have written (an imported, signed policy): print it as text `actions ${p.actions.length ? p.actions.join(', ') : 'NONE: everything is denied'} (anything unlisted is denied)`, `scripts ${p.scripts?.length ? p.scripts.map((s) => `${s.tool} ${cleanCodePoints(s.path)} (sha256 ${s.sha256.slice(0, 12)}…)`).join('; ') : 'none pinned'} (an allowed interpreter runs only these)`, + `executables ${p.executables?.length ? p.executables.map((s) => `${s.tool} ${cleanCodePoints(s.path)} (sha256 ${s.sha256.slice(0, 12)}…)`).join('; ') : 'none pinned'} (a pinned tool starts only this file; unpinned tools are name-only)`, 'always denied whatever the actions list: init --pai, learn check run, learn probe run (each starts a process the guard cannot see)', `limits per_tx ${p.per_tx_raw} · daily ${p.daily_raw} · total ${p.total_raw} (smallest units)${defaulted.length ? `; left out, so 0: ${defaulted.join(', ')}` : ''}; allowed so far: ${spent}`, `expires ${new Date(p.expires_at * 1000).toISOString()}`, @@ -629,8 +656,12 @@ export function runGuard(argv: string[], io: IO, env: NodeJS.ProcessEnv = proces if (verb === 'allow' || verb === 'deny') { const si = words.indexOf('--script'); const scriptArg = si === -1 ? undefined : words[si + 1]; - const action = si === -1 ? words : words.filter((_, i) => i !== si && i !== si + 1); + const ei = words.indexOf('--exe'); + const exeArg = ei === -1 ? undefined : words[ei + 1]; + const action = words.filter((_, i) => !(si !== -1 && (i === si || i === si + 1)) && !(ei !== -1 && (i === ei || i === ei + 1))); if (si !== -1 && (scriptArg === undefined || scriptArg.startsWith('-'))) return bad('--script takes the path of one script'); + if (ei !== -1 && (exeArg === undefined || exeArg.startsWith('-'))) return bad('--exe takes the path of one executable'); + if (si !== -1 && ei !== -1) return bad('give --script or --exe, not both: a script pin and an executable pin are set one at a time'); if (action.length !== 1) return bad(`guard ${verb} takes one action: spend, post, tool or tool:`); const a = action[0] as string; if (!isPolicyAction(a)) { @@ -640,6 +671,7 @@ export function runGuard(argv: string[], io: IO, env: NodeJS.ProcessEnv = proces // C5: pins belong to the executable's own name, the one `run` derives (lower case). const exe = a.startsWith('tool:') ? a.slice(5).toLowerCase() : ''; const pins = p.scripts ?? []; + const exePins = p.executables ?? []; if (scriptArg !== undefined) { if (!isInterpreter(exe)) return bad(`--script pins a script for an interpreter (${INTERPRETER_NAMES}), and ${a} is not one, so nothing was written`); const r = resolveScript(scriptArg); @@ -659,15 +691,38 @@ export function runGuard(argv: string[], io: IO, env: NodeJS.ProcessEnv = proces p.scripts = others; via = `guard deny ${a} --script ${target}`; } + } else if (exeArg !== undefined) { + // #679: pin (or unpin) a tool's executable by resolved path + sha256. Applies to any named tool. + if (!a.startsWith('tool:')) return bad(`--exe pins the executable for a named tool (tool:), and ${a} is not one, so nothing was written`); + const r = resolveExecutable(exeArg, env); + // Unpinning an executable that is gone still works, by the path it was pinned under. + const target = r.ok ? r.path : verb === 'deny' ? resolve(exeArg) : null; + if (target === null) { + io.err(`not written: ${r.ok ? '' : r.why}`); + return 2; + } + const others = exePins.filter((s) => !(s.tool === exe && s.path === target)); + if (verb === 'deny' && others.length === exePins.length) return bad(`no executable is pinned for ${exe} at ${target}, so nothing was written`); + if (verb === 'allow' && r.ok) { + if (!p.actions.includes(a)) p.actions = [...p.actions, a]; + p.executables = [...others, { tool: exe, path: target, sha256: r.sha256 }]; + via = `guard allow ${a} --exe ${target} sha256 ${r.sha256}`; + } else { + p.executables = others; + via = `guard deny ${a} --exe ${target}`; + } } else { p.actions = verb === 'allow' ? (p.actions.includes(a) ? p.actions : [...p.actions, a]) : p.actions.filter((x) => x !== a); // Denying an interpreter drops its pins too: allowing it again must not quietly revive old ones. if (verb === 'deny' && isInterpreter(exe)) p.scripts = pins.filter((s) => s.tool !== exe); + // #679: denying a tool drops its executable pin too, for the same reason. + if (verb === 'deny' && exe) p.executables = exePins.filter((s) => s.tool !== exe); if (verb === 'allow' && isInterpreter(exe) && !pins.some((s) => s.tool === exe)) { note = `note ${exe} is an interpreter: run starts it only with a script the policy pins by sha256, and none is pinned yet (trustshell guard allow ${a} --script )`; } } if (p.scripts && !p.scripts.length) delete p.scripts; + if (p.executables && !p.executables.length) delete p.executables; } else if (verb === 'limit') { const key = { 'per-tx': 'per_tx_raw', daily: 'daily_raw', total: 'total_raw' }[words[0] ?? ''] as 'per_tx_raw' | 'daily_raw' | 'total_raw' | undefined; if (!key || words.length !== 2) return bad('guard limit takes per-tx, daily or total, then an amount'); diff --git a/src/lib/guardrails.ts b/src/lib/guardrails.ts index 388e8bb2..d32d9604 100644 --- a/src/lib/guardrails.ts +++ b/src/lib/guardrails.ts @@ -61,6 +61,13 @@ export interface LocalPolicy { autonomy?: AutonomyConfig; /** Local only (not in the kernel): the scripts an allowed interpreter may run (C5). Absent = none. */ scripts?: ScriptPin[]; + /** + * Local only (not in the kernel): executable identity (#679). A pin binds a tool to ONE file by resolved path and + * sha256, so `run --tool X` starts only that file — a substitute with the same basename, a planted PATH entry or a + * repointed symlink is refused. Absent for a tool = name-only (the weaker, legacy check: any file by that basename). + * Reuses ScriptPin's shape; verified INDEPENDENTLY of `scripts` (an interpreter can pin both its binary and its script). + */ + executables?: ScriptPin[]; /** Local only (not in the kernel): `trustshell run` must use --sandbox (C2). Absent = it need not. */ run?: RunRules; } @@ -155,6 +162,51 @@ export function resolveScript(arg: string, cwd: string = process.cwd()): { ok: t } } +/** The rule every executable-identity denial states. */ +export const EXECUTABLE_RULE = 'a pinned tool starts only the executable the policy pins by path and sha256'; + +/** + * Where `command[0]` would actually resolve, the way the OS spawner does: a path-ish name against the cwd; a bare name + * along PATH (on Windows also the cwd first, with each PATHEXT). The FIRST match wins, as spawn would pick it. Returns + * the candidate's path (not yet realpath'd — resolveExecutable does that); null when nothing matches. + */ +function whichExecutable(name: string, env: NodeJS.ProcessEnv, cwd: string): string | null { + const fs = require('node:fs') as typeof import('node:fs'); + if (name.includes('/') || name.includes('\\')) return resolve(cwd, name); + const isWin = process.platform === 'win32'; + const dirs = (env.PATH ?? env.Path ?? '').split(isWin ? ';' : ':').filter(Boolean); + const search = isWin ? [cwd, ...dirs] : dirs; + const exts = isWin ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) : ['']; + const hasExt = isWin && /\.[^.\\/]+$/.test(name); + for (const d of search) { + for (const c of hasExt ? [join(d, name)] : exts.map((e) => join(d, name + e))) { + try { + if (fs.statSync(c).isFile()) return c; + } catch { + /* next candidate */ + } + } + } + return null; +} + +/** + * The executable `run` would actually start, resolved to one regular file through PATH and symlinks, and hashed — + * the file identity the policy pins (#679). Reuses resolveScript for the realpath+regular-file+sha256 step, so a + * binary and a script are resolved the same way. TOCTOU: hashed here, spawned a moment later; a swap in between is + * not caught, the same bound as scripts (anything that can swap it runs as the owner). Spawn the resolved path, not + * the bare name, so PATH cannot be re-pointed between the check and the spawn. + */ +export function resolveExecutable( + command0: string, + env: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), +): { ok: true; path: string; sha256: string } | { ok: false; why: string } { + const cand = whichExecutable(command0, env, cwd); + if (cand === null) return { ok: false, why: `the executable ${command0.slice(0, 80)} was not found on PATH` }; + return resolveScript(cand, cwd); +} + /** * C5, for `run`: the script an interpreter would open, resolved and hashed, and every reason it must not start. Not an * interpreter: nothing. Whether the script is PINNED is decided under the policy lock, in decide(). @@ -194,7 +246,9 @@ const ACTION = /^(spend|post|tool|tool:[A-Za-z0-9_.:-]{1,128})$/; /** PURE. Is `a` an entry a policy's `actions` may hold (spend, post, tool or tool:)? */ export const isPolicyAction = (a: string): boolean => ACTION.test(a); const CANONICAL_UINT = /^(?:0|[1-9][0-9]{0,77})$/; -const POLICY_KEYS = ['subject', 'actions', 'per_tx_raw', 'daily_raw', 'total_raw', 'expires_at', 'nonce', 'autonomy', 'scripts', 'run']; +const POLICY_KEYS = ['subject', 'actions', 'per_tx_raw', 'daily_raw', 'total_raw', 'expires_at', 'nonce', 'autonomy', 'scripts', 'executables', 'run']; +/** A pinned tool's own name (as `run` derives the executable: letters, digits, _ . -). Not `spend`/`post`/`tool`. */ +const EXEC_PIN_TOOL = /^[A-Za-z0-9_.-]{1,128}$/; const SHA256_HEX = /^[0-9a-f]{64}$/; /** Absolute on either platform: a carried policy is parsed on the host it was carried to. */ const ABSOLUTE = /^(\/|[A-Za-z]:[\\/]|\\\\)/; @@ -223,6 +277,32 @@ function parseScripts(v: unknown, errors: string[]): ScriptPin[] { } return pins; } + +/** Like parseScripts, but for executable-identity pins: any tool name (#679), not only interpreters. */ +function parseExecutables(v: unknown, errors: string[]): ScriptPin[] { + if (!Array.isArray(v) || v.length > 64) { + errors.push('executables must be a list of at most 64 { tool, path, sha256 }'); + return []; + } + const pins: ScriptPin[] = []; + for (const s of v) { + const ok = + isObject(s) && + Object.keys(s).every((k) => k === 'tool' || k === 'path' || k === 'sha256') && + typeof s.tool === 'string' && + EXEC_PIN_TOOL.test(s.tool) && + typeof s.path === 'string' && + s.path.length <= 4096 && + ABSOLUTE.test(s.path) && + !s.path.includes('\0') && + typeof s.sha256 === 'string' && + SHA256_HEX.test(s.sha256); + if (!ok) errors.push('executables holds an entry that is not { tool: an executable name, path: an absolute path, sha256: 64 lower-case hex }'); + else if (pins.some((p) => p.tool === s.tool && p.path === s.path)) errors.push(`executables pins ${s.tool} ${String(s.path).slice(0, 80)} twice`); + else pins.push({ tool: s.tool as string, path: s.path as string, sha256: s.sha256 as string }); + } + return pins; +} const UINT256_MAX = BigInt('0x' + 'f'.repeat(64)); /** The policy `guard init` writes: local commands only, every limit 0, expires in `days` days. */ @@ -300,6 +380,7 @@ export function parsePolicy(input: unknown): { ok: true; policy: LocalPolicy; de const auto = input.autonomy === undefined ? null : parseAutonomy(input.autonomy); if (auto && !auto.ok) errors.push(...auto.errors); const scripts = input.scripts === undefined ? null : parseScripts(input.scripts, errors); + const executables = input.executables === undefined ? null : parseExecutables(input.executables, errors); const run = input.run; const runOk = run === undefined || (isObject(run) && Object.keys(run).length === 1 && typeof run.require_sandbox === 'boolean'); if (!runOk) errors.push('run must be { require_sandbox: true or false }'); @@ -318,6 +399,7 @@ export function parsePolicy(input: unknown): { ok: true; policy: LocalPolicy; de // Only when present: a policy without it hashes exactly as it did before autonomy existed. ...(auto?.ok ? { autonomy: auto.config } : {}), ...(scripts ? { scripts } : {}), + ...(executables ? { executables } : {}), ...(run !== undefined ? { run: { require_sandbox: (run as RunRules).require_sandbox } } : {}), }, }; @@ -387,6 +469,8 @@ export interface GuardRequest { executable?: string; /** The script an interpreter `executable` would open (C5): resolved path and sha256. Recorded by its hash only. */ script?: { path: string; sha256: string }; + /** The file `command[0]` resolves to (#679): resolved path and sha256 of the actual executable. Recorded by hash only. */ + executable_file?: { path: string; sha256: string }; /** `trustshell run` only: whether it was asked to run the command in a sandbox mission (--sandbox). */ sandbox?: boolean; } @@ -408,6 +492,27 @@ export function pinReason(policy: LocalPolicy, req: GuardRequest): string | null : `the script (sha256 ${s.sha256.slice(0, 12)}…) is not pinned for ${exe}; ${INTERPRETER_RULE} (${how} pins it)`; } +/** + * PURE. #679: why this request may not start its executable, or null. A tool with an executable pin starts ONLY the + * file whose resolved path AND sha256 the policy pins: a substitute (different file), a planted PATH entry or a + * repointed symlink all resolve to a path/hash the pin does not list, and are denied. A tool with NO pin returns null + * here — it stays name-only (the weaker legacy check), which the run card labels as such; pinning is opt-in. + */ +export function execPinReason(policy: LocalPolicy, req: GuardRequest): string | null { + const exe = req.executable; + if (exe === undefined) return null; + const pins = (policy.executables ?? []).filter((p) => p.tool === exe); + if (!pins.length) return null; + const how = `trustshell guard allow tool:${exe} --exe `; + const f = req.executable_file; + if (!f) return `${exe} is pinned to an approved executable, but the file that would run could not be resolved; ${EXECUTABLE_RULE} (${how})`; + const atPath = pins.filter((p) => p.path === f.path); + if (atPath.some((p) => p.sha256 === f.sha256)) return null; + return atPath.length + ? `the executable for ${exe} changed since it was pinned (sha256 now ${f.sha256.slice(0, 12)}…); ${EXECUTABLE_RULE} (${how} pins it again)` + : `the executable that would run (sha256 ${f.sha256.slice(0, 12)}…) is not the one pinned for ${exe}: a different file, PATH entry or symlink target; ${EXECUTABLE_RULE} (${how})`; +} + /** The action string the policy must list for this request (the kernel's actionPermitted). */ export function actionFor(req: GuardRequest): string { return req.class === 'spend' ? 'spend' : req.class === 'post' ? 'post' : `tool:${req.tool}`; @@ -443,6 +548,8 @@ export function decide( if (!permitted(policy.actions, req)) reasons.push(`the owner policy does not list ${actionFor(req)}`); const pin = pinReason(policy, req); if (pin) reasons.push(pin); + const expin = execPinReason(policy, req); + if (expin) reasons.push(expin); if (policy.run?.require_sandbox && req.sandbox === false) { reasons.push('the owner policy requires --sandbox for trustshell run (run.require_sandbox; trustshell guard require-sandbox off clears it)'); } @@ -481,8 +588,8 @@ export interface GuardRecord { * that enforced a carried policy), with the hash before and after. A rule change is never silent. */ kind: 'decision' | 'check' | 'outcome' | 'policy-change'; - /** What was asked. A script is recorded by its sha256 only, never by its path (the command is never recorded). */ - requested: Omit & { action: string; script_sha256?: string }; + /** What was asked. A script/executable is recorded by its sha256 only, never by its path (the command is never recorded). */ + requested: Omit & { action: string; script_sha256?: string; executable_sha256?: string }; authorized: GuardDecision & { policy_sha256: string | null; policy_nonce: string | null; policy_signed_by: string | null }; /** outcome only: what the started process reported. Never what it did. */ executed: { exit_code: number | null; signal: string | null; duration_ms: number } | 'not run' | null; @@ -827,6 +934,7 @@ function decideLocked( if (req.command_sha256 !== undefined) requested.command_sha256 = req.command_sha256; if (req.executable !== undefined) requested.executable = req.executable; if (req.script !== undefined) requested.script_sha256 = req.script.sha256; + if (req.executable_file !== undefined) requested.executable_sha256 = req.executable_file.sha256; if (req.sandbox === true) requested.sandbox = true; const kind = ctx.kind ?? 'decision'; const record = appendUnlocked( diff --git a/tests/run-executable-identity.test.ts b/tests/run-executable-identity.test.ts new file mode 100644 index 00000000..702c4fd5 --- /dev/null +++ b/tests/run-executable-identity.test.ts @@ -0,0 +1,190 @@ +/** + * Executable identity (PR #679): `run --tool git` must start the executable the owner approved, not merely a file + * whose BASENAME is `git`. Before this, the guard matched `basename(command[0])` against `--tool` and then spawned + * `command[0]` verbatim, so `run --tool git -- /attacker/git` (same basename, different file), a PATH entry planted + * ahead of the real one, or a repointed symlink all passed. A new `executables` pin binds the tool to one file by + * resolved path + sha256; an unpinned tool stays name-only (the weaker, legacy check, labelled as such on the card). + * + * Every test uses a temp TRUSTSHELL_HOME and a fake spawner, so nothing is started and the real home is never touched. + */ +import { createHash } from 'node:crypto'; +import { delimiter, join } from 'node:path'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import type { CliIO } from '../src/cli'; +import { EXIT_REFUSED, runGuard, runRun, type Spawner } from '../src/cli/guard'; +import { GUARD_RECEIPTS_FILE, POLICY_FILE, defaultPolicy, parsePolicy, renderPolicyDocument, type GuardRecord, type LocalPolicy } from '../src/lib/guardrails'; + +/** The rule every executable-identity denial states, word for word. */ +const RULE = 'a pinned tool starts only the executable the policy pins by path and sha256'; + +let canLink = false; +beforeAll(() => { + const d = mkdtempSync(join(tmpdir(), 'ts-exe-link-')); + try { + writeFileSync(join(d, 't'), 'x'); + symlinkSync(join(d, 't'), join(d, 'l')); + canLink = true; + } catch { + /* Windows without the symlink privilege: the symlink test reports as skipped, not passed */ + } + rmSync(d, { recursive: true, force: true }); +}); + +describe('executable identity: a pinned tool starts only the file the policy pins by path+sha256', () => { + const prevCwd = process.cwd(); + const isWin = process.platform === 'win32'; + // Plain names (no extension) so basename stripping yields the same `git` on both platforms for the cross-platform tests. + const EXE = 'git'; + let root = ''; + let home = ''; + let work = ''; + let out: string[] = []; + let err: string[] = []; + let started: string[][] = []; + const io: CliIO = { out: (s) => out.push(s), err: (s) => err.push(s) }; + const spawner: Spawner = (cmd, args) => (started.push([cmd, ...args]), { status: 0, signal: null }); + const env = (extra: Record = {}): NodeJS.ProcessEnv => ({ ...process.env, TRUSTSHELL_HOME: home, ...extra }); + const guard = (...argv: string[]) => { + out = []; + err = []; + return runGuard(argv, io, env()); + }; + const runIt = (argv: string[], extra: Record = {}) => { + out = []; + err = []; + return runRun(argv, io, env(extra), spawner); + }; + const records = (): GuardRecord[] => readFileSync(join(home, GUARD_RECEIPTS_FILE), 'utf8').trim().split('\n').map((l) => JSON.parse(l) as GuardRecord); + const last = () => records().pop() as GuardRecord; + const real = (f: string) => realpathSync.native(join(work, f)); + const sha = (f: string) => createHash('sha256').update(readFileSync(join(work, f))).digest('hex'); + const exePin = (tool: string, f: string) => ({ tool, path: real(f), sha256: sha(f) }); + const policyFile = (p: Record) => { + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, POLICY_FILE), renderPolicyDocument({ policy: { ...defaultPolicy(), ...p } as LocalPolicy, signature: null })); + }; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'ts-exe-')); + home = join(root, 'home'); + work = join(root, 'work'); + mkdirSync(join(work, 'bin'), { recursive: true }); + mkdirSync(join(work, 'evil'), { recursive: true }); + writeFileSync(join(work, 'bin', EXE), '#!/bin/sh\necho real-git\n'); + writeFileSync(join(work, 'evil', EXE), '#!/bin/sh\necho ATTACKER\n'); + process.chdir(work); + started = []; + }); + afterEach(() => { + process.chdir(prevCwd); + rmSync(root, { recursive: true, force: true }); + }); + + it('BYPASS REPRO: a substituted file with the pinned tool’s basename is REFUSED before it starts', () => { + policyFile({ actions: [`tool:${EXE}`], executables: [exePin(EXE, `bin/${EXE}`)] }); + // Same basename `git`, different file — the classic executable-substitution bypass. + expect(runIt(['--tool', EXE, '--', real(`evil/${EXE}`), 'status'])).toBe(EXIT_REFUSED); + expect(started).toEqual([]); + expect(last()).toMatchObject({ kind: 'decision', authorized: { decision: 'DENIED' }, executed: 'not run' }); + expect(last().authorized.reasons.join(' ')).toMatch(/not the one pinned/); + expect(last().authorized.reasons.join(' ')).toContain(RULE); + }); + + it('the exact pinned executable is ALLOWED and the RESOLVED absolute path is what starts', () => { + policyFile({ actions: [`tool:${EXE}`], executables: [exePin(EXE, `bin/${EXE}`)] }); + expect(runIt(['--tool', EXE, '--', real(`bin/${EXE}`), 'status'])).toBe(0); + expect(started).toEqual([[real(`bin/${EXE}`), 'status']]); + // The run record carries the executable by hash, never the resolved path. + expect(JSON.stringify(records().filter((r) => r.kind !== 'policy-change'))).not.toContain(real(`bin/${EXE}`)); + expect(records().slice(-2)[0]?.requested).toMatchObject({ executable: EXE, executable_sha256: sha(`bin/${EXE}`) }); + }); + + it('the pinned path with changed bytes is REFUSED (digest), even though the path still matches', () => { + policyFile({ actions: [`tool:${EXE}`], executables: [exePin(EXE, `bin/${EXE}`)] }); + writeFileSync(join(work, 'bin', EXE), '#!/bin/sh\necho swapped\n'); + expect(runIt(['--tool', EXE, '--', real(`bin/${EXE}`), 'status'])).toBe(EXIT_REFUSED); + expect(started).toEqual([]); + expect(last().authorized.reasons.join(' ')).toMatch(/changed since it was pinned/); + }); + + it('LEGACY name-only: an unpinned tool still runs any file by that basename, but the card labels it the weaker check', () => { + policyFile({ actions: [`tool:${EXE}`] }); // no executables pin + expect(runIt(['--tool', EXE, '--', real(`evil/${EXE}`), 'status'])).toBe(0); + expect(started).toEqual([[real(`evil/${EXE}`), 'status']]); + expect(err.join('\n')).toMatch(/name-only/); + expect(err.join('\n')).toContain(`guard allow tool:${EXE} --exe`); + }); + + (canLink && !isWin ? it : it.skip)('PATH hijack: a planted entry ahead of the pinned path is REFUSED (bare name resolves to it)', () => { + policyFile({ actions: [`tool:${EXE}`], executables: [exePin(EXE, `bin/${EXE}`)] }); + // chmod the fixtures executable so the PATH search finds them. + require('node:fs').chmodSync(join(work, 'evil', EXE), 0o755); + require('node:fs').chmodSync(join(work, 'bin', EXE), 0o755); + const PATH = [join(work, 'evil'), join(work, 'bin')].join(delimiter); + expect(runIt(['--tool', EXE, '--', EXE, 'status'], { PATH })).toBe(EXIT_REFUSED); + expect(started).toEqual([]); + expect(last().authorized.reasons.join(' ')).toContain(RULE); + // Control: with the real dir first, the same bare name resolves to the pinned file and starts. + const good = [join(work, 'bin'), join(work, 'evil')].join(delimiter); + expect(runIt(['--tool', EXE, '--', EXE, 'status'], { PATH: good })).toBe(0); + expect(started).toEqual([[real(`bin/${EXE}`), 'status']]); + }); + + (canLink && !isWin ? it : it.skip)('symlink substitution: a link that resolves to a different file than the pin is REFUSED', () => { + symlinkSync(join(work, 'evil', EXE), join(work, EXE)); // ./git -> evil/git + policyFile({ actions: [`tool:${EXE}`], executables: [exePin(EXE, `bin/${EXE}`)] }); + expect(runIt(['--tool', EXE, '--', `./${EXE}`, 'status'])).toBe(EXIT_REFUSED); + expect(started).toEqual([]); + }); + + it('guard allow tool:git --exe pins the file by sha256; the pinned file runs and a substitute is refused', () => { + expect(guard('init')).toBe(0); + expect(guard('allow', `tool:${EXE}`, '--exe', `bin/${EXE}`)).toBe(0); + const doc = JSON.parse(readFileSync(join(home, POLICY_FILE), 'utf8')) as { policy: LocalPolicy & { executables?: unknown } }; + expect(doc.policy.executables).toEqual([exePin(EXE, `bin/${EXE}`)]); + const change = last(); + expect(change.kind).toBe('policy-change'); + expect(change.change?.via).toContain(`guard allow tool:${EXE} --exe`); + expect(change.change?.via).toContain(sha(`bin/${EXE}`)); + expect(runIt(['--tool', EXE, '--', real(`bin/${EXE}`)])).toBe(0); + expect(runIt(['--tool', EXE, '--', real(`evil/${EXE}`)])).toBe(EXIT_REFUSED); + // Denying the tool drops its executable pin too, so re-allowing revives none. + expect(guard('deny', `tool:${EXE}`)).toBe(0); + const doc2 = JSON.parse(readFileSync(join(home, POLICY_FILE), 'utf8')) as { policy: { executables?: unknown } }; + expect(doc2.policy.executables).toBeUndefined(); + }); + + (isWin ? it : it.skip)('WINDOWS: substitution past the .exe basename strip is refused', () => { + writeFileSync(join(work, 'bin', 'git.exe'), 'MZreal'); + writeFileSync(join(work, 'evil', 'git.exe'), 'MZevil'); + policyFile({ actions: ['tool:git'], executables: [{ tool: 'git', path: real('bin/git.exe'), sha256: sha('bin/git.exe') }] }); + expect(runIt(['--tool', 'git', '--', real('evil/git.exe'), 'status'])).toBe(EXIT_REFUSED); + expect(started).toEqual([]); + expect(runIt(['--tool', 'git', '--', real('bin/git.exe'), 'status'])).toBe(0); + }); +}); + +describe('parsePolicy: executables, a local-only pin list like scripts', () => { + const NOW = 1_800_000_000; + const ABS = join(tmpdir(), 'git'); + const H = 'a'.repeat(64); + const base = { subject: 'a', actions: ['tool:git'], expires_at: NOW, nonce: '1' }; + + it('a well-formed executable pin list parses; a policy without one has no executables key, so it hashes as before', () => { + const r = parsePolicy({ ...base, executables: [{ tool: 'git', path: ABS, sha256: H }] }); + expect(r.ok && (r.policy as unknown as Record).executables).toEqual([{ tool: 'git', path: ABS, sha256: H }]); + const r0 = parsePolicy(base); + expect(r0.ok && 'executables' in r0.policy).toBe(false); + }); + + it.each([ + ['a sha256 that is not 64 hex', [{ tool: 'git', path: ABS, sha256: 'xyz' }]], + ['a relative path', [{ tool: 'git', path: 'git', sha256: H }]], + ['the same tool and path twice', [{ tool: 'git', path: ABS, sha256: H }, { tool: 'git', path: ABS, sha256: H }]], + ['an unknown key', [{ tool: 'git', path: ABS, sha256: H, note: 'x' }]], + ['not a list', { tool: 'git', path: ABS, sha256: H }], + ])('%s is an error', (_n, executables) => { + expect(parsePolicy({ ...base, executables }).ok).toBe(false); + }); +}); From 576167f06e0136919819f783c71f76a9ab95d6fb Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sat, 7 Nov 2026 12:57:55 -0800 Subject: [PATCH 2/3] =?UTF-8?q?test(guard):=20negative=20control=20?= =?UTF-8?q?=E2=80=94=20"exited=200"=20never=20promotes=20to=20success/obse?= =?UTF-8?q?rvation=20(L-AUDIT)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L-AUDIT trust-boundary audit, hop "actual execution → runtime observation". The guard proves only that the policy let a command START and the EXIT CODE it ended with; it never watches what the command did, and no outside party observes it. guardrails.test.ts pins that the run card contains the 'independently observed' label but not the words after it, so regressing guard.ts's honest line to "…verified" still passed. This control bites: a run with the strongest honest signal (exit 0) must promote to nothing beyond {started, exit-code-0} — the full "independently observed nothing" phrase, an outcome record of exactly {exit_code, signal, duration_ms}, and a RUN_SCOPE.mediated that never claims the job was done. Verified RED by regressing guard.ts:187; reverted. No source change. Co-Authored-By: Claude Opus 4.8 --- tests/exec-observation-honesty.test.ts | 75 ++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/exec-observation-honesty.test.ts diff --git a/tests/exec-observation-honesty.test.ts b/tests/exec-observation-honesty.test.ts new file mode 100644 index 00000000..486a6edc --- /dev/null +++ b/tests/exec-observation-honesty.test.ts @@ -0,0 +1,75 @@ +/** + * NEGATIVE CONTROL for the execution→observation trust boundary (L-AUDIT, hop "actual execution → + * runtime observation"). test-only; no source change. + * + * The guard proves exactly two things about a command: that the owner policy let it START, and the + * EXIT CODE it ended with. It never watches what the command did (files, network, child processes) + * and no outside party observes it. The silent-promotion risk at this hop is the most natural + * over-claim there is: reading "exited 0" as "succeeded / was correct / was observed / was verified". + * + * guardrails.test.ts already pins that the run card CONTAINS the 'independently observed' label — but + * NOT the words that follow it, so an edit of guard.ts's honest line to "…verified" would still pass + * there. This is the control that bites: a run carrying the STRONGEST honest signal the guard has + * (exit 0) must promote to NOTHING beyond {started, exit-code-0}. If the honesty regresses — the card + * line, the record shape, or the mediated scope — one of these fails. + */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { CliIO } from '../src/cli'; +import { RUN_SCOPE, runRun, type Spawner } from '../src/cli/guard'; +import { GUARD_RECEIPTS_FILE, POLICY_FILE, defaultPolicy, renderPolicyDocument, type GuardRecord, type LocalPolicy } from '../src/lib/guardrails'; + +const NOW = 1_800_000_000; // 2027-01-15 +// Words that would turn a start-decision or an exit code into an outcome, correctness, or observation +// claim. The guard's one legitimate use of "observed" is to DENY observation ("observed nothing"), +// which is stripped before this net is applied. +const PROMOTION = /\b(succe|verif|correct|validat|confirm|attest|observed it|observed the|did the job|trustworthy)/i; + +describe('execution→observation honesty (L-AUDIT negative control)', () => { + const prevEnv = { ...process.env }; + let home = ''; + let err: string[] = []; + const io: CliIO = { out: () => {}, err: (s) => err.push(s) }; + const ok0: Spawner = () => ({ status: 0, signal: null }); // exit 0 — the strongest honest signal the guard sees + const policyFile = () => writeFileSync(join(home, POLICY_FILE), renderPolicyDocument({ policy: { ...defaultPolicy(NOW), actions: ['tool:git'] } as LocalPolicy, signature: null })); + const records = (): GuardRecord[] => readFileSync(join(home, GUARD_RECEIPTS_FILE), 'utf8').trim().split('\n').map((l) => JSON.parse(l) as GuardRecord); + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'ts-exec-honesty-')); + process.env.TRUSTSHELL_HOME = home; + err = []; + policyFile(); + }); + afterEach(() => { + process.env = { ...prevEnv }; + rmSync(home, { recursive: true, force: true }); + }); + + it('a command that EXITED 0 is promoted to nothing beyond {started, exit 0}: the card states observation as "nothing", never success/correctness', () => { + expect(runRun(['--tool', 'git', '--', 'git', 'status'], io, process.env, ok0)).toBe(0); + const card = err.join('\n'); + // the honest line is pinned IN FULL (guardrails.test.ts pins only the bare 'independently observed' label) + expect(card).toContain('independently observed nothing: no outside party watched this command'); + // exit 0 reads as a bare exit code, never as success / correctness / observation + expect(card).toMatch(/executed\s+started; exited 0 after \d+ ms/); + // with the one honest denial-of-observation removed, no word promotes the start/exit into an outcome claim + const rest = card.replace('independently observed nothing', ''); + expect(rest).not.toMatch(PROMOTION); + }); + + it('the outcome record carries ONLY {exit_code, signal, duration_ms}: no field can smuggle a success/observation claim', () => { + runRun(['--tool', 'git', '--', 'git', 'status'], io, process.env, ok0); + const outcome = records().find((r) => r.kind === 'outcome'); + expect(outcome?.executed).toEqual({ exit_code: 0, signal: null, duration_ms: expect.any(Number) }); + expect(Object.keys(outcome?.executed as object).sort()).toEqual(['duration_ms', 'exit_code', 'signal']); + }); + + it('RUN_SCOPE names only the start-decision and the exit code as mediated — never that the command did its job — and lists the blind spots', () => { + for (const m of RUN_SCOPE.mediated) expect(m).not.toMatch(PROMOTION); + expect(RUN_SCOPE.mediated.join(' ')).toMatch(/decision to start/i); + expect(RUN_SCOPE.mediated.join(' ')).toMatch(/exit code/i); + // what the command actually did is explicitly OUT of scope, not quietly assumed fine + expect(RUN_SCOPE.not_mediated.join(' ')).toMatch(/what the command did after it started/i); + }); +}); From 181ef83c2e4bfd140c1159b763dd03c78e0fc5cf Mon Sep 17 00:00:00 2001 From: Sean Goodwin Date: Sat, 7 Nov 2026 13:32:32 -0800 Subject: [PATCH 3/3] test(e2e): fresh-install walk reproduces the ENFORCED path offline (L-E2E, #679 #7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skeptic path, every step a spawned `trustshell …`, no engine, no network: owner policy → pin tool by path+sha256 → ALLOWED run starts the pinned file → a SUBSTITUTED executable is REFUSED (exit 5, "not the one pinned") → the signed golden verifies offline (VERIFIED), a tampered copy and a foreign root both FAIL. This is the one walk that drives #679's executable-identity pin through the shipped binary; every other E2E runs the guard unpinned or in-process. Two sourcing modes, identical assertions: default runs the built dist (green on any OS); E2E_PACK=1 npm-packs and clean-installs the tarball + the documented ethers peer and runs the INSTALLED binary (no local node_modules). Both verified green on Windows: built-dist 10/10, packed install 11/11. Closes #679 acceptance #7 (demo/E2E under the real enforcement path). Test-only; no source change. Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + tests/e2e/enforced-run-walk.mjs | 126 ++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 tests/e2e/enforced-run-walk.mjs diff --git a/package.json b/package.json index 77a7de2d..6ae4f336 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "test:acceptance": "node tests/e2e/harness-acceptance.mjs", "test:acceptance-baseline": "node tests/e2e/acceptance-baseline.mjs", "e2e:mvp": "node tests/e2e/mvp-pack.mjs", + "test:enforced-run-walk": "node tests/e2e/enforced-run-walk.mjs", "test:sdk-import": "npm run sdk:build && node tests/sdk-import-contract.mjs", "test:verify-import": "npm run sdk:build && node tests/verify-subpath-import.mjs", "test:trustlattice": "npm run sdk:build && node tests/trustlattice-conformance.mjs", diff --git a/tests/e2e/enforced-run-walk.mjs b/tests/e2e/enforced-run-walk.mjs new file mode 100644 index 00000000..ba0fbeae --- /dev/null +++ b/tests/e2e/enforced-run-walk.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * e2e:enforced-run (L-E2E) — a skeptic reproduces the ENFORCED path end to end, OFFLINE, from the + * real CLI. No engine, no network. This is the one walk that drives #679's executable-identity pin + * through the shipped binary — every other E2E runs the guard unpinned (name-only) or in-process. + * + * The chain, each step a spawned `trustshell …`: + * guard init → guard allow tool:node --exe (list + pin by path+sha256) + * run --tool node -- … ALLOWED, starts the pinned file (exit 0) + * run --tool node -- … REFUSED: "not the one pinned" — substitution (exit 5) + * verify-receipt --root Integrity VERIFIED (exit 0) + * verify-receipt --root Integrity FAILED (exit 1) + * verify-receipt --root Integrity FAILED (foreign key) (exit 1) + * + * SOURCING. Default: run the built `dist/cli/index.js` in this tree (builds dist first). This is + * runnable on every OS. E2E_PACK=1: `npm pack` + clean `npm install` the tarball and run the + * INSTALLED binary instead (no local node_modules on the path) — the true fresh-install, run in CI + * where `npm pack` is reliable; it is flaky on the Windows dev box, so that leg lives in CI, same as + * #683's POSIX-only tests. Either way the assertions are identical. + */ +import { execFileSync, spawnSync } from 'node:child_process'; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dirname, '../..'); +const GOLDEN = join(ROOT, 'tests', 'fixtures', 'kernel-receipt-v2.golden.json'); +const PACK = process.env.E2E_PACK === '1'; +const EXE = process.platform === 'win32' ? '.exe' : ''; +const dir = mkdtempSync(join(tmpdir(), 'ts-enforced-')); +const home = join(dir, 'home'); // the guarded home: a policy lives here, so every command is gated +const homeV = join(dir, 'home-verify'); // policy-free home for verify-receipt (else the gate denies it) +const findings = []; +const note = (ok, what, detail = '') => { + findings.push({ ok, what, detail }); + console.log(`${ok ? 'OK ' : 'GAP '} ${what}${detail ? ` — ${detail}` : ''}`); +}; + +/** Spawn the trustshell CLI with a given TRUSTSHELL_HOME; offline. Returns {code, out, err}. */ +function ts(cli, args, hdir = home) { + const r = spawnSync(process.execPath, [cli, ...args], { + cwd: dir, + encoding: 'utf8', + timeout: 120_000, + windowsHide: true, + env: { ...process.env, TRUSTSHELL_HOME: hdir, OFFLINE: '1' }, + }); + return { code: r.status, out: r.stdout ?? '', err: r.stderr ?? '' }; +} + +try { + // ── resolve the CLI the skeptic runs ─────────────────────────────────────────────────────────── + execFileSync('npm', ['run', 'sdk:build'], { cwd: ROOT, stdio: 'inherit', shell: true }); + let cli = join(ROOT, 'dist', 'cli', 'index.js'); + if (PACK) { + execFileSync('npm', ['pack', '--pack-destination', dir], { cwd: ROOT, stdio: 'inherit', shell: true }); + const tgz = readdirSync(dir).find((f) => f.endsWith('.tgz')); + if (!tgz) throw new Error('npm pack produced no tarball'); + spawnSync('npm', ['init', '-y'], { cwd: dir, encoding: 'utf8', shell: true }); + // ethers is an OPTIONAL peer (since 1.6.0); offline receipt verify needs it for keccak. Install it + // the way the CLI tells a user to ("npm i ethers@^6") — a fresh install without it fail-closes + // offline verify to NOT_CHECKED (never a false pass), which is correct, just not what we assert here. + const inst = spawnSync('npm', ['install', join(dir, tgz), 'ethers@^6'], { cwd: dir, encoding: 'utf8', shell: true, timeout: 180_000 }); + note(inst.status === 0, 'fresh install of the packed tarball (+ ethers peer for offline verify)', inst.stderr?.slice(0, 160)); + cli = join(dir, 'node_modules', '@hyperdag', 'trustshell', 'dist', 'cli', 'index.js'); + } + + // The pinned tool is a NON-interpreter executable (a reserved name like `node`/`python` triggers the + // stricter pinned-script rule instead). A copy of this node binary under a neutral name both resolves + // and exits 0; the substitute is any other regular file at another path (refused before it ever runs). + const binDir = join(dir, 'bin'); + mkdirSync(binDir, { recursive: true }); + const runner = join(binDir, `runner${EXE}`); + copyFileSync(process.execPath, runner); + try { chmodSync(runner, 0o755); } catch { /* no-op on Windows */ } + const fakeDir = join(dir, 'evil'); + mkdirSync(fakeDir, { recursive: true }); + const fake = join(fakeDir, `runner${EXE}`); // same basename, different path — a planted substitute + writeFileSync(fake, 'not the pinned binary\n'); + + // ── owner authorization: a policy, then pin the tool's executable by path+sha256 ──────────────── + note(ts(cli, ['guard', 'init']).code === 0, 'guard init writes an owner policy'); + const allow = ts(cli, ['guard', 'allow', 'tool:runner', '--exe', runner]); + note(allow.code === 0, 'guard allow tool:runner --exe pins the executable', allow.err.slice(0, 160)); + const show = ts(cli, ['guard', 'show', '--json']); + let pinned = false; + try { pinned = (JSON.parse(show.out).policy.executables ?? []).some((e) => e.tool === 'runner'); } catch {} + note(pinned, 'guard show lists the runner executable pin'); + + // ── actual execution: the ALLOWED run starts the pinned file ──────────────────────────────────── + const ok = ts(cli, ['run', '--tool', 'runner', '--', runner, '-e', 'process.exit(0)']); + note(ok.code === 0, 'run --tool runner (pinned file) is ALLOWED and exits 0', `code=${ok.code} ${ok.err.slice(0, 120)}`); + note(/pinned by path\+sha256/.test(ok.err), 'the ALLOWED card states the run went through the path+sha256 pin'); + + // ── the enforcement bites: a substituted executable is REFUSED before it starts ──────────────── + const sub = ts(cli, ['run', '--tool', 'runner', '--', fake, '-e', 'process.exit(0)']); + note(sub.code === 5, 'run with a SUBSTITUTED executable is REFUSED (exit 5)', `code=${sub.code}`); + note(/is not the one pinned for runner/.test(sub.out + sub.err), 'the refusal names the pin, not a generic deny'); + + // ── signed receipt → independent offline verification, and its negative controls ─────────────── + // A policy-free home (homeV): once a policy exists, EVERY command is gated, so verify-receipt would be + // denied for not being listed — which is correct behaviour, just not what this leg is testing. + const golden = JSON.parse(readFileSync(GOLDEN, 'utf8')); + const key = golden.public_key; + const verified = ts(cli, ['verify-receipt', GOLDEN, '--root', key], homeV); + note(verified.code === 0 && /Integrity\s+VERIFIED/.test(verified.out), 'verify-receipt: golden under its pinned root → Integrity VERIFIED (exit 0)', `code=${verified.code}`); + + const tampered = JSON.parse(JSON.stringify(golden.receipt_response)); + tampered.receipt.decision.verdict = 'DENY'; // mutates bytes; header.receipt_hash is now stale + const tamperedPath = join(dir, 'tampered.json'); + writeFileSync(tamperedPath, JSON.stringify(tampered)); + const tamperOut = ts(cli, ['verify-receipt', tamperedPath, '--root', key], homeV); + note(tamperOut.code === 1 && /Integrity\s+FAILED/.test(tamperOut.out + tamperOut.err), 'verify-receipt: a tampered receipt → Integrity FAILED (exit 1)', `code=${tamperOut.code}`); + + const wrongRoot = '0x' + 'ab'.repeat(32); + const wrong = ts(cli, ['verify-receipt', GOLDEN, '--root', wrongRoot], homeV); + note(wrong.code === 1 && /Integrity\s+FAILED/.test(wrong.out + wrong.err), 'verify-receipt: golden under a FOREIGN root → Integrity FAILED (exit 1)', `code=${wrong.code}`); +} catch (e) { + note(false, 'enforced-run walk crashed', e instanceof Error ? e.message : String(e)); +} finally { + rmSync(dir, { recursive: true, force: true }); +} + +const failed = findings.filter((f) => !f.ok); +console.log(`\n${failed.length ? `FAIL ${failed.length}/${findings.length}` : `PASS ${findings.length}/${findings.length}`} — enforced-run walk${PACK ? ' (packed install)' : ' (built dist)'}`); +process.exit(failed.length ? 1 : 0);