@sema-agent/core 7.24.2 → 7.24.4

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 (34) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/dist/core/mcp-failure.d.ts +73 -0
  3. package/dist/core/mcp-failure.js +21 -0
  4. package/dist/core/mcp.d.ts +18 -1
  5. package/dist/core/mcp.js +33 -12
  6. package/dist/core/permission-rules.js +14 -10
  7. package/dist/core/rule-denial-sentence.d.ts +75 -0
  8. package/dist/core/rule-denial-sentence.js +16 -0
  9. package/dist/core/runner/prepare-policy-chain.js +30 -20
  10. package/dist/core/runner/prepare-wiring-manifest.d.ts +3 -2
  11. package/dist/core/runner/prepare-wiring-manifest.js +3 -1
  12. package/dist/core/runner/run-leg.js +2 -1
  13. package/dist/core/runner/session-rule-policy.d.ts +74 -2
  14. package/dist/core/runner/session-rule-policy.js +113 -21
  15. package/dist/core/wiring-manifest.d.ts +20 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/orchestration/workflow.js +12 -3
  19. package/dist/prompt-assembly/packs/sema-default.js +15 -0
  20. package/dist/prompts/default.d.ts +1 -1
  21. package/dist/prompts/default.js +4 -4
  22. package/dist/tools/fs/bash-program-position.d.ts +5 -0
  23. package/dist/tools/fs/bash-program-position.js +1 -1
  24. package/dist/tools/fs/bash-readonly-classifier.d.ts +476 -1
  25. package/dist/tools/fs/bash-readonly-classifier.js +98 -34
  26. package/dist/tools/fs/fs-bash.js +2 -325
  27. package/dist/tools/fs/shell-read-establish.d.ts +127 -0
  28. package/dist/tools/fs/shell-read-establish.js +326 -0
  29. package/dist/tools/fs/shell-read-plan.d.ts +113 -0
  30. package/dist/tools/fs/shell-read-plan.js +700 -0
  31. package/package.json +1 -1
  32. package/test/export-surface.snapshot.json +7 -1
  33. package/wire-changes/core@7.24.3.json +244 -0
  34. package/wire-changes/core@7.24.4.json +54 -0
package/CHANGELOG.md CHANGED
@@ -1,6 +1,56 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 7.24.4 — 2026-09-20
4
+
5
+ ### Every rule source is re-read at every gate (#978 ①; P2; behavior-face widening of when a session rule binds; no wire key; @server @cli @test)
6
+ - **Before.** `sessionPolicyStore.getRules` was read ONCE, at prepare, and the compiled rules were frozen for the leg; only the ANCESTOR snapshots were re-read live, and only at prepare too. The permission-rule store, meanwhile, is read at every adjudicated call. So a deployment that wrote a `deny` to a session's policy row while the run was going — the ordinary shape of an operator narrowing a task already in flight — watched the run's next calls execute anyway; the deny bound the NEXT run. Measured downstream: the row was written at the first `tool_end` and 3/3 later calls of that tool still ran. Worse, a session with NO row at prepare composed no layer at all, so a session's FIRST rule could never bind mid-run whatever the read tempo.
7
+ - **After — one law, both stores.** A session-rule layer takes its rules from a `SessionRuleSource` at EVERY gate. The prepare read is a SEED, not the reading: it is what the prepare-time policy-name audit sees and the floor a live read may never loosen. The layer is composed whenever the store is wired for the session, including when the row is empty (an empty seed constrains nothing, and a layer that was never composed cannot re-read anything). The ancestor lane uses the same reader rather than its own hand-written live-preferring read.
8
+ - **Direction, unchanged.** Whole-row replacement at `live.rev >= floor.rev` — an operator's AUTHORIZED change propagates in both directions (tighten and relax). What the floor forbids is an UNAUTHORIZED loosening: a lagging replica, a deleted row and an unreachable store each enforce the floor instead. The floor RATCHETS to the highest-rev row the layer has served, so a row tightened mid-run and then unreadable cannot fall back past the tightening.
9
+ - **Cost and loudness.** One store read per adjudicated call per layer, shared inside one call by the same bounded per-call handoff the permission-rule lanes use (first reader hands, second claims). That handoff has a real firing path here: `policyLayers` re-appends the narrowing layers AFTER a rewrite-capable caller layer, so one adjudication asks the layer twice under one id and both passes must judge one set of rules. A source that cannot be read discloses once per distinct reason through `onError` (`phase: "config"`, `code: "config.session_rules_unreadable"`) — the advisory channel the policy-name audit already uses, not a new notice code.
10
+ - **The read is BOUNDED.** This is the only I/O the narrowing fold performs, and the resumed-execution path awaits that fold with a consumed approval already claimed — a store that never settled would strand the call rather than refuse it. The read now rides `withGateTimeout` on the gate's own signal and the tree's default deadline; an elapsed or aborted wait takes the same fail-closed arm as a throwing store (enforce the floor, disclose once).
11
+ - **A vanished row ends its rev line.** `rev` counts within ONE row's lifetime — both bundled backends restart it at 1 after a delete, and the tighten gate has no prior row to judge the first write against, so a recreated row's rev can be climbed past an enforced floor by ordinary non-operator writes (measured: enforce rev 2, delete, three `{}` writes, rev 3 replaces the restriction). Once a row this layer served is observed GONE, nothing that appears afterwards is comparable to it and the floor is final for the leg. COST: a legitimate delete-then-recreate is not picked up until the next run, and a backend that answers "no row" transiently ends the line early — both conservative. A session that simply never had a row is unaffected and silent (absence is not a deletion).
12
+ - **Five bounds an adversarial round added.** (a) A live row replaces the floor only on a STRICTLY GREATER `rev`: both bundled backends compute `rev` as `(prior?.rev ?? 0) + 1`, so a DELETED-and-recreated row restarts at 1, and at `>=` a recreated row that merely tied the enforced one would replace it — an unauthorized loosening wearing an update's shape. A tie now enforces the floor (a no-op for the honest case, where equal rev is the same row). This raises the cost of the recycle rather than closing it — the rev can still be climbed past the floor by repeated writes, because the tighten gate has no prior row to judge against after a delete; closing it belongs to the STORE's rev lifecycle (a counter that survives deletion, or a generation identity) and is a cross-backend contract change. (b) A handed read is CLAIMED only while its `rev` still stands at the enforced floor: `toolCallId` is not always unique (the turn-boundary read-visibility query reuses one synthetic id for every path and every turn), and a claim from a later adjudication would otherwise walk back past a tightening the layer already applied. (c) The enforced row is published for the child chain from the same place it is enforced, each time the floor advances — so a rule written mid-run reaches a child spawned after it, and a child whose own store read fails falls back to its parent's CURRENT floor rather than an older one. The empty seed is never published (it is not a row a child may inherit).
13
+ - **Reach.** `createSessionRulePolicy`'s first parameter widens to `SessionPermissionRules | SessionRuleSource` (additive; a plain rules object is still the constant source and every existing caller is unchanged). No wire key, code or closed-set member moves. RESIDUAL, recorded: a child spawned BEFORE the session's first row exists inherits no ancestor entry — seeding an empty one would put an `ancestorRules` row on every delegated child of every store-bearing deployment, which other readers take to mean "this leg is delegated". `test/backlog978-session-rules-every-gate.test.ts` pins the mid-run first deny, the mid-run tighten, the authorized relaxation, the three fail-closed arms (store down, lagging replica, deleted row), the ancestor lane, the read count, the handoff, the rev-recycle / claim-revalidation / vanished-row / bounded-read arms, the child-chain publication, and the two no-rule controls; 8 of the 10 Runner-level ones are red on 7.24.2.
14
+
15
+ ### One sentence for "a rule refused this call" (#978 ②; P2; refusal TEXT change on two lanes; no wire key; @cli @server @test)
16
+ - **Before.** Two rule stores can refuse a call and each spelled it its own way: `tool "X" is denied by a session rule`, `denied by permission rule "R" (layer)`, plus four further shapes for the session lane's containment arms (`write-capable tool "X" denied: …`, `write to "p" is outside …`), and a session `commandDeny` that came out as the coarse policy's generic `command "curl" is denied by policy`. The resumed-execution re-check asserted `a session rule` for a fold that also holds inherited ancestor rules, the frozen constraint projections and the skill scope. A consumer could not read which store had spoken — the confusion that produced the wrong answer to "is the rule store re-read at every gate?".
17
+ - **After.** `src/core/rule-denial-sentence.ts` mints them all: `[tool "<tool>" is ]denied by <source>[ "<rule>"][ (<origin>)][: <detail>]`, over a closed source vocabulary derived from its own phrase table (`session` / `permission` / `narrowing`). The seven session-lane exits now differ only in the `detail` that says WHY; the session `commandDeny` refusal is re-minted as the session lane's own, carrying the coarse policy's sentence as its detail; the resumed re-check names the fold instead of one of its layers.
18
+ - **What a reader keyed on the old text sees.** The permission lane's sentence gained a head and kept its tail byte-for-byte (`denied by permission rule "Bash(rm -r:*)" (settings.permissions.deny)` still `toContain`s). The session lane's plain tool-name denial is unchanged. The containment arms and the allowlist miss are REWORDED and keep their diagnostic substrings (`cannot be path-confined`, `declares no path target`, `no resolvable path`, `outside the session-rule allowDirs`, `not in the session-rule allowlist`). The ASK sentence (`flagged by permission rule …`) is deliberately untouched: an ask is a question on a card, not a refusal in a tool result.
19
+ - **Reach.** No wire key, code, closed-set member or export moves; `PermissionResult.message` is a rendered string and this is a rendering change. `@cli` renders these in tool results — a fence pinned on a session-lane containment wording should re-pin on the substrings above. `test/backlog978-rule-denial-sentence.test.ts` pins the grammar, the two lanes' agreement on it, every session exit, the mint's composition, and a source scan holding the denial spellings to one module (its ceiling is stated: code lines only, and the ask / non-rule-store `denied by policy` faces are out of scope).
20
+
21
+ ### A workflow agent's fleet row now says which attempt it is on (#979; P2; presence change on an existing wire key, additive; no new key, no export; @client-core @server)
22
+ - **Before.** `BackgroundChildEvent.seq` is contracted as "present whenever a cycle number is knowable, absent when no carrier exists". The `a*` delegation lanes have carried it on spawn / tick / terminal since #258; the `wa*` workflow lane carried it on NONE of its three frames — while it has held a carrier all along: the orchestration layer's stall/throttle retry re-runs the same logical agent on a fresh session under the SAME `wa*` id, which is exactly the "one row, two cycles" shape the field exists to separate. A fleet consumer folding rows by generation had a structurally unreachable third branch on this lane.
23
+ - **After.** The `wa*` row carries a generation: a fresh spawn is `1`, and each retry of the same agent bumps it, so a tick or terminal says which attempt produced it. The number is the ROW's, taken at emit — never copied from a forwarded descendant's frame, the same rule `agentType` and `model` already follow on this lane.
24
+ - **The carrier is the ATTEMPT counter, not the session rebind.** A retry also rebinds the row's transcript coordinate, and today the two move together — but the coordinate is a different fact (it is skipped for a spec shape that mints no id, and once the run is finalized), so counting rebinds would make the generation a property of whether a coordinate existed.
25
+ - **Reach.** `seq` is now ALWAYS present on a `wa*` row, where it was always absent; a consumer that read that absence as "this lane has no generation" must now read the number, and an absent `seq` on a `wa*` row dates the producer as pre-7.24.4. `task_progress.seq` is UNCHANGED and still absent for workflow agents (no `a*` registry row). `@client-core` B4 folds rows by generation; `@server` relays the frame unchanged; `@cli` has no `wa*` fleet renderer. `docs/wire-consumption/unreleased-979.md` carries the table, including the disclosure that one Workflow fan-out's children all share ONE `parentToolCallId` (up to `maxAgents`, default 50) — `seq` separates one row's cycles, not two rows. `test/backlog979-workflow-bce-seq.test.ts` pins the six readings with a five-row mutation table.
26
+
27
+ ## 7.24.3 — 2026-09-20
28
+
29
+ ### A per-session value high in the prompt costs the whole shared prefix (#975; P2; prompt SECTION ORDER, no key and no wording change; @server @cli @client-core)
30
+ - **Before.** The session reminder-mark declaration extended the reminder sentence inline, ~290 characters into the system prompt. The value is per-session random, and a provider-side prefix cache (vLLM automatic prefix caching, Anthropic prompt caching) reuses the longest IDENTICAL token prefix — so a new session matched those ~290 characters and re-prefilled everything after them. Measured on a real deployment at 7.24.1: the same body replayed 120–306ms TTFB, a NEW session's first turn 440–500ms, the same session's second turn 216–290ms.
31
+ - **After.** The declaration is its own section, `core/harness.reminder-mark` (rank 590), emitted with the prompt's session-variable group: declaration → environment block → memory tail. The `# Harness` head went back to the mark-free sentence it carried before the mark existed. On the default assembly the first byte that differs between two sessions moved from index 290 to 9 406 of 10 280 (2.8% → 91.5%); the ratio is fixture-dependent (a large env tail lowers it) but the property is not — nothing of the shared base follows the declaration any more.
32
+ - **Nothing about the defense changed.** Same sentence, same wording, exactly one per composed prompt, and `<system-reminder mark="…">` comparison never read position to begin with. The section is LOCKED: a `replaceAll` persona does not displace it (it is mechanism truth, not identity), and the already-assembled pass-through still gets the existing "declaration missing" disclosure rather than a second copy. **Only this sentence moved** — cwd/date have ridden the environment tail since the pack existed.
33
+ - **No public composite emits the grant without its caveat.** The head GRANTS `<system-reminder>` tags authority and the declaration is what lets the model tell an engine mint from a forgery, so the two travel together: `harnessContext()` appends the declaration when a mark is threaded, and `constitutionBlocks()` / `composeConstitution()` / `defaultPromptProvider.stableSystem` inherit exactly one. The single deliberate exception is `harnessHeadLines()` — the PACK's section source, which returns the grant alone because the pack ranks the caveat itself; it no longer takes a `reminderMark` at all, so it cannot regrow one by accident. Two adversarial rounds pushed this boundary down to the lowest level it can sit at.
34
+ - **CC 2.1.259, checked.** CC has no per-session mark, so this seat is UNANCHORED. Its nearest relative, CC's `env_info_simple` block, sits MID-list in the default builder (ahead of several static sections) and near the tail in the subagent builder; sema keeps its environment tail strictly last and puts this beside it — a divergence that predates this change and is kept on purpose (CC caches behind explicit breakpoints, sema's deployments behind longest-identical-prefix caches).
35
+ - **Reach.** `prompt.assembled` gains one block id (`harness.reminder-mark`) and `harness.context` loses 886 characters to it; a prompt EPOCH re-pins once per session on upgrade, which is the ordinary declared-bytes-changed path. No wire key, no code, no posture. `test/backlog975-reminder-mark-prefix.test.ts` pins the two-session prefix property with a positive control (two distinct marks), the one-declaration/nothing-stable-after rule, the same-session byte-identity invariant #254 established, and both the task-`systemPrompt` and `replaceAll` shapes.
36
+
37
+ ### An MCP server's reachability is its own fact, and absence of it is indeterminate (#874; P2; additive wire key + additive record field + 3 exports; @server @cli @client-core)
38
+ - **Before.** Three faces reported three different things about an MCP server and none of them answered "can the engine reach it right now": `wiring_manifest.mcp[].status` is the connect-time verdict of the dial, deliberately frozen (a server that dies mid-task still reads `connected`); `McpServerStatus.transportClosed` is one EVENT, and only on transports that raise one — a streamable-HTTP endpoint that dies never does; `McpReconnectResult.outcome` is one action's judgment. A shell asking "is this server up" had to infer it, and every inference folded an unknown into a health verdict.
39
+ - **After.** `wiring_manifest.mcp[].liveness` (and the same record on `McpServerStatus.liveness`) carries `{state, observedAt, errorCode?}` — the engine's last OBSERVATION, stamped where the connection layer already works: the dial, a re-dial, a transport close, and a request that comes back with the transport gone (the one case `transportClosed` structurally cannot see). **No probe, no poll, no timer**: core adds no traffic of its own, so the manifest's copy is a per-leg snapshot and nothing pretends otherwise.
40
+ - **Three words, one question — "did an exchange complete".** `reachable` includes a server that ANSWERED the handshake with a protocol error, so `status:"failed"` beside `liveness.state:"reachable"` is a real row and a useful one (up, and misconfigured). `unreachable` = refused / reset / closed / unspawnable / timed out. `unknown` = something came back and it does not say (an HTTP status a gateway in front of the server may have written, a non-MCP payload, a thrown value with no structure). The mapping is ONE table over the existing `MCP_FAILURE_KINDS` (`LIVENESS_BY_FAILURE_KIND`, fenced like `DELIVERED_BY_KIND`) — no new code vocabulary, and a new failure class cannot land without saying what it means for reach.
41
+ - **Absence is the fourth reading and is not a word.** The key is present IFF an observation exists: a `skipped` row never reached a transport, a malformed declaration (`invalid_config`) was never dialed, and an older manifest has no field. A consumer must not fold absence into `unknown` — `unknown` is the stronger statement that core DID look and could not tell.
42
+ - **`errorCode` inside the record rides `unreachable` only**, deliberately: on `unknown` the failure class was judged NOT to decide reachability, and carrying it there would invite a reach action keyed on evidence that says nothing about reach. The connect-time class is still reported in its own right as the row's top-level `errorCode`.
43
+ - **Every exchange reports, at the REQUEST boundary; the record is the LAST observation, not the worst one.** A dial, a tool call, a resource read, each listing page, a refresh's failed listing and the idle watchdog's own expiry report their outcome through the same writer — including SUCCESS, so a demotion can never outlive the next exchange that works. Deliberately NOT after a paginating helper returns: those recover from later-page failures and can return having issued no request at all, so a stamp there would fabricate a recovery (`KL-874a` names the three paths that therefore take no observation, and what a stale record costs). The first cut filtered the request lane to `connection_closed` and an adversarial round showed why that is the wrong half: a streamable-HTTP endpoint that dies raises no close notification, and its next request fails `connect_refused` / `connection_failed`. There is no per-site judgment about which failures "count" — the class table decides, and a class that decides nothing about reach writes nothing.
44
+ - **The engine's own teardown records nothing.** All observations go through ONE lifecycle-aware writer on the per-server health object (the slot discipline `announce.fn` already used): it is silent while the task is tearing down — our own `dispose()` closes every transport, and stamping "this server died" on the way out is a lie a consumer reading the statuses afterwards cannot tell from the truth — and it is DISARMED when a re-dial deliberately replaces a handle. An adversarial round found the first cut recording a teardown as an outage; the fix removed a write site rather than adding a guard.
45
+ - **Reach.** Additive: one optional wire key, one optional field on an existing public record, three new exports (`MCP_LIVENESS_STATES`, `McpLivenessState`, `McpServerLiveness`). No key removed, re-typed or re-meant; `status`, `transportClosed` and the re-dial result are byte-unchanged. `@client-core` asked for this face (CC-36); `@server` relays the manifest; `@cli` renders it. `test/backlog874-mcp-liveness.test.ts` pins the three states and the two absence forms on REAL `wiring_manifest` frames (a live stdio server, a missing binary, a real HTTP endpoint answering 401, a malformed url), the re-dial flip, and the `errorCode`-only-on-`unreachable` law.
46
+
47
+ ### One extraction of shell read candidates, ahead of one judge (#945 S0; zero behaviour change; no wire key; no export)
48
+ - **What lands.** `src/tools/fs/shell-read-plan.ts` — `planShellReads(command, decl)`, a pure, zero-I/O parse of a shell command into every path word it MAY read, each tagged with its provenance (`READ_CANDIDATE_PROVENANCES`, a closed set of ten words registered in `docs/CLOSED-SETS.md`) and the working-directory candidates it is relative to; nested plans for substitution bodies, unquoted here-document substitutions and re-entered shell text (`sh -c`, `eval`, `env -S`, a launcher handing text to a shell); `<` targets as read candidates and `>`-family targets as tagged write targets; `cd` threaded as a base-candidate set (`&&` proves a move), a loop body replayed per literal beat. Nothing consumes it yet: the three shell faces judge exactly as before.
49
+ - **What moves.** The filesystem-resolving read boundary (`createCanonicalReadBoundary`, its glob matcher and bounds, `BoundaryQuestions`) leaves `fs-bash.ts` for `src/tools/fs/shell-read-establish.ts`, byte-for-byte; `fs-bash.ts` imports it back. `dist/` shows the move; no call site changed.
50
+ - **Two readers now say more, to the same callers.** `readSedScript` returns the file names its `r`/`R` (read) and `w`/`W` (write) commands name and the command lines its `e` commands run, where it used to answer only "names"; every existing caller reads the same kind it always did (pinned). `LauncherRow` gains a `commandString` arity (`env`'s `-S` / `--split-string`); the program-position reader still refuses that spelling exactly as before (pinned) — only the candidate extraction reads the value.
51
+ - **The proof of "zero behaviour change" is a 219-cell matrix in tree** (`test/backlog945-matrix.test.ts`): three shell faces × two moments over the design's 38 shapes, the no-deny controls, and four `#976` shapes, every cell a reading taken on the pre-slice base with the same harness. LEAK cells are recorded as leaks; the pipeline slice flips them one marked commit at a time.
52
+ - **Two operand readers are exported from `bash-readonly-classifier.ts`** for the plan (`readVerbOptionSlot`, `slotValueCandidates`, `attachedOptionPayloads`, `segmentSelectsRecursiveRead`, `inlineProgramClause`, …): internal modules, not on the package's export surface (`handoff:diff` v7.24.2 → HEAD: +0 −0).
53
+ - Reach: @server / @cli / @client-core zero; headless zero.
4
54
 
5
55
  ## 7.24.2 — 2026-09-20
6
56
 
@@ -46,6 +46,79 @@ export declare const DELIVERED_BY_KIND: {
46
46
  };
47
47
  /** The fence over the delivery table: `never` while every kind has a verdict. */
48
48
  export type DeliveryTableCoversEveryMcpFailureKind = AssertAllKeysHandled<Exclude<McpFailureKind, keyof typeof DELIVERED_BY_KIND>>;
49
+ /**
50
+ * The closed set of {@link McpServerLiveness.state} — the engine's answer to ONE question, "can this
51
+ * client complete an exchange with this server":
52
+ * · `reachable` — an exchange COMPLETED (the dial handshaked and listed, a re-dial succeeded, or the
53
+ * server itself answered — an MCP-level rejection is an answer, so it proves reach
54
+ * even though it failed);
55
+ * · `unreachable` — an exchange was attempted and lost at the transport or the clock (refused, reset,
56
+ * closed, unspawnable, timed out);
57
+ * · `unknown` — an exchange was attempted and what came back does not answer the question (a status
58
+ * a gateway in front of the server may have written, a non-MCP payload, a thrown value
59
+ * with no structure this table reads).
60
+ * ABSENCE is the fourth reading and is NOT one of these words: no observation exists at all (the leg
61
+ * never reached this server, or the declaration could not be dialed). A consumer must not fold an absent
62
+ * record into either `unknown` or a health verdict — absent means indeterminate, and `unknown` means
63
+ * "we looked and could not tell".
64
+ */
65
+ export declare const MCP_LIVENESS_STATES: readonly ["reachable", "unreachable", "unknown"];
66
+ export type McpLivenessState = (typeof MCP_LIVENESS_STATES)[number];
67
+ /**
68
+ * One liveness OBSERVATION about one server (see {@link MCP_LIVENESS_STATES} for the words). It answers
69
+ * what the engine last LEARNED, at `observedAt` (epoch ms) — never what is true now, and never a poll:
70
+ * the engine makes no probe of its own, so every record here is a by-product of work the task already
71
+ * did (the dial, a re-dial, a transport close).
72
+ *
73
+ * `errorCode` rides `unreachable` ONLY, and names the failure class that decided it (a `spawn_failed` is
74
+ * a missing binary, a `connect_refused` a closed port). It is deliberately absent on `unknown`: the whole
75
+ * point of that word is that the failure did NOT decide reachability, so carrying its class there would
76
+ * invite a consumer to key a reach action off evidence that says nothing about reach. The connect-time
77
+ * class is still reported in its own right beside this record (the status / manifest row's `errorCode`).
78
+ */
79
+ export interface McpServerLiveness {
80
+ state: McpLivenessState;
81
+ observedAt: number;
82
+ errorCode?: McpFailureKind;
83
+ }
84
+ /**
85
+ * What each failure CLASS says about reachability — the second disposition table over
86
+ * {@link MCP_FAILURE_KINDS}, beside {@link DELIVERED_BY_KIND}, and deliberately NOT derived from it:
87
+ * delivery asks "did the caller's request run", reachability asks "did an exchange complete", and the two
88
+ * part company on both edges — a `timeout` leaves delivery `unknown` while the exchange provably did not
89
+ * complete, and a `protocol` rejection is a failure the caller must handle while being the strongest
90
+ * proof of reach there is.
91
+ *
92
+ * `invalid_config` maps to `undefined` — the one class that is NOT an observation: a declaration that
93
+ * could not be dialed means no transport was ever attempted, so the honest record is no record (see the
94
+ * absence reading in {@link MCP_LIVENESS_STATES}), not a third-hand `unknown`.
95
+ */
96
+ export declare const LIVENESS_BY_FAILURE_KIND: {
97
+ readonly connect_refused: "unreachable";
98
+ readonly connection_failed: "unreachable";
99
+ readonly connection_closed: "unreachable";
100
+ readonly http_status: "unknown";
101
+ readonly not_mcp_response: "unknown";
102
+ readonly spawn_failed: "unreachable";
103
+ readonly timeout: "unreachable";
104
+ readonly protocol: "reachable";
105
+ readonly invalid_config: undefined;
106
+ readonly unknown: "unknown";
107
+ };
108
+ /** The fence over the liveness table: `never` while every kind has a verdict. */
109
+ export type LivenessTableCoversEveryMcpFailureKind = AssertAllKeysHandled<Exclude<McpFailureKind, keyof typeof LIVENESS_BY_FAILURE_KIND>>;
110
+ /**
111
+ * What ONE exchange with a server tells the liveness record: the CLASS of the failure it ended in, or
112
+ * `"completed"` when it finished. Every round trip the engine makes — a dial, a tool call, a resource
113
+ * read, a listing, a refresh — reports exactly this, so the engine needs no second vocabulary and no
114
+ * per-site judgment about which failures "count".
115
+ */
116
+ export type McpExchangeOutcome = McpFailureKind | "completed";
117
+ /** The liveness observation an exchange supports, or `undefined` when the outcome is not an observation
118
+ * at all (see {@link LIVENESS_BY_FAILURE_KIND}: a declaration that could never be dialed). THE single
119
+ * mint of the record — no two sites can disagree about the word, about which words may carry
120
+ * `errorCode`, or about what a completed exchange proves. */
121
+ export declare function livenessOf(outcome: McpExchangeOutcome, observedAt: number): McpServerLiveness | undefined;
49
122
  /** Where the failure was judged: at the dial (connect + initial listing), where an errno on a stdio
50
123
  * transport is the child process failing to spawn, or on a request over an established connection. */
51
124
  export type McpFailureSite = {
@@ -25,6 +25,27 @@ export const DELIVERED_BY_KIND = {
25
25
  invalid_config: "no",
26
26
  unknown: "unknown",
27
27
  };
28
+ export const MCP_LIVENESS_STATES = ["reachable", "unreachable", "unknown"];
29
+ export const LIVENESS_BY_FAILURE_KIND = {
30
+ connect_refused: "unreachable",
31
+ connection_failed: "unreachable",
32
+ connection_closed: "unreachable",
33
+ http_status: "unknown",
34
+ not_mcp_response: "unknown",
35
+ spawn_failed: "unreachable",
36
+ timeout: "unreachable",
37
+ protocol: "reachable",
38
+ invalid_config: undefined,
39
+ unknown: "unknown",
40
+ };
41
+ export function livenessOf(outcome, observedAt) {
42
+ if (outcome === "completed")
43
+ return { state: "reachable", observedAt };
44
+ const state = LIVENESS_BY_FAILURE_KIND[outcome];
45
+ if (state === undefined)
46
+ return undefined;
47
+ return state === "unreachable" ? { state, observedAt, errorCode: outcome } : { state, observedAt };
48
+ }
28
49
  const CONNECT_KEEPS_THE_TABLE_VERDICT = new Set(["protocol", "http_status"]);
29
50
  const NETWORK_CODES_NEVER_DELIVERED = new Set([
30
51
  "ECONNREFUSED",
@@ -1,5 +1,5 @@
1
1
  import { type ProtocolId } from "./protocol-table.js";
2
- import { type McpDelivered, type McpFailureKind } from "./mcp-failure.js";
2
+ import { type McpDelivered, type McpFailureKind, type McpServerLiveness } from "./mcp-failure.js";
3
3
  import type { AgentTool } from "../internal/harness-types.js";
4
4
  import type { ImageContent, TextContent } from "../internal/llm.js";
5
5
  import { type McpImageResizer } from "./image-downsample.js";
@@ -344,6 +344,23 @@ export interface McpServerStatus {
344
344
  * server name and the outcome) and this field stays absent.
345
345
  */
346
346
  transportClosed?: boolean;
347
+ /**
348
+ * The engine's last liveness OBSERVATION about this server ({@link McpServerLiveness}) — a different
349
+ * question from all three of its neighbours, and the note each of them carries says so:
350
+ * · {@link status} is the CONNECT-TIME verdict of this leg, frozen at the dial — it is not this;
351
+ * · {@link transportClosed} is one EVENT (a close notification arrived), on the transports that have
352
+ * one — it is not this (this record also carries an `unreachable` that no close ever announced);
353
+ * · `McpReconnectResult.outcome` is one ACTION's judgment — it is not this (it says what a re-dial
354
+ * did, this says what the connection layer last learned).
355
+ * Written where the connection layer establishes or loses a connection (the dial, a re-dial, a close,
356
+ * a request that comes back with the transport gone), never on a timer and never by a probe: the
357
+ * engine adds no traffic of its own to answer it.
358
+ *
359
+ * ABSENT = no observation ({@link import("./mcp-failure.js").MCP_LIVENESS_STATES}): a declaration that could not be dialed
360
+ * (`invalid_config`) never touched a transport, and an older / foreign record predates the field. A
361
+ * consumer must read absence as indeterminate — never as healthy, never as off.
362
+ */
363
+ liveness?: McpServerLiveness;
347
364
  }
348
365
  /**
349
366
  * design/108 ① (CC 2.1.187 parity) — the GLOBAL `mcp__` marker prefix. CC names every MCP tool
package/dist/core/mcp.js CHANGED
@@ -6,7 +6,7 @@ import { findNamespacePrefixCollision, mintNamespacePrefix, mintNamespacedToolNa
6
6
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
7
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
8
8
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
9
- import { classifyMcpFailure, describeHttpTransportFailure } from "./mcp-failure.js";
9
+ import { classifyMcpFailure, describeHttpTransportFailure, livenessOf } from "./mcp-failure.js";
10
10
  import { lstat, mkdir, writeFile } from "node:fs/promises";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
@@ -213,6 +213,7 @@ function rethrowHonestMcpError(err, ctx) {
213
213
  };
214
214
  sanitizeMcpErrorTextInPlace(err);
215
215
  if (ctx.idle?.signal.reason === IDLE_WATCHDOG_ABORT_REASON) {
216
+ ctx.liveness?.observe?.("timeout");
216
217
  const serverLabel = inlineUntrusted(ctx.server);
217
218
  finish(markMcpFailure(new Error(`${ctx.what} on MCP server "${serverLabel}" received no response for ${ctx.idle.idleMs}ms (idle watchdog — ` +
218
219
  `distinct from the ${ctx.timeoutMs}ms total ceiling above). The server may still be alive but is not ` +
@@ -224,6 +225,7 @@ function rethrowHonestMcpError(err, ctx) {
224
225
  const serverLabel = inlineUntrusted(ctx.server);
225
226
  const fenceServerText = (label, raw) => delimitUntrusted(label, truncateMcpErrorText(raw));
226
227
  const failure = classifyMcpFailure(err, { phase: "request" });
228
+ ctx.liveness?.observe?.(failure.kind);
227
229
  const details = (extra) => ({ code: failure.kind, delivered: failure.delivered, server: ctx.server, ...extra });
228
230
  switch (failure.kind) {
229
231
  case "timeout": {
@@ -721,10 +723,19 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
721
723
  else
722
724
  serverHandles[hIdx] = handle;
723
725
  const hasInstructions = s.instructions !== undefined;
726
+ const observe = (outcome) => {
727
+ if (disposing)
728
+ return;
729
+ const observed = livenessOf(outcome, Date.now());
730
+ if (observed !== undefined)
731
+ s.status.liveness = observed;
732
+ };
733
+ s.health.observe = observe;
724
734
  const announceClose = () => {
725
735
  if (disposing)
726
736
  return;
727
737
  s.status.transportClosed = true;
738
+ observe("connection_closed");
728
739
  if (hasInstructions)
729
740
  instructionsDelta.pendingRemovals.push(spec.name);
730
741
  };
@@ -807,7 +818,8 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
807
818
  else {
808
819
  warnings.push(asServerWarning(spec, r.reason));
809
820
  const { kind, delivered, httpStatus } = classifyMcpFailure(r.reason, { phase: "connect", transport: dialKinds[i] });
810
- statuses.push({ name: spec.name, status: "failed", errorCode: kind, delivered, ...(httpStatus !== undefined ? { httpStatus } : {}), error: namedMcpFailureText(r.reason) });
821
+ const liveness = livenessOf(kind, Date.now());
822
+ statuses.push({ name: spec.name, status: "failed", errorCode: kind, delivered, ...(httpStatus !== undefined ? { httpStatus } : {}), error: namedMcpFailureText(r.reason), ...(liveness !== undefined ? { liveness } : {}) });
811
823
  }
812
824
  }
813
825
  const resourceTools = buildResourceTools(resourceServers, isServerRevoked);
@@ -838,6 +850,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
838
850
  const priorInstructions = prior?.instructions;
839
851
  if (prior) {
840
852
  prior.announce.fn = undefined;
853
+ prior.health.observe = undefined;
841
854
  serverHandles.splice(hIdx, 1);
842
855
  const cIdx = clients.indexOf(prior.client);
843
856
  if (cIdx !== -1)
@@ -851,7 +864,8 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
851
864
  }
852
865
  catch (err) {
853
866
  const { kind, delivered, httpStatus } = classifyMcpFailure(err, { phase: "connect", transport: dialKind });
854
- const status = { name: server, status: "failed", errorCode: kind, delivered, ...(httpStatus !== undefined ? { httpStatus } : {}), error: namedMcpFailureText(err) };
867
+ const redialLiveness = livenessOf(kind, Date.now());
868
+ const status = { name: server, status: "failed", errorCode: kind, delivered, ...(httpStatus !== undefined ? { httpStatus } : {}), error: namedMcpFailureText(err), ...(redialLiveness !== undefined ? { liveness: redialLiveness } : {}) };
855
869
  const sIdx = statuses.findIndex((st) => st.name === server);
856
870
  if (sIdx === -1)
857
871
  statuses.push(status);
@@ -953,6 +967,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
953
967
  });
954
968
  }
955
969
  catch (err) {
970
+ h.health.observe?.(classifyMcpFailure(err, { phase: "request" }).kind);
956
971
  results.push({
957
972
  server: h.name,
958
973
  prefix: prefixOf(h.name),
@@ -1334,7 +1349,8 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
1334
1349
  try {
1335
1350
  res = await rs.client
1336
1351
  .readResource({ uri }, { signal: watchdog.combinedSignal, timeout: timeoutMs })
1337
- .catch((err) => rethrowHonestMcpError(err, { server, what, timeoutMs, writeEffect: false, signal, attributeServer: true, idle: { signal: watchdog.idleSignal, idleMs } }));
1352
+ .catch((err) => rethrowHonestMcpError(err, { server, what, timeoutMs, writeEffect: false, signal, attributeServer: true, idle: { signal: watchdog.idleSignal, idleMs }, liveness: rs.health }));
1353
+ rs.health.observe?.("completed");
1338
1354
  }
1339
1355
  finally {
1340
1356
  watchdog.dispose();
@@ -1393,7 +1409,7 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
1393
1409
  try {
1394
1410
  const ext = rs.client.getServerCapabilities()?.extensions?.[MCP_SKILLS_EXTENSION];
1395
1411
  if (ext?.directoryRead === true) {
1396
- const r = await readDirViaExtension(rs, uri, signal, timeoutMs, watchdog).catch((err) => rethrowHonestMcpError(err, { server, what, timeoutMs, writeEffect: false, signal, attributeServer: true, idle: { signal: watchdog.idleSignal, idleMs } }));
1412
+ const r = await readDirViaExtension(rs, uri, signal, timeoutMs, watchdog).catch((err) => rethrowHonestMcpError(err, { server, what, timeoutMs, writeEffect: false, signal, attributeServer: true, idle: { signal: watchdog.idleSignal, idleMs }, liveness: rs.health }));
1397
1413
  const serverSaid = (detail) => `\nThe server's error text follows as external/untrusted data:\n${delimitUntrusted(`${server} error`, truncateMcpErrorText(detail))}`;
1398
1414
  if (r.kind === "not_found") {
1399
1415
  return {
@@ -1421,7 +1437,8 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
1421
1437
  const walk = await walkMcpListPages(async (cursor, remainingMs) => {
1422
1438
  const page = await rs.client
1423
1439
  .listResources(cursor === undefined ? undefined : { cursor }, { signal: watchdog.combinedSignal, timeout: remainingMs })
1424
- .catch((err) => rethrowHonestMcpError(err, { server, what, timeoutMs: remainingMs, writeEffect: false, signal, attributeServer: true, idle: { signal: watchdog.idleSignal, idleMs } }));
1440
+ .catch((err) => rethrowHonestMcpError(err, { server, what, timeoutMs: remainingMs, writeEffect: false, signal, attributeServer: true, idle: { signal: watchdog.idleSignal, idleMs }, liveness: rs.health }));
1441
+ rs.health.observe?.("completed");
1425
1442
  return { items: page.resources, ...(page.nextCursor !== undefined ? { nextCursor: page.nextCursor } : {}) };
1426
1443
  }, { budgetMs: timeoutMs, onPage: () => watchdog.rearm(), fingerprintOf: listEntryFingerprint, ...(signal !== undefined ? { signal } : {}) });
1427
1444
  const dirPrefix = uri.endsWith("/") ? uri : `${uri}/`;
@@ -1552,13 +1569,15 @@ async function connectServer(spec, principal, onElicit, imageResizer, reminderDi
1552
1569
  const rawInstructions = client.getInstructions();
1553
1570
  const instructions = rawInstructions && rawInstructions.trim() ? sanitizeUntrustedText(rawInstructions.trim()) : undefined;
1554
1571
  const info = client.getServerVersion();
1572
+ const status = {
1573
+ name: spec.name,
1574
+ status: "connected",
1575
+ ...(info ? { serverInfo: { name: info.name, version: info.version } } : {}),
1576
+ toolNames: serverTools.map((t) => t.name),
1577
+ liveness: livenessOf("completed", Date.now()),
1578
+ };
1555
1579
  return {
1556
- status: {
1557
- name: spec.name,
1558
- status: "connected",
1559
- ...(info ? { serverInfo: { name: info.name, version: info.version } } : {}),
1560
- toolNames: serverTools.map((t) => t.name),
1561
- },
1580
+ status,
1562
1581
  client,
1563
1582
  health,
1564
1583
  announce,
@@ -1693,10 +1712,12 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1693
1712
  writeEffect,
1694
1713
  signal,
1695
1714
  idle: { signal: watchdog.idleSignal, idleMs },
1715
+ liveness: health,
1696
1716
  ...(reminderDisclosure !== undefined
1697
1717
  ? { reminder: { mark: reminderDisclosure.mark, outlet: "mcp", ...(reminderDisclosure.counts !== undefined ? { counts: reminderDisclosure.counts } : {}) } }
1698
1718
  : {}),
1699
1719
  }));
1720
+ health.observe?.("completed");
1700
1721
  }
1701
1722
  finally {
1702
1723
  watchdog.dispose();
@@ -5,6 +5,7 @@ import { PATH_RULE_BASE_LABEL, isUsablePathBase, parseRuleText, pathRuleReachOf,
5
5
  import { pathFamilyOf } from "../tools/fs/safety.js";
6
6
  import { effectivePathTargetOf } from "./effective-path-target.js";
7
7
  import { protectivePathTargetOf } from "./tool-registry.js";
8
+ import { ruleDenialSentence } from "./rule-denial-sentence.js";
8
9
  export { parsePermissionRule };
9
10
  export const BASH_GENERIC_PARAMS = new Set(catalogRuleFaceOf("Bash")?.params ?? []);
10
11
  const DEFAULT_CAPS = {
@@ -267,8 +268,11 @@ function matchParamRules(rules, args, maxScalarValueChars) {
267
268
  }
268
269
  return undefined;
269
270
  }
270
- function ruleMessage(kind, ruleText, source) {
271
- return `${kind} by permission rule "${ruleText}"${source ? ` (${source})` : ""}`;
271
+ function ruleDenial(req, ruleText, source) {
272
+ return ruleDenialSentence({ source: "permission", tool: req.toolName, rule: ruleText, ...(source ? { origin: source } : {}) });
273
+ }
274
+ function ruleAskMessage(ruleText, source) {
275
+ return `flagged by permission rule "${ruleText}"${source ? ` (${source})` : ""}`;
272
276
  }
273
277
  export function createPermissionRulePolicy(rules, opts) {
274
278
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
@@ -357,45 +361,45 @@ export function createPermissionRulePolicy(rules, opts) {
357
361
  const entry = byTool.get(req.toolName);
358
362
  const covering = hasCovering && protocolOf(req.toolName) !== undefined;
359
363
  if (entry?.bare.deny) {
360
- return { action: "deny", message: ruleMessage("denied", entry.bare.deny.ruleText, entry.bare.deny.source) };
364
+ return { action: "deny", message: ruleDenial(req, entry.bare.deny.ruleText, entry.bare.deny.source) };
361
365
  }
362
366
  if (covering) {
363
367
  const coveringDeny = coveringHit(namespacedCovering.deny, req.toolName);
364
368
  if (coveringDeny)
365
- return { action: "deny", message: ruleMessage("denied", coveringDeny.ruleText, coveringDeny.source) };
369
+ return { action: "deny", message: ruleDenial(req, coveringDeny.ruleText, coveringDeny.source) };
366
370
  }
367
371
  const contentDeny = entry !== undefined ? contentHit(entry.content.deny, req) : undefined;
368
372
  if (contentDeny !== undefined && contentDeny.unreadable === undefined) {
369
- return { action: "deny", message: ruleMessage("denied", contentDeny.rule.ruleText, contentDeny.rule.source) };
373
+ return { action: "deny", message: ruleDenial(req, contentDeny.rule.ruleText, contentDeny.rule.source) };
370
374
  }
371
375
  if (entry) {
372
376
  const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars);
373
377
  if (paramDeny) {
374
- return { action: "deny", message: ruleMessage("denied", paramDeny.ruleText, paramDeny.source) };
378
+ return { action: "deny", message: ruleDenial(req, paramDeny.ruleText, paramDeny.source) };
375
379
  }
376
380
  }
377
381
  if (contentDeny?.unreadable !== undefined)
378
382
  return unreadableAsk({ rule: contentDeny.rule, unreadable: contentDeny.unreadable }, req);
379
383
  if (entry) {
380
384
  if (entry.bare.ask) {
381
- return { action: "ask", message: ruleMessage("flagged", entry.bare.ask.ruleText, entry.bare.ask.source), matchedAskRule: entry.bare.ask.ruleText };
385
+ return { action: "ask", message: ruleAskMessage(entry.bare.ask.ruleText, entry.bare.ask.source), matchedAskRule: entry.bare.ask.ruleText };
382
386
  }
383
387
  }
384
388
  if (covering) {
385
389
  const coveringAsk = coveringHit(namespacedCovering.ask, req.toolName);
386
390
  if (coveringAsk)
387
- return { action: "ask", message: ruleMessage("flagged", coveringAsk.ruleText, coveringAsk.source), matchedAskRule: coveringAsk.ruleText };
391
+ return { action: "ask", message: ruleAskMessage(coveringAsk.ruleText, coveringAsk.source), matchedAskRule: coveringAsk.ruleText };
388
392
  }
389
393
  if (entry) {
390
394
  const contentAsk = contentHit(entry.content.ask, req);
391
395
  if (contentAsk !== undefined) {
392
396
  if (contentAsk.unreadable !== undefined)
393
397
  return unreadableAsk({ rule: contentAsk.rule, unreadable: contentAsk.unreadable }, req);
394
- return { action: "ask", message: ruleMessage("flagged", contentAsk.rule.ruleText, contentAsk.rule.source), matchedAskRule: contentAsk.rule.ruleText };
398
+ return { action: "ask", message: ruleAskMessage(contentAsk.rule.ruleText, contentAsk.rule.source), matchedAskRule: contentAsk.rule.ruleText };
395
399
  }
396
400
  const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
397
401
  if (paramAsk) {
398
- return { action: "ask", message: ruleMessage("flagged", paramAsk.ruleText, paramAsk.source), matchedAskRule: paramAsk.ruleText };
402
+ return { action: "ask", message: ruleAskMessage(paramAsk.ruleText, paramAsk.source), matchedAskRule: paramAsk.ruleText };
399
403
  }
400
404
  if (entry.bare.allow) {
401
405
  return { action: "allow" };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The ONE mint for "a rule refused this call".
3
+ *
4
+ * WHY IT EXISTS. The engine has two rule stores that can refuse a tool call — the per-session policy rows
5
+ * a deployment writes against a session id, and the declarative permission rules compiled from a settings
6
+ * layer — and each used to spell its refusal in its own grammar at its own site: `tool "X" is denied by a
7
+ * session rule` beside `denied by permission rule "R" (layer)`, with the session lane's four containment
8
+ * arms in three further shapes and the resumed-execution re-check asserting a source ("a session rule")
9
+ * that was only ever one of the narrowing layers it speaks for. A consumer reading those strings could not
10
+ * tell WHICH store had spoken, which is precisely the confusion that produced the wrong answer to "is the
11
+ * rule store re-read at every gate?" — the two stores were being described as one.
12
+ *
13
+ * So: one grammar, one vocabulary of sources, one place to change either.
14
+ */
15
+ /**
16
+ * The closed vocabulary of rule sources a refusal may name, each with the two spellings the sentence needs:
17
+ * `bare` when the source has no rule text to quote, `named` when it does. The type is DERIVED from this
18
+ * table, so a source without its spellings is not expressible and no fence is needed to say so.
19
+ *
20
+ * The words are the ENGINE'S rule-bearing faces, not a settings layer's (`source`/`origin` are different
21
+ * questions: WHICH STORE spoke, versus WHERE in that store's layering the row was written — the second
22
+ * rides `origin`).
23
+ */
24
+ declare const RULE_SOURCE_PHRASE: {
25
+ /** The per-session policy rows (`SessionPolicyStore`), folded in as a deny-narrowing layer. */
26
+ readonly session: {
27
+ readonly bare: "a session rule";
28
+ readonly named: "session rule";
29
+ };
30
+ /** The declarative permission rules compiled from a deployment's settings layers. */
31
+ readonly permission: {
32
+ readonly bare: "a permission rule";
33
+ readonly named: "permission rule";
34
+ };
35
+ /**
36
+ * The deny-narrowing fold as a WHOLE — session rules, inherited ancestor rules, the frozen constraint
37
+ * projections and the active skill scope. Used where the refusing layer's own sentence is carried as the
38
+ * detail and the wrapper must not claim to know which of them spoke.
39
+ */
40
+ readonly narrowing: {
41
+ readonly bare: "a narrowing policy layer";
42
+ readonly named: "narrowing policy layer";
43
+ };
44
+ };
45
+ /** A rule-bearing face that can refuse a call ({@link RULE_SOURCE_PHRASE} holds the whole vocabulary). */
46
+ export type RuleDenialSource = keyof typeof RULE_SOURCE_PHRASE;
47
+ /** How a refusal names `source` in prose, with no rule text to quote — for a sentence this module does not
48
+ * mint whole (a wrapper that carries the refusing layer's own sentence as its body). */
49
+ export declare function ruleSourcePhrase(source: RuleDenialSource): string;
50
+ /**
51
+ * Mint the refusal.
52
+ *
53
+ * @contract rule_denial.one_sentence — every refusal a RULE STORE mints reads
54
+ * `[tool "<tool>" is ]denied by <source>[ "<rule>"][ (<origin>)][: <detail>]`, composed here and nowhere
55
+ * else: the call is named first, then WHICH rule-bearing face refused (from the closed source vocabulary),
56
+ * then the rule's own text and the layer it was written in where the source has them, and last the reason
57
+ * this call failed the rule. A consumer reading a refusal can therefore always tell the session-policy
58
+ * store's answer from the permission rules' — and a carrier sentence that already named the call omits the
59
+ * head rather than spelling a second grammar.
60
+ *
61
+ * - `tool` — named when the sentence stands alone; omitted when a carrier sentence already named the call.
62
+ * - `rule` — the rule's own text, when the source has one to quote (a permission rule does; a session
63
+ * policy row is structured fields, not a rule DSL, so it does not).
64
+ * - `origin` — where in that store's layering the row was written (e.g. a settings layer's key path).
65
+ * - `detail` — WHY this call failed the rule, when the source word alone does not say it (an allowlist
66
+ * miss, a write the confinement cannot place, the refusing layer's own sentence).
67
+ */
68
+ export declare function ruleDenialSentence(spec: {
69
+ source: RuleDenialSource;
70
+ tool?: string;
71
+ rule?: string;
72
+ origin?: string;
73
+ detail?: string;
74
+ }): string;
75
+ export {};
@@ -0,0 +1,16 @@
1
+ const RULE_SOURCE_PHRASE = {
2
+ session: { bare: "a session rule", named: "session rule" },
3
+ permission: { bare: "a permission rule", named: "permission rule" },
4
+ narrowing: { bare: "a narrowing policy layer", named: "narrowing policy layer" },
5
+ };
6
+ export function ruleSourcePhrase(source) {
7
+ return RULE_SOURCE_PHRASE[source].bare;
8
+ }
9
+ export function ruleDenialSentence(spec) {
10
+ const phrase = RULE_SOURCE_PHRASE[spec.source];
11
+ const by = spec.rule === undefined ? phrase.bare : `${phrase.named} ${JSON.stringify(spec.rule)}`;
12
+ const head = spec.tool === undefined ? "" : `tool ${JSON.stringify(spec.tool)} is `;
13
+ const origin = spec.origin === undefined ? "" : ` (${spec.origin})`;
14
+ const detail = spec.detail === undefined ? "" : `: ${spec.detail}`;
15
+ return `${head}denied by ${by}${origin}${detail}`;
16
+ }
@@ -14,7 +14,7 @@ import { probeMandatedAsk } from "../ask-origin.js";
14
14
  import { headlessDenyAtFold, headlessDenyAtRecheck, inheritedAskCarry, judgeInheritedClassifier, lateAskSettlementObserver, settleDenialLimitFallback } from "./denial-limit-arms.js";
15
15
  import { askGrantShapeOf } from "./inherited-ask-grants.js";
16
16
  import { createPermissionRuleLanes, inheritedAskRuleEvidence, createRuleOffersOf } from "./permission-rule-lanes.js";
17
- import { createSessionRulePolicy } from "./session-rule-policy.js";
17
+ import { createLiveSessionRules, createSessionRulePolicy } from "./session-rule-policy.js";
18
18
  import { thrownText } from "../thrown-value.js";
19
19
  export async function preparePolicyChain(input) {
20
20
  const { spec, deps, internals, sessionId, runId, hostTaskId, taskRootFinal, executionEnv, lockedPreflight, tools, harnessTools, toolEffects, egressTools, irreversibilityTier, axisExplicitNegatives, shellGatedTools, envHandToolNames, handsCwdRef, readDenyMatcher, questionToolMounted, runtimeCaps, inheritedAncestorRules, inheritedParentConstraints, seedInheritedGate, hasSkillManifest, skillScope, frozenOnAsk, hookEnvSource, notifyOwnHookCrash, autoModeDecider, stopForDenialLimit, delegation, abortController, now, ownSessionRulesRef } = input;
@@ -69,32 +69,42 @@ export async function preparePolicyChain(input) {
69
69
  }
70
70
  };
71
71
  auditPolicyNames(policy);
72
+ const announceSessionRulesUnreadable = (reason) => {
73
+ try {
74
+ const e = new Error(`session permission rules: ${reason} — the snapshot this leg already enforces stays in force (a failed read never loosens it)`);
75
+ e.code = "config.session_rules_unreadable";
76
+ deps.onError?.(e, { phase: "config", sessionId });
77
+ }
78
+ catch {
79
+ }
80
+ };
72
81
  let sessionRulePolicy;
73
82
  if (deps.sessionPolicyStore && spec.sessionId) {
74
- const rules = await deps.sessionPolicyStore.getRules(spec.sessionId, spec.principal);
75
- if (rules) {
76
- sessionRulePolicy = createSessionRulePolicy(rules, { env: executionEnv, rootPath: taskRootFinal, toolEffects });
83
+ const store = deps.sessionPolicyStore;
84
+ const sessionRuleKey = { sessionId: spec.sessionId, ...(spec.principal !== undefined ? { principal: spec.principal } : {}) };
85
+ const rules = await store.getRules(spec.sessionId, spec.principal);
86
+ const seed = rules ?? { rev: 0 };
87
+ const publishOwnRules = (row) => {
77
88
  ownSessionRulesRef.current = {
78
- sessionId: spec.sessionId,
79
- ...(spec.principal !== undefined ? { principal: spec.principal } : {}),
80
- rev: rules.rev,
81
- rules,
89
+ sessionId: sessionRuleKey.sessionId,
90
+ ...(sessionRuleKey.principal !== undefined ? { principal: sessionRuleKey.principal } : {}),
91
+ rev: row.rev,
92
+ rules: row,
82
93
  };
83
- }
94
+ };
95
+ sessionRulePolicy = createSessionRulePolicy({ seed, read: createLiveSessionRules(store, { ...sessionRuleKey, seed }, { onUnreadable: announceSessionRulesUnreadable, onEnforced: publishOwnRules }) }, { env: executionEnv, rootPath: taskRootFinal, toolEffects });
96
+ if (rules)
97
+ publishOwnRules(rules);
84
98
  }
85
99
  const ancestorRulePolicies = [];
86
100
  for (const snap of inheritedAncestorRules ?? []) {
87
- let ancestorRules = snap.rules;
88
- if (deps.sessionPolicyStore) {
89
- try {
90
- const live = await deps.sessionPolicyStore.getRules(snap.sessionId, snap.principal);
91
- if (live !== null && live.rev >= snap.rev)
92
- ancestorRules = live;
93
- }
94
- catch {
95
- }
96
- }
97
- ancestorRulePolicies.push(createSessionRulePolicy(ancestorRules, { env: executionEnv, rootPath: taskRootFinal, toolEffects }));
101
+ const seed = { ...snap.rules, rev: snap.rev };
102
+ ancestorRulePolicies.push(createSessionRulePolicy({
103
+ seed,
104
+ ...(deps.sessionPolicyStore
105
+ ? { read: createLiveSessionRules(deps.sessionPolicyStore, { sessionId: snap.sessionId, ...(snap.principal !== undefined ? { principal: snap.principal } : {}), seed }, { onUnreadable: announceSessionRulesUnreadable }) }
106
+ : {}),
107
+ }, { env: executionEnv, rootPath: taskRootFinal, toolEffects }));
98
108
  }
99
109
  const skillScopePolicy = hasSkillManifest
100
110
  ? createActiveSkillScopePolicy({