@sema-agent/core 7.14.0 → 7.15.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 (33) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/core/ask-origin.d.ts +2 -2
  3. package/dist/core/checkpoint-store.d.ts +14 -15
  4. package/dist/core/hooks.d.ts +4 -3
  5. package/dist/core/runner/contracts.d.ts +2 -2
  6. package/dist/core/runner/permission-rule-lanes.d.ts +3 -1
  7. package/dist/core/runner/permission-rule-lanes.js +1 -1
  8. package/dist/core/runner/prepare-defer-classify.d.ts +1 -1
  9. package/dist/core/runner/prepare-defer-classify.js +12 -10
  10. package/dist/core/runner/prepare-gate-stations.d.ts +3 -4
  11. package/dist/core/runner/prepare-gate-stations.js +2 -2
  12. package/dist/core/runner/prepare-hands-readface.d.ts +8 -8
  13. package/dist/core/runner/prepare-hands-readface.js +27 -29
  14. package/dist/core/runner/prepare-inherited-gate.d.ts +2 -2
  15. package/dist/core/runner/prepare-park-ask.d.ts +5 -6
  16. package/dist/core/runner/prepare-park-ask.js +3 -3
  17. package/dist/core/runner/prepare-policy-chain.d.ts +2 -2
  18. package/dist/core/runner/prepare-policy-chain.js +2 -2
  19. package/dist/core/runner/prepare-task.js +5 -7
  20. package/dist/core/runner/tool-defer-gate.d.ts +86 -0
  21. package/dist/core/runner/tool-defer-gate.js +57 -0
  22. package/dist/core/runner/tool-disclosure.d.ts +0 -36
  23. package/dist/core/runner/tool-disclosure.js +0 -43
  24. package/dist/core/runner-deps.d.ts +16 -9
  25. package/dist/core/task-spec.d.ts +8 -2
  26. package/dist/core/tool-spec.d.ts +7 -4
  27. package/dist/core/types.d.ts +1 -1
  28. package/dist/index.d.ts +2 -2
  29. package/dist/index.js +1 -1
  30. package/dist/tools/fs/fs-bash.d.ts +41 -0
  31. package/dist/tools/fs/fs-bash.js +86 -29
  32. package/package.json +1 -1
  33. package/test/export-surface.snapshot.json +5 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.15.0 — 2026-09-11
4
+
5
+ ### Internal — abstraction unification pass over the 7.15.0 merges (no behaviour change; exports +1 type)
6
+ - Added · `ShellGateDoctrine` (`"off" | "classify" | "always"`) is exported from the package root — the one alias behind `TaskSpec.shellGate`, `RiskDescriptor.shellGateDoctrine` and the inherited-gate seat (nine inline spellings retired; the type domain is unchanged).
7
+ - Internal · the shell seat's two provenance bits became one membership set (`shellGatedTools`) read by the park mint, the gate station and the rule-offer factory; the two shell probe seats read the command through one single-read helper and the deny judge's voice through one predicate (`gate:single-mint` row `denyJudgeSpoke`); `tool-defer-gate.ts` joins the runner's machinery stratum. Pins: `test/backlog706-shell-seat-single-spelling.test.ts` (7), the Monitor twin of the durable-descriptor pin in `test/backlog696-…`.
8
+ - Not changed (reviewed, kept) · the sandbox-boundary predicate keeps `toolName.includes("__")` — the separator, not the protocol table's ownership, is the containment fact (a `__`-bearing name mounted after the scan-time reservation is unowned yet uncontained); the containment abstraction (mount-time execution provenance) is ticketed.
9
+
10
+ ### Narrowing — the read boundary is judged on every shell call under every `shellGate` doctrine (#696; P1 design; CC 2.1.250 form: the read boundary is a path-layer property judged on every Bash call, the permission mode disposes only of the residual; sema keeps its deny table — the registered divergence — and its opt-out operand scan; @server @cli @test)
11
+ - **Before.** The read boundary (built-in deny table + roots containment) rode the `shellGate` doctrine's probe: `shellGate` absent / `"off"` mounted NO probe on Bash, so `cat .claude/settings.json` / `cat ~/.ssh/id_rsa` ran with zero asks and the bytes reached the session record while `Read` of the same path refused typed. Two read faces of one process gave two answers about one path, and the shell face's judgment hung on a switch unrelated to the boundary. Three rules: the structured faces always judge; the shell face judges only under `"classify"`; `"always"` absorbs the question into its blanket ask.
12
+ - **After (two rules).** ① Every read face judges the read boundary — structured faces refuse (`read_path_denied`), the shell face gets a MANDATED ask (neither a stored allow rule nor the read-only arm retires it; a person's yes releases the read). ② `shellGate` disposes only of the RESIDUAL shell risk: `"off"` (the default) installs no shape classifier — writes, egress and unknown commands go to the ordinary policy/hook chain; `"classify"` auto-allows a provably read-only in-boundary command and asks for everything else; `"always"` asks for every command. Mechanically: the shell seat (`prepare-hands-readface.ts`, `installShellSeat`) puts Bash and Monitor on the `"maybe"` tier with a probe under EVERY doctrine — `bashReadBoundaryProbe` (new named seat, `src/tools/fs/fs-bash.ts`; export +1) under `"off"`, `bashReversibilityProbe` (= boundary ∘ shape classifier) under `"classify"`; under `"always"` the tier is the mandate and no probe runs. The boundary mandate is one shared function on both seats (`readBoundaryMandate`: deny-listed operand / recursive walk under the deny judge / out-of-root operand). A read face's refusal is ATTRIBUTED (`attributeRefusal`): the face reports one sentence, so the same face is asked again with the deny judge removed and containment opened, and once more with no boundary — a changed or lifted sentence means the boundary objected (mandated), an unchanged sentence that no boundary lifts means the grammar refused (the doctrine's residual), anything else means the walk could not read an operand (a plain fail-closed ask). A loop body reading a deny-listed in-root file — or `cd`-ing into a deny-listed directory — is mandated on both seats (under `"classify"` it was an un-mandated, rule-clearable ask), and a body that also carries a glob does not lose the declared objection. A `~`-rooted operand stays UNRESOLVABLE to the walk (no home is wired into the shell read boundary — deliberately: `NodeExecutionEnv.homeDir` is the host's `os.homedir()` even under a `shellEnv.HOME` override, so resolving `~` against it would vouch a read of a directory the shell never expands to; the adapter's declaration is the contract to fix first, P1 candidate below): the seat answers a plain ask for it, exactly as the classify seat always did — see the next paragraph for what the gate then does with that ask.
13
+ - **BREAKING (behaviour narrowing, one lane).** Under `shellGate` absent / `"off"`, a Bash or Monitor command that reads a deny-listed path (`cat .claude/settings.json`, `cat ~/.ssh/id_rsa`, `head`/`sed -n`/`wc`/`grep` of one), a path outside the session's roots, or a RECURSIVE form whose walk reaches into the root (`grep -r … .`, `find .`, `ls -R`, `du .` — the walk reads the deny-listed files inside the root; the same mandated ask `"classify"` already gave, with cause `shell.recursive_read_unbounded`) goes from ZERO asks to ONE mandated ask (rule-clearable: no; auto-mode classifier: no — the ask is structural). Affected lanes: `permissionMode: "bypassPermissions"` through the shell (server's `shellGateForMode` writes `"off"` for that mode only) and a direct integration that states no `permissionMode` / no `shellGate`. NOT affected: the shell's ordinary lanes — server's `shellGateForMode` writes `"classify"` for every other mode and the cli always passes a mode, so those lanes already asked for exactly these commands. An in-root non-listed read, a no-operand command (`echo`, `pwd`) and every non-reader (`rm`, `curl`, `git`, `npm`) keep zero asks under `"off"`.
14
+ - BREAKING (consequence on the same lane, disclosed, not fixed here — ticket below) · under `shellGate` absent / `"off"`, Bash and Monitor now sit on the `"maybe"` tier, and `gate-lanes.ts` derives a park's safety axis from the STATIC tier: a POLICY or hook ask on Bash (`npm run build` under a `defaultAction:"ask"` policy) that the boundary seat vouched now parks as the non-budgetable `irreversible_ask` kind with `safety.irreversible: true` and the `shell` axis on its descriptor, where it parked as a budgetable `human` ask before (`"classify"` has always parked it this way). A budget resolver auto-approves fewer asks on this lane. The fix (the safety axis read off THIS call's seat judgment rather than the static tier) touches `gate-lanes.ts` and the classify lane and is its own ticket.
15
+ - BREAKING · `RiskDescriptor.shellGateDoctrine` widens from `"classify" | "always"` to the whole `TaskSpec.shellGate` vocabulary `"off" | "classify" | "always"` (a shell-gated durable park under the `"off"` doctrine now persists `"off"` where it used to omit the key); a consumer that pinned the two-member set re-pins.
16
+ - Wire-observable (additive, per-key rows in `docs/wire-consumption/unreleased-696.md`): under `"off"` the tool roster's Bash/Monitor rows carry `irreversibility: "maybe"` + `reversibilityProbe: true`; a Bash/Monitor ask under `"off"` carries `riskAxes.irreversible: true` (the seat tier's reading, as under `"classify"`), grades as the `shell` axis (severity 3, never `irreversible` 4), attributes `tightenedBy: "shell_gate"` / deny `source: "shellGate"`, and its card's `ruleOffersAbsence` reads `"mandated"` / `"shadowed"`.
17
+ - Disclosure text · the `shell-gate-off` config note (phase `config`, classification `shell-gate-off`) now reads "the read boundary is still judged on every command (a deny-listed or out-of-root read asks; the ask is mandated), but no residual shell-risk classifier is installed …" — the previous "no shell safety-axis fold applies" would be false.
18
+ - **Not changed (measured, disclosed — and the headline example's `~` spelling is IN this list).** (i) An operand the walk cannot RESOLVE or DECIDE — `cat ~/.ssh/id_rsa` with no home declared, `cat .s?/x`, `cat *.log` (an unexpanded glob) — gets a plain, un-mandated ask from BOTH probe seats, and the gate's read-only shell arm (7.11.0, a reader of command NAMES: `cat` is read-only) then CLEARS that ask: measured end-to-end at the base commit under `"classify"` and after this change under both doctrines, ZERO asks, the command runs. The `~` spelling of the ticket's own example therefore still runs unasked; the absolute spelling (`cat /Users/<u>/.ssh/id_rsa`) is resolved, declared and mandated. This is baseline behaviour (RB-474's "undecided ⇒ ask" and the `~` fail-closed have been cleared by that arm since 7.11.0), pinned here as a tripwire; the candidate fix — the unread arms joining the mandated family under the `probe_unanswered` doctrine ("the boundary is unknown, which no standing yes may stand in for") — makes every glob read ask un-clearably on the shell's ordinary lanes and is a ruling, not this car's. (ii) The boundary seat's reach is the read faces' reach and nothing wider: a read — deny-listed OR out-of-root — hidden in a shape the faces refuse whole (`cat .claude/settings.json | base64`, `cat /etc/passwd | base64` — an unlisted segment; `echo $(cat x)` — a substitution; `cat $DIR/x` — a variable operand; `env cat ~/.ssh/id_rsa` — a launcher; `/bin/cat /etc/passwd` — a path-prefixed program) is not seen under `"off"` and runs with zero asks; `"classify"` asks for every one of those shapes (as before — mandated where its text-level containment gate recognizes the reader, `env cat` / `/bin/cat` / the piped out-of-root read). The same residual reaches TWO shapes that are plain listed readers: `tail -f <path>` (follow mode) and a special file (`cat /proc/1/environ`) — the compound face refuses them BEFORE it walks their operands, so no structural finding exists for the boundary seat to read: `tail -f /etc/passwd` and `tail -f .claude/settings.json` run with zero asks under `"off"` (Monitor's own idiom is `tail -f`), while `"classify"` mandates them through the text gate. Fix: the compound face keeps walking the listed readers' operands on an availability / special-file refusal and surfaces `readDenied` / `outOfRootRead` beside the `reason` (a scan-form change in `bash-readonly-classifier.ts`; `reason` and the classify verdict unchanged; the second form of #714 — a judge refusing before it walks the operands leaves no finding for a downstream seat to read). The boundary seat deliberately does NOT consult that text-level gate (`classifyOutOfRootReadGate`): it re-scans the command under conservative assumptions that may ADD a mandate to an existing ask but would ORIGINATE false asks on non-readers (`env rm /tmp/cache`, `env echo /etc/passwd`, `pushd . && echo hello` — measured, all zero asks under `"off"`). Closing the in-shape gap is a classifier scan-form change (ticket #663, the two read-only classifiers' unification). `run_in_background` is not the boundary's question (WHEN, not WHERE): a backgrounded in-root read runs under `"off"`; a backgrounded deny-listed read is mandated under both doctrines. The containment judgment is LEXICAL against the CANONICAL roots (the classifier is pure, no I/O — its standing contract): an absolute operand spelled through a symlink alias of the root (macOS `/var/…` for `/private/var/…`, `/tmp/…` for `/private/tmp/…`) reads as out-of-root and asks (mandated) — pre-existing under `"classify"`, now reachable in the `"off"` lane too; two Monitor test fixtures that watched a `tail -f /var/folders/…` path under a canonical root were re-spelled through `realpathSync` (the convention the read-face fixtures already follow). A relative operand resolves against the live tracked cwd and is unaffected.
19
+ - **Review rounds (adversarial, three).** Round 1 — five findings, all reproduced: three same-root (the boundary-only seat consumed the text-level containment gate, which mis-resolved a loop body's `cd`, originated asks on launchers, and let a `cd`-dependent refusal read as grammar) → adopted as one change (structural members + answer-comparison attribution); one ruled out of this car (the adapter's `homeDir` ignores a `shellEnv.HOME` override — the seat therefore takes no home; #712); one ledgered (policy asks park as `irreversible_ask` under `"off"` — static-tier safety axis; #713). Round 2 — two findings: the attribution's remaining blind spot (a deny-listed read masked by a glob in the same loop body; an unmodelled `cd -` after an out-of-root read) → adopted (attribute by the face's sentence, not by presence); the early-refusal residual (`tail -f`, `/proc/…`) → disclosed, #714 second form. Round 3 (narrow, the attribution alone) — approve, no findings.
20
+ - Pins: `test/backlog696-read-boundary-any-doctrine.test.ts` (28 — on the real load path Runner prepare → NodeExecutionEnv → gate: G-a/G-b default-doctrine mandated asks with a clean record and `ruleOffersAbsence` `"shadowed"` under a covering rule (G-e), G-c/G-d/`touch` zero asks, tier-off and open-face controls (G-f), `"always"` asks (G-g), `"classify"` ≡ default on G-a/b/c/e (G-h), Monitor pair, the disclosure text, an unattended default-doctrine run parking durably with `shellGateDoctrine: "off"` on the persisted descriptor; the boundary seat's three answers at the unit face incl. the poll-loop attribution, the deny-plus-glob body, `cd -`, the `~`-without-home form and the two early-refusal tripwires). `test/backlog685-agent-config-read-deny.test.ts` "declared scope" pin flipped (off ⇒ one mandated ask, rule does not clear, control zero asks). Mutations (10, each red ≥1 in the pin set): restore the classify-only probe guard; read the walk's unresolvable refusal as a shape refusal; attribute a refusal by presence alone instead of by the face's sentence; let the text-level containment gate originate asks on the boundary seat; vouch the undecided arm; drop the declared mandate; un-attribute the poll-loop deny; skip the Monitor seat; restore the `!== "off"` doctrine filter on the descriptor.
21
+
22
+ ### Behaviour — deferred tool disclosure is one threshold gate over the engine's candidates; the caller's `deferTools` list stays literal (#695; ruling 「效果与 CC 一致;更差则对齐」; CC 2.1.250 `eDn`/`XD` form for the gate and its measure; @server @cli @test)
23
+ - **Before.** Every remote-protocol tool (MCP and A2A), every `ToolSpec.defer: true` and every self-declared built-in (the shared-memory pair) was deferred UNCONDITIONALLY — on a two-tool MCP server the first request already carried placeholders plus an injected `ToolSearch`. `deferMode: "auto"` was a second, additive arm: it swept the caller's undeclared tools in when the whole inline face (pinned schemas included) exceeded 10% of the context window. Measured on three roster shapes (264 live runs, deepseek-v4-flash), the unconditional arm cost 16–20% more total input than inlining the same tools; CC keeps the identical mechanism behind a 10% threshold measured on the deferrable set.
24
+ - **After (two rules).** ① The caller's list is literal: `TaskSpec.deferTools` names defer on every request and are not measured; `alwaysLoad` (`ToolSpec.alwaysLoad`, `TaskSpec.alwaysLoadTools`, the server's `_meta["anthropic/alwaysLoad"]`) pins inline and is judged first. ② The engine's CANDIDATES — `ToolSpec.defer: true`, every remote-protocol name, self-declared built-ins, and under `deferMode: "auto"` every caller spec — sit behind ONE gate, all or nothing: deferred iff their inlined size (name + description + schema, chars ÷ 3) reaches `floor(contextWindow × 10%)` tokens; below that they ship inline in full and no `ToolSearch` is mounted. Only the candidates are measured — the caller's list, pins and non-candidates weigh nothing (CC `XD()` filter-then-count). `"auto"` is a candidate-WIDENING knob, not a second gate; engine built-ins the deployment did not name are candidates under no setting; a `deferMode` outside `"auto"`/absent refuses `config.defer_mode_invalid` (loud, no fallback). The comparison is `≥` (CC `p >= E`); a zero window is a zero threshold.
25
+ - BREAKING · **the default first request changes shape**: remote-protocol tools, `defer: true` tools and the shared-memory pair ship INLINE (full schema, no `ToolSearch`) whenever their combined inlined size is under 10% of the model's context window — previously they were placeholders on every request. A consumer that pinned "an MCP tool is always a placeholder" / "`ToolSearch` is present whenever an MCP server is configured" re-pins on the threshold (or names the tool in `deferTools`, which is unchanged). `deferMode: "auto"` deployments: the trip measure is now the candidate set only — a face heavy in PINNED or `deferTools` bytes no longer trips the gate.
26
+ - **Rules 8 → 2.** Gone: the four unconditional sources as separate rules, the auto candidate sweep as an extra arm, the inline-face measure, the `window > 0` guard, the strict `>` trip. Left: the literal list (①) and the one gate (②). The gate lives in its own module, `src/core/runner/tool-defer-gate.ts` (`deferralGate` → `{ explicit, gated, gatedChars, thresholdChars, open }`, `classifyDeferred`, `DEFER_THRESHOLD_FRACTION`, `DEFER_MODE_INVALID_CODE`); `tool-disclosure.ts` keeps the placeholder / `ToolSearch` mechanics.
27
+ - **Estimator note (honest reading).** The engine measures chars ÷ 3 (the mid-stream budget constant), not a tokenizer: on the measured `std` roster the 12 declared tools read 11 595 chars ≈ 3 865 estimated tokens against a 3 276-token threshold (32k window) and the gate OPENS, where the live API count of the same tools (2 842 tokens) would keep it shut; `big` (64k) and `fat` (128k) windows stay shut either way. CC's own char fallback (2.5 chars/token) would open too. Pinned as measured in `test/backlog695-defer-gate-alignment.test.ts`.
28
+ - **Review round 1 (adversarial; one REAL finding adopted, one confirmed-and-already-in-hand).** ① The materialized MCP roster also carries the engine's own global resource helpers (`ListMcpResourcesTool` / `ReadMcpResourceTool` / `ReadMcpResourceDirTool`); read as "every name on `mcp.tools`" they entered the remote-candidate set — measured, and deferred when the gate opened (before this change they were deferred unconditionally with the rest). Remote now means a name the protocol table minted (`protocolOf(name)` — `mcp__…` / `a2a__…`), so the helpers are what they are: engine built-ins, never candidates, never measured (pinned on a resource-capable stdio server, gate shut and gate open). ② Ten placeholder-mechanics pins outside the derived set relied on unconditional deferral — re-pinned with the ballast candidate (below).
29
+ - **Review round 2 (adversarial; two REAL test-precondition findings adopted).** ① The registry-exhaustion pin (CC207 P1-4, `tool-disclosure-integration.test.ts`) had gained the ballast as a third placeholder the script never activated, so "every deferred tool activated" was no longer reached — the two tools now defer through the literal `deferTools` list, exhaustion is reached in two selects. ② The RB-414 stale-schema pin (`rb422-deferred-directcall-hardening.test.ts`) had fallen off the placeholder path (a one-tool remote roster sits under the threshold, so the direct-call resolver under test was never exercised) — the remote tool is now on the literal list and its placeholder is asserted before the refresh and immediately before the direct call. Round 2 confirmed the round-1 helper-provenance fix and the gate invariants.
30
+ - **Fail-open reverse check — what still defers below the threshold?** Only the caller's own `deferTools` list (by design: an intentional deferral is not a size question; the shell relies on it to keep `Workflow` out of the model's view). Nothing else: no candidate, no built-in, no undeclared tool.
31
+ - Pins: `test/backlog695-defer-gate-alignment.test.ts` (the three-shape roster: default gate readings per shape, auto widening per shape, auto below its widened threshold, the face-wide-measure mutation guard; real load path with a stdio MCP server: below-threshold inline + no ToolSearch, `deferTools` literal beside an inline sibling, small-window placeholders + `alwaysLoadTools` pin); `test/tool-disclosure.test.ts` (sources, literal list disjoint from candidates, auto widening + measure, candidate-only measure, `≥` boundary both sides, bad value, zero window, invariants, RB-400-a); `test/runner-support-coverage.test.ts`; `test/rb422-deferred-directcall-hardening.test.ts` DD-4 (a pin's bytes never open the gate; small-window control); `test/public-knob-liveness.test.ts` (auto through the Runner). Re-pinned to the gate (small window opens it): `mcp.test.ts` RB-400-a e2e, `shared-memory-mount.test.ts`, `memory-tools-mount.test.ts`, `prepare-defer-classify-transcript.test.ts` (plus the roomy-window inline control), `tool-materialize-static.test.ts`, `policy-name-audit.test.ts`, `deferred-materialize-constrained-decoding.test.ts`, `backlog126-definetool-axis-carry.test.ts`.
32
+
3
33
  ## 7.14.0 — 2026-09-11
4
34
 
5
35
  ### Narrowing — the agent's own key-bearing settings files are refused on the structured read faces by default (#685; P0; ruling B: the session record stays verbatim, the fix is on the READ face; sema form — CC 2.1.250 has no read-face deny table, the table itself is the registered divergence; @server @cli @test)
@@ -234,8 +234,8 @@ export declare function classifierMayAnswer(origin: AskOrigin): boolean;
234
234
  * · `egress` — the tool's own external-write mark, judged next: it is the tool's declaration even
235
235
  * when the coarse doctrine also installed a shell tier on the same seat;
236
236
  * · `shellGated` + tier `"always"` — the operator's per-call confirmation doctrine
237
- * (`"operator_always"`); the classify doctrine installs `"maybe"`, and THOSE asks stay the rule
238
- * lane's home turf (`undefined`) — that is the don't-ask-again main case, and the per-call member
237
+ * (`"operator_always"`); the shell seat installs `"maybe"` under the off and classify doctrines,
238
+ * and THOSE asks stay the rule lane's home turf (`undefined`) — that is the don't-ask-again main case, and the per-call member
239
239
  * above is deliberately the ONLY thing that carves a mandate out of it;
240
240
  * · a tool's OWN `"always"`/`"maybe"` irreversibility tier without the doctrine (`"tool_marks"`).
241
241
  *
@@ -8,7 +8,7 @@ import type { RepairBundle } from "../agents/repair-loop.js";
8
8
  import type { WorkspaceHandle } from "./remote-env.js";
9
9
  import type { ConsolidationNote } from "./runner/memory-consolidation.js";
10
10
  import type { SessionPermissionRules } from "./session-policy-store.js";
11
- import type { NestedUsageAccum, TaskResult } from "./types.js";
11
+ import type { NestedUsageAccum, ShellGateDoctrine, TaskResult } from "./types.js";
12
12
  import type { ActiveWorktreeSession } from "../internal/harness-types.js";
13
13
  /**
14
14
  * design/45 — the **durable-checkpoint** primitive: cross-process / resumable `suspend`/`resume`.
@@ -120,10 +120,9 @@ export interface RiskDescriptor {
120
120
  /** ToolEmu-style severity tier 1..5 (5 = most severe). The inbox sorts DESC by this. Deterministic — the
121
121
  * pure {@link riskSeverity} of {@link axes}. The ORDERING is what matters (the inbox's triage key). */
122
122
  severity: 1 | 2 | 3 | 4 | 5;
123
- /** Which safety axes tripped — for `irreversible_ask`, derived from the D-2 {@link SafetyAxis} (+ the
124
- * shell-gate). `shell` marks a coarse shellGate tighten (a bash command gated only because the deployment
125
- * set `shellGate`, with NO explicit per-tool egress/irreversible mark). Empty `{}` for a plain budgetable
126
- * `human` ask. Self-contained so the inbox needn't cross-ref `safetyAxis`. */
123
+ /** Which safety axes tripped — for `irreversible_ask`, derived from the D-2 {@link SafetyAxis} (+ the shell
124
+ * seat). `shell` marks a tighten by the engine's shell seat (read-boundary judge and/or `shellGate` doctrine,
125
+ * NO explicit per-tool egress/irreversible mark). Empty `{}` for a plain budgetable `human` ask. */
127
126
  axes: {
128
127
  egress?: boolean;
129
128
  irreversible?: boolean;
@@ -131,12 +130,12 @@ export interface RiskDescriptor {
131
130
  };
132
131
  /** The tool whose call is gated (mirrors the gate's `toolName`). */
133
132
  toolName: string;
134
- /** Ruled 2026-08-05 (forensics): the run's resolved shellGate doctrine, present ONLY when this ask
135
- * was minted through the shell gate (coarse `always`, or a `classify` probe that declined to
136
- * auto-allow). Absent for per-tool safety-axis / plain policy asks. Answers "which doctrine was
137
- * live when this gate fired" from the persisted row alone three artifact layers could not
138
- * answer that in the field. */
139
- shellGateDoctrine?: "classify" | "always";
133
+ /** The run's resolved shellGate doctrine, present ONLY when this ask was minted through the engine's shell
134
+ * seat (coarse `always`; a `classify` probe that declined to auto-allow; the read-boundary judge asking under
135
+ * `off` the boundary is judged under every doctrine, so the vocabulary is `TaskSpec.shellGate`'s whole).
136
+ * Absent for per-tool safety-axis / plain policy asks. Answers "which doctrine was live when this gate
137
+ * fired" from the persisted row alone — three artifact layers could not answer that in the field. */
138
+ shellGateDoctrine?: ShellGateDoctrine;
140
139
  /** A REDACTED, length-capped ONE-LINE summary of the call (the command for a shell gate; a brief key-arg
141
140
  * digest otherwise) for the inbox preview. NEUTRALIZED via
142
141
  * {@link import("./permission-rule-model.js").renderUntrustedCommandText} + length-capped (it is
@@ -406,11 +405,11 @@ export declare function buildRiskDescriptor(input: {
406
405
  args: unknown;
407
406
  /** The D-2 {@link SafetyAxis} threaded to the mint (egress/irreversible), or `undefined` for a plain ask. */
408
407
  safety?: SafetyAxis;
409
- /** True ONLY when this is a `bash` call gated coarsely by `shellGate` (no explicit per-tool egress/irreversible
410
- * mark) — drives the `shell` axis + severity-3 coarse grade. */
408
+ /** True ONLY when this is a shell call whose tier is the engine's shell seat's (read-boundary judge and/or
409
+ * `shellGate` doctrine, no explicit per-tool egress/irreversible mark) — drives the `shell` axis + severity 3. */
411
410
  shellGated?: boolean;
412
411
  /** The resolved doctrine to persist when `shellGated` (see {@link RiskDescriptor.shellGateDoctrine}). */
413
- shellGateDoctrine?: "classify" | "always";
412
+ shellGateDoctrine?: ShellGateDoctrine;
414
413
  /** #144: the matched-but-outranked persisted rule to persist (see {@link RiskDescriptor.shadowedRule}). */
415
414
  shadowedRule?: string;
416
415
  /** backlog #239: the reversibility probe's cause for not clearing this call (see
@@ -1110,7 +1109,7 @@ export interface CheckpointState {
1110
1109
  rev: number;
1111
1110
  rules: SessionPermissionRules;
1112
1111
  }>;
1113
- shellGate?: "off" | "always" | "classify";
1112
+ shellGate?: ShellGateDoctrine;
1114
1113
  /** The chain's AUTO-MODE INTENT at suspend (data half, same law as `shellGate`): `true` when the
1115
1114
  * suspended leg was an auto-mode task — its own seat, the bit its live chain carried, or the bit
1116
1115
  * an earlier suspend of the same chain recorded (carried forward across a re-suspend). A resume
@@ -1326,9 +1326,10 @@ export interface ToolGateInput {
1326
1326
  */
1327
1327
  onHookError?: (err: unknown) => void;
1328
1328
  /**
1329
- * design/134 R5: true when this tool's `irreversibility` tier was installed by the COARSE
1330
- * `TaskSpec.shellGate` doctrine (design/80 D-E `shellGatedBash`/`shellGatedMonitor`), not an explicit
1331
- * per-tool mark attributes a tighten-deny to `source:"shellGate"` instead of `"safety"`.
1329
+ * design/134 R5: true when this tool's `irreversibility` tier was installed by the ENGINE'S SHELL SEAT
1330
+ * (the read-boundary judge under every doctrine, plus the `TaskSpec.shellGate` doctrine's residual-risk
1331
+ * classifierdesign/80 D-E; the seat's `shellGatedTools` membership), not an explicit per-tool mark —
1332
+ * attributes a tighten-deny to `source:"shellGate"` instead of `"safety"`.
1332
1333
  */
1333
1334
  shellGated?: boolean;
1334
1335
  /**
@@ -43,7 +43,7 @@ import type { SystemInjectionPriority, TaskNotificationPayload } from "../task-n
43
43
  import type { OnAsk, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "../tool-policy.js";
44
44
  import type { RemoteExecutionEnv, SnapshotId, WorkspaceHandle } from "../remote-env.js";
45
45
  import type { ToolDisclosureManifest } from "../trace.js";
46
- import type { AgentDefinition, Brain, CompactOutcome, ModelRoles, NestedUsageAccum, RunnerDeps, TaskEvent, TaskResult, TaskSpec, TaskStream, ToolActivity, ToolEffect } from "../types.js";
46
+ import type { AgentDefinition, Brain, CompactOutcome, ModelRoles, NestedUsageAccum, RunnerDeps, ShellGateDoctrine, TaskEvent, TaskResult, TaskSpec, TaskStream, ToolActivity, ToolEffect } from "../types.js";
47
47
  import type { WiringManifest } from "../wiring-manifest.js";
48
48
  import type { ActiveSkillFrame } from "./active-skill-scope.js";
49
49
  import type { BudgetAxis } from "./assemble-result.js";
@@ -1189,7 +1189,7 @@ export interface InheritedGate {
1189
1189
  rules: SessionPermissionRules;
1190
1190
  }>;
1191
1191
  /** The chain's effective shell-gate doctrine — the child folds it by max-rank with its own spec. */
1192
- shellGate?: "off" | "always" | "classify";
1192
+ shellGate?: ShellGateDoctrine;
1193
1193
  /**
1194
1194
  * The chain's AUTO-MODE INTENT (`TaskSpec.autoModeRequested`, session-wide like a permission mode):
1195
1195
  * emitted when the spawning task carried the intent itself or inherited it, so an engine-spawned
@@ -245,7 +245,9 @@ export declare function createRuleOffersOf(cfg: {
245
245
  irreversibilityTier: {
246
246
  get(name: string): Parameters<typeof persistedRuleMandateOf>[0]["irreversibility"];
247
247
  };
248
- shellGatedBash: boolean;
248
+ shellGatedTools: {
249
+ has(name: string): boolean;
250
+ };
249
251
  taskRoot: string | undefined;
250
252
  /** The live tracked-cwd ref (read per call — the relative-cd resolution base, adversarial-review P1/r3). */
251
253
  cwdRef: {
@@ -176,7 +176,7 @@ export function createRuleOffersOf(cfg) {
176
176
  if (persistedRuleMandateOf({
177
177
  egress: cfg.egressTools.has(toolName),
178
178
  irreversibility: cfg.irreversibilityTier.get(toolName),
179
- shellGated: cfg.shellGatedBash,
179
+ shellGated: cfg.shellGatedTools.has(toolName),
180
180
  probeMandated: ask?.probeMandated === true,
181
181
  }) !== undefined) {
182
182
  return { ruleOffersAbsence: "mandated" };
@@ -28,7 +28,7 @@ export interface PrepareDeferClassifyInput {
28
28
  fullShellReachable: boolean;
29
29
  /** borrowed-readonly — the resolved prompt profile (`classic` swaps in `descriptionClassic`). */
30
30
  promptProfile: "simple" | "classic";
31
- /** borrowed-readonly — the resolved model (the classifier's constant-defer arm reads its shape). */
31
+ /** borrowed-readonly — the resolved model (the deferral gate's threshold reads its context window). */
32
32
  model: Model;
33
33
  /** borrowed-readonly — the materialized MCP roster; only `tools` is read (protocol names for the
34
34
  * remote-roster arm; `mcpAlwaysLoad` self-declarations, losing to an explicit operator defer). */
@@ -3,10 +3,12 @@ import { FULL_SHELL_CONTRACT_ID } from "../../tools/fs/index.js";
3
3
  import { ASK_USER_QUESTION_TOOL_NAME } from "../ask-question.js";
4
4
  import { MEMORY_ENGINE_TOOL_NAMES } from "../memory-engine/tools.js";
5
5
  import { SHARED_MEMORY_TOOL_NAMES } from "../shared-memory/types.js";
6
- import { TOOL_SEARCH_NAME, classifyDeferred } from "./tool-disclosure.js";
6
+ import { protocolOf } from "../protocol-table.js";
7
+ import { classifyDeferred } from "./tool-defer-gate.js";
8
+ import { TOOL_SEARCH_NAME } from "./tool-disclosure.js";
7
9
  import { RosterBuilder } from "../tool-roster.js";
8
- function explicitlyDeferredMemoryTrio(mounted, roster, deferNames) {
9
- return mounted ? MEMORY_ENGINE_TOOL_NAMES.filter((n) => roster.some((t) => t.name === n) && (deferNames ?? []).includes(n)) : [];
10
+ function explicitlyDeferredMemoryTrio(mounted, roster, callerDeferNames) {
11
+ return mounted ? MEMORY_ENGINE_TOOL_NAMES.filter((n) => roster.some((t) => t.name === n) && (callerDeferNames ?? []).includes(n)) : [];
10
12
  }
11
13
  function memoryGroupRetractionSet(builtinDeferPairNames, engineTrioInPlay) {
12
14
  return new Set([...builtinDeferPairNames, ...(engineTrioInPlay ? MEMORY_ENGINE_TOOL_NAMES : [])]);
@@ -39,29 +41,29 @@ export function prepareDeferClassify(input) {
39
41
  roster.replaceAt(i, { ...t, description: t.descriptionClassic });
40
42
  }
41
43
  }
42
- const userToolNames = (spec.tools ?? []).map((t) => t.name);
43
- const protocolToolNames = [...mcp.tools.map((t) => t.name), ...a2a.tools.map((t) => t.name)];
44
+ const protocolToolNames = [...mcp.tools, ...a2a.tools].filter((t) => protocolOf(t.name) !== undefined).map((t) => t.name);
44
45
  const mcpAlwaysLoadNames = mcp.tools
45
46
  .filter((t) => t.mcpAlwaysLoad === true && !(toolFaceSnapshot.defer ?? []).includes(t.name))
46
47
  .map((t) => t.name);
47
48
  const classifyDeferredOverFace = (face, builtinDeferNames) => {
49
+ const mounted = new Set(face.map((t) => t.name));
48
50
  const deferredSet = classifyDeferred({
49
51
  specs: spec.tools ?? [],
50
52
  protocolToolNames,
51
- fullTools: face.filter((t) => userToolNames.includes(t.name) || protocolToolNames.includes(t.name)),
53
+ fullTools: face,
52
54
  deferMode: deps.deferMode,
53
55
  model,
54
- deferNames: [...(toolFaceSnapshot.defer ?? []), ...builtinDeferNames].filter((n) => face.some((t) => t.name === n)),
56
+ callerDeferNames: (toolFaceSnapshot.defer ?? []).filter((n) => mounted.has(n)),
57
+ builtinDeferNames: builtinDeferNames.filter((n) => mounted.has(n)),
55
58
  alwaysLoadNames: [
56
59
  ASK_USER_QUESTION_TOOL_NAME,
57
60
  ...(toolFaceSnapshot.alwaysLoad ?? []),
58
61
  ...mcpAlwaysLoadNames,
59
62
  ],
60
63
  });
61
- for (const n of [...deferredSet]) {
62
- if (!face.some((t) => t.name === n))
64
+ for (const n of [...deferredSet])
65
+ if (!mounted.has(n))
63
66
  deferredSet.delete(n);
64
- }
65
67
  return deferredSet;
66
68
  };
67
69
  const sharedMemoryPair = sharedMemoryPairMounted ? SHARED_MEMORY_TOOL_NAMES.filter((n) => tools.some((t) => t.name === n)) : [];
@@ -130,10 +130,9 @@ export interface PrepareGateStationsInput {
130
130
  abortController: {
131
131
  readonly signal: AbortSignal;
132
132
  };
133
- /** borrowed-readonly — whether Bash is coarsely shell-gated (the gate's `shellGated` attribution). */
134
- shellGatedBash: boolean;
135
- /** borrowed-readonly — whether Monitor is coarsely shell-gated (same). */
136
- shellGatedMonitor: boolean;
133
+ /** borrowed-readonly — the shell-seat provenance set: the shell-running tools whose tier is the engine's shell seat's, not a
134
+ * deployment mark (the gate's `shellGated` attribution is the gated tool's membership). */
135
+ shellGatedTools: ReadonlySet<string>;
137
136
  /** borrowed-readonly — the auto-mode decider, or undefined (the classifier lane the gate routes a surviving ask through). */
138
137
  autoModeDecider: AutoModeDecider | undefined;
139
138
  /** borrowed-readonly — the auto-mode denial tracker, or undefined (handed to the gate beside the decider). */
@@ -42,7 +42,7 @@ function maybeHumanRejectionHalt(input) {
42
42
  };
43
43
  }
44
44
  export function prepareGateStations(input) {
45
- const { askLane, parkAsk, tools, toolRosterDeltas, toolEffects, deps, toolCallGateArmedRef, effectivePolicy, hooks, egressTools, irreversibleTools, spec, complianceDenies, harness, blockedToolCalls, inheritedAskGrants, inheritedUnavailableAsks, foldAskClasses, ancestorSandboxAdmissions, preToolContexts, gateOutcomes, batchHaltRef, blockedTracked, hookIdentity, reminderMark, planModeRef, hostTaskId, sessionId, ownGatePreToolUse, hookTimeoutMs, handsCwdRef, hookEnvFace, irreversibilityTier, reversibilityProbes, abortController, shellGatedBash, shellGatedMonitor, autoModeDecider, autoModeDenialTracking, stopForDenialLimit, permissionRuleLane, permissionRuleOrgLane, questionToolMounted, sandboxAdmissionArmed, sandboxBoundaryCapable, emitSandboxAdmitted, delegation, notifyOwnHookCrash } = input;
45
+ const { askLane, parkAsk, tools, toolRosterDeltas, toolEffects, deps, toolCallGateArmedRef, effectivePolicy, hooks, egressTools, irreversibleTools, spec, complianceDenies, harness, blockedToolCalls, inheritedAskGrants, inheritedUnavailableAsks, foldAskClasses, ancestorSandboxAdmissions, preToolContexts, gateOutcomes, batchHaltRef, blockedTracked, hookIdentity, reminderMark, planModeRef, hostTaskId, sessionId, ownGatePreToolUse, hookTimeoutMs, handsCwdRef, hookEnvFace, irreversibilityTier, reversibilityProbes, abortController, shellGatedTools, autoModeDecider, autoModeDenialTracking, stopForDenialLimit, permissionRuleLane, permissionRuleOrgLane, questionToolMounted, sandboxAdmissionArmed, sandboxBoundaryCapable, emitSandboxAdmitted, delegation, notifyOwnHookCrash } = input;
46
46
  if (askLane !== undefined && parkAsk !== undefined) {
47
47
  const { adjudicate, resolveAskBound, notifyPermissionDenied, notifyHookError } = askLane;
48
48
  const { resolveContentAsk, suspendAsk } = parkAsk;
@@ -119,7 +119,7 @@ export function prepareGateStations(input) {
119
119
  abortSignal: abortController.signal,
120
120
  ...(notifyPermissionDenied ? { permissionDenied: notifyPermissionDenied } : {}),
121
121
  onHookError: notifyHookError,
122
- shellGated: (e.toolName === "Bash" && shellGatedBash) || (e.toolName === "Monitor" && shellGatedMonitor),
122
+ shellGated: shellGatedTools.has(e.toolName),
123
123
  ...(autoModeDecider ? { autoMode: { decider: autoModeDecider, ...(autoModeDenialTracking !== undefined ? { denialTracking: autoModeDenialTracking } : {}) } } : {}),
124
124
  onHeadlessDenialLimit: stopForDenialLimit,
125
125
  onAskUnresolvable: (info) => deliverEngineNotice(deps.onNotice, askUnresolvableNotice({ sessionId, ...info })),
@@ -1,6 +1,6 @@
1
1
  import { type BeforeWriteHook, type CwdRef, type ReadDenyMatcher, type ReadFace } from "../../tools/fs/index.js";
2
2
  import { type ReadFileState } from "../../tools/fs/safety.js";
3
- import type { RunnerDeps, TaskSpec } from "../types.js";
3
+ import type { RunnerDeps, ShellGateDoctrine, TaskSpec } from "../types.js";
4
4
  import type { Model } from "../../internal/llm.js";
5
5
  import type { AgentTool, ExecutionEnv, WorkspaceState } from "../../internal/harness.js";
6
6
  import type { AdvertisedWritableDirs } from "./advertised-writable-dirs.js";
@@ -136,7 +136,7 @@ export interface PrepareHandsReadFaceInput {
136
136
  fullShellReachable: boolean;
137
137
  /** borrowed-readonly — the MAX-RANK fold of spec.shellGate with the inherited chain's doctrine
138
138
  * (computed in the driver's inherited-gate block); the shellGate fold's doctrine value. */
139
- effectiveShellGate: "off" | "classify" | "always";
139
+ effectiveShellGate: ShellGateDoctrine;
140
140
  /** borrowed-readonly — B-1's frozen task-start face snapshot; only `.exclude` is read here (the
141
141
  * Monitor-mount honesty bit for the gh rate-limit hint, #181-F6). */
142
142
  toolFaceSnapshot: ToolFaceSnapshot;
@@ -282,12 +282,12 @@ export interface PrepareHandsReadFaceResult {
282
282
  /** owned — design/199 件B: the ONE compiled deny judge per prepare (built-ins ∪ deployment ∪
283
283
  * task additions). Undefined ⇔ hands-less (its consumers only exist with hands). */
284
284
  readDenyMatcher: ReadDenyMatcher | undefined;
285
- /** write-back"the shellGate doctrine is the SOLE POSITIVE source of Bash's tier" provenance
286
- * bit; the driver's `let` (risk grading + the persisted-rule mandate boundary read it
287
- * call-time). Written ONLY by this phase; false when the fold did not run. */
288
- shellGatedBash: boolean;
289
- /** write-back the Monitor twin of the bit above. */
290
- shellGatedMonitor: boolean;
285
+ /** owned — the shell-seat PROVENANCE set: the shell-running tools whose tier the engine's shell seat (read
286
+ * boundary + shellGate doctrine) is the SOLE POSITIVE source of no explicit deployment mark on that name.
287
+ * The one seat every consumer reads (risk grading, the gate's tighten attribution, the persisted-rule mandate
288
+ * boundary): `shellGatedTools.has(toolName)`. Minted here, never grown afterwards; empty when the seat fold
289
+ * did not run (no real writable shell). */
290
+ shellGatedTools: ReadonlySet<string>;
291
291
  /** owned (design/380 rescan C4) — the ENV-HAND tool names, minted AT THE MOUNT: every tool this
292
292
  * phase mounted over the ExecutionEnv (the fs/bash band), later grown by the driver's own env
293
293
  * mounts (Monitor, the worktree pair — the SAME Set identity, mutated at those mount sites).
@@ -2,7 +2,7 @@ import { emitTrace } from "../trace.js";
2
2
  import { StubExecutionEnv } from "../stub-env.js";
3
3
  import { hasBackgroundShell } from "../background-shell.js";
4
4
  import { defaultTaskRegistry } from "../task-registry.js";
5
- import { bashReversibilityProbe, compileReadDeny, createHandsToolkit, deploymentReadFaceClampNotice, pdfModelCapabilitiesOf, resolveReadDenyBuiltins, inheritSessionReadFileState, resolveReadFace, seedReadFileStateFromContext, seedReadFileStateFromTranscript, } from "../../tools/fs/index.js";
5
+ import { bashReadBoundaryProbe, bashReversibilityProbe, compileReadDeny, createHandsToolkit, deploymentReadFaceClampNotice, pdfModelCapabilitiesOf, resolveReadDenyBuiltins, inheritSessionReadFileState, resolveReadFace, seedReadFileStateFromContext, seedReadFileStateFromTranscript, } from "../../tools/fs/index.js";
6
6
  import { resolveKey, sha256 } from "../../tools/fs/safety.js";
7
7
  import { normalizeFileText } from "../../tools/fs/encoding.js";
8
8
  import { wholeFileRecordsFromTranscript } from "./session-file-state-replay.js";
@@ -117,8 +117,7 @@ export function resolveHandsLessReadFace(input) {
117
117
  attachmentRootCanonical: undefined,
118
118
  readDenyMatcher: undefined,
119
119
  envHandToolNames: new Set(),
120
- shellGatedBash: false,
121
- shellGatedMonitor: false,
120
+ shellGatedTools: new Set(),
122
121
  sealReadStateSeat: undefined,
123
122
  };
124
123
  }
@@ -140,8 +139,7 @@ export async function prepareHandsMount(input) {
140
139
  let resolvedReadFace;
141
140
  let readDenyAdditionsNormalized = [];
142
141
  let handsLessResolvedFace;
143
- let shellGatedBash = false;
144
- let shellGatedMonitor = false;
142
+ const shellGatedTools = new Set();
145
143
  const additionalRootsCanonical = [];
146
144
  const additionalReadRootsCanonical = [];
147
145
  let attachmentRootCanonical;
@@ -345,34 +343,35 @@ export async function prepareHandsMount(input) {
345
343
  }
346
344
  roster.mountAll(band.map((t) => (envHandToolNames.add(t.name), firstPartyOffload(t))), { source: "builtin", mountedBy: "hands" });
347
345
  const shellGate = effectiveShellGate;
348
- if (shellGate === "off" && !(executionEnv instanceof StubExecutionEnv) && spec.handsReadOnly !== true) {
349
- deps.onError?.(new Error(`shell gate doctrine is "off" while a real writable shell (Bash) is mounted — no shell safety-axis ` +
350
- `fold applies to this run (commands are adjudicated by the ordinary policy/hook chain only). ` +
351
- `Set spec.shellGate to "classify" or "always" if this deployment expects doctrine-gated shell behavior.`), { phase: "config", sessionId, classification: "shell-gate-off" });
352
- }
353
- if (shellGate !== "off" && !(executionEnv instanceof StubExecutionEnv) && spec.handsReadOnly !== true) {
354
- const bashTierBefore = irreversibilityTier.get("Bash");
355
- shellGatedBash = !egressTools.has("Bash") && bashTierBefore !== "always" && bashTierBefore !== "maybe";
356
- const bashEffectiveTier = shellGate === "always" || bashTierBefore === "always" ? "always" : "maybe";
357
- irreversibilityTier.set("Bash", bashEffectiveTier);
358
- irreversibleTools.add("Bash");
346
+ if (!(executionEnv instanceof StubExecutionEnv) && spec.handsReadOnly !== true) {
347
+ if (shellGate === "off") {
348
+ deps.onError?.(new Error(`shell gate doctrine is "off" while a real writable shell (Bash) is mounted the read boundary is ` +
349
+ `still judged on every command (a deny-listed or out-of-root read asks; the ask is mandated), but no ` +
350
+ `residual shell-risk classifier is installed: writes, egress and unknown commands are adjudicated by ` +
351
+ `the ordinary policy/hook chain only. Set spec.shellGate to "classify" or "always" if this deployment ` +
352
+ `expects doctrine-gated shell behavior.`), { phase: "config", sessionId, classification: "shell-gate-off" });
353
+ }
359
354
  const shellReadBoundary = () => ({
360
355
  roots: [rootCanonical, ...additionalRootsCanonical, ...additionalReadRootsCanonical],
361
356
  ...(handsCwdRef?.current !== undefined ? { cwd: handsCwdRef.current } : {}),
362
357
  denyMatch: (p) => readDenyMatcher?.matchPath(p)?.pattern ?? null,
363
358
  ...(resolvedReadFace !== undefined ? { face: resolvedReadFace } : {}),
364
359
  });
365
- if (shellGate === "classify" && shellGatedBash)
366
- reversibilityProbes.set("Bash", bashReversibilityProbe(undefined, shellReadBoundary));
367
- if (backgroundTaskToolsActive) {
368
- const monitorTierBefore = irreversibilityTier.get("Monitor");
369
- shellGatedMonitor = !egressTools.has("Monitor") && monitorTierBefore !== "always" && monitorTierBefore !== "maybe";
370
- const monitorEffectiveTier = shellGate === "always" || monitorTierBefore === "always" ? "always" : "maybe";
371
- irreversibilityTier.set("Monitor", monitorEffectiveTier);
372
- irreversibleTools.add("Monitor");
373
- if (shellGate === "classify" && shellGatedMonitor)
374
- reversibilityProbes.set("Monitor", bashReversibilityProbe(undefined, shellReadBoundary));
375
- }
360
+ const installShellSeat = (name) => {
361
+ const tierBefore = irreversibilityTier.get(name);
362
+ const seatOwned = !egressTools.has(name) && tierBefore !== "always" && tierBefore !== "maybe";
363
+ if (seatOwned)
364
+ shellGatedTools.add(name);
365
+ const tier = shellGate === "always" || tierBefore === "always" ? "always" : "maybe";
366
+ irreversibilityTier.set(name, tier);
367
+ irreversibleTools.add(name);
368
+ if (seatOwned && tier === "maybe") {
369
+ reversibilityProbes.set(name, shellGate === "classify" ? bashReversibilityProbe(undefined, shellReadBoundary) : bashReadBoundaryProbe(shellReadBoundary));
370
+ }
371
+ };
372
+ installShellSeat("Bash");
373
+ if (backgroundTaskToolsActive)
374
+ installShellSeat("Monitor");
376
375
  }
377
376
  }
378
377
  return {
@@ -391,8 +390,7 @@ export async function prepareHandsMount(input) {
391
390
  attachmentRootCanonical,
392
391
  readDenyMatcher,
393
392
  envHandToolNames,
394
- shellGatedBash,
395
- shellGatedMonitor,
393
+ shellGatedTools,
396
394
  sealReadStateSeat,
397
395
  };
398
396
  }
@@ -43,7 +43,7 @@ import type { DelegationContentSafety } from "../memory-engine/delegation-proven
43
43
  import type { SessionPermissionRules } from "../session-policy-store.js";
44
44
  import type { SessionStore } from "../session.js";
45
45
  import { type OnAsk } from "../tool-policy.js";
46
- import type { RunnerDeps, RuntimeCaps, TaskEvent, TaskSpec, ToolExecuteContext } from "../types.js";
46
+ import type { RunnerDeps, RuntimeCaps, ShellGateDoctrine, TaskEvent, TaskSpec, ToolExecuteContext } from "../types.js";
47
47
  import type { ActiveSkillScope } from "./active-skill-scope.js";
48
48
  import type { InheritedGate, Prepared, PrepareResume, RunInternals, ToolFaceSnapshot } from "./contracts.js";
49
49
  import { type StopForDenialLimit } from "./denial-limit-arms.js";
@@ -194,7 +194,7 @@ export interface PrepareInheritedGateResult {
194
194
  /** owned — the monotonic governed-provenance bit for this leg. */
195
195
  orgGovernedProvenance: boolean;
196
196
  /** owned — the spec's own shellGate tightened by the inherited doctrine. */
197
- effectiveShellGate: "off" | "classify" | "always";
197
+ effectiveShellGate: ShellGateDoctrine;
198
198
  /** borrowed-mutable — THIS task's own session-rule snapshot for its children's chain. Writers (R-3): the policy-chain
199
199
  * station's session-policy read, once per leg, when a store is wired and the task carries its own sessionId. Read by
200
200
  * `inheritedGateForChildren` at spawn time. */
@@ -25,7 +25,7 @@ import { type CheckpointStore } from "../checkpoint-store.js";
25
25
  import type { SessionStore } from "../session.js";
26
26
  import { type OnAsk, type ToolCallRequest, type ToolPolicy } from "../tool-policy.js";
27
27
  import { type ToolResultStore } from "../tool-result-store.js";
28
- import type { RunnerDeps, RuntimeCaps, TaskSpec } from "../types.js";
28
+ import type { RunnerDeps, RuntimeCaps, ShellGateDoctrine, TaskSpec } from "../types.js";
29
29
  import type { CwdRef } from "../../tools/fs/fs-shared.js";
30
30
  import { type ContentAskBinding } from "./content-ask-bindings.js";
31
31
  import type { AskLane, ParkAsk, Prepared, SuspendSaga } from "./contracts.js";
@@ -108,12 +108,11 @@ export interface PrepareParkAskInput {
108
108
  maxSuspends: number;
109
109
  /** borrowed-mutable — the run-scoped remote-lifecycle failure log. Writer here: a refused `suspendVM` (`push`). */
110
110
  remoteEnvFailures: Prepared["remoteEnvFailures"];
111
- /** borrowed-readonly — whether Bash is coarsely shell-gated (the risk descriptor's `shellGated`). */
112
- shellGatedBash: boolean;
113
- /** borrowed-readonly — whether Monitor is coarsely shell-gated (same). */
114
- shellGatedMonitor: boolean;
111
+ /** borrowed-readonly — the shell-seat provenance set: the shell-running tools whose tier is the engine's shell seat's, not a
112
+ * deployment mark (the risk descriptor's `shellGated` is the gated tool's membership). */
113
+ shellGatedTools: ReadonlySet<string>;
115
114
  /** borrowed-readonly — the effective shell-gate doctrine persisted on the descriptor. */
116
- effectiveShellGate: "off" | "classify" | "always";
115
+ effectiveShellGate: ShellGateDoctrine;
117
116
  /** borrowed-readonly — the EFFECTIVE durable-approval opt-in (the caller's, or the synthesized mandate scope): picks the
118
117
  * gate kind, the scope and the ttl. */
119
118
  durableApproval: TaskSpec["durableApproval"];
@@ -22,7 +22,7 @@ class ParkRefusal extends Error {
22
22
  }
23
23
  }
24
24
  export function prepareParkAsk(input) {
25
- const { askLane, saga, spec, deps, sessionId, checkpointStore, toolRosterDeltas, parkLaneArmed, contentAskRoutable, liveQuestionFace, mountedQuestionTool, contentAskBindings, lateStrandedAnswers, discloseStrandedAnswers, onAsk, runtimeCaps, inheritedUnavailableAsks, basePolicyForResumeEdit, budgetSnapshot, handsCwdRef, offloadStore, ownedEnv, incompleteSuspendAdapter, session, sessions, suspendChainBase, maxSuspends, remoteEnvFailures, shellGatedBash, shellGatedMonitor, effectiveShellGate, durableApproval, priorLedger, liveSpendRef, resourceTotal, faceCheckpointState, f012CheckpointState, orgAdmissionCheckpointState, ruleOffersOf, now, humanReviewRef, abortController, harness, pausedRef } = input;
25
+ const { askLane, saga, spec, deps, sessionId, checkpointStore, toolRosterDeltas, parkLaneArmed, contentAskRoutable, liveQuestionFace, mountedQuestionTool, contentAskBindings, lateStrandedAnswers, discloseStrandedAnswers, onAsk, runtimeCaps, inheritedUnavailableAsks, basePolicyForResumeEdit, budgetSnapshot, handsCwdRef, offloadStore, ownedEnv, incompleteSuspendAdapter, session, sessions, suspendChainBase, maxSuspends, remoteEnvFailures, shellGatedTools, effectiveShellGate, durableApproval, priorLedger, liveSpendRef, resourceTotal, faceCheckpointState, f012CheckpointState, orgAdmissionCheckpointState, ruleOffersOf, now, humanReviewRef, abortController, harness, pausedRef } = input;
26
26
  if (askLane === undefined || saga === undefined)
27
27
  return { parkAsk: undefined };
28
28
  const { composedCallSignal, approvalPreviewOf } = askLane;
@@ -232,8 +232,8 @@ export function prepareParkAsk(input) {
232
232
  ...(shadowedRule !== undefined ? { shadowedRule } : {}),
233
233
  ...(probeReason !== undefined ? { probeReason } : {}),
234
234
  ...(probeCause !== undefined ? { probeCause } : {}),
235
- shellGated: (req.toolName === "Bash" && shellGatedBash) || (req.toolName === "Monitor" && shellGatedMonitor),
236
- ...(effectiveShellGate !== "off" ? { shellGateDoctrine: effectiveShellGate } : {}),
235
+ shellGated: shellGatedTools.has(req.toolName),
236
+ shellGateDoctrine: effectiveShellGate,
237
237
  });
238
238
  gate =
239
239
  safety !== undefined || realApproval !== undefined
@@ -78,8 +78,8 @@ export interface PreparePolicyChainInput {
78
78
  /** borrowed-readonly — the caller tools' names, the first-await snapshot (the sandbox boundary predicate: a caller tool
79
79
  * is never contained by the hand tools' env). */
80
80
  ownToolNames: ReadonlySet<string>;
81
- /** borrowed-readonly — whether the shell tool is under an operator `shellGate:"always"` (the offer factory's mandate term). */
82
- shellGatedBash: boolean;
81
+ /** borrowed-readonly — the shell-seat provenance set (the offer factory's `shellGated` mandate term, read per call). */
82
+ shellGatedTools: ReadonlySet<string>;
83
83
  /** borrowed-readonly — the tracked-cwd seat, read per call by the lanes and the offer factory (never written here). */
84
84
  handsCwdRef: CwdRef | undefined;
85
85
  /** borrowed-readonly — the compiled read-deny judge, or undefined (the offer factory's directory negative control). */
@@ -16,7 +16,7 @@ import { askGrantShapeOf } from "./inherited-ask-grants.js";
16
16
  import { createPermissionRuleLanes, inheritedAskRuleEvidence, createRuleOffersOf } from "./permission-rule-lanes.js";
17
17
  import { createSessionRulePolicy } from "./session-rule-policy.js";
18
18
  export async function preparePolicyChain(input) {
19
- const { spec, deps, internals, sessionId, runId, hostTaskId, taskRootFinal, executionEnv, lockedPreflight, tools, harnessTools, toolEffects, egressTools, irreversibilityTier, axisExplicitNegatives, ownToolNames, shellGatedBash, handsCwdRef, readDenyMatcher, questionToolMounted, runtimeCaps, inheritedAncestorRules, inheritedParentConstraints, seedInheritedGate, hasSkillManifest, skillScope, frozenOnAsk, hookEnvSource, notifyOwnHookCrash, autoModeDecider, stopForDenialLimit, delegation, abortController, now, ownSessionRulesRef } = input;
19
+ const { spec, deps, internals, sessionId, runId, hostTaskId, taskRootFinal, executionEnv, lockedPreflight, tools, harnessTools, toolEffects, egressTools, irreversibilityTier, axisExplicitNegatives, ownToolNames, shellGatedTools, handsCwdRef, readDenyMatcher, questionToolMounted, runtimeCaps, inheritedAncestorRules, inheritedParentConstraints, seedInheritedGate, hasSkillManifest, skillScope, frozenOnAsk, hookEnvSource, notifyOwnHookCrash, autoModeDecider, stopForDenialLimit, delegation, abortController, now, ownSessionRulesRef } = input;
20
20
  const policy = lockedPreflight.toolPolicy;
21
21
  const auditPolicyNames = (auditee) => {
22
22
  const nameGroups = toolPolicyNameSets(auditee);
@@ -265,7 +265,7 @@ export async function preparePolicyChain(input) {
265
265
  localOwnerDeclared: deps.localOwnerRules === true,
266
266
  egressTools,
267
267
  irreversibilityTier,
268
- shellGatedBash,
268
+ shellGatedTools,
269
269
  taskRoot: taskRootFinal,
270
270
  cwdRef: handsCwdRef,
271
271
  deniesDirectoryRead: readDenyMatcher !== undefined ? (d) => readDenyMatcher.matchPath(d) !== null : undefined,