@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.
- package/CHANGELOG.md +51 -1
- package/dist/core/mcp-failure.d.ts +73 -0
- package/dist/core/mcp-failure.js +21 -0
- package/dist/core/mcp.d.ts +18 -1
- package/dist/core/mcp.js +33 -12
- package/dist/core/permission-rules.js +14 -10
- package/dist/core/rule-denial-sentence.d.ts +75 -0
- package/dist/core/rule-denial-sentence.js +16 -0
- package/dist/core/runner/prepare-policy-chain.js +30 -20
- package/dist/core/runner/prepare-wiring-manifest.d.ts +3 -2
- package/dist/core/runner/prepare-wiring-manifest.js +3 -1
- package/dist/core/runner/run-leg.js +2 -1
- package/dist/core/runner/session-rule-policy.d.ts +74 -2
- package/dist/core/runner/session-rule-policy.js +113 -21
- package/dist/core/wiring-manifest.d.ts +20 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/orchestration/workflow.js +12 -3
- package/dist/prompt-assembly/packs/sema-default.js +15 -0
- package/dist/prompts/default.d.ts +1 -1
- package/dist/prompts/default.js +4 -4
- package/dist/tools/fs/bash-program-position.d.ts +5 -0
- package/dist/tools/fs/bash-program-position.js +1 -1
- package/dist/tools/fs/bash-readonly-classifier.d.ts +476 -1
- package/dist/tools/fs/bash-readonly-classifier.js +98 -34
- package/dist/tools/fs/fs-bash.js +2 -325
- package/dist/tools/fs/shell-read-establish.d.ts +127 -0
- package/dist/tools/fs/shell-read-establish.js +326 -0
- package/dist/tools/fs/shell-read-plan.d.ts +113 -0
- package/dist/tools/fs/shell-read-plan.js +700 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +7 -1
- package/wire-changes/core@7.24.3.json +244 -0
- package/wire-changes/core@7.24.4.json +54 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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 = {
|
package/dist/core/mcp-failure.js
CHANGED
|
@@ -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",
|
package/dist/core/mcp.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
271
|
-
return
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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:
|
|
79
|
-
...(
|
|
80
|
-
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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({
|