Skip to content

feat(guard): executable identity — pin tool:X by path+sha256, reject substitution/PATH/symlink (#679) - #683

Merged
DealAppSeo merged 3 commits into
mainfrom
claude/run-executable-identity
Nov 8, 2026
Merged

DealAppSeo merged 3 commits into
mainfrom
claude/run-executable-identity

Conversation

@DealAppSeo

Copy link
Copy Markdown
Owner

What

trustshell run --tool X matched basename(command[0]) against --tool and then spawned command[0]
verbatim. So a file whose basename was X — at any path, reached through a planted PATH entry, or via a
repointed 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; deny unpins; denying the tool drops its pin too.
  • On run, when the tool is pinned, the guard resolves the real file command[0] would start (through PATH
    and 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.
  • A tool with no pin stays name-only (the legacy, weaker check): it still runs any file by that
    basename, and the run card now labels it executable identity name-only (weaker): … pin it with …. A
    pinned tool's card reads pinned by path+sha256: only that file starts.
  • The executable is recorded in the receipt by sha256 only (requested.executable_sha256), never by path —
    the same discipline as script_sha256.

Reuses ScriptPin, resolveScript (realpath + regular-file + hash), and the pinReason/decide pattern;
parseExecutables mirrors parseScripts. A policy with no executables key hashes exactly as before.

Trust-boundary note (residual limitations, stated plainly)

  • TOCTOU. The file is hashed microseconds before it is spawned; a same-path byte swap in that window is
    not caught. This is the same bound --script already carries, and anything that can make that swap runs as
    the owner and could edit the policy. For genuinely untrusted code the stronger boundary is --sandbox (the
    command runs in a container), unchanged here.
  • Name-only is still weak, and still available (migration path): existing policies keep working unchanged;
    owners opt into strong enforcement per tool with --exe. Name-only is never represented as strong — the
    card says so.
  • Performance: only a pinned tool is hashed (a cheap policy peek gates it), so a name-only run never
    reads a large interpreter binary.

Tests

tests/run-executable-identity.test.ts (test-first; the bypass repro went red before the fix):

  • bypass repro — a substituted file with the pinned basename is refused before it starts;
  • the exact pinned file is allowed and the resolved absolute path is what starts;
  • a byte change at the pinned path is refused (digest);
  • name-only (unpinned) still runs and the card labels it the weaker check;
  • PATH hijack and symlink substitution are refused (POSIX-gated: run in Linux CI, skipped on the
    Windows dev box)
    ;
  • guard allow tool:… --exe creates the pin, the pinned file runs, a substitute is refused, and denying the
    tool drops the pin;
  • Windows: substitution past the .exe basename strip is refused (runs on win32);
  • parsePolicy validation for the new executables list.

Full non-e2e jest suite green; guard-interpreter-pins (C5) unchanged.

Not in scope / not done

  • Guided demo under the enforced path: the demo already runs unpinned tools (name-only); wiring a pinned-tool
    demo path is a follow-up.
  • Not merged. MEDIUM change on a dev branch — independent security review + fresh CI requested first.

🤖 Generated with Claude Code

…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>
@vercel

vercel Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
trustshell-landing Ready Ready Preview Nov 7, 2026 9:33pm UTC

Request Review

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

🧾 Receipt

1 claim(s) checked against GitHub's own check runs on 181ef83: 1 verified · 0 not checked · 0 failed

Claim Said in Evidence Result
tests: Full non-e2e jest suite green; guard-interpreter-pins (C5) unchanged. PR description step `Unit tests (no Docker needed)` in mission (runsc), step `Unit tests (no Docker needed)` in mission (runc), step `Unit tests` in check passed and the job's own workflow file at this commit runs a matching command, and every other check on this commit finished green, skipped or neutral. ✅ VERIFIED
What this receipt does not prove
  • A green check proves that check passed on this commit. It does not prove the check tested this change.
  • Evidence is read at the strongest level each check offers: the run: command of the job, taken from the workflow file at this commit; else the step name and that step own result; else the check name alone. The Result column says which. A check name is a label, not evidence.
  • At command level the receipt confirms the job runs a command that normally runs this kind of check. It cannot confirm the command asserted anything: a test script that exits 0 without running a test still reads as tests.
  • A job whose steps cannot be read (a third-party check, or no actions: read permission) is matched by its name alone, and a job whose work happens inside a composite action shows no command.
  • Claims are read from the PR description and the latest commit message by plain patterns. A claim worded unusually is not seen. Text in double quotes, code blocks and block quotes is ignored on purpose; text in single quotes is read, because a single quote is also an apostrophe.
  • A check created after this receipt was written is not counted until the receipt runs again.

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>
@DealAppSeo
DealAppSeo marked this pull request as ready for review November 8, 2026 03:32
@strix-security

strix-security Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Strix Security Review

To 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 summary

The core of this PR holds up: for a pinned tool, run resolves command[0] through PATH and symlinks, hashes the real file, denies a path or digest mismatch, and spawns the resolved absolute path, so the basename-substitution, planted-PATH and symlink-repoint bypasses the PR targets are genuinely closed on the non-sandbox path. The peek-then-decide race fails closed, and a policy without an executables key hashes as before. The issues found are all in the surrounding edges of the new control rather than in its main check: the pin is not enforced on the file a --sandbox run actually starts even though the run card says it is; re-pinning a tool appends a second pin instead of replacing the first, so a revoked executable still starts; executable-pin denials are not classified as autonomy incidents, so substitution attempts go unrecorded; and the new executable identity card line interpolates the caller-controlled executable name without the sanitizer the rest of the file uses, allowing terminal escape injection on denied runs. No high or critical issue was found.

Fixed the findings? re-run the review, or tag @strix-security in a PR comment to run a fresh review.

Review 1 on this PR · 181ef83 · requested Nov 8 03:33 UTC when the PR was opened


Reviewed by Strix
Re-run review · Configure security review settings

@strix-security strix-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strix flagged 4 new security findings below. See the pinned summary comment for the full PR status.

Comment thread src/lib/guardrails.ts
Comment on lines +551 to +552
const expin = execPinReason(policy, req);
if (expin) reasons.push(expin);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.

Comment thread src/cli/guard.ts
Comment on lines +183 to +192
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>`,
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.

Suggested change
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.

Comment thread src/cli/guard.ts
Comment on lines +704 to +708
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 }];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.

Suggested change
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.

Comment thread src/lib/guardrails.ts
Comment on lines +504 to +510
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any matching pin allows, so a leftover pin keeps the old file runnable

Comment thread src/cli/guard.ts
Comment on lines 309 to +316
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.

Suggested change
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.

Comment thread src/cli/guard.ts
Comment on lines +189 to +191
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>`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the card asserts the pin holds for sandbox runs too

Suggested change
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>`,

@DealAppSeo
DealAppSeo merged commit 63db226 into main Nov 8, 2026
15 checks passed

This branch was successfully deployed

1 active deployment
Preview — 181ef83c Deployed Nov 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant