@sema-agent/core 7.43.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.
- package/CHANGELOG.md +245 -0
- package/dist/agents/subagent.d.ts +2 -1
- package/dist/agents/subagent.js +33 -14
- package/dist/agents/tool-filter.d.ts +65 -4
- package/dist/agents/tool-filter.js +127 -4
- package/dist/brain/errors.d.ts +23 -2
- package/dist/brain/errors.js +26 -2
- package/dist/brain/stream-engine.d.ts +2 -1
- package/dist/brain/stream-engine.js +10 -9
- package/dist/core/agent-definition.d.ts +2 -2
- package/dist/core/allow-entry.d.ts +25 -1
- package/dist/core/allow-entry.js +29 -4
- package/dist/core/ask-origin.d.ts +23 -4
- package/dist/core/ask-origin.js +10 -4
- package/dist/core/checkpoint-store.d.ts +28 -15
- package/dist/core/checkpoint-store.js +4 -13
- package/dist/core/decision-receipt.js +1 -1
- package/dist/core/effective-path-target.d.ts +7 -6
- package/dist/core/effective-path-target.js +3 -7
- package/dist/core/engine-notice.d.ts +33 -14
- package/dist/core/engine-notice.js +14 -7
- package/dist/core/gate-lanes.js +2 -0
- package/dist/core/gate-outcome.d.ts +5 -0
- package/dist/core/gate-outcome.js +2 -0
- package/dist/core/governance-codes.d.ts +4 -3
- package/dist/core/governance-codes.js +1 -0
- package/dist/core/hooks.d.ts +2 -0
- package/dist/core/memory-engine/content-origin.d.ts +0 -34
- package/dist/core/memory-engine/content-origin.js +9 -4
- package/dist/core/memory-engine/engine.d.ts +2 -2
- package/dist/core/memory-engine/engine.js +7 -14
- package/dist/core/memory-engine/file-backend.d.ts +2 -2
- package/dist/core/memory-engine/file-backend.js +5 -7
- package/dist/core/memory-engine/layout.d.ts +41 -4
- package/dist/core/memory-engine/layout.js +35 -5
- package/dist/core/permission-rules.d.ts +4 -0
- package/dist/core/permission-rules.js +5 -2
- package/dist/core/real-approval.d.ts +9 -6
- package/dist/core/real-approval.js +5 -5
- package/dist/core/runner/child-tree.d.ts +13 -0
- package/dist/core/runner/child-tree.js +3 -0
- package/dist/core/runner/contracts.d.ts +9 -18
- package/dist/core/runner/prepare-ask-lane.d.ts +2 -0
- package/dist/core/runner/prepare-ask-lane.js +2 -2
- package/dist/core/runner/prepare-config-doors.js +3 -1
- package/dist/core/runner/prepare-execution-env.js +2 -1
- package/dist/core/runner/prepare-gate-stations.d.ts +3 -0
- package/dist/core/runner/prepare-gate-stations.js +2 -1
- package/dist/core/runner/prepare-listings.d.ts +4 -4
- package/dist/core/runner/prepare-listings.js +2 -22
- package/dist/core/runner/prepare-park-ask.d.ts +2 -0
- package/dist/core/runner/prepare-park-ask.js +2 -2
- package/dist/core/runner/prepare-policy-chain.d.ts +2 -0
- package/dist/core/runner/prepare-policy-chain.js +4 -2
- package/dist/core/runner/prepare-task.js +10 -6
- package/dist/core/runner/resume-policy-outcome.js +2 -1
- package/dist/core/runner/run-leg.js +1 -1
- package/dist/core/runner/run-notification-lane.js +2 -2
- package/dist/core/runner/runtask.d.ts +2 -0
- package/dist/core/runner/runtask.js +3 -5
- package/dist/core/runner/steer-admission.d.ts +10 -11
- package/dist/core/runner/steer-admission.js +6 -8
- package/dist/core/runner/stream-steer-verb.d.ts +5 -4
- package/dist/core/runner/stream-steer-verb.js +8 -10
- package/dist/core/runner/sub-agent-deny-lists.d.ts +32 -0
- package/dist/core/runner/sub-agent-deny-lists.js +121 -0
- package/dist/core/runner/synthetic-tools.d.ts +1 -1
- package/dist/core/runner/synthetic-tools.js +3 -2
- package/dist/core/session-policy-store.d.ts +6 -2
- package/dist/core/session-policy-store.js +2 -1
- package/dist/core/skill-grant.d.ts +10 -2
- package/dist/core/skill-grant.js +20 -4
- package/dist/core/steer-replay.d.ts +46 -0
- package/dist/core/steer-replay.js +22 -0
- package/dist/core/task-event.d.ts +3 -2
- package/dist/core/task-notification.d.ts +12 -1
- package/dist/core/task-notification.js +13 -1
- package/dist/core/task-spec.d.ts +5 -3
- package/dist/core/task-stream.d.ts +12 -6
- package/dist/core/tool-errors.d.ts +2 -2
- package/dist/core/tool-policy.d.ts +15 -4
- package/dist/core/tool-policy.js +9 -13
- package/dist/core/tool-spec.d.ts +20 -2
- package/dist/core/types.d.ts +2 -2
- package/dist/core/types.js +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.js +7 -4
- package/dist/orchestration/workflow-types.d.ts +5 -0
- package/dist/orchestration/workflow.d.ts +3 -2
- package/dist/orchestration/workflow.js +16 -7
- package/dist/tools/fs/removal-targets.d.ts +82 -0
- package/dist/tools/fs/removal-targets.js +236 -0
- package/dist/tools/fs/shell-read-write-targets.d.ts +27 -0
- package/dist/tools/fs/shell-read-write-targets.js +1 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +43 -5
- package/wire-changes/core@7.44.0.json +469 -0
- package/wire-changes/core@7.45.0.json +1084 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,248 @@
|
|
|
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
|
+
|
|
173
|
+
## 7.44.0 — 2026-10-07
|
|
174
|
+
|
|
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.
|
|
176
|
+
|
|
177
|
+
**Package exports (the whole release):** +5 / −2 — +`PROXY_TUNNEL_REFUSED` (#1157), +`USER_INPUT_UNDRAINED_CAUSES`, +`UserInputUndrainedCause` (#1362), +`STEER_REPLAY_FACETS`, +`SteerReplayFacet` (#1374); −`USER_STEER_UNDRAINED_CAUSES`, −`UserSteerUndrainedCause` (#1362). **Closed sets (the whole release):** `UserSteerUndrainedCause` → `UserInputUndrainedCause` (renamed, members unchanged); new `SteerReplayFacet`; new `ScopeHomeForm`; the memory code registry +1 (`memory.scope_home_not_directory`). The paragraphs below state each ticket's own share.
|
|
178
|
+
|
|
179
|
+
**#1319 —** hard-breaking: none; closed sets: none; exports: none added or removed; the `WorkflowRun.parks` entry gains two optional members, `parkedBy` and `generation` (wire typeKeys +2, both optional).
|
|
180
|
+
|
|
181
|
+
**#1319 — behavior:** a workflow run record's `parks` entry names the run standing on the park and the generation of the call's park line; a recorded entry whose `parkedBy` / `generation` is malformed, or that carries one without the other, refuses the resume as a malformed entry (`workflow.park_binding_broken`).
|
|
182
|
+
|
|
183
|
+
**#1157 —** hard-breaking: none; closed sets: none added (`BrainRetryErrClass`, `BrainStatusPhase`, `BrainErrorCode`, `TerminalFailureDetail` unchanged; the `connect_refused` bucket now also names a proxy refusal). Exports: +1 / −0 — `PROXY_TUNNEL_REFUSED`. **Behavior narrowing:** one — a proxy that refuses the tunnel no longer gets the long retry ladder (see below).
|
|
184
|
+
|
|
185
|
+
**#1368 —** hard-breaking: none; closed sets: +1 — `ScopeHomeForm` (`symlink` | `file` | `other`, the `detail.form` of the new refusal below); the memory code registry +1 (`memory.scope_home_not_directory`, non-governance, control-plane IO). Exports: none added or removed. **Behavior:** a memory scope home that is not a real directory is refused with a code instead of the code-less control-plane error, and a coded store refusal's sentence carries no host path (it rides `detail.path`).
|
|
186
|
+
|
|
187
|
+
**#1362 / #1374 —** closed sets: `UserSteerUndrainedCause` → `UserInputUndrainedCause` (renamed, members unchanged); new `SteerReplayFacet` (`text`, `trusted`, `priority`, `actor`). Exports: +4 / −2 — +`USER_INPUT_UNDRAINED_CAUSES`, +`UserInputUndrainedCause`, +`STEER_REPLAY_FACETS`, +`SteerReplayFacet`; −`USER_STEER_UNDRAINED_CAUSES`, −`UserSteerUndrainedCause`. New optional member: `CheckpointError` `detail.differs`.
|
|
188
|
+
|
|
189
|
+
**#1362 / #1374 — behavior:** `task.user_followup_undrained` now carries `detail.cause` (`leg_end` from this engine) like `task.user_steer_undrained` (#1362); `steering.duplicate_input_id` now carries `detail.differs` on both faces, and a repeat that differs only in tier / trust / attribution is no longer told to re-issue under a fresh `inputId` (#1374). What counts as a replay is unchanged.
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
### A workflow park entry names who stands on it — `parkedBy` / `generation` (#1319; additive record members) (@server @client-core @test)
|
|
193
|
+
|
|
194
|
+
- **Before (v7.43.x).** An entry carried `callKey` / `sessionId` / `token`, `originRunId` (the run whose journal holds the park's material — not the run standing on it) and `originUnconfirmed`. A same-token re-park whose journal append failed and an inherited park the run never reached left the SAME entry on the successor (measured on the base: `{ token, sessionId, originRunId: <prior run> }` in both), and nothing said which record of a resume chain is its tail — so a consumer routed a decision by the record's `agents[]` rows and redeemed it by `parks[]`, two readings of one question.
|
|
195
|
+
- **Now.** "Does this record's run hold this park" has ONE reading: `entry.parkedBy === record.id`.
|
|
196
|
+
- `parkAgentLeg` writes `parkedBy: <this run>` and `generation` in the synchronous stretch that sets the row `parked`, on both branches (a new token, a same-token re-park) and on every spawn lane (`ctx.agent`, `ctx.parallel` legs, `ctx.agentStream` — measured: the same entry shape on all three), before any journal append is awaited: a failed append leaves `originRunId` / `originUnconfirmed` as they were and moves neither member.
|
|
197
|
+
- `generation` is the generation of the call's inherited entry, else `0`: a call's first park in its chain is `0`; a park at a call whose entry the run inherited keeps that entry's generation — the same token again, **or a new token after the inherited one was driven** (C-R251: resetting a new token to `0` would tie with the earlier record's entry on the same child session and read as a fork).
|
|
198
|
+
- Resume admission carries `parkedBy` verbatim and adds one to `generation`, at the one site that establishes the inherited set (a recorded entry whose empty token or session another key cache completes keeps its pair; a half it does name that contradicts that cache is now a broken binding, `workflow.park_binding_broken`, where 7.43.x took the other cache's coordinate silently) — so an entry inherited and not reached names the run that parked it, and along a chain the record holding the highest generation on a session is the tail. Two records holding the same generation are a fork (one record resumed twice in turn); the engine admits both resumes (concurrent resumes are already excluded by the resume claim) and leaves the refusal to the consumer.
|
|
199
|
+
- The two members travel together. An entry written before they existed — an older engine's list, or keys derived from the journal / agent rows of a record with no list — carries neither after inheritance (nothing writes them onto it) and proves no holder; the new engine reaching such a park writes its own line at `generation: 0`.
|
|
200
|
+
- A recorded entry whose `parkedBy` is not a non-empty string, whose `generation` is not a non-negative integer, or that carries one without the other is a malformed entry: the resume is refused before anything is dispatched (`workflow.park_binding_broken`).
|
|
201
|
+
- **Who is hurt.** A record written by an older engine carries neither member: a consumer that routes by the new reading proves no holder for it (the serving layer answers its existing `decide.workflow_parks_unreadable` and drains such records before upgrading — its half, S-266). A store or tool that rewrites a `parks` entry and mangles a member (a column store turning `generation` into text) now has the next resume refused instead of carried. A probe that pinned the exact entry shape (the four established members) sees two more. A fork whose one branch drove the token while the other stood on it (R1 resumed as R2 and R2′; R2′ re-parks on T, R2 drives T and its child pauses on T′): both hold an entry on the session at generation 1, so the consumer's fork refusal answers the new card until one branch is resumed.
|
|
202
|
+
- **Compensation.** A reader that ignores unknown members needs nothing. A store that projects the record by column carries the two members verbatim (they are part of the `parks` entry, whose projection obligation is unchanged); dropping them turns the record into an older engine's, never into a wrong holder.
|
|
203
|
+
- **Consumers.** server (S-266) — route and redeem from `parks[]` alone: the rows holding a session are the entries with `parkedBy === record.id`; the highest `generation` held by exactly one record routes, a tie is the fork 409, a record with no holder entry for the session answers as today; its design's "a new token's first park is 0" reads "a call's first park in its chain is 0; a new token at a call whose entry the run inherited keeps that generation" (C-R251); the parallel and stream lanes write the same entry shape as the sequential one (one line); `agents[]` routing retires. client-core — mirror the two optional members on the run record's `parks` entry. test — five cells (G1–G5: a fresh park on each lane; the chain — inherited not reached, re-park, drive to a new token, the tail; a lost journal append; a fork admitted; an older record and a malformed member).
|
|
204
|
+
- Pins: `test/backlog1319-park-holder-generation.test.ts` (① lanes and the same-write snapshot, ② inheritance and the chain's tail, ③ the lost append, ④ the fork, ④b both-or-neither on every entry written, ⑤ older records, ⑤b a recorded entry completed by the journal keeps its holder and a contradicting half refuses, ⑥ malformed members; real `runWorkflow` over a runner stub and a checkpoint seat); re-pinned the exact entry shape in `backlog757-parks-persist-order`, `backlog652-park-truth-from-store`, `backlog755-resume-migration`, `backlog642-workflow-park-lane`.
|
|
205
|
+
|
|
206
|
+
### A proxy that refuses the tunnel is a definite answer: short ladder, a plain status line, a sentence chosen by the proxy's status (#1157; behavior; +1 export) (@server @cli @client-core @test)
|
|
207
|
+
|
|
208
|
+
- **Before (v7.43.x).** An outbound proxy that answered the tunnel request (`CONNECT`) with 403 / 407 reached the engine as a `fetch failed` whose cause carried no code the connect judge knew, so it was read as indeterminate and took the full transport ladder: 11 attempts, 185 s on the production backoff (measured on a fake clock against a fake `fetch`: 11 calls, 185 155 ms), every wait frame saying `connection lost, reconnecting` (n/10), and a terminal failure `[network] fetch failed (…)` that never said a proxy had refused (test 7.103.0 acceptance, `sema -p` 188.8 s with no output).
|
|
209
|
+
- **Now.** `PROXY_TUNNEL_REFUSED` (`"ERR_PROXY_TUNNEL_REFUSED"`, exported at the package root) is a member of the same table as `ECONNREFUSED` / `ENOTFOUND`, read by the same one judge (`readConnectFailure`, one walk of the `cause` / `errors` graph; `classifyConnectFailure` is its verdict). A graph whose proxy-refusal node sits anywhere in it, under code-less wrappers, is a definite connect answer: the short lane (2 retries by default, ≤1 s backoff ceiling; 3 calls, 1 584 ms on the same fake clock) with the wait frames reading `proxy refused the tunnel, retrying briefly` (`phase` stays `reconnecting`, `errClass: "connect_refused"`; no status on the frame) and a terminal failure that begins with one sentence chosen by the status the proxy stated on that node (`statusCode`, an integer 100–599 naming a failure): 407 `proxy authentication required (HTTP 407)`, 403 `proxy forbids this destination (HTTP 403)`, any other `proxy refused the tunnel (HTTP <n>)`, none readable `proxy refused the tunnel`; the cause chain follows after ` — `. The unanimity rule is unchanged: a foreign code anywhere in the graph (a reset above or below the node, an aggregate leg that timed out) keeps the long ladder. `deterministicConnectRetries` at or above `maxRetries` turns the short lane off for it too (long ladder, frame `proxy refused the tunnel, retrying`).
|
|
210
|
+
- **The fetch-throw gate reads the same table.** A `TypeError` the runtime's shell does not name and that carries no `cause` used to be read as "the request was never built" (`invalid_request`, no retry); one whose own node states a definite connect answer (`PROXY_TUNNEL_REFUSED`, and by the same table `ECONNREFUSED` / `ENOTFOUND`) is now transport evidence — the short lane and the proxy sentence apply to it too (a hand-written `fetchImpl` that forwards the dispatcher's stamped error without a wrapper). A causeless `TypeError` with any other code, or none, is unchanged.
|
|
211
|
+
- **The proxy's status is not published as the provider's.** `terminal.detail` (`provider_rejected`), `TaskResult.apiFailure.status`, `BrainError.status` and the retry frame's `errorStatus` stay absent for this failure: they say what the PROVIDER answered, and a 407 from a proxy read there would send a consumer to "change the request and resend" (pinned; one mutation of it reds).
|
|
212
|
+
- **Who is hurt.** A deployment whose proxy refuses the tunnel for a short while and then recovers (a credential rotation, a policy push taking tens of seconds) rode the long ladder to success; it now fails after two retries (~2 s). A consumer matching the terminal text `[network] fetch failed (…)` for this failure sees the sentence first. Nothing else moves: a silent proxy (dial deadline), a reset and every undecoded `OutboundProxyError` keep the transport ladder.
|
|
213
|
+
- **Compensation.** `deterministicConnectRetries` ≥ `maxRetries` restores the long ladder (with the proxy wording on the frames). A host that must branch on 407 / 403 reads the sentence's `(HTTP n)` today; a structural seat for the status (a new `terminal.detail` kind) is not in this version.
|
|
214
|
+
- **Consumers.** server (S-803) — in the dispatcher's non-200 `CONNECT` branch, mint the error with `code: PROXY_TUNNEL_REFUSED` (import it from `@sema-agent/core`) and the proxy's status as a numeric `statusCode` on the same node; dial failures keep wrapping the original errno error with no code of their own (the errno below decides, as today). The node's own cause must carry no code: a stock undici `ProxyAgent` spells this refusal with `UND_ERR_ABORTED` beneath `fetch failed`, and a stamp placed over that coded error is read as indeterminate (long ladder; measured with undici 7.30.0 against a listener answering `CONNECT` with 407). Core has not verified that a custom code on that node leaves undici's connection-phase recovery alone (the dispatcher's own note ties "no code" to that); that check is the server's. cli — nothing to render specially: the failure text and the frame detail carry the words. client-core — `BrainRetryErrClass` unchanged; if a mirror lists the root exports, add `PROXY_TUNNEL_REFUSED` (a string). test — acceptance cell: a fake `fetch` rejecting `TypeError("fetch failed", { cause: { code: "ERR_PROXY_TUNNEL_REFUSED", statusCode: 407 } })` ends in ~2 s with the 407 sentence; the same error without the code ends after 11 attempts.
|
|
215
|
+
- **Known limits.** KL-1157-a (Bun hands a proxy's CONNECT 403 back as a Response, not a throw: read as `auth` / "gateway HTTP 403", no code to carry; no text sniffing). KL-1157-b (the fetch-throw gate reads the connect judge's unanimous verdict as transport evidence: a hand-built causeless `TypeError` stamped with a table code over a mixed aggregate still reads unbuildable, as before; a plain object copying an invalid-URL cause under a proxy stamp reads transport). The MCP / A2A failure table reads an `ERR_*` code as unknown (no `delivered: no` for a refused tunnel) — separate seat, not this version.
|
|
216
|
+
- Pins: `test/backlog1157-proxy-tunnel-refused.test.ts` (36 cells: ladder × {407, 403, 502} on a fake clock with the production backoff, the code-less control and a foreign-coded wrapper on the long ladder, lane-off wording, recovery, three sentences + seven unreadable statuses + chain evidence, a Runner end to end, the provider-status seats absent, a stamped causeless `TypeError` through the fetch-throw gate and the engine, the node at the top / under the shell / mid-chain / sole aggregate leg, three unanimity vetoes, a cyclic and a hostile graph, the exported constant); `test/export-surface.snapshot.json` (+1 name); smoke decision row. Base v7.43.x: 17 of the first 22 cells red (11 attempts, 185 s); the stamped-`TypeError` cells were red before the fetch-throw gate read the table.
|
|
217
|
+
|
|
218
|
+
### A scope home that is not a real directory is refused with its own code, and a coded store refusal's sentence says no host path (#1368; behavior) (@server @client-core @test)
|
|
219
|
+
|
|
220
|
+
- **Before (v7.43.0 and earlier).** A memory scope home (`<memoryDir>/<label>`) replaced by a symbolic link, a regular file or any other non-directory was refused with a `ControlPlaneCorruptError` that carried no `code` and no structured member — `scope home <path> is a symbolic link — a scope home must be a real directory (fail-closed)` — the class the control plane raises for damaged ledgers, so a host could not tell an unmet layout precondition from damage, and a host that maps coded memory refusals to a conflict answer and everything else to an internal error answered this swap with the latter (a 500, on the server's black-box report) while the same swap one level down (a topic directory replaced by a link out) answered the coded `memory.target_outside_store` (a 409). That code's sentences named host paths (the act's target and where it resolved, the store root and where it now resolves, and on a journal replay the journal's own path) and the error carried no `detail`.
|
|
221
|
+
- **Now.** `memory.scope_home_not_directory` — a plain `Error` with `.code` and `.detail: { path, form }`, `form` one of `"symlink" | "file" | "other"` judged by `lstat` (a link is never followed; a dangling one is a link, not an absent home) — on every face that reaches a home: registration (`registerScope`, an engine mount, the file backend's reads and its commit planning), the layout adoption's owner-home pre-checks, and `ensureScopeHome`'s own creation (a `recursive` mkdir over a link to a directory reports success and the check after it refuses it; a mkdir that fails because a file, FIFO or dangling link stands there re-judges the form and refuses with the code; nothing is registered). One minter in `layout.ts` (`storeFormRefusal`) with three seats — an act refused, a root that moved, a home that is not a directory — and `memory.target_outside_store` is minted by the same function: the code leads the sentence, the sentence names no path, `detail` is `{ path }` (the act's target, or the memory dir's spelling for a moved root). The hand-written sentences in `engine.ts` / `file-backend.ts` are gone. The verdict `landsOutsideStore` answers a boolean (its reason string was where two of the paths came from). **Error class:** the home arm moves from `ControlPlaneCorruptError` (`name: "ControlPlaneCorruptError"`, no `code`) to a plain `Error` (`name: "Error"`) with `code`; `memory.target_outside_store` stays a plain `Error` with `code` and gains `detail`.
|
|
222
|
+
- **Who is hurt.** A host or log script that matched the old sentence text (`scope home … is a symbolic link`, `… resolves to <dir>, outside the store root <root>`, the path inside any of them) or tested `instanceof ControlPlaneCorruptError` for a scope home that is not a directory. Every line built from the sentence loses the path with it: the harvest's `incident.detail` and `warnings` text, the batch planning's per-patch conflict `reason` (`io error: …`).
|
|
223
|
+
- **Compensation.** Branch on `code`; read `detail.path` / `detail.form` for the path and the form. `instanceof ControlPlaneCorruptError` still holds for every real control-plane damage arm; only the home-form arm left it.
|
|
224
|
+
- **Consumers.** server (S-806) — the `targetOutsideStoreOf` family reads one more code (`memory.scope_home_not_directory`), and a flat store and a nested store with a swapped root now answer the same conflict; an operator leg that relays the sentence relays no path (it is `detail.path`). client-core — the memory-code mirror +1. test — a swapped-root black-box in both layouts (entries directly in the home / under a topic directory).
|
|
225
|
+
- **Known limits.** A scope home swapped for a file, FIFO or dangling link in the window between a face's form check and one of the four `mkdirSync(dirname(<target>), { recursive: true })` calls that create an entry's directory (the backend's staging of a flat entry, the engine's missing-file restore, its index write and its projection write) still surfaces the file system's own error (`EEXIST` / `ENOENT`, host path in its text, no code) there; the refusal stands and nothing is written outside, only the code is missing. Not reachable without a concurrent actor swapping the home inside that window (pin ① swaps before the face runs and every face answers the code). A TOPIC directory replaced by a regular file (not a link) answers the same native `EEXIST` on a write under it, on this version and the last. Both are #1378 (one directory-creation seat that answers the code); the commit planning's `conflicts[]` entry still carries the refusal as text only (the code leads the sentence, no `code` member) — #1378 too. Coded memory refusals outside the store-form family that still spell a host path (`config.memory_scope_placement`, `config.memory_project_marker_nested`) and the layout preconditions still reported as control-plane damage are #1379.
|
|
226
|
+
- Pins: `test/backlog1368-scope-home-not-directory.test.ts` — ① both layouts × symlink / file / FIFO on registration, the file backend's read, an engine mount and the commit planning, ② `ensureScopeHome` (link to a directory, a link / file / FIFO / dangling link racing in before the mkdir, a dangling link, the layout adoption's owner-home check, controls), ③ the four `memory.target_outside_store` arms path-free with `detail.path`, ④ one mint site and the closed form set; re-pinned `backlog1094` ① / ⑭a (the sentence names no path; `detail.path` does) and `memory-layout-migration` P2 (a home that is a link / a file answers the code).
|
|
227
|
+
|
|
228
|
+
### The two undrained families name the cause from one set under one name (#1362; hard-breaking surface) (@server @client-core @test)
|
|
229
|
+
|
|
230
|
+
- **Before (v7.43.x).** `task.user_steer_undrained` carried `detail.cause` (closed `USER_STEER_UNDRAINED_CAUSES`: `leg_end` minted here, `crashed` minted by a serving layer for steers the dead process never confirmed consumed); `task.user_followup_undrained` carried none. A `later` steer rides the follow-up lane, so the same loss was `task.user_followup_undrained` with no cause when the leg ended, and — counted by a serving layer after a crash — a steer-family row with `cause: "crashed"`: the code changed with who reported it.
|
|
231
|
+
- **Now.** One set, one name: `USER_INPUT_UNDRAINED_CAUSES` (`src/core/engine-notice.ts`), the `detail.cause` of BOTH families. `undrainedUserInputNotices` stamps one value for both rows — the leg's own and a parent's copy (`{ followUp, cause: "leg_end", taskId?, sessionId?, runId? }`; the copy `{ followUp, cause: "leg_end", taskId?, sessionId, childSessionId }`). Codes stay two and follow the LANE the input was queued on (steer: `next` / `now`; follow-up: `later`); the `NOTICE_MINTER` member-level exception now covers both codes — their `crashed` arm is the serving layer's, choosing the family by lane, not by verb. No new code; `ENGINE_NOTICE_CODES`, `NOTICE_AUDIENCE` and `NOTICE_MINTER` rows unchanged.
|
|
232
|
+
- **Who is hurt.** See the hard-breaking line (the rename). A consumer that pinned the follow-up row's `detail` exactly sees one more key (`cause`). A mirror of the set downstream renames with it.
|
|
233
|
+
- **Compensation.** Import `USER_INPUT_UNDRAINED_CAUSES` / `UserInputUndrainedCause`; read `cause` on either family the same way.
|
|
234
|
+
- **Consumers.** server (S-1122) — the crash arm picks the code by the lane the lost input was queued on (`later` ⇒ `task.user_followup_undrained` with `cause: "crashed"`), not by the request verb; rename the mirrored set. client-core — rename the mirror table of the set. test — both families' `cause` on a real leg end and on a parent's copy.
|
|
235
|
+
- Pins: `test/backlog1362-undrained-cause-one-vocabulary.test.ts` (⑦u the factory, ⑦ two real lanes on a child leg — a `later` steer stranded by a halt, a `next` steer cut by interrupt —, ⑧ the package root and the export snapshot, ⑨ CLOSED-SETS and the minter's exception sentence); re-pinned `backlog1233-steer-undrained-cause` (② the follow-up row now carries the cause), `backlog1279-child-undrained-to-parent`, `ref-b6-harness-injection-enqueue`.
|
|
236
|
+
|
|
237
|
+
### `steering.duplicate_input_id` says which facets differ — one judgement for both seats, and no fresh-key advice for the same instruction (#1374) (@server @client-core @test)
|
|
238
|
+
|
|
239
|
+
- **Before (v7.43.x).** Two comparators wrote the same four-facet test twice — the parked queue's (`appendPendingSteer`) over the caller's text, the live verb's over the delivered payload (the framed bytes, with trust and the speaker label folded in) — and both seats answered every non-replay with one sentence, "re-issue this one with a fresh inputId", and no `detail`. A client that re-sent the SAME instruction at another tier (or trust, or attribution) under its key and followed the sentence queued the instruction twice.
|
|
240
|
+
- **Now.** One function, `steerReplayDiffers` (`src/core/steer-replay.ts`), answers which facets a repeat differs in — closed `STEER_REPLAY_FACETS`: `text`, `trusted`, `priority` (absent reads `next`), `actor` (`id`, `hostAsserted`, `issuer`) — in the set's order; empty is the idempotent replay. Both seats judge what the caller SENT (the live seat remembers the caller's record, the one a durable park carries), and the same answer feeds the decision, the refusal's `detail.differs` (by name only, never the values; always present on this code, never empty) and one sentence mint: with `text` listed, "a different steering instruction is already parked under inputId "<id>" / accepted under this inputId (this repeat differs in: …) — re-issue this one with a fresh inputId …"; without, "the same steering instruction is already … ; this repeat differs only in: …, which cannot change after acceptance — sending it again under another inputId would queue it a second time; to confirm the standing receipt, replay it with its original values". The parked face carries it as `CheckpointError.detail.differs`, the live face on the rejected `Error` (`code` + `detail`). One code for every arm. What counts as a replay is unchanged: the live seat's frame and speaker label are a one-to-one function of the caller's text for a given trust, speaker and run (`frameMidTurnUserInput` / `projectHumanInput` / `mintSystemReminder`), so comparing the text in place of the delivered bytes answers the same for every pair; the delivered-payload rule for what books a key (an untrusted whitespace-only steer queues nothing and reserves nothing; a trusted blank one does) is untouched.
|
|
241
|
+
- **Who is hurt.** A host that matched the old sentence text (the code is the contract; it is unchanged). A host that re-sent an accepted instruction at another tier under a fresh key because the sentence told it to — the sentence now says that queues it twice.
|
|
242
|
+
- **Compensation.** Branch on `detail.differs`: includes `"text"` ⇒ another instruction, take a fresh key; otherwise ⇒ the accepted instruction, replay it with its original values (or accept the standing receipt).
|
|
243
|
+
- **Consumers.** server — the 409 body passes `detail.differs` through from both faces (`STEER_REPLAY_FACETS` is exported for its schema). client-core (CC-374) — two arms by whether `differs` includes `"text"`. test — each facet alone on both seats, and the tier-only sentence.
|
|
244
|
+
- Pins: `test/backlog1374-steer-replay-one-difference.test.ts` (① each facet alone × the parked seat on the in-memory and the file store × the live seat on a real run, ② several facets in the set's order, ③ the same value — absent tier × `next`, a fresh actor object, a trusted one — still the idempotent replay, ③b the booking rule for blank steers, ④ the tier-only sentence, ⑤ both seats answer one `differs`, ⑥ one comparison and one sentence under `src/`); re-pinned `backlog1361-steer-priority-one-reading` (the live cell reads the one function), `steer-input-id` (⑤ names the facets), `steering-pending`.
|
|
245
|
+
|
|
1
246
|
## 7.43.0 — 2026-10-06
|
|
2
247
|
|
|
3
248
|
**Hard-breaking changes:** one — #1318. **#1318 — What breaks:** the parameter spelling of a Skill rule — `Skill(skill:<name>)`, and `Skill(args:…)` / `Skill(skill:*)` with it — a working but non-reference spelling in a caller policy (`createPermissionRulePolicy`), is refused at construction (`config.invalid_permission_rules`, one `issues[]` entry per rule with `code: "invalid.command_rule"`, the sentence naming `Skill(<name>)` and `Skill(<name>:*)`; under `onInvalidRule: "skip"` it is reported through `onIssue` and dropped, so a deny written that way stops firing until it is respelled); the reference's own spelling `Skill(<name>)` / `Skill(<prefix>:*)`, which 7.42.x refused (`invalid.param_split` in a policy, `unsupported.tool` in the persisted grammar), is now the one spelling, read by both compilers. **Who is hurt:** a host that wrote the parameter spelling — the serving layer that encodes a `permissions.ask` / `permissions.deny` entry of the Skill tool into policy rules, a hand-written policy; a probe that pinned `Skill(<name>)` as refused; a consumer that switches exhaustively over `PersistedRuleMatch`. An organization rule file with a `Skill(…)` row still refuses the WHOLE snapshot (the org grammar is the shell's alone — unchanged). **Compensation:** respell `Skill(skill:deploy)` as `Skill(deploy)`, and a wildcard on the skill (`Skill(skill:deploy*)`) as `Skill(deploy:*)` (a name PREFIX — no word boundary).
|
|
@@ -361,7 +361,8 @@ export interface SubagentSteerHandle {
|
|
|
361
361
|
* contract, value domain and typed refusals are that verb's; absent ⇒ byte-identical to every
|
|
362
362
|
* pre-existing call). ⚠️ Same deliberate arm gap as the workflow twin: each call mints a FRESH
|
|
363
363
|
* correlation marker into the framing, so a retry under the same id is a same-id-DIFFERENT-
|
|
364
|
-
* instruction call and refuses typed `steering.duplicate_input_id`
|
|
364
|
+
* instruction call and refuses typed `steering.duplicate_input_id` (`detail.differs` always names `text`
|
|
365
|
+
* here) — the key buys AT-MOST-ONCE
|
|
365
366
|
* (loud refusal), never silent idempotent replay. */
|
|
366
367
|
steer: (content: string, opts?: {
|
|
367
368
|
inputId?: string;
|
package/dist/agents/subagent.js
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
|
|
1306
|
-
const
|
|
1307
|
-
const
|
|
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
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
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
|
}
|