@sema-agent/core 7.44.0 → 7.45.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 (75) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/dist/agents/subagent.js +33 -14
  3. package/dist/agents/tool-filter.d.ts +65 -4
  4. package/dist/agents/tool-filter.js +127 -4
  5. package/dist/core/agent-definition.d.ts +2 -2
  6. package/dist/core/allow-entry.d.ts +25 -1
  7. package/dist/core/allow-entry.js +29 -4
  8. package/dist/core/ask-origin.d.ts +23 -4
  9. package/dist/core/ask-origin.js +10 -4
  10. package/dist/core/checkpoint-store.d.ts +2 -2
  11. package/dist/core/decision-receipt.js +1 -1
  12. package/dist/core/effective-path-target.d.ts +7 -6
  13. package/dist/core/effective-path-target.js +3 -7
  14. package/dist/core/engine-notice.d.ts +20 -3
  15. package/dist/core/engine-notice.js +12 -6
  16. package/dist/core/gate-lanes.js +2 -0
  17. package/dist/core/gate-outcome.d.ts +5 -0
  18. package/dist/core/gate-outcome.js +2 -0
  19. package/dist/core/hooks.d.ts +2 -0
  20. package/dist/core/memory-engine/content-origin.d.ts +0 -34
  21. package/dist/core/memory-engine/content-origin.js +9 -4
  22. package/dist/core/memory-engine/layout.d.ts +5 -1
  23. package/dist/core/memory-engine/layout.js +1 -0
  24. package/dist/core/permission-rules.d.ts +4 -0
  25. package/dist/core/permission-rules.js +5 -2
  26. package/dist/core/real-approval.d.ts +9 -6
  27. package/dist/core/real-approval.js +5 -5
  28. package/dist/core/runner/child-tree.d.ts +13 -0
  29. package/dist/core/runner/child-tree.js +3 -0
  30. package/dist/core/runner/contracts.d.ts +8 -2
  31. package/dist/core/runner/prepare-ask-lane.d.ts +2 -0
  32. package/dist/core/runner/prepare-ask-lane.js +2 -2
  33. package/dist/core/runner/prepare-config-doors.js +3 -1
  34. package/dist/core/runner/prepare-execution-env.js +2 -1
  35. package/dist/core/runner/prepare-gate-stations.d.ts +3 -0
  36. package/dist/core/runner/prepare-gate-stations.js +2 -1
  37. package/dist/core/runner/prepare-listings.d.ts +4 -4
  38. package/dist/core/runner/prepare-listings.js +2 -22
  39. package/dist/core/runner/prepare-park-ask.d.ts +2 -0
  40. package/dist/core/runner/prepare-park-ask.js +2 -2
  41. package/dist/core/runner/prepare-policy-chain.d.ts +2 -0
  42. package/dist/core/runner/prepare-policy-chain.js +4 -2
  43. package/dist/core/runner/prepare-task.js +10 -6
  44. package/dist/core/runner/resume-policy-outcome.js +2 -1
  45. package/dist/core/runner/run-leg.js +1 -1
  46. package/dist/core/runner/run-notification-lane.js +2 -2
  47. package/dist/core/runner/runtask.d.ts +2 -0
  48. package/dist/core/runner/runtask.js +2 -0
  49. package/dist/core/runner/stream-steer-verb.js +2 -2
  50. package/dist/core/runner/sub-agent-deny-lists.d.ts +32 -0
  51. package/dist/core/runner/sub-agent-deny-lists.js +121 -0
  52. package/dist/core/runner/synthetic-tools.d.ts +1 -1
  53. package/dist/core/runner/synthetic-tools.js +3 -2
  54. package/dist/core/session-policy-store.d.ts +6 -2
  55. package/dist/core/session-policy-store.js +2 -1
  56. package/dist/core/skill-grant.d.ts +10 -2
  57. package/dist/core/skill-grant.js +20 -4
  58. package/dist/core/task-notification.d.ts +12 -1
  59. package/dist/core/task-notification.js +13 -1
  60. package/dist/core/task-spec.d.ts +5 -3
  61. package/dist/core/tool-policy.d.ts +15 -4
  62. package/dist/core/tool-policy.js +9 -13
  63. package/dist/core/tool-spec.d.ts +20 -2
  64. package/dist/core/types.d.ts +1 -1
  65. package/dist/core/types.js +1 -1
  66. package/dist/index.d.ts +5 -3
  67. package/dist/index.js +5 -3
  68. package/dist/orchestration/workflow.js +2 -1
  69. package/dist/tools/fs/removal-targets.d.ts +82 -0
  70. package/dist/tools/fs/removal-targets.js +236 -0
  71. package/dist/tools/fs/shell-read-write-targets.d.ts +27 -0
  72. package/dist/tools/fs/shell-read-write-targets.js +1 -1
  73. package/package.json +1 -1
  74. package/test/export-surface.snapshot.json +33 -1
  75. package/wire-changes/core@7.45.0.json +1084 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,175 @@
1
+ ## 7.45.0 — 2026-10-08
2
+
3
+ **#1376 —** hard-breaking: none; closed sets: none (no new code, ask class or release reason — the answer is the existing `rule_unreadable`; its reason clause gains one free-text sentence); exports: none added or removed; `ToolCallRequest` gains one optional member, `root` (wire typeKeys +1). **Behavior narrowing:** one — a caller rule policy compiled with no base judges a relative target instead of passing it (see below).
4
+
5
+ **Hard-breaking changes (the whole release):** none. **Package exports (the whole release):** +16 / −0 — +`HOST_DECIDERS`, +`HostDecider`, +`isHostDecider` (#1387); +`policyCannotAnswer`, +`HeldQuestion` (#1395); +`USER_INPUT_UNDRAINED_FAMILIES`, +`injectionLaneOf`, +`UserInputLane` (#1408); +`STORE_FORM_REFUSAL_CODES`, +`TARGET_OUTSIDE_STORE`, +`SCOPE_HOME_NOT_DIRECTORY`, +`SCOPE_HOME_FORMS`, +`StoreFormRefusalCode`, +`StoreFormRefusalDetail`, +`StoreFormRefusal`, +`ScopeHomeForm` (#1409); the `ToolSpec` face gains two optional members (`agentToolLevelDenyTools` #1377, `agentToolLevelAllowTools` #1385) and an `agentToolFaces` entry one (`authored`, #1377); `AllowEntryInertDetail` gains one optional key (`valueType`, #1385); `SessionRuleNameRefusal` gains one optional key (`guidance`, #1410). **Closed sets (the whole release):** +1 set — `StoreFormRefusalCode` (`STORE_FORM_REFUSAL_CODES`: the two codes the memory store's form refusals already carried, #1409); +1 member of an existing set — `AskClass` +`dangerous_delete` (`ASK_CLASSES`, before `unverifiable_delete`, #1403). **One ticket, #1377, with four sides that loosen what was refused (marked below, open to veto) and three that tighten what ran.** The paragraphs below state it. **#1385 / #1366** adds one tightening (an entry whose tool-name part holds a control or format character reads as `grammar` in every list that reads the shared entry grammar) and one change of sentence form (a list entry prints in one quoted form wherever a sentence names it). **#1390** adds one more tightening of the same shape: a skill table whose value is not a list reads as one entry that reads as nothing, and each table then applies its own law. **#1395** adds one tightening of a different kind (an approval seat, not a grammar): a shell read the read boundary cannot keep inside a narrowed (`roots`) read face is no longer put to the auto-mode classifier — it goes to the approval seat as the probe card `default` gives. **#1408** adds one tightening of an exported function's bad-value reading: `steerPriorityOf` and `injectionLaneOf` throw a coded `steering.invalid_content` error on a tier outside `now | next | later` instead of passing it on. **#1403** adds one tightening of a safety class: a removal whose target is a protected location — the filesystem root, a top-level directory, a home directory, the task root / working directory / an additional working directory or an ancestor of one, or a shell script built only when the command runs that may run a removal — is the mandated class `dangerous_delete`, which no permission mode, allow-layer member or classifier clears.
6
+
7
+ **#1377 —** hard-breaking: none; closed sets: none; exports: none added or removed. **Behavior:** a sub-agent's `denyTools` list is read as the reference reads it and judged once, before the run, from every place a deployment writes one — `TaskSpec.agents`, the definitions a mounted delegation tool carries (`createSubagentTool({ agents })`) and its tool-level list (`createSubagentTool({ denyTools })`); a workflow leg's agentType list and the memory content-origin face read an entry the same way. **Behavior loosening, open to veto (four sides):** on `TaskSpec.agents` a name no tool carries (`Bahs`) is told to the host instead of refusing the run, a name with a look-alike character (a Cyrillic `і` in `Edit`) is told instead of refused (the shared entry grammar reads it as a plain unknown name; an invisible character such as `"Edit\u200b"` is refused as `grammar` since #1385 below), and a `Tool(content)` entry (`Edit(src/**)`) hides the whole tool and is told instead of refusing the run; on a workflow leg a `Tool(content)` entry denies the whole tool for the leg instead of throwing `config.invalid_tool_name_set` at its launch. **Behavior narrowing (three sides):** a delegation tool's own list entry that is a spelling no tool can have, a case twin of a mounted tool or a retired name now refuses the run at start; an entry that is not a string (a number, `null`, an object, an array) or a delegation tool's tool-level list that is not a list refuses the run at start, naming the entry's place and its type (on `TaskSpec.agents` it already refused, with a sentence that printed the value); and a `Tool(content)` entry in a delegation tool's list or a team member's list now hides the whole tool where it used to hide nothing.
8
+
9
+ **#1387 —** hard-breaking: none; closed sets: none changed (an existing vocabulary gains its constant); exports: +3 (`HOST_DECIDERS`, `HostDecider`, `isHostDecider`); no runtime behavior.
10
+
11
+ **#1386 —** hard-breaking: none; closed sets: none (the refusal reuses `isolation.unavailable`); exports: none added or removed; `RunnerSelfSeat` gains one optional method, `canRerootChild` (wire typeKeys +1, optional). **Behavior narrowing:** one — a delegation's `isolation: "worktree"` on a runner with no `executionEnvFactory` is refused before the worktree is minted, instead of running the child in the caller's own root (see below).
12
+
13
+ **#1385 / #1366 —** hard-breaking: none; closed sets: none (the reason word stays `grammar`; no new code, no new notice code); exports: none added or removed (`renderListEntry` and `allowedToolNames` are module members, not package exports); the `ToolSpec` face gains one optional member, `agentToolLevelAllowTools`, and the entries the shared compile reports gain one optional key, `valueType` (the type of a list member that is not a string). **Behavior narrowing:** an entry whose tool-name part holds a control or format character (Unicode Cc / Cf: U+200B, U+200C, U+200D, U+2060, U+00AD, the bidirectional controls, the tag block, a C0 / C1 control) reads as `grammar` in the three tables that read the shared entry grammar — the allow layer's lists (announced, grants nothing — it was silent), a skill's lists (a deny entry keeps the skill from activating — it did not exist before), a sub-agent's denied-tools list (refused at start, all three sources — the tool was handed over before); a sub-agent definition's allowed-tools list that is not a list of strings is refused at start with `config.agent.unknown_tool` (it failed the run with the host's own message and no code, or threw at the mount, or was told). **Sentence form:** every sentence that prints a list entry prints it through one function, as a JSON string with every character that prints as nothing (or as a space) written `\uXXXX`; the sub-agent allow advisory and a skill's deny / allow decision name their entries quoted where they named them bare.
14
+
15
+ **#1390 —** hard-breaking: none; closed sets: none (no new code, no new notice code — the entry reads as `grammar`, the codes are #1372's and #1385's); exports: none added or removed (`readSkillTable` and `nonListValueLabel` are module members); no type changes. **Behavior narrowing:** a skill table whose value is not a list reads as one entry that reads as nothing, and each table then applies its own law (see below); `null` reads as no table (it failed the run before).
16
+
17
+ **#1395 —** hard-breaking: none; closed sets: none (no new class, code or word — the class is `probe`); exports: +2 (`policyCannotAnswer`, `HeldQuestion`); optional additions: `AskClassFacts.readFace`, `ToolGateInput.readFace`, a second optional parameter on `foldArbitrationRow(classes, readFace?)` (wire typeKeys +2, signature +1 optional). **Behavior narrowing:** one — under `auto` on a `roots` read face, a `probe` question is no longer put to the classifier (see below).
18
+
19
+ **#1408 —** hard-breaking: none; closed sets: none (the lane table is keyed by the two lanes, it adds no word); exports: +3 (`USER_INPUT_UNDRAINED_FAMILIES`, `injectionLaneOf`, `UserInputLane`). **Behavior narrowing:** one — `steerPriorityOf` and `injectionLaneOf` throw `steering.invalid_content` on a tier outside `now | next | later` (`steerPriorityOf` used to hand it back as it was, and read `null` as `next`); no value the engine itself passes is affected.
20
+
21
+ **#1409 —** hard-breaking: none; closed sets: +1 (`StoreFormRefusalCode` — the two codes the form refusals already carried; no new word); exports: +8 (`STORE_FORM_REFUSAL_CODES`, `TARGET_OUTSIDE_STORE`, `SCOPE_HOME_NOT_DIRECTORY`, `SCOPE_HOME_FORMS`, `StoreFormRefusalCode`, `StoreFormRefusalDetail`, `StoreFormRefusal`, `ScopeHomeForm`); no runtime behavior.
22
+
23
+ **#1410 —** hard-breaking: none; closed sets: none; exports: none added or removed; `SessionRuleNameRefusal` gains one optional member, `guidance` (wire typeKeys +1, optional); no new sentence.
24
+
25
+ **#1403 —** hard-breaking: none; closed sets: +1 member (`AskClass` +`dangerous_delete`, with its `MODE_ARBITRATION` row; the guard word on the ask is engine-internal); exports: none added or removed (the class rides `ASK_CLASSES` / `AskClass`; the predicate `dangerousRemovalOf` is a module member); `createUnverifiableDeletePolicy` gains one optional option, `removalSeat`. **Behavior narrowing:** one — a removal of a protected location asks a person under every mode and every allow member (it was an `ordinary` question an allow cleared, or a `probe` question `bypassPermissions` released).
26
+
27
+ ### A path rule judges a relative target when the policy carries no base: the leg's tree, else unreadable (#1376; behavior narrowing; +1 optional request member) (@server @client-core @test)
28
+
29
+ - **Before (v7.44.x).** A caller rule policy compiled without a tree in `pathBases` (`createPermissionRulePolicy([{ rule: "Read(//w/proj/secrets/**)", behavior }])`) read a call whose target is spelled relative (`Read("secrets/k")`, `./secrets/k`, `../proj/secrets/k`, `Grep({ path: "secrets" })`) as "no target": an ask row did not ask and a deny row did not refuse at the gate — `allow`, no `ruleUnreadable`, nothing disclosed (policy seat, three tools × ask / deny × three relative spellings). On a real lane (a Runner over a local execution env): a relative `Read` / `Grep` under an ask rule ran in `default` and in `bypassPermissions`, and the file's content reached the model; under a deny rule the gate allowed and the in-tool deny table refused after the fact (Read / Edit rules only — a `Grep(<path>)` deny has no in-tool row). The same call spelled absolute was asked / refused. A search with no `path` under such a policy was likewise read as no target.
30
+ - **Now.**
31
+ - `ToolCallRequest.root` (optional): the root the leg's tools resolve a root-relative spelling against — the hands' own root, in the same canonical spelling as `cwd` (the value `cwd` starts at). Stamped beside `cwd` at the three Runner adjudication seats (the gate, the park re-adjudication, the resume belt). Present whenever the leg mounts file hands — read-only hands included, where `root` is present and `cwd` is absent; absent on a hands-less leg and on a request a host builds itself.
32
+ - A caller rule policy reads each call under ONE set of bases, for the target and for every rule's pattern alike: its own `pathBases` when they name a tree (`root` or `cwd`) — unchanged — else the request's `root` / `cwd` (with the policy's `home`). The two sources are never mixed: a policy that names only `cwd` is not completed from the request's root.
33
+ - A target whose base neither source supplies (a relative spelling; a search with no `path` and no root) reads UNREADABLE — the shared target reader answered "no target" there; it is now the same arm as a supplied base that is not a fully-qualified path. Every path rule able to reach the call (the tool's own path rules, and `Read` / `Edit` rules whose face holds the call) answers it with the existing fail-closed ask — `ruleUnreadable` (`content_deny` / `content_ask`), `requiresRealApproval`, class `rule_unreadable`: `default` asks, `bypassPermissions` releases it (`gate.disposition.released.reason: "rule_unreadable"`), no automatic lane answers it — for a deny row and an ask row alike, the deny tier first. A policy whose lane holds no path rule for the call mints nothing.
34
+ - **Who is hurt.**
35
+ - A deployment that compiles path ask / deny rules with no tree in `pathBases` (a base-less lane): a relative-path call under such a rule ran (ask) or was refused only inside the tool (deny); on a Runner leg with file hands it now gets the rule's own card (`ask_rule`) or the gate's refusal. A search with a relative `path` or none is judged against the leg's root: one rooted inside the rule's path is asked / refused, one rooted above it is not (as with a base).
36
+ - On a hands-less leg, or on a request a host builds itself with neither `root` nor `cwd`, every relative-path call a path rule can reach gets a `rule_unreadable` card where it ran.
37
+ - A policy that names only `cwd` in `pathBases`: a Grep / Glob with a relative `path` or no `path` (their base is the root) gets a `rule_unreadable` card where it ran.
38
+ - A task root the host spelled non-canonically (a symlinked prefix; `/var` vs `/private/var` on macOS): the request's tree is the canonical spelling, so a `//` rule written in the host's spelling still does not reach a relative target — as it does not reach the same file spelled canonically today (it does reach the host-spelled absolute form). Measured: an ask row raises no card and the content reaches the model; a `Read` / `Edit` deny is still refused inside the tool, while a deny written on `Grep` (no in-tool row) lets a relative `Grep` run. `config.task_root_not_canonical` already discloses the two spellings.
39
+ - A downstream test that pinned the old reading (a relative target with no base reads as no target / `allow`) goes red: re-pin it to the new semantics.
40
+ - **Compensation.** Give the policy its tree (`pathBases: { root, cwd }`): its own bases then decide exactly as before and a resolvable call mints no unreadable card; a policy naming only `cwd` adds `root`. A host driving a policy outside a Runner stamps `root` / `cwd` on the request.
41
+ - **Consumers.** server (S-1136) — once the run-time task root / working directory reach the compiler as `pathBases`, this arm does not fire on its lanes; until then its sandbox / device lanes judge a relative target against the leg's root / cwd (read-only hands: the root). A host that builds `ToolCallRequest`s itself may set `root` / `cwd`. client-core — `ToolCallRequest` typeKeys +1 (`root`, optional, canonical spelling); the CC-366 / CC-378 premise "a relative rule target on a base-less lane is not judged" no longer holds for the engine's lane. test — on a base-less lane: a relative `Read` / `Grep` / `Glob` (with `path`) under an absolute ask / deny rule (card / refusal, content does not reach the model); a `Glob` with no `path` runs; a hands-less leg's caller read tool with a relative path (a `rule_unreadable` card; `bypassPermissions` releases it with the reason).
42
+ - **Known limits.** KL-1376-a (the resume-edit re-check before the claim carries no `root`: the checkpoint stores the working directory, not the root). KL-1376-b (a non-canonical host root × a `//` rule in the host's spelling × a relative target — not reached, disclosed by `config.task_root_not_canonical`). On a win-family tree, a base-less policy's in-tool deny rows (`pathDenyRules`, built when the policy is compiled, with no tree to read the path family from) still do not reach the canonical target — a `//C/…` row matches neither `C:\…` nor `/c/…`; the gate judges the call against the request's bases (#1383).
43
+ - Pins: `test/backlog1376-relative-target-no-base.test.ts` — policy seat P1 (no base, no tree on the request: relative ⇒ unreadable, absolute ⇒ the rule), P2 (a cwd only: Read / Edit resolve, Grep unreadable), P2r (root and cwd: every spelling resolves; Grep / Glob with a path, without one, a relative pattern; a root only), P3 (the policy's tree outranks the request's; a cwd-only policy is not completed), P4 (no path rule / an Edit rule for a Read / a shell call: nothing minted), P5 (a win-family tree reads the rule and the target alike); real lane L1 / L1b / L2 (Read relative, after a `cd`, ask / deny), L3 (read-only hands), L4 (Grep / Glob), L5 (a hands-less leg: the `rule_unreadable` card and the bypass release), L6 (controls), L7 (`root` / `cwd` on the request per leg kind); S1 (every seat stamping `cwd` stamps `root`). Base v7.44.0 sources: 31 of 39 cells red (the 8 green are the order and control cells). Re-pinned `backlog635-effective-path-target` (a relative spelling with no base is unreadable) and `path-identity-family-matrix` §1.
44
+
45
+ ### A sub-agent's denied-tools list is read once, from every place it is written (#1377; behavior; loosening open to veto) (@server @client-core @test)
46
+
47
+ - **Before (v7.44.0).** The list is the assembly-time tool-visibility subset (the child is simply not handed the named tools; it compares tool NAMES). Through `TaskSpec.agents`, every entry the task's roster did not carry refused the run before it started (`config.agent.unknown_tool`) — the content form `Edit(src/**)`, a case twin (`edit`), a spelling with whitespace (`"Edit "`) and a plain unknown name (`NoSuchTool`) alike, and a name only the delegation tool's own pool carried too. Through the delegation tool's own definitions or its tool-level list the SAME entries were not read at all: the run started, the child ran, and that deny did nothing — silently (measured on the 7.43.0 content tree, five forms × the two doors). The filter read each entry as a plain name, so `Edit(src/**)` matched no tool and left `Edit` offered to the child on every lane; a workflow leg's agentType list threw `config.invalid_tool_name_set` at the leg's launch for the same entry (the Agent tool and the workflow lane share one definition catalog); and the memory content-origin face compared the raw entries again, a third reader.
48
+ - **Now.** One reading and one judgment. `readDeniedEntry` (`src/agents/tool-filter.ts`) reads an entry the way the reference does (cli 2.1.280 `hTe` @19465976 adds every entry's tool name to the disallowed set — `Tool(content)` included, the content dropped — `nC` @19468854 filters the pool by it, and a name nothing matches is silent): `Tool(content)` names `Tool`, `Tool()` / `Tool(*)` are the bare name, an entry with surrounding whitespace or a malformed paren form is the whole string and names nothing. `judgeDeniedToolNames` (`src/core/runner/sub-agent-deny-lists.ts`) takes the entry's name through the allow layer's own name reading (`compileAllowList`, deny direction — the reading a skill's `disallowed-tools` gets) against ONE universe: the task's assembled roster plus the pool of every delegation tool it mounts, names and aliases. A task's own definitions and the delegation tool's definitions (`ToolSpec.agentToolFaces` entries now carry `authored: true` when a caller wrote them; the engine's built-in Explore / Plan, which name tools of other vocabularies, never do) and its tool-level list (`ToolSpec.agentToolLevelDenyTools`) are the three sources:
49
+ - a name the universe carries ⇒ the agent is not handed that tool;
50
+ - `Tool(content)` with the tool carried ⇒ the WHOLE tool is hidden from that agent (this list reads no content) and a notice says so, pointing at the deny rules of the caller's permission policy for path / command rules;
51
+ - a name that names no tool (whitespace, a malformed paren form) ⇒ the run is refused before it starts, `config.agent.unknown_tool`, the sentence naming the source, the definition and the entry and carrying that pointer; the same for a stale name while the pool can be listed — another case of a mounted tool (`edit` ⇒ `Edit`), a retired spelling (`bash` ⇒ `Bash`), a protocol peer the mint never produces (`mcp__github.com__*` ⇒ `mcp__github_com__*`) — in the reading's own words (`near_miss` / `tool_name`);
52
+ - any other name (a name no tool carries, `Tool(content)` of a tool not carried, a server-level `mcp__srv`) ⇒ hides nothing and is TOLD (`RunnerDeps.onError`, phase `"config"`, once per session; no notice code) — a definition shared across deployments is not wrong because one lacks a tool;
53
+ - an entry that is not a string (a list a host read from a file can hold anything: a number, `null`, an object, an array) names no tool ⇒ refused the same way, the sentence naming the source, the definition, the entry's place (`entry 2`) and its TYPE (`a number`, `null`, `an object`, `an array`) and nothing of its value — no member of a host value is read, converted or printed (`typeof` and the array test only); a tool-level list that is not a list at all, or a list that cannot be read (a throwing accessor, a proxy trap), is refused the same way; every reader of a list — the judge, the workflow helper, the filter, the memory face, the roster line and the mount's snapshot of a definition's list — reads it ONCE, by index, into a dense copy (holes read as `undefined`, no iterator of the host runs, a host's own message is never carried into a sentence);
54
+ - `*` is skipped.
55
+ - **A pool the engine cannot list** (a delegation tool with an `extraTools` factory): a name that names no tool is still refused; a stale name or a plain unknown name is told, the sentence saying the name cannot be checked.
56
+ - **Judged where the tool is mounted**: only a tool a deployment mounted publishes its lists to the judgment (`ToolSpec.agentToolFaces[].authored`, `ToolSpec.agentToolLevelDenyTools`); the engine's nested copy for a delegated child carries neither, so a child's prepare does not judge it against its narrower roster, and a delegation tool a child run mounts — an `extraTools` product, or a tool a deployment carries in another delegation tool's pool — is judged by that child's prepare against that run's own universe.
57
+ - **Every other reader takes the entry through the same reading.** The filter serves a team member's and a synthesizer's lists (`Tool(content)` hides the whole tool there too; no prepare judgment for the team's own lists); the memory content-origin face (`delegationCallIsExternal`) selects the child's pool by it (an entry that is not a string selects nothing there, hides nothing in the filter and is left out of the roster's `(Tools: …)` line), so `WebFetch(domain:x)` leaves the child no `WebFetch` and a pool whose only outward-reaching tool is denied that way no longer reads as reaching outside; a workflow leg's agentType list (`applyWorkflowAgentType`) maps each entry to its tool name before its narrowing policy is built (`Tool(content)` denies the whole tool for the leg, no notice: the launch site has no sink), and an entry that names no tool (a spelling, or a value that is not a string) refuses the leg's launch with the same code, naming `workflow agentType "<name>"`; the stale-name refusals and the told notice need the leg's roster and a sink and stay with the leg's own policy-name audit.
58
+ - **Who is hurt.** (1) A deployment whose delegation tool's definition or tool-level `denyTools` holds `Tool(content)` (`Bash(rm:*)`, `Edit(src/**)`): until now that entry hid nothing; its agent now never sees the tool — a child that relied on `Edit` under a path-limited deny loses `Edit` altogether. (2) A deployment whose delegation lists hold a spelling no tool can have, a case twin or a retired name (`edit`, `"Edit "`, `bash`, `mcp__github.com__*`): every run now refuses at start (`config.agent.unknown_tool`) where it ran with that deny doing nothing. (3) **Loosening, open to veto** — `TaskSpec.agents`: a plain unknown name or a `Tool(content)` entry no longer refuses the run (`Bahs` is told; `Edit(src/**)` hides `Edit` and is told); with an `extraTools` factory in the pool, a stale name there is told too. The refusal this relaxes was the 7.x fail-loud for a deny typo; what remains refused is the typo no tool can be (whitespace, case twin, retired name). (4) A name only a delegation tool's own pool carries is no longer refused on `TaskSpec.agents`. (5) **Loosening, open to veto** — a workflow agentType whose `denyTools` holds `Tool(content)` threw `config.invalid_tool_name_set` when the leg launched; it now denies the whole tool for the leg. (6) **Loosening on the memory face, open to veto** — a delegation whose face denies the only outward-reaching tool of its pool through a content entry (`WebFetch(domain:x)`) now classifies as local, since the child cannot reach it, so the parent's memory is marked polluted less often for that child. (7) A deployment that reads its agent definitions from YAML or JSON and holds a non-string entry (`123`, `null`, an object, a nested list) in a delegation tool's list: it ran with that entry doing nothing; it now refuses at start. (8) **A task parked before the upgrade**: a task that parked under a clean list resumes through the new engine, and if its delegation tool's list now holds a stale spelling (`edit`, `"Edit "`, `bash`) the resumed leg ends `failed` with `config.agent.unknown_tool` — the checkpoint row stays `pending` and the approved tool did not run (measured: park, resume with the stale list ⇒ failed, row pending, nothing executed; resume the SAME token again with the list corrected ⇒ completed, the approved tool ran). A `Tool(content)` entry or a plain unknown name does not stop a resume. (9) With an `extraTools` factory in the pool, a name with an invisible character is told, not refused, like any stale name there.
59
+ - **Compensation.** Write the NAME of the tool the agent must not be handed, spelled as it is mounted (the notice names the right spelling for a case twin or a retired name). To keep a tool usable but block by path or command, write a deny rule in the caller's permission policy (`createPermissionRulePolicy`); the whole-tool notice and the whitespace refusal say so. A host that wants the notice for a typo reads `onError` phase `"config"`. A task that must resume across the upgrade: correct the list first (the refusal names the spelling to write), then resume the same token again. A list read from YAML or JSON: make every entry a string.
60
+ - **Not changed.** The allow side's treatment of an unknown NAME (it stays the `TaskSpec.agents` host advisory and nothing elsewhere; a workflow agentType's allow list is read as before; what an allow list that is not a list, or an entry that is not a string, does is the #1385 section below); on a workflow leg a case twin or a retired name is still not refused at the agentType (the leg's policy-name audit reads it) and `mcp__srv` still covers the server's tools there; the delegation filter's matching of server-level entries (`mcp__srv`, `mcp__*` hide nothing there: they are told, and `mcp__*` is refused as a peer the mint never produces).
61
+ - **Consumers.** server — the acceptance door that refuses a content-form `denyTools` at its own 400 may keep it or drop it: the engine now reads the entry as the reference does; a name no tool carries reaches the host through `onError` (phase `"config"`), and its wiring to an operator surface is the server's; a pin on a workflow leg's `config.invalid_tool_name_set` for a content-form agentType deny flips (the leg now launches with that tool denied); a resume leg that ends `failed` with `config.agent.unknown_tool` did not consume its checkpoint row (still `pending`, nothing executed): correct the configuration and resume the same token again — do not treat the row as lost. client-core — none (no wire shape, no code, no notice). test — `test/backlog1377-subagent-deny-names-one-judgment.test.ts` (five forms × three sources with a real child, the extra forms, the sentence, the universe, the unlistable pool, one reading across levels, per-task replacement, two delegation tools, controls, the filter's reading, structure).
62
+ - **Known limits.** A workflow leg's agentType list still lacks two of the dispositions the delegation lanes have: the refusal of a stale spelling (a case twin of a mounted tool, a retired name) and the two notices (a name no tool carries, the whole-tool notice) — the launch site has neither a roster nor a notice sink; tracked as a separate ticket. A look-alike character (a Cyrillic `і` in `Edit`) reads as a plain unknown name wherever the shared entry grammar is used (the allow lists and a skill's lists too): KL-1385-a; an invisible character is refused as `grammar` since #1385.
63
+ - Pins: `test/backlog1377-subagent-deny-names-one-judgment.test.ts` (263 cells; 211 red on the unchanged 7.44.0 tree; the cells for a value that is not a string or not a list, a hostile list container and a very long entry are the last sections); the existing `[886]①c` and `AGENTS AUDIT` pins moved from "a plain unknown name refuses" to "a retired spelling refuses, a plain unknown name is told".
64
+
65
+ ### "Who reported this decide" is one exported closed set (#1387; surface only) (@server @client-core @cli @sdk)
66
+
67
+ - **Before (v7.44.x).** The words a durable decide names its decider with — `hostDecision.decidedBy: "person" | "sla_timeout" | "policy"` — were an inline union written in three type positions (`ResumeOutcome`, the real-approval judge, the approved-call screen) and once more as a hand-written membership test at the resume door. Nothing at the package root carried them, so a host's decide door or a client's generated mirror could only copy the three strings from a comment.
68
+ - **Now.** `HOST_DECIDERS` (frozen, `["person", "sla_timeout", "policy"]`), the type `HostDecider` and the guard `isHostDecider` are exported from the package root; the three type positions and the resume door's membership test read them. The resume door's two refusal sentences are byte-identical (they still spell the three words out; a pin holds them to the set). No behavior change: the same values are accepted and refused.
69
+ - **Who is hurt.** Nobody.
70
+ - **Consumers.** server — S-1154 (the decide door takes "this was the policy's answer") mirrors its accepted words from `HOST_DECIDERS`; the door may accept a subset (`person` / `policy`): `sla_timeout` is the host's own deadline sweep, not something a client fills in. client-core — CC-403 generates its table from the export. cli / sdk — the same table through the package.
71
+ - Pins: `test/backlog1387-host-deciders-export.test.ts` (members, frozen, guard; type-level sameness with the decide body and the judge's parameter; no second spelling in the four source files; the two sentences name every word); `test/export-surface.snapshot.json` 2601 → 2604; `scripts/consumer-smoke-probe.mjs` (+2 kinds).
72
+
73
+ ### A delegation's worktree isolation is refused on a deployment that cannot re-root a child (#1386; behavior narrowing; +1 optional seat method) (@server @sdk @test)
74
+
75
+ - **Before (v7.44.x).** On a deployment that wires only a static `executionEnv` (no `executionEnvFactory`), a delegation that asked for `isolation: "worktree"` (the call's argument, or the chosen definition's `isolation`) minted a git worktree and then started the child with the worktree path as a hint only — a static env has one shared working directory and cannot act on it. The child ran in the CALLER's own root: it read the caller's uncommitted working copy, wrote into the caller's checkout, and the tool result still said `worktree: <dir> (pruned — the agent made no changes)`. A background child was launched the same way, with a worktree minted for it. Nothing was disclosed. Measured on a real Runner over a real git repo (committed copy `COMMITTED`, working copy `WORKING`): the child's system prompt named the repo root as its working directory, its `Read` returned `WORKING`, its `Write` landed in the repo root, and the result named a pruned worktree. The same request with an explicit `cwd` was already refused (`config.cwd_unsupported`), and a workflow agent's `isolation: "worktree"` already failed prepare with `isolation.unavailable` — the delegation tool was the one place that did not ask.
76
+ - **Now.**
77
+ - Whether a runner can place a child in a tree other than the caller's is one function, `deploymentCanRerootChild` (`src/core/runner/child-tree.ts`): true only with an `executionEnvFactory`. The explicit-`cwd` door, the workflow isolation guard and `Runner.canRerootChild()` all read it; none decides it again.
78
+ - `RunnerSelfSeat` gains one OPTIONAL method, `canRerootChild(): boolean`; the `Runner` class always implements it (a live read of its deps — `swapDeps` is followed). Before it mints anything, the delegation tool asks the runner that will RUN the child — which, on a deployment that mounts the tool on a split child-execution runner, is not the runner that served the parent leg — and nothing is created unless that runner can re-root a child.
79
+ - On a runner that cannot: the child never starts and nothing is created — no worktree directory, no registration, no branch, no background task. The delegation tool returns an error result (`isError: true`, `details.error: "isolation.unavailable"`) whose sentence names the missing `executionEnvFactory` and tells the model to retry without the `isolation` parameter. The same single sentence shape answers a run that mounts no worktree capability at all (a read-only run), with that reason.
80
+ - A seat that does NOT declare the method (a host-built `RunnerSelfSeat`) is read as "cannot say", never as "can": the same refusal and code, with a sentence saying the runner this tool runs children on does not declare whether it can place a child in another tree.
81
+ - The workflow guard's first arm reads the shared function; a factory that hands back no env at all is now reported as that ("the executionEnvFactory returned no env instead of a worktree-rooted env"), not as "no executionEnvFactory is configured" — same code, same fail-closed outcome.
82
+ - A runner WITH an `executionEnvFactory` is unchanged: the child works in its own worktree (reads the committed copy, writes there, the report says `pruned` or `KEPT`), the caller's checkout is not touched. Split the other way (a static parent leg, children on a runner with a factory) the child works in its worktree too.
83
+ - **Who is hurt.**
84
+ - A deployment that wires only a static `executionEnv` yet lets the model pass `isolation: "worktree"` (or ships a definition with `isolation: "worktree"`): the child used to run — in the caller's root, silently; it now does not start and the model gets the refusal sentence. A host that surfaced `details.error` / the result text as before sees `isolation.unavailable` instead of a completed child with a pruned-worktree line.
85
+ - A host that hand-builds a `RunnerSelfSeat` for the delegation tool and does not implement `canRerootChild`: worktree isolation is refused on it. A test rig that mounted the tool on a factory-less Runner (or a stub seat) with a hand-supplied worktree lane and expected the child to run: the same refusal — give the rig a factory, or have the stub declare `canRerootChild`.
86
+ - **Compensation.** Deploy an `executionEnvFactory` (one env per task; a factory that roots the child at `parentCwd` is what makes the worktree real), or do not expose the `isolation` parameter / definition `isolation` to the model on a static-env deployment. A host-built seat implements `canRerootChild`, answering for the runner its `runTask` / `runTaskStream` forward to (true only when children that runner runs can be re-rooted) — the engine trusts the declaration and cannot check it against the runner that actually executes.
87
+ - **Consumers.** server, sdk — check which kind of execution env each entry point wires (a single-user shell over a local env is usually static; a per-task container deployment has a factory), and whether the delegation tool is mounted on a runner that is not the one serving the task (a split child-execution runner answers for itself); check for a hand-built `RunnerSelfSeat` (none was found in the server / cli / client-core / sdk sources read for this entry — not run). test — black-box cells: a static-env run × `Agent({ isolation: "worktree" })` ⇒ the child starts no turn, the checkout has no new file and no `.sema-worktrees/` entry, the result is an error with `isolation.unavailable`; the same call on a factory run ⇒ the child reads the committed copy and the checkout is untouched.
88
+ - **Known limits.** A factory that ignores the `parentCwd` hint (the contract says it SHOULD honor it) still lets the delegation tool's worktree child run outside its worktree: the explicit-`cwd` door verifies the env's actual root after the factory answers (`config.cwd_not_honored`), the worktree request does not. Measured with a factory that always roots at the caller's directory: the child ran in the caller's root and the result named a pruned worktree — the same symptom this entry removes for a static env, on a deployment that has a factory.
89
+ - Pins: `test/backlog1386-worktree-isolation-needs-factory.test.ts` — A (static env × isolation: no child turn, caller's checkout untouched, no worktree directory / registration / branch, coded refusal naming the factory and the retry), B (factory control: the child reads the committed copy in its worktree, writes there), C (explicit `cwd` refusal unchanged), D (no execution env at all: the same refusal, not a git-probe failure), E (a background child: refused before launch, no task), F (a definition-level isolation), S1 / S2 (split runners, both directions: a leg served with a factory but children run on a static runner is refused; a static leg with children on a factory runner works), S3 / S3b (a hand-built seat that does not say is refused with its own sentence even over a factory runner; one that declares runs the child), S4 (a fork child on a static env: the same refusal), H1 / H2 (the workflow arm: unchanged on a static env; a factory returning no env is named as that), G (the condition is defined once, every site reads it, the tool asks the child runner); `test/runner-self-seat.test.ts` (the seat's key set gains `canRerootChild`); mutation table `criteria-7450-mutations-1386` (M1–M10, per cell).
90
+
91
+ ### A list entry is read by one grammar and printed by one function; an invisible character in a tool name is unspellable (#1385 / #1366; behavior narrowing; sentence form) (@server @client-core @cli @test)
92
+
93
+ - **Before (v7.44.x).** The shared entry grammar's name test was `/^[^\s()]+$/`; JS `\s` does not hold U+200B / U+200C / U+200D / U+2060 / U+00AD or any other control / format character, so `"Edit\u200b"` read as a plain tool name nothing carries. Measured on the 7.44.0 tree (five characters × first / middle / last place, real Runner, the three tables that read the grammar): the allow layer's lists (`TaskSpec.allowedTools`, `RunnerDeps.allowedTools`) asked for the call and announced nothing (15 of 15 cells, no notice); a skill's `disallowed-tools` entry did not exist — the skill activated and its tool ran (15 of 15) — and an `allowed-tools` entry was silent too; a sub-agent's denied list (all three sources) drew only a host advisory and handed the tool over (45 of 45). The entry's own sentence printed the zero-width characters and a right-to-left override RAW (invisible, or reordering the line), folded a no-break space into an ordinary space, and two sentences named entries without the notice's form: the sub-agent allow advisory joined host values bare and uncapped, a skill's deny / allow decision named its entries bare under a 200-character cap. A skill's `allowed-tools` / `disallowed-tools` member that is not a string (a list a host read from a file: a number, `null`, an object, an array…) ended the run `failed` before the first model call, uncoded, with the runtime's own message (`raw.trim is not a function`, `Cannot read properties of null (reading 'trim')`) — on either list and either route; the allow layer's lists and the sub-agent lists already refused such a member with a code. (`"Write "` with a trailing space already printed with its space inside the quotes since 7.43.0; the `"Write" (grammar)` form the ticket reports does not reproduce on this tree.) A sub-agent definition's allowed-tools list was spread at the mount and joined into the advisory: a symbol, a value with a throwing `toString` or a list with a throwing accessor failed the run with the host's own message and no code, a value that is not a list made the mount throw (a definition source; a string was spread into one-letter names there) or the run fail (`TaskSpec.agents`), and `[123]` / `[null]` / `[{}]` were told bare; a delegation tool's tool-level denied list was read twice, once by the judgment and once at the assembly, and a tool-level allowed value that was not a list failed every delegation through that tool at the spawn — the filter threw (`allowTools.includes is not a function`; nothing catches it between the filter and the tool's `execute`), the runtime's own message went back to the model as the tool's result, and no child ran.
94
+ - **Now.**
95
+ - **One grammar.** An entry whose tool-name part (the text before the first `(`, or the whole entry) holds a Unicode Cc or Cf character names no tool and reads as `grammar` (`TOOL_NAME_SPELLING`, `src/core/allow-entry.ts`). The three tables follow with no reader-side judgment: the allow layer's entry is announced (`permission.allowed_tool_not_granted`, reason `grammar`) and grants nothing; a skill's `disallowed-tools` entry keeps the skill from activating (`config.skill_deny_unreadable` on the user route; the activation refused on the model route — #1372's rule); a skill's `allowed-tools` entry grants nothing and is announced; a sub-agent's denied entry refuses the run at start, `config.agent.unknown_tool`, from `TaskSpec.agents`, a definition on a mounted delegation tool and its tool-level list. The content part of a `Tool(content)` entry keeps its own grammar (`Probe(x\u200by)` is still `content_unsupported`, `Edit(src/\u200b**)` still `path_pattern`; a shell rule's parser refuses a format character in its content as before).
96
+ - **A list member that is not a string reads as `grammar` — by its type.** The shared compile (`compileAllowEntry` / `compileAllowList`) reads such a member as `grammar` and reports it as `{ entry: "<not a string: a number>", reason: "grammar", valueType: "a number" }` (`valueType`: the optional key, from the same `valueTypeLabel` the sub-agent sentences use — `typeof` and the array test only, so no `toString` / `toJSON` / getter of the host's value runs); the sentence says `a value that is a number (grammar: not a string)`. The skill lists follow with no reader-side judgment: a `disallowed-tools` member keeps the skill from activating (the user route ends `failed` with `config.skill_deny_unreadable`, the model route's `Skill` call is refused — #1372's rule), an `allowed-tools` member grants nothing, is announced and the skill still activates; a real entry beside it keeps its reading. The Skill card's binding digest (`skillGrantDigest`) binds such a member by its type under the same label (`<not a string: a number>` — a spelling no readable entry can have: a space inside, no parenthesis, so a `null` member never binds as a real entry named `<null>`), so a change of the member's type changes the digest and a change of its value does not. The allow layer's lists and the sub-agent lists refuse such a member earlier, with their own codes.
97
+ - **One writer.** `renderListEntry` (`src/core/allow-entry.ts`) prints an entry as a JSON string with every control, format, line / paragraph separator and space character but one lone U+0020 written `\uXXXX` (`\u{…}` beyond the BMP), a run of spaces kept space by space, capped at 122 printed characters. The start-of-run notice, a skill's refusal (`inertEntryPhrase` is its caller), a skill's deny and allow decisions, a sub-agent list's refusals and its two advisories (the whole-tool notice and the unknown-name advisory) all print through it; the same entry prints the same bytes at every point.
98
+ - **The sub-agent allowed list reads like the denied one** (`readToolNameList`, formerly `readDeniedList`): by index, once, into a dense copy — no iterator of the host runs. A value that is not a list, a list that cannot be read, or an entry that is not a string is refused at start with `config.agent.unknown_tool` from `TaskSpec.agents` and from a definition on a mounted delegation tool; the sentence names the source, the definition, the entry's place and its TYPE, never the value (`declares in its allowed tools, as entry 2, a value that is an object, not a tool name`). `null` reads as absent, as on the denied side. A name the roster does not carry stays an advisory, never a refusal. The filter (`resolveToolSubset`, `toolNameAllowed`; a team member's and a synthesizer's lists included), the roster line and the memory content-origin face read the same list through `allowedToolNames`; a value that is not a list admits nothing there (the judgment refuses it first).
99
+ - **The delegation tool's tool-level lists are copied once where the tool is minted**, as a definition's lists are: the judgment and every spawn read one copy, never the host's live value. The tool-level allowed list is published on the tool face (`ToolSpec.agentToolLevelAllowTools`, depth 0 only, beside `agentToolLevelDenyTools`) and judged before the run like a definition's: a value that is not a list, a list that cannot be read, or an entry that is not a string is refused at start with `config.agent.unknown_tool` (`delegation tool "Agent": the tool-level list declares in its allowed tools …`, the type, never the value); `null` is absent; an unknown name is never refused. A call that reaches the spawn with such a list anyway — a tool built by hand, a direct `execute`: no prepare ran — comes back as an error result with `details.error: "config.agent.unknown_tool"` and the same sentence, no child is spawned and the runtime's own message never reaches the model. A list the copy could not read is kept as that failure (one frozen marker the judgment refuses), never as the host's container, so a list that throws once and answers afterwards does not pass a second look; and `TaskSpec.agents` is copied once where the definitions are bound to the delegation tool, so the judgment before the run (which reads the rebound task) and the assembly read one copy.
100
+ - **Who is hurt.** A skill whose `allowed-tools` / `disallowed-tools` holds a member that is not a string: the run no longer fails at start with the runtime's message — a deny member now keeps that skill from activating (the user route fails the run with `config.skill_deny_unreadable`), an allow member is announced. A deployment whose list holds an entry with a control / format character in its name: its allow entry now announces (it never granted); a skill carrying such a deny entry no longer activates (the user route fails the run, the model route is refused at the `Skill` call); a sub-agent definition carrying one in its denied list now refuses the run at start where the tool used to be handed over. A delegation tool whose tool-level `allowTools` is not a list: the run is now refused at start (`config.agent.unknown_tool`) where every delegation through the tool failed at the spawn with the runtime's own message; `null` now reads as no list (the delegation used to fail). A definition whose allowed-tools list is not a list of strings now refuses the run (it failed the run uncoded, or threw at the mount, or was told); a definition source used to throw at `createSubagentTool` for a non-list and now mounts and is refused at the run. A host that matched the old text: the allow advisory prints `allows tool(s) "Typoed"` where it printed `allows tool(s) Typoed`, a skill's decision prints `lists this tool in disallowed-tools ("Probe")` where it printed `(Probe)`, an entry's zero-width character or no-break space prints as `\u200b` / `\u00a0` where it printed raw / as a space, and a very long allowed-tools entry in the advisory is now capped.
101
+ - **Compensation.** Spell the tool name as it is mounted: the sentences now show the offending character as `\uXXXX`. An allowed list a host reads from a file: make it a list of strings.
102
+ - **Consumers.** server — a test or a filter that matched the allow advisory or the skill decision sentences takes the quoted form (the notice's `detail.entries` is unchanged: the raw entry); the K1.c disclosure (an entry the engine reads one way and the notice prints another) can be withdrawn. client-core — the notice's rendering of `detail.entries[].entry` is the raw string; render it with escapes if it must show an invisible character (the engine's sentence already does). cli — the Skill card and the `allowRuleShadowed` records print their entries as before (KL-1385-b). test — re-read K1.c: `"Write "` printed with its space before this version too; the invisible characters were the real wash, and are now refused and shown.
103
+ - **Known limits.** KL-1385-a (only Cc / Cf are refused: a look-alike character, a Hangul filler, a variation selector or a braille blank in a tool name still reads as a plain unknown name — measured: U+034F, U+115F / U+1160, U+17B4 / U+17B5, U+180B–U+180D / U+180F, U+FE00–U+FE0F / U+E0100–U+E01EF, U+3164, U+FFA0, U+2800; a separate ticket is to give the entry grammar and the mounted tool name's legality one character set; the engine checks no character set for a caller tool's name at the mount — only the `__` reservation — so the entry grammar and the mounted name's legality are two places). KL-1385-b (the Skill card and the `allowRuleShadowed` records name a matched entry as before: a no-break space or a run of spaces inside a shell rule's content shows as ONE ordinary space there — measured: `Bash(echo a<NBSP>b)` and `Bash(echo a b)` both print `Bash(echo a b)` on the card; an entry with a zero-width space in its content reads as `grammar`, is not on the card, and is announced by the notice). KL-1385-c (a workflow leg's agentType allow list is read as before). KL-1385-e (a skill's `allowed-tools` / `disallowed-tools` VALUE that is not a list ended the run `failed`, uncoded, with `<name> is not iterable`, and a string was spread into letters — measured on the 7.44.0 tree; retired by #1390 in this release: the two lists are read through one reading, as the sub-agent lists are).
104
+ - Pins: `test/backlog1385-entry-grammar-one-render.test.ts` (308 cells; 302 red on the unchanged 7.44.0 tree): ① the grammar over five characters plus seven more × first / mid / last × allow / deny, the spellings that read before still read, the content part's own reasons; ② the writer's table (trailing / leading space, tab, newline, zero-width, no-break space, override, line separator, astral tag, run of spaces, quote and backslash, the cap); ③ the allow layer, request and deployment; ④ a skill's two lists, the user route and the control; ④b a skill's two lists × nine non-string member forms × the model and the user route, the shared compile, the card digest; ⑤ a sub-agent's denied list × three sources and the content form; ⑥ one entry at every point, the cap and the single-writer structure; ⑦ the allowed list's hostile forms × three sources (`TaskSpec.agents`, a definition on a delegation tool, the tool-level seat), the iterator, `null`, the unknown-name advisory, the readers; ⑧ the lists read once (tool-level denied: a counting accessor and a live list mutated after the mint; tool-level allowed: a live list mutated after the mint; a list that throws once and answers afterwards, in every seat; a definition accessor that answers differently per read); ⑨ a call that reaches the spawn without prepare (a hand-built tool, a direct `execute`) × the tool-level seat and a definition × five bad forms, and the controls. The `AGENTS AUDIT` pin in `test/prepare-listings-transcript.test.ts` and the advisory regex in `test/backlog560-announce-once-per-session.test.ts` moved to the quoted advisory.
105
+
106
+ ### A skill table whose value is not a list reads as one entry that reads as nothing — never spread into letters (#1390; behavior narrowing) (@server @cli @test)
107
+
108
+ - **Before (v7.44.x).** `normalizeSkills` spread each table's value (`[...value]`). Measured on the 7.44.0 tree (real Runner, seven forms × the two tables × the model's `Skill` call and `userInvokedSkills`): an object, a number, `null`, a boolean and an object whose getters throw ended the run `failed` before the first call, uncoded, with the runtime's text (`disallowedTools is not iterable`) — all 20 cells; a list whose index accessor throws failed the run with the HOST's own message (`SECRET-IDX` in the probe), uncoded, 4 of 4. **A string was spread into one-letter entries and the run `completed`**: `disallowedTools: "Probe"` refused nothing — the skill activated, `Probe` was asked and RAN (the deny face failed open: each letter is a grammar-valid name nothing mounts, so nothing was announced either); `allowedTools: "Probe"` granted nothing and announced nothing. The resume's copy (`detachSkillLists`) copied arrays only and spread a hostile array through the host's iterator, so a list whose index accessor throws failed the resume entry with the host's own message (`SECRET-IDX`), uncoded; a value that is not a list passed through unread — a re-supplied bad `disallowed-tools` was refused by nothing before the claim (only prepare, after it, failed the leg) and a bad `allowed-tools` made the resume entry throw, uncoded.
109
+ - **Now.** One reading. A skill's two tables are read once by `readSkillTable` (`src/core/skill-grant.ts`) — `normalizeSkills` (the prepare's roster and the resume's Skill binding) and the resume's copy (`detachSkillLists`) both go through it, so `skillGrantBinding` / `seedSkillGrants` / `activate` / the card never meet a host value:
110
+ - absent — `undefined`, or `null` (a frontmatter's empty key reads as `null`) — is no table, as a sub-agent definition reads it (`toolNameListFlaw`);
111
+ - a list is its dense copy, by index through `readToolNameList` (no iterator of the host's runs; a member that is not a string stays a member and reads by its type, as #1385 pinned);
112
+ - a value that is not a list (an object, a number, a boolean, a **string** — never spread — a function, a bigint, a revoked proxy) is ONE entry, `<not a list: a string>`; a list whose read throws is `<a list whose entries cannot be read>` (`nonListValueLabel`, `src/core/allow-entry.ts`, the sibling of `nonStringMemberLabel`: a space, no parenthesis — it reads as `grammar` under both directions and every roster, and names only the type, never the value; no `toString` / `toJSON` / getter / trap of the host's value runs).
113
+ Then, with no new arm, each table applies the law it already has to an entry that reads as nothing: a `disallowed-tools` entry keeps its Skill from activating (#1372 — the model's `Skill` call is refused at the gate, denied / policy, no card, no park; `userInvokedSkills` naming it ends the run `failed` with `config.skill_deny_unreadable`; a resume that re-supplies it with the skill invoked by name is refused before the claim, `checkpoint.config_refused`), an `allowed-tools` entry grants nothing and is announced (`permission.allowed_tool_not_granted`, reason `grammar`; the skill still activates). The Skill card digest binds the entry's label like any entry (a change of the value's type changes the digest).
114
+ - **Who is hurt.**
115
+ - A deployment whose skill source hands a table as a STRING (a loader that passes a frontmatter value `Bash, Read` through un-split): a `disallowed-tools` string used to refuse nothing silently; the skill now does not activate (the user route fails with the code). An `allowed-tools` string used to grant nothing silently; it is now announced at the start of the run.
116
+ - A skill with `null` for a table: it failed the run; it now reads as no table and activates.
117
+ - A skill whose allow table is not a list now carries a card (a table is declared: `Execute skill: <name>`, releasing nothing, the one entry announced) where a non-list used to fail the run.
118
+ - A host that matched the runtime's text (`is not iterable`) or the host's own message on a throwing list: the failure is now a refusal or a notice, in the sentence above.
119
+ - A Skill card parked on 7.44.x for a skill whose table was a string digested the table as one-letter entries; the same skill now digests as one `<not a list: a string>` entry, so that parked approval is refused at the claim as a changed card (the row stays pending: deny it and activate again). No other skill's digest moves — a list of strings digests as before (pinned against a 7.44.0 value).
120
+ - **Compensation.** Hand each table as a list of tool-name strings (`["Bash(git:*)", "Read"]`); split a frontmatter string into entries in the loader.
121
+ - **Consumers.** server — a skill loader that passes a frontmatter string through un-split should split it (the engine no longer spreads it; a bad value is announced / refused by its type). cli — a skill with a bad allow table now shows the Skill card with no entries and the notice names `"<not a list: …>"`. test — two cells: a skill whose `disallowed-tools` is a string / object (the model's call refused, no card; the invoked run `failed` with the code, nothing ran) and `allowed-tools: null` (no table).
122
+ - **Known limits.** KL-1385-e retired (this change). None new: a list's holes and non-string members keep their per-member reading (#1385).
123
+ - Pins: `test/backlog1390-skill-table-value-one-read.test.ts` (82 cells; 65 red on the unchanged 7.44.0 tree): ① the deny table × twelve forms (object, number, boolean, the string `Probe`, a long string, an object whose getters throw, a proxy whose traps throw, a revoked proxy, a bigint, a function, a list whose index accessor throws, an array proxy whose traps throw) × the model route and the user route — refused, one notice, no runtime text, no value bytes, no getter / trap touched — and the string `"Probe"` not spread; ② the allow table, same forms × both routes; ③ `null` / `undefined` = no table; ④ a well-formed table byte-for-byte (`normalizeSkills`, the digest golden minted on 7.44.0, a lane); ⑤ the resume's re-supplied skill (a deny string / hostile list / object with the skill invoked ⇒ `checkpoint.config_refused`, the row pending and unchanged, the corrected config resumes; an allow table ⇒ announced; a table the definition carries on its prototype or as a non-enumerable property still counts, for the screen and for a parked approval's digest); ⑥ controls (the request's allow list, a sub-agent definition's lists); the reading unit cells (a parked approval minted over a string table's letters is refused as a changed card; `readSkillTable`, the label reads `grammar`, one read of a throwing accessor, one read of a definition's property, one read end to end).
124
+
125
+ ### A shell read the read boundary cannot keep inside a narrowed read face is not the classifier's to answer (#1395; behavior narrowing; +2 exports) (@server @cli @client-core @test)
126
+
127
+ - **Before (v7.44.x).** On a `roots` read face a shell read the station could not keep inside the roots (`cat <outside file>`, or a spelling it could not place such as `cat $(echo <outside file>)`) raised a mandated question of class `probe`. Under `auto` — the interactive default — that class was classifier-eligible (`MODE_ARBITRATION.probe.classifierEligible` is `true`), so the classifier answered it. Measured on a real Runner over a real `NodeExecutionEnv` with a classifier that allows what it is asked: one classifier call, no card, the tool ran and the file's content reached the model. An operator who had narrowed the read face so that reads outside the roots are asked about was asked nothing out of the box. Under `default` the same question was a card; under `open` the station vouches a read of an outside path outright (no question) and still asks about a spelling it cannot place — which the classifier answered.
128
+ - **Now.** One fold, one reading of the face. The class set an ask carries is folded with the read face its leg resolved: `classifyAsk(ask, { …facts, readFace })` → `foldArbitrationRow(classes, readFace)`. Under `roots` a set holding `probe` is not classifier-eligible; under `open`, and on a leg that resolved no face, the table's own row stands. The leg's gate and an inherited station (an ancestor policy's ask met by a child leg) read the same folded `classifierEligible`; neither judges the face itself. A question the classifier may not answer goes to the leg's approval seat as every such question does (an explicit ask rule, a hook's ask): zero classifier calls about the read, and the card is the one `default` has always given — `askClass: "probe"`, `classes: ["probe"]`, `mandated: true`, the table word (`probe_mandate`, or `probe_unanswered` where a probe did not answer), no real-approval bit, the read-root grant offer unchanged. With no approver wired it is refused with the headless sentence; with a durable park armed it parks (the row is the one `default` parks).
129
+ - Unchanged, pinned: `default`, `acceptEdits`, `plan` and `bypassPermissions` (the card is byte-for-byte the one `auto` now gets; bypass still releases it); the structured `Read` of an outside file in `auto` (class `ordinary`, the classifier answers it); an inside shell read (no question); every other class's row under either face.
130
+ - `policyCannotAnswer(question, decidedBy)` and the type `HeldQuestion` are exported from the package root. `HeldQuestion` gains the request an approver was handed (`AskRequest`) as a fourth form beside the parked row, a policy re-check and the org's verdict: a question demands a person when it carries `requiresRealApproval === true` — the predicate the resume entry and the execution belt already read. Each form is now read by its own members, never chosen by the presence of another form's key: a request carrying a stray `status`, `action` or `gate` still demands a person when it says so (before, such a key could select another form and read the demand as none, and `gate: undefined` threw), and a malformed value reads as no question. `decidedBy` is `HostDecider` (#1387).
131
+ - **Who is hurt.**
132
+ - A deployment in `auto` whose resolved read face is `roots` — the engine default when nothing declares `open` (a library embedding that never set `readFace`; a multi-tenant default): an out-of-roots shell read, or one the station cannot place (a heredoc commit message `git commit -m "$(cat <<'EOF' …)"`, a variable path `S=src; sed -n 1p $S/a.txt`), now reaches the approval seat instead of the classifier. With no approver wired it is refused with the headless sentence (nothing runs that ran before).
133
+ - A custom tool whose own reversibility probe answers `mandated: true` or does not answer, or a caller policy that declares `probeMandated`, in an `auto` leg on a `roots` face with file hands: the same change (the class is `probe` whichever layer raised it).
134
+ - An interactive user on `READ_FACE=roots` with the factory `auto`: a read outside the roots from a shell command is now a card (it ran silently).
135
+ - **Compensation.** Declare `readFace: "open"` where the deployment means it (an interactive single-user product says so in one line): the station then vouches a read of an outside path and the classifier keeps answering what the open face still asks. Or answer the card. A deployment that wants the classifier to answer these must run `open`.
136
+ - **Consumers.** server — S-1156: change the pin; the live decision doors' hand-rolled `requiresRealApproval === true` second judge retires for `policyCannotAnswer(held, decidedBy)` (pass the request the approver was handed); the black-box cell "roots × auto × a shell read outside the roots ⇒ a card". cli — KL0144 retires with the pickup. client-core — the arbitration-table mirror: `probe`'s `classifierEligible` in the table is the open face's reading; the folded row takes the face (`foldArbitrationRow(classes, readFace)`). test — two cells: `roots × auto × cat <outside>` ⇒ one probe card (the card `default` gives) and no classifier call; `open × auto` ⇒ the classifier still answers an unplaceable spelling (`cat $(echo <outside>)`; `cat <outside>` itself is vouched on `open`).
137
+ - **Known limits.** The card carries no real-approval bit: in `auto`, as in `default`, a blanket `onAsk: "allow"` or an approver that reports the deployment's policy still answers it — whether a `probe` card should refuse those answers is a separate question, not moved here. The structured `Read` of a file outside the roots on `roots` × `auto` is a different question (class `ordinary`, with a read-root grant offer) and the classifier still answers it — #1245.
138
+ - Pins: `test/backlog1395-narrowed-read-probe-is-a-persons.test.ts` — ① `roots × auto` placed and unplaced reads (the probe card, no classifier call, a yes runs it, a no keeps the content from the model; ①b no seat: refused), ② a blanket / policy allow answers the card, ③ `open` controls, ④ `default` byte-identical and `bypassPermissions` unchanged, ⑤ the structured `Read` and an inside read unchanged, ⑥ a delegated child leg, ⑦ a parked card, ⑧ the fold (every class under both faces), the inherited station on its own and in a real lane, ⑩ the gate driven directly (the face, the mode, no bit minted), ⑨ the package root (and the stray-key shapes); `test/export-surface.snapshot.json` 2604 → 2606; `scripts/consumer-smoke-probe.mjs` (+1 kind); three module ceilings and the phase-api floor raised with reasons.
139
+
140
+ ### Three vocabularies a serving layer copied from comments are on the package root (#1408 / #1409 / #1410; surface; +11 exports, +1 optional key; one bad-value tightening) (@server @client-core @cli @sdk)
141
+
142
+ - **Before (v7.44.x).** A serving layer that mints or maps these facts had to copy them. (1) The stranded-input account's two families — which notice code and which `detail` count key belong to a steer and to a follow-up — were written in a doc comment (`engine-notice.d.ts`), and the tier-to-lane reading (`"later"` rides the follow-up lane, `"next"` and `"now"` the steer lane) was a comparison written inline at two injection seats; only `steerPriorityOf` was exported. (2) The memory store's form refusals — `memory.target_outside_store` and `memory.scope_home_not_directory`, and the three words a scope home's `detail.form` can say — lived in `layout.ts` and were not reachable from the package root. (3) A session-rule write refused for its tool name (`SessionPolicyError`, code `legacy_tool_name`) carried the entry, the verdict and the successor but not the judge's sentence; a host that wanted it asked `judgeRuleToolName` a second time and fell back to a line of its own when the two disagreed.
143
+ - **Now.**
144
+ - **#1408.** `USER_INPUT_UNDRAINED_FAMILIES` (frozen) is keyed by lane — `steer`, `followUp` — and each row is `{ code, countKey }`: `task.user_steer_undrained` / `steer` and `task.user_followup_undrained` / `followUp`. The minter `undrainedUserInputNotices` writes those two spellings (the notice's own rows and the parent's copy); the doc comment now cites the table instead of restating it. `injectionLaneOf(priority)` answers the lane a tier rides (`later` ⇒ `followUp`, `now` and `next` ⇒ `steer`); the steer verb and the notification lane both ask it, and a parked row that carries no tier reads `injectionLaneOf(steerPriorityOf(row))`. The type is `UserInputLane`. A tier outside `now | next | later` (`undefined`, a word, a number, `null`, an object) throws a coded `steering.invalid_content` error whose sentence names the set (`a steer priority must be one of now/next/later, got …`; a long string is clipped, a non-string is named by its kind, nothing of the value is called); `steerPriorityOf` goes through the same check and sentence, so an absent tier is the only thing read as `next` (before, it returned any value it was given as it was, and `null` read as `next`). `crashed` rows stay the serving layer's to mint: the minter is not exported (its sentence tells of a run that ended first), the table is what a serving layer needs to pick the family and spell the count.
145
+ - **#1409.** `STORE_FORM_REFUSAL_CODES` (frozen, the two codes) with its type `StoreFormRefusalCode`; the two constants `TARGET_OUTSIDE_STORE` and `SCOPE_HOME_NOT_DIRECTORY` (each typed as a member of the set); `SCOPE_HOME_FORMS` (`symlink`, `file`, `other` — the object the refusal's `detail.form` is typed by) with `ScopeHomeForm`; and the shapes `StoreFormRefusal` (a plain `Error` carrying `code` and `detail`) and `StoreFormRefusalDetail` (`{ path, form? }`). One module mints the refusals and exports the vocabulary; no value changed.
146
+ - **#1410.** `SessionRuleNameRefusal.guidance` (optional) is the `guidance` of `judgeRuleToolName` for the same entry, byte for byte — the string the refusal's own sentence quotes. Present for `retired` and `unmintable_segment` (the two verdicts that carry a sentence), absent for `unprefixed_namespace` (that judgment has none). No sentence changed or was added.
147
+ - **Who is hurt.** Nobody the engine itself serves: every tier it passes is in the set (the steer verb and the parked-steer store refuse a tier outside it before anything reads it). A host that called `steerPriorityOf` or `injectionLaneOf` with a value outside the set now gets the coded throw instead of that value back (or `next` for `null`); a parked row handed back by a store that skipped its own write check with such a tier makes the replay comparison throw instead of reporting a `priority` difference. A detail read by exact equality (a host or a test that compared `SessionPolicyError.detail` with `toEqual` against the old four keys) sees one more key on a `retired` or `unmintable_segment` refusal.
148
+ - **Consumers.** server — S-1168: `user-input-ledger.ts` reads its two family rows from `USER_INPUT_UNDRAINED_FAMILIES` and picks the family with `injectionLaneOf(steerPriorityOf(row))`; S-1169: `memory-store-refusal.ts` imports `TARGET_OUTSIDE_STORE`, `SCOPE_HOME_NOT_DIRECTORY` and `STORE_FORM_REFUSAL_CODES` and drops its copies; S-1176: `session-policy-wire.ts` reads `detail.guidance` and drops the second `judgeRuleToolName` call and the hand-written fallback. client-core / cli / sdk — the same names through the package.
149
+ - **Known limits.** The family table covers the account's rows, not its `cause` word: `cause` is the closed `USER_INPUT_UNDRAINED_CAUSES` (a serving layer mints `crashed`). The `crashed` arm's sentence is the serving layer's own.
150
+ - Pins: `test/backlog1408-user-input-families-and-lane.test.ts` (24 cells: the table, the minter's rows, the four spellings of a tier, six values outside the set through both functions, a real stream per tier, no second reading in the two seats), `test/backlog1409-store-form-refusal-closed-set.test.ts` (8 cells: the sets, every seat's code and form, a real file backend per form, the types), `test/backlog1410-refusal-carries-the-judges-sentence.test.ts` (26 cells: seven entries × the in-memory store, the file store and the durable helper; the verdict with no sentence; the other keys; no second copy); the `#1156` and `#1355` detail pins carry the new key; `test/export-surface.snapshot.json` 2606 → 2617; `scripts/consumer-smoke-probe.mjs` (+6 kinds); `docs/CLOSED-SETS.md` (+1 row).
151
+
152
+ ### A removal of the filesystem root, a home, or the task's own directories is one question only a person answers — `dangerous_delete` (#1403; behavior narrowing; +1 class) (@server @cli @client-core @test)
153
+
154
+ - **Before (v7.44.x).** No arm judged WHAT a removal removes when its target was written out. With the environment's HOME inside the task root (a sandbox that puts the user's home under the project tree), `rm -rf ~` and `rm -rf <HOME>` read as an in-root delete of class `ordinary`: any allow-layer member — `Bash(rm:*)` or `Bash` in `TaskSpec.allowedTools` / `RunnerDeps.allowedTools`, a stored allow row, a PreToolUse hook's `allow` — cleared it and the command ran with no card; `bypassPermissions` released it; `acceptEdits` answered it. With the task root inside HOME, on the deployment's `open` read face the same removal (and `rm -rf /`) was the same `ordinary` question; on the `roots` face it was a `probe` question, which `bypassPermissions` releases — so `rm -rf ~`, `rm -rf /` and `bash -c 'rm -rf ~'` ran with no card in `bypassPermissions` whatever the layout. Measured on real Runner lanes over a `NodeExecutionEnv` whose shell is `/usr/bin/true` (nothing ran).
155
+ - **Now.**
156
+ - One predicate (`dangerousRemovalOf`, in `src/tools/fs/removal-targets.ts` beside the removal-target reading it reads — the write-target reading's argv reader) says whether one removal target is a protected location: the filesystem root (a drive root, a UNC share root), a top-level directory (one segment below the root; macOS `/private/{etc,var,tmp,home}` read as the top-level directory it aliases), a home directory (the environment's declared home — the `HOME` the shell child receives — and its resolved spelling), or a workspace directory — the task root, the working directory, an additional working directory — or an ANCESTOR of one. Containment does not enter: the home is protected whether it lies inside the task root or the task root inside it. A trailing `/*` reads as the directory itself (`rm -rf ~/*`, `rm -rf /*`, `rm -rf *` in the task root), as the reference does.
157
+ - One walk over the command's established plan — the read station's own planner and write-target reading — judges every `rm` / `rmdir` operand on the write face (the entry the program unlinks: `rm -rf link` removes the link and is not dangerous even when the link points at HOME; `rm -rf link/` follows it and is), nested shell text included (`bash -c '…'`, `sh -c "…"`, `eval '…'`, a substitution body). A `~user` the environment cannot place reads as a home directory. An operand built from a name the command itself binds (an assignment word, `export` / `declare` included; a `for` / `select` list; a `read`) is judged in every spelling its bindings give it — `D=<HOME>; rm -rf "$D"` and `for d in ./build /; do rm -rf $d; done` are dangerous — and a recursive force `rm` of one whose binding the guard cannot read (`read D`, `D=$(…)`, a value with an expansion) is an unverifiable target (`unverifiable_delete`, the same walk's second word; a name the command does not bind is still the text arm's). A relative removal after a move the text cannot settle is judged where the command started, as the reference does. A shell handed a script that is built only when the command runs — any part of its `-c` text (or `eval`'s words) not literal: `bash -c "$(…)"`, `sh -c "$x"`, `bash -c "exec $X -rf ~"`, `bash -c "echo $X"` — is the same class: nobody can read that script before it runs, and a substituted value can carry a command separator of its own.
158
+ - One mint: the always-on removal guard (`createUnverifiableDeletePolicy`, its `removalAsk`) plans the command, establishes it lexically and through the environment's resolver (bounded), and asks with the guard word `dangerous_delete` and `requiresRealApproval: true` before its unverifiable-target arm (`rm -rf $HOME` with a declared home IS the home). The card's sentence names the location and says no mode, allow rule or classifier clears it.
159
+ - One table row: `MODE_ARBITRATION.dangerous_delete` = `{ bypassImmune: true, classifierEligible: false, sandboxAdmission: false, ruleMayClear: false, hookAskFloor: true }` — the reference implementation's dangerous-removal breaker (bypass-immune; in auto mode never handed to the classifier). It explains the real-approval bit (`RRA_EXPLAINERS`), so the card reads `dangerous_delete`, not `policy_rra`. On the `roots` face the station's `probe` rides beside it (`classes: ["dangerous_delete", "probe"]`; the sentence is the removal's). A matched allow entry is disclosed (`allowRuleShadowed`) with the class's reason.
160
+ - The reference's set includes the working directory and its ancestors; sema's workspace is the task root, the live working directory and the additional working directories (the same question for each), and a `~user` it cannot place reads as a home. **Stricter than the reference (divergence `dangerous-removal-nonliteral-shell-text`):** the reference asks about a `-c` script it cannot check only when the script's text names a removal or puts an expansion where a command starts (cli 2.1.291 `Ynn` / `tye`); sema asks about every shell script built at run time (a value can carry `; rm -rf ~` into `echo $X`).
161
+ - **Who is hurt.**
162
+ - A deployment that cleans up with `rm -rf ~` / `rm -rf $HOME` / `rm -rf <HOME>` in a sandbox whose HOME lies inside the task root: every such removal now asks a person, and no allow member, mode or classifier clears it; with no approver it is refused (the headless sentence names the class: no permission mode releases it).
163
+ - A script deployment that clears the task root with `rm -rf *` (or `rm -rf ./*`, `rm -rf <root>`) in the working directory: it now asks a person every time, and an allow cannot clear it.
164
+ - A removal of a top-level directory (`rmdir /tmp`, `rm -rf /opt`) or of the task root's parent: the same.
165
+ - `bypassPermissions` no longer runs `rm -rf ~` / `rm -rf /` / `bash -c 'rm -rf ~'` / `bash -c "$(…)"` unasked; a run with nobody to ask refuses them. Every `bash -c` / `sh -c` / `eval` whose text is built at run time (`bash -c "$CMD"`, `sh -c "echo $X"`) now asks a person in every mode, and no allow clears it.
166
+ - A recursive force `rm` of a variable the same command sets from something the guard cannot read (`T=$(mktemp -d); …; rm -rf "$T"`, `read D; rm -rf "$D"`) asks as an unverifiable target (bypass-immune; the auto classifier may answer it), where an allow cleared it before when the text arm read the name as assigned.
167
+ - In `auto`, the classifier is no longer asked about these removals (it answered the `ordinary` / `probe` question before).
168
+ - **Compensation.** Remove what is inside the location by name (`rm -rf ./build ./dist`), or confirm the card. Nothing else clears the class by design.
169
+ - **Consumers.** server — the `ASK_CLASSES` mirror gains `dangerous_delete` (immune, not releasable: it never appears on `GateDisposition.released`); the approval card renders the engine's sentence. cli — the card for the new class (its sentence names the location). client-core — `AskClass` +1 and the arbitration-table mirror's row; the allow-layer shadow reason table +1. test — the black-box matrix below (G24).
170
+ - **Known limits.** KL-1403-a: a script fed to a shell on its standard input (`bash <<'EOF' … EOF`, `echo '…' | bash`) is not planned as shell text at this base: `default` asks (`probe`), **`bypassPermissions` releases it** — the plan does not yet cut the body into a nested plan; once it does, the walk covers it with no new reading. KL-1403-b: a relative removal after a move the text cannot settle (`cd "$(…)" && rm -rf name`) is judged against the directory the command started in, as the reference resolves it — a target that is protected only from the unknown directory is not caught. `find ~ -delete` is not a removal program (the reference judges `rm` / `rmdir` only).
171
+ - Pins: `test/backlog1403-dangerous-delete.test.ts` (357 cells: ① HOME inside the root × 3 spellings × 4 modes × 5 allow members; ② the root inside HOME × 4 targets × both read faces; ③ `/`, `/*`, `bash -c`, `sh -c`, `*` in the root, `rmdir /tmp`; ④ controls — `./x`, `<root>/sub`, `$UNSET/`, `rm -rf link`; ⑤ the card and `rm -rf link/`; ⑥ bypass with no approver; ⑥b a blanket `onAsk: "allow"`; ⑦ a delegated child; ⑧ runtime-built scripts; ⑨ the predicate alone; ⑩ operands built from names the command binds, and a removal after an unsettled move).
172
+
1
173
  ## 7.44.0 — 2026-10-07
2
174
 
3
175
  **Hard-breaking changes (the whole release):** one — #1362. **#1362 — What breaks:** the closed set `USER_STEER_UNDRAINED_CAUSES` and its type `UserSteerUndrainedCause` are renamed `USER_INPUT_UNDRAINED_CAUSES` / `UserInputUndrainedCause` — the same two words in the same order, no alias left behind. **Who goes red:** a host that imports either old name from the package root (a TypeScript build fails; plain JavaScript reads `undefined`), and a downstream table generated from or pinned to the old name. **How to fix:** import the new names; nothing else about the set changed.
@@ -15,7 +15,7 @@ export { childThinkingSeat, deploymentSubagentThinking, deploymentSubagentTierMo
15
15
  import { OUTPUT_TOOL_NAME, REPORT_BLOCKED_TOOL_NAME } from "../core/runner/synthetic-tools.js";
16
16
  import { TOOL_SEARCH_NAME } from "../core/runner/tool-disclosure.js";
17
17
  import { OFFLOAD_TOOL_NAME } from "../core/tool-result-store.js";
18
- import { resolveToolSubset, toolNameAllowed } from "./tool-filter.js";
18
+ import { readToolNameList, resolveToolSubset, snapshotDefinitionLists, snapshotToolNameList, toolNameAllowed, toolNameListFlaw } from "./tool-filter.js";
19
19
  import { builtinAgentDefinitions } from "./builtin-agents.js";
20
20
  import { SUBAGENT_PROMPT } from "../prompts/default.js";
21
21
  import { hasSessionFork } from "../core/session.js";
@@ -267,6 +267,17 @@ export function forkGovernanceDenial(enableFork, allowFork) {
267
267
  return "principal";
268
268
  return undefined;
269
269
  }
270
+ const worktreeUnavailableSentence = (reason) => `Sub-agent not started: isolation "worktree" is unavailable on this run (${reason}). Retry without the isolation parameter.`;
271
+ const WORKTREE_UNAVAILABLE = {
272
+ readOnly: worktreeUnavailableSentence("no worktree capability — a read-only run mints no worktree"),
273
+ needsFactory: worktreeUnavailableSentence("this deployment has no executionEnvFactory — a static execution environment has one shared working directory, so the child would run in this run's own root instead of a worktree; deploy an executionEnvFactory to enable worktree isolation"),
274
+ seatSilent: worktreeUnavailableSentence("the runner this tool runs children on does not declare whether it can place a child in a tree other than the caller's (its seat has no canRerootChild), and an undeclared answer is read as no; use a Runner, or add that method to the seat"),
275
+ };
276
+ const isolationUnavailable = (content) => ({
277
+ isError: true,
278
+ content,
279
+ details: { error: "isolation.unavailable" },
280
+ });
270
281
  export function createSubagentWorktreeHelper(baseEnv, repoRoot) {
271
282
  const handles = new Map();
272
283
  return {
@@ -1259,13 +1270,15 @@ export function createSubagentTool(opts) {
1259
1270
  opts = { ...opts, builtinAgents: catalog.builtinAgents };
1260
1271
  }
1261
1272
  const assemblyModels = opts.models ?? catalog?.models;
1273
+ if (opts.denyTools !== undefined)
1274
+ opts = { ...opts, denyTools: snapshotToolNameList(opts.denyTools) };
1275
+ if (opts.allowTools !== undefined)
1276
+ opts = { ...opts, allowTools: snapshotToolNameList(opts.allowTools) };
1262
1277
  if (opts.agents?.length) {
1263
1278
  opts = {
1264
1279
  ...opts,
1265
1280
  agents: opts.agents.map((a) => ({
1266
- ...a,
1267
- ...(a.allowTools !== undefined ? { allowTools: [...a.allowTools] } : {}),
1268
- ...(a.denyTools !== undefined ? { denyTools: [...a.denyTools] } : {}),
1281
+ ...snapshotDefinitionLists(a),
1269
1282
  ...(a.skills !== undefined ? { skills: [...a.skills] } : {}),
1270
1283
  })),
1271
1284
  };
@@ -1302,9 +1315,11 @@ export function createSubagentTool(opts) {
1302
1315
  return built;
1303
1316
  }
1304
1317
  export function agentToolsNote(def) {
1305
- const allow = def.allowTools;
1306
- const hasAllow = allow !== undefined && allow.length > 0 && !allow.includes("*");
1307
- const deny = def.denyTools ?? [];
1318
+ const allowRead = readToolNameList(def.allowTools);
1319
+ const allow = allowRead.kind === "entries" ? allowRead.entries.filter((entry) => typeof entry === "string") : [];
1320
+ const hasAllow = allow.length > 0 && !allow.includes("*");
1321
+ const denyRead = readToolNameList(def.denyTools);
1322
+ const deny = denyRead.kind === "entries" ? denyRead.entries.filter((entry) => typeof entry === "string") : [];
1308
1323
  const hasDeny = deny.length > 0;
1309
1324
  if (hasAllow && hasDeny) {
1310
1325
  const denied = new Set(deny);
@@ -1367,6 +1382,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1367
1382
  ...(a.allowTools !== undefined ? { allowTools: a.allowTools } : {}),
1368
1383
  ...(a.denyTools !== undefined ? { denyTools: a.denyTools } : {}),
1369
1384
  ...(depthPermitsNesting && toolNameAllowed(delegationToolName, a.allowTools, a.denyTools) ? { canRedelegate: true } : {}),
1385
+ ...(depth === 0 && !builtins.includes(a) ? { authored: true } : {}),
1370
1386
  })),
1371
1387
  ...(forkOffered ? [{ name: FORK_SUBAGENT_TYPE, ...(depthPermitsNesting ? { canRedelegate: true } : {}) }] : []),
1372
1388
  ];
@@ -1384,6 +1400,8 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1384
1400
  ...toolFace("agent"),
1385
1401
  agentListing,
1386
1402
  agentToolFaces,
1403
+ ...(depth === 0 && opts.allowTools !== undefined ? { agentToolLevelAllowTools: opts.allowTools } : {}),
1404
+ ...(depth === 0 && opts.denyTools !== undefined ? { agentToolLevelDenyTools: opts.denyTools } : {}),
1387
1405
  ...(agentToolPool !== undefined ? { agentToolPool } : {}),
1388
1406
  ...(rosterNames !== undefined ? { agentModels: rosterNames } : {}),
1389
1407
  executionMode: "parallel",
@@ -1527,6 +1545,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1527
1545
  details: { error: "unknown subagent_type" },
1528
1546
  };
1529
1547
  }
1548
+ const allowFlaw = toolNameListFlaw(`delegation tool "${toolName}": ${def !== undefined ? `agent definition "${def.name}"` : "the tool-level list"}`, "allowed", def !== undefined ? def.allowTools : opts.allowTools);
1549
+ if (allowFlaw !== undefined)
1550
+ return { isError: true, content: `Sub-agent not started: ${inlineUntrusted(allowFlaw, 480)}`, details: { error: "config.agent.unknown_tool" } };
1530
1551
  if (a.name !== undefined && typeof a.name !== "string") {
1531
1552
  return { isError: true, content: "Sub-agent not started: name must be a string.", details: { error: "name.invalid" } };
1532
1553
  }
@@ -1717,13 +1738,11 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1717
1738
  let worktreeDir;
1718
1739
  if (reviveClaim === undefined && (a.isolation ?? def?.isolation) === "worktree") {
1719
1740
  const iso = ctx.worktreeIsolation;
1720
- if (!iso) {
1721
- return {
1722
- isError: true,
1723
- content: `Sub-agent not started: isolation "worktree" is unavailable on this run (no worktree capability — read-only run or no execution environment). Retry without the isolation parameter.`,
1724
- details: { error: "isolation.unavailable" },
1725
- };
1726
- }
1741
+ if (!iso)
1742
+ return isolationUnavailable(WORKTREE_UNAVAILABLE.readOnly);
1743
+ const reroots = opts.runner.canRerootChild?.();
1744
+ if (reroots !== true)
1745
+ return isolationUnavailable(reroots === undefined ? WORKTREE_UNAVAILABLE.seatSilent : WORKTREE_UNAVAILABLE.needsFactory);
1727
1746
  try {
1728
1747
  worktreeDir = (await iso.mint(ctx.toolCallId)).worktreeDir;
1729
1748
  }
@@ -1,4 +1,4 @@
1
- import type { ToolSpec } from "../core/types.js";
1
+ import type { AgentDefinition, ToolSpec } from "../core/types.js";
2
2
  /**
3
3
  * Per-agent tool allow/deny (design/38 1A). Statically narrows a tool POOL to the subset a delegated
4
4
  * sub-agent / team member may use — assembly-time filtering (the tool simply never appears in the
@@ -16,13 +16,74 @@ import type { ToolSpec } from "../core/types.js";
16
16
  * Anchored (dual-leg verified): this spawn-time item-level filter IS the upstream shape for allow
17
17
  * entries — CC's resolveAgentTools partitions unknown names into an `invalidTools` bucket nobody
18
18
  * consumes at runtime, and a live 2.1.207 probe shows the agent stays listed/delegable with the
19
- * unknown item silently dropped, zero warnings. The asymmetric prepare-time fail-loud for
20
- * `TaskSpec.agents` DENY entries is a deliberate sema extension (no upstream deny-list exists): an
21
- * allow-typo silently narrows (safe direction), a deny-typo silently widens (unacceptable).
19
+ * unknown item silently dropped, zero warnings. A DENY entry is read as the reference reads it
20
+ * ({@link readDeniedEntry}: `Tool(content)` names `Tool`, the content is not read, the whole tool is hidden). The
21
+ * list is judged once before the run, from every place a deployment writes one (prepare's
22
+ * `judgeDeniedToolNames`): a spelling that can name no tool is refused, the rest is told to the host.
22
23
  *
23
24
  * Note: `"*"` in `allowTools` is the "allow all" sentinel, so a tool literally NAMED `"*"` cannot be
24
25
  * the sole allowed tool via this path (tool names are conventionally identifiers, so this is moot).
25
26
  */
27
+ /**
28
+ * How ONE denied-tools entry is read — the reference reads each as a rule string, without trimming: `Tool(content)` names
29
+ * `Tool` (the content is dropped, so the whole tool is hidden), `Tool()` and `Tool(*)` are the bare name, and anything else
30
+ * — a malformed paren form, an entry with surrounding whitespace — is the whole string, which names no tool. A value that is
31
+ * not a string (a list a host read from a file can hold anything) names no tool either: it reads as `not_a_string` with a
32
+ * fixed label of its TYPE, and nothing about the value is read, converted or printed — `typeof` and the array test only.
33
+ */
34
+ export type DeniedEntryReading = {
35
+ readonly kind: "name";
36
+ readonly name: string;
37
+ readonly content: boolean;
38
+ } | {
39
+ readonly kind: "not_a_string";
40
+ readonly type: string;
41
+ };
42
+ export declare function readDeniedEntry(entry: unknown): DeniedEntryReading;
43
+ /** A fixed label of a host value's TYPE for a sentence that must not print or convert the value (`typeof` and the array test only). */
44
+ export declare function valueTypeLabel(value: unknown): string;
45
+ /**
46
+ * How a tool-name list's VALUE (an agent definition's allowed-tools or denied-tools list) reads as a list. A host that reads its
47
+ * definitions from a file can hand over anything, and a list can be hostile as a container (an accessor or a proxy trap that
48
+ * throws, an overridden iterator, holes): it is read BY INDEX over its length, once, into a dense copy — no iterator of the host
49
+ * runs, a hole reads as `undefined` (an entry that is not a string) — and a value that is not an array, or an array whose read
50
+ * throws, is reported as such and never printed or converted.
51
+ */
52
+ export type ToolNameListReading = {
53
+ readonly kind: "entries";
54
+ readonly entries: readonly unknown[];
55
+ } | {
56
+ readonly kind: "not_a_list";
57
+ readonly type: string;
58
+ } | {
59
+ readonly kind: "unreadable";
60
+ };
61
+ export declare function readToolNameList(value: unknown): ToolNameListReading;
62
+ /** A tool-name list as the mount keeps it: the dense copy when it reads as a list, the failure marker when it cannot be read, else
63
+ * the value itself (not a list: the judge refuses it, and its type never changes). */
64
+ export declare function snapshotToolNameList(value: unknown): unknown;
65
+ /** The two tool-name lists of a definition, and the words a sentence calls each. */
66
+ export type ToolNameListSide = "allowed" | "denied";
67
+ /** The sentence for an entry that is not a string: it can be no tool name; the sentence names its place and its type, never its value. */
68
+ export declare function notAStringSentence(where: string, side: ToolNameListSide, index: number, type: string): string;
69
+ /** The sentence for a value that is not a list, or a list that cannot be read: it names the type, never the value. */
70
+ export declare function notAListSentence(where: string, side: ToolNameListSide, list: Exclude<ToolNameListReading, {
71
+ kind: "entries";
72
+ }>): string;
73
+ /**
74
+ * Why a host-written tool-name list cannot be one, as a sentence — or `undefined` when it is absent (`undefined` / `null`) or a list
75
+ * of strings. The one reading the judgment before the run and the spawn-time check of a tool that never went through prepare share.
76
+ */
77
+ export declare function toolNameListFlaw(where: string, side: ToolNameListSide, value: unknown): string | undefined;
78
+ /** A definition with its two tool-name lists replaced by their one copy — the copy every later reader (the judgment before the
79
+ * run, the roster, the filter at each spawn) takes instead of the host's container. */
80
+ export declare function snapshotDefinitionLists(def: AgentDefinition): AgentDefinition;
81
+ /**
82
+ * The names an allowed-tools value admits, or `undefined` when it names no allowlist: absent, `null`, or a list holding the
83
+ * wildcard. A list admits its string entries (an entry that is not a string admits nothing); a value that is not a list admits
84
+ * NOTHING — the judge before the run refuses it, and a reader that meets it first fails closed.
85
+ */
86
+ export declare function allowedToolNames(allowTools: unknown): ReadonlySet<string> | undefined;
26
87
  /**
27
88
  * Membership check for ONE tool name under the same allow/deny semantics as {@link resolveToolSubset}
28
89
  * (canonicalized both sides; `"*"` allow sentinel; deny wins). Used by the delegation tool to decide