@sema-agent/core 5.38.0 → 5.40.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 (51) hide show
  1. package/CHANGELOG.md +182 -10
  2. package/dist/agents/send-message-tool.d.ts +8 -0
  3. package/dist/agents/send-message-tool.js +8 -0
  4. package/dist/agents/teacher.js +9 -3
  5. package/dist/agents/verify.js +9 -3
  6. package/dist/core/checkpoint-store.d.ts +12 -0
  7. package/dist/core/governance-codes.js +2 -0
  8. package/dist/core/hooks.d.ts +23 -0
  9. package/dist/core/hooks.js +53 -4
  10. package/dist/core/mailbox-store.d.ts +39 -0
  11. package/dist/core/mailbox-store.js +9 -0
  12. package/dist/core/memory-engine/engine.d.ts +27 -0
  13. package/dist/core/memory-engine/engine.js +103 -1
  14. package/dist/core/memory-engine/export-bundle.d.ts +192 -0
  15. package/dist/core/memory-engine/export-bundle.js +306 -0
  16. package/dist/core/memory-engine/file-backend.d.ts +178 -1
  17. package/dist/core/memory-engine/file-backend.js +637 -6
  18. package/dist/core/memory-engine/index.d.ts +2 -1
  19. package/dist/core/memory-engine/index.js +1 -0
  20. package/dist/core/memory-engine/layout.d.ts +89 -1
  21. package/dist/core/memory-engine/layout.js +131 -1
  22. package/dist/core/memory-engine/memory-backend-contract.d.ts +1 -1
  23. package/dist/core/memory-engine/memory-backend-contract.js +52 -0
  24. package/dist/core/memory-engine/tools.js +8 -1
  25. package/dist/core/permission-rule-consent.d.ts +27 -4
  26. package/dist/core/permission-rule-consent.js +41 -4
  27. package/dist/core/permission-rule-model.d.ts +7 -1
  28. package/dist/core/runner/prepare-task.js +24 -3
  29. package/dist/core/runner/runtask.js +5 -0
  30. package/dist/core/runner/synthetic-tools.js +3 -1
  31. package/dist/core/runner/tool-disclosure.js +2 -1
  32. package/dist/core/sensitive-path-policy.js +3 -3
  33. package/dist/core/store-contracts/mailbox-store-contract.d.ts +29 -1
  34. package/dist/core/store-contracts/mailbox-store-contract.js +78 -0
  35. package/dist/core/types.d.ts +21 -0
  36. package/dist/core/write-protect.d.ts +73 -0
  37. package/dist/core/write-protect.js +195 -0
  38. package/dist/index.d.ts +4 -3
  39. package/dist/index.js +4 -3
  40. package/dist/orchestration/governance-baseline-validity.d.ts +44 -0
  41. package/dist/orchestration/governance-baseline-validity.js +55 -0
  42. package/dist/orchestration/run-workflow-tool.js +33 -8
  43. package/dist/orchestration/workflow-script-runner.js +9 -4
  44. package/dist/tools/fs/read-deny.d.ts +15 -5
  45. package/dist/tools/fs/read-deny.js +33 -12
  46. package/dist/tools/fs/safety.d.ts +5 -2
  47. package/dist/tools/fs/safety.js +5 -3
  48. package/dist/tools/fs/search.d.ts +33 -0
  49. package/dist/tools/fs/search.js +72 -0
  50. package/package.json +1 -1
  51. package/test/export-surface.snapshot.json +21 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,177 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.40.0 — 2026-08-18
4
+
5
+ No BREAKING changes. Two behavioral narrowings disclosed below (the requestedCwd × workspace-restore
6
+ combination now refuses loudly; Grep's ripgrep leg withholds alias-spelled deny-listed paths it
7
+ previously returned).
8
+
9
+ ### Added
10
+
11
+ - design/178 v2-c — governed memory export/import: `MemoryEngine.exportMemoryScopes` /
12
+ `importMemoryBundle` over a self-describing bundle (`MemoryExportBundle`: entries + governance
13
+ rows — unresolved challenges, durable pollution markers, committed lineage, custody evidence —
14
+ + residuals + a recomputable integrity section). Import is two-phase: entries land under a
15
+ synthetic lineage LATCH (withheld from every model-visible read face) and only a durable,
16
+ fully-validated completion receipt releases them; a crash mid-governance converges by re-running
17
+ the SAME bundle import (idempotent). Tenant slicing is per-artifact three-way (refusal /
18
+ field redaction with an import-visible provenance stamp / global-residual); a `complete:false`
19
+ export refuses to mint a package at all. Integrity detects accidents, never forgery (self-attested
20
+ hashes; authenticity is the transport's job — the multi-tenant service form gates imports behind
21
+ its authenticated endpoint). New exports: `computeMemoryBundleHash` + `MemoryExportBundle` /
22
+ `MemoryImportReport` / `MemoryExportSnapshot` / `MemoryBundleImportPlan` / `BundleChallengeRow` /
23
+ `BundleLineageRow` / `BundlePollutedSession` types. New coded errors: `memory.export_incomplete`,
24
+ `memory.import_rejected` (input-shape errors use the existing `config.memory_export_request` /
25
+ `config.memory_import_request` family). Free-string fields (pollution/challenge reasons, custody
26
+ `req`, session ids) export VERBATIM; the bundle's `doc` lines disclose that sub-scope tenant
27
+ isolation does not extend into those literals.
28
+ - backlog #243 — new coded refusal `checkpoint.cwd_conflicts_restore` (see Fixed below).
29
+
30
+ ### Fixed
31
+
32
+ - backlog #243 — `RunInternals.requestedCwd` combined with a checkpoint workspace restore is now
33
+ REFUSED loudly instead of half-working: the restored workspace's mountPath is authoritative for
34
+ the task root, so the cwd was semantically ignored at best — and verifying it was prepare's FIRST
35
+ env RPC, which a lazy-connect remote adapter answers by connecting a fresh EMPTY sandbox before
36
+ `resumeVM` (silently running the resumed task on an empty workspace), or the check itself
37
+ deadlocked the approval on a phantom mismatch (`env_failed` reopen retrying deterministically).
38
+ Two gates: the resume ladder refuses PRE-CAS (`checkpoint.cwd_conflicts_restore` — the checkpoint
39
+ stays pending; re-resume without the cwd succeeds), and prepare carries a depth twin for direct
40
+ `runTaskStream(spec, resume, internals)` callers (`config.cwd_conflicts_restore`, refused before
41
+ the env factory mints anything). **Narrowing**: on eager adapters this combination previously
42
+ "worked"; it now refuses on every adapter. Core's own delegation drivers never mint the pair.
43
+ - backlog #303 — Grep's ripgrep leg gains a deny TRIPWIRE: rg's exclusion globs are spelled from
44
+ the pattern text and cannot express the win32 component-alias family (`.aws.` — trailing
45
+ dots/spaces the win32 namespace strips), so a directory planted under an alias spelling slipped
46
+ past the globs and its contents reached Grep results whenever ripgrep was installed, while the JS
47
+ leg correctly pruned it. Every rg output line is now judged with the same two-view matchPath the
48
+ JS walker prunes with; ANY deny hit — or any line the record format cannot account for (e.g. a
49
+ newline inside a path component splitting one rg record across physical lines) — abandons the rg
50
+ pass entirely and the JS scanner answers, pruning with the authoritative judge and declaring the
51
+ abandonment on both the text and structured surfaces. **Narrowing**: results no longer differ by
52
+ whether ripgrep is installed; rg remains the fast path when no guarded entry is involved
53
+ (`#306` tracks the structured `--json` terminal form).
54
+ - backlog #298 — a THROWING deployment-supplied `breakerOpen` on the auto-mode decider reads as
55
+ not-open: the peer-referral ask is still minted (one extra question), never a silent allow.
56
+ - backlog #300 — `WRITE_PROTECTED_DEFAULT_TABLE` and every resolved table are deep-frozen: an
57
+ admin-face in-place mutate (splice / `length = 0`) throws at the mutation site instead of
58
+ silently weakening the process-wide protection floor.
59
+ - backlog #301 — the CC settings-import preview guards the settings ROOT like it already guarded
60
+ the `permissions` slot: a valid-JSON document that is a string/number/array reports the layer as
61
+ UNREAD (`skipped`) instead of two clean zeros; a `null` document remains the readable
62
+ "nothing written".
63
+ - 5.40 pre-release rescan (memory-engine import/export hardening, all pinned):
64
+ - the import's retroactive pollution sweep mints its challenge events under a TOP-LEVEL
65
+ `imp-sweep:` prefix, structurally disjoint from the carried-challenge leg's
66
+ `imp:<hash>:<source eventId>` — a shared key with a stale frozen `challengedRev` (a
67
+ crash-window promote on the source drifting lineage `lastRev`) aborted the read-back verifier
68
+ AFTER the latch staged, and the documented re-import convergence reproduced the abort forever;
69
+ - BOTH export faces refuse over the durable chain-degradation marker
70
+ (`transfers.chain-degraded.json`): a healed (quarantined + truncated) evidence chain parses
71
+ clean, but the store has declared the lost rows unprovable, and a bundle minted over it
72
+ testified to a deletion history the source knew was incomplete (an erased id could resurrect on
73
+ the destination); the composite throws `memory.export_incomplete`, `governanceExport` answers
74
+ `complete:false` with the reason;
75
+ - the bundle-import completion receipt validates EVERY `MemoryImportReport` member — an 8-of-15
76
+ husk could release a standing latch and hand back a report whose missing arrays throw on first
77
+ touch.
78
+ - `deferMode: "auto"` never sweeps engine BUILT-INS (align-with-CC ruling): the pressure valve's
79
+ candidates narrow to caller-supplied specs — CC's own `isDeferredTool` defers MCP as a family
80
+ plus individually self-declared tools and has no pressure valve at all, so built-ins like
81
+ Grep/Glob/Read stay inline unconditionally; sweeping them made every small-window model pay a
82
+ first-use activation error on tools its training treats as always present. An explicit
83
+ `deferTools` naming a built-in still honors the deployment's stated intent.
84
+ - Out-of-root refusals no longer print a duplicated root: a directory declared through two doors
85
+ (`additionalDirectories` + the advertised scratchpad) deduplicates by canonical value in the
86
+ disclosure list (the judgment was already idempotent).
87
+ - backlog #309 — the out-of-root escape hint names the `readFace: "open"` knob beside the
88
+ per-directory seats; the unknown-skill refusal states that foreign skill directories are not
89
+ loaded and points at importing the content instead (signpost ruling).
90
+
91
+ ## 5.39.0 — 2026-08-17
92
+
93
+ No BREAKING changes. Two behavioral narrowings disclosed below (write-protection default table;
94
+ peer messages join the permission chain) plus one governance-config narrowing (malformed workflow
95
+ baseline slots refuse at mount instead of silently mounting ungoverned).
96
+
97
+ ### Added
98
+
99
+ - #286 (#279) — the default-on WRITE-protection table (CC 2.1.233 DANGEROUS_* parity): a Write /
100
+ Edit / NotebookEdit whose target lands on a named row (shell/profile rc files, VCS hook and config
101
+ paths, editor/tool trust files — basename, segment and segment-run row kinds; case-folded, win32
102
+ spellings and trailing-dot/space component aliases included) is DEMOTED TO ASK at the tool gate
103
+ when it would otherwise auto-allow. **Narrowing**: in 5.38.0 these writes rode a surviving allow
104
+ silently. The tighten demotes to ask (classifier / blanket `onAsk` / persisted rules can still
105
+ clear it) and never blocks reads; ONE consumer arm denies instead — an approved edit the
106
+ restriction chain rewrites onto a table row after the human looked is refused fail-closed, since
107
+ minting a fresh ask there would hand a pathological rewriter an unbounded approval treadmill.
108
+ Whole-table replacement seat `RunnerDeps.writeProtectedPaths`
109
+ (`[]` disables; a non-empty list replaces the default table; garbage rows refuse loudly, including
110
+ spellings the candidate fold would orphan). New exports (admin face): `WRITE_PROTECTED_DEFAULT_TABLE`,
111
+ `resolveWriteProtectedTable`, `compileWriteProtection` + entry/row/kind/hit/matcher types.
112
+ - design/276 — peer messages join the permission adjudication chain: in auto mode, `SendMessage` is
113
+ routed through the classifier (peer-referral tighten, third member of the post-fold tighten
114
+ family), and the two ancestor sandbox-admission arms stop auto-allowing an inherited
115
+ `sandbox_local` ask on SendMessage. **Narrowing**: a peer message that previously rode an ambient
116
+ allow now asks; the referral ask carries no `requiresRealApproval`, so classifier / blanket
117
+ `onAsk` / persisted rules can clear it.
118
+ - Mailbox pre-delete contract half ([4194] ruling (b)): enqueue against a recipient a deletion
119
+ cascade has already tombstoned is a CODED refusal — new exports `MAILBOX_TOMBSTONED_RECIPIENT_CODE`
120
+ (`"mailbox.recipient_tombstoned"`), `MailboxStoreError`, and the `mailboxTombstonedRecipientContract`
121
+ acceptance-kit leg (optional contract clause: only backends that can see recipient lifecycle carry
122
+ it; the refusal is per-recipient and enqueue-only).
123
+ - CC settings import — the preview reports `uncovered.denyAskBuckets: { deny, ask }`: how many
124
+ deny/ask entries the import leaves in place across the layers it reads (only the allow bucket is
125
+ imported; by-design-not-imported and unnoticed no longer look identical).
126
+
127
+ ### Fixed
128
+
129
+ - Workflow governance baseline hardening (5.39 rescan window): a non-object `base` slot
130
+ (null / undefined / primitive / array — the shapes JSON/DB schema drift mints) is a CODED mount
131
+ refusal (`config.invalid_governance_baseline`) instead of silently mounting every script-spawned
132
+ child with ZERO inherited governance (5.38.0's raw spread accepted these spellings silently);
133
+ `worktreeBase: null` folds to its documented absence, any other non-object shape there refuses
134
+ with the same code. Face-list fields (`excludeTools`/`deferTools`/`alwaysLoadTools`) validate as
135
+ string arrays at mount (`config.invalid_tool_name_set`), and a null face field reads as absent in
136
+ BOTH slots (a null overlay field no longer silently remounts what the deployment excluded).
137
+ - **The win32 component alias is closed on every path face, by one uniform rule.** `.bashrc.` and
138
+ `.bashrc ` name the SAME file as `.bashrc` on the wire (the win32 file namespace strips trailing
139
+ dots and spaces from every component), and `. ` / `.. ` are that namespace's spellings of `.` and
140
+ `..`. Pre-fix, `.aws.\credentials` missed the built-in read-deny set entirely on the purely
141
+ lexical legs — the bash-classify deny probe resolves operands with zero I/O by contract, so
142
+ nothing collapsed the alias later and the read took the auto-allow lane.
143
+ Every lexical path face (the write-protection matcher, the read-deny segment engine and its
144
+ sensitive-path consumer) now judges **two views** — components verbatim, and win32-normalized —
145
+ and protects if **either** hits. A lexical matcher cannot know the target platform (execution
146
+ envs may be remote, and win32 accepts forward-slash spellings, so separator gating carries
147
+ nothing), so picking one interpretation is always wrong for the other; the two errors are not
148
+ symmetric, since a missed protection is silent while an over-match is one extra question.
149
+ **The win32 view is skipped on one reported fact**: `canonicalizeTarget`'s realpath arm now returns
150
+ `aliasResolved`, meaning the real filesystem produced that key, so any component alias in the
151
+ spelling was already collapsed by the filesystem itself — a genuinely-resolved POSIX target keeps
152
+ its own reading and a real `.aws.` directory stays readable and writable. The arms that resolve
153
+ NOTHING do not report it and keep both views: UNC keys (minted with zero probes for liveness) and
154
+ not-yet-existing targets (whose tail is rejoined verbatim) — which is where the pre-creation path
155
+ lives. Unreported is the fail-closed default, so a caller that forgets to pass it loses precision,
156
+ never protection.
157
+ **Residual cost**: a target the filesystem has NOT resolved still over-matches — writing a
158
+ not-yet-created file inside a legal POSIX `.aws.` directory asks (write face) or refuses
159
+ (sensitive-path policy). Trailing-dot table ROWS stay legal (they match the POSIX file of exactly
160
+ that name; on win32 that spelling *is* the stripped name, which the ordinary row already covers).
161
+ **Known gap, ticketed not fixed**: the ripgrep exclusion globs do not fold, so a Grep result can
162
+ differ by whether ripgrep is installed (#303).
163
+ - The self-orchestration gates ask the mount's question, not just "is the field there".
164
+ `workflowsCapability` tested `!== undefined` while the mount site tested truthiness, so
165
+ `workflowGovernanceBaseline: null` announced workflows with `run_workflow` unmounted and
166
+ suppressed the fail-closed operator warning; and once the mount began refusing an unusable `base`
167
+ slot, `{ base: null }` / `{}` announced workflows that then hard-failed every opted-in task. One
168
+ predicate now answers for all of them, and it reads the `base` slot — so an unusable baseline is
169
+ capability-false, inactive, and named in the operator warning instead of failing at run time.
170
+ - Internal verify/teacher leg spec inheritance stops spreading a bare-string
171
+ `excludeTools`/`deferTools`/`alwaysLoadTools` into inert single-character names — malformed shapes
172
+ ride verbatim so the leg's own prepare judges them (coded doors for the three public seats are
173
+ tracked separately).
174
+
3
175
  ## 5.38.0 — 2026-08-17
4
176
 
5
177
  No BREAKING changes. One behavioral narrowing (default task-scaffold trim on strong claude models)
@@ -818,7 +990,7 @@ breaking change (rows without the new gate bit keep their historic stamps).
818
990
  governed row (org origin) is refused PRE-CAS on a worker with no `permissionRuleOrg` wiring (the
819
991
  human decision stays unspent, redeemable on an org-wired worker; a post-CAS belt remains as
820
992
  defense in depth); a v8 row whose bit was stripped in storage, and a sub-v8 row carrying a bit no
821
- release minted, are both refused pre-CAS (corruption/forgery guards).
993
+ release minted, are both refused pre-CAS (corruption/fabrication guards).
822
994
  - **`run_in_background: true` is judged by the backgrounding doctrine (#125, P0).** On a
823
995
  `shellGate:"classify"` deployment, `bashReversibilityProbe` used to read only the command text, so
824
996
  `ls` + the parameter form auto-admitted exactly what `ls &` asks for. The parameter spelling now
@@ -1552,7 +1724,7 @@ mysterious runtime.
1552
1724
  claiming the row is still pending. Terminal-state refusals name the observed status without
1553
1725
  coercing store-supplied values.
1554
1726
  - The approval lane's `reason` is validated as plain text at capture; malformed decisions and
1555
- hostile text shapes get typed refusals instead of raising inside the refusal path.
1727
+ unexpected text shapes get typed refusals instead of raising inside the refusal path.
1556
1728
  - Orchestration guidance, the TaskOutput tool card, and the selective-recall affordance stop
1557
1729
  teaching retired tool names: the workflow tool is named by its wire name, and
1558
1730
  `composeSelectiveBody` accepts an optional caller-supplied `recallToolName` (additive).
@@ -2094,7 +2266,7 @@ live `onAsk` is wired — drop the approver (or arm `forceDurableGate`) to test
2094
2266
 
2095
2267
  - **Memory recall: an unknown write time is incomparable, not 1970.** A note whose `ts` failed to parse used to sort as epoch 0 — it lost every `[[name]]` collision (permanently unreachable through links) and rendered a fabricated age ("written 678 months ago"). `MemoryNoteHeader.timestampMissing` (new, optional) is minted at the single parse point; the one recency comparison treats missing as a tie (the incumbent keeps the name — an unknown-time note neither sinks nor poses as newest), and the render face says "write time unknown — verify it's still current". A literal 1970 timestamp string stays a real time (the numeric value is never the signal; negative pins lock this). Bonus: a wire row with NO `ts` key used to throw and kill the whole scope's manifest build. **Consumer flip**: a probe pinning the fabricated-age wording or the sink-to-bottom collision outcome reds.
2096
2268
  - **Three awaited rescue timers are REF'd + cleared-on-settle** (subagent observer drain, teacher abortFallback, WebFetch deadline): unref'd, the process could exit before the rescue fired when the wedged arm held no live handles — the settle never completed. The bg notify drain window keeps its unref by design (a background lane must not pin the process).
2097
- - **A harness-side throw around ONE tool call becomes that call's disclosed error result — the batch survives.** Worker exceptions that are not cancellation (a rejected `tool_execution_update` delivery, a sink failure) used to reject the whole parallel batch, orphan the sibling calls, and on the worst path rewrite a COMMITTED side effect into `[tool execution harness failed: …]` — inviting the model to re-run an already-effective write. Progress deliveries now settle at one point; a delivery failure on an executed tool keeps `content`/`isError` untouched and rides `details.toolProgressDeliveryFailed` (total by construction: hostile accessors or exotic `details` decline the annotation, never the outcome; `afterToolCall` cannot erase it). Cancellation — including one wrapped in the harness's own `AgentHarnessError` (`cause`-chain walk) — stays a hard batch failure on every lane, and the in-stream lane closes admission immediately. The sequential lane shares the same disclosure band. **Consumer flips**: probes pinning the old `[tool execution harness failed]` rewrite or `isError:true` on a delivery-failed success red — re-pin on the new shape plus a retired-dialect tripwire.
2269
+ - **A harness-side throw around ONE tool call becomes that call's disclosed error result — the batch survives.** Worker exceptions that are not cancellation (a rejected `tool_execution_update` delivery, a sink failure) used to reject the whole parallel batch, orphan the sibling calls, and on the worst path rewrite a COMMITTED side effect into `[tool execution harness failed: …]` — inviting the model to re-run an already-effective write. Progress deliveries now settle at one point; a delivery failure on an executed tool keeps `content`/`isError` untouched and rides `details.toolProgressDeliveryFailed` (total by construction: throwing accessors or exotic `details` decline the annotation, never the outcome; `afterToolCall` cannot erase it). Cancellation — including one wrapped in the harness's own `AgentHarnessError` (`cause`-chain walk) — stays a hard batch failure on every lane, and the in-stream lane closes admission immediately. The sequential lane shares the same disclosure band. **Consumer flips**: probes pinning the old `[tool execution harness failed]` rewrite or `isError:true` on a delivery-failed success red — re-pin on the new shape plus a retired-dialect tripwire.
2098
2270
  - **openai lane: an evidence-free `tool_calls` fragment no longer fabricates an unnamed-call disclosure.** The evidence gate sits at the finalize disclosure point (slot assignment also routes id-less continuations, so gating there corrupted two real calls); the stream's own verdict wins — `finish_reason:"tool_calls"` with zero survivors, or an empty slot beside real calls, discloses loudly; an isolated terminal fragment with `stop`/`length` stays silent.
2099
2271
  - **A throwing tool keeps its machine-readable marks on BOTH legs.** Two strip points, one class: the `defineTool` adapter rewrapped a ToolSpec throw as a bare `Error` (marks died even LIVE — a ToolSpec throw carrying `details.code` got no `errorCode` lift while a native tool's identical error did), and the durable-resume batch's catch stringified the message alone (breaking the live/resume frame-parity contract). Both carry `details`/`errorKind` through now; message text unchanged.
2100
2272
  - **Cache-break attribution: an aborted / usage-missing row is "unknown", not a cold cache.** Feeding it to the design/31 detector minted a false `server-or-ttl` finding and poisoned the warm baseline for the next real turn. Such rows are skipped; a genuine cacheRead collapse still fires.
@@ -2624,7 +2796,7 @@ _Emergency direct-publish path (CI publishing resumes 8/1); pre-tag `gate:blackb
2624
2796
  **API retry chain (user-reported, rate-limited third-party providers)**
2625
2797
 
2626
2798
  - `maxRetries` defaults to 10 (was 2), configurable via explicit brain options (highest priority, uncapped) or `SEMA_MAX_RETRIES` (clamped to [0,15]); `retryDelayMs` base is now 500.
2627
- - Backoff is increasing-with-light-jitter — `min(500·2^(n-1), 32000) + rand·0.25·backoff` — replacing full-jitter (attempt N could previously wait less than attempt 1). `Retry-After` keeps its existing max-composition semantics; `anthropic-ratelimit-unified-reset` is now honored the same way (header values clamped to 60s against hostile headers).
2799
+ - Backoff is increasing-with-light-jitter — `min(500·2^(n-1), 32000) + rand·0.25·backoff` — replacing full-jitter (attempt N could previously wait less than attempt 1). `Retry-After` keeps its existing max-composition semantics; `anthropic-ratelimit-unified-reset` is now honored the same way (header values clamped to 60s against out-of-range headers).
2628
2800
  - Retry liveness reaches the task event stream: `status` frames carry `attempt`/`maxRetries`/`retryInMs`, waits longer than 30s re-announce per slice, recovery emits a `recovered` terminal and exhaustion a `gave_up` terminal (phases added to `BrainStatusPhase`), and `circuit_open` carries the remaining cooldown. Clients can finally clear a stale error row the moment the stream recovers.
2629
2801
 
2630
2802
  **Security (read-only bash boundary — bypass closure, disclosure correction for 2.8.0)**
@@ -3402,7 +3574,7 @@ Carried over from 1.410.0's open list: **RB-95** (the compaction cut-point's bac
3402
3574
 
3403
3575
  ## 1.410.0 (2026-07-25)
3404
3576
 
3405
- The divergent-attack round: four more probe agents, again forbidden to touch source, produced 44 reproducible defects across the approval chain, the session tree, the tool surface and the agent-team stores. 22 are fixed here, 2 are recorded as open with their reds resident. One of them was mine — 1.409.0's own fix introduced it, and it is the most serious thing in this release.
3577
+ The divergent-probe round: four more probe agents, again forbidden to touch source, produced 44 reproducible defects across the approval chain, the session tree, the tool surface and the agent-team stores. 22 are fixed here, 2 are recorded as open with their reds resident. One of them was mine — 1.409.0's own fix introduced it, and it is the most serious thing in this release.
3406
3578
 
3407
3579
  **Note on 1.409.0**: its tag was pushed but CI went red on `flaky-guard` and it never reached npm. The cause was not the release content — the guard's stale-allowlist check flagged a declaration for an untracked `*.local.test.ts` probe, a file that exists on a developer's machine and, by design, not in CI. The check now skips entries whose file is absent from the scan. Everything in 1.409.0 ships here.
3408
3580
 
@@ -3429,7 +3601,7 @@ A multi-dimensional red hunt (five independent probe agents + a first-party pass
3429
3601
  - **fix (RB-64): the anti-re-vote guard was nested entirely inside the `policy_ask` arm**, so a `plan_review` checkpoint reopened after an `env_failed` retry accepted a decision contradicting the one the human actually made — and `winnerFromOutcome` recorded no winner for that gate, leaving nothing to replay against. Winners are now recorded for the review gates (with a synthetic `gate:plan_review` sentinel that can never collide with a real toolCallId) and the guard is gate-agnostic.
3430
3602
  - **fix (RB-68): `durableApproval.ttlMs` reached `Date.now() + ttlMs` unvalidated** — `Number(unset_env_var)` produced `deadline = NaN`, and `NaN <= cutoff` is always false, so a checkpoint that carries a TTL and looks bounded could never be reaped by any cutoff. Fixed in both layers: mints normalize a non-finite/non-positive TTL to the documented default (a deliberate `undefined` still means unbounded), and `reap` treats an uncomparable deadline as reapable, since checkpoint rows are also written by deployment-side stores and older versions.
3431
3603
  - **fix (RB-70): `resumeStream` never set `requireExistingSession`**, so the headline durable scenario — a restarted process resolving a checkpoint while only the CheckpointStore was wired durably — fabricated a brand-new empty session under the checkpoint's id and pointed a dangling leaf at it. Every other resume-shaped call site already set it. The resulting `resume.session_not_found` now reopens the checkpoint as `env_failed`: the action never ran and the human's decision is still valid, so the approval is retryable rather than burned.
3432
- - **fix (RB-71): one throwing getter collapsed an entire object to a single `<unserializable>` sentinel**, so any two hostile payloads hashed identically — and `boundInputHashOf` exists precisely to prove the args being executed are the args a human saw. The sentinel is now scoped to the offending key; totality is unchanged, resolution costs one key instead of the whole payload.
3604
+ - **fix (RB-71): one throwing getter collapsed an entire object to a single `<unserializable>` sentinel**, so any two unserializable payloads hashed identically — and `boundInputHashOf` exists precisely to prove the args being executed are the args a human saw. The sentinel is now scoped to the offending key; totality is unchanged, resolution costs one key instead of the whole payload.
3433
3605
  - **fix (RB-57): `findOrphanToolCalls` resolved tool calls against a global id set**, so a provider that reuses a toolCallId across turns (OpenAI-compatible gateways that number per request) hid a genuine orphan — and an unclosed `tool_use` makes every subsequent request on that session fail at a strict provider, not just one reconcile. Resolution is now positional.
3434
3606
  - **fix (RB-65): `tightenTaskSpec` left `excludeTools`/`deferTools` outside its tighten-only guard** — an override replaced the base list instead of unioning with it, and `[]` cleared every base exclusion outright.
3435
3607
  - **fix (RB-69): the CC task-list store's addressable id set and its enumerable id set were different** — a `007.json` counted toward the allocation high-water yet was unreachable through get/set/delete, and `list()` reported it as the id `"7"`. Checked against CC 2.1.219 itself (`edr`/`Uid`/`ste`): CC constrains a character class, not a numeric shape, and its list returns filenames verbatim, so `007` is a first-class id there. Both sides now use CC's charset; sema keeps the stricter half of the divergence (an off-charset id throws rather than being silently rewritten into a neighbouring file).
@@ -3653,7 +3825,7 @@ design/154 (shellGate classify granularity — server [1558]④): compound read-
3653
3825
  - **feat: `classifyCompoundReadonly(command, allow)`** (exported) — textual scan-split on `;`/`&&`/`||`/`|`, every segment re-vetted by the single shared `parseLeadingCommandName` + bare-name allowlist; all-green ⇒ read-only. Whole-string hard rejects stay for `< > $ ( )` backtick newline CR backslash; a LONE `&` is hard-rejected too (backgrounding outlives the shell and escapes TaskStop lifecycle governance; `|&` rejects via the same rule). Soundness: with escapes/expansion/redirection hard-rejected, quotes can only HIDE connectors (real boundaries ⊆ textual boundaries), so every executed argv[0] is a vetted segment head — a quote-carrying head is parser-rejected, a clean head equals the executed program name. Error direction is strictly false-negative.
3654
3826
  - **feat: `bashReversibilityProbe` upgraded** to the compound classifier (signature unchanged). The `bash_readonly` tool's `effect:"read"` declaration face deliberately KEEPS the strict single-command check (documented fork). The Monitor face intentionally shares the upgraded probe (design/135 G2: same env seam, classification is about the command text; a polled read-only compound stays read-only) — pinned by two wiring tests.
3655
3827
  - **NOT included by design:** no git/`find`/subcommand allowlisting — "zero-flag" vetting operates on pre-expansion text while the shell expands quotes/braces/globs into argv, and the candidate git subcommands are not provably read-only under repo config (`core.fsmonitor`, `diff.external`/textconv, promisor fetches, index refresh). `git status` still asks — recorded false-negative; widening needs a dedicated review.
3656
- - Verification: adversarial review (SOUND across five attack directions) + a black-box harness in the server-[1558] methodology — red-first detector self-check, then 5000 seeded fuzz commands with EVERY green actually executed under real bash in a snapshot sandbox: zero filesystem mutations, zero surviving process groups, zero hangs; curated adversarial family all red or verified-harmless. Ask-rate on a realistic coding-task corpus: 69% → 43%.
3828
+ - Verification: adversarial review (SOUND across five probe directions) + a black-box harness in the server-[1558] methodology — red-first detector self-check, then 5000 seeded fuzz commands with EVERY green actually executed under real bash in a snapshot sandbox: zero filesystem mutations, zero surviving process groups, zero hangs; curated adversarial family all red or verified-harmless. Ask-rate on a realistic coding-task corpus: 69% → 43%.
3657
3829
  - Backlog: RB-41 (availability residue — stdin-blocking greens like bare `cat` hang to tool timeout; not a reversibility issue, documented).
3658
3830
 
3659
3831
  ## 1.386.0 (2026-07-23)
@@ -3901,7 +4073,7 @@ CWD case core B-half ([1451]/[1452]/[1461]④): resume workspace-root fidelity +
3901
4073
  - **hardening round 5 (1 high folded): a divergent restore under a PENDING APPROVED ACTION fails closed.** The approved args were authored + human-approved against the checkpointed root — internal path-state rebase cannot rewrite tool args (`rm /app/…` would run against a root the workspace no longer occupies, or burn the approval on a fence refusal). Divergence + pending approval → `resume.env_failed` → the existing reopen leg (resolved→pending) keeps the approval retryable until the adapter honors `priorHandle` or the knob rolls back (pinned end-to-end: fail → reopen → knob-back retry executes exactly once at the right root). The guard keys on the RESUME OUTCOME (`tool_approval` × an `allow` winner — `PrepareResume.executesApprovedAction`), not on the checkpoint kind: a DENY winner executes nothing and resolves under the diverged root without wedging (round 6); non-approval suspends (resource_limit etc.) likewise proceed under the rebase.
3902
4074
  - **hardening round 4 (2 high folded):** the rebase contract is POSIX-ONLY — every remote lane's `mountPath` is a Linux container path; a backslash anywhere marks the value outside the domain and the path passes through UNCHANGED (un-rebased is observable via the divergence observation; wrongly-guessed Windows drive/UNC recomposition would silently corrupt cwd/read-state). And the divergence/misalignment `onError` observations are try/catch-isolated — a throwing observer sink must never turn an already-consumed (resolved) checkpoint into a terminal failure (observe-only means observe-only).
3903
4075
  - **residuals ledgered (RB-35, adversarial round 7, LOW):** two documented tails of the narrow "rotation window × non-honoring adapter × durable suspend" pathology — canonical-vs-logical root prefixes can miss the rebase on symlinked mounts (observable degradation: cd failure / fence refusal / read-state miss, never a silent wrong write), and the fail-closed guard keys on the allow winner before zero-execution branches (walltime exhaustion, tightened rules) are known (a permanent rotation can loop fail→reopen with an explicit remedy in every error). Both wait on a real deployment shape.
3904
- - **test: 13 new pins** — priorHandle rides resumeVM, mount-rotation divergence observation (non-fatal), POSITIVE honor leg (prompt cwd = checkpointed root across a rotation, zero divergence noise), resumeFacts render trio (honest sentences / hostile note / fresh-task-never), resume-leg e2e gating, cwd-alignment guard both ways (honoring env silent; dropping env observed, task proceeds).
4076
+ - **test: 13 new pins** — priorHandle rides resumeVM, mount-rotation divergence observation (non-fatal), POSITIVE honor leg (prompt cwd = checkpointed root across a rotation, zero divergence noise), resumeFacts render trio (honest sentences / degraded note / fresh-task-never), resume-leg e2e gating, cwd-alignment guard both ways (honoring env silent; dropping env observed, task proceeds).
3905
4077
 
3906
4078
  ## 1.359.0 (2026-07-20)
3907
4079
 
@@ -4039,7 +4211,7 @@ Redaction tiering ([1295]② — the assertion-killer) + workflow schema teachin
4039
4211
 
4040
4212
  - **fix: same-principal faces stop redacting paths/URLs.** `boundedRedactedSummary` (workflow agent outputs/results/errors flowing back to the launching model+script; run rows behind the scope-gated observe API) now applies `redactSecrets` only — the old blanket `[redacted-path]`/`[redacted-url]` strip killed the cross-agent assertion pattern ("return the path of the file you wrote") while protecting nothing on faces whose isolation is ACCESS CONTROL. CC redacts nothing here; the secret arm stays as a declared superset. Three host-error sites in the Workflow tool (script-path read failures) join the tier — their paths are the HOST'S own error text.
4041
4213
  - **`redactSecrets` rides the centralized `scrubSecrets` scanner** (codex HIGH: a second hand-rolled vocabulary WAS the bypass — SAS `sig=`, JWTs, sk-prefixed path tokens all slipped it): prefixed tokens, JWT, PEM, Bearer, `secretKey=value`, plus two URL arms that matter once URLs survive — userinfo credentials stripped to the LAST `@` of the authority (a first-`@` regex left `p@ssword` half-visible and let an empty-username form escape entirely, codex HIGH), and signed-URL params (`sig`/`signature`/`sas`/`code`) redacted by key.
4042
- - **External faces keep the FULL strip** (codex HIGH): `untrustedEgressForHuman` (human display; URL stripping also disarms exfil-lure links) and the cross-replica `WorkflowCompletionNotifier` payload (its contract promises host paths never escape) still apply `redactHostLeaks`. `subscribeWorkflow` remains in-process/by-runId — a deployment exposing it over HTTP scope-gates first (as `getWorkflowRun` does).
4214
+ - **External faces keep the FULL strip** (codex HIGH): `untrustedEgressForHuman` (human display; URL stripping also removes outbound-link bait) and the cross-replica `WorkflowCompletionNotifier` payload (its contract promises host paths never escape) still apply `redactHostLeaks`. `subscribeWorkflow` remains in-process/by-runId — a deployment exposing it over HTTP scope-gates first (as `getWorkflowRun` does).
4043
4215
  - **teaching ([1295]①④)**: the Workflow tool card now states standard-JSON-Schema union support (`{"type":["string","null"]}` / `anyOf` — typebox handles both, verified) and that cross-agent assertions belong in the SCRIPT body (plain JS between stages). Suite green, tsc 0, deepseek live 9/9.
4044
4216
 
4045
4217
  ## 1.346.0 (2026-07-19)
@@ -4120,7 +4292,7 @@ Approval display projection ([1245] — the "Run a dynamic workflow?" confirmati
4120
4292
  - **feat: `approvalPreview` seam** — a tool may declare `approvalPreview(args)` (AgentTool/ToolSpec, mechanism-neutral): a pure display projection of its args for human approval surfaces. The gate mints it through ONE helper — alias-aware tool lookup, throw-swallowed, 16KiB serialized clamp, and **control-character sanitization on every string leaf** (terminal escapes/CR can never reach an approval renderer raw). Delivered on BOTH lanes: `AskRequest.preview` (live onAsk dialogs) and the durable checkpoint's `pendingAction.preview` + **`CheckpointSummary.preview`** (the one-call `listByScope` inbox — no N+1 get).
4121
4293
  - **Trust contract (explicit)**: the preview is UNTRUSTED, ADVISORY display metadata — renderers must contextually escape it and approval surfaces should show the bound raw args alongside (args + boundInputHash remain the sole execution contract; adjudication and resume never read the preview).
4122
4294
  - **run_workflow implements it**: script form → statically parsed meta (name/description/phases[{title,detail}], scriptChars, hasArgs); named/scriptPath forms → identity stubs; malformed meta → bounded parse-error head. Runtime agent prompts are statically unknowable — phase detail is the honest ceiling. Also pinned: run_workflow rides the tool gate with NO exemption (an ask-policy naming it parks/asks like any tool — the [1230] "manual mode ran without confirmation" was a policy name-list gap, not an engine bypass).
4123
- - codex adversarial 2 rounds (hostile-metadata sanitization, alias-invoked preview loss, durable-summary projection all folded). Suite 6181 green, tsc 0, deepseek live 9/9.
4295
+ - codex adversarial 2 rounds (malformed-metadata sanitization, alias-invoked preview loss, durable-summary projection all folded). Suite 6181 green, tsc 0, deepseek live 9/9.
4124
4296
 
4125
4297
  ## 1.337.0 (2026-07-19)
4126
4298
 
@@ -144,6 +144,14 @@ export declare const SEND_MESSAGE_SUMMARY_MAX = 200;
144
144
  * four re-derivations into a SINGLE call site, immediately after the gate that makes the argument
145
145
  * non-empty — so this helper now has exactly one caller in the tool body. */
146
146
  export declare function clipSendMessageSummary(raw: string): string;
147
+ /**
148
+ * design/276 (direct-mount guidance): the engine's first-party auto-mount declares this tool's safety
149
+ * axes itself (`effect:"write"`, explicit `egress:false`). A host mounting this factory's product via
150
+ * `spec.tools` suppresses that auto-mount, so its own ToolSpec declaration is what the safety scan
151
+ * reads — declare `effect:"write"` there (a message is repeat-unsafe: replay = second delivery /
152
+ * second revive). The auto-mode peer-referral tighten keys on the WIRE NAME and applies to a direct
153
+ * mount all the same.
154
+ */
147
155
  export declare function createSendMessageTool(opts: SendMessageToolOptions): import("../core/types.js").AgentTool<Type.TObject<{
148
156
  to: Type.TString;
149
157
  message: Type.TString;
@@ -2,6 +2,7 @@ import { Type } from "typebox";
2
2
  import { defineTool } from "../core/tools.js";
3
3
  import { normalizeAgentName, DURABLE_AGENT_HANDLE_RE, DURABLE_AGENT_HEARTBEAT_MS } from "../core/task-registry.js";
4
4
  import { canAccessAgentRecord, clearRevivedRowTerminalPayload } from "../core/background-agent-store.js";
5
+ import { MAILBOX_TOMBSTONED_RECIPIENT_CODE } from "../core/mailbox-store.js";
5
6
  import { escapeAttributeValue, escapeEnvelopeTag, isObserverTaskId, OBSERVER_SENDMESSAGE_SENDER_REFUSAL, OBSERVER_SENDMESSAGE_TARGET_REFUSAL, } from "./observer.js";
6
7
  import { SUBAGENT_RESUME_CAP, SubagentRetainLedger, getSessionRetainLedger } from "./retain-ledger.js";
7
8
  import { createSubagentResume } from "./subagent.js";
@@ -404,6 +405,13 @@ export function createSendMessageTool(opts) {
404
405
  }
405
406
  catch (e) {
406
407
  await rollback();
408
+ if (e?.code === MAILBOX_TOMBSTONED_RECIPIENT_CODE) {
409
+ return {
410
+ content: `Message not sent: that agent is being deleted — its mailbox no longer accepts messages, so nothing was parked and it was not revived. Do not retry; launch a new agent with the needed context instead.`,
411
+ details: { error: "mailbox_recipient_deleted", to },
412
+ isError: true,
413
+ };
414
+ }
407
415
  return {
408
416
  content: `Message not sent: the durable mailbox refused the message (${e instanceof Error ? e.message : String(e)}) — nothing was parked and the agent was not revived. ${DEDUP_RETRY_NOTE}`,
409
417
  details: { error: "mailbox_failed", to },
@@ -183,9 +183,15 @@ async function runTeacherCore(runner, studentSpec, teacher) {
183
183
  ...(studentSpec.readFace === "roots" ? { readFace: "roots" } : {}),
184
184
  ...(studentSpec.readDenyPatterns !== undefined ? { readDenyPatterns: [...studentSpec.readDenyPatterns] } : {}),
185
185
  ...(studentSpec.interactiveTools === false ? { interactiveTools: false } : {}),
186
- ...(studentSpec.excludeTools !== undefined ? { excludeTools: [...studentSpec.excludeTools] } : {}),
187
- ...(studentSpec.deferTools !== undefined ? { deferTools: [...studentSpec.deferTools] } : {}),
188
- ...(studentSpec.alwaysLoadTools !== undefined ? { alwaysLoadTools: [...studentSpec.alwaysLoadTools] } : {}),
186
+ ...(studentSpec.excludeTools !== undefined
187
+ ? { excludeTools: Array.isArray(studentSpec.excludeTools) ? [...studentSpec.excludeTools] : studentSpec.excludeTools }
188
+ : {}),
189
+ ...(studentSpec.deferTools !== undefined
190
+ ? { deferTools: Array.isArray(studentSpec.deferTools) ? [...studentSpec.deferTools] : studentSpec.deferTools }
191
+ : {}),
192
+ ...(studentSpec.alwaysLoadTools !== undefined
193
+ ? { alwaysLoadTools: Array.isArray(studentSpec.alwaysLoadTools) ? [...studentSpec.alwaysLoadTools] : studentSpec.alwaysLoadTools }
194
+ : {}),
189
195
  ...(studentSpec.restoreGatedTools !== undefined
190
196
  ? { restoreGatedTools: Array.isArray(studentSpec.restoreGatedTools) ? [...studentSpec.restoreGatedTools] : studentSpec.restoreGatedTools }
191
197
  : {}),
@@ -146,9 +146,15 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
146
146
  limits: { ...(specBase.limits?.maxWalltimeMs !== undefined ? { maxWalltimeMs: specBase.limits.maxWalltimeMs } : {}) },
147
147
  getApiKeyAndHeaders: specBase.getApiKeyAndHeaders,
148
148
  ...(specBase.principal !== undefined ? { principal: specBase.principal } : {}),
149
- ...(specBase.excludeTools !== undefined ? { excludeTools: [...specBase.excludeTools] } : {}),
150
- ...(specBase.deferTools !== undefined ? { deferTools: [...specBase.deferTools] } : {}),
151
- ...(specBase.alwaysLoadTools !== undefined ? { alwaysLoadTools: [...specBase.alwaysLoadTools] } : {}),
149
+ ...(specBase.excludeTools !== undefined
150
+ ? { excludeTools: Array.isArray(specBase.excludeTools) ? [...specBase.excludeTools] : specBase.excludeTools }
151
+ : {}),
152
+ ...(specBase.deferTools !== undefined
153
+ ? { deferTools: Array.isArray(specBase.deferTools) ? [...specBase.deferTools] : specBase.deferTools }
154
+ : {}),
155
+ ...(specBase.alwaysLoadTools !== undefined
156
+ ? { alwaysLoadTools: Array.isArray(specBase.alwaysLoadTools) ? [...specBase.alwaysLoadTools] : specBase.alwaysLoadTools }
157
+ : {}),
152
158
  ...(specBase.restoreGatedTools !== undefined
153
159
  ? { restoreGatedTools: Array.isArray(specBase.restoreGatedTools) ? [...specBase.restoreGatedTools] : specBase.restoreGatedTools }
154
160
  : {}),
@@ -1610,6 +1610,12 @@ export declare class CheckpointError extends Error {
1610
1610
  * Rejected pre-CAS so the checkpoint stays `pending` and a capable worker can still resume it
1611
1611
  * (design/49 §2/§3, code-ready council round-2). */
1612
1612
  | "checkpoint.unsupported_version"
1613
+ /** backlog #243: `resume`/`resumeStream` was handed `internals.requestedCwd` for a checkpoint that
1614
+ * carries a remote `workspaceHandle`. The restored workspace's mountPath is authoritative for the
1615
+ * task root, so the cwd would be ignored at best — and verifying it would touch the env BEFORE the
1616
+ * VM restore (a lazy-connect adapter then connects a fresh empty sandbox, the design/49 disease).
1617
+ * Rejected pre-CAS so the checkpoint stays `pending`; re-resume without `requestedCwd`. */
1618
+ | "checkpoint.cwd_conflicts_restore"
1613
1619
  /** design/164: the checkpoint was written by a pre-164 worker AND its ledger carries the RETIRED
1614
1620
  * cross-slice wall-clock allocation (`resourceLedger.totalWalltimeSec`). The axis no longer exists,
1615
1621
  * so resuming would run the leg with the operator's time ceiling silently unenforced. Refused
@@ -1733,6 +1739,12 @@ export declare class CheckpointError extends Error {
1733
1739
  * Rejected pre-CAS so the checkpoint stays `pending` and a capable worker can still resume it
1734
1740
  * (design/49 §2/§3, code-ready council round-2). */
1735
1741
  | "checkpoint.unsupported_version"
1742
+ /** backlog #243: `resume`/`resumeStream` was handed `internals.requestedCwd` for a checkpoint that
1743
+ * carries a remote `workspaceHandle`. The restored workspace's mountPath is authoritative for the
1744
+ * task root, so the cwd would be ignored at best — and verifying it would touch the env BEFORE the
1745
+ * VM restore (a lazy-connect adapter then connects a fresh empty sandbox, the design/49 disease).
1746
+ * Rejected pre-CAS so the checkpoint stays `pending`; re-resume without `requestedCwd`. */
1747
+ | "checkpoint.cwd_conflicts_restore"
1736
1748
  /** design/164: the checkpoint was written by a pre-164 worker AND its ledger carries the RETIRED
1737
1749
  * cross-slice wall-clock allocation (`resourceLedger.totalWalltimeSec`). The axis no longer exists,
1738
1750
  * so resuming would run the leg with the operator's time ceiling silently unenforced. Refused
@@ -24,6 +24,8 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
24
24
  "memory.erasure_selector_mismatch",
25
25
  "memory.erasure_census_incomplete",
26
26
  "memory.erasure_index_residue",
27
+ "memory.export_incomplete",
28
+ "memory.import_rejected",
27
29
  ]);
28
30
  export function governanceRetryClass(code) {
29
31
  if (Object.prototype.hasOwnProperty.call(GOVERNANCE_CODES, code)) {
@@ -938,6 +938,29 @@ export interface ToolGateInput {
938
938
  * per-tool mark — attributes a tighten-deny to `source:"shellGate"` instead of `"safety"`.
939
939
  */
940
940
  shellGated?: boolean;
941
+ /**
942
+ * design/276 (CC 2.1.233 P11 arm 4): true when the called tool is the peer-message verb — a message
943
+ * to another agent (`SendMessage`). ENGINE-FILLED from the wire name at the caller, never a
944
+ * configuration knob; a caller's same-named shadow tool is keyed too (for a tighten, over-asking is
945
+ * the fail-safe direction). Under an armed {@link autoMode} the gate tightens a surviving `allow` to
946
+ * `ask` (the peer-referral tighten below) so the message passes the classifier's eye — its prompt
947
+ * already carries the Multi-Agent Coordination exemption, so the ordinary teammate message resolves
948
+ * allow and the injection-shaped one is what this member exists to catch. Absent/false, or with no
949
+ * armed auto mode, the decision path is byte-identical. Per-run semantic: armed by THIS run's
950
+ * resolved caps, never inherited down the delegation chain (CC parity — the mode predicate reads the
951
+ * SENDING session's own mode).
952
+ */
953
+ peerMessage?: boolean;
954
+ /**
955
+ * backlog #286 (CC 2.1.233 `DANGEROUS_*` parity): the write-protection judge — ENGINE-BUILT from
956
+ * the deployment's table seat by `createWriteProtectionCheck` (write-protect.ts), never a
957
+ * deployment callback (the deployment authors table ROWS, which are validated loudly at compile;
958
+ * the judge itself is pure and synchronous, so it is trusted here like the `egress` mark). Judges
959
+ * the FINAL args of a path-confinable write tool; a hit demotes a surviving `allow` to `ask`
960
+ * (the write-protection tighten below). Absent ⇒ the deployment replaced the table with `[]` (or
961
+ * the caller runs the gate without one) and the decision path is byte-identical.
962
+ */
963
+ writeProtectionCheck?: (toolName: string, args: unknown) => import("./write-protect.js").WriteProtectedHit | null;
941
964
  /**
942
965
  * design/143 批2 ([672]-A, CC 2.1.207 auto mode): when present, a surviving `ask` is routed to the
943
966
  * small-model policy CLASSIFIER before any human/durable resolution:
@@ -398,6 +398,33 @@ export async function runToolGate(input) {
398
398
  denySource = input.shellGated === true ? "shellGate" : "safety";
399
399
  }
400
400
  }
401
+ const breakerKnownOpen = (d) => {
402
+ try {
403
+ return d.breakerOpen();
404
+ }
405
+ catch {
406
+ return false;
407
+ }
408
+ };
409
+ if (input.peerMessage === true && decision.action === "allow" && input.autoMode !== undefined && !breakerKnownOpen(input.autoMode.decider)) {
410
+ decision = {
411
+ action: "ask",
412
+ message: `tool "${toolName}" sends a message to another agent — routed for classifier review in auto mode`,
413
+ decisionReason: "safety",
414
+ };
415
+ denySource = "safety";
416
+ }
417
+ if (decision.action === "allow" && input.writeProtectionCheck !== undefined) {
418
+ const writeProtectedHit = input.writeProtectionCheck(toolName, policyRewrite !== undefined ? policyRewrite : currentInput);
419
+ if (writeProtectedHit !== null) {
420
+ decision = {
421
+ action: "ask",
422
+ message: `tool "${toolName}" writes to a write-protected path (table entry "${writeProtectedHit.name}") — explicit approval required`,
423
+ decisionReason: "safety",
424
+ };
425
+ denySource = "safety";
426
+ }
427
+ }
401
428
  let orgRealApprovalRequired = false;
402
429
  let orgAskOrigin;
403
430
  let orgTightenCount = 0;
@@ -573,6 +600,7 @@ export async function runToolGate(input) {
573
600
  decision.decisionReason !== "hook" &&
574
601
  hookAsk === undefined &&
575
602
  input.egress !== true &&
603
+ input.peerMessage !== true &&
576
604
  input.sandboxAdmission.boundaryCapable !== true) {
577
605
  const recorded = input.sandboxAdmission.askClassesOf(toolCallId);
578
606
  const admissible = recorded !== undefined && recorded.length > 0 && recorded.every((r) => r.cls === "sandbox_local");
@@ -655,6 +683,7 @@ export async function runToolGate(input) {
655
683
  if (decision.action === "allow" && decision.updatedInput !== undefined) {
656
684
  let editArgs = decision.updatedInput;
657
685
  let editDenied;
686
+ let editRewrittenSinceHuman = false;
658
687
  for (let round = 0;; round++) {
659
688
  if (round >= 3) {
660
689
  editDenied = {
@@ -691,8 +720,10 @@ export async function runToolGate(input) {
691
720
  denySource = "hook";
692
721
  break;
693
722
  }
694
- if (hr.updatedInput !== undefined)
723
+ if (hr.updatedInput !== undefined) {
695
724
  editArgs = hr.updatedInput;
725
+ editRewrittenSinceHuman = true;
726
+ }
696
727
  }
697
728
  }
698
729
  if (!adjudicate && input.orgRules === undefined)
@@ -714,8 +745,10 @@ export async function runToolGate(input) {
714
745
  denySource = "policy";
715
746
  break;
716
747
  }
717
- if (recheck.updatedInput !== undefined)
748
+ if (recheck.updatedInput !== undefined) {
718
749
  editArgs = recheck.updatedInput;
750
+ editRewrittenSinceHuman = true;
751
+ }
719
752
  const orgTightensBefore = orgTightenCount;
720
753
  recheck = await applyOrgLayer(recheck, editArgs);
721
754
  const orgRaisedThisRound = orgTightenCount > orgTightensBefore;
@@ -724,12 +757,16 @@ export async function runToolGate(input) {
724
757
  break;
725
758
  }
726
759
  if (recheck.action === "allow") {
727
- if (recheck.updatedInput !== undefined)
760
+ if (recheck.updatedInput !== undefined) {
728
761
  editArgs = recheck.updatedInput;
762
+ editRewrittenSinceHuman = true;
763
+ }
729
764
  break;
730
765
  }
731
- if (recheck.updatedInput !== undefined)
766
+ if (recheck.updatedInput !== undefined) {
732
767
  editArgs = recheck.updatedInput;
768
+ editRewrittenSinceHuman = true;
769
+ }
733
770
  const rr = await resolveAsk({ ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) }, { toolName, args: editArgs, toolCallId });
734
771
  resolvedApprover = rr.action !== "ask" ? rr.approver : undefined;
735
772
  if (rr.action !== "allow") {
@@ -741,9 +778,21 @@ export async function runToolGate(input) {
741
778
  if (rr.updatedInput === undefined) {
742
779
  if (rr.presentedInput !== undefined)
743
780
  editArgs = rr.presentedInput;
781
+ editRewrittenSinceHuman = false;
744
782
  break;
745
783
  }
746
784
  editArgs = rr.updatedInput;
785
+ editRewrittenSinceHuman = false;
786
+ }
787
+ if (editDenied === undefined && editRewrittenSinceHuman && input.writeProtectionCheck !== undefined) {
788
+ const editHit = input.writeProtectionCheck(toolName, editArgs);
789
+ if (editHit !== null) {
790
+ editDenied = {
791
+ action: "deny",
792
+ 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`,
793
+ };
794
+ denySource = "safety";
795
+ }
747
796
  }
748
797
  decision = editDenied ?? { ...decision, updatedInput: editArgs };
749
798
  }
@@ -13,6 +13,28 @@ export interface MailboxMessage {
13
13
  * cross-engine records sit outside the guard's promise domain by ruling. */
14
14
  hopChain?: string[];
15
15
  }
16
+ /** The enqueue refusal code of the pre-delete clause (see {@link MailboxStore} and
17
+ * {@link MailboxStoreError}) — the ONE place it is spelled, so an out-of-repo store twin imports it
18
+ * instead of value-copying the string (same posture as `STALE_RUNNING_REAP_ATTRIBUTION`: a shared
19
+ * symbol makes drift impossible rather than merely caught). */
20
+ export declare const MAILBOX_TOMBSTONED_RECIPIENT_CODE = "mailbox.recipient_tombstoned";
21
+ /**
22
+ * Typed store error so callers branch on `code` (mirrors `BackgroundAgentStoreError`).
23
+ *
24
+ * `recipient_tombstoned` is the ENQUEUE refusal a backend raises when the recipient it addresses is
25
+ * in a deployment's PRE-DELETE state — the window a retention/deletion cascade opens when it has
26
+ * decided a session (and every row hanging off it) is going away but has not finished removing the
27
+ * rows. An `append` accepted in that window returns a seq to the sender — a durable receipt — for a
28
+ * message the cascade then deletes before any consumer could ever lease it. The refusal is what
29
+ * turns that into a fact the sender can act on.
30
+ *
31
+ * A backend may raise the same code with a plain `Error` carrying `.code`; consumers branch on the
32
+ * string, not on this class (a cross-process/out-of-repo store cannot hand back an instance).
33
+ */
34
+ export declare class MailboxStoreError extends Error {
35
+ readonly code: typeof MAILBOX_TOMBSTONED_RECIPIENT_CODE;
36
+ constructor(code: typeof MAILBOX_TOMBSTONED_RECIPIENT_CODE, message: string);
37
+ }
16
38
  /** A leased batch: the messages a claim winner owns for delivery, plus the ack cursor. */
17
39
  export interface MailboxLease {
18
40
  messages: MailboxMessage[];
@@ -55,8 +77,25 @@ export interface MailboxAppendMessage {
55
77
  * is ADVISORY: a drop fault never blocks the reap, and a box orphaned that way still ages out here.
56
78
  * `drop` therefore has a real caller — an implementation that stubs it strands mailboxes until the
57
79
  * age policy catches them.
80
+ * - PRE-DELETE STATE (optional capability, additive contract extension): a backend that can SEE its
81
+ * recipient's lifecycle — one whose rows live alongside the session rows a retention/deletion
82
+ * cascade removes — must refuse `append` for a recipient the cascade has already tombstoned,
83
+ * CODED: an `Error` whose `code` is `"mailbox.recipient_tombstoned"` ({@link MailboxStoreError} is
84
+ * the bundled shape; consumers branch on the string). Accepting is the failure this clause names:
85
+ * `append` is durable-first, so the returned seq is a receipt for a message the cascade deletes
86
+ * before any consumer can lease it — a delivery promised to the sender and kept to nobody, which
87
+ * no other face reports. The refusal is per RECIPIENT and enqueue-only: a tombstone belongs to one
88
+ * `(scope, handle)` — the same two-part identity every other method here is keyed by, so it says
89
+ * nothing about a sibling handle NOR about the same handle under another scope — and it is not a
90
+ * wipe (whatever is already parked stays under the same lease/ack rules until the cascade removes
91
+ * the box). A backend with NO view of that
92
+ * lifecycle (the two bundled ones, the CC inbox adapter) has nothing to refuse and keeps accepting
93
+ * — the clause fixes the SPELLING of the refusal, so a deployment reads one code instead of a
94
+ * per-backend dialect. Acceptance kit: `mailboxTombstonedRecipientContract`.
58
95
  */
59
96
  export interface MailboxStore {
97
+ /** Durably park one message. Refuses `"mailbox.recipient_tombstoned"` when the backend can see
98
+ * that its recipient is in the deployment's pre-delete state (see the interface notes above). */
60
99
  append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>;
61
100
  claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
62
101
  ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<void>;