@sema-agent/core 7.9.0 → 7.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/agents/child-model-seat.d.ts +51 -0
  3. package/dist/agents/child-model-seat.js +34 -0
  4. package/dist/agents/subagent.d.ts +4 -2
  5. package/dist/agents/subagent.js +16 -12
  6. package/dist/core/ask-origin.d.ts +7 -3
  7. package/dist/core/checkpoint-store.d.ts +2 -7
  8. package/dist/core/effective-path-target.d.ts +43 -0
  9. package/dist/core/effective-path-target.js +56 -0
  10. package/dist/core/engine-notice.d.ts +8 -0
  11. package/dist/core/fs-write-gate-policy.js +2 -1
  12. package/dist/core/gate-lanes.d.ts +1 -0
  13. package/dist/core/gate-lanes.js +11 -5
  14. package/dist/core/governance-codes.d.ts +1 -1
  15. package/dist/core/governance-codes.js +2 -0
  16. package/dist/core/hooks.d.ts +10 -1
  17. package/dist/core/mcp-injection-drop.d.ts +74 -0
  18. package/dist/core/mcp-injection-drop.js +27 -0
  19. package/dist/core/permission-rule-model.d.ts +91 -75
  20. package/dist/core/permission-rule-model.js +90 -111
  21. package/dist/core/permission-rule-org.d.ts +12 -6
  22. package/dist/core/permission-rule-org.js +9 -3
  23. package/dist/core/permission-rules.d.ts +3 -2
  24. package/dist/core/permission-rules.js +38 -15
  25. package/dist/core/persisted-rule-arms.d.ts +7 -2
  26. package/dist/core/persisted-rule-arms.js +3 -1
  27. package/dist/core/roles.d.ts +8 -0
  28. package/dist/core/runner/active-skill-scope.js +2 -1
  29. package/dist/core/runner/contracts.d.ts +3 -2
  30. package/dist/core/runner/permission-rule-lanes.d.ts +12 -4
  31. package/dist/core/runner/permission-rule-lanes.js +13 -15
  32. package/dist/core/runner/prepare-caps-and-workflow.js +2 -2
  33. package/dist/core/runner/runtask.d.ts +2 -1
  34. package/dist/core/runner/runtask.js +1 -1
  35. package/dist/core/runner/session-rule-policy.js +2 -1
  36. package/dist/core/runner/tool-face-overlay.js +22 -3
  37. package/dist/core/sensitive-path-policy.js +5 -3
  38. package/dist/core/shell-lexer.d.ts +47 -0
  39. package/dist/core/shell-lexer.js +478 -0
  40. package/dist/core/shell-scan.d.ts +60 -0
  41. package/dist/core/shell-scan.js +183 -0
  42. package/dist/core/shell-wrapper-table.d.ts +297 -0
  43. package/dist/core/shell-wrapper-table.js +58 -0
  44. package/dist/core/tool-catalog-entries.js +6 -6
  45. package/dist/core/tool-face.d.ts +80 -4
  46. package/dist/core/tool-face.js +10 -0
  47. package/dist/core/tool-policy.d.ts +1 -6
  48. package/dist/core/tool-registry.d.ts +8 -11
  49. package/dist/core/tool-registry.js +5 -2
  50. package/dist/core/tool-roster.d.ts +11 -2
  51. package/dist/core/tool-roster.js +21 -3
  52. package/dist/index.d.ts +6 -3
  53. package/dist/index.js +5 -2
  54. package/dist/orchestration/workflow.js +21 -4
  55. package/dist/tools/fs/fs-search-tools.d.ts +3 -2
  56. package/dist/tools/fs/fs-search-tools.js +17 -9
  57. package/dist/tools/fs/index.js +1 -1
  58. package/dist/tools/fs/search.d.ts +0 -8
  59. package/dist/tools/fs/search.js +0 -23
  60. package/package.json +1 -1
  61. package/test/export-surface.snapshot.json +57 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.9.2 — 2026-09-09
4
+
5
+ ### Fixes — a delegated child runs on the parent run's model, not the deployment's boot default (#641 · B-050; @cli @server)
6
+ - **One seat, one rule.** `childModelSeat` (subagent.ts) is now the only place an engine-spawned run's model is decided — the delegation child (`buildChildSpec`, every lane: sync / background / fork / revival) and the observer sidecar share it: an EXPLICIT model for the spawn (per-call `model` > agent definition `model` > tool-level `model`) > the deployment's STATED subagent tier (`roles.subagent` naming a model source of its own — the literal `"default"` spells "no tier of my own", a `select` form counts as stated) > the **parent run's effective model** (the caller's current `Model` object, per-model routing and credentials included) > the bare `subagent` role (only reachable outside a Runner-enriched ctx). The cell that changed is the last resort: it used to be `FALLBACK.subagent → "default"`, i.e. the boot default — a run whose main turn ran on model B spawned children on boot model A, and a bare deployment (catalog, no roles) spawned children that died at prepare with `No model for role "subagent"`. Both now run on the parent's model. A deployment that stated a cheap tier keeps it (that statement is operator intent; children do not follow the parent there).
7
+ - **Role identity rides the seat.** Every non-explicit child spec carries `modelRole: "subagent"` beside the inherited `model`, so a deployment's `roles.subagent` `systemPrompt` / `thinking` preset still applies when the model is the parent's (the explicit-model branch of `resolveTaskModel` keys presets on the role).
8
+ - **Precedence note (definition lane).** The tool-level `model` (`createSubagentTool({ model })`) is an explicit seat and now outranks "inherit the parent" for a definition that names no model; it used to sit below the parent snapshot on that lane only. Fork children are unchanged (always the caller's model).
9
+ - **The workflow lane reads the same seat.** A model-less script child (`agent()` / `agentStream()`) used to take the host's `Model` unconditionally; the workflow mount's host-model getter now folds the deployment's stated tier first (`deploymentSubagentTierModel(runnerSelf.agentCatalog) ?? host model`, prepare-caps-and-workflow.ts — read off the LIVE catalog per call, so a `swapModels` between two spawns is seen by the second), so the Agent lanes and the workflow lane can no longer answer one deployment two ways. A script `model` and an `agentType` model keep outranking it (explicit), the allow-list keeps gating script words. A stated tier whose catalog ref is dead fails THAT call on the recorded path (failed agent record + `agent_end`, then the throw the script's own `catch` sees — the unknown-`agentType` shape; the next call's slot is released), never a substituted model. Both lanes' pre-launch catch now stamps an ordinary throw's message as the record's `errorMessage` (no machine code minted), so the row says why the ordinal has no child — previously only a review refusal or a budget stop named its cause.
10
+ - **The durable row names the inherited model.** A background child's record (`model`, the tier-3 revival key) records the seat's model — the parent's id for an inherited child, exactly what the child's own ticks say — so a revival from another parent keeps running the model the row was spawned on instead of silently moving to the reviver's; a stated-tier child's row stays blank as before (the role resolves it).
11
+ - **Read face (additive, @server @cli):** `Runner.agentCatalog` / `RunnerSelfSeat.agentCatalog` gains `roles?: ModelRoles` (a copy of `RunnerDeps.roles`) — the seat's reader. No wire change; no request-lane roles seat (the deployment/request boundary stands).
12
+ - **Engine side channels are unchanged by design** (`@contract roles.side_channels_deployment_only`): the auto-mode classifier, the compaction/summarize lane, memory consolidation and next-prompt suggestion resolve on the deployment roles alone and never take a run's model — they are deployment tools, not children of the run.
13
+ - Consumers: `@cli` the `_subagent-model-follows-run-live-pty.mjs` probe's `EXPECTED_VERDICT=b` door flips to `a` on this engine; `@server` zero code (`roles.subagent = "default"` when `MODEL_CHEAP_ID` is unset is exactly the "not stated" spelling).
14
+
15
+ ### Docs — the sub-run inheritance matrix (#643; @cli @server @test)
16
+ - `docs/SUBRUN-INHERITANCE.md`: every inheritance face (model, approver seat + park facility, posture + spawn review, inherited gate + ancestor rules, thinking, role fallback, budgets/TTLs, event-forwarding identity keys, principal + auth hook, session/run lineage, contentOrigin) × every child lane (Agent sync / background / workflow agents / fork·team·synthesize / scheduler wake-up / observer·verifier·advisor / resume-rebuilt / tier-3 revival), each cell "inherits / falls back to (file:line) / gated?". Appendix A lists every deployment-default fallback position with its verdict (design → contract sentence; defect → ticket); Appendix B lists every fail-closed deny mouth with who reaches it and where it is visible.
17
+ - **B-049 (#642) is DEFINED here, not fixed in this release** (see the matrix §B-049): a workflow-lane child whose inherited ask the host approver answers `unavailable` denies fail-closed exactly like a SYNC delegation child (pinned three-lane: `test/backlog642-child-ask-lanes.test.ts`); only the BACKGROUND lane parks, because only it is handed the parent's `durableApproval` (§7.3 park family). Making workflow children park is the same park family extended to the workflow row (`wa*` row parked state + the workflow's own pause cascade + a resume path) — a design slice, scheduled separately; the interim is the loud terminal (the deny sentence in the tool result and `tool_end.gate.settlement.kind = "approver_unavailable"`).
18
+
19
+ ## 7.9.1 — 2026-09-08
20
+
21
+ ### Wire (additive) — `mcp.injection_dropped`: the request-lane MCP drop reaches the user (L-167; @server @cli @client-core)
22
+ - **One mint, host facts.** A host that drops an entry of the user's own request-lane MCP injection (a `.mcp.json` row) now hands the FACTS to core — `mcpInjectionDroppedNotice({ sessionId, server, reason, field? })` — and core composes the notice: code `mcp.injection_dropped`, audience `user`, one sentence per reason naming the recovery. The host spells no code, audience or text (`@contract mcp.injection_dropped.single_mint`); a fact outside the vocabulary (an unlisted reason, an empty session or server name) is refused at the mint with a `TypeError`, never shaped into a notice that routes nowhere.
23
+ - **Closed set** `McpInjectionDropReason` = `malformed_entry` / `name_reserved_by_deployment` / `gate_closed` / `over_cap` (`MCP_INJECTION_DROP_REASONS`, registered in CLOSED-SETS with its text table's fence); `detail: { sessionId, server, reason, field? }` — `sessionId` is REQUIRED (the routing half of a user row) and lifted onto the typed key by the one delivery throat; `field` present only for a non-empty host-supplied key. Dedup unit is the host's: per leg per server name.
24
+ - **Consumers:** `ENGINE_NOTICE_CODES` +1 (`mcp.injection_dropped`, `NOTICE_AUDIENCE` row `user`) — a downstream audience mirror diffing against the catalog gains one row; server's `engine_notice` whitelist +1; cli renders one user-level transcript line off `detail.reason`. Exports +5 (`MCP_INJECTION_DROP_REASONS`, `MCP_INJECTION_DROP_TEXT`, `mcpInjectionDroppedNotice`, `McpInjectionDropReason`, `McpInjectionDropFacts`).
25
+
26
+ ### Narrowing — a path rule is judged against the path the tool really opens (#635)
27
+ - **One effective-target resolver, shared with tool execution.** `ToolPathTarget` gains three resolution members — `base` (`"cwd" | "root"`, absent ⇒ `cwd`), `absent` (`"none" | "base"`, absent ⇒ `none`) and `patternParam` (the parameter whose ABSOLUTE glob pattern carries its own base directory) — declared by Read/Edit/Write/NotebookEdit (`cwd`/`none`) and Grep/Glob (`root`/`base`; Glob + `pattern`). `effectivePathTargetOf` (new export, with `pathTargetBaseOf`, `pathTargetSlotOnly` and the closed sets `TOOL_PATH_BASES` / `TOOL_PATH_ABSENCES`) is the one reader: both permission-rule lanes judge a call with it, and the four canonicalizing write fences plus the two search tools share the BASE SELECTION (and, for Glob, the absolute-pattern split) before applying their own identity to it — lexical-normal for a rule, canonical for a tool. One declaration, one selection, so a fence and the tool it fences can no longer resolve one call to two files. **Three shapes now REACH the rules that name them** (each was silently unreachable before): `Grep(//proj/**)` vs a Grep with the path omitted (the tool searches the root); `Glob(//proj/secret/**)` vs `{pattern:"/proj/secret/**"}` (the absolute pattern is the base); and a relative Grep `path`, which resolves against the ROOT as the tool does rather than against the live `cd`. The file tools are byte-unchanged (their declared `cwd` base is the old guess). One honesty change: a relative spelling with NO usable base is now "no verdict" instead of being fabricated into `/<spelling>`.
28
+ - **Wire (additive, @server @cli @client-core @web-admin):** `ToolRosterEntry.pathTarget` carries optional `base` / `absent` / `patternParam` (present only when the definition declares them), and so do `ToolCallFace.pathTarget` and the parked row's face — without them the gate's stamped face loses the facts that say which file a call is about. A mirrored row schema with `additionalProperties: false` must admit the three keys. `ToolPathTarget` / `ToolCallFace.pathTarget` / `ToolCallLike.face.pathTarget` / the checkpoint parked-face row now all name ONE shape: `ToolCallPathTarget` is the READER type (both spellings satisfy it) and `ToolRosterPathTarget` its NORMALIZED spelling (`aliases` and `skillScopeEligible` always present, as the mint has always filled them) — both new exports; the call face and the parked row keep the normalized one, so a consumer already reading `face.pathTarget.aliases` compiles unchanged. `@server` `pathBases` is unchanged.
29
+ - **The declaration door** (`toolFaceProblem`, caller faces and MCP `toolFaces` alike) refuses a `base` or `absent` word outside its closed set, a `patternParam` that is not a top-level schema property, a `patternParam` declared while `absent` is not `"base"` (it could never be read), **`base: "root"` on a face whose `access` is not `"read"`** — two write guards not taught by this change (the session-transcript directory guard, which is handed no root at all, and the frozen-spec deny) resolve a write target against the call's working directory alone, so a write face declaring a root base would be judged on two different files; refused until #638 threads the base through both, with no built-in affected (the three write tools declare `base: "cwd"`, the two search tools are reads) — and **`absent: "base"` on a face whose `access` is not `"read"`** — that word says "with no path, the call is about the whole base directory", which is a search SCOPE; a write with no path names no file, and a write face declaring it would be judged on its base by the rule lanes and on nothing by the containment fences (a writer could then evade the sensitive-path guard by omitting its path). A refused face does not mount, as before.
30
+ - **An EMPTY path slot is absent on both sides.** Grep and Glob read their scope argument with the engine's own slot reader (first NON-EMPTY string under the declared param) instead of `!== undefined`. Behavior change, small and in the closing direction: `Glob({pattern:"/proj/secret/**", path:""})` now decomposes the absolute pattern exactly as an omitted `path` does (it used to fold `""` to the root and match the absolute pattern there, while every fence judged the pattern's own base — one call, two directories, which is how a root deny could be walked past). `Grep` with `path:""` still searches the whole root, as it did.
31
+ - **A win-family spelling lands where the env puts it.** `canonicalizeTarget` never joins a spelling it calls absolute (a drive letter, a UNC root) to the base a tool hands it — it passes the spelling through and the execution env resolves it against its own working directory. So the resolver does not apply the DECLARED base to such a spelling either; it uses the tracked cwd with the root as the floor, which is what the tool's env will do. Without this, `Grep({path:"C:/secret.txt"})` under root `/` with a live `cd` to `/proj` was judged `/C:/secret.txt` while the tool read `/proj/C:/secret.txt`, and `Grep(//proj/**)` — a deny that fired in 7.9.0 — missed. The source-side fix is #637 (the canonicalizer's absoluteness is family-blind); this branch retires with it.
32
+ - **The protective floor hands over the slot, not the resolution.** A call whose face declares no path target of its own is judged against the catalog's declaration for the NAME (design/388 B6) — a floor whose whole justification is "naming a slot can only tighten a deny". Naming a slot does; claiming how a FOREIGN tool of the same name resolves that slot does not, so the floor now supplies `param`/`aliases`/`access` and lets the resolution members default (`pathTargetSlotOnly`, new export). Net effect versus 7.9.0 for a call that carries a face: none — this keeps the floor where it was while the declarations moved. A BARE request (no face at all — a policy invoked outside a Runner) does read the catalog whole, resolution members included, which is the point: there the catalog IS the tool being judged.
33
+
34
+ ### Narrowing — the tightening (deny/ask) reach reads a real shell lexer (#631)
35
+ - **Reach.** `programRunReachOf(rule, command)` answers the closed set `ProgramRunReach` = `reached` / `not_reached` / `unreadable` (registered in CLOSED-SETS); `ruleReachesProgramRun` is its boolean face (unchanged signature, `reached` only). The command is read by `src/core/shell-lexer.ts` (no grammar library): quotes and escapes, every expansion form, the full redirection operator set (IO_NUMBER, `&>`/`&>>`, `N>&M-`, `>&-`, `<>`, `<<<`, here-documents whose bodies are skipped), every connector (`&` and a newline bound segments), subshell/group boundaries, leading keywords (`if rm -r x; then …` runs `rm`), leading assignments, and the closed wrapper table `SHELL_WRAPPER_TABLE` (`command`/`builtin`/`exec`/`env`/`sudo`/`doas`/`pkexec`/`nohup`/`time`/`nice`/`timeout`/`stdbuf`/`xargs`/`watch`/`ionice`/`setsid`/`taskset`/`chrt`/`strace`/`ltrace`/`flock`/`script`/`unshare`/`nsenter`/`noglob`/`nocorrect` — upstream's three strips as one table). Peeling is ADDITIVE (`ShellSegment.peeled`): `sudo rm -r x` is a run of `sudo …` and of `rm -r x`, so a deny on either reaches, and a rule body is never peeled (`Bash(sudo rm -r x)` denies exactly that). A command/process substitution anywhere in a segment (`$(…)`, backticks, `<(…)`, inside `${…}`/`$((…))`, a redirection operand, an unquoted here-document body) marks the segment `peelUnreadable`: the runs it spells are still compared (`echo "$(rm -r x)"` reaches `Bash(echo:*)`), and every other deny/ask reads `unreadable` — the commands INSIDE a substitution are deliberately not read (an approval prompt, never a guessed boundary). Words are compared argv-wise with quotes removed on BOTH sides: `rm "-r" x`, `sudo rm -r x`, `command rm -r x`, `rm -r x; echo "$HOME"`, `rm -r x &> log`, `rm -r x 2>&-`, `npm test & curl x`, `do curl x; done`, `timeout -- 5 rm -r x` now REACH a `Bash(rm -r:*)` / `Bash(curl:*)` deny or ask (7.9.0: silence on every one of them). A `#` comment and a quoted-delimiter here-document body are not runs (7.9.0 judged `echo ok # ; curl x` as a curl run — a limitation, not over-approximation). A leading `~` is an expansion of one word (an exact deny on the expanded path asks; a prefix deny reaches). Module split beside the lexer: `shell-scan.ts` (the byte-walking scanners) and `shell-wrapper-table.ts` (the table).
36
+ - **Unreadable is a fail-closed ask, never silence — and only when a standing deny/ask rule could not be judged** (zero tightening rules ⇒ the lane stays silent, as before). Unreadable = an expansion where the rule reads a word (`rm $X` vs `Bash(rm -r:*)`; `"$@"` / `"${a[@]…}"` after an exact body — they may be zero words), an unterminated quote/substitution, a syntax error (`> ;`, a dangling `&&`), a `for`/`case`/`function` header, a wrapper handed its command as one string (`env -S`, `flock -c`, `script -c`) or to a shell (`watch`, `script`), a wrapper option outside the row's CLOSED option sets (short or long — the table names every option it reads; an unknown one asks rather than guessing its arity), an expanding option value or operand, a positional that does not match (`taskset zz`), more than 8 wrapper layers, a here-document delimiter spelled with an expansion, a command over 10 000 characters (`MAX_SHELL_READ_CHARS`, upstream's own bound). Per lane: the PERSISTED store answers `{ unreadable: true, reason }` — the same shape as an unreadable store (origin `rule_store_unavailable`, real-approval, the classifier never answers; the message says the reason instead of "the store could not be read"); the ORG verdict carries `unreadable: <reason>` beside the first rule it could not judge (`behavior: "ask"`, origin `org_rule`, message names rule + reason); the rule DSL mints `{ action: "ask", matchedAskRule, requiresRealApproval: true }`. Precedence: a deny that cannot be judged outranks an ask that can; within one behavior a reach settles it. **No new AskOrigin word** — `rule_store_unavailable`'s meaning is widened to "the person's deny/ask rows cannot be enforced for this call" (store unreadable OR call unreadable against them; JSDoc amended).
37
+ - **Wire (additive):** `PersistedRuleUnreadable.reason?: string`; `OrgGateVerdict.verdict.unreadable?: string` (only beside `behavior: "ask"`; both screened by the normalizers, a non-string dropped); `PersistedRuleVerdict.unreadable?: string`; `orgRuleVerdictFor` returns `OrgRuleVerdict` (the same two members plus the optional third). New exports: `programRunReachOf`, `PROGRAM_RUN_REACHES`, `ProgramRunReach`, `ProgramRunReachOutcome`, `readShellCommand`, `isFullyReadable`, `MAX_SHELL_READ_CHARS`, `SHELL_WRAPPER_TABLE`, `ShellCommandShape`/`ShellSegment`/`ShellWord`/`ShellWrapperName`, `OrgRuleVerdict`. Retired (internal, never exported): `stripRedirections`, `stripFdDuplications`, `ruleLaneSegmentsOf`, `ruleAdmitsProgramRun`.
38
+ - **The allow side is byte-untouched:** `ruleAdmitsCommand` keeps its whole-string refusal of every shell construct (`rm -r x > f` is admitted by nothing; a quoted spelling is not an allow's equal) — and the deny now reads the quoted spelling, so an exact allow row minted for `rm "-r" x` no longer outruns a `Bash(rm -r:*)` deny.
39
+ - **Merged-tree round (four `not_reached` blind spots, each now `unreadable` or reached at all three lanes, the classifier never consulted):** ① an extended glob's INTERIOR is read for substitutions (`echo @($(rm -r x))` — the pattern's interior runs a program). ② a clustered `xargs -tI R` records the replacement token by the resolved letter (`-tI R rm -R x` no longer reads `rm -R x` as literal). ③ **executors are rows of the same closed table**: `viaShell` now means "evaluates its operands as shell input" (a command string, a script file, an expression) and covers `eval`, `source`, `.`, `trap`, `let`, `sh`/`bash`/`zsh`/`dash`/`ksh`/`fish`/`csh`/`tcsh` beside `watch`/`script` — the string is not split and judged (upstream splits on whitespace; the strict word stands), so `bash -c 'rm -r x'`, `eval 'rm -r x'`, `bash cleanup.sh`, `sudo bash -c …` ask once when a deny/ask rule stands (7.9.0: silence); a new row member `executes` names the words after which a NON-wrapper runs a command anywhere in its argv — `find` with `-exec`/`-execdir`/`-ok`/`-okdir` (such a word, or any expansion among `find`'s arguments, asks; `find . -name '*.ts'` stays silent). The executor's own run is still read (`Bash(eval:*)`, `Bash(find:*)` reach). ④ indirect arithmetic through a parameter SUBSCRIPT (`${arr[a]}`, `${arr[$i]}`, `${#arr[n]}` — bash evaluates an indexed subscript recursively, so a stored `arr[$(…)]` runs; `[@]`/`[*]`/a number name nothing) and through the `(( … ))` command (read as the arithmetic command — a run-less piece — instead of two boundaries around a phantom program) are unreadable, the same law as `$((a))`. `isFullyReadable` (exported; not read by the offer lane, which mints from its own reader) answers false for every shape above. **The round's two adversarial passes (all findings adopted):** a `$…` inside arithmetic or a subscript is a name too (`(( $1 ))`, `${arr[$1]}`); the arithmetic command is read after `time`/`time -p`/`{`/an adjacent `!`, and its interior is judged safe only when it is digits, operators and spaces — `((:);)` (nested subshells bash re-reads after the arithmetic parse fails) asks instead of vanishing; an `xargs -I` token carrying an expansion stops the peel; `$[…]` (legacy arithmetic) and a slice offset/length naming a variable (`${x:a}`, `${x:0:a}`) are hidden runs like a subscript; **builtins whose operands NAME variables** (`VARIABLE_OPERAND_BUILTINS` in the table module: `printf`, `unset`, `read`, `mapfile`/`readarray`, `declare`/`typeset`/`local`/`export`/`readonly`, `test`/`[`/`[[`) mark a hidden run when an operand spells an array element with a non-numeric subscript (`printf -v 'arr[a]'`, `-v'arr[a]'`, `unset arr[a]`, `declare arr[a]=1`, `[[ -v arr[a] ]]`) or an integer-attribute assignment (`declare -i x=a`) — judged at every peel candidate (`command printf -v …`); `compgen`/`complete` with `-C`/`-F` join the `executes` rows; an unclosed `((` is an unreadable tail in linear time. Not judged (documented, deliberate — the price would be a prompt on every `export FOO="$BAR"` / `[[ $n -gt 0 ]]`): an operand that is WHOLLY an expansion to one of those builtins, and an arithmetic comparison's operands.
40
+ - **@cli** (card copy): an ask of origin `rule_store_unavailable` may now be "your command could not be read against your deny/ask rules (<reason>)", not only "the rule store could not be read" — the decision's `message` carries the reason; a card that hard-codes store wording for the origin should read the message. An org ask's message has a second form (`… may reach this call, which could not be read against it (<reason>)`). **@server**: zero required changes (both new members are optional and screened; `OrgGateVerdict` producers may add `unreadable` on an ask verdict).
41
+
3
42
  ## 7.9.0 — 2026-09-08
4
43
 
5
44
  The resume decision ladder leaves runtask.ts, and the two `runtask.ts` seams design/388 slice 1 shipped unwired in 7.8.0 are wired.
@@ -0,0 +1,51 @@
1
+ import type { Model, ModelRef, ModelRoles } from "../core/types.js";
2
+ /** The deployment role map behind the runner (`Runner.agentCatalog.roles`) — same posture as `runnerModelCatalog` (subagent.ts):
3
+ * host code, a throwing/absent stand-in reads as "no role map", never as a failed delegation. */
4
+ export declare function runnerRoleMap(runner: unknown): ModelRoles | undefined;
5
+ /**
6
+ * Did the deployment STATE a model for its subagents? True iff `roles.subagent` names a model source of its
7
+ * own: a string ref other than the literal `"default"`, a `Model` object, or a `{ model | select }` config
8
+ * whose `model` is not that literal. The word `"default"` is how a deployment spells "no subagent tier of my
9
+ * own — same as the main model" (a catalog key of that name is the boot default), so it reads as NOT stated;
10
+ * a role entry carrying only a `thinking` / `systemPrompt` preset states no model either.
11
+ */
12
+ export declare function deploymentStatesSubagentModel(roles: ModelRoles | undefined): boolean;
13
+ /**
14
+ * The deployment's stated subagent tier as a resolved `Model` — for a spawn lane that needs the concrete
15
+ * model up front (the workflow lane keys call identity and the display label on it), so both lanes read the
16
+ * same rule: `undefined` when the deployment stated nothing (the caller then falls to the parent's model),
17
+ * else the role chain's answer for `subagent`. A stated ref the catalog cannot resolve throws here, loudly,
18
+ * exactly where a child's own prepare would have thrown.
19
+ */
20
+ export declare function deploymentSubagentTierModel(deps: {
21
+ models?: Record<string, Model>;
22
+ roles?: ModelRoles;
23
+ } | undefined): Model | undefined;
24
+ /**
25
+ * The child run's model seat — ONE rule for every engine-spawned run (the delegation child at
26
+ * `buildChildSpec`, the observer sidecar): an EXPLICIT model for this spawn wins (per-call `model`, the
27
+ * agent definition's `model`, the tool-level `model`); else a deployment that STATED a subagent tier gets
28
+ * it (`{ modelRole: "subagent" }` — the role chain resolves the stated entry, `select` forms included);
29
+ * else the child runs on the PARENT run's effective model (the caller's current `Model` object, so
30
+ * per-model routing/credentials ride along); else — no parent model readable, which only happens outside
31
+ * a Runner-enriched ctx — the bare role, whose chain ends at the deployment default.
32
+ *
33
+ * The role identity `modelRole: "subagent"` stays on every non-explicit seat: the explicit-model branch of
34
+ * `resolveTaskModel` keys the role's `systemPrompt` / `thinking` presets on it, so a deployment's
35
+ * `roles.subagent` preset is not lost when the MODEL is inherited from the parent.
36
+ *
37
+ * Why "default" was the wrong last resort: `FALLBACK.subagent = ["default"]` resolves to the deployment's
38
+ * boot default, which is not the model the parent run was switched to — a run whose main turn was moved to
39
+ * model B by the caller spawned children on boot model A (and a deployment whose boot default was removed
40
+ * from the roster spawned children that failed at prepare). The parent's effective model is the honest
41
+ * default; the deployment's stated tier keeps outranking it because that statement is operator intent.
42
+ */
43
+ /** The seat: an explicit ref alone, or the role identity with an optional inherited Model (see {@link childModelSeat}). */
44
+ export type ChildModelSeat = {
45
+ model: ModelRef;
46
+ modelRole?: undefined;
47
+ } | {
48
+ modelRole: "subagent";
49
+ model?: Model;
50
+ };
51
+ export declare function childModelSeat(explicit: ModelRef | undefined, deploymentStated: boolean, parent: Model | undefined): ChildModelSeat;
@@ -0,0 +1,34 @@
1
+ import { resolveTaskModel } from "../core/roles.js";
2
+ export function runnerRoleMap(runner) {
3
+ try {
4
+ return runner?.agentCatalog?.roles;
5
+ }
6
+ catch {
7
+ return undefined;
8
+ }
9
+ }
10
+ export function deploymentStatesSubagentModel(roles) {
11
+ const s = roles?.subagent;
12
+ if (s == null)
13
+ return false;
14
+ if (typeof s === "string")
15
+ return s !== "default";
16
+ if ("id" in s && "api" in s)
17
+ return true;
18
+ const cfg = s;
19
+ if (cfg.model !== undefined)
20
+ return cfg.model !== "default";
21
+ return cfg.select !== undefined;
22
+ }
23
+ export function deploymentSubagentTierModel(deps) {
24
+ return deps !== undefined && deploymentStatesSubagentModel(deps.roles) ? resolveTaskModel({ modelRole: "subagent" }, deps).model : undefined;
25
+ }
26
+ export function childModelSeat(explicit, deploymentStated, parent) {
27
+ if (explicit !== undefined)
28
+ return { model: explicit };
29
+ if (deploymentStated)
30
+ return { modelRole: "subagent" };
31
+ if (parent !== undefined)
32
+ return { modelRole: "subagent", model: parent };
33
+ return { modelRole: "subagent" };
34
+ }
@@ -1,5 +1,6 @@
1
1
  import type { RunInternals, RunnerSelfSeat } from "../core/runner/contracts.js";
2
2
  import type { AgentDefinition, Model, ModelRef, TaskLimits, TerminalCause, ToolSpec } from "../core/types.js";
3
+ export { deploymentSubagentTierModel } from "./child-model-seat.js";
3
4
  import { type ExecutionEnv } from "../internal/harness.js";
4
5
  import type { TaskNotificationPayload } from "../core/task-notification.js";
5
6
  import { RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX } from "../config/defaults.js";
@@ -679,12 +680,13 @@ export interface SubagentToolOptions {
679
680
  */
680
681
  ensureChildSessionDurable?: (sessionId: string) => Promise<void>;
681
682
  };
682
- /** Model the child runs on. If omitted, the child resolves the `subagent` role ( `default`). */
683
+ /** Model the child runs on when neither the call nor the agent definition names one (the tool-level explicit seat).
684
+ * Omitted too ⇒ the deployment's stated `roles.subagent` tier, else the parent run's effective model (`childModelSeat`). */
683
685
  model?: ModelRef;
684
686
  /**
685
687
  * Declarative sub-agents the model can pick from (design/38 1B). When set, the tool exposes an `agent`
686
688
  * parameter (enum of these names) and the chosen definition's config builds the child task — its
687
- * `allowTools`/`denyTools` REPLACE the tool-level ones, `model` omitted = inherit the caller's model.
689
+ * `allowTools`/`denyTools` REPLACE the tool-level ones, `model` omitted = tool-level `model` > stated `roles.subagent` tier > the caller's model (`childModelSeat`).
688
690
  * design/141 件A: when OMITTED, defaults to the runner's deployment catalog (`RunnerDeps.agents`) —
689
691
  * one Runner, one registry across the Agent-tool and workflow lanes. Pass explicitly to narrow.
690
692
  */
@@ -7,6 +7,8 @@ import { newDelegationProvenanceAggregate, reduceDelegationAttestation } from ".
7
7
  import { registerDelegationLaunch, replayExternalSettlementEffects, settleDelegation } from "../core/memory-engine/delegation-settlement.js";
8
8
  import { enqueueMemoryAnnouncement } from "../core/memory-engine/layout.js";
9
9
  import { resolveModel, resolveModelDisplayLabel } from "../core/roles.js";
10
+ import { childModelSeat, deploymentStatesSubagentModel, runnerRoleMap } from "./child-model-seat.js";
11
+ export { deploymentSubagentTierModel } from "./child-model-seat.js";
10
12
  import { OUTPUT_TOOL_NAME, REPORT_BLOCKED_TOOL_NAME } from "../core/runner/synthetic-tools.js";
11
13
  import { TOOL_SEARCH_NAME } from "../core/runner/tool-disclosure.js";
12
14
  import { OFFLOAD_TOOL_NAME } from "../core/tool-result-store.js";
@@ -1466,7 +1468,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1466
1468
  })),
1467
1469
  model: Type.Optional(Type.String({
1468
1470
  description: rosterNames !== undefined && rosterNames.length > 0
1469
- ? `Optional model override for this agent. Takes precedence over the agent definition's model. If omitted, uses the agent definition's model, or inherits from the parent. A value outside this deployment's model catalog is REFUSED — the call does not fall back silently. Ignored for subagent_type: "${FORK_SUBAGENT_TYPE}" — forks always inherit the parent model.`
1471
+ ? `Optional model override for this agent. Takes precedence over the agent definition's model. If omitted, uses the agent definition's model, else the deployment's subagent tier, else inherits from the parent. A value outside this deployment's model catalog is REFUSED — the call does not fall back silently. Ignored for subagent_type: "${FORK_SUBAGENT_TYPE}" — forks always inherit the parent model.`
1470
1472
  : `Optional model override request. No model catalog is configured here, so a requested name cannot be resolved — the agent runs its definition's model (or inherits from the parent) and the result notes the override was not applied. Ignored for subagent_type: "${FORK_SUBAGENT_TYPE}".`,
1471
1473
  })),
1472
1474
  isolation: Type.Optional(Type.Literal("worktree", {
@@ -1784,9 +1786,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1784
1786
  const childTools = def
1785
1787
  ? resolveToolSubset(childPool, def.allowTools, def.denyTools)
1786
1788
  : resolveToolSubset(childPool, opts.allowTools, opts.denyTools);
1787
- let childModel = wantsFork
1788
- ? ctx.model ?? opts.model
1789
- : perCallModel ?? (def ? (def.model ?? ctx.model ?? opts.model) : opts.model);
1789
+ const explicitModelChain = wantsFork ? [ctx.model, opts.model] : [perCallModel, def?.model, opts.model];
1790
+ let childModel = explicitModelChain.find((c) => c !== undefined);
1791
+ const deploymentStatedSubagentModel = deploymentStatesSubagentModel(runnerRoleMap(opts.runner));
1790
1792
  if (reviveClaim !== undefined && typeof childModel === "string") {
1791
1793
  const resolvable = (ref) => {
1792
1794
  try {
@@ -1799,24 +1801,26 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1799
1801
  };
1800
1802
  if (!resolvable(childModel)) {
1801
1803
  const dead = childModel;
1802
- const rest = def !== undefined ? [ctx.model, opts.model] : [opts.model];
1804
+ const rest = explicitModelChain.slice(explicitModelChain.indexOf(dead) + 1);
1803
1805
  childModel = rest.find((c) => c !== undefined && (typeof c !== "string" || resolvable(c)));
1804
1806
  if (modelNote === undefined) {
1805
1807
  modelNote = `note: model "${inlineUntrusted(dead, 80)}" from this agent type's configuration is not on the current model roster and was NOT applied — the revived agent ran on its default (inherited) model.`;
1806
1808
  }
1807
1809
  }
1808
1810
  }
1809
- const spawnModel = typeof childModel === "string"
1811
+ const childSeat = childModelSeat(childModel, deploymentStatedSubagentModel, ctx.model);
1812
+ const seatModel = childSeat.model;
1813
+ const spawnModel = typeof seatModel === "string"
1810
1814
  ? (() => {
1811
1815
  try {
1812
- return resolveModel(childModel, judgedModels).id;
1816
+ return resolveModel(seatModel, judgedModels).id;
1813
1817
  }
1814
1818
  catch {
1815
- return resolveModelDisplayLabel(childModel);
1819
+ return resolveModelDisplayLabel(seatModel);
1816
1820
  }
1817
1821
  })()
1818
- : childModel?.id;
1819
- const rowModelKey = perCallModel !== undefined ? requestedModel ?? perCallModel.id : typeof childModel === "string" ? childModel : childModel?.id;
1822
+ : seatModel?.id;
1823
+ const rowModelKey = perCallModel !== undefined ? requestedModel ?? perCallModel.id : typeof seatModel === "string" ? seatModel : seatModel?.id;
1820
1824
  const childSystemPrompt = def?.systemPrompt ?? opts.systemPrompt;
1821
1825
  const childDefaultPersona = childSystemPrompt === undefined && !wantsFork ? SUBAGENT_PROMPT : undefined;
1822
1826
  const defMaxTurns = typeof def?.maxTurns === "number" && Number.isFinite(def.maxTurns) && def.maxTurns > 0 ? def.maxTurns : undefined;
@@ -1888,7 +1892,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1888
1892
  sessionId: sid,
1889
1893
  ...(resume ? { requireExistingSession: true } : {}),
1890
1894
  objective: objectiveText,
1891
- ...(ctx.model !== undefined ? { model: ctx.model } : { modelRole: "subagent" }),
1895
+ ...childModelSeat(undefined, deploymentStatesSubagentModel(runnerRoleMap(opts.runner)), ctx.model),
1892
1896
  ...(observerDef.systemPrompt !== undefined && !resume ? { systemPrompt: observerDef.systemPrompt } : {}),
1893
1897
  ...(ctx.principal !== undefined ? { principal: ctx.principal } : {}),
1894
1898
  ...(ctx.getApiKeyAndHeaders !== undefined ? { getApiKeyAndHeaders: ctx.getApiKeyAndHeaders } : {}),
@@ -2140,7 +2144,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2140
2144
  : undefined;
2141
2145
  const buildChildSpec = (signal) => ({
2142
2146
  objective: prompt,
2143
- ...(childModel ? { model: childModel } : { modelRole: "subagent" }),
2147
+ ...childSeat,
2144
2148
  ...(childThinking !== undefined ? { thinking: childThinking } : {}),
2145
2149
  tools: childTools,
2146
2150
  systemPrompt: childSystemPrompt,
@@ -42,9 +42,13 @@ import type { PermissionResult } from "./tool-policy.js";
42
42
  * - `org_rule` — an organization policy rule asked (or stamped an existing ask): "no configuration
43
43
  * clears this, a person nods".
44
44
  * - `rule_store_unavailable` — the deployment wired a persisted-rule store and could not READ it for
45
- * this call (a read failure, a timeout): the person's own deny/ask rows cannot be enforced, so the
46
- * call fails closed the way an unreadable org snapshot does — an allow tightens to an ask a person
47
- * must clear, and no automatic lane may answer ("does one of my rules forbid this?" is unanswered).
45
+ * this call (a read failure, a timeout), OR could not read the CALL against the person's standing
46
+ * deny/ask rows (the tightening lexer's `unreadable` word: an expansion where a rule reads a word, an
47
+ * unterminated quote, a syntax error the answer's `reason`): the person's own deny/ask rows cannot
48
+ * be enforced for this call, so it fails closed the way an unreadable org snapshot does — an allow
49
+ * tightens to an ask a person must clear, and no automatic lane may answer ("does one of my rules
50
+ * forbid this?" is unanswered). One word for both, deliberately: the AUTHORITY semantics (who may
51
+ * answer, what clears it, what the evidence records) are identical, and only the mechanism differs.
48
52
  * - `hook` — a deployment's PreToolUse screening face raised or co-raised the ask; the word is
49
53
  * engine-stamped at the fold and is the deployment's own per-call adjudication.
50
54
  * - `ask_rule` — an EXPLICIT `ask` permission rule matched: a person's standing "ask me each time",
@@ -843,15 +843,10 @@ export type PendingAction = {
843
843
  * display `family` + declared `pathTarget` — off the same roster row at the same mint. The pre-CAS resume-edit
844
844
  * recheck has no roster yet; it rebuilds the call face from these (`parkedCallFaceOf`) and judges the edit as
845
845
  * the live gate judged the original (merge-seam H1: without them a caller slot fell to the catalog's slot for
846
- * the NAME). Any member absent ⇒ pre-388 row ⇒ the recheck falls to the catalog, never a widened grant.
846
+ * the NAME). Any member absent ⇒ pre-388 row ⇒ the recheck falls to the catalog, never a widened grant — including #635's resolution members, which a row written before them simply lacks (they then read as their documented defaults).
847
847
  */
848
848
  family?: import("./tool-face.js").ToolFamily;
849
- pathTarget?: {
850
- param: string;
851
- aliases: string[];
852
- access: "read" | "create" | "edit";
853
- skillScopeEligible: boolean;
854
- };
849
+ pathTarget?: import("./tool-face.js").ToolRosterPathTarget;
855
850
  /**
856
851
  * #438 (additive; no checkpoint-version bump — the `reminderMark` precedent: an optional field an
857
852
  * older reader ignores. Unlike that precedent seat — which resume DOES read, through its
@@ -0,0 +1,43 @@
1
+ import type { ToolCallPathTarget } from "./tool-face.js";
2
+ import { type ToolCallLike } from "./tool-registry.js";
3
+ /** The bases of ONE CALL a target resolves against: the task root, and the live tracked working directory
4
+ * (absent when no tracker moved or the caller keeps none). Facts of the call, never of the tool. */
5
+ export interface ToolPathBases {
6
+ readonly root?: string | undefined;
7
+ readonly cwd?: string | undefined;
8
+ }
9
+ /**
10
+ * WHICH base this target's relative spellings resolve against, as SPELLED by the caller (no normalization:
11
+ * the tool hands this straight to `canonicalizeTarget`, which resolves win-form and symlinked spellings the
12
+ * fences must not pre-fold). `undefined` ⇒ the deployment gave no such base, so a relative spelling in this
13
+ * slot names nothing this reader can locate.
14
+ */
15
+ export declare function pathTargetBaseOf(pt: Pick<ToolCallPathTarget, "base"> | undefined, bases: ToolPathBases): string | undefined;
16
+ /**
17
+ * design/116 批④ #8 (CC 2.1.198 RWp, bundle :332987) — decompose an ABSOLUTE glob pattern into a base
18
+ * directory + relative pattern: cut at the last separator before the first glob metachar (`* ? [ {`); a
19
+ * pattern with no metachar splits dirname/basename. `undefined` for a relative pattern (the caller keeps it
20
+ * as-is) or when no usable base can be derived.
21
+ *
22
+ * Lives HERE rather than beside the search engine because two readers need the identical cut: Glob's own
23
+ * scope resolution and the path fences that must know which directory an absolute pattern really searches.
24
+ */
25
+ export declare function splitAbsoluteGlobPattern(pattern: string): {
26
+ baseDir: string;
27
+ relativePattern: string;
28
+ } | undefined;
29
+ /**
30
+ * The absolute, LEXICAL-NORMAL path this call is about under ONE reading of its path slot, or `undefined`
31
+ * when that reading locates none. `pt` is the reading (design/388 B6/B17: the PROTECTIVE target for a
32
+ * tightening fence, the DECLARED target for a grant) — this function never picks between the two.
33
+ *
34
+ * The order is the tool's own:
35
+ * ① a spelling in the slot wins — absolute as spelled, relative against {@link pathTargetBaseOf}'s base;
36
+ * ② otherwise, when the declaration says an absent slot means `none`, the call is about no path;
37
+ * ③ otherwise a `patternParam`'s ABSOLUTE pattern supplies its own base directory;
38
+ * ④ otherwise the base itself (the tool searches all of it).
39
+ *
40
+ * A base the deployment did not supply yields `undefined`: a relative spelling with nothing to resolve
41
+ * against names no file, and claiming it names `/<spelling>` was a fabrication no fence should judge.
42
+ */
43
+ export declare function effectivePathTargetOf(call: ToolCallLike, pt: ToolCallPathTarget | undefined, bases: ToolPathBases): string | undefined;
@@ -0,0 +1,56 @@
1
+ import { isAbsolutePathForm } from "../tools/fs/safety.js";
2
+ import { lexicalNormalAbsolutePathOf } from "./permission-rule-model.js";
3
+ import { pathTargetValue } from "./tool-registry.js";
4
+ export function pathTargetBaseOf(pt, bases) {
5
+ return (pt?.base ?? "cwd") === "root" ? bases.root : (bases.cwd ?? bases.root);
6
+ }
7
+ export function splitAbsoluteGlobPattern(pattern) {
8
+ if (!isAbsolutePathForm(pattern))
9
+ return undefined;
10
+ const m = /[*?[{]/.exec(pattern);
11
+ if (!m) {
12
+ const i = Math.max(pattern.lastIndexOf("/"), pattern.lastIndexOf("\\"));
13
+ if (i <= 0)
14
+ return undefined;
15
+ return { baseDir: pattern.slice(0, i), relativePattern: pattern.slice(i + 1) };
16
+ }
17
+ const prefix = pattern.slice(0, m.index);
18
+ const cut = Math.max(prefix.lastIndexOf("/"), prefix.lastIndexOf("\\"));
19
+ if (cut === -1)
20
+ return undefined;
21
+ let baseDir = pattern.slice(0, cut);
22
+ if (baseDir === "" && cut === 0)
23
+ baseDir = "/";
24
+ if (/^[A-Za-z]:$/.test(baseDir))
25
+ baseDir = baseDir + pattern[cut];
26
+ if (!baseDir)
27
+ return undefined;
28
+ return { baseDir, relativePattern: pattern.slice(cut + 1) };
29
+ }
30
+ function under(base, rest) {
31
+ return `${base === "/" ? "" : base}/${rest}`;
32
+ }
33
+ export function effectivePathTargetOf(call, pt, bases) {
34
+ if (pt === undefined)
35
+ return undefined;
36
+ const declaredBase = pathTargetBaseOf(pt, bases);
37
+ const spelled = pathTargetValue(call.args, pt);
38
+ if (spelled !== undefined)
39
+ return resolveSpelling(spelled, declaredBase, bases);
40
+ if ((pt.absent ?? "none") === "none")
41
+ return undefined;
42
+ if (pt.patternParam !== undefined) {
43
+ const pattern = pathTargetValue(call.args, { param: pt.patternParam });
44
+ const split = pattern === undefined ? undefined : splitAbsoluteGlobPattern(pattern);
45
+ if (split !== undefined)
46
+ return resolveSpelling(split.baseDir, declaredBase, bases);
47
+ }
48
+ return declaredBase === undefined ? undefined : lexicalNormalAbsolutePathOf(declaredBase);
49
+ }
50
+ function resolveSpelling(spelled, declaredBase, bases) {
51
+ if (spelled.startsWith("/"))
52
+ return lexicalNormalAbsolutePathOf(spelled);
53
+ const landsOn = isAbsolutePathForm(spelled) ? pathTargetBaseOf(undefined, bases) : declaredBase;
54
+ const normalBase = landsOn === undefined ? undefined : lexicalNormalAbsolutePathOf(landsOn);
55
+ return normalBase === undefined ? undefined : lexicalNormalAbsolutePathOf(under(normalBase, spelled));
56
+ }
@@ -61,6 +61,14 @@ export interface EngineNotice {
61
61
  * its explicit {@link NOTICE_AUDIENCE} row (#433 made the registry total over the catalog —
62
62
  * no engine-minted code is audience-defaulted any more). The refusal itself
63
63
  * (`mcp.server_revoked`) is a tool RESULT code, not a notice.
64
+ * - `"mcp.injection_dropped"` (L-167) — an entry of the user's own request-lane MCP injection
65
+ * was NOT mounted for the leg. The HOST owns the drop sites and supplies the facts; CORE composes
66
+ * the notice ({@link import("./mcp-injection-drop.js").mcpInjectionDroppedNotice}, the one mint).
67
+ * `detail: { sessionId, server, reason, field? }` — `reason` is the closed set
68
+ * `malformed_entry` / `name_reserved_by_deployment` / `gate_closed` / `over_cap`
69
+ * (`MCP_INJECTION_DROP_REASONS`, CLOSED-SETS), `field` names the bad key for `malformed_entry`
70
+ * when known. `"user"` audience; `sessionId` REQUIRED at the mint (a user row with no session
71
+ * routes nowhere — refused, never fabricated). Dedup unit: per leg per server name, the host's.
64
72
  * - `"config.models_swapped"` — `Runner.swapModels` replaced the model catalog generation
65
73
  * (zero-restart model switching). `detail: { models, tiers, occurrenceId }` — key COUNTS only,
66
74
  * never the
@@ -1,6 +1,7 @@
1
1
  import { canonicalizeTarget, writeTargetPath } from "../tools/fs/safety.js";
2
2
  import { isWithin } from "./runner/session-rule-policy.js";
3
3
  import { declaredPathTargetOf, isProtectedWrite } from "./tool-registry.js";
4
+ import { pathTargetBaseOf } from "./effective-path-target.js";
4
5
  const ask = (message) => ({ action: "ask", message, decisionReason: "rule" });
5
6
  export function createFsWriteGatePolicy(opts) {
6
7
  const { env, rootPath, defaultWrite } = opts;
@@ -26,7 +27,7 @@ export function createFsWriteGatePolicy(opts) {
26
27
  if (path === undefined) {
27
28
  return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine${declared === undefined ? " (the tool declares no path target)" : ""}${advisory}`);
28
29
  }
29
- const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
30
+ const canon = await canonicalizeTarget(env, path, signal, pathTargetBaseOf(declared, { root: rootPath, cwd: req.cwd }));
30
31
  if (!canon.ok) {
31
32
  return ask(`write to "${path}" requires approval: its real target could not be resolved (${canon.message})${advisory}`);
32
33
  }
@@ -45,6 +45,7 @@ export declare function normalizeOrgGateVerdict(answer: unknown, unreadable: str
45
45
  export declare function normalizePersistedRuleHit(hit: PersistedRuleAnswer): {
46
46
  hit?: PersistedRuleHit;
47
47
  unreadable?: true;
48
+ reason?: string;
48
49
  coverage?: readonly import("./permission-rule-model.js").SegmentCoverage[];
49
50
  };
50
51
  /**
@@ -70,7 +70,9 @@ export function normalizeOrgGateVerdict(answer, unreadable) {
70
70
  const rule = ownDataValue(verdict, "rule");
71
71
  if ((behavior !== "deny" && behavior !== "ask") || typeof rule !== "string")
72
72
  return clean({ status: "unavailable", disclosures: [unreadable] });
73
- return clean({ status: "available", verdict: clean({ behavior: behavior, rule }), ...revisionCell });
73
+ const reason = ownDataValue(verdict, "unreadable");
74
+ const unreadableCell = behavior === "ask" && typeof reason === "string" ? { unreadable: reason } : {};
75
+ return clean({ status: "available", verdict: clean({ behavior: behavior, rule, ...unreadableCell }), ...revisionCell });
74
76
  }
75
77
  function copiedUsableDots(dots) {
76
78
  let copied;
@@ -93,8 +95,10 @@ function copiedUsableDots(dots) {
93
95
  export function normalizePersistedRuleHit(hit) {
94
96
  if (!isPlainOwnRecord(hit))
95
97
  return {};
96
- if (ownDataValue(hit, "unreadable") === true)
97
- return { unreadable: true };
98
+ if (ownDataValue(hit, "unreadable") === true) {
99
+ const reason = ownDataValue(hit, "reason");
100
+ return typeof reason === "string" ? { unreadable: true, reason } : { unreadable: true };
101
+ }
98
102
  const rulesRaw = ownDataValue(hit, "rules");
99
103
  if (rulesRaw === undefined) {
100
104
  const coverageRaw = ownDataValue(hit, "segmentCoverage");
@@ -219,7 +223,7 @@ export async function runGateLanes(pass) {
219
223
  orgEvidence = { ...revisionCell, ruleAbsent: "no_match" };
220
224
  return { decision: decided, org: undefined };
221
225
  }
222
- const { behavior, rule } = answer.verdict;
226
+ const { behavior, rule, unreadable } = answer.verdict;
223
227
  orgEvidence = { ...revisionCell, rule };
224
228
  if (behavior === "deny") {
225
229
  orgTightenCount += 1;
@@ -235,7 +239,9 @@ export async function runGateLanes(pass) {
235
239
  ? { ...decided, requiresRealApproval: true }
236
240
  : {
237
241
  action: "ask",
238
- message: `an organization policy rule (${rule}) requires approval for this call`,
242
+ message: unreadable !== undefined
243
+ ? `an organization policy rule (${rule}) may reach this call, which could not be read against it (${unreadable}) — a person must decide`
244
+ : `an organization policy rule (${rule}) requires approval for this call`,
239
245
  decisionReason: ORG_RULE_DECISION_REASON,
240
246
  requiresRealApproval: true,
241
247
  },
@@ -103,7 +103,7 @@ export type NoticeAudience = "user" | "operator";
103
103
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
104
104
  * no longer minted.
105
105
  */
106
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
106
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "mcp.injection_dropped", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
107
107
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
108
108
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
109
109
  * sink is a supported shape, and narrowing that field would break it. */
@@ -113,6 +113,7 @@ export const ENGINE_NOTICE_CODES = [
113
113
  "classifier.denial_limit",
114
114
  "checkpoint.execution_outcome_unrecorded",
115
115
  "delegation.transcript_integrity",
116
+ "mcp.injection_dropped",
116
117
  "mcp.revocation_probe_failed",
117
118
  "workflow.governance_key_stripped",
118
119
  "workflow.agent_option_ignored",
@@ -177,6 +178,7 @@ const NOTICE_AUDIENCE_TABLE = {
177
178
  "peer.idle_subscription": "user",
178
179
  "delegation.transcript_integrity": "operator",
179
180
  "checkpoint.execution_outcome_unrecorded": "operator",
181
+ "mcp.injection_dropped": "user",
180
182
  "mcp.revocation_probe_failed": "operator",
181
183
  "workflow.governance_key_stripped": "operator",
182
184
  "workflow.agent_option_ignored": "operator",
@@ -993,10 +993,13 @@ export interface ParkAttemptFailed {
993
993
  */
994
994
  export type OrgGateVerdict = {
995
995
  status: "available";
996
- /** The org rule that speaks for this call, if any. Deny outranks ask; there is no allow bucket. */
996
+ /** The org rule that speaks for this call, if any. Deny outranks ask; there is no allow bucket. An
997
+ * `unreadable` member (the lexer's reason) says the call could not be READ against `rule` — an
998
+ * org rule that may reach it — so `behavior` is `ask` and the message names the reason. */
997
999
  verdict?: {
998
1000
  behavior: "deny" | "ask";
999
1001
  rule: string;
1002
+ unreadable?: string;
1000
1003
  };
1001
1004
  /** design/252 G-2 (additive): the REVISION of the snapshot this answer was read off — the
1002
1005
  * published-policy version an auditor reconciles the resulting decision against. Present on the
@@ -1057,6 +1060,12 @@ export interface PersistedRuleHit {
1057
1060
  */
1058
1061
  export interface PersistedRuleUnreadable {
1059
1062
  readonly unreadable: true;
1063
+ /** WHY the lane could not read: absent for a store read failure; for a call the lane could not READ
1064
+ * against the person's standing deny/ask rows (an expansion where a rule reads a word, an unterminated
1065
+ * quote, a syntax error — `programRunReachOf`'s `unreadable` word), the lexer's reason. The DECISION
1066
+ * is the same fail-closed one either way (the question "does one of my rules forbid this?" is
1067
+ * unanswered); the reason is what the ask's message says instead of "the store could not be read". */
1068
+ readonly reason?: string;
1060
1069
  }
1061
1070
  /**
1062
1071
  * design/375 §5.2② — the CLEAN NEGATIVE that still carries the per-segment coverage table: no rule