@sema-agent/core 7.19.0 → 7.20.1

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 (114) hide show
  1. package/CHANGELOG.md +199 -2
  2. package/dist/agents/subagent.js +12 -20
  3. package/dist/agents/teacher.js +2 -5
  4. package/dist/agents/verify.js +21 -10
  5. package/dist/brain/anthropic.js +3 -1
  6. package/dist/brain/open-responses.js +4 -2
  7. package/dist/brain/openai.js +3 -1
  8. package/dist/brain/stream-engine.d.ts +4 -0
  9. package/dist/brain/stream-engine.js +4 -0
  10. package/dist/core/auto-mode.d.ts +37 -18
  11. package/dist/core/auto-mode.js +1 -1
  12. package/dist/core/child-clamp-seats.d.ts +79 -0
  13. package/dist/core/child-clamp-seats.js +29 -0
  14. package/dist/core/engine-notice.d.ts +76 -49
  15. package/dist/core/fs-write-gate-policy.d.ts +17 -9
  16. package/dist/core/fs-write-gate-policy.js +51 -4
  17. package/dist/core/gate-bounded-await.d.ts +38 -0
  18. package/dist/core/gate-bounded-await.js +53 -0
  19. package/dist/core/gate-fold.js +16 -48
  20. package/dist/core/gate-lanes.js +11 -3
  21. package/dist/core/gate-outcome.d.ts +9 -6
  22. package/dist/core/governance-codes.d.ts +16 -1
  23. package/dist/core/governance-codes.js +14 -0
  24. package/dist/core/hooks.d.ts +8 -8
  25. package/dist/core/memory-engine/engine.d.ts +14 -9
  26. package/dist/core/memory-engine/engine.js +21 -8
  27. package/dist/core/memory-engine/layout.d.ts +31 -0
  28. package/dist/core/memory-engine/layout.js +68 -3
  29. package/dist/core/memory-engine/tools.js +6 -0
  30. package/dist/core/memory-engine/types.d.ts +24 -0
  31. package/dist/core/memory.d.ts +19 -0
  32. package/dist/core/memory.js +12 -0
  33. package/dist/core/remote-env.d.ts +32 -0
  34. package/dist/core/remote-env.js +4 -1
  35. package/dist/core/runner/advertised-writable-dirs.d.ts +59 -1
  36. package/dist/core/runner/advertised-writable-dirs.js +51 -3
  37. package/dist/core/runner/announce-once-ledger.d.ts +15 -0
  38. package/dist/core/runner/announce-once-ledger.js +8 -0
  39. package/dist/core/runner/classifier-ladder.d.ts +5 -0
  40. package/dist/core/runner/classifier-ladder.js +13 -2
  41. package/dist/core/runner/contracts.d.ts +44 -1
  42. package/dist/core/runner/denial-limit-arms.d.ts +14 -1
  43. package/dist/core/runner/denial-limit-arms.js +5 -4
  44. package/dist/core/runner/env-capability-census.d.ts +30 -0
  45. package/dist/core/runner/env-capability-census.js +38 -0
  46. package/dist/core/runner/forward-sink.d.ts +32 -0
  47. package/dist/core/runner/forward-sink.js +41 -0
  48. package/dist/core/runner/gate-exit.d.ts +4 -0
  49. package/dist/core/runner/gate-exit.js +4 -1
  50. package/dist/core/runner/git-status-frame.d.ts +0 -9
  51. package/dist/core/runner/listing-fold.d.ts +57 -0
  52. package/dist/core/runner/listing-fold.js +75 -0
  53. package/dist/core/runner/prepare-execution-env.d.ts +1 -1
  54. package/dist/core/runner/prepare-execution-env.js +2 -0
  55. package/dist/core/runner/prepare-gate-stations.d.ts +6 -0
  56. package/dist/core/runner/prepare-gate-stations.js +1 -3
  57. package/dist/core/runner/prepare-hands-readface.d.ts +4 -0
  58. package/dist/core/runner/prepare-hands-readface.js +3 -5
  59. package/dist/core/runner/prepare-listings.d.ts +3 -0
  60. package/dist/core/runner/prepare-listings.js +4 -2
  61. package/dist/core/runner/prepare-memory.js +7 -1
  62. package/dist/core/runner/prepare-policy-chain.js +5 -5
  63. package/dist/core/runner/prepare-prompt-inputs.d.ts +2 -2
  64. package/dist/core/runner/prepare-run-refs.d.ts +4 -2
  65. package/dist/core/runner/prepare-run-refs.js +5 -4
  66. package/dist/core/runner/prepare-task.js +8 -6
  67. package/dist/core/runner/prepare-wiring-manifest.d.ts +8 -1
  68. package/dist/core/runner/prepare-wiring-manifest.js +5 -2
  69. package/dist/core/runner/run-clock-and-content.js +3 -7
  70. package/dist/core/runner/run-harness-handlers.js +10 -10
  71. package/dist/core/runner/run-leg.js +28 -19
  72. package/dist/core/runner/run-settle-and-teardown.js +2 -5
  73. package/dist/core/runner/run-turn-boundary.js +44 -33
  74. package/dist/core/runner/runtask.js +2 -1
  75. package/dist/core/runner/synthetic-tools.d.ts +13 -39
  76. package/dist/core/runner/synthetic-tools.js +15 -59
  77. package/dist/core/runner/task-root-canonical.d.ts +45 -0
  78. package/dist/core/runner/task-root-canonical.js +18 -0
  79. package/dist/core/runner/turn-attachments.d.ts +125 -49
  80. package/dist/core/runner/turn-attachments.js +124 -66
  81. package/dist/core/runner/write-protection-station.d.ts +59 -0
  82. package/dist/core/runner/write-protection-station.js +32 -0
  83. package/dist/core/safe-notify.d.ts +5 -0
  84. package/dist/core/safe-notify.js +24 -2
  85. package/dist/core/sensitive-path-policy.d.ts +53 -27
  86. package/dist/core/sensitive-path-policy.js +16 -26
  87. package/dist/core/skill-listing-budget.d.ts +34 -40
  88. package/dist/core/skill-listing-budget.js +25 -26
  89. package/dist/core/skills-directory.js +17 -10
  90. package/dist/core/task-registry.js +2 -1
  91. package/dist/core/task-spec.d.ts +8 -7
  92. package/dist/core/tool-policy.d.ts +12 -1
  93. package/dist/core/tool-policy.js +39 -25
  94. package/dist/core/wiring-manifest.d.ts +31 -0
  95. package/dist/core/wiring-manifest.js +1 -0
  96. package/dist/core/write-protect.d.ts +124 -18
  97. package/dist/core/write-protect.js +91 -34
  98. package/dist/index.d.ts +6 -5
  99. package/dist/index.js +6 -5
  100. package/dist/orchestration/workflow-primitives.js +10 -20
  101. package/dist/stores/file/memory-store.d.ts +5 -2
  102. package/dist/stores/file/memory-store.js +8 -4
  103. package/dist/tools/fs/bash-readonly-classifier.d.ts +13 -2
  104. package/dist/tools/fs/bash-readonly-classifier.js +318 -36
  105. package/dist/tools/fs/fs-bash.d.ts +1 -1
  106. package/dist/tools/fs/fs-bash.js +12 -25
  107. package/dist/tools/fs/fs-shared.d.ts +22 -0
  108. package/dist/tools/fs/fs-shared.js +30 -0
  109. package/dist/tools/fs/index.d.ts +1 -1
  110. package/dist/tools/fs/index.js +1 -1
  111. package/package.json +2 -1
  112. package/test/export-surface.snapshot.json +25 -3
  113. package/wire-changes/core@7.20.0.json +568 -0
  114. package/wire-changes/core@7.20.1.json +183 -0
@@ -10,31 +10,50 @@ export interface EngineNotice {
10
10
  * `autoCompactTokens` ABOVE its physical window; the trigger-side geometry was clamped to the
11
11
  * physical window (an autocompact window only ever lowers the trigger) and the bad value is
12
12
  * announced once per prepared task; `detail: { modelId, declaredAutoCompactTokens,
13
- * physicalWindow, sessionId, runId }` — `runId` (#499) is what makes the "once per prepared
14
- * task" claim checkable: the session id beside it is shared by every run of the session, so two
15
- * runs clamping the same model used to mint byte-identical lines.
13
+ * physicalWindow, sessionId, runId }` — `runId` is what makes the "once per prepared task" claim
14
+ * checkable, the session id beside it being shared by every run of the session.
16
15
  * - `"config.env_timeout_discarded"` — a Bash timeout knob (option or env) held a value that is not
17
16
  * the value in force; `detail: { knob, raw, usedMs }`.
18
17
  * - `"config.materialize_env_discarded"` — `SEMA_TOOL_MATERIALIZE_STRATEGY` held a value outside the
19
18
  * closed set in a seat where it is not in force; `detail: { raw, specStrategy? }`.
19
+ * - `"config.task_root_not_canonical"` (#759) — the task root this run was handed is not its own
20
+ * canonical path (a symlinked directory; `/var` → `/private/var` on macOS). Containment is judged
21
+ * on the CANONICAL spelling, so the deployment and the engine reason about two different
22
+ * directories: a target the host considers inside the root can be judged outside it. The run is
23
+ * unchanged — announce, never narrow, never repair — and the fix is to hand the canonical path in.
24
+ * Audience `operator` (a deployment wiring fact). `detail: { given, canonical, sessionId, runId }`
25
+ * — both spellings, so a consumer can diff them without guessing which one it holds. Once per
26
+ * prepared leg, and never for a root that already is its own canonical form (its absence is the
27
+ * claim that the two agree).
28
+ * - `"config.write_protection_target_view_absent"` — this leg's write-protection judge was compiled
29
+ * with NO execution environment while at least one path-confinable write tool is mounted, so it
30
+ * compares SPELLINGS against its table and nothing else: a write reaching a protected path only
31
+ * through an alias is cleared. Announce, never narrow — no verdict moves (an environment-less judge
32
+ * has no filesystem to resolve in). Audience `operator`. `detail: { tools, sessionId, runId }`,
33
+ * `tools` = the path-confinable write tools on the LEG-START roster, ascending. EXACTLY ONCE per
34
+ * prepared leg, never per judgment and never re-fired for a mid-run roster delta; absent ⇒ the judge
35
+ * has its target view, or no covered write tool was mounted AT PREPARE, or there is no judge —
36
+ * `wiringManifest.writeProtection` reports the judge's reading either way.
20
37
  * - `"config.tool_card_undeclared"` (design/388 L4) — a tool minted a `details.type` card its definition
21
38
  * (or its caller declaration's `renderHints.resultCards`) never declared; the card was dropped from
22
39
  * `tool_end.structured`; once per leg per type; `detail: { type, sessionId, runId }`.
23
40
  * - `"config.tool_face_undeclared"` / `"config.tool_face_invalid"` (design/388 §2.7) — an MCP server's
24
41
  * `toolFaces` overlay names a tool the listing does not carry / does not fit the tool's materialized
25
42
  * schema (the tool mounts bare); once per leg per tool; `detail: { server, tool, problem?, sessionId, runId }`.
43
+ * - `"agents.listing_truncated"` — the agent-type roster frame did not fit the same listing budget:
44
+ * at least one type is listed name-only (`nameOnly`) or not at all (`dropped`). Audience `operator`.
45
+ * `detail: { budgetBytes, total, kept, nameOnly: [name], dropped: [name], sessionId }`; the same
46
+ * firing rule as its skills sibling below (once per delivered frame, on the carrying message's receipt).
26
47
  * - `"skills.listing_truncated"` — the `<skills>` disclosure block did not fit its byte budget, so
27
48
  * at least one skill is listed without its description (`nameOnly`) or, in the pathological case,
28
49
  * not listed at all (`dropped`); the `Skill` tool still serves every one of them. Audience
29
50
  * `operator` — raising `TaskSpec.skillListingBudgetFraction`, lowering `skillListingMaxDescChars`
30
- * or declaring fewer skills are all deployment acts. `detail: { budgetBytes,
31
- * requestedBudgetBytes, descMaxChars, total, kept, nameOnly: [name], dropped: [name], sessionId }`
32
- * — `requestedBudgetBytes` is what the declared fraction asked for BEFORE the delivery lane's
33
- * structural clamp, and differs from `budgetBytes` only when that clamp bound, so a listing
34
- * shortened by the lane is distinguishable from one shortened by the deployment's own fraction.
35
- * Announced by the lane that DELIVERS a listing frame — the leg's first-frame producer, or the
36
- * turn boundary that a drift (or a resume leg's initial listing) rides — once per delivered frame
37
- * the budget actually degraded, and never for one that fit. Every count is read off that frame, so
51
+ * or declaring fewer skills are all deployment acts. `detail: { budgetBytes, descMaxChars, total,
52
+ * kept, nameOnly: [name], dropped: [name], sessionId }` — `budgetBytes` IS the budget in force;
53
+ * no ceiling stands above the declared fraction, so no second number rides beside it.
54
+ * Announced on the RECEIPT of the message that carried the frame — the leg's first user message, or
55
+ * the boundary steer that a drift (or a resume leg's initial listing) rides — once per delivered
56
+ * frame the budget actually degraded, and never for one that fit. Every count is read off that frame, so
38
57
  * `total` is the whole roster on an initial listing and the newly announced set on a drift frame.
39
58
  * A run whose deployment turned the listing off (`attachments.skillsListing: false`) emits nothing,
40
59
  * since nothing is rendered. There is NO dedup key: each notice is one delivered frame`s cost, and
@@ -43,21 +62,19 @@ export interface EngineNotice {
43
62
  * failed; THIS attempt stored nothing (the failure arm reports, it never re-inserts under the
44
63
  * ref) — an earlier attempt of the same idempotent re-put may already have stored the row, so
45
64
  * the notice claims a failed write, not an empty ref;
46
- * `detail: { ref, sessionId, cause, occurrenceId }`.
47
- * Per-occurrence, not per-process-deduplicated: each failed write is a distinct fact — and
48
- * `occurrenceId` (#495, uuidv7 minted at the failure) is what lets a consumer act on that:
49
- * `ref` names the ROW, and the same ref is attempted again on every later re-clear, so two
50
- * genuine losses of one ref were byte-identical notices. Dedup key: (code, occurrenceId).
65
+ * `detail: { ref, sessionId, cause, occurrenceId }`. Per-occurrence, not
66
+ * per-process-deduplicated: each failed write is a distinct fact, and `ref` names the ROW rather
67
+ * than the attempt, so the uuidv7 `occurrenceId` minted at the failure is what tells two genuine
68
+ * losses of one ref apart. Dedup key: (code, occurrenceId).
51
69
  * - `"workflow.governance_key_stripped"` (#235) — the fields of an LLM-authored workflow `agent(spec)`
52
70
  * that did NOT cross the governed default-deny whitelist: an unrecognized/control-plane field, or a
53
71
  * `readFace` value that is not the containment-tightening one. The spawn PROCEEDS on the deployment
54
72
  * baseline (the strip is not a refusal in this window), which is why the drop is announced;
55
73
  * `detail: { total, stripped: [{ key, reason }], omitted?, occurrenceId }`, the rendered key list
56
74
  * bounded in count and length because the names come from the untrusted script. One aggregated
57
- * notice per governed child build, not de-duplicated across builds: each spec is a distinct fact —
58
- * `occurrenceId` (#495, uuidv7 minted at the announcement) carries that claim to the consumer,
59
- * which otherwise saw two builds of the same spec as one repeated notice. Dedup key:
60
- * (code, occurrenceId).
75
+ * notice per governed child build, not de-duplicated across builds: each spec is a distinct fact,
76
+ * and the uuidv7 `occurrenceId` minted at the announcement is what carries that claim to a consumer
77
+ * that would otherwise read two builds of one spec as one repeated notice. Dedup key: (code, occurrenceId).
61
78
  * - `"workflow.agent_option_ignored"` (#505 ①) — the OPTIONS-object twin of the row above: keys of
62
79
  * `agent(spec, opts)`'s SECOND argument that were not applied (unrecognized, or a known option
63
80
  * whose value its type check rejected — `label: 42`). The spawn PROCEEDS without them, which is
@@ -67,6 +84,21 @@ export interface EngineNotice {
67
84
  * key: (code, occurrenceId). NOT the whole family: a SPEC field written on the options object
68
85
  * (`model`, `modelName`, `thinking`, …) is REFUSED at the membrane instead — a silently ignored
69
86
  * `model` runs a different agent than the author asked for.
87
+ * - `"config.forward_sink_failed"` — the deployment's opt-in subagent display sink
88
+ * (`RunInternals.onForwardEvent`) failed and the frame it was handed was dropped: it threw, or —
89
+ * since an `async` function satisfies that `=> void` seat — it returned a promise that rejected.
90
+ * The run is untouched (forwarding is a render channel, and delivery is never awaited, so a sink
91
+ * that never settles is not a failure and announces nothing). `detail: { site, cause }` — `site` is
92
+ * the engine-owned site name the failures are counted under, `cause` the thrown value's message.
93
+ * Audience `operator`: the sink is deployment wiring and nobody else can repair it. Dedup unit:
94
+ * once per RUN (the counters ride `RunInternals.forwardScope`, which every copy of a run's
95
+ * internals carries), later failures of the same run counted only — a sink that fails on every
96
+ * frame therefore costs ONE line, not one per frame.
97
+ * - `"memory.restore_withheld"` — a memory containment DID NOT write a committed baseline back: another
98
+ * writer held the path when the restore reached it, and those newer bytes were left standing.
99
+ * Containment is unaffected and the committed copy is still in the store, so reconciling the two is
100
+ * the host's act (audience `operator`). `detail: { paths: [{ path, entryId?, rev? }], sessionId? }`,
101
+ * `rev` = the declined generation. Dedup unit: once per containment PASS, never per path.
70
102
  * - `"mcp.revocation_probe_failed"` (design/338) — the deployment's `mcpRevocations.isRevoked`
71
103
  * probe threw; MCP dispatch FAILS OPEN (revocation is a tightening face) and this announces
72
104
  * once per materialization (a resume re-materializes and may announce again).
@@ -90,10 +122,9 @@ export interface EngineNotice {
90
122
  * { models, tiers, occurrenceId }` — key COUNTS only, never the catalog itself. In-flight tasks
91
123
  * finish on the models they resolved at prepare (natural snapshot); every later prepare resolves
92
124
  * against the new generation. A failed swap (illegal tier binding, a refused candidate) throws
93
- * atomically and mints nothing.
94
- * `occurrenceId` (#495, uuidv7 minted at the announcement): the verb carries no run and no
95
- * session, and the payload is a pair of counts, so two swaps of the same shape were byte-
96
- * identical lines. Dedup key: (code, occurrenceId).
125
+ * atomically and mints nothing. The verb carries no run and no session and the payload is a pair
126
+ * of counts, so the uuidv7 `occurrenceId` minted at the announcement is what tells two swaps of the
127
+ * same shape apart. Dedup key: (code, occurrenceId).
97
128
  * - `"config.read_face_swapped"` — `Runner.swapDeps` replaced the deployment's read-face
98
129
  * declaration (`RunnerDeps.readFace`, the `readFace` seat of the swappable set) without a
99
130
  * restart. `detail: { from, to, occurrenceId }` — the seat's previous and new value, each
@@ -102,8 +133,7 @@ export interface EngineNotice {
102
133
  * read-only mount) still runs per prepare. In-flight legs keep the face they prepared under;
103
134
  * every later prepare resolves against the new declaration. A candidate outside the value gate
104
135
  * (`assertContainmentFaceValue`) throws atomically and mints nothing. Own `occurrenceId` for the same
105
- * reason as the catalog line (no run, no session, a two-word payload). Dedup key:
106
- * (code, occurrenceId).
136
+ * reason as the catalog line (no run, no session, a two-word payload). Dedup key: (code, occurrenceId).
107
137
  * - `"config.write_face_swapped"` — the same line for the WRITE seat (`RunnerDeps.writeFace`),
108
138
  * same payload, same laws. A SEPARATE code rather than a face field on the line above: the audience
109
139
  * table and every consumer table are keyed by code.
@@ -114,10 +144,9 @@ export interface EngineNotice {
114
144
  * `detail: { seat, from, to, cause, fixHint, sessionId?, runId? }` — `cause` is the refusal code
115
145
  * (`route.credential_mismatch` / `route.credential_missing`); `sessionId` (#433, additive)
116
146
  * rides when the adjudicating seat knows its session, and `runId` (#499, additive) names the
117
- * INVOCATION whose seats were being resolved — both correlation keys only, the audience
118
- * stays `"operator"` (see the registry's entitlement-not-routability note). Two runs of one
119
- * session falling back on the same seat used to mint byte-identical lines; `runId` is what
120
- * tells one task falling back twice apart from two tasks falling back once each.
147
+ * INVOCATION whose seats were being resolved — both correlation keys only, the audience stays
148
+ * `"operator"` (see the registry's entitlement-not-routability note), and `runId` is what tells one
149
+ * task falling back twice apart from two tasks falling back once each.
121
150
  * Explicitly-named models never mint this: they refuse at the brain's request gate instead.
122
151
  * - `"route.classifier_unroutable"` (same pre-flight, the other POSTURE) — the derived classifier seat
123
152
  * failed the same pairing pre-flight while the deployment had declared NO fallback
@@ -155,10 +184,9 @@ export interface EngineNotice {
155
184
  * refusal; per-model-hook credentials are invisible to the catalog swap, so the notice is
156
185
  * deliberately conservative (it may fire when a hook-side credential DID change in lockstep).
157
186
  * One aggregated notice per swap; `detail: { entries: [{ modelId, from, to }], total,
158
- * occurrenceId }` (rendered list bounded, total always exact). `occurrenceId` (#495, uuidv7
159
- * minted at the announcement, independent of the `config.models_swapped` line's own): the
160
- * bounded render makes two swaps moving the same first entries byte-identical.
161
- * Dedup key: (code, occurrenceId).
187
+ * occurrenceId }` (rendered list bounded, total always exact); the `occurrenceId` is minted at this
188
+ * announcement, independently of the `config.models_swapped` line's own, because the bounded render
189
+ * makes two swaps moving the same first entries identical. Dedup key: (code, occurrenceId).
162
190
  * - `"config.read_face_deployment_clamped"` (#237) — a deployment-wide `readFace: "open"` is not
163
191
  * in force beside a read-only (verifier) mount: it clamps to "roots" without throwing
164
192
  * (stricter-wins; the clamp verdict stands, only its occurrence was undisclosed). Announced
@@ -194,14 +222,17 @@ export interface EngineNotice {
194
222
  * threw: the `Artifact` tool is not mounted for the task and this is the loud half (an absent seat
195
223
  * is silent; a broken one must not look the same). Once per prepared leg;
196
224
  * `detail: { problem, sessionId, runId }`.
225
+ * - `"config.execution_env_capability_invalid"` — a remote env DECLARES a boolean capability key
226
+ * (`isolation`, `suspendable`, `canonicalPathAuthoritative`) with a non-boolean value; ABSENCE is a
227
+ * supported reading of each and stays silent. Once per key per run; `detail: { key, valueType }`.
197
228
  * - `"config.durable_gate_unavailable"` — a durable-approval MANDATE is in force on a leg with NO
198
229
  * checkpoint store: the per-principal `forceDurableGate` entitlement, or the task's own
199
230
  * `durableApproval` opt-in with no live approver seat (`durableMandateSourceOf`, the predicate the
200
231
  * leg's children inherit). A mandate with no park facility cannot be honored: no ask of the leg can suspend, so each one resolves on the LIVE
201
232
  * chain — a live approver / question face answers in-stream, and with none the ask is denied
202
233
  * fail-closed — and NO durable approval record is written. The ask routing itself is not
203
- * changed by the notice (a store-less leg never could park); the notice is the loud half of a
204
- * posture that used to degrade silently, disclosed before the first ask of the leg. Audience
234
+ * changed by the notice (a store-less leg never could park); the notice is its loud half,
235
+ * disclosed before the first ask of the leg. Audience
205
236
  * `"user"` (the person whose asks will not be recorded is entitled to that; the operator hears
206
237
  * it through the sink as always). Once per prepared task leg — a resume or a delegated child
207
238
  * is its own leg and says so again; `detail: { sessionId, runId, principal?, cause, source,
@@ -224,12 +255,10 @@ export interface EngineNotice {
224
255
  * (`sessionId` = #433's routing half for these two `"user"`-audience rows; `runId` = #499's
225
256
  * per-run identity, the key this account is actually about — see
226
257
  * {@link undrainedUserInputNotices}; all omitted when the caller has none, never fabricated).
227
- * Per-run, at most once per family (the terminal sweep is a single site) — which is exactly why
228
- * `taskId`/`sessionId` cannot key it: both answer the same value for two runs of one session,
229
- * so two runs each stranding one steer folded into one indistinguishable row.
230
- * **#389 (two corrections).** ① The family now fires on the INTERRUPT path too: `abort()` used
231
- * to empty both queues before agent_end could count them, so the one loss path an operator most
232
- * needs to hear about was the one path that stayed silent. ② On a DURABLE PARK the verdict is
258
+ * Per-run, at most once per family (the terminal sweep is a single site) — which is why
259
+ * `taskId`/`sessionId` cannot key it: both answer the same value for two runs of one session.
260
+ * ① The family fires on the INTERRUPT path too, `abort()` being barred from emptying either queue
261
+ * before agent_end can count it. ② On a DURABLE PARK the verdict is
233
262
  * settled at the run tail instead of at agent_end, because the tail first carries the still-
234
263
  * undelivered input onto the committed checkpoint's parked-steer queue (the resume delivers it)
235
264
  * — what is announced is the remainder that could NOT be carried, so a fully-migrated park says
@@ -260,10 +289,9 @@ export interface EngineNotice {
260
289
  * the other question (was the halt CONSUMED), see that seat.
261
290
  * In both lanes: one notice per REAL cut (a `now` that found nothing in flight or whose frame
262
291
  * already rode the imminent boundary, and a halt with nothing in flight, announce nothing — no
263
- * false interrupt claims); `runId` (#499) is the INVOCATION that was cut (the other two ids are
264
- * shared by every run of the session); every cut turn counts toward `maxTurns`, so an interrupt
265
- * storm's cost is bounded and each of its cuts is on the record. Audience `"user"`: the person
266
- * who forced the boundary is the one entitled to see that it landed.
292
+ * false interrupt claims); `runId` is the INVOCATION that was cut; every cut turn counts toward
293
+ * `maxTurns`, so an interrupt storm's cost is bounded and each of its cuts is on the record.
294
+ * Audience `"user"`: the person who forced the boundary is the one entitled to see that it landed.
267
295
  * RETIRED here (BREAKING, named): `"task.injection_priority_unimplemented"` — the ladder is
268
296
  * implemented, so the unhonored-knob disclosure it carried has no referent; consumers must
269
297
  * judge ladder support by VERSION, never by that code's absence.
@@ -350,9 +378,8 @@ export interface EngineNotice {
350
378
  * the inputId, so "accepted at park, screened at redelivery" is auditable end to end. Audience
351
379
  * `"user"` (the person whose instruction was withheld is the one entitled to re-issue it);
352
380
  * `detail: { inputId?, sessionId, runId, taskId? }` — `inputId` is the parked entry's stored
353
- * correlation key (OPTIONAL: a row parked without one exists), and `runId` (#499) names the
354
- * resume leg that withheld it, so a session resumed twice does not report two withholdings as
355
- * one.
381
+ * correlation key (OPTIONAL: a row parked without one exists), and `runId` names the resume leg
382
+ * that withheld it, so a session resumed twice does not report two withholdings as one.
356
383
  *
357
384
  * - `"memory.session_polluted"` (design/178 §3, #324a; message mode-aware since design/336) —
358
385
  * this session's memory crossed into the one-way externally-exposed state (a tool classified
@@ -20,7 +20,12 @@ export interface FsWriteGatePolicyOptions {
20
20
  acceptDirs?: readonly string[];
21
21
  /** Writes whose canonical target lands inside one of these dirs are ALLOWED and never asked
22
22
  * (auto-approved working areas: the scratchpad dir, plan files). Checked before `acceptDirs`
23
- * (same verdict — the split is intent/observability, exemptions are structural not session-won). */
23
+ * (same verdict — the split is intent/observability, exemptions are structural not session-won).
24
+ * An entry that RESOLVES onto `rootPath` — or above it — grants nothing: a domain that holds the
25
+ * root is not a working area but the whole workspace, and a symlinked scratchpad is all it takes.
26
+ * Same rule, same predicate as the prompt's advertised writable directories; say "every write here
27
+ * is pre-approved" with `defaultWrite: "allow"` instead. `acceptDirs` is NOT judged by it (a person
28
+ * may grant the whole root for a session — see the module header). */
24
29
  exemptDirs?: readonly string[];
25
30
  /** Verdict for a covered write OUTSIDE every domain: `"ask"` (the manual-mode default) routes it
26
31
  * to the ask-resolution chain (live onAsk / durable park / headless deny); `"allow"` is the
@@ -55,17 +60,20 @@ export interface FsWriteGatePolicyOptions {
55
60
  * deployment's gate options, not in core-global state: core stays lane-agnostic (no device concept
56
61
  * here); the deployment that runs guarded-capable envs arms it.
57
62
  *
58
- * WHAT ARMED DOES AND DOES NOT CLOSE (honesty boundary, codex r1-1): armed guarantees every covered
59
- * write lands through an ATOMIC precondition-verified backend step — the tool's own
60
- * canonicalize→write window is closed. It does NOT yet bind THIS GATE's adjudicated canonical key
61
- * across an approval wait: the tool re-canonicalizes at execute time, so a symlink swapped between
62
- * this gate's verdict and the tool's execution is re-resolved (and then atomically verified against
63
- * the NEW resolution). Closing that remaining window needs the adjudicated key carried through a
64
- * trusted approval receipt into tool execution — a separate seat, deliberately not smuggled into
65
- * this knob.
63
+ * WHAT ARMED DOES AND DOES NOT CLOSE (honesty boundary): armed guarantees every covered write lands
64
+ * through an ATOMIC precondition-verified backend step, closing the tool's own canonicalize→write
65
+ * window. It does NOT bind THIS GATE's adjudicated canonical key across an approval wait: the tool
66
+ * re-canonicalizes at execute time, so a symlink swapped between this gate's verdict and the tool's
67
+ * execution is re-resolved (then atomically verified against the NEW resolution). Closing that
68
+ * remaining window needs the adjudicated key carried through a trusted approval receipt into tool
69
+ * execution — a separate seat, deliberately not smuggled into this knob.
66
70
  */
67
71
  requireGuardedWrite?: boolean;
68
72
  }
73
+ /** The coded refusal a gate wired with a root-covering exempt domain throws at CONSTRUCTION — the
74
+ * `config.*` prepare-refusal family (one exported spelling per member, thrown so the task never
75
+ * starts), so a deployment can compare against this constant instead of matching message text. */
76
+ export declare const WRITE_EXEMPT_DIR_COVERS_ROOT_CODE = "config.write_exempt_dir_covers_root";
69
77
  /**
70
78
  * Build the file-write approval gate policy. Mount via `combinePolicies` alongside the deployment's
71
79
  * other layers (sensitive-path deny, session rules, caller policy) — see the module header for the
@@ -1,12 +1,37 @@
1
- import { canonicalizeTarget, pathFamilyOf, writeTargetPath } from "../tools/fs/safety.js";
1
+ import { canonicalizeTarget, lexicalViewOf, pathFamilyOf, writeTargetPath } from "../tools/fs/safety.js";
2
2
  import { isWithin } from "./runner/session-rule-policy.js";
3
+ import { swallowedFenceRoot } from "./runner/advertised-writable-dirs.js";
3
4
  import { declaredPathTargetOf, isProtectedWrite } from "./tool-registry.js";
4
5
  import { pathTargetBaseOf } from "./effective-path-target.js";
5
6
  const ask = (message) => ({ action: "ask", message, decisionReason: "rule" });
7
+ function absoluteRootOf(rootPath, envCwd) {
8
+ return rootPath === undefined ? undefined : lexicalViewOf(rootPath, envCwd);
9
+ }
10
+ export const WRITE_EXEMPT_DIR_COVERS_ROOT_CODE = "config.write_exempt_dir_covers_root";
11
+ function assertExemptDirsDoNotCoverRoot(exemptDirs, rootLexical) {
12
+ if (exemptDirs === undefined || rootLexical === undefined)
13
+ return;
14
+ for (const raw of exemptDirs) {
15
+ if (!raw)
16
+ continue;
17
+ const dirLexical = lexicalViewOf(raw, rootLexical);
18
+ const swallowed = swallowedFenceRoot(dirLexical, [rootLexical]);
19
+ if (swallowed === undefined)
20
+ continue;
21
+ const e = new Error(`createFsWriteGatePolicy: exemptDirs entry "${raw}" resolves to ${dirLexical}, which covers this gate's root ${swallowed} — ` +
22
+ `an exemption that holds the root auto-approves every write under it, which is the whole workspace, so the gate refuses to be built rather than run with write governance silently off. ` +
23
+ `If that IS what this deployment wants, say it with the knob that means it: defaultWrite: "allow", an open write face, or a permission rule.`);
24
+ e.code = WRITE_EXEMPT_DIR_COVERS_ROOT_CODE;
25
+ e.detail = { exemptDir: raw, exemptDirResolved: dirLexical, root: swallowed };
26
+ throw e;
27
+ }
28
+ }
6
29
  export function createFsWriteGatePolicy(opts) {
7
30
  const { env, rootPath, defaultWrite } = opts;
8
31
  const acceptDirs = opts.acceptDirs && opts.acceptDirs.length > 0 ? opts.acceptDirs : undefined;
9
32
  const exemptDirs = opts.exemptDirs && opts.exemptDirs.length > 0 ? opts.exemptDirs : undefined;
33
+ const rootLexical = absoluteRootOf(rootPath, env.cwd);
34
+ assertExemptDirsDoNotCoverRoot(exemptDirs, rootLexical);
10
35
  const guardedCapable = env.writeFileGuarded !== undefined;
11
36
  const advisory = guardedCapable
12
37
  ? ""
@@ -35,12 +60,34 @@ export function createFsWriteGatePolicy(opts) {
35
60
  if (isWithin(root, canon.key))
36
61
  return { action: "allow" };
37
62
  }
38
- for (const dirs of [exemptDirs, acceptDirs]) {
63
+ let fenceRootsCanonical;
64
+ let refusedDomain;
65
+ for (const [dirs, structural] of [
66
+ [exemptDirs, true],
67
+ [acceptDirs, false],
68
+ ]) {
39
69
  if (!dirs)
40
70
  continue;
41
71
  for (const raw of dirs) {
42
72
  const dir = await canonicalizeTarget(env, raw, signal, pathFamilyOf({ root: rootPath ?? env.cwd }), rootPath);
43
- if (dir.ok && isWithin(dir.key, canon.key))
73
+ if (!dir.ok)
74
+ continue;
75
+ if (structural) {
76
+ if (fenceRootsCanonical === undefined) {
77
+ const r = rootPath === undefined ? undefined : await canonicalizeTarget(env, rootPath, signal, pathFamilyOf({ root: rootPath }), undefined);
78
+ fenceRootsCanonical = r === undefined ? [] : r.ok ? [r.key] : "unresolved";
79
+ }
80
+ if (fenceRootsCanonical === "unresolved") {
81
+ refusedDomain ??= `"${raw}" (this task's root ${rootPath} could not be resolved, so a structural domain that may cover it grants nothing)`;
82
+ continue;
83
+ }
84
+ const swallowed = swallowedFenceRoot(dir.key, fenceRootsCanonical);
85
+ if (swallowed !== undefined) {
86
+ refusedDomain ??= `"${raw}" (resolves to ${dir.key}, which contains this task's root ${swallowed})`;
87
+ continue;
88
+ }
89
+ }
90
+ if (isWithin(dir.key, canon.key))
44
91
  return { action: "allow" };
45
92
  }
46
93
  }
@@ -62,7 +109,7 @@ export function createFsWriteGatePolicy(opts) {
62
109
  }
63
110
  if (defaultWrite === "allow")
64
111
  return { action: "allow" };
65
- return ask(`approve write to "${path}"?${advisory}`);
112
+ return ask(`approve write to "${path}"?${advisory}${refusedDomain === undefined ? "" : ` — note: the configured write domain ${refusedDomain} was not honored; a domain that holds the root would exempt every write under it`}`);
66
113
  },
67
114
  };
68
115
  }
@@ -0,0 +1,38 @@
1
+ import { traceHookCrash } from "./runner/gate-exit.js";
2
+ import type { WriteProtectionVerdict } from "./write-protect.js";
3
+ /**
4
+ * design/77 §4: resolve `p`, but REJECT (so the caller catches and fails closed) if it hasn't settled within
5
+ * `ms` OR the `signal` aborts first (the task timed out / was cancelled — its real deadline). A rejection of
6
+ * `p` propagates with its true cause. Both `ms` and `signal` undefined ⇒ no deadline (await `p`) — the
7
+ * helper stays generic; every gate call site supplies a finite `ms` ({@link createGateDeadline}), so the
8
+ * no-deadline arm is unreachable from the gate. Mirrors `tool-policy.ts withTimeout`, but REJECTS on
9
+ * timeout (no bounded await here has a safe fallback VALUE — the gate's only safe action on an unsettled
10
+ * one is its own fail-closed arm). `label` names the wait in the rejection, so the arm that catches it can
11
+ * say which one elapsed.
12
+ */
13
+ export declare function withGateTimeout<T>(p: Promise<T>, ms: number | undefined, signal?: AbortSignal, label?: string): Promise<T>;
14
+ /**
15
+ * The deadline used when the caller supplies no `approvalTimeoutMs`. The wait must be bounded EVERYWHERE:
16
+ * the engine's own assembly never passes `approvalTimeoutMs`, and its only other bound is the task abort
17
+ * signal, which a task with no walltime limit and no external cancel never fires — so a host driving the
18
+ * public `runToolGate` with neither knob had NO bound at all and one hung probe froze the call forever. A
19
+ * probe is an args inspection and a judgment is a path resolution, not an approval wait: 30s is generous
20
+ * for any legitimate one, and an elapse simply takes the caller's own fail-closed arm.
21
+ */
22
+ export declare const DEFAULT_GATE_AWAIT_TIMEOUT_MS = 30000;
23
+ /**
24
+ * The gate's ONE deadline seat, resolved LAZILY and at most once per gate pass: a pass that makes no
25
+ * bounded await must not sound about a seat it never read. A garbage value (NaN/Infinity/negative, or
26
+ * above setTimeout's 2^31-1 ceiling, which it would silently clamp to ~1ms — an instant fail-closed the
27
+ * deployment never asked for) is refused LOUDLY to the default rather than silently reinterpreted
28
+ * (bad-value loudness discipline; the same ceiling the approve-wait seat refuses).
29
+ */
30
+ export declare function createGateDeadline(input: {
31
+ approvalTimeoutMs?: number;
32
+ }, notifier: Parameters<typeof traceHookCrash>[2]): () => number;
33
+ /**
34
+ * The write-protection judge's wait, at both of its call sites. An elapsed or aborted judgment is the
35
+ * judge's own `unresolvable` verdict — which the fold denies and the approved-edit arm denies — because a
36
+ * wait that did not finish IS a target nobody could resolve, and an unjudged target is never vouched.
37
+ */
38
+ export declare function awaitWriteProtectionJudgment(judged: Promise<WriteProtectionVerdict>, ms: number, signal?: AbortSignal): Promise<WriteProtectionVerdict>;
@@ -0,0 +1,53 @@
1
+ import { traceHookCrash } from "./runner/gate-exit.js";
2
+ export function withGateTimeout(p, ms, signal, label = "reversibilityProbe") {
3
+ if (ms === undefined && signal === undefined)
4
+ return p;
5
+ return new Promise((resolve, reject) => {
6
+ let settled = false;
7
+ const done = (fn, v) => {
8
+ if (settled)
9
+ return;
10
+ settled = true;
11
+ if (timer !== undefined)
12
+ clearTimeout(timer);
13
+ signal?.removeEventListener("abort", onAbort);
14
+ fn(v);
15
+ };
16
+ const onAbort = () => done(reject, new Error(`${label} aborted (task ended)`));
17
+ const timer = ms !== undefined
18
+ ? setTimeout(() => done(reject, new Error(`${label} timed out after ${ms}ms`)), ms)
19
+ : undefined;
20
+ p.then((v) => done(resolve, v), (err) => done(reject, err));
21
+ if (signal?.aborted) {
22
+ onAbort();
23
+ return;
24
+ }
25
+ signal?.addEventListener("abort", onAbort);
26
+ });
27
+ }
28
+ export const DEFAULT_GATE_AWAIT_TIMEOUT_MS = 30_000;
29
+ export function createGateDeadline(input, notifier) {
30
+ let resolved;
31
+ return () => {
32
+ if (resolved !== undefined)
33
+ return resolved;
34
+ const supplied = input.approvalTimeoutMs;
35
+ if (supplied === undefined)
36
+ resolved = DEFAULT_GATE_AWAIT_TIMEOUT_MS;
37
+ else if (Number.isFinite(supplied) && supplied >= 0 && supplied <= 2_147_483_647)
38
+ resolved = supplied;
39
+ else {
40
+ resolved = DEFAULT_GATE_AWAIT_TIMEOUT_MS;
41
+ traceHookCrash(input, new Error(`approvalTimeoutMs must be a non-negative finite number no greater than 2147483647 (got ${String(supplied)}) — the gate's bounded awaits fall back to the ${DEFAULT_GATE_AWAIT_TIMEOUT_MS}ms default`), notifier);
42
+ }
43
+ return resolved;
44
+ };
45
+ }
46
+ export async function awaitWriteProtectionJudgment(judged, ms, signal) {
47
+ try {
48
+ return await withGateTimeout(judged, ms, signal, "writeProtectionJudge");
49
+ }
50
+ catch (err) {
51
+ return { kind: "unresolvable", cause: err instanceof Error ? err.message : String(err), unresolvedSymlink: false };
52
+ }
53
+ }
@@ -2,35 +2,11 @@ import { inlineUntrusted } from "./untrusted-text.js";
2
2
  import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
3
3
  import { normalizeReadRootCandidate } from "./read-root-candidate.js";
4
4
  import { traceHookCrash } from "./runner/gate-exit.js";
5
- function withProbeTimeout(p, ms, signal) {
6
- if (ms === undefined && signal === undefined)
7
- return p;
8
- return new Promise((resolve, reject) => {
9
- let settled = false;
10
- const done = (fn, v) => {
11
- if (settled)
12
- return;
13
- settled = true;
14
- if (timer !== undefined)
15
- clearTimeout(timer);
16
- signal?.removeEventListener("abort", onAbort);
17
- fn(v);
18
- };
19
- const onAbort = () => done(reject, new Error("reversibilityProbe aborted (task ended)"));
20
- const timer = ms !== undefined
21
- ? setTimeout(() => done(reject, new Error(`reversibilityProbe timed out after ${ms}ms`)), ms)
22
- : undefined;
23
- if (signal?.aborted) {
24
- onAbort();
25
- return;
26
- }
27
- signal?.addEventListener("abort", onAbort);
28
- p.then((v) => done(resolve, v), (err) => done(reject, err));
29
- });
30
- }
31
- const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
5
+ import { writeProtectionTightenDecision } from "./write-protect.js";
6
+ import { createGateDeadline, awaitWriteProtectionJudgment, withGateTimeout } from "./gate-bounded-await.js";
32
7
  export async function runGateFold(pass) {
33
8
  const { input, toolName, toolCallId, callSignal, ledger, notifier, adjudicate } = pass;
9
+ const boundedAwaitDeadlineMs = createGateDeadline(input, notifier);
34
10
  pass.req = { toolName, args: pass.currentInput, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
35
11
  pass.decision = adjudicate ? await (callSignal !== undefined ? adjudicate(pass.req, callSignal) : adjudicate(pass.req)) : { action: "allow" };
36
12
  {
@@ -39,6 +15,9 @@ export async function runGateFold(pass) {
39
15
  pass.deniedBy = refusedBy;
40
16
  if (refusedBy !== undefined)
41
17
  pass.denyCause = ledger.causeOf(pass.decision);
18
+ const attributed = ledger.classifierRoundOf(pass.decision);
19
+ if (attributed !== undefined)
20
+ pass.classifierRound = attributed;
42
21
  }
43
22
  if ((pass.decision.action === "allow" || pass.decision.action === "ask") && pass.decision.updatedInput !== undefined) {
44
23
  pass.policyRewrite = pass.decision.updatedInput;
@@ -85,20 +64,9 @@ export async function runGateFold(pass) {
85
64
  if (input.irreversibility === "maybe" && input.reversibilityProbe) {
86
65
  let reversible = false;
87
66
  const probeArgs = pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput;
88
- const suppliedProbeMs = input.approvalTimeoutMs;
89
- let probeTimeoutMs;
90
- if (suppliedProbeMs === undefined) {
91
- probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS;
92
- }
93
- else if (Number.isFinite(suppliedProbeMs) && suppliedProbeMs >= 0 && suppliedProbeMs <= 2_147_483_647) {
94
- probeTimeoutMs = suppliedProbeMs;
95
- }
96
- else {
97
- probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS;
98
- traceHookCrash(input, new Error(`approvalTimeoutMs must be a non-negative finite number no greater than 2147483647 (got ${String(suppliedProbeMs)}) — the reversibilityProbe deadline falls back to the ${DEFAULT_PROBE_TIMEOUT_MS}ms default`), notifier);
99
- }
67
+ const probeTimeoutMs = boundedAwaitDeadlineMs();
100
68
  try {
101
- const verdict = await withProbeTimeout(Promise.resolve(input.reversibilityProbe(probeArgs)), probeTimeoutMs, input.abortSignal);
69
+ const verdict = await withGateTimeout(Promise.resolve(input.reversibilityProbe(probeArgs)), probeTimeoutMs, input.abortSignal);
102
70
  reversible = verdict?.reversible === true;
103
71
  if (!reversible) {
104
72
  const raw = verdict?.reason;
@@ -143,14 +111,14 @@ export async function runGateFold(pass) {
143
111
  pass.tightenedBy = "safety";
144
112
  }
145
113
  if (pass.decision.action === "allow" && input.writeProtectionCheck !== undefined) {
146
- const writeProtectedHit = input.writeProtectionCheck(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput, pass.req.face, input.trackedCwd?.());
147
- if (writeProtectedHit !== null) {
148
- pass.decision = {
149
- action: "ask",
150
- message: `tool "${toolName}" writes to a write-protected path (table entry "${writeProtectedHit.name}") — explicit approval required`,
151
- decisionReason: "safety",
152
- };
153
- pass.tightenedBy = "safety";
114
+ const judged = input.writeProtectionCheck(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput, pass.req.face, input.trackedCwd?.(), pass.callSignal ?? input.abortSignal);
115
+ const decision = judged === null ? undefined : writeProtectionTightenDecision(toolName, await awaitWriteProtectionJudgment(judged, boundedAwaitDeadlineMs(), pass.callSignal ?? input.abortSignal));
116
+ if (decision !== undefined) {
117
+ pass.decision = decision;
118
+ if (decision.action === "ask")
119
+ pass.tightenedBy = "safety";
120
+ else
121
+ pass.deniedBy = "write_protection";
154
122
  }
155
123
  }
156
124
  return await pass.afterFold(pass);
@@ -10,6 +10,7 @@ import { COMMAND_RULE_TOOL } from "./permission-rule-model.js";
10
10
  import { catalogRuleFaceOf } from "./tool-registry.js";
11
11
  import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
12
12
  import { exitGate, traceHookCrash } from "./runner/gate-exit.js";
13
+ import { createGateDeadline, awaitWriteProtectionJudgment } from "./gate-bounded-await.js";
13
14
  function isPlainOwnRecord(x) {
14
15
  try {
15
16
  if (x === null || typeof x !== "object" || Array.isArray(x))
@@ -149,6 +150,7 @@ function parkWith(suspendAsk, parkArgs, carry) {
149
150
  }
150
151
  export async function runGateLanes(pass) {
151
152
  const { input, toolName, toolCallId, callSignal, ledger, notifier, screening, adjudicate, resolveAsk, suspendAsk } = pass;
153
+ const boundedAwaitDeadlineMs = createGateDeadline(input, notifier);
152
154
  let orgRealApprovalRequired = false;
153
155
  let markedThisRound;
154
156
  const markedUnresolvable = () => (markedThisRound ??= input.isMarkedUnresolvable?.(input.event.toolCallId) === true);
@@ -613,6 +615,9 @@ export async function runGateLanes(pass) {
613
615
  }
614
616
  }
615
617
  {
618
+ const attributed = ledger.classifierRoundOf(recheck);
619
+ if (attributed !== undefined)
620
+ pass.classifierRound = attributed;
616
621
  const refusedBy = ledger.fromDecision(recheck);
617
622
  if (recheck.action === "deny" && refusedBy !== undefined) {
618
623
  editDenied = recheck;
@@ -677,11 +682,14 @@ export async function runGateLanes(pass) {
677
682
  editRewrittenSinceHuman = false;
678
683
  }
679
684
  if (editDenied === undefined && editRewrittenSinceHuman && input.writeProtectionCheck !== undefined) {
680
- const editHit = input.writeProtectionCheck(toolName, editArgs, pass.req.face, input.trackedCwd?.());
681
- if (editHit !== null) {
685
+ const judged = input.writeProtectionCheck(toolName, editArgs, pass.req.face, input.trackedCwd?.(), callSignal ?? input.abortSignal);
686
+ const verdict = judged === null ? undefined : await awaitWriteProtectionJudgment(judged, boundedAwaitDeadlineMs(), callSignal ?? input.abortSignal);
687
+ if (verdict !== undefined && verdict.kind !== "clear") {
682
688
  editDenied = {
683
689
  action: "deny",
684
- message: `the approved edit for "${toolName}" was rewritten by the restriction chain onto a write-protected path (table entry "${editHit.name}") that no approval covers — denied fail-closed; re-submit the edited action directly`,
690
+ message: verdict.kind === "hit"
691
+ ? `the approved edit for "${toolName}" was rewritten by the restriction chain onto a write-protected path (table entry "${verdict.hit.name}") that no approval covers — denied fail-closed; re-submit the edited action directly`
692
+ : `the approved edit for "${toolName}" was rewritten by the restriction chain onto a target whose real location could not be resolved (${verdict.cause}) — denied fail-closed; re-submit the edited action directly`,
685
693
  };
686
694
  pass.deniedBy = "write_protection";
687
695
  }