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/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/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/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); 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); + }); +}); 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); + }); +});