fix(permissions): say why the sandbox blocks a command in plain English - #2331
Conversation
The "Run outside sandbox?" dialog printed the classifier's rule identifiers
verbatim, so a `python3 - <<'EOF' … ` script asked the user to approve
"heredoc script fed to an interpreter; inline script (interpreter -c/-e/--eval)"
— two internal rule names for one fact, inside a sentence missing its relative
pronoun ("needs access the macOS project sandbox blocks").
Reasons have to stay identifiers: the regex and token passes dedupe on them
verbatim and every answered prompt writes them into the decision spine. So add a
copy layer instead. `SCOPE_REASON_TEXT` in shell-scope.ts holds one plain
sentence per reason and `describeShellScopeReasons` resolves a reason list at
prompt-build time; logs and decision records are unchanged.
- `ScopeReason` is derived from the table's keys and annotates the pattern
tables, so a new classifier rule with no copy fails to typecheck.
- Deduping happens on the resolved sentence, so rules describing one fact
collapse: a heredoc and a `-c` body both become "Runs a script written inside
the command itself, so Copse can't tell what it does"; `~/` and `$HOME` both
become one home-directory line.
- Reasons built at runtime (`absolute path outside workspace: …`) are matched by
prefix; anything unrecognised is still shown verbatim rather than dropped.
- Prompts render one reason per line instead of a semicolon-joined parenthetical.
- The escape prompts no longer claim to be macOS-only — they appear whenever a
project sandbox is active, which is seatbelt on macOS and bubblewrap on Linux.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9xawQf1Nu7VGA5Ktew66E
…tall prompts Rendering every shell approval variant turned up two surfaces the first pass missed. The Guarded YOLO harm prompt still printed `REASON_RECURSIVE_DELETE` and `REASON_FIND_DELETE` raw. Those constants are shared between the harm gate and the scope classifier precisely so the two agree on the wording, so an ordinary prompt saying "Deletes files and folders recursively (rm -rf)" beside a harm prompt saying "recursive/forced delete (rm -rf)" is the divergence they exist to prevent. It resolves them through the same copy layer now; harm-only reasons have no entry and pass through untouched, ahead of the existing truncation. The install and ephemeral-runner prompts still said "outside the macOS sandbox", which is wrong on Linux, where bubblewrap is the boundary — the same one-word fix already applied to the sibling prompts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9xawQf1Nu7VGA5Ktew66E
🖥️ PR preview
|
|
Every step of the job passed — This diff changes no dependency, manifest or lockfile — it is TypeScript copy, tests and docs — so it cannot affect an audit of the dependency tree. The same step is green on the base branch ( There is no fix to port: a registry socket timeout has no code remedy. Re-running the failed job once, which is the one re-run I'll spend here. If this recurs, the durable fix belongs in CI rather than in this PR — Generated by Claude Code |
jonathanKingston
left a comment
There was a problem hiding this comment.
Review: plain-English sandbox reasons
Verdict: the copy layer is sound and well tested, but the branch does not merge into current main, and the string the PR edits in the hook runner has moved. Mechanical, but blocking.
CI
The latest precheck failure is not this PR's: pnpm audit --audit-level=high timed out against registry.npmjs.org (ERR_SOCKET_TIMEOUT after three retries) before typecheck or lint ran. A re-run should clear it; everything below passed locally.
| Check | Result |
|---|---|
pnpm run typecheck |
pass |
oxfmt --check on the 11 changed files |
pass |
eslint on the changed .ts files |
pass |
pnpm test -- shell-scope shell-prompt-copy approval-dialog-batch sandbox-failure |
124/124 pass |
permission-gate.test.ts |
cannot load node-pty on this Linux box (env issue, not the PR); its new assertions bundled and run ad hoc against the sources pass |
pnpm run check:dead-code |
pass |
pnpm run oracle |
broad (demo-scenarios.ts and a demo spec changed) |
Dry merge with origin/main (1 behind) |
CONFLICT in src/main/services/hooks/command-hook-runner.ts and src/main/services/security/sandbox-failure.ts |
Blocking
Rebase and re-target the hook-runner edit. #2319 moved the hook runner into packages/hooks-dialects. On main the string this PR changes now lives at packages/hooks-dialects/src/command-hook-runner.ts:213 and still reads "blocked by the macOS project sandbox"; sandbox-failure.ts is now a re-export of @copse/hooks-dialects/sandbox-failure-detection.ts, whose header comment also says macOS. Resolving the conflict in favour of main silently drops the change, so a Linux (bubblewrap) user whose hook is blocked still sees the wrong platform in the Sources error, contradicting the new paragraph at docs/shell-permissions.md:88-89.
Non-blocking
- The "fails to typecheck" guarantee is narrower than the docs say.
packages/shell-guard/src/shell-scope.ts:463declaresaddReason = (reason: string), andREASON_INTERPRETER_FILE,REASON_INTERPRETER_INLINE,REASON_BUILD_DRIVER(lines 305 to 309) are only emitted through it, as are the pushes at 524, 710 and 725. I checked all 60 emitted literals and every one has aSCOPE_REASON_TEXTkey today, but a future edit to one of those constants compiles and falls through to "shown verbatim", whiledocs/shell-permissions.md:82-83promises a typecheck failure. TypingaddReasonand those three constants asScopeReasonmakes the doc true as written. --evalstill yields two near-duplicate lines. The comment atshell-scope.ts:812-815says the interpreter and dynamic-execution matchers dedupe for--eval, but the two identifiers map to different sentences.node --eval "x"renders "Runs a script written inside the command itself, so Copse can't tell what it does" and "Builds and runs code as it goes (eval/exec/base64), so Copse can't tell what it does". Either share one sentence or fix the comment.- The Guarded YOLO cap test no longer exercises the cap.
src/main/services/security/permission-gate.test.ts:1533-1539passes three identical operands;describeShellScopeReasonsnow dedupes them to one, so the advice is 267 chars and the 1200-char branch atpermission-policy.ts:434-437is untested. Use three distinct operands. docs/shell-permissions.md:76-77says one bullet per line, butformatGuardedYoloHarmPromptAdvice(permission-policy.ts:432-433) still joins with;. Worth a qualifier.- Both
tests/e2e/screenshots/approval-grouped-shell-commands.pngandapproval-light-accent.pngwill change (the light-accent scenario'sbodyAdvicechanged atsrc/shared/demo-scenarios.ts:600-601and that demo captures the same element). The updated grouped-spec assertion satisfies the focused-spec rule; please make sure the screenshot child PR lands with this one and eyeball the wrapped second bullet. ScopeReasonis exported fromshell-scope.ts:920with no importer. Harmless.
Checked and fine
Spine and log paths still receive identifiers (permission-gate.ts:917 passes decision.reasons untouched; describeShellScopeReasons is only called inside the formatters). The only runtime-built reason ("absolute path outside workspace: ...", lines 644 and 647) is covered by the prefix table at 926 to 935. No other test, fixture or demo matched on the old prompt text. .approval-advice and .approval-footer are white-space: pre-wrap, so the one-per-line rendering works. Only as const satisfies is used, no object-literal casts, no suppressions.
Generated by Claude Code
#2319 moved the hook runner and the sandbox-failure detector into `@copse/hooks-dialects`, which conflicted with this branch's platform-naming fix in both files. Resolved in favour of main and the fix re-applied where the code now lives, so a Linux (bubblewrap) user whose hook is blocked no longer sees "macOS" in the Sources error: - `packages/hooks-dialects/src/command-hook-runner.ts` — the `runtimeError` string, previously edited at `src/main/services/hooks/command-hook-runner.ts`. - `packages/hooks-dialects/src/sandbox-failure-detection.ts` — the header comment, previously edited at `src/main/services/security/sandbox-failure.ts`, now a re-export. Taking main's side alone would have silently dropped the change and contradicted the new paragraph in docs/shell-permissions.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9xawQf1Nu7VGA5Ktew66E
Four findings from review, plus one the review's last item turned up. - **The typecheck guarantee now matches what the docs claim.** `addReason` in the token pass took a plain `string`, and the shared reason constants were inferred, so editing one to a string with no `SCOPE_REASON_TEXT` entry compiled and fell through to "shown verbatim". Both classifier accumulators, the adder, the shared constants and the verdict-note literals are now typed `ScopeReason`. The list widens to `string[]` at exactly one point, where the runtime-built outside-path reason joins it. - **The dedupe comment claimed more than the code does.** `--eval` trips the interpreter rule and the generic dynamic-execution one, and those map to different sentences, so it reports two lines. The comment now says that, and the dynamic-execution sentence drops the trailing clause it shared with the interpreter one so the pair reads as two facts rather than one said twice. - **The Guarded YOLO cap test stopped exercising the cap.** Its three identical operands now dedupe to one line, leaving both the per-reason and the total budget untested. It uses distinct operands and enough of them to overrun the 1200-char total (advice lands at 267 and 1200 chars respectively). - **docs/shell-permissions.md** notes that the harm prompt keeps its one-paragraph shape, and describes the typing guarantee as it now is. Rendering the wrap case the review asked me to eyeball showed the second line of a long reason returning to the left margin, flush with the bullets, so it read as another bullet. Each bullet is now its own inline-block with a hanging indent, so a wrapped line sits under its own text. The newlines stay as text nodes between the spans, so the advice element's `textContent` is still exactly the string the main process sent, and every existing assertion on it holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9xawQf1Nu7VGA5Ktew66E
|
Thanks — all of it addressed, in Blocking: rebase and re-targetMerged
You were right that resolving in favour of Non-blockingTypecheck guarantee. Fixed rather than documented down. const { reasons: scopeReasons, hasHard } = collectExternalReasons(trimmed)
// Widened here and only here: `referencesOutsideWorkspace` can return the one
// runtime-built reason, which by construction is not a `ScopeReason` key.
const reasons: string[] = scopeReasons
Cap test. Distinct operands, and enough of them to overrun the total budget — the single-reason case lands at 267 chars (per-reason cap) and the twelve-reason case at exactly 1200 (total cap), so both branches are now covered. Docs. Qualified the bullet-per-line claim for the harm prompt, and rewrote the typing paragraph to match what the code now guarantees. One thing your last item turned upEyeballing the wrapped bullet found a real defect, so I fixed it rather than just confirming it. With Each bullet is now its own This is scope I added on my own, so push back if you'd rather it were separate. Not doneI can't regenerate the two reference PNGs here — they're pixel-rendered on the CI runner, and this container can't run WDIO at all (the network policy blocks its chromedriver download, and the local chromedriver majors don't match its Chromium). Both will need the usual candidate-artifact pass, and the hanging indent changes them a little further than your review anticipated.
Local: Generated by Claude Code |
Reference screenshots ready for reviewReview GitHub’s image diffs in screenshot PR #2341. Rendered for If this source branch moves, a later successful render closes the stale review PR and replaces this link. |
|
The filter held them, from the I checked the committed Why the scope rule holds them
That looks like a gap worth its own issue rather than something to fix here. Options
I can't do either from my environment — I have no way to download the artifact, and hand-rendering PNGs here would be wrong fidelity and would then win as "contested" against CI's own render. Everything else on the PR is green and mergeable. Generated by Claude Code |
`node --eval x` trips the interpreter rule and the generic eval/exec/base64
rule, and the two mapped to different sentences, so the prompt said the same
thing twice ("Runs a script written inside the command itself" and "Builds
and runs code as it goes"). Both, like the heredoc rule, mean code this
analysis cannot read before it runs, so all three now share one sentence and
the resolved-text dedupe collapses them; the identifiers on the decision
spine are unchanged. The table comment claimed the collapse already happened,
and now describes what the code does. A regression test covers `--eval`.
`analyzeShellCommand` widened the typed reason list by aliasing it as
`string[]` before pushing the runtime-built outside-path reason into it,
which wrote a plain string back into the `ScopeReason[]`; it copies instead.
docs/shell-permissions.md quotes the shared sentence and notes that the
Guarded YOLO harm prompt dedupes the same way but keeps its one-paragraph
join rather than one bullet per line.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jaQbed96h8aSZLijfiHNB
The total-length case asserted `<= 1300`, looser than the 1200-char `MAX_GUARDED_YOLO_HARM_ADVICE_CHARS` it exists to exercise. Twelve distinct over-long operands overrun the budget and the advice lands on exactly 1200 characters, footer included, so the test now says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011jaQbed96h8aSZLijfiHNB
|
Your 7b204d4 merge and af961db already covered the rebase, the hook-runner string in Generated by Claude Code |
Problem
The "Run outside sandbox?" dialog printed the classifier's rule identifiers verbatim. A
python3 - <<'EOF' … EOFprototype script produced:Three things wrong with that:
REASON_INTERPRETER_INLINEinpackages/shell-guard/src/shell-scope.ts, matched by the inline-code regex and reinforced by theargv[0]token pass. A user cannot act on it.docs/plans/docs-site.mdalready flags this area: "A user cannot read what the dialog is asking them."Approach
Reason strings have to stay identifiers — the regex and token passes dedupe on them verbatim, and every answered prompt writes them into the decision spine. So this adds a copy layer rather than renaming them.
SCOPE_REASON_TEXTinshell-scope.tsholds one plain sentence per reason, anddescribeShellScopeReasonsresolves a reason list at the moment a prompt is built. Logs, hooks and decision records are untouched.Two properties hold it together:
ScopeReasonis derived from the table's keys and annotates the pattern tables, the shared reason constants, and the accumulators both classifier passes push through, so a new classifier rule whose reason has no sentence fails to typecheck. The list widens tostring[]at exactly one point — a copy, so the widened list can never write a plain string back into the typed one — where the runtime-builtabsolute path outside workspace: …joins it. That one is matched by prefix, and anything still unrecognised is shown verbatim rather than dropped.-cbody, a heredoc, andeval/exec/base64all share one sentence, sonode --eval x(which trips two of them) says it once.~/and$HOMElikewise share the home-directory line.Prompts render one reason per line instead of a semicolon-joined parenthetical, and the sandbox-escape prompts name no platform (they only appear while a project sandbox is active — seatbelt on macOS, bubblewrap on Linux).
Result
Every prompt variant that carries classifier reasons is covered: the two up-front escape prompts, the
expects_sandbox_blockone, the in-sandbox "Run shell command?" footer, the Guarded YOLO harm prompt, and the install / ephemeral-runner prompts.Changes
packages/shell-guard/src/shell-scope.tsSCOPE_REASON_TEXT,ScopeReason,describeShellScopeReasons; pattern tables, shared constants and both accumulators typed against the unionsrc/main/services/security/permission-policy.tssrc/main/services/security/sandbox-failure.ts,packages/hooks-dialects/src/command-hook-runner.ts,packages/hooks-dialects/src/sandbox-failure-detection.tssrc/renderer/views/approval-dialog.ts,src/renderer/styles/global/approval.csssrc/shared/demo-scenarios.ts,tests/demo/approval-grouped-shell-commands.demo.tsdocs/shell-permissions.mdValidation
CI is green on
35dc90b:precheck,check(full unit suite),build, all eighte2eshards,screenshot-artifactsandCI Passed.New coverage:
src/main/services/security/shell-prompt-copy.test.ts— 8 cases over the three formatters, driven from realanalyzeShellCommandoutput.describeShellScopeReasonscases inshell-scope.test.ts, including a regression test thatnode --evalreports the unreadable-code concern once.approval-dialog-batch.test.ts: multi-line advice, and the bullet-span structure that carries the hanging indent.Visual evidence
The demo scenarios were rendered from
dist/demoover CDP while developing, which is where the wrapped-bullet defect showed up and was fixed. Thee2etier has since exercised the same scenarios in a real browser session, so the updatedapproval-grouped-shell-commands.demo.tsassertion is confirmed against the shipped renderer.Needs a human
Reference screenshots — and note the screenshot review PR is incomplete.
tests/e2e/screenshots/approval-grouped-shell-commands.pngandapproval-light-accent.pngboth change, but the candidate filter holds them as out-of-scope:computeScreenshotGate(test-oracle.mts:694) counts onlysrc/**andtests/e2e/**as render-affecting andaffectedScreenshots(:663) maps from the e2e selection alone, so a demo-tier shot can never be oracle-owned — not even when the diff edits the demo spec that renders it. Screenshot PR #2341 therefore carries only unrelated re-renders.To land the two that matter: recover them from the
screenshots-demoartifact on run 33859836149 and commit them (which also makes thembranchOwnedfor later runs), or add theupdate-screenshotslabel and re-run. The scope gap itself is worth a separate issue.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q9xawQf1Nu7VGA5Ktew66E