Repository navigation
feat(guard): executable identity — pin tool:X by path+sha256, reject substitution/PATH/symlink (#679) - #683
Conversation
…256 (#679) 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:<name> --exe <path> 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 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🧾 Receipt1 claim(s) checked against GitHub's own check runs on
What this receipt does not prove
Receipts by TrustShell. Read-only: it reads this PR and its check runs, and writes only this comment. No code leaves the repo. |
…observation (L-AUDIT)
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 <noreply@anthropic.com>
…-E2E, #679 #7) 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 <noreply@anthropic.com>
Strix Security ReviewTo fix before merging (4)
PR summary: Adds an opt-in "executable identity" pin to the trustshell guard: `guard allow tool:<name> --exe <path>` binds a tool to one file by resolved path and sha256, and `trustshell run` resolves the command through PATH and symlinks, hashes it, denies a mismatch, and spawns the resolved absolute path. Unpinned tools keep the legacy name-only check, now labelled as weaker on the run card. Review summaryThe core of this PR holds up: for a pinned tool, Fixed the findings? re-run the review, or tag Review 1 on this PR · Reviewed by Strix |
| const expin = execPinReason(policy, req); | ||
| if (expin) reasons.push(expin); |
There was a problem hiding this comment.
🟢 Executable-pin denials are not classified as autonomy incidents, so substitution attempts go unrecorded
Severity: LOW · CWE-778
The PR adds a new denial reason, execPinReason, to decide() so a pinned tool refuses a substituted executable, a planted PATH entry or a repointed symlink. That reason is not added to the autonomy "out of lane" test in decideLocked(), which still lists only pinReason (the interpreter-script pin). A run refused solely by the executable pin therefore writes a would-be autonomy record but no incident, and the agent's autonomy level is not demoted, unlike the equivalent script-pin refusal. An agent that repeatedly attempts executable substitution leaves no incident in the owner's autonomy ledger and keeps its level, so the record under-reports exactly the hostile attempt this feature exists to stop.
Prompt to fix with AI
This is a security vulnerability found during a code review.
Vulnerability: Executable-pin denials are not classified as autonomy incidents, so substitution attempts go unrecorded
Severity: LOW
CWE: CWE-778
The PR adds a new denial reason, `execPinReason`, to `decide()` so a pinned tool refuses a substituted executable, a planted PATH entry or a repointed symlink. That reason is not added to the autonomy "out of lane" test in `decideLocked()`, which still lists only `pinReason` (the interpreter-script pin). A `run` refused solely by the executable pin therefore writes a `would-be` autonomy record but no `incident`, and the agent's autonomy level is not demoted, unlike the equivalent script-pin refusal. An agent that repeatedly attempts executable substitution leaves no incident in the owner's autonomy ledger and keeps its level, so the record under-reports exactly the hostile attempt this feature exists to stop.
Location: src/lib/guardrails.ts:968-970
Context: autonomy out-of-lane classification omits the new executable pin
```
// Before:
const outOfLane =
decision.decision === 'DENIED' &&
(!permitted(policy.actions, req) || pinReason(policy, req) !== null || (ctx.extraReasons?.length ?? 0) > 0 || (auto.mode === 'enforce' && !levelOk));
// After:
const outOfLane =
decision.decision === 'DENIED' &&
(!permitted(policy.actions, req) || pinReason(policy, req) !== null || execPinReason(policy, req) !== null || (ctx.extraReasons?.length ?? 0) > 0 || (auto.mode === 'enforce' && !levelOk));
```
Location: src/lib/guardrails.ts:551-552
Context: the new denial reason that is never classified as an incident
```
const expin = execPinReason(policy, req);
if (expin) reasons.push(expin);
```
How to fix:
Treat an executable-identity refusal as an out-of-lane attempt in the autonomy classification, alongside the interpreter-script pin: add `execPinReason(policy, req) !== null` to the `outOfLane` disjunction in `decideLocked()` and extend the adjacent comment. Add a regression test asserting that a `run` denied only by the executable pin writes an `incident` record and demotes the level, mirroring the existing script-pin case.
Please fix this vulnerability. If you propose a fix, make it concise and minimal.React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.
| 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 <path>`, | ||
| ]; |
There was a problem hiding this comment.
🟢 Terminal escape-sequence injection into the run card's new executable identity line
Severity: LOW · CWE-150
formatRunCard interpolates d.requested.executable into the new executable identity line without passing it through cleanCodePoints, the sanitizer the rest of the file uses for untrusted text. That value is basename(command[0]) with only a Windows-extension strip and toLowerCase() applied, so it carries arbitrary caller bytes; the --tool charset regex constrains only --tool, not the command path. Running trustshell run --tool git -- $'/tmp/\x1b]0;pwned\x07git' produces a DENIED card whose executable identity line contains a raw ESC and BEL, letting the invoking agent rewrite the terminal title, move the cursor over the DENIED verdict, or drive OSC 52 clipboard writes on the owner's terminal. The same raw interpolation already existed on the pre-existing requested line, so the PR adds new sinks for an existing class rather than introducing it.
| 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 <path>`, | |
| ]; | |
| const exe = d.requested.executable; | |
| const exeShown = exe === undefined ? undefined : cleanCodePoints(exe); | |
| const pinnedExe = exe !== undefined && (doc?.policy.executables ?? []).some((p) => p.tool === exe); | |
| const identity = | |
| exeShown === 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 ${exeShown} that resolves first would start; pin it with trustshell guard allow tool:${exeShown} --exe <path>`, | |
| ]; |
Prompt to fix with AI
This is a security vulnerability found during a code review.
Vulnerability: Terminal escape-sequence injection into the run card's new `executable identity` line
Severity: LOW
CWE: CWE-150
`formatRunCard` interpolates `d.requested.executable` into the new `executable identity` line without passing it through `cleanCodePoints`, the sanitizer the rest of the file uses for untrusted text. That value is `basename(command[0])` with only a Windows-extension strip and `toLowerCase()` applied, so it carries arbitrary caller bytes; the `--tool` charset regex constrains only `--tool`, not the command path. Running `trustshell run --tool git -- $'/tmp/\x1b]0;pwned\x07git'` produces a DENIED card whose `executable identity` line contains a raw ESC and BEL, letting the invoking agent rewrite the terminal title, move the cursor over the `DENIED` verdict, or drive OSC 52 clipboard writes on the owner's terminal. The same raw interpolation already existed on the pre-existing `requested` line, so the PR adds new sinks for an existing class rather than introducing it.
Location: src/cli/guard.ts:183-192
Context: New `executable identity` line interpolates the unsanitized basename
```
// Before:
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 <path>`,
];
// After:
const exe = d.requested.executable;
const exeShown = exe === undefined ? undefined : cleanCodePoints(exe);
const pinnedExe = exe !== undefined && (doc?.policy.executables ?? []).some((p) => p.tool === exe);
const identity =
exeShown === 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 ${exeShown} that resolves first would start; pin it with trustshell guard allow tool:${exeShown} --exe <path>`,
];
```
How to fix:
Pass the executable through `cleanCodePoints` before it reaches the card, as `showLines` already does for policy paths. Clean it once into a display variable and use that variable in both the new `executable identity` line and the pre-existing `requested` line, keeping the raw value only for the pin comparison. Consider applying the same treatment to the `authorized` reasons, which embed the same basename in the mismatch message.
Please fix this vulnerability. If you propose a fix, make it concise and minimal.React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.
| 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 }]; |
There was a problem hiding this comment.
🟢 Re-pinning a tool's executable adds a second pin instead of replacing the old one, so a revoked executable still starts
Severity: LOW · CWE-1288
trustshell guard allow tool:<name> --exe <path> is documented as binding a tool to one file ("only that file starts"), but the pin list is filtered by tool AND path, so pinning a second path for the same tool leaves the first pin in place. execPinReason then accepts any pin whose path and sha256 match, so both files start. An owner who re-pins a tool to a new location (moved install, hardened rebuild) believes the old executable is revoked, but it remains an approved target and the run card still reports "pinned by path+sha256: only that file starts". The stale pin only admits the exact bytes previously approved, so the impact is a silently retained allow for a binary the owner meant to revoke, not arbitrary substitution.
| 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 }]; | |
| 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]; | |
| // A pin binds the tool to ONE file: re-pinning replaces the old pin instead of adding a second one. | |
| p.executables = [...exePins.filter((s) => s.tool !== exe), { tool: exe, path: target, sha256: r.sha256 }]; |
Prompt to fix with AI
This is a security vulnerability found during a code review.
Vulnerability: Re-pinning a tool's executable adds a second pin instead of replacing the old one, so a revoked executable still starts
Severity: LOW
CWE: CWE-1288
`trustshell guard allow tool:<name> --exe <path>` is documented as binding a tool to one file ("only that file starts"), but the pin list is filtered by tool AND path, so pinning a second path for the same tool leaves the first pin in place. `execPinReason` then accepts any pin whose path and sha256 match, so both files start. An owner who re-pins a tool to a new location (moved install, hardened rebuild) believes the old executable is revoked, but it remains an approved target and the run card still reports "pinned by path+sha256: only that file starts". The stale pin only admits the exact bytes previously approved, so the impact is a silently retained allow for a binary the owner meant to revoke, not arbitrary substitution.
Location: src/cli/guard.ts:704-708
Context: allow branch keeps pins for the same tool at other paths
```
// Before:
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 }];
// After:
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];
// A pin binds the tool to ONE file: re-pinning replaces the old pin instead of adding a second one.
p.executables = [...exePins.filter((s) => s.tool !== exe), { tool: exe, path: target, sha256: r.sha256 }];
```
Location: src/lib/guardrails.ts:504-510
Context: any matching pin allows, so a leftover pin keeps the old file runnable
```
const pins = (policy.executables ?? []).filter((p) => p.tool === exe);
if (!pins.length) return null;
const how = `trustshell guard allow tool:${exe} --exe <path>`;
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;
```
How to fix:
Make `guard allow tool:<name> --exe <path>` replace every existing pin for that tool rather than only the pin at the same path, so the one-file-per-tool contract the run card and usage text state actually holds. Keep the path-scoped filter for `deny --exe`, which must still refuse to remove a pin that is not at the given path. Add a regression test that pins two different paths for one tool and asserts the first is refused afterwards.
Please fix this vulnerability. If you propose a fix, make it concise and minimal.React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.
| const pins = (policy.executables ?? []).filter((p) => p.tool === exe); | ||
| if (!pins.length) return null; | ||
| const how = `trustshell guard allow tool:${exe} --exe <path>`; | ||
| 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; |
There was a problem hiding this comment.
any matching pin allows, so a leftover pin keeps the old file runnable
| 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)); |
There was a problem hiding this comment.
🟢 Executable pin is not enforced on the file a --sandbox run starts, while the run card claims it is
Severity: LOW
trustshell guard allow tool:<name> --exe <path> pins a tool to one host file by resolved path and sha256, and the run card prints executable identity pinned by path+sha256: only that file starts. That guarantee does not hold for --sandbox runs. The pin is checked against the host file that command[0] resolves to, but the sandbox branch returns before the resolved-path spawn and hands the raw command array to the mission container, which resolves command[0] against the image's own PATH. A pinned ls therefore starts the image's busybox ls, not the pinned /usr/bin/ls, and the card still reports the pin as enforced. The owner is told a specific vetted binary ran when a different one did. The pin's deny side still gates host-side substitution, so this is a false-assurance defect rather than a substitution bypass.
| 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 pol = g.loaded.state === 'ok' ? g.loaded.doc.policy : null; | |
| const pinnedExe = exe.ok && (pol?.executables ?? []).some((p) => p.tool === executable); | |
| // #679: a pin binds the tool to one HOST file. A --sandbox run starts command[0] inside the container, where it | |
| // resolves against the image's PATH, so the file that starts is not the pinned one: refuse rather than start a | |
| // different file under a pin. | |
| if (sandbox && pinnedExe) { | |
| const outcome = appendRecord(dir, { kind: 'outcome', requested: g.record.requested, authorized: g.record.authorized, executed: 'not run', assurance_scope: SANDBOX_RUN_SCOPE }); | |
| const card = formatRunCard(g.record, outcome, g.loaded.state === 'ok' ? g.loaded.doc : null, own); | |
| const why = `${executable} is pinned to ${(exe as { path: string }).path}, and a --sandbox run starts the container's own ${executable}, not that file. Nothing was run.`; | |
| (json ? io.out : io.err)(json ? JSON.stringify({ decision: g.record, outcome, sandbox: { refused: why } }, null, 2) : `${card}\nREFUSED: ${why}`); | |
| return EXIT_REFUSED; | |
| } | |
| if (sandbox) return runInSandbox(dir, g, own, json, io, command, runtime ?? 'runc', sandboxExec); | |
| const t0 = Date.now(); | |
| // #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 r = spawn(pinnedExe ? (exe as { path: string }).path : command[0], command.slice(1)); |
Prompt to fix with AI
This is a security vulnerability found during a code review.
Vulnerability: Executable pin is not enforced on the file a `--sandbox` run starts, while the run card claims it is
Severity: LOW
`trustshell guard allow tool:<name> --exe <path>` pins a tool to one host file by resolved path and sha256, and the run card prints `executable identity pinned by path+sha256: only that file starts`. That guarantee does not hold for `--sandbox` runs. The pin is checked against the host file that `command[0]` resolves to, but the sandbox branch returns before the resolved-path spawn and hands the raw `command` array to the mission container, which resolves `command[0]` against the image's own PATH. A pinned `ls` therefore starts the image's busybox `ls`, not the pinned `/usr/bin/ls`, and the card still reports the pin as enforced. The owner is told a specific vetted binary ran when a different one did. The pin's deny side still gates host-side substitution, so this is a false-assurance defect rather than a substitution bypass.
Location: src/cli/guard.ts:309-316
Context: sandbox branch returns before the resolved-path spawn, so the pinned file is never the one started
```
// Before:
if (sandbox) return runInSandbox(dir, g, own, json, io, command, runtime ?? 'runc', sandboxExec);
const t0 = Date.now();
// #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));
// After:
const pol = g.loaded.state === 'ok' ? g.loaded.doc.policy : null;
const pinnedExe = exe.ok && (pol?.executables ?? []).some((p) => p.tool === executable);
// #679: a pin binds the tool to one HOST file. A --sandbox run starts command[0] inside the container, where it
// resolves against the image's PATH, so the file that starts is not the pinned one: refuse rather than start a
// different file under a pin.
if (sandbox && pinnedExe) {
const outcome = appendRecord(dir, { kind: 'outcome', requested: g.record.requested, authorized: g.record.authorized, executed: 'not run', assurance_scope: SANDBOX_RUN_SCOPE });
const card = formatRunCard(g.record, outcome, g.loaded.state === 'ok' ? g.loaded.doc : null, own);
const why = `${executable} is pinned to ${(exe as { path: string }).path}, and a --sandbox run starts the container's own ${executable}, not that file. Nothing was run.`;
(json ? io.out : io.err)(json ? JSON.stringify({ decision: g.record, outcome, sandbox: { refused: why } }, null, 2) : `${card}\nREFUSED: ${why}`);
return EXIT_REFUSED;
}
if (sandbox) return runInSandbox(dir, g, own, json, io, command, runtime ?? 'runc', sandboxExec);
const t0 = Date.now();
// #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 r = spawn(pinnedExe ? (exe as { path: string }).path : command[0], command.slice(1));
```
Location: src/cli/guard.ts:189-191
Context: the card asserts the pin holds for sandbox runs too
```
// Before:
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 <path>`,
// After:
pinnedExe
? d.requested.sandbox
? ` executable identity pinned by path+sha256 on this host, NOT enforced in the container: the container starts its own ${exe}, not the pinned file`
: ` 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 <path>`,
```
How to fix:
Make the pin and the sandbox agree. Either refuse a pinned tool under `--sandbox` (the container cannot start the pinned host file, so nothing should start under a pin that cannot hold), or verify the container's own binary against the pin before start. In both cases the run card must stop asserting `only that file starts` for a sandbox run, and should state plainly that the pin is a host-side check the container does not carry.
Please fix this vulnerability. If you propose a fix, make it concise and minimal.React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.
| 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 <path>`, |
There was a problem hiding this comment.
the card asserts the pin holds for sandbox runs too
| 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 <path>`, | |
| pinnedExe | |
| ? d.requested.sandbox | |
| ? ` executable identity pinned by path+sha256 on this host, NOT enforced in the container: the container starts its own ${exe}, not the pinned file` | |
| : ` 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 <path>`, |
What
trustshell run --tool Xmatchedbasename(command[0])against--tooland then spawnedcommand[0]verbatim. So a file whose basename was
X— at any path, reached through a planted PATH entry, or via arepointed symlink — passed as
X. The guard honestly disclosed this (RUN_SCOPE.not_mediated,LEVEL_IS_NOT), but a skeptic breaks it in one line:run --tool git -- /attacker/git.This adds executable identity: an opt-in pin binds a tool to one file by resolved path + sha256,
verified independently of the existing
--script(interpreter-script) pin.trustshell guard allow tool:<name> --exe <path>pins;denyunpins; denying the tool drops its pin too.run, when the tool is pinned, the guard resolves the real filecommand[0]would start (through PATHand symlinks), hashes it, and denies anything whose resolved path+sha256 is not the pin — substitution,
a planted PATH entry, a symlink repoint, or a byte change at the pinned path. On ALLOW it spawns the
resolved absolute path, so PATH cannot be re-pointed between the check and the spawn.
basename, and the run card now labels it
executable identity name-only (weaker): … pin it with …. Apinned tool's card reads
pinned by path+sha256: only that file starts.requested.executable_sha256), never by path —the same discipline as
script_sha256.Reuses
ScriptPin,resolveScript(realpath + regular-file + hash), and thepinReason/decidepattern;parseExecutablesmirrorsparseScripts. A policy with noexecutableskey hashes exactly as before.Trust-boundary note (residual limitations, stated plainly)
not caught. This is the same bound
--scriptalready carries, and anything that can make that swap runs asthe owner and could edit the policy. For genuinely untrusted code the stronger boundary is
--sandbox(thecommand runs in a container), unchanged here.
owners opt into strong enforcement per tool with
--exe. Name-only is never represented as strong — thecard says so.
runneverreads a large interpreter binary.
Tests
tests/run-executable-identity.test.ts(test-first; the bypass repro went red before the fix):Windows dev box);
guard allow tool:… --execreates the pin, the pinned file runs, a substitute is refused, and denying thetool drops the pin;
.exebasename strip is refused (runs on win32);parsePolicyvalidation for the newexecutableslist.Full non-e2e jest suite green;
guard-interpreter-pins(C5) unchanged.Not in scope / not done
demo path is a follow-up.
🤖 Generated with Claude Code