@sema-agent/core 7.18.0 → 7.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +276 -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 +8 -3
  6. package/dist/brain/open-responses.js +12 -4
  7. package/dist/brain/openai.js +7 -2
  8. package/dist/brain/reasoning.d.ts +13 -13
  9. package/dist/brain/reasoning.js +2 -2
  10. package/dist/brain/stream-engine.d.ts +4 -0
  11. package/dist/brain/stream-engine.js +6 -1
  12. package/dist/brain/stream-shared.d.ts +3 -0
  13. package/dist/brain/stream-shared.js +1 -0
  14. package/dist/core/ask-origin.d.ts +16 -0
  15. package/dist/core/ask-origin.js +8 -0
  16. package/dist/core/auto-mode-arming.d.ts +6 -0
  17. package/dist/core/auto-mode-arming.js +5 -5
  18. package/dist/core/auto-mode-defaults.d.ts +43 -15
  19. package/dist/core/auto-mode-defaults.js +4 -1
  20. package/dist/core/auto-mode.d.ts +74 -26
  21. package/dist/core/auto-mode.js +80 -11
  22. package/dist/core/checkpoint-store.d.ts +7 -0
  23. package/dist/core/checkpoint-store.js +2 -2
  24. package/dist/core/child-clamp-seats.d.ts +79 -0
  25. package/dist/core/child-clamp-seats.js +29 -0
  26. package/dist/core/engine-notice.d.ts +89 -41
  27. package/dist/core/fs-write-gate-policy.d.ts +17 -9
  28. package/dist/core/fs-write-gate-policy.js +51 -4
  29. package/dist/core/gate-bounded-await.d.ts +38 -0
  30. package/dist/core/gate-bounded-await.js +53 -0
  31. package/dist/core/gate-fold.js +20 -48
  32. package/dist/core/gate-lanes.js +17 -9
  33. package/dist/core/gate-outcome.d.ts +34 -6
  34. package/dist/core/gate-outcome.js +7 -0
  35. package/dist/core/governance-codes.d.ts +16 -1
  36. package/dist/core/governance-codes.js +18 -0
  37. package/dist/core/hooks.d.ts +8 -8
  38. package/dist/core/memory-engine/engine.d.ts +14 -9
  39. package/dist/core/memory-engine/engine.js +21 -8
  40. package/dist/core/memory-engine/layout.d.ts +31 -0
  41. package/dist/core/memory-engine/layout.js +68 -3
  42. package/dist/core/memory-engine/types.d.ts +24 -0
  43. package/dist/core/memory.d.ts +19 -0
  44. package/dist/core/memory.js +12 -0
  45. package/dist/core/read-root-candidate.d.ts +12 -0
  46. package/dist/core/read-root-candidate.js +24 -0
  47. package/dist/core/remote-env.d.ts +32 -0
  48. package/dist/core/remote-env.js +4 -1
  49. package/dist/core/roles.d.ts +9 -1
  50. package/dist/core/roles.js +4 -0
  51. package/dist/core/runner/advertised-writable-dirs.d.ts +59 -1
  52. package/dist/core/runner/advertised-writable-dirs.js +51 -3
  53. package/dist/core/runner/announce-once-ledger.d.ts +15 -0
  54. package/dist/core/runner/announce-once-ledger.js +8 -0
  55. package/dist/core/runner/classifier-ladder.d.ts +154 -11
  56. package/dist/core/runner/classifier-ladder.js +170 -10
  57. package/dist/core/runner/contracts.d.ts +46 -1
  58. package/dist/core/runner/denial-limit-arms.d.ts +14 -1
  59. package/dist/core/runner/denial-limit-arms.js +7 -6
  60. package/dist/core/runner/env-capability-census.d.ts +30 -0
  61. package/dist/core/runner/env-capability-census.js +38 -0
  62. package/dist/core/runner/forward-sink.d.ts +32 -0
  63. package/dist/core/runner/forward-sink.js +41 -0
  64. package/dist/core/runner/gate-exit.d.ts +4 -0
  65. package/dist/core/runner/gate-exit.js +4 -1
  66. package/dist/core/runner/git-status-frame.d.ts +0 -9
  67. package/dist/core/runner/listing-fold.d.ts +57 -0
  68. package/dist/core/runner/listing-fold.js +75 -0
  69. package/dist/core/runner/prepare-ask-lane.js +1 -0
  70. package/dist/core/runner/prepare-caps-and-workflow.js +20 -31
  71. package/dist/core/runner/prepare-config-doors.d.ts +6 -0
  72. package/dist/core/runner/prepare-config-doors.js +8 -10
  73. package/dist/core/runner/prepare-execution-env.d.ts +1 -1
  74. package/dist/core/runner/prepare-execution-env.js +2 -0
  75. package/dist/core/runner/prepare-gate-stations.d.ts +8 -0
  76. package/dist/core/runner/prepare-gate-stations.js +6 -2
  77. package/dist/core/runner/prepare-hands-readface.d.ts +4 -0
  78. package/dist/core/runner/prepare-hands-readface.js +3 -5
  79. package/dist/core/runner/prepare-listings.d.ts +3 -0
  80. package/dist/core/runner/prepare-listings.js +4 -2
  81. package/dist/core/runner/prepare-memory.js +7 -1
  82. package/dist/core/runner/prepare-policy-chain.js +5 -5
  83. package/dist/core/runner/prepare-project-context.d.ts +5 -1
  84. package/dist/core/runner/prepare-project-context.js +3 -2
  85. package/dist/core/runner/prepare-prompt-inputs.d.ts +2 -2
  86. package/dist/core/runner/prepare-run-refs.d.ts +4 -2
  87. package/dist/core/runner/prepare-run-refs.js +5 -4
  88. package/dist/core/runner/prepare-task.js +8 -6
  89. package/dist/core/runner/resume-apply.js +3 -3
  90. package/dist/core/runner/run-clock-and-content.js +3 -7
  91. package/dist/core/runner/run-harness-handlers.js +10 -10
  92. package/dist/core/runner/run-leg.js +31 -16
  93. package/dist/core/runner/run-settle-and-teardown.js +2 -5
  94. package/dist/core/runner/run-turn-boundary.js +44 -25
  95. package/dist/core/runner/runtask.js +2 -1
  96. package/dist/core/runner/synthetic-tools.d.ts +41 -29
  97. package/dist/core/runner/synthetic-tools.js +25 -40
  98. package/dist/core/runner/task-root-canonical.d.ts +45 -0
  99. package/dist/core/runner/task-root-canonical.js +18 -0
  100. package/dist/core/runner/turn-attachments.d.ts +160 -53
  101. package/dist/core/runner/turn-attachments.js +153 -51
  102. package/dist/core/safe-notify.d.ts +5 -0
  103. package/dist/core/safe-notify.js +24 -2
  104. package/dist/core/sensitive-path-policy.d.ts +84 -17
  105. package/dist/core/sensitive-path-policy.js +37 -19
  106. package/dist/core/skill-listing-budget.d.ts +106 -0
  107. package/dist/core/skill-listing-budget.js +57 -0
  108. package/dist/core/skills-directory.js +17 -10
  109. package/dist/core/task-registry.js +2 -1
  110. package/dist/core/task-spec.d.ts +29 -2
  111. package/dist/core/tool-policy.d.ts +31 -5
  112. package/dist/core/tool-policy.js +45 -32
  113. package/dist/core/tool-spec.d.ts +37 -4
  114. package/dist/core/types.d.ts +1 -1
  115. package/dist/core/write-protect.d.ts +105 -20
  116. package/dist/core/write-protect.js +89 -32
  117. package/dist/index.d.ts +8 -7
  118. package/dist/index.js +6 -5
  119. package/dist/orchestration/workflow-primitives.js +10 -20
  120. package/dist/stores/file/memory-store.d.ts +5 -2
  121. package/dist/stores/file/memory-store.js +8 -4
  122. package/dist/tools/fs/bash-readonly-classifier.d.ts +40 -2
  123. package/dist/tools/fs/bash-readonly-classifier.js +376 -41
  124. package/dist/tools/fs/fs-bash.d.ts +1 -1
  125. package/dist/tools/fs/fs-bash.js +19 -28
  126. package/dist/tools/fs/fs-shared.d.ts +22 -0
  127. package/dist/tools/fs/fs-shared.js +30 -0
  128. package/dist/tools/fs/index.d.ts +1 -1
  129. package/dist/tools/fs/index.js +1 -1
  130. package/dist/tools/fs/read-deny.d.ts +4 -3
  131. package/dist/tools/fs/safety.d.ts +15 -0
  132. package/dist/tools/fs/safety.js +7 -0
  133. package/package.json +2 -1
  134. package/test/export-surface.snapshot.json +31 -5
  135. package/wire-changes/core@7.18.0.json +1273 -0
  136. package/wire-changes/core@7.19.0.json +457 -0
  137. package/wire-changes/core@7.20.0.json +568 -0
@@ -10,38 +10,62 @@ 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).
20
28
  * - `"config.tool_card_undeclared"` (design/388 L4) — a tool minted a `details.type` card its definition
21
29
  * (or its caller declaration's `renderHints.resultCards`) never declared; the card was dropped from
22
30
  * `tool_end.structured`; once per leg per type; `detail: { type, sessionId, runId }`.
23
31
  * - `"config.tool_face_undeclared"` / `"config.tool_face_invalid"` (design/388 §2.7) — an MCP server's
24
32
  * `toolFaces` overlay names a tool the listing does not carry / does not fit the tool's materialized
25
33
  * schema (the tool mounts bare); once per leg per tool; `detail: { server, tool, problem?, sessionId, runId }`.
34
+ * - `"agents.listing_truncated"` — the agent-type roster frame did not fit the same listing budget:
35
+ * at least one type is listed name-only (`nameOnly`) or not at all (`dropped`). Audience `operator`.
36
+ * `detail: { budgetBytes, total, kept, nameOnly: [name], dropped: [name], sessionId }`; the same
37
+ * firing rule as its skills sibling below (once per delivered frame, on the carrying message's receipt).
38
+ * - `"skills.listing_truncated"` — the `<skills>` disclosure block did not fit its byte budget, so
39
+ * at least one skill is listed without its description (`nameOnly`) or, in the pathological case,
40
+ * not listed at all (`dropped`); the `Skill` tool still serves every one of them. Audience
41
+ * `operator` — raising `TaskSpec.skillListingBudgetFraction`, lowering `skillListingMaxDescChars`
42
+ * or declaring fewer skills are all deployment acts. `detail: { budgetBytes, descMaxChars, total,
43
+ * kept, nameOnly: [name], dropped: [name], sessionId }` — `budgetBytes` IS the budget in force;
44
+ * no ceiling stands above the declared fraction, so no second number rides beside it.
45
+ * Announced on the RECEIPT of the message that carried the frame — the leg's first user message, or
46
+ * the boundary steer that a drift (or a resume leg's initial listing) rides — once per delivered
47
+ * frame the budget actually degraded, and never for one that fit. Every count is read off that frame, so
48
+ * `total` is the whole roster on an initial listing and the newly announced set on a drift frame.
49
+ * A run whose deployment turned the listing off (`attachments.skillsListing: false`) emits nothing,
50
+ * since nothing is rendered. There is NO dedup key: each notice is one delivered frame`s cost, and
51
+ * a frame is delivered (and committed) once.
26
52
  * - `"tool_result.offload_put_failed"` (#167) — a clear-with-offload persist's fire-and-forget put
27
53
  * failed; THIS attempt stored nothing (the failure arm reports, it never re-inserts under the
28
54
  * ref) — an earlier attempt of the same idempotent re-put may already have stored the row, so
29
55
  * the notice claims a failed write, not an empty ref;
30
- * `detail: { ref, sessionId, cause, occurrenceId }`.
31
- * Per-occurrence, not per-process-deduplicated: each failed write is a distinct fact — and
32
- * `occurrenceId` (#495, uuidv7 minted at the failure) is what lets a consumer act on that:
33
- * `ref` names the ROW, and the same ref is attempted again on every later re-clear, so two
34
- * genuine losses of one ref were byte-identical notices. Dedup key: (code, occurrenceId).
56
+ * `detail: { ref, sessionId, cause, occurrenceId }`. Per-occurrence, not
57
+ * per-process-deduplicated: each failed write is a distinct fact, and `ref` names the ROW rather
58
+ * than the attempt, so the uuidv7 `occurrenceId` minted at the failure is what tells two genuine
59
+ * losses of one ref apart. Dedup key: (code, occurrenceId).
35
60
  * - `"workflow.governance_key_stripped"` (#235) — the fields of an LLM-authored workflow `agent(spec)`
36
61
  * that did NOT cross the governed default-deny whitelist: an unrecognized/control-plane field, or a
37
62
  * `readFace` value that is not the containment-tightening one. The spawn PROCEEDS on the deployment
38
63
  * baseline (the strip is not a refusal in this window), which is why the drop is announced;
39
64
  * `detail: { total, stripped: [{ key, reason }], omitted?, occurrenceId }`, the rendered key list
40
65
  * bounded in count and length because the names come from the untrusted script. One aggregated
41
- * notice per governed child build, not de-duplicated across builds: each spec is a distinct fact —
42
- * `occurrenceId` (#495, uuidv7 minted at the announcement) carries that claim to the consumer,
43
- * which otherwise saw two builds of the same spec as one repeated notice. Dedup key:
44
- * (code, occurrenceId).
66
+ * notice per governed child build, not de-duplicated across builds: each spec is a distinct fact,
67
+ * and the uuidv7 `occurrenceId` minted at the announcement is what carries that claim to a consumer
68
+ * that would otherwise read two builds of one spec as one repeated notice. Dedup key: (code, occurrenceId).
45
69
  * - `"workflow.agent_option_ignored"` (#505 ①) — the OPTIONS-object twin of the row above: keys of
46
70
  * `agent(spec, opts)`'s SECOND argument that were not applied (unrecognized, or a known option
47
71
  * whose value its type check rejected — `label: 42`). The spawn PROCEEDS without them, which is
@@ -51,6 +75,21 @@ export interface EngineNotice {
51
75
  * key: (code, occurrenceId). NOT the whole family: a SPEC field written on the options object
52
76
  * (`model`, `modelName`, `thinking`, …) is REFUSED at the membrane instead — a silently ignored
53
77
  * `model` runs a different agent than the author asked for.
78
+ * - `"config.forward_sink_failed"` — the deployment's opt-in subagent display sink
79
+ * (`RunInternals.onForwardEvent`) failed and the frame it was handed was dropped: it threw, or —
80
+ * since an `async` function satisfies that `=> void` seat — it returned a promise that rejected.
81
+ * The run is untouched (forwarding is a render channel, and delivery is never awaited, so a sink
82
+ * that never settles is not a failure and announces nothing). `detail: { site, cause }` — `site` is
83
+ * the engine-owned site name the failures are counted under, `cause` the thrown value's message.
84
+ * Audience `operator`: the sink is deployment wiring and nobody else can repair it. Dedup unit:
85
+ * once per RUN (the counters ride `RunInternals.forwardScope`, which every copy of a run's
86
+ * internals carries), later failures of the same run counted only — a sink that fails on every
87
+ * frame therefore costs ONE line, not one per frame.
88
+ * - `"memory.restore_withheld"` — a memory containment DID NOT write a committed baseline back: another
89
+ * writer held the path when the restore reached it, and those newer bytes were left standing.
90
+ * Containment is unaffected and the committed copy is still in the store, so reconciling the two is
91
+ * the host's act (audience `operator`). `detail: { paths: [{ path, entryId?, rev? }], sessionId? }`,
92
+ * `rev` = the declined generation. Dedup unit: once per containment PASS, never per path.
54
93
  * - `"mcp.revocation_probe_failed"` (design/338) — the deployment's `mcpRevocations.isRevoked`
55
94
  * probe threw; MCP dispatch FAILS OPEN (revocation is a tightening face) and this announces
56
95
  * once per materialization (a resume re-materializes and may announce again).
@@ -74,10 +113,9 @@ export interface EngineNotice {
74
113
  * { models, tiers, occurrenceId }` — key COUNTS only, never the catalog itself. In-flight tasks
75
114
  * finish on the models they resolved at prepare (natural snapshot); every later prepare resolves
76
115
  * against the new generation. A failed swap (illegal tier binding, a refused candidate) throws
77
- * atomically and mints nothing.
78
- * `occurrenceId` (#495, uuidv7 minted at the announcement): the verb carries no run and no
79
- * session, and the payload is a pair of counts, so two swaps of the same shape were byte-
80
- * identical lines. Dedup key: (code, occurrenceId).
116
+ * atomically and mints nothing. The verb carries no run and no session and the payload is a pair
117
+ * of counts, so the uuidv7 `occurrenceId` minted at the announcement is what tells two swaps of the
118
+ * same shape apart. Dedup key: (code, occurrenceId).
81
119
  * - `"config.read_face_swapped"` — `Runner.swapDeps` replaced the deployment's read-face
82
120
  * declaration (`RunnerDeps.readFace`, the `readFace` seat of the swappable set) without a
83
121
  * restart. `detail: { from, to, occurrenceId }` — the seat's previous and new value, each
@@ -86,8 +124,7 @@ export interface EngineNotice {
86
124
  * read-only mount) still runs per prepare. In-flight legs keep the face they prepared under;
87
125
  * every later prepare resolves against the new declaration. A candidate outside the value gate
88
126
  * (`assertContainmentFaceValue`) throws atomically and mints nothing. Own `occurrenceId` for the same
89
- * reason as the catalog line (no run, no session, a two-word payload). Dedup key:
90
- * (code, occurrenceId).
127
+ * reason as the catalog line (no run, no session, a two-word payload). Dedup key: (code, occurrenceId).
91
128
  * - `"config.write_face_swapped"` — the same line for the WRITE seat (`RunnerDeps.writeFace`),
92
129
  * same payload, same laws. A SEPARATE code rather than a face field on the line above: the audience
93
130
  * table and every consumer table are keyed by code.
@@ -98,10 +135,9 @@ export interface EngineNotice {
98
135
  * `detail: { seat, from, to, cause, fixHint, sessionId?, runId? }` — `cause` is the refusal code
99
136
  * (`route.credential_mismatch` / `route.credential_missing`); `sessionId` (#433, additive)
100
137
  * rides when the adjudicating seat knows its session, and `runId` (#499, additive) names the
101
- * INVOCATION whose seats were being resolved — both correlation keys only, the audience
102
- * stays `"operator"` (see the registry's entitlement-not-routability note). Two runs of one
103
- * session falling back on the same seat used to mint byte-identical lines; `runId` is what
104
- * tells one task falling back twice apart from two tasks falling back once each.
138
+ * INVOCATION whose seats were being resolved — both correlation keys only, the audience stays
139
+ * `"operator"` (see the registry's entitlement-not-routability note), and `runId` is what tells one
140
+ * task falling back twice apart from two tasks falling back once each.
105
141
  * Explicitly-named models never mint this: they refuse at the brain's request gate instead.
106
142
  * - `"route.classifier_unroutable"` (same pre-flight, the other POSTURE) — the derived classifier seat
107
143
  * failed the same pairing pre-flight while the deployment had declared NO fallback
@@ -118,6 +154,20 @@ export interface EngineNotice {
118
154
  * `detail: { from, to, cause, sessionId?, runId? }` — `cause` is the `AutoModeFallbackCause` word, the
119
155
  * two ids are the rungs. Dedup key: (code, from, cause) PER RUN; a second CAUSE on the same rung is
120
156
  * its own line.
157
+ * - `"classifier.role_defaulted"` (`src/core/runner/classifier-ladder.ts`, the seat resolver) — no
158
+ * `classifier` role names a model, so another model STANDS IN for the seat. `detail: { model, reason,
159
+ * thinkingOffSpelled, sessionId?, runId? }` — `reason` ∈ `tier_below_main` (the nearest directly-bound
160
+ * tier below the run's main model) / `tier_table` (the static `flash` row, a main bound to no tier) /
161
+ * `role_fallback` (the `summarize → default` chain, no tiers) / `main_only` (the main model is the only
162
+ * model there is) / `role_unresolvable` (the role named a model the catalog cannot resolve; the main
163
+ * stands in); `thinkingOffSpelled` = whether that model's wire can carry a disable key. Minted
164
+ * AFTER the route pre-flight, so it names the seat actually used. Once per run.
165
+ * - `"classifier.contract_broken"` (same file, the round observer) — ONE model answered
166
+ * `AUTO_MODE_CLASSIFIER_CONTRACT_BROKEN_AFTER` CONSECUTIVE classifications without a verdict (each already
167
+ * re-asked once by the leg). `detail: { model, rung, consecutive, sessionId?, runId? }` — `rung` is the
168
+ * ladder position (0 the seat, 1 the model behind it), since two rungs may share an id. A ruling from
169
+ * that rung resets its count; an `unavailable` round neither counts nor resets. Once per run per rung,
170
+ * and by construction before the denial limit's third block hands the user its card.
121
171
  * - `"route.base_url_changed_key_unchanged"` (key↔URL pairing) — `Runner.swapDeps` moved a
122
172
  * same-name entry's `baseUrl` while its Model-visible credential half (auth-bearing headers)
123
173
  * did not change: legal (a provider changing domains), but worth one loud line — if the
@@ -125,10 +175,9 @@ export interface EngineNotice {
125
175
  * refusal; per-model-hook credentials are invisible to the catalog swap, so the notice is
126
176
  * deliberately conservative (it may fire when a hook-side credential DID change in lockstep).
127
177
  * One aggregated notice per swap; `detail: { entries: [{ modelId, from, to }], total,
128
- * occurrenceId }` (rendered list bounded, total always exact). `occurrenceId` (#495, uuidv7
129
- * minted at the announcement, independent of the `config.models_swapped` line's own): the
130
- * bounded render makes two swaps moving the same first entries byte-identical.
131
- * Dedup key: (code, occurrenceId).
178
+ * occurrenceId }` (rendered list bounded, total always exact); the `occurrenceId` is minted at this
179
+ * announcement, independently of the `config.models_swapped` line's own, because the bounded render
180
+ * makes two swaps moving the same first entries identical. Dedup key: (code, occurrenceId).
132
181
  * - `"config.read_face_deployment_clamped"` (#237) — a deployment-wide `readFace: "open"` is not
133
182
  * in force beside a read-only (verifier) mount: it clamps to "roots" without throwing
134
183
  * (stricter-wins; the clamp verdict stands, only its occurrence was undisclosed). Announced
@@ -164,14 +213,17 @@ export interface EngineNotice {
164
213
  * threw: the `Artifact` tool is not mounted for the task and this is the loud half (an absent seat
165
214
  * is silent; a broken one must not look the same). Once per prepared leg;
166
215
  * `detail: { problem, sessionId, runId }`.
216
+ * - `"config.execution_env_capability_invalid"` — a remote env DECLARES a boolean capability key
217
+ * (`isolation`, `suspendable`, `canonicalPathAuthoritative`) with a non-boolean value; ABSENCE is a
218
+ * supported reading of each and stays silent. Once per key per run; `detail: { key, valueType }`.
167
219
  * - `"config.durable_gate_unavailable"` — a durable-approval MANDATE is in force on a leg with NO
168
220
  * checkpoint store: the per-principal `forceDurableGate` entitlement, or the task's own
169
221
  * `durableApproval` opt-in with no live approver seat (`durableMandateSourceOf`, the predicate the
170
222
  * 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
171
223
  * chain — a live approver / question face answers in-stream, and with none the ask is denied
172
224
  * fail-closed — and NO durable approval record is written. The ask routing itself is not
173
- * changed by the notice (a store-less leg never could park); the notice is the loud half of a
174
- * posture that used to degrade silently, disclosed before the first ask of the leg. Audience
225
+ * changed by the notice (a store-less leg never could park); the notice is its loud half,
226
+ * disclosed before the first ask of the leg. Audience
175
227
  * `"user"` (the person whose asks will not be recorded is entitled to that; the operator hears
176
228
  * it through the sink as always). Once per prepared task leg — a resume or a delegated child
177
229
  * is its own leg and says so again; `detail: { sessionId, runId, principal?, cause, source,
@@ -194,12 +246,10 @@ export interface EngineNotice {
194
246
  * (`sessionId` = #433's routing half for these two `"user"`-audience rows; `runId` = #499's
195
247
  * per-run identity, the key this account is actually about — see
196
248
  * {@link undrainedUserInputNotices}; all omitted when the caller has none, never fabricated).
197
- * Per-run, at most once per family (the terminal sweep is a single site) — which is exactly why
198
- * `taskId`/`sessionId` cannot key it: both answer the same value for two runs of one session,
199
- * so two runs each stranding one steer folded into one indistinguishable row.
200
- * **#389 (two corrections).** ① The family now fires on the INTERRUPT path too: `abort()` used
201
- * to empty both queues before agent_end could count them, so the one loss path an operator most
202
- * needs to hear about was the one path that stayed silent. ② On a DURABLE PARK the verdict is
249
+ * Per-run, at most once per family (the terminal sweep is a single site) — which is why
250
+ * `taskId`/`sessionId` cannot key it: both answer the same value for two runs of one session.
251
+ * ① The family fires on the INTERRUPT path too, `abort()` being barred from emptying either queue
252
+ * before agent_end can count it. ② On a DURABLE PARK the verdict is
203
253
  * settled at the run tail instead of at agent_end, because the tail first carries the still-
204
254
  * undelivered input onto the committed checkpoint's parked-steer queue (the resume delivers it)
205
255
  * — what is announced is the remainder that could NOT be carried, so a fully-migrated park says
@@ -230,10 +280,9 @@ export interface EngineNotice {
230
280
  * the other question (was the halt CONSUMED), see that seat.
231
281
  * In both lanes: one notice per REAL cut (a `now` that found nothing in flight or whose frame
232
282
  * already rode the imminent boundary, and a halt with nothing in flight, announce nothing — no
233
- * false interrupt claims); `runId` (#499) is the INVOCATION that was cut (the other two ids are
234
- * shared by every run of the session); every cut turn counts toward `maxTurns`, so an interrupt
235
- * storm's cost is bounded and each of its cuts is on the record. Audience `"user"`: the person
236
- * who forced the boundary is the one entitled to see that it landed.
283
+ * false interrupt claims); `runId` is the INVOCATION that was cut; every cut turn counts toward
284
+ * `maxTurns`, so an interrupt storm's cost is bounded and each of its cuts is on the record.
285
+ * Audience `"user"`: the person who forced the boundary is the one entitled to see that it landed.
237
286
  * RETIRED here (BREAKING, named): `"task.injection_priority_unimplemented"` — the ladder is
238
287
  * implemented, so the unhonored-knob disclosure it carried has no referent; consumers must
239
288
  * judge ladder support by VERSION, never by that code's absence.
@@ -320,9 +369,8 @@ export interface EngineNotice {
320
369
  * the inputId, so "accepted at park, screened at redelivery" is auditable end to end. Audience
321
370
  * `"user"` (the person whose instruction was withheld is the one entitled to re-issue it);
322
371
  * `detail: { inputId?, sessionId, runId, taskId? }` — `inputId` is the parked entry's stored
323
- * correlation key (OPTIONAL: a row parked without one exists), and `runId` (#499) names the
324
- * resume leg that withheld it, so a session resumed twice does not report two withholdings as
325
- * one.
372
+ * correlation key (OPTIONAL: a row parked without one exists), and `runId` names the resume leg
373
+ * that withheld it, so a session resumed twice does not report two withholdings as one.
326
374
  *
327
375
  * - `"memory.session_polluted"` (design/178 §3, #324a; message mode-aware since design/336) —
328
376
  * 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
+ }
@@ -1,35 +1,12 @@
1
1
  import { inlineUntrusted } from "./untrusted-text.js";
2
2
  import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
3
+ import { normalizeReadRootCandidate } from "./read-root-candidate.js";
3
4
  import { traceHookCrash } from "./runner/gate-exit.js";
4
- function withProbeTimeout(p, ms, signal) {
5
- if (ms === undefined && signal === undefined)
6
- return p;
7
- return new Promise((resolve, reject) => {
8
- let settled = false;
9
- const done = (fn, v) => {
10
- if (settled)
11
- return;
12
- settled = true;
13
- if (timer !== undefined)
14
- clearTimeout(timer);
15
- signal?.removeEventListener("abort", onAbort);
16
- fn(v);
17
- };
18
- const onAbort = () => done(reject, new Error("reversibilityProbe aborted (task ended)"));
19
- const timer = ms !== undefined
20
- ? setTimeout(() => done(reject, new Error(`reversibilityProbe timed out after ${ms}ms`)), ms)
21
- : undefined;
22
- if (signal?.aborted) {
23
- onAbort();
24
- return;
25
- }
26
- signal?.addEventListener("abort", onAbort);
27
- p.then((v) => done(resolve, v), (err) => done(reject, err));
28
- });
29
- }
30
- const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
5
+ import { writeProtectionTightenDecision } from "./write-protect.js";
6
+ import { createGateDeadline, awaitWriteProtectionJudgment, withGateTimeout } from "./gate-bounded-await.js";
31
7
  export async function runGateFold(pass) {
32
8
  const { input, toolName, toolCallId, callSignal, ledger, notifier, adjudicate } = pass;
9
+ const boundedAwaitDeadlineMs = createGateDeadline(input, notifier);
33
10
  pass.req = { toolName, args: pass.currentInput, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
34
11
  pass.decision = adjudicate ? await (callSignal !== undefined ? adjudicate(pass.req, callSignal) : adjudicate(pass.req)) : { action: "allow" };
35
12
  {
@@ -38,6 +15,9 @@ export async function runGateFold(pass) {
38
15
  pass.deniedBy = refusedBy;
39
16
  if (refusedBy !== undefined)
40
17
  pass.denyCause = ledger.causeOf(pass.decision);
18
+ const attributed = ledger.classifierRoundOf(pass.decision);
19
+ if (attributed !== undefined)
20
+ pass.classifierRound = attributed;
41
21
  }
42
22
  if ((pass.decision.action === "allow" || pass.decision.action === "ask") && pass.decision.updatedInput !== undefined) {
43
23
  pass.policyRewrite = pass.decision.updatedInput;
@@ -80,23 +60,13 @@ export async function runGateFold(pass) {
80
60
  let probeReason;
81
61
  let probeCause;
82
62
  let probeMandated;
63
+ let probeReadRootCandidate;
83
64
  if (input.irreversibility === "maybe" && input.reversibilityProbe) {
84
65
  let reversible = false;
85
66
  const probeArgs = pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput;
86
- const suppliedProbeMs = input.approvalTimeoutMs;
87
- let probeTimeoutMs;
88
- if (suppliedProbeMs === undefined) {
89
- probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS;
90
- }
91
- else if (Number.isFinite(suppliedProbeMs) && suppliedProbeMs >= 0 && suppliedProbeMs <= 2_147_483_647) {
92
- probeTimeoutMs = suppliedProbeMs;
93
- }
94
- else {
95
- probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS;
96
- 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);
97
- }
67
+ const probeTimeoutMs = boundedAwaitDeadlineMs();
98
68
  try {
99
- 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);
100
70
  reversible = verdict?.reversible === true;
101
71
  if (!reversible) {
102
72
  const raw = verdict?.reason;
@@ -105,6 +75,7 @@ export async function runGateFold(pass) {
105
75
  probeCause = normalizeProbeCause(verdict?.cause);
106
76
  if (verdict?.mandated === true)
107
77
  probeMandated = true;
78
+ probeReadRootCandidate = normalizeReadRootCandidate(verdict?.readRootCandidate);
108
79
  }
109
80
  }
110
81
  catch (err) {
@@ -123,6 +94,7 @@ export async function runGateFold(pass) {
123
94
  ...(probeReason !== undefined ? { probeReason } : {}),
124
95
  ...(probeCause !== undefined ? { probeCause } : {}),
125
96
  ...(probeMandated === true ? { probeMandated: true } : {}),
97
+ ...(probeReadRootCandidate !== undefined ? { probeReadRootCandidate } : {}),
126
98
  };
127
99
  pass.tightenedBy = input.shellGated === true ? "shell_gate" : "safety";
128
100
  }
@@ -139,14 +111,14 @@ export async function runGateFold(pass) {
139
111
  pass.tightenedBy = "safety";
140
112
  }
141
113
  if (pass.decision.action === "allow" && input.writeProtectionCheck !== undefined) {
142
- const writeProtectedHit = input.writeProtectionCheck(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput, pass.req.face);
143
- if (writeProtectedHit !== null) {
144
- pass.decision = {
145
- action: "ask",
146
- message: `tool "${toolName}" writes to a write-protected path (table entry "${writeProtectedHit.name}") — explicit approval required`,
147
- decisionReason: "safety",
148
- };
149
- 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";
150
122
  }
151
123
  }
152
124
  return await pass.afterFold(pass);