@sema-agent/core 7.46.0 → 7.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/dist/core/allow-entry.d.ts +1 -1
  3. package/dist/core/allow-entry.js +4 -5
  4. package/dist/core/ask-origin.d.ts +35 -12
  5. package/dist/core/ask-origin.js +23 -6
  6. package/dist/core/checkpoint-store.d.ts +1 -1
  7. package/dist/core/engine-notice.d.ts +7 -4
  8. package/dist/core/gate-fold.js +2 -2
  9. package/dist/core/gate-lanes.js +2 -1
  10. package/dist/core/gate-outcome.d.ts +4 -2
  11. package/dist/core/gate-outcome.js +2 -2
  12. package/dist/core/mcp-server-spec.d.ts +1 -1
  13. package/dist/core/permission-rule-model.d.ts +12 -6
  14. package/dist/core/permission-rule-model.js +10 -2
  15. package/dist/core/permission-rules.d.ts +4 -5
  16. package/dist/core/permission-rules.js +28 -15
  17. package/dist/core/runner/gate-exit.d.ts +4 -1
  18. package/dist/core/runner/gate-exit.js +5 -1
  19. package/dist/core/runner/park-commit.d.ts +1 -1
  20. package/dist/core/runner/prepare-hands-readface.js +5 -5
  21. package/dist/core/runner/prepare-policy-chain.js +2 -1
  22. package/dist/core/safety-axis-vocab.d.ts +1 -1
  23. package/dist/core/safety-merge-corpus.d.ts +1 -1
  24. package/dist/core/session-policy-store.d.ts +6 -3
  25. package/dist/core/session-policy-store.js +28 -10
  26. package/dist/core/shell-wrapper-projection.js +1 -1
  27. package/dist/core/shell-wrapper-table.d.ts +8 -8
  28. package/dist/core/shell-wrapper-table.js +3 -3
  29. package/dist/core/tool-catalog-entries.js +3 -2
  30. package/dist/core/tool-face.d.ts +10 -1
  31. package/dist/core/tool-name-aliases.d.ts +9 -8
  32. package/dist/core/tool-name-aliases.js +2 -0
  33. package/dist/core/tool-name-readings.d.ts +8 -0
  34. package/dist/core/tool-name-readings.js +2 -0
  35. package/dist/core/tool-policy.d.ts +10 -9
  36. package/dist/core/tool-policy.js +6 -7
  37. package/dist/core/tool-registry.d.ts +9 -0
  38. package/dist/core/tool-registry.js +10 -0
  39. package/dist/core/write-protect.d.ts +10 -3
  40. package/dist/core/write-protect.js +11 -1
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/orchestration/workflow.js +2 -2
  44. package/dist/prompt-assembly/types.d.ts +1 -1
  45. package/dist/shell/lex.d.ts +15 -0
  46. package/dist/shell/lex.js +86 -15
  47. package/dist/tools/fs/bash-program-position.d.ts +16 -6
  48. package/dist/tools/fs/bash-program-position.js +18 -12
  49. package/dist/tools/fs/index.d.ts +3 -2
  50. package/dist/tools/fs/index.js +2 -2
  51. package/dist/tools/fs/program-read-model.d.ts +84 -12
  52. package/dist/tools/fs/program-read-model.js +105 -34
  53. package/dist/tools/fs/program-read-model.rows.js +26 -7
  54. package/dist/tools/fs/read-deny-home.d.ts +20 -1
  55. package/dist/tools/fs/read-deny-home.js +28 -7
  56. package/dist/tools/fs/read-deny.d.ts +13 -6
  57. package/dist/tools/fs/read-deny.js +12 -3
  58. package/dist/tools/fs/removal-targets.js +24 -5
  59. package/dist/tools/fs/shell-read-argv.d.ts +6 -8
  60. package/dist/tools/fs/shell-read-argv.js +5 -5
  61. package/dist/tools/fs/shell-read-bindings.d.ts +71 -19
  62. package/dist/tools/fs/shell-read-bindings.js +190 -75
  63. package/dist/tools/fs/shell-read-establish.js +6 -7
  64. package/dist/tools/fs/shell-read-plan.d.ts +3 -15
  65. package/dist/tools/fs/shell-read-plan.js +126 -129
  66. package/dist/tools/fs/shell-read-start.d.ts +57 -0
  67. package/dist/tools/fs/shell-read-start.js +92 -0
  68. package/dist/tools/fs/shell-read-word.d.ts +68 -8
  69. package/dist/tools/fs/shell-read-word.js +106 -12
  70. package/dist/tools/fs/shell-read-write-targets.js +4 -3
  71. package/dist/tools/fs/shell-reentry-text.d.ts +103 -3
  72. package/dist/tools/fs/shell-reentry-text.js +122 -4
  73. package/dist/tools/monitor.d.ts +3 -3
  74. package/package.json +1 -1
  75. package/test/export-surface.snapshot.json +9 -3
  76. package/wire-changes/core@7.47.0.json +412 -0
  77. package/wire-changes/core@7.48.0.json +252 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,109 @@
1
+ ## 7.48.0 — 2026-10-10
2
+
3
+ **Docs and tooling (zero behavior).** `docs/ARCHITECTURE-LAYERS.md` — the layering constitution (five layers + the L−1 primitives layer, rules R1–R8, six placement tests, gates G1–G3; adopted as drafted, C-R318, #1469; mirrored in the shared audits), with the reference-adapter positioning note (`src/examples/adapters/pg-adapter.ts`) and the `docs/ARCHITECTURE.md` storage line rewritten under it (one contract, several implementations is the legal shape; a second contract of the same name is the defect). Comments and docs say `web-admin` where they said the retired names (C-R325, #1499; historical records and the fixture env names that mirror server's real keys are unchanged). `scripts/verify-debts.mjs --rc-gate` resolves the shared ledger through the main checkout via the one git seam and refuses with exit 1 when no ledger is found (#1477; a release-candidate tree under /private/tmp used to pass vacuously).
4
+
5
+ **Shell read family (#1461 #1459 #1460 #1446 #1452 #1463 #1424 #1466) —** hard-breaking: none; closed sets: 0; exports: 0 added / 0 removed; `SHELL_WRAPPER_TABLE` (exported constant): the `watch` row gains its option sets (the projection of its two implementations' grammar rows; `-d`, which they read differently, is left out). Behavior: narrowing, with four loosenings named below (each open to veto; #1461 names two) and one plan-level reading (#1452).
6
+
7
+ - **#1461 one value table (narrowing; open to veto).** A segment is read once per combination of one value for each name its words may hold several values of; in each reading a settled word is its value for every reader — the program position (`P=rm; "$P" -rf <root>`: `dangerous_delete` on every arm; was `ordinary`, run under a `Bash` allow and under bypassPermissions), a launcher's fields, a re-entered text, an operand, a pipe's printed text. An unquoted value of words splits into its fields (`X="<root> zz"; sudo -s rm -rf $X`: `dangerous_delete`; was `probe`). The recursive-forced-removal text check reads a value's safe-root reference as the reference it is (`X='$PWD'; sudo -s rm -rf "$X"` ≡ `sudo -s rm -rf '$PWD'`: `unverifiable_delete`). **Loosening:** an unquoted value is no longer an unresolved read unless it holds a backslash, a `~` or an extglob parenthesis (a quote, `$`, brace or `!` in it is a character: bash re-reads none) — a multi-word one is split into its fields, and each field is read as a word of its own under the same identity rule as a written word (named by its text, its `NAME=value` shape kept), never as syntax (`X="for <root>/apple"; $X` runs a program named `for`; no field is a loop, block, control word, `coproc` or assignment), by every reader, the removal guard included: `cat $X` with `X='a b'` reads `a` and `b`; removing the task root still asks (`X="-- <root>"; rm -rf $X`, `X="-v <root>"; rm -rf $X`, `X="-rf <root>"; rm $X`, `command rm $X`: `dangerous_delete`, as `rm -rf -- <root>`; were `unverifiable_delete` / `probe`), and `X='if=a of=b'; dd $X` reads `a`; `X=ls; su root -c "$X"` is no longer a runtime-script `dangerous_delete` (the script is `ls`). The lexer's command position is the one reading of "is this word syntax", and it now covers `coproc`: the word after `coproc`, and after `coproc NAME`, stands at command position (bash's grammar), so `coproc [NAME] [[ 0 -eq 'a[$(cat <key>)]' ]]` / `(( … ))` / `{ …; }` plan the nested command the arithmetic or group runs, as on 7.47.0 (without it, `coproc [[ 0 -eq 'a[$(rm -rf <root>)]' ]]` would run without a removal card under a `Bash` allow rule and under bypassPermissions, and the protected-file read inside the same form would still be denied — through the operand reading alone).
8
+ - **#1459 every launcher field asks the value table (narrowing).** `su -c` / `runuser -c` / `watch` / `bash -c` hand every established value (were: one) — a link to a denied file named by any value is refused.
9
+ - **#1460 the fold counts distinct names; no sample (narrowing).** Names are read in the order the words read them while the readings stay within 64; a name past that is unknown, every value it held a deny floor — carried into the shell a launcher starts. The 7.47.0 diagonal sample is gone: `sudo -s "$P" -rf "$X"` nine × nine with `rm` and the root off the diagonal is `dangerous_delete` (was `probe`, run under bypassPermissions). A loop nested in a body (`do for x in …`) now binds its name (was unknown: the test-[9595] double `for` loop asked `probe`). **Loosening:** such a loop's reads are now established (a read-only nested loop over in-root files no longer asks the read-face `probe`: it runs under a `Bash` allow or bypassPermissions without a card; the default arm still asks, `ordinary`).
10
+ - **#1446 one launcher reading: getopt clusters (narrowing).** A launcher option cluster is read as getopt reads it (`sudo -iu root rm -rf ~`, `sudo -su root …`, `env -iu FOO rm -rf ~`: `dangerous_delete`; were a decline run under bypassPermissions) — its command-line letter too: the rest of the word, or the next word, is the command line it runs (`env -vS 'rm -rf ~'`, `env -iS'rm -rf ~'`: `dangerous_delete`; were run under a `Bash` allow and bypassPermissions on 7.47.0 as well).
11
+ - **#1452 `watch` declares its options, procps and BusyBox (narrowing).** procps: `-n` / `--interval`, `-q` / `--equexit`, `-s` / `--shotsdir` (a directory it writes into) take a value, `-d` / `--differences` one only attached (`-dn` is `-d` holding `n`), `-x` / `--exec` run the program; BusyBox: `-d` / `-t` switches, `-n` a value. `--` ends the options; each implementation's words from its first operand on are a shell text, read (an implementation that rejects an option reads none): `watch -n 1 'rm -rf ~'` (was `probe`, run under bypassPermissions), `watch -dn 1 'rm -rf ~'`, `watch -dn rm -rf ~`: `dangerous_delete`. A launcher's option is told apart by its spelling — a short switch in front (`watch -d 'rm -rf ~'`, `-c`, `-b`, `-t -d`, `-d=permanent`) no longer reads as `-x`, nor `su -f` as `-l`. **Plan only:** `watch -n 5 ls -l` names `ls -l` as its text (was a program named `5`); the lane still asks `probe` for it, as on 7.47.0.
12
+ - **#1463 a shell run as another user starts with that user's home (narrowing; message change).** `sudo -i`, `su` / `runuser` (not `-m` / `-p`), `doas -s`: `HOME` is the target's (root: `/root`, macOS `/var/root`; another user: unknown); `sudo -s`: the caller's or the target's (sudoers policy); a login shell (`sudo -i`, `su -` / `-l`) stands in that home (`sudo -i rm -rf ../etc`: `dangerous_delete`; was `probe`, run under bypassPermissions). `sudo -i rm -rf ~`, `su root -c 'rm -rf ~'` now card the top-level `/root` (were: the caller's home). KL-1421-a retired.
13
+ - **#1424 standard input flows (narrowing; open to veto).** A group's members read the pipe that feeds the group (`echo 'rm -rf ~' | { true; bash; }`); a `-c` text reads its shell's input (`bash -c 'bash' <<EOF`); a shell's process-substitution script file (`bash <(…)`) and a shell whose argv a stream fills in (`… | xargs -I{} bash -c '{}'`) run a script built when the command runs (`dangerous_delete`, `runtime_script`) — also for a harmless text (`ls | xargs -I{} sh -c 'echo {}'`).
14
+ - **#1466 the judge sees every name bash opens (narrowing).** A part of a pattern word (an attached option value, `if=` / `NAME=` values) carries the word's widened pattern (`grep --file=["!"a]* x`, `grep -f["!"a]* x`, `dd if=["!"a]*` refused where they reach a denied link); a `[:` / `[=` / `[.` that forms no unit is a member as bash reads it, and a class holding one is widened (`[[:alpha]]`, `[[:a]x/y:]`, `[a[=b]c=]` — bash 3.2's literal name and 5.3's match both judged); an array element's floor carries its pattern; an unquoted value's glob characters form its pattern (`X='<root>/["!"a]*'; cat $X`) and an expansion inside brackets forms the class from its value (`X=/; cat [${X}a]*`); the pattern is formed once the whole field has expanded — a value's `[` and a literal's `]` form one class (`X='['; cat ${X}a]pple` refused where `apple` is a denied link), a value split inside brackets hands its words (`X=" <root> "; rm -rf [${X}]`: `dangerous_delete`; was `probe`, run under bypassPermissions), a quoted `-` there is a member, never a range. **Loosening:** an unquoted value with glob characters is no longer an unresolved read (a pattern over in-root names no longer asks the read-face `probe`: it runs under a `Bash` allow or bypassPermissions without a card; the default arm still asks, `ordinary`). KL-7470-c / KL-7470-d retired.
15
+
16
+ **Package exports** (`npm run handoff:diff v7.47.0 HEAD`): +0 / −0 for the four items below (no export added or removed; one exported function, `normalizeRules`, throws where it used to return, and one exported type, `ReadDenyEntry`, documents a spelling it always accepted).
17
+
18
+ ### Session rules: a bucket that is not a list of strings is refused — a bare `"rm"` was stored as `["r","m"]` (#1471; behavior narrowing: string-shaped rules are now rejected) (@server)
19
+
20
+ - **Before (v7.47.0).** `normalizeRules` spread whatever a bucket held: `commandDeny: "rm"` became `["r","m"]` — a deny of two commands that do not exist, so `rm` ran — and `allowDirs: "/x"` threw a raw `.map is not a function`. Both bundled stores' `putRules` stored the result.
21
+ - **Now.** `normalizeRules` reads each of the five buckets (`toolAllow`, `toolDeny`, `commandAllow`, `commandDeny`, `allowDirs`) through one reader and throws a `TypeError` (`session rules commandDeny must be an array of strings, got a string …`) for a bucket that is not an array of strings — a bare string, `null`, an object, an array holding a non-string, or an array holding an empty or white-space-only entry (`""` is a string and no name; `allowDirs: [""]` used to be stored as `"."`) — the last in the same sentence family (`… got an array holding an empty or blank entry — a rule list is written as a list of names`), no new code. Both stores' `putRules` call it before they judge names, tighten or persist anything, so the write rejects and nothing is stored: no row, no high-water mark, no rev taken (an existing row keeps its rev and its list; pinned for a shape refusal, an empty / blank entry, a retired name and an unmintable name, on both stores). The refusal is not a `SessionPolicyError` code (the four-word code set is unchanged). Absent / `undefined` buckets stay absent; `[]` is a list.
22
+ - **Who is hurt.** A host that handed rules as a string, with non-string entries, or with an empty entry (for example a list split from an empty setting, `"".split(",")` is `[""]`) — now a `TypeError` from `putRules` / `normalizeRules` instead of a stored dead list. Callers of `normalizeRules` outside the bundled stores (read from the server tree: its SQL session-policy store and session sync) throw on a malformed record where they stored characters. A row an older release already stored as characters (or holding `""`) stays as written; an operator write replaces it (a non-operator write reads the removed entries as a loosening — `loosenReasons`; the judge answers `current` for `""`, so a stored empty `toolDeny` entry is not removable by the session's owner).
23
+ - **Compensation.** Write `commandDeny: ["rm"]`.
24
+ - Pins: `test/backlog7480-smallfix.test.ts` #1471 ①②.
25
+
26
+ ### A protocol name with an empty tool segment (`mcp__srv__`) is judged `unmintable_segment`, not `current` (#1400; behavior narrowing: a rule that guarded nothing in silence is now refused or announced) (@server @cli)
27
+
28
+ - **Before (v7.47.0).** `judgeRuleToolName("mcp__srv__", roster)` answered `current` and `compileAllowEntry("mcp__srv__", "deny" | "allow", roster)` compiled a `tool` row named `mcp__srv__` — a row no tool is minted under, with no notice. `APPROVAL_DENY=mcp__srv__` read as "deny the server" and guarded nothing.
29
+ - **Now.** A protocol name that ends in the separator (an empty tool segment, in every namespace of the protocol table) answers the existing `unmintable_segment` verdict, with a one-line `guidance` naming the empty tool segment and the two spellings (`<prefix><peer>` for every tool of a peer, `<prefix><peer>__<tool>` for one tool) and no `currentName`. No new verdict word (the closed set stays four). It is judged before the peer's spelling, so no suggested rewrite is itself dead. Its readers answer as for the empty peer: a session-rule write is refused (`SessionPolicyError` `legacy_tool_name`, `detail.verdict: "unmintable_segment"`); a run whose tool policy or stored session row holds the name fails at prepare (`config.legacy_tool_name`); an `allowedTools` / `disallowed-tools` entry read against a roster is announced as `tool_name`, and the notice says "a namespaced name with a segment the engine never mints" (was "a peer segment …"). The roster-less compile still reads the grammar alone (as it does for the empty peer).
30
+ - **Who is hurt.** A deployment, policy or stored session row that holds `mcp__<server>__` / `a2a__<peer>__`: a run that started with the dead entry now fails at prepare (a session write is refused); a stored row's owner takes the entry out with an ordinary write (removing it is not a loosening).
31
+ - **Compensation.** Write `mcp__<server>` to cover the whole server, `mcp__<server>__<tool>` for one tool.
32
+ - **Consumers.** server — the one question `namespaced-rule-name.ts` kept for this name (server's file; the ledger names it) can go once it takes this build.
33
+ - Pins: `test/backlog7480-smallfix.test.ts` #1400 ①②③.
34
+
35
+ ### `workflow_run.error` is secret-redacted where the engine mints it (#1472; behavior change: credential-shaped text in a failed run's `error` is now `[redacted…]`) (@server)
36
+
37
+ - **Before (v7.47.0).** `runWorkflow` minted `WorkflowRun.error` from the thrown text unredacted while `result` went through `redactSecrets`; the file run store (`DB_BACKEND=local`) wrote `error` to disk as it came, and a SQL store had to redact it again on its own.
38
+ - **Now.** The failure branch passes the thrown text through the same `redactSecrets` (same `[redacted…]` markers) — no length bound is added. The thrown value itself reaches the caller untouched. In-memory and file stores hold the redacted text; a store that redacts a second time changes nothing (the redactor is idempotent on its own output — pinned).
39
+ - **Who is hurt.** A consumer that matched a credential-shaped fragment inside `run.error`. Rows an older release already wrote stay as written.
40
+ - **Consumers.** server — its own pass at the SQL store's write (`workflow-run-store-sql.ts:91`) is now a second pass over already-redacted text; it stays until the 7.49.0 fixture union settles ONE detection table — the two tables are not supersets of each other today ([9588]: each side passes patterns the other catches), so retiring it now would drop the server-only patterns (#1468 phase two).
41
+ - Pins: `test/backlog7480-smallfix.test.ts` #1472.
42
+
43
+ ### `readDenyPatterns`: a pattern that opens with `/` is rooted (#1442; behavior narrowing: the row now names that one location, not that tail at any depth) (@server @cli)
44
+
45
+ - **Before (v7.47.0).** `normalizeEntry` stripped the leading `/` and the row matched its segments at ANY depth (ASCII-folded): a deployment's `/app/config-lkg.json` also blocked a user repo's `<anything>/app/config-lkg.json` — Read refused it, Grep silently left it out, the rg legs excluded `!**/app/config-lkg.json`.
46
+ - **Now.** A pattern that opens with `/` (and whose first segment is not the home word) is **rooted**: its run starts at the first segment of an absolute path — `/app/config-lkg.json` and what lies under it, nothing at depth. The mechanism is the home word's (`anchors` + `rootedOnly`), the case rule is the entry's `caseSensitive` knob (default ASCII-folded), `*` stays one segment. The canonical entry text keeps the leading `/` (`effectiveReadDenyPatterns`, the persisted entries), so `/a/b` and `a/b` are two rows and `//a//b/` is `/a/b`; `/~/x` is still the home word (`~/x`). A path spelling that is not absolute is not judged by a rooted row (the resolved key is). A rooted row carries no rg exclusion glob (`unexcludableUnder`): a Grep whose scope is relative, above or at the row does not use the rg exclusion and judges with the matcher — the route a `~/` row already takes (read from `search.ts`; the Grep cell below runs it). The built-in table has no row that opens with `/` (additive). **A rooted row is placed where its literal head leads, as a `~/` row is**: the head (the run's segments before the first `*`) is resolved in the execution environment the way the read faces mint their keys, and where a component of it is a symlink or a mount point the row is placed at the target too, the rest of its run matched below it — so `/app/config-lkg.json` with `/app` a link to `/data/app` also refuses a direct read of `/data/app/config-lkg.json`, and the deployment names the declared spelling alone. It is one machine (`linkedLocationsOf`, `read-deny-home.ts`) with a second row builder (`readDenyRootLinksOf`, beside `readDenyHomeLinksOf`) and the one `linked` arm in `compileReadDeny`; the placement reads it whether or not a home answered, a head the environment does not answer leaves the placement not whole (asked again, as for a `~/` row), a head with nothing below it or none at all (`/*.json`) adds nothing. The module-internal functions `readDenyRootLinksOf` / `isRootedReadDenyPattern` are not package exports.
47
+ - **Who is hurt.** A deployment whose `readDenyPatterns` row opens with `/` and that relied on the any-depth reading (for example a row meant to guard a mounted path under several parents): it now guards the exact absolute location only. A win32 path keeps its drive segment (`C:`), so a `/…` row does not name it (read from the matcher; not run on win32). A checkpoint written by an older release holds the stripped, any-depth form and resumes with it beside the new rooted row (the union, wider; inferred from the resume seat's addition list).
48
+ - **Compensation.** Spell the row without the leading `/` to keep the any-depth reading; write `/…` for the exact location. A deployment need not list the declared spelling and the realpath both: the placement adds the target of a link. server's two rows derived from the config path (declared spelling and realpath) are exact locations now and the second is redundant but harmless (@server).
49
+ - **Known limits.** The placement resolves a row's literal head only. A link BELOW the head, under a wildcard run (`/app/*.json` with `/app/secret.json` a link to `/vault/key`), is not enumerated: the row refuses the spelling that names the link and not a direct read of `/vault/key` — the same for a `~/` row, and not a regression (the any-depth row did not refuse it either); name the target to guard it. A rooted head under a directory the engine may not enter leaves a library mount's placement not whole, asked again at the next placement (a `~/` head's law).
50
+ - Pins: `test/backlog7480-smallfix.test.ts` #1442 ①②③④ (③ is the real Read and Grep through the production toolkit; ④ is the placement: the link's target through the toolkit, a wildcard rest, the Runner's carried placement, the held-anywhere path and an unanswered head; `~/` row placed today as the premise).
51
+
52
+ **Pins and mutations.** `test/backlog7480-smallfix.test.ts` (80 cells, r2 included; with each ticket's domain files put back to the base `5c7d569b9` the pin reads 13 / 12 / 3 / 12 red for #1471 / #1400 / #1472 / #1442, and 6 more red for the #1442 placement alone, every control cell green). Mutations (`.gate-logs/criteria-7480-mutations.md`, per fixture, each one's expected-red fixtures hit and its controls kept): M1471a–e, g–k (validation off; a bare string accepted as one rule; one bucket unchecked, twice; non-string entries accepted; a sparse array's hole skipped; r2: empty / blank entries accepted, names judged after the store's write, in both stores), M1400a–g (the arm off; arm after the peer rewrite; bare peer also flagged; mcp only; a new verdict word; the verdict set gains a word; the allow layer ignores the verdict), M1472a–b (unredacted; length-bounded), M1442a–f, h (leading `/` stripped; `rootedOnly` dropped; unanchored; home word loses precedence; persisted check on the old spelling; case fold lost; rg globs minted), MP1–MP7 (the placement: linked arm skips rooted rows; the main path drops the rooted answer; the held-anywhere path omits it; the places-nothing test ignores rooted rows; the head ignores its first star; `whole` ignores the rooted heads; an emptied `anchored`). `test/read-deny.test.ts` "separator spellings canonicalize ONCE" is re-pinned (its first entry `/corp-secrets` was the old any-depth reading; it is `corp-secrets//` now).
53
+ - **Rollback.** A checkpoint 7.48.0 writes carries a `/…` row in its rooted, canonical form; a worker on core 7.47.x resuming it refuses (`checkpoint.invalid_outcome`, reason `read_face_damaged` — the older screen holds every persisted deny entry to its own canonicality bar) and the checkpoint stays pending. Roll back only when no parked task holds such a checkpoint, or resume them on 7.48.0 first (@server).
54
+
55
+ ## 7.47.0 — 2026-10-09
56
+
57
+ **Erratum (docs; 7.43.0 compensation).** The 7.43.0 compensation sentence «finish a parked task on a worker of the release that parked it» named a way out without its cost: that release reads the twin / retired `disallowed-tools` entry as nothing too, so the tool the entry meant to deny runs there with no refusal (KL-1372-b). Finishing there is a choice to run that task without that deny; the recovering way out is to expire the task and submit it again with the entry corrected. `docs/INTEGRATION-CORE.md` says so now. Reported by the test lane ([9368] ④).
58
+
59
+ **#1402 —** hard-breaking: one (a type) — `COARSE_SHELL_TOOLS` is now the frozen literal `readonly ["Bash", "Monitor"]` (was `string[]` — a literal in v7.46.0; the catalog-derived form existed only on the unreleased stage): code that assigns it to a mutable `string[]` or pushes to it stops compiling, and a push throws at runtime; read it as `readonly string[]` (server does; the catalog's shell face stays the truth, kept one with the literal by the lockstep pin); closed sets: no registered set changes — the new catalog-only member `ToolCatalogEntry.face.ruleFace.grammar` is ONE optional literal (`"command"`), a presence mark rather than a set of words (`docs/CLOSED-SETS.md` registers sets of two or more; a second grammar word makes it one); `PermissionRuleIssue.code` keeps its members (the refusal below is `invalid.command_rule`); exports: none added or removed (`COARSE_SHELL_TOOLS` keeps its name and its members, `["Bash", "Monitor"]`, so a copy a downstream layer holds keeps its meaning; it stays a spelled-out list, frozen, typed `readonly ["Bash", "Monitor"]` where v7.46.x typed it `string[]` — reading it is unchanged, while assigning it to a mutable `string[]` no longer compiles and pushing to it throws; the catalog's shell face is the truth, and a pin keeps the list one with it); `ToolCatalogEntry.face.ruleFace` gains one optional catalog-only member (`grammar: "command"`; `ToolSpec.ruleFace` is unchanged); `Monitor`'s roster row gains `ruleFace.primaryParams: ["command"]`. **Behavior narrowing:** two — `Bash(...)` command rules now judge `Monitor` calls (deny / ask / allow, every source); `Monitor(<command>)` rule text is refused at compile time. **Safety loosening, vetoable (C-R275 form):** one — the allow half of the same reading: a `Bash(...)` allow (an allow rule, an `allowedTools` / `RunnerDeps.allowedTools` entry, a person's stored allow row, a skill grant) now clears a `Monitor` call's ordinary question exactly as it clears `Bash`'s, under `dontAsk` too (the allow answers before the mode refuses); upstream judges a `Monitor` command by the `Bash` rules (`cli291.js:245292`).
60
+
61
+ **#1423 —** hard-breaking: one — the export `RELEASABLE_ASK_CLASSES` is removed (read `releasableAskClassesFor(askFaceOf(toolName))`); closed sets: `MODE_RELEASE_REASONS` +1 (`write_protection_unresolved`), one new (`ASK_FACES`: `shell`, `other`); exports: +4 / −1 (`ASK_FACES`, `AskFace`, `askFaceOf`, `releasableAskClassesFor`; −`RELEASABLE_ASK_CLASSES`); `foldArbitrationRow` gains one optional trailing parameter (`askFace`) and `AskClassFacts` one optional member (`askFace`) — a caller passing neither reads the v7.46.x fold. **Safety loosening, vetoable (C-R275):** under `bypassPermissions` a shell-face call carrying the class `write_protection_unresolved` is released instead of refused by the mode (file tools unchanged); and on the shell face a standing ALLOW over a write target the judge did not resolve becomes that class's question instead of the tighten's refusal — released under `bypassPermissions`, a card under `default` / `auto` / `acceptEdits` / `plan`, refused by the mode under `dontAsk` (file tools unchanged, byte for byte). Both are one loosening and are vetoed together; the reach is stated below, and it is narrow.
62
+
63
+ ### `Bash(...)` command rules judge every tool that runs a shell command — `Monitor` included (#1402; behavior narrowing; one loosening open to veto) (@server @cli @client-core @test)
64
+
65
+ - **Before (v7.46.x).** A `Bash(<command>)` / `Bash(<prefix>:*)` rule was compiled under the literal tool name `Bash` and matched only calls named `Bash`. `Monitor` runs its `command` argument as a shell command line through the same execution seam, so under `deny Bash(touch:*)` a `Monitor` `touch k1` asked an `ordinary` question under `default` (and under `default` with `allowedTools: ["Bash"]`) and **ran with no question under `bypassPermissions`**; `ask Bash(git push:*)` did not reach it (an `ordinary` card, released by bypass); `allow Bash(npm test:*)` did not clear its question. The same held for a person's stored rows, the org partition and the allow lists. `Monitor(touch:*)` compiled clean and matched nothing.
66
+ - **Now.** The tool catalog declares a **shell face**: a definition whose `command` argument is a shell command line declares `ruleFace.grammar: "command"` (the two `Bash` definitions and `Monitor`). One reading — `laneToolNamesOf`, the rule words a call is judged under — maps every shell-face name a call answers to (aliases included) to the command word `Bash`, and every lane reads it: the policy compiler (`createPermissionRulePolicy`), a person's stored rows, the org partition and the allow lists (`allowedTools`, `RunnerDeps.allowedTools`, skill grants). So a `Bash(...)` command rule — deny, ask or allow — judges a `Monitor` call by its `command` exactly as it judges a `Bash` call: under `deny Bash(touch:*)` a `Monitor` `touch k1` is refused with no card in every mode (`deniedBy: "policy"`, the refusal naming the rule); `ask Bash(git push:*)` is an `ask_rule` card that bypass does not release; `allow Bash(npm test:*)` clears its ordinary question. `COARSE_SHELL_TOOLS` (the coarse command-name gate's and the removal / transcript guards' default tool list), the shell read station and the shell seat are the same set — the catalog's shell face, which `COARSE_SHELL_TOOLS` spells out for the wire (a pin holds the two one, both ways).
67
+ - **`Monitor(<command>)` is refused** (`invalid.command_rule`, the sentence naming the `Bash(<command>)` spelling to write instead): a command rule is written in the command word. Where the text is a parameter rule on an argument `Monitor` does not declare but `Bash` does (`Monitor(timeout:5)`), the sentence names no `Bash(timeout:5)` — that is a rule on `Bash`'s own argument and does not reach `Monitor` — and says a parameter rule reaches only the tool that declares the parameter, pointing at `Bash(<command>)` for the command. Upstream (2.1.291) judges a `Monitor` command by the `Bash` rules only (`cli291.js:245292` — Monitor's `checkPermissions` runs the shell checker; `:241246` — that checker reads the `Bash` rules) and silently accepts a `Monitor(…)` rule it never reads (`:228564`); here the dead rule is loud. A parameter rule on one of Monitor's own other arguments (`Monitor(description:nightly*)`) still compiles and matches; `Monitor(command:…)` is the primary-field refusal (`invalid.primary_field`).
68
+ - **A caller tool wearing the `Monitor` name** (`aliases: ["Monitor"]`) reaches `Bash(...)` rules — grants included — exactly as a caller tool wearing `Bash` already did (an alias is one name at the gate); the reach of grants to such a wearer itself is #1444.
69
+ - **A bare `Bash` rule stays a tool-name rule**: `deny: ["Bash"]` / `allowedTools: ["Bash"]` reach the tool named `Bash` only, as upstream's whole-tool matcher does (`cli291.js:233892`, Monitor declares no family parent). A deployment that wants no shell at all denies both names (or excludes them).
70
+ - **Who is hurt.** (1) A deployment that relied on `Monitor` running a command its `Bash(...)` deny or ask rules cover — now refused or asked. (2) A rule set holding a `Monitor(<command>)` entry now fails to compile (`config.invalid_permission_rules`; under `onInvalidRule: "skip"` the entry is dropped and reported) instead of compiling into a rule that matched nothing; `Monitor(command:…)` likewise (`invalid.primary_field`, opt in with `primaryFieldGeneric: "allow-lexical"`). (3) A host reading `Monitor`'s roster `ruleFace.primaryParams` sees `["command"]` instead of `[]`. (4) **Safety loosening, vetoable (C-R275 form):** a deployment whose `Bash(...)` allow — an allow rule, an allow-list entry, a stored allow row or a skill grant — matches a command a `Monitor` call runs: that call's ordinary question is now cleared, under `dontAsk` too (`allowedTools: ["Bash(tail:*)"]` runs Monitor `tail -f app.log` with no card under `default` and under `dontAsk`, where v7.46.x asked under `default` and refused under `dontAsk`, `deniedBy: "mode"`). A question an allow never clears (a dangerous removal, an ask rule, a read-boundary deny) is untouched, as for `Bash`. Reverting is reading the allow lane under the call's own name for `Monitor`; the deny and ask halves stand.
71
+ - **Compensation.** Write command rules as `Bash(<command>)`: they reach `Monitor` too. A host that kept its own copy of the shell tool list reads `COARSE_SHELL_TOOLS` (or the catalog rows declaring `ruleFace.grammar: "command"`) instead.
72
+ - **Consumers.** server — a governance layer that mirrors `COARSE_SHELL_TOOLS` keeps reading it (same members; server reads it as `readonly string[]` and into a `Set`, `src/runtime-governance.ts:112`, `src/router/route-orchestration.ts:172`, which the narrowed type fits); a settings layer that writes `Monitor(...)` rules gets the compile refusal; cli — a `Monitor` call under a `Bash(...)` deny now shows the policy refusal instead of an approval card, and under bypass it is refused rather than run; test — PA-11 (deny on the equivalent execution face, bypass included) flips at this version.
73
+ - **Known limits.** KL-1402-a — three clearing arms are still keyed on the `Bash` tool name: the read-only shell lane (a command the read station leaves an ordinary question — `tail -f app.log`, `git status` — runs on `Bash` and asks on `Monitor`; `ls`, `cat`, `grep`, `tail -n` run with no card on both), the `acceptEdits` file commands and the card's standing-rule offer; every one errs toward asking; #1443 carries the shell face to those three.
74
+ - Pins: `test/backlog1402-monitor-shell-rules.test.ts`; `test/backlog7470-fix.test.ts` ⑥ (the parameter-shaped refusal sentence; 2 cells, 1 red on `648d56b58`, the control green).
75
+
76
+ **#1421 (with #1445 merged) —** hard-breaking: none; closed sets: 0; exports: 0 added / 0 removed; `ReadCandidate` (exported type) gains one optional member (`pattern`: the spelling a pattern's names are matched by where it is not `spelled` — below); `SHELL_WRAPPER_TABLE` (exported constant): the `su` and `runuser` rows gain their option sets (`valueFlags` / `flags` / `commandString`, the projection of their new grammar rows — the peel still stops at both, `viaShell: true`). **Behavior narrowing:** two — the second, for every command and not only a launcher's, a character quoted or escaped inside a bracket class widens that class for the path reader (below) — with four loosenings open to veto (below).
77
+
78
+ ### A launcher that runs a program named by no word of its own hands it an argv, read as that program reads its own (#1421, #1445 merged; behavior narrowing) (@test @cli @server)
79
+
80
+ - **Before (v7.46.x).**
81
+ - **`sudo -s` / `-i` / `--shell` / `--login` with a command** stepped over the switch and read the command as the program sudo runs: `sudo -s rm -rf '$HOME'` read a literal operand `$HOME` (a file in the root), and the only card was the text reader's `unverifiable_delete` ("references $HOME, which has no assignment in this command") — under every mode, but not naming the home; `sudo -s rm -r '$HOME'` (no `-f`) and `sudo -s rmdir '$HOME'` raised no card under a `Bash` allow or `bypassPermissions` — while sudo hands the shell `rm -r $HOME` and the shell removes the home.
82
+ - **`su` / `runuser`** handed every operand to the shell as text with the option words dropped: the user name was the text's first word (`su root -c 'rm -rf ~'` planned `root rm -rf ~`, `su root <<'EOF'` planned `root`), so neither raised `dangerous_delete` (a `probe` question, released by `bypassPermissions`); `runuser -u USER -- CMD` was read as a shell too; `su -s /bin/rm root -- -rf ~` was `dangerous_delete` only because the joined text happened to start with `/bin/rm`.
83
+ - **Now.** One rule: a launcher that runs a program named by no word of its own hands it an argv its row composes, and the argv is read as that program reads its own — a shell's `-c` text a nested plan, its first operand a script file, nothing at all its standard input (#1417); any other program its own row.
84
+ - **`sudo` (`shellCommand: "escaped"`).** With words after its options, a shell switch hands the shell `-c` and the words joined by a space, every character but a letter, a digit, `_`, `-` and `$` escaped with a backslash (sudo(8) `-s` / `-i`; sudo `parse_args.c`, sudo-rs `escaped`) — `$NAME` stays an expansion, every other character is literal. `sudo -s rm -rf '$HOME'` is `dangerous_delete` (the home); `sudo -s 'rm -rf $HOME'` is one word to the shell (a command named `rm -rf <home>`), so no removal; a bare `sudo -s` runs its standard input as before. The rule is about the FIELDS the command line's shell hands sudo, whatever spelled them: a word's literal value, or each value the shell established for a word it rewrites first — `X='#'; sudo -s rm -r "$X" DIR` hands `rm -r \# DIR` (an operand, so DIR is still the removal), a value carrying `;` or a space stays one argument (`X='; rm -rf /'; sudo -s echo "$X"` is one `echo`), `$` in a value stays an expansion (`X='$HOME'; sudo -s rm -rf "$X"` removes the home), a pattern word keeps its pattern — the value's characters escaped, the unquoted pattern characters bare, as written (`X=<root>; sudo -s rm -rf "$X"/*` hands `rm -rf \/<root>\/*`, a `*` inside the quoted value escaped; `$X/[^\.]*` keeps `[^\.]`), a pattern a `for` list binds included (`for X in '#'*; do sudo -s rm -rf "$X" DIR; done` hands `rm -rf \#* DIR`, so DIR is still the removal) — and words with several established values are read in every combination of them, the same word spelled twice taking one value per reading (past 64 combinations, reading *i* hands each word its *i*-th value — every value is still read, not every combination (KL-1421-e, #1460: `sudo -s "$P" -rf "$X"` with nine values each has no `rm -rf <root>` reading where `rm` and `<root>` sit at different positions — a `probe` question, released by `bypassPermissions`), and the readings stay as many as one word's values however long the command). A word whose value the reader does not know is handed as written, so the launched shell re-reads the expansion with the command line's quoting and each word keeps its own argument (`sudo -s rm -r "#$Y" DIR`). `doas -s CMD` is refused by doas (OpenBSD / OpenDoas) and is read as before.
85
+ - **A glob piece is read by the pattern the lexer hands it; a quoted character widens its class; a value's leading `~` is the literal tilde it is.** Before (v7.46.x), a value word's glob piece was cut out of the word's spelling by the written width of the pieces before it, so the cut drifted wherever the spelling and the written text differ: a word that opens with a quote read its pattern one character early (`X=/w; rm -rf "$X"/*` read `/w//`, the workspace itself), a bracket holding a quote or a backslash was read with them (`X=<home>; rm -rf $X/[^\.]*` read `<home>/[^\.]*`, a pattern the matcher does not expand), and a value that starts with an escaped or quoted `~` read the home (`X=/w; rm -rf \~$X/*` read `~/w/*`, `X='~/a'; rm -rf "$X"` read `~/a`). Now the lexer hands each glob piece its pattern three times — as the program receives it where it matches nothing (quotes removed: `[^.]`), as written (`[^\.]`, what a shell re-reading it matches by), and as a path reader judges it — and every value, a `for` list's pattern included, is read by one walk over the pieces: `"$X"/*` reads `/w/*` (same `dangerous_delete` class; the card names `<root>/*` where it named `<root>`), `$X/[^\.]*` reads `<home>/[^.]*`, and a value that starts with `~`, read directly as a path, is spelled `./~…` as a literal word is (`rm -rf \~/w/*` already read `./~/w/*`), the home spelling beside it for the deny judge; a shell that re-parses a word (`eval`, `sh -c`, a launcher's line) is handed the program's text (`X='~'; eval rm -rf "$X"` re-parses `rm -rf ~`). **Narrowing:** a character quoted or escaped inside a bracket class is a literal member, which the quote-removed text may read as a negation, a range, the end of the class or a `[:…:]` / `[=…=]` unit's opener (`["!"a]`, `[a"-"z]`, `[a\]b]`, `["["=a=]]`): the read candidate's names are matched by its `pattern`, every class holding such a character — or a `[:…:]` / `[=…=]` / `[.….]` unit, which the matcher cannot model — read as any one character (`?`, as the matcher reads a POSIX class) — so every name the shell may match is judged, except where a part is cut out of a word (an attached option value, `dd if=`'s value, an assignment's value): the part is matched by its own quote-removed text while bash matches the whole word, a live miss (KL-7470-c, #1466): with a plain `--file=apple` and a link `apple` to a denied file in the working directory, `grep --file=["!"a]* x` opens `apple` and runs on every arm, as on v7.46.x (`grep -f["!"a]* x` likewise); a well-formed class is read as bash forms it — it ends where bash ends it (a `[:…:]` / `[=…=]` / `[.….]` unit skipped whole, a quoted `]` closing nothing: `rm -rf ~/[[:alpha:]"]"]*` was closed at `[:alpha:]`'s `]`, a `probe` question released by `bypassPermissions`, and is now `dangerous_delete`), and a `/` inside the brackets forms no class (`["/"a]*` is the directory `[` and the pattern `a]*` in it: with `<root>/[/a]key` a link to a denied file, `cat <root>/["/"a]*` is refused as on v7.46.x, and its value form `X=<root>; cat $X/["/"a]*` now too, where v7.46.x asked an `ordinary` question under `default` and ran under an allow or `bypassPermissions`); a malformed unit is not (`[[:alpha]]`, `[[:a]x/y:]`, `[a[=b]c=]`: the judge reads a literal `[` where bash reads a member, so a link bash opens — `a]`, `ax/y:]`, `ac=]` — runs on every arm, as on v7.46.x; KL-7470-d, #1466); where the pattern matches nothing the program opens the name the word spells with quotes removed, the candidate's `spelled` (`cat <root>/["!"a]` opens `<root>/[!a]`), and that name is judged, for a written word and a value alike — every class the matcher widens — a quoted character or a `[:…:]` / `[=…=]` / `[.….]` unit alike — carries a `pattern`, whose matches prove none of the shell's, so the name it spells is judged beside whatever it matches (`cat <root>/[[:alpha:]]` with only `<root>/1` beside a link `<root>/[[:alpha:]]` to a denied file: v7.46.x matched `1` by the matcher's own widening and ran on every arm, the value form likewise under an allow and `bypassPermissions`; both are now refused) (`cat <root>/["a"]` with a plain `<root>/b` and `<root>/[a]` a link to a denied file: refused on every arm, as on v7.46.x; the value form `X=<root>; cat $X/["a"]` is now refused too, where v7.46.x asked an `ordinary` question under `default` and ran under an allow or `bypassPermissions`). v7.46.x read `cat <root>/["!"a]*` by `[!a]*` (not `a`): a link `<root>/apple` to a denied file ran with no question on every arm; it is now refused by the deny table on every arm, the value word `X=<root>; cat $X/["!"a]*` likewise (v7.46.x refused it: its quotes were read as members), and `[a"-"z]*` over a link `-key` the same (v7.46.x: the written word ran, the value word an `ordinary` question under `default` and ran under an allow or `bypassPermissions`). Where nothing matches and `<root>/[!a]` is the link, `X=<root>; cat $X/["!"a]` is now refused on every arm (v7.46.x read the value's no-match name with its quotes: an `ordinary` question under `default`, run under an allow or `bypassPermissions`); the written `cat <root>/["!"a]` is refused as on v7.46.x. A quoted or escaped `[` / `:` opens no unit: `["["=a=]]` is the class {`[`, `=`, `a`} and a literal `]`, matching `a]` — with `<root>/a]` the link, `cat <root>/["["=a=]]`, `cat <root>/[[":"alpha:]]` and `cat <root>/[\[=a=]]` ran on every arm on v7.46.x and are now refused; their value forms were refused on v7.46.x (`[\[=a=]]`: a `probe` question, released by `bypassPermissions`) and are refused now. **Who is hurt (the superset):** a name the class cannot match is judged too — under `bypassPermissions` `cat <root>/["a"]*` with a link `<root>/bkey` to a denied file is refused (v7.46.x ran it; bash opens no `bkey`), and `rm -rf <home>/["a"]*` raises a `dangerous_delete` card naming the workspace's parent `work`, which bash would not remove (v7.46.x: no card); and where the class does match (`<root>/a` exists) the name it spells is judged as well — `cat <root>/["a"]` beside a link `<root>/[a]` to a denied file is refused, though bash opens `a` (v7.46.x ran it); likewise `cat <root>/[[:alpha:]]` with `<root>/a` present beside a link `<root>/[[:alpha:]]` to a denied file (v7.46.x ran it).
86
+ - **`su` / `runuser` (`shellText: "user"`).** The rows carry util-linux su's options and macOS su's; the first operand past a lone `-` names the user, and the program is handed `-c TEXT` for su's own `-c` / `--command` / `--session-command` and the words after the user — per implementation (util-linux reads its options anywhere, macOS hands everything after the user on). `su root -c 'rm -rf ~'`, `su -l root -c …`, `su root <<'EOF'`, `su - root <<'EOF'` are `dangerous_delete`; `su root ls` hands its shell the script file `ls`. `runuser -u USER [--] CMD` runs CMD (util-linux `execvp`): CMD is the program the line names.
87
+ - **The program (#1445 merged).** su's `-s PROGRAM` / `--shell=PROGRAM` names the program run in the user's shell's place (util-linux su(1)): a shell's row reads its argv as a shell's (`su -s /bin/bash root -c 'rm -rf ~'`), any other program is read as the command line it is (`su -s /bin/rm root -- -rf ~` is `/bin/rm -rf ~`: `dangerous_delete`). A program the text does not spell — `-s "$X"`, or `-m` / `-p` with no `-s` (the caller's `$SHELL`) — may be any program, a shell among them: its command line is read (an unknown program: a question on both faces) and its argv is read as a shell's too (`su -m operator -c 'rm -rf ~'` is `dangerous_delete`).
88
+ - **Option terminators.** sudo's and doas's grammars step `--` as getopt does: `sudo -s -- rm -rf '$HOME'` is the shell's text (`dangerous_delete`), `sudo -- CMD` / `doas -- CMD` read CMD by its own row (they declined before). An option value attached to its option word is read by its value, however the option word is quoted (`su "-s"/bin/rm …`), and a shell-text value attached to `script`'s option word (`script -c'CMD'`, `--command=CMD`) is a `-c` text of its own (it was dropped with the option words).
89
+ - The record: the program position names the launcher and the first word it hands on; the plan reads a launched shell and a named one through one path, and the removal guard's script-built-at-run-time arm reads the launched argv the same way (a value `sudo` escapes is a value, not script text).
90
+ - **Who is hurt.** (1) A run under `bypassPermissions` or a `Bash` allow that removes a protected location through `sudo -s` / `-i CMD`, `su USER -c CMD`, `su USER <<EOF`, `su -s PROGRAM …`, `runuser USER -c CMD` or `runuser -u USER CMD` now stops on a `dangerous_delete` card (no mode or allow clears it); for the `'$HOME'` forms the card was `unverifiable_delete` and is now `dangerous_delete` naming the home. (2) Every `sudo -s` / `-i` with a command is now a shell re-entry, as `bash -c` is: under `default` and under a `Bash` allow the read station asks (`probe`, the read set is not established) where it walked the command's operands before (`sudo -s ls`, `sudo -s cat '$X'` were allowed under a `Bash` allow); the read-only classifier refuses it with the control-syntax sentence. (3) Loosenings open to veto: `runuser -u USER CMD` is judged as CMD (a reader's operands read, not a shell text's words); a quoted word in su's user slot (`su 'rm -r x'`) is a user name and plans nothing (su refuses an unknown user); a value that starts with a quoted or escaped `~`, read directly as a path, is read in the working directory, as the shell hands it, where v7.46.x read it as the home (a shell that re-parses the value — `eval`, `sh -c`, a launcher's line — still reads `~`, the home, as bash does) — so a read that home reading placed outside the roots no longer asks under a `Bash` allow (`X=<root>; rm -rf \~$X/*`: a `probe` question under `default` and under a `Bash` allow, released by `bypassPermissions`; now an `ordinary` question under `default`, no card under the allow, released by `bypassPermissions`); the deny judge still reads the home spelling, and `X='~'; rm -rf "$X"` is still `dangerous_delete` (the home); a bracket's extent follows bash over the text its quotes leave, so `read H[""]ME <<< …; cat "$HOME/key.txt"` — `[""]ME` is `[]ME`, a `]` first and no close, the `[` a literal, `H[]ME` no pattern and no name `read` binds — no longer asks whether `read` rebound `HOME` (v7.46.x asked: the bracket was closed at the written `]`, a fail-closed reading of a wrong extent); `cat "$HOME/key.txt"` is judged at the real home.
91
+ - **Compensation.** None for a removal that was never safe; a command that must run as another user without a question names its targets literally (a re-entry asks under `default` as `bash -c` does).
92
+ - **Consumers.** test — the cells below (dangerous_delete under every arm and face; the read face; the controls); cli — `dangerous_delete` cards appear for these forms under bypass, with the existing wording; server — no wire shape changes, `judgeShellRead` answers differently for these commands.
93
+ - **Known limits.** KL-1421-a (a launched shell's `$HOME` / `~` read as the declared home) · KL-1421-c (`watch` / `script` text is the station's non-option-word reading, not their grammar) · KL-1421-d (a launcher option cluster ending in a value letter — `sudo -iu root CMD` — is refused at the program position while the second lexer peels it; on the base too; #1446). KL-1417-a / KL-1417-b retired. · KL-1421-e (launcher values past 64 combinations fold by position — every value is still read, not every combination; the off-diagonal (program, target) pair is a probe card and released under `bypassPermissions`, as on v7.46.x; #1460) · KL-7470-e (`bash -c` inline text is still a probe card on a guarded face) · KL-7470-f (a `Read(...)` deny rule does not reach a traversal's members — `grep -r` / `git grep` run; the built-in deny tiers do block them; #1470)
94
+ - Pins: `test/backlog7470-fix.test.ts` ①–④ (the fields rule above, ①–④b; 54 cells — lanes three arms × one or two faces, and the plan; 34 red on the stage tree `648d56b58`, where an established value was handed unescaped, an unknown word by its quote-stripped spelling and a name's first value only; the 20 green are ④ (the `$HOME` control) and ④b's pattern lanes and controls, which that tree carded through the unescaped value — those twelve lanes are red on this change's earlier cut `ace36acb2`, where a pattern word was handed as written) and ④c (the one value walk, the widened class, the no-match name and the literal tilde above; 116 cells — 105 lanes and 11 plan / lane-parity / card cells; 88 red on this change's earlier cut `4e201964b`, where a `for` list pattern was handed whole as pattern text, a bracket holding a quote or a backslash drifted and `\~$X/*` read `~/w/X`; 58 red on its cut `8030dc983`, which judged a quoted class member by the quote-removed class and handed a re-parsing shell the path spelling; 40 red on its cut `308801be9`, which placed a pattern's no-match name at the judged spelling; 33 red on its cut `eefe944b9`, which widened only a quoted `!` `^` `-` `]` and closed a class at a POSIX unit's `]`; 22 red on its cut `f6c35411e`, which judged a widened pattern's name only where nothing matched and folded a bracket holding a `/` into one class; 15 red on its cut `351a9aedd`, which gave a POSIX unit's class no pattern and still folded a `/`; 64 red on the stage tree; 70 on v7.46.0; the POSIX real-match cell red on every one of them; the three `[^.]` control lanes green on every tree) and ⑦ (a run of `[` with a `/` after it forms no class: the reading pinned, the lexing time linear in the run — red on this change's cut `8782e634d`, which rescanned the rest of the word for a `/` after every `[`: 7.7 s for a run of a million); `test/backlog1421-launcher-reread.test.ts` (140 cells; 103 red on `57c798d13`, the 37 green are the forms the base already carded — `sudo -i rm -rf ~`, `sudo --shell rm -rf "$HOME"`, `doas -s rm -rf ~`, `su -s /bin/rm …` (by accident) — and the controls and read-face cells the base already refused or asked).
95
+
96
+ ### The mode reads the unresolved-write class by the face the question was put on; one releasable-class reader per face replaces the face-less constant (#1423; table and reader aligned to the reference form; safety loosening, vetoable; exports −1 +4; closed set +1) (@client-core @cli @server @test)
97
+ - **Before (v7.46.x).** The arbitration row of `write_protection_unresolved` (the write-protection judge did not answer for the target) was bypass-immune for every call, and `arbitrateByMode` refused a set holding it under `bypassPermissions` whatever tool the call was made under (`deniedBy: "mode"`). The only reader of "which classes can a mode release" was one face-less list, `RELEASABLE_ASK_CLASSES` (ten classes), so a consumer that mirrors the engine's table (client-core's generated copy; a shell's bypass-posture lane) had one answer for a `Write` and for a `Bash` and kept a local exception for the shell face where the reference form releases it (a second reading of one fact).
98
+ - **Now.** One fold, one reader per face. The question's tool face enters the fold once, where the read face already does: `foldArbitrationRow(classes, readFace?, askFace?)`; `classifyAsk` reads `AskClassFacts.askFace`, and every classification the engine makes stamps `askFaceOf(<the call's wire name>)` (the live gate and the inherited-ancestor station). `askFaceOf` is the catalog's shell face (the rows declaring the command grammar — `Bash`, `Monitor`) or `other`. The face is read off the call's WIRE name, not off every name the call answers to: it widens a mode's release, and a release keyed on an engine name reaches the tool of that name, not a tool that merely wears it as an alias (the two new rows `gate.askFace` — `passAskFace`, the one reading the lanes' classification and the write-protection tighten both make — and `inherited.askFace` of the tool-name readings table are `wire`, like the read-only shell lane's). `releasableAskClassesFor(face)` lists the classes a mode can release for a call on that face, derived from the same table and the same per-face reading the fold uses (no second table): the `other` face lists the ten v7.46.x classes, the `shell` face the same ten plus `write_protection_unresolved`. `RELEASABLE_ASK_CLASSES` is removed. `MODE_RELEASE_REASONS` gains `write_protection_unresolved`: a release names the first member of `MODE_RELEASE_REASONS` its class set holds — `rule_unreadable` before `write_protection_unresolved` — so a release of this class alone carries `reason: "write_protection_unresolved"`, and one beside an unreadable rule carries `reason: "rule_unreadable"` (both words in `released.classes`); the I7 outcome screen accepts a release whose class SOME face releases (the record carries no face). The bypass refusal arm now reads the folded row: a set holding the class beside an immune member (an explicit ask rule, a hook) is refused exactly as before; on the shell face a set whose members are all releasable there is released (the class alone, or beside `rule_unreadable`).
99
+ - **On the shell face a standing allow reads as a standing ask.** The write-protection tighten's word for a target the judge could not resolve is the class's own reading on the call's face (`writeProtectionTightenDecision(toolName, verdict, face)` reads `releasableAskClassesFor(face)`): on the file face the refusal (`deniedBy: "write_protection"`, the v7.46.x sentence byte for byte); on the shell face the class's question — `tool "<T>" writes to a target that could not be resolved on this tree, so the write-protection judge cannot tell whether it lands on a protected path (<cause>) — explicit approval required` (a symlink whose real target could not be resolved has its own clause) — which the mode answers as it answers the same record over a standing ask. Before this, on the shell face an allow rule made the call stricter than no rule: a standing ask was released under `bypassPermissions` and a standing allow refused.
100
+ - **Reach, stated plainly.** The engine's own `Bash` and `Monitor` cannot carry this class today: its one setter (`gate-fold.ts:442`) is fed by `writeProtectionCheck`, which answers null for a call whose face declares no write path target (`write-protect.ts:1036-1038`; the two shell definitions declare none); the shell read station records only the matched-table class `write_protection` (its judge is reduced to a hit / no-hit, `write-protection-station.ts:113`); and the `unresolvable` verdict has one minter, the gate's bounded wait on a judge that never answers (`gate-bounded-await.ts:122`). On a real `Runner`, `Bash` and `Monitor` × `touch .claude/settings.json` read the class `write_protection` — a card under `default` (an allow list does not clear it), released under `bypassPermissions` — identical before and after this change. This change is therefore the arbitration table and its reader aligned to the reference form, not a fix for a card the engine's shell face was showing; the live reach is a caller tool whose wire name IS a shell-face name (it replaces the engine's own `Bash` / `Monitor`; a tool that wears the name only as an alias stays on the `other` face) and that declares a write path target, plus every consumer that reads the table. No tri-state shell judge is added (it would be a new question on shell commands).
101
+ - **Who is hurt.** (1) A host or shell that imports `RELEASABLE_ASK_CLASSES` (it is removed): read `releasableAskClassesFor(askFaceOf(req.toolName))`; the `other` list is the old list, member for member. (2) A deployment running a caller tool whose wire name is `Bash` or `Monitor` and which declares a write path target, under `bypassPermissions`, whose write target the judge never answered for: that call is now released (disclosed on `disposition.released`, `reason: "write_protection_unresolved"`) instead of refused by the mode. (3) A consumer that mirrors the closed set `MODE_RELEASE_REASONS` (client-core's generated copy; server's `isModeReleaseReason` reads the package's own predicate and follows) sees one new word. (4) Shell face: a standing allow with an unresolved write judge now asks instead of refusing under `default` / `auto` (and `acceptEdits` / `plan`) — where the card is answered no or nobody can answer it, the refusal reads `deniedBy: "ask_resolution"` instead of `deniedBy: "write_protection"`; `dontAsk` refuses it by mode (`deniedBy: "mode"`, the class named); `bypassPermissions` releases it; file tools unchanged (an allow there is still refused, `deniedBy: "write_protection"`, while an ask stays a card in the modes that ask — `dontAsk` and `bypassPermissions` refuse it by mode, `deniedBy: "mode"`). The reach is (2)'s: a caller tool whose wire name is `Bash` or `Monitor` and which declares a write path target. **Safety loosening, vetoable (C-R275):** the loosening is one class on one face; reverting is removing the class from the shell face's released set and its word from `MODE_RELEASE_REASONS` — the tighten then refuses on every face again, since it reads the same per-face list; the readers, the fold parameter and the closed set keep working.
102
+ - **Compensation.** Where a consumer keeps a local shell-face exception for this class (client-core / cli's bypass-posture lane), delete it and read `releasableAskClassesFor(askFaceOf(toolName))` — a card the engine would release for that call is the card the consumer may answer. Where a consumer needs the old face-less list, the `other` face's list is it.
103
+ - **Consumers.** client-core — the generated engine fact table keeps its five columns per class (`src/generated/engineFactTables.ts:215`, the class's row) and the mirrored release-reason set gains the word; the posture verdict must read the per-face list for the shell face: today its rule 5 answers `must_ask` for the mandate word `write_protection_unresolved` on every face (`src/hitl/automaticAnswerVerdict.ts:20`, `MANDATE_WORD_IMPLIES_CLASS` `:106-109`) before any class row is read, and its in-package fold `foldModeArbitrationRow` (`:116`) takes no face — both need the face before the shell-face row can answer; cli — CC-436's Bash-face exception (`src/sema/engineGateSyncAllow.ts:1278-1280` `isBashFace`, exact name `Bash`, `Monitor` not included; the view at `:1294`) retires into `releasableAskClassesFor(askFaceOf(toolName))` only AFTER client-core's rule 5 reads the face — retiring it first sends the shell-face card back to `must_ask`; server — the removed constant is imported by three tests (`test/core741-requires-user-interaction.test.ts:16`, `test/unattended-permission-mode.test.ts:25`, `test/read-face-e2e.test.ts:46`, the last asserting at `:1795`), and `src/unattended-permission-mode.ts:21/24/50` states its invariant against it (comments): re-point all four at `releasableAskClassesFor("other")`; sdk — four wire tests import it (`packages/sdk/test/server-7115-wire.test.ts:41`, `server-7116-wire.test.ts:34`, `server-7118-wire.test.ts:29`, `server-7119-wire.test.ts:19`; `:261` / `:313` / `:378` assert ten classes — the `other` face's count; the shell face's is eleven) and `packages/sdk/src/types.ts:2413` names it in a doc comment; test — G46 / G47 in `docs/release-criteria/7.47.0.json`.
104
+ - **Known limits.** KL-1423-a — the release record (`ModeRelease`) carries no face, so the outcome screen checks that SOME face releases the class, not that the call's own face does.
105
+ - Pins: `test/backlog7470-fix.test.ts` ⑤ / ⑤b / ⑤c (the standing allow on its face; 17 cells, 8 red on the stage tree `648d56b58` — the shell-face allow under every mode and the shell face's question words; the 9 green are the standing-ask cells and the file face, its refusal byte for byte and its standing ask in every mode — a card where the mode asks, refused by `dontAsk` and `bypassPermissions`); `test/backlog1423-wpu-by-face.test.ts` (24 cells; 10 red on the unchanged stage tree `998bfc44b` — the shell-face release (two tools), the wire-name `Monitor` cell, the per-face readers, the retired constant, the closed-set word and the fold's third parameter; the 14 green are the file-face cells, the default-mode cards, the alias-only wearer, the other-ask controls and the six real-lane cells). The sweep pin `test/backlog1212-mode-release-reasons.test.ts` now folds each class set on every face; `test/name-readings-oracle.test.ts` carries the two new `wire` rows.
106
+
1
107
  ## 7.46.0 — 2026-10-08
2
108
 
3
109
  **Hard-breaking changes (the whole release):** two — ① `ToolPolicy.pathDenyRules` and the root export `toolPolicyPathDenyRules` are removed; a policy's path rules are `ToolPolicy.pathRules(anchors)` and `toolPolicyPathRules(policies, anchors)` (rows bound at the anchors of the leg the policy is installed on; `PathRuleDenyRow` stays the bound row's type, and `HandsToolkitOptions.pathDenyRules` — the library-direct deny table — is unchanged). ② `adjudicatePersistedPathRules(rules, call, views, anchorsOf, leads)`: the call's target is its views (`TargetViews`: `{ spellings, unreadable? }`, read by `callTargetViewsOf`), a row's anchors come from `anchorsOf(row)` (the person's rows: `persistedRowAnchors(scope, chainTop)`), and `leads` is where row heads lead in the judging leg (an empty map judges rows as spelled). **Who breaks:** a host that reads `policy.pathDenyRules` or calls `toolPolicyPathDenyRules` / `adjudicatePersistedPathRules` directly (three repos' code reads none of them — server only names `pathDenyRules` in comments and passes the member through `wrapToolPolicy`). **How to migrate:** a wrapper built with `wrapToolPolicy` needs nothing (it carries `pathRules`); a direct reader calls `toolPolicyPathRules(policies, { root, home })` with the anchors of the leg it builds a table for and takes the `deny` rows whose `faces` are non-empty; a direct `adjudicatePersistedPathRules` caller passes `{ tighten: { spellings: [target] } }`, `(row) => persistedRowAnchors(row.scope, { root, home })` and `new Map()`.
@@ -1,5 +1,5 @@
1
1
  import { type RuleToolNameVerdict } from "./tool-name-aliases.js";
2
- import { type ToolCallRequest } from "./tool-policy.js";
2
+ import type { ToolCallRequest } from "./tool-policy.js";
3
3
  import { type AllowRuleMember, type AllowRuleShadow } from "./allow-rule-shadow.js";
4
4
  import { type PathRuleBases, type PersistedRule } from "./permission-rule-model.js";
5
5
  import { type AskClass, type AssertAllKeysHandled, type PermissionMode, type PersistedRuleMandate } from "./ask-origin.js";
@@ -1,8 +1,7 @@
1
1
  import { heldBy } from "./tool-name-readings.js";
2
2
  import { isNamespacedCoveringRuleName, namespacedRuleNameCovers, parsePermissionRule } from "./permission-rules.js";
3
3
  import { judgeRuleToolName, splitNamespacedName } from "./tool-name-aliases.js";
4
- import { catalogToolNames } from "./tool-registry.js";
5
- import { COARSE_SHELL_TOOLS } from "./tool-policy.js";
4
+ import { catalogToolNames, isShellFaceToolName } from "./tool-registry.js";
6
5
  import { ALLOW_RULE_MEMBERS } from "./allow-rule-shadow.js";
7
6
  import { COMMAND_RULE_TOOL, READ_RULE_TOOL, findAdmittingRule, laneToolNamesOf, parseRuleText, programRunReachOf, ruleToolGrammarOf } from "./permission-rule-model.js";
8
7
  import { pathRuleLaneAnswer } from "./runner/permission-rule-lanes.js";
@@ -68,7 +67,7 @@ function readAllowEntry(raw, behavior, roster) {
68
67
  return row({ kind: "namespace", pattern: name });
69
68
  return row({ kind: "tool", name });
70
69
  }
71
- return row(COARSE_SHELL_TOOLS.includes(name) ? { kind: "shell", name } : { kind: "tool", name });
70
+ return row(isShellFaceToolName(name) ? { kind: "shell", name } : { kind: "tool", name });
72
71
  }
73
72
  if (name === COMMAND_RULE_TOOL) {
74
73
  const allowRead = parseRuleText(raw, behavior);
@@ -147,7 +146,7 @@ export function autoStripped(compiled, call) {
147
146
  case "shell":
148
147
  return true;
149
148
  case "tool":
150
- return call !== undefined && answersTo(call, (n) => COARSE_SHELL_TOOLS.includes(n));
149
+ return call !== undefined && answersTo(call, isShellFaceToolName);
151
150
  case "command":
152
151
  return shellContentStripped(shellRuleContent(compiled.rule));
153
152
  case "namespace":
@@ -406,7 +405,7 @@ export function inertEntryPhrase(e) {
406
405
  case "unprefixed_namespace":
407
406
  return 'tool_name: a "__" name with no protocol prefix';
408
407
  case "unmintable_segment":
409
- return `tool_name: a peer segment the engine never mints${spelling !== undefined ? `, write "${spelling}"` : ""}`;
408
+ return `tool_name: a namespaced name with a segment the engine never mints${spelling !== undefined ? `, write "${spelling}"` : ""}`;
410
409
  default: {
411
410
  const exhaustive = e.verdict;
412
411
  return exhaustive;
@@ -488,6 +488,9 @@ export interface ModeArbitrationRow {
488
488
  * tool's effect lands at a remote peer, outside any containment the leg declares). `hookAskFloor` marks the classes the reference's rule / safety check answers
489
489
  * `ask` for — an ask rule, a question the tool needs a person for, a dangerous removal, a protected or unresolvable write
490
490
  * target, the session transcript, another's shared artifact.
491
+ * A row is a class's reading on every face but the ones {@link RELEASED_ON_SHELL_FACE} names: `write_protection_unresolved`'s
492
+ * `bypassImmune` is the file face's (and every other face's) reading, and the shell face reads it released — folded where the
493
+ * ask's row is ({@link foldArbitrationRow}), listed per face by {@link releasableAskClassesFor}.
491
494
  */
492
495
  export declare const MODE_ARBITRATION: {
493
496
  readonly content_question: {
@@ -668,23 +671,37 @@ export declare const MODE_ARBITRATION: {
668
671
  };
669
672
  /** The fence over the arbitration table: `never` while every class has a row. */
670
673
  export type ArbitrationTableCoversEveryClass = AssertAllKeysHandled<Exclude<AskClass, keyof typeof MODE_ARBITRATION>>;
674
+ /**
675
+ * The FACE a question was put on — the second axis the permission mode reads a class under, beside the leg's read face
676
+ * ({@link ContainmentFace}). `shell`: the call's `command` argument is a shell command line the command grammar judges (the
677
+ * tool catalog's shell face — Bash, Monitor); `other`: every other call (the file tools, MCP, A2A, a caller's own tool). The set
678
+ * is closed; {@link askFaceOf} is the one derivation of a face from a tool name.
679
+ */
680
+ export declare const ASK_FACES: readonly ["shell", "other"];
681
+ export type AskFace = (typeof ASK_FACES)[number];
682
+ /** The face of a call, by its WIRE name (the name the gate resolved the call to — a release widens, so an alias a tool merely
683
+ * wears does not count): the catalog's shell face for exactly the names that declare the command grammar, `other` for every
684
+ * other name. */
685
+ export declare function askFaceOf(toolName: string): AskFace;
671
686
  /** The classes whose own mint puts the `requiresRealApproval` bit on the ask — a bit one of them explains is not
672
687
  * the bare bit `policy_rra` names. The mark classes (probe / write protection / tool marks / operator forced / the
673
688
  * shell floor) mint no bit and explain none — except `requires_user_interaction`, whose declaration the origin stamp
674
689
  * turns into the bit on every ask of the tool. */
675
690
  export declare const RRA_EXPLAINERS: ReadonlySet<AskClass>;
676
- /** The classes a permission mode can RELEASE (the `bypassImmune: false` rows) — the only words that can appear on
677
- * `GateDisposition.released.askClass`; every other class reaches a person under every mode. */
678
- export declare const RELEASABLE_ASK_CLASSES: readonly AskClass[];
691
+ /** The classes a permission mode can RELEASE for a call on `face`, in {@link ASK_CLASSES} order. The same list on every face
692
+ * but one class — {@link RELEASED_ON_SHELL_FACE}. A frozen list: a caller holding it cannot change the next read. A word
693
+ * outside {@link ASK_FACES} throws. */
694
+ export declare function releasableAskClassesFor(face: AskFace): readonly AskClass[];
679
695
  /**
680
696
  * The closed set of `reason` words a mode release carries (`GateDisposition.released.reason`, the `released` arm of
681
697
  * {@link Arbitration}): each is a releasable class the release record names on its own — `rule_unreadable`, a release
682
- * over a person's rule the call could not be read against. A release carries the first member (array order) its class
698
+ * over a person's rule the call could not be read against; `write_protection_unresolved`, a release (the shell face only,
699
+ * {@link RELEASED_ON_SHELL_FACE}) over a write target the judge could not resolve. A release carries the first member (array order) its class
683
700
  * set holds, and none when it holds no member. {@link arbitrateByMode} and {@link isModeReleaseReason} read one copy
684
701
  * of this list taken at load (a caller that writes into the exported array changes neither), so a new reason is one
685
702
  * word here.
686
703
  */
687
- export declare const MODE_RELEASE_REASONS: readonly ["rule_unreadable"];
704
+ export declare const MODE_RELEASE_REASONS: readonly ["rule_unreadable", "write_protection_unresolved"];
688
705
  export type ModeReleaseReason = (typeof MODE_RELEASE_REASONS)[number];
689
706
  /** Membership test for {@link ModeReleaseReason} — the word crosses the wire on `tool_end.gate.disposition.released.reason`. */
690
707
  export declare function isModeReleaseReason(v: unknown): v is ModeReleaseReason;
@@ -700,6 +717,8 @@ export interface AskClassFacts extends AskOriginFacts {
700
717
  readonly ancestorClasses?: ReadonlySet<AskClass>;
701
718
  /** The read face the leg this ask was raised on resolved ({@link ContainmentFace}); absent ⇒ none resolved (a leg with no hands). */
702
719
  readonly readFace?: ContainmentFace;
720
+ /** The tool face the question was put on ({@link askFaceOf} of the call's tool name); absent ⇒ the face-less reading, which is the `other` face's. */
721
+ readonly askFace?: AskFace;
703
722
  }
704
723
  /** The classification the arbitration reads: the whole set, the disclosed word (the set's first member in
705
724
  * {@link ASK_CLASSES} order) and the row folded across the set. */
@@ -735,10 +754,11 @@ export declare function decisionAskClassesOf(d: Extract<PermissionResult, {
735
754
  }>): Set<AskClass>;
736
755
  /** Fold a class set onto one row: `bypassImmune` and `hookAskFloor` by OR (any immune member keeps the ask; any floored
737
756
  * member floors a hook's ask), the three permissive switches by AND (one refusing member withholds the lane). An empty
738
- * set is `ordinary`. `readFace` is the face the ask was raised under: `roots` takes the classifier's lane from a set that
739
- * holds a class the narrowed face withholds from it — the one place the face enters, so every reader of `classifierEligible`
740
- * reads it already folded. */
741
- export declare function foldArbitrationRow(classes: ReadonlySet<AskClass>, readFace?: ContainmentFace): ModeArbitrationRow;
757
+ * set is `ordinary`. The two faces the ask was raised under enter HERE and nowhere else, so every reader of the folded row
758
+ * reads it already folded: `readFace` — `roots` takes the classifier's lane from a set that holds a class the narrowed face
759
+ * withholds from it; `askFace` — `shell` reads a class {@link RELEASED_ON_SHELL_FACE} holds as not bypass-immune. Absent
760
+ * faces read the table's own rows. */
761
+ export declare function foldArbitrationRow(classes: ReadonlySet<AskClass>, readFace?: ContainmentFace, askFace?: AskFace): ModeArbitrationRow;
742
762
  /**
743
763
  * The ONE class derivation: the decision half ({@link decisionAskClassesOf}) joined with the gate's own facts —
744
764
  * the question tool, an ancestor's mark, the org layer's two arms, the persisted lane's unreadable call, the tool
@@ -814,9 +834,12 @@ export type Arbitration = {
814
834
  * The ONE mode read point. `dontAsk` refuses every ask — one value for every class — but a hook's that no member of the
815
835
  * set floors (`hookAskFloor`: a hook's ask beside a rule's or a safety check's ask is refused, as the reference's full
816
836
  * pipeline refuses it; a hook's ask alone is the reference's forced decision, which never reaches that conversion);
817
- * `bypassPermissions` refuses a write whose protection could not be judged (a person cannot be asked, and
818
- * releasing would be looser than the allow the judge already refuses), keeps every immune class and releases the
819
- * rest; `acceptEdits` answers an ORDINARY ask itself on a call the caller vouches is one of the mode's file commands
837
+ * `bypassPermissions` refuses a write whose protection could not be judged where that class stays immune (the file
838
+ * face: a person cannot be asked, and releasing would be looser than the allow the write-protection tighten refuses there —
839
+ * the folded row says it, {@link foldArbitrationRow}), keeps every immune class and releases the rest: the shell face
840
+ * releases that class ({@link RELEASED_ON_SHELL_FACE}), and there the tighten turns an allow into the same question rather
841
+ * than a refusal (`writeProtectionTightenDecision` reads the face), so on neither face is the release looser than an allow;
842
+ * `acceptEdits` answers an ORDINARY ask itself on a call the caller vouches is one of the mode's file commands
820
843
  * (`call.fileCommand`, `accepted`); `plan`, `default` and `auto` hand every ask on (plan's own questions were raised by
821
844
  * its write gate; the reference implementation refuses nothing at this point under plan — auto's classifier reads the
822
845
  * row at its own station). Reads the class SET and the folded row — never the disclosed word.
@@ -1,3 +1,4 @@
1
+ import { isShellFaceToolName } from "./tool-registry.js";
1
2
  export const ASK_ORIGINS = Object.freeze(["content_question", "ancestor_marked", "org_unavailable", "org_rule", "rule_store_unavailable", "hook", "ask_rule", "denial_limit_fallback", "shell_gate_tighten", "safety_tighten", "policy"]);
2
3
  const ASK_ORIGIN_SET = new Set(ASK_ORIGINS);
3
4
  export function isAskOrigin(v) {
@@ -224,10 +225,26 @@ export const MODE_ARBITRATION = {
224
225
  unstated: { bypassImmune: false, classifierEligible: true, sandboxAdmission: false, ruleMayClear: true, hookAskFloor: false },
225
226
  ordinary: { bypassImmune: false, classifierEligible: true, sandboxAdmission: true, ruleMayClear: true, hookAskFloor: false },
226
227
  };
228
+ export const ASK_FACES = Object.freeze(["shell", "other"]);
229
+ export function askFaceOf(toolName) {
230
+ return isShellFaceToolName(toolName) ? "shell" : "other";
231
+ }
232
+ const RELEASED_ON_SHELL_FACE = new Set(["write_protection_unresolved"]);
233
+ function bypassImmuneOn(c, face) {
234
+ return MODE_ARBITRATION[c].bypassImmune && !(face === "shell" && RELEASED_ON_SHELL_FACE.has(c));
235
+ }
227
236
  const CLASSIFIER_WITHHELD_ON_ROOTS = new Set(["probe"]);
228
237
  export const RRA_EXPLAINERS = new Set(["dangerous_delete", "unverifiable_delete", "transcript_write", "org_rule", "governance_unavailable", "rule_unreadable", "denial_limit_fallback", "classifier_no_ruling", "ancestor", "artifact_shared_read", "requires_user_interaction"]);
229
- export const RELEASABLE_ASK_CLASSES = ASK_CLASSES.filter((c) => !MODE_ARBITRATION[c].bypassImmune);
230
- export const MODE_RELEASE_REASONS = Object.freeze(["rule_unreadable"]);
238
+ const RELEASABLE_BY_FACE = Object.freeze({
239
+ shell: Object.freeze(ASK_CLASSES.filter((c) => !bypassImmuneOn(c, "shell"))),
240
+ other: Object.freeze(ASK_CLASSES.filter((c) => !bypassImmuneOn(c, "other"))),
241
+ });
242
+ export function releasableAskClassesFor(face) {
243
+ if (!ASK_FACES.includes(face))
244
+ throw new TypeError(`releasableAskClassesFor: "${String(face)}" is not an ask face (${ASK_FACES.join(", ")})`);
245
+ return RELEASABLE_BY_FACE[face];
246
+ }
247
+ export const MODE_RELEASE_REASONS = Object.freeze(["rule_unreadable", "write_protection_unresolved"]);
231
248
  const MODE_RELEASE_REASON_SET = new Set(MODE_RELEASE_REASONS);
232
249
  export function isModeReleaseReason(v) {
233
250
  return MODE_RELEASE_REASON_SET.has(v);
@@ -278,14 +295,14 @@ export function decisionAskClassesOf(d) {
278
295
  out.add(c);
279
296
  return out;
280
297
  }
281
- export function foldArbitrationRow(classes, readFace) {
298
+ export function foldArbitrationRow(classes, readFace, askFace) {
282
299
  if (classes.size === 0)
283
300
  return MODE_ARBITRATION.ordinary;
284
301
  const withheld = readFace === "roots" && [...classes].some((c) => CLASSIFIER_WITHHELD_ON_ROOTS.has(c));
285
302
  let bypassImmune = false, classifierEligible = !withheld, sandboxAdmission = true, ruleMayClear = true, hookAskFloor = false;
286
303
  for (const c of classes) {
287
304
  const row = MODE_ARBITRATION[c];
288
- bypassImmune ||= row.bypassImmune;
305
+ bypassImmune ||= bypassImmuneOn(c, askFace);
289
306
  classifierEligible &&= row.classifierEligible;
290
307
  sandboxAdmission &&= row.sandboxAdmission;
291
308
  ruleMayClear &&= row.ruleMayClear;
@@ -340,7 +357,7 @@ export function classifyAsk(d, facts) {
340
357
  if (classes.size === 0)
341
358
  classes.add("ordinary");
342
359
  const askClass = ASK_CLASSES.find((c) => classes.has(c));
343
- return { classes, askClass, row: foldArbitrationRow(classes, facts.readFace) };
360
+ return { classes, askClass, row: foldArbitrationRow(classes, facts.readFace, facts.askFace) };
344
361
  }
345
362
  export function headlessDenialMessage(toolName, askMessage) {
346
363
  return `Permission for this tool use was denied: it requires interactive approval, and permission prompts are not available in this session (nobody can be asked to approve "${toolName}" in this run — headless auto-deny). The action was NOT performed. Do not claim it succeeded, and do not retry it in this session — report the limitation to the user, or suggest an alternative. What was requested: ${askMessage}`;
@@ -379,7 +396,7 @@ export function arbitrateByMode(mode, c, disclosure, call) {
379
396
  case "dontAsk":
380
397
  return has("hook") && !c.row.hookAskFloor ? { kind: "ask" } : { kind: "denied_by_mode", mode, askClass: c.askClass, classes };
381
398
  case "bypassPermissions":
382
- if (has("write_protection_unresolved"))
399
+ if (has("write_protection_unresolved") && c.row.bypassImmune)
383
400
  return { kind: "denied_by_mode", mode, askClass: c.askClass, classes };
384
401
  if (c.row.bypassImmune)
385
402
  return { kind: "ask" };
@@ -1231,7 +1231,7 @@ export interface CheckpointState {
1231
1231
  * [c209-C] Q5 — the listing frames' announced NAME-SETS at suspend (agent types / skills the model
1232
1232
  * has actually been shown this run, committed on first-frame delivery or intact boundary survival).
1233
1233
  * A resume leg seeds its producers from these names and DIFFS the current roster/skill set against
1234
- * them, so cross-leg drift (config-center roster change between suspend and resume) is announced as
1234
+ * them, so cross-leg drift (web-admin roster change between suspend and resume) is announced as
1235
1235
  * a delta frame at the first boundary instead of silently swallowed — the exact event the listing
1236
1236
  * eviction from the cached prefix exists to serve. Names only, never contents (a description change
1237
1237
  * on a surviving name is deliberately not re-announced — name-keyed diff, agent_listing parity).
@@ -834,11 +834,14 @@ export interface EngineNotice {
834
834
  * boot; deletes in it will fail until the operator restores the bit. Audience `"operator"`; `detail: { layers,
835
835
  * remedy }` — layer directory names and the one command. No session. Once per boot.
836
836
  * - `"server.model_unreachable"` — a model call of this leg ended in error and its last request failed to
837
- * connect. Audience `"user"`; `detail: { code, targetHost, remedy, sessionId, proxyInUse? }` — `code` one of
838
- * `UND_ERR_CONNECT_TIMEOUT` / `ECONNREFUSED` / `ENOTFOUND` / `EAI_AGAIN` / `EHOSTUNREACH` / `ENETUNREACH`;
837
+ * connect. Audience `"user"`; `detail: { code, targetHost, remedy, sessionId, taskId?, proxyInUse? }` — `code`
838
+ * one of `UND_ERR_CONNECT_TIMEOUT` / `ECONNREFUSED` / `ENOTFOUND` / `EAI_AGAIN` / `EHOSTUNREACH` / `ENETUNREACH`;
839
839
  * `targetHost` the model endpoint's `host:port`; `remedy` the constant `"check VPN / proxy; restart if the
840
- * proxy changed since boot"`; `proxyInUse` present only when that request went through a proxy, its value the
841
- * variable's name (`HTTP_PROXY` or `HTTPS_PROXY`), never the proxy address. No `runId`. Once per leg per target.
840
+ * proxy changed since boot"`; `taskId` (server 7.121.0+) the run resource id of the leg this notice belongs to —
841
+ * absent on a streaming leg that has no run store, and the SAME value on a resumed leg and its original (a
842
+ * per-leg dedup folds the two into one line); `proxyInUse` present only when that request went through a proxy,
843
+ * its value the variable's name (`HTTP_PROXY` or `HTTPS_PROXY`), never the proxy address. No `runId`. Once per
844
+ * leg per target.
842
845
  *
843
846
  * Deliberately NOT a notice family: brain retry/reconnect liveness (a rate limit, a 5xx, a
844
847
  * transient network failure being retried). Those are per-attempt liveness frames with their own
@@ -2,7 +2,7 @@ import { inlineUntrusted } from "./untrusted-text.js";
2
2
  import { markTightenMessage } from "./safety-axis-vocab.js";
3
3
  import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
4
4
  import { normalizeReadRootCandidate } from "./read-root-candidate.js";
5
- import {} from "./runner/gate-exit.js";
5
+ import { passAskFace } from "./runner/gate-exit.js";
6
6
  import { traceHookCrash } from "./gate-crash-trace.js";
7
7
  import { writeProtectionMandated, writeProtectionTightenDecision, writeProtectionUnresolved } from "./write-protect.js";
8
8
  import { createGateDeadline, awaitWriteProtectionJudgment, gateWaitFailureOf, withGateTimeout } from "./gate-bounded-await.js";
@@ -167,7 +167,7 @@ export async function runGateFold(pass) {
167
167
  pass.writeProtected = true;
168
168
  if (verdict !== undefined && writeProtectionUnresolved(verdict))
169
169
  pass.writeProtectionUnresolved = true;
170
- const decision = verdict === undefined ? undefined : writeProtectionTightenDecision(toolName, verdict);
170
+ const decision = verdict === undefined ? undefined : writeProtectionTightenDecision(toolName, verdict, passAskFace(pass));
171
171
  if (decision !== undefined && writeProtectionStanding === "allow") {
172
172
  pass.decision = decision;
173
173
  if (decision.action === "ask")
@@ -17,7 +17,7 @@ import { callFaceSeat } from "./tool-roster.js";
17
17
  import { heldBy, wireOf } from "./tool-name-readings.js";
18
18
  import { asksUnstated, modeAcceptsFileCommand, permissionSeatAt, unstatedAskMessage } from "./mode-policy.js";
19
19
  import { ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON } from "./permission-rule-org.js";
20
- import { exitGate } from "./runner/gate-exit.js";
20
+ import { exitGate, passAskFace } from "./runner/gate-exit.js";
21
21
  import { readOrgRuleLane, readPersistedRuleLane } from "./rule-lane-reads.js";
22
22
  import { traceHookCrash } from "./gate-crash-trace.js";
23
23
  import { createGateDeadline, awaitWriteProtectionJudgment, withGateTimeout } from "./gate-bounded-await.js";
@@ -52,6 +52,7 @@ export async function runGateLanes(pass) {
52
52
  ...(input.toolMarkSources !== undefined ? { toolMarkSources: input.toolMarkSources } : {}),
53
53
  ...(input.a2aIsolatePeers === true ? { a2aIsolatePeers: true } : {}),
54
54
  ...(readFaceNow !== undefined ? { readFace: readFaceNow } : {}),
55
+ askFace: passAskFace(pass),
55
56
  });
56
57
  const classifyStanding = (unstamped, facts) => (pass.askClassification = classifyAsk(unstamped, facts));
57
58
  let orgTightenCount = 0;
@@ -343,9 +343,11 @@ export type GateDisposition = {
343
343
  /**
344
344
  * A permission MODE released a surviving ask — the allowed disposition's account of the question it did not put
345
345
  * to anyone: the mode, the disclosed class word (`askClass`, the first member of the folded set in `ASK_CLASSES`
346
- * order — one of {@link RELEASABLE_ASK_CLASSES}), the whole set, and the origin / mandate words the card would
346
+ * order — one of {@link releasableAskClassesFor} on the call's face), the whole set, and the origin / mandate words the card would
347
347
  * have shown. `reason` is a member of {@link import("./ask-origin.js").MODE_RELEASE_REASONS} (`isModeReleaseReason`):
348
- * `"rule_unreadable"` marks a release over a rule the call could not be read against; absent, the release names none.
348
+ * `"rule_unreadable"` marks a release over a rule the call could not be read against, `"write_protection_unresolved"` a release (the
349
+ * shell face) over a write target the judge could not resolve; absent, the release names none. The record carries no face, so the
350
+ * screen asks that SOME face releases the class.
349
351
  */
350
352
  export interface ModeRelease {
351
353
  mode: PermissionMode;
@@ -1,4 +1,4 @@
1
- import { isAskClass, isAskOrigin, isModeReleaseReason, isPermissionMode, RELEASABLE_ASK_CLASSES } from "./ask-origin.js";
1
+ import { isAskClass, isAskOrigin, isModeReleaseReason, isPermissionMode, ASK_FACES, releasableAskClassesFor } from "./ask-origin.js";
2
2
  import { isAutoModeFallbackCause } from "./auto-mode.js";
3
3
  export const SETTLEMENT_KINDS = Object.freeze([
4
4
  "human_allowed",
@@ -139,7 +139,7 @@ export function screenGateOutcome(g) {
139
139
  defects.push("I7: a mode release carries no settlement — nobody answered the ask it released");
140
140
  if (r.mode !== "bypassPermissions")
141
141
  defects.push(`I7: a mode release names mode "${String(r.mode)}" — only bypassPermissions releases`);
142
- if (!isAskClass(r.askClass) || !RELEASABLE_ASK_CLASSES.includes(r.askClass))
142
+ if (!isAskClass(r.askClass) || !ASK_FACES.some((face) => releasableAskClassesFor(face).includes(r.askClass)))
143
143
  defects.push(`I7: released.askClass "${String(r.askClass)}" is not a releasable class`);
144
144
  if (!Array.isArray(r.classes) || r.classes.length === 0 || !r.classes.every((c) => isAskClass(c)))
145
145
  defects.push("I7: released.classes must be a non-empty list of ask classes");
@@ -9,7 +9,7 @@ import type { ToolContentOrigin, ToolEffect } from "./tool-spec.js";
9
9
  import type { ToolFamily, ToolPathTarget, ToolRenderHints, ToolRuleFace } from "./tool-face.js";
10
10
  /** Definition of an MCP server to connect to for the duration of one task, then discard. */
11
11
  export interface McpServerSpec {
12
- /** Stable name; tool names are namespaced as `<name>__<tool>` to avoid collisions. */
12
+ /** Stable name; tool names are namespaced as `mcp__<server>__<tool>` (the protocol prefix, then this name as the peer segment) to avoid collisions. */
13
13
  name: string;
14
14
  /**
15
15
  * Where this declaration CAME FROM, in the declaring side's own words (a shell's `plugin` / `user` /