Commit 39281ec
fix(permissions): say why the sandbox blocks a command in plain English (#2331)
## Problem
The "Run outside sandbox?" dialog printed the classifier's rule
identifiers verbatim. A `python3 - <<'EOF' … EOF` prototype script
produced:
> This command needs access the macOS project sandbox blocks (heredoc
script fed to an interpreter; inline script (interpreter -c/-e/--eval)).
Three things wrong with that:
1. **"interpreter -c/-e/--eval" is a rule name, not an explanation.**
It's `REASON_INTERPRETER_INLINE` in
`packages/shell-guard/src/shell-scope.ts`, matched by the inline-code
regex and reinforced by the `argv[0]` token pass. A user cannot act on
it.
2. **The same fact is stated twice.** The heredoc rule and the
inline-code rule both mean "this runs code the classifier can't read",
and both fire on a command that does both.
3. **The sentence is missing its relative pronoun** ("needs access
*that* the … sandbox blocks"), so it garden-paths on "the macOS project
sandbox blocks" reading as a verb phrase. It also claims to be
macOS-only, which is wrong on Linux, where bubblewrap is the boundary.
`docs/plans/docs-site.md` already 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_TEXT` in `shell-scope.ts` holds one plain sentence per
reason, and `describeShellScopeReasons` resolves a reason list at the
moment a prompt is built. Logs, hooks and decision records are
untouched.
Two properties hold it together:
- **Every rule has copy.** `ScopeReason` is 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 to `string[]` at exactly one point — a *copy*, so the
widened list can never write a plain string back into the typed one —
where the runtime-built `absolute path outside workspace: …` joins it.
That one is matched by prefix, and anything still unrecognised is shown
verbatim rather than dropped.
- **One concern, one line.** Deduping happens on the resolved sentence,
so every rule meaning "code this analysis cannot read before it runs"
collapses to a single line: a `-c` body, a heredoc, and
`eval`/`exec`/`base64` all share one sentence, so `node --eval x` (which
trips two of them) says it once. `~/` and `$HOME` likewise 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
```
BEFORE: This command needs access the macOS project sandbox blocks (inline script
(interpreter -c/-e/--eval); heredoc script fed to an interpreter).
AFTER : The project sandbox would block this command:
• Runs code written or built inside the command itself, so Copse can't tell what it does
```
```
BEFORE: This command needs access the macOS project sandbox blocks (network download
(curl/wget); home directory path (~/)).
AFTER : The project sandbox would block this command:
• Downloads from the internet (curl/wget)
• Reads or writes in your home directory, outside the project
```
Every prompt variant that carries classifier reasons is covered: the two
up-front escape prompts, the `expects_sandbox_block` one, the in-sandbox
"Run shell command?" footer, the Guarded YOLO harm prompt, and the
install / ephemeral-runner prompts.
## Changes
| File | Change |
| --- | --- |
| `packages/shell-guard/src/shell-scope.ts` | `SCOPE_REASON_TEXT`,
`ScopeReason`, `describeShellScopeReasons`; pattern tables, shared
constants and both accumulators typed against the union |
| `src/main/services/security/permission-policy.ts` | Bulleted reason
rendering in the shell prompt formatters; rewritten advice sentences;
the harm prompt resolves the same sentences |
| `src/main/services/security/sandbox-failure.ts`,
`packages/hooks-dialects/src/command-hook-runner.ts`,
`packages/hooks-dialects/src/sandbox-failure-detection.ts` | Drop the
macOS-only claim from the sibling copy |
| `src/renderer/views/approval-dialog.ts`,
`src/renderer/styles/global/approval.css` | Each reason bullet is its
own inline-block, so a wrapped line hangs under its own text instead of
reading as another bullet |
| `src/shared/demo-scenarios.ts`,
`tests/demo/approval-grouped-shell-commands.demo.ts` | Demo copy and its
assertion follow the new wording |
| `docs/shell-permissions.md` | New "What an approval prompt says"
section pinning the contract |
## Validation
CI is green on `35dc90b`: `precheck`, `check` (full unit suite),
`build`, all eight `e2e` shards, `screenshot-artifacts` and `CI Passed`.
New coverage:
- `src/main/services/security/shell-prompt-copy.test.ts` — 8 cases over
the three formatters, driven from real `analyzeShellCommand` output.
- `describeShellScopeReasons` cases in `shell-scope.test.ts`, including
a regression test that `node --eval` reports the unreadable-code concern
once.
- Two rendering cases in `approval-dialog-batch.test.ts`: multi-line
advice, and the bullet-span structure that carries the hanging indent.
- The Guarded YOLO cap test uses distinct operands and enough of them to
overrun the total budget, pinned at exactly 1200 characters.
### Visual evidence
The demo scenarios were rendered from `dist/demo` over CDP while
developing, which is where the wrapped-bullet defect showed up and was
fixed. The `e2e` tier has since exercised the same scenarios in a real
browser session, so the updated
`approval-grouped-shell-commands.demo.ts` assertion 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.png` and
`approval-light-accent.png` both change, but the candidate filter holds
them as out-of-scope: `computeScreenshotGate` (`test-oracle.mts:694`)
counts only `src/**` and `tests/e2e/**` as render-affecting and
`affectedScreenshots` (`: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-demo`
artifact on run 33859836149 and commit them (which also makes them
`branchOwned` for later runs), or add the `update-screenshots` label and
re-run. The scope gap itself is worth a separate issue.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Q9xawQf1Nu7VGA5Ktew66E
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent e4b19c1 commit 39281ec
14 files changed
Lines changed: 563 additions & 47 deletions
File tree
- docs
- packages
- hooks-dialects/src
- shell-guard/src
- src
- main/services/security
- renderer
- styles/global
- views
- tests/demo
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
64 | 64 | | |
65 | 65 | | |
66 | 66 | | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
67 | 100 | | |
68 | 101 | | |
69 | 102 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
210 | 210 | | |
211 | 211 | | |
212 | 212 | | |
213 | | - | |
| 213 | + | |
214 | 214 | | |
215 | 215 | | |
216 | 216 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
| 2 | + | |
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| 6 | + | |
6 | 7 | | |
7 | 8 | | |
8 | 9 | | |
| |||
711 | 712 | | |
712 | 713 | | |
713 | 714 | | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
0 commit comments