@sema-agent/core 5.38.0 → 5.39.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,89 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.39.0 — 2026-08-17
4
+
5
+ No BREAKING changes. Two behavioral narrowings disclosed below (write-protection default table;
6
+ peer messages join the permission chain) plus one governance-config narrowing (malformed workflow
7
+ baseline slots refuse at mount instead of silently mounting ungoverned).
8
+
9
+ ### Added
10
+
11
+ - #286 (#279) — the default-on WRITE-protection table (CC 2.1.233 DANGEROUS_* parity): a Write /
12
+ Edit / NotebookEdit whose target lands on a named row (shell/profile rc files, VCS hook and config
13
+ paths, editor/tool trust files — basename, segment and segment-run row kinds; case-folded, win32
14
+ spellings and trailing-dot/space component aliases included) is DEMOTED TO ASK at the tool gate
15
+ when it would otherwise auto-allow. **Narrowing**: in 5.38.0 these writes rode a surviving allow
16
+ silently. The tighten demotes to ask (classifier / blanket `onAsk` / persisted rules can still
17
+ clear it) and never blocks reads; ONE consumer arm denies instead — an approved edit the
18
+ restriction chain rewrites onto a table row after the human looked is refused fail-closed, since
19
+ minting a fresh ask there would hand a pathological rewriter an unbounded approval treadmill.
20
+ Whole-table replacement seat `RunnerDeps.writeProtectedPaths`
21
+ (`[]` disables; a non-empty list replaces the default table; garbage rows refuse loudly, including
22
+ spellings the candidate fold would orphan). New exports (admin face): `WRITE_PROTECTED_DEFAULT_TABLE`,
23
+ `resolveWriteProtectedTable`, `compileWriteProtection` + entry/row/kind/hit/matcher types.
24
+ - design/276 — peer messages join the permission adjudication chain: in auto mode, `SendMessage` is
25
+ routed through the classifier (peer-referral tighten, third member of the post-fold tighten
26
+ family), and the two ancestor sandbox-admission arms stop auto-allowing an inherited
27
+ `sandbox_local` ask on SendMessage. **Narrowing**: a peer message that previously rode an ambient
28
+ allow now asks; the referral ask carries no `requiresRealApproval`, so classifier / blanket
29
+ `onAsk` / persisted rules can clear it.
30
+ - Mailbox pre-delete contract half ([4194] ruling (b)): enqueue against a recipient a deletion
31
+ cascade has already tombstoned is a CODED refusal — new exports `MAILBOX_TOMBSTONED_RECIPIENT_CODE`
32
+ (`"mailbox.recipient_tombstoned"`), `MailboxStoreError`, and the `mailboxTombstonedRecipientContract`
33
+ acceptance-kit leg (optional contract clause: only backends that can see recipient lifecycle carry
34
+ it; the refusal is per-recipient and enqueue-only).
35
+ - CC settings import — the preview reports `uncovered.denyAskBuckets: { deny, ask }`: how many
36
+ deny/ask entries the import leaves in place across the layers it reads (only the allow bucket is
37
+ imported; by-design-not-imported and unnoticed no longer look identical).
38
+
39
+ ### Fixed
40
+
41
+ - Workflow governance baseline hardening (5.39 rescan window): a non-object `base` slot
42
+ (null / undefined / primitive / array — the shapes JSON/DB schema drift mints) is a CODED mount
43
+ refusal (`config.invalid_governance_baseline`) instead of silently mounting every script-spawned
44
+ child with ZERO inherited governance (5.38.0's raw spread accepted these spellings silently);
45
+ `worktreeBase: null` folds to its documented absence, any other non-object shape there refuses
46
+ with the same code. Face-list fields (`excludeTools`/`deferTools`/`alwaysLoadTools`) validate as
47
+ string arrays at mount (`config.invalid_tool_name_set`), and a null face field reads as absent in
48
+ BOTH slots (a null overlay field no longer silently remounts what the deployment excluded).
49
+ - **The win32 component alias is closed on every path face, by one uniform rule.** `.bashrc.` and
50
+ `.bashrc ` name the SAME file as `.bashrc` on the wire (the win32 file namespace strips trailing
51
+ dots and spaces from every component), and `. ` / `.. ` are that namespace's spellings of `.` and
52
+ `..`. Pre-fix, `.aws.\credentials` missed the built-in read-deny set entirely on the purely
53
+ lexical legs — the bash-classify deny probe resolves operands with zero I/O by contract, so
54
+ nothing collapsed the alias later and the read took the auto-allow lane.
55
+ Every lexical path face (the write-protection matcher, the read-deny segment engine and its
56
+ sensitive-path consumer) now judges **two views** — components verbatim, and win32-normalized —
57
+ and protects if **either** hits. A lexical matcher cannot know the target platform (execution
58
+ envs may be remote, and win32 accepts forward-slash spellings, so separator gating carries
59
+ nothing), so picking one interpretation is always wrong for the other; the two errors are not
60
+ symmetric, since a missed protection is silent while an over-match is one extra question.
61
+ **The win32 view is skipped on one reported fact**: `canonicalizeTarget`'s realpath arm now returns
62
+ `aliasResolved`, meaning the real filesystem produced that key, so any component alias in the
63
+ spelling was already collapsed by the filesystem itself — a genuinely-resolved POSIX target keeps
64
+ its own reading and a real `.aws.` directory stays readable and writable. The arms that resolve
65
+ NOTHING do not report it and keep both views: UNC keys (minted with zero probes for liveness) and
66
+ not-yet-existing targets (whose tail is rejoined verbatim) — which is where the planting vector
67
+ lives. Unreported is the fail-closed default, so a caller that forgets to pass it loses precision,
68
+ never protection.
69
+ **Residual cost**: a target the filesystem has NOT resolved still over-matches — writing a
70
+ not-yet-created file inside a legal POSIX `.aws.` directory asks (write face) or refuses
71
+ (sensitive-path policy). Trailing-dot table ROWS stay legal (they match the POSIX file of exactly
72
+ that name; on win32 that spelling *is* the stripped name, which the ordinary row already covers).
73
+ **Known gap, ticketed not fixed**: the ripgrep exclusion globs do not fold, so a Grep result can
74
+ differ by whether ripgrep is installed (#303).
75
+ - The self-orchestration gates ask the mount's question, not just "is the field there".
76
+ `workflowsCapability` tested `!== undefined` while the mount site tested truthiness, so
77
+ `workflowGovernanceBaseline: null` announced workflows with `run_workflow` unmounted and
78
+ suppressed the fail-closed operator warning; and once the mount began refusing an unusable `base`
79
+ slot, `{ base: null }` / `{}` announced workflows that then hard-failed every opted-in task. One
80
+ predicate now answers for all of them, and it reads the `base` slot — so an unusable baseline is
81
+ capability-false, inactive, and named in the operator warning instead of failing at run time.
82
+ - Internal verify/teacher leg spec inheritance stops spreading a bare-string
83
+ `excludeTools`/`deferTools`/`alwaysLoadTools` into inert single-character names — malformed shapes
84
+ ride verbatim so the leg's own prepare judges them (coded doors for the three public seats are
85
+ tracked separately).
86
+
3
87
  ## 5.38.0 — 2026-08-17
4
88
 
5
89
  No BREAKING changes. One behavioral narrowing (default task-scaffold trim on strong claude models)
@@ -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
  : {}),
@@ -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,25 @@ export async function runToolGate(input) {
398
398
  denySource = input.shellGated === true ? "shellGate" : "safety";
399
399
  }
400
400
  }
401
+ if (input.peerMessage === true && decision.action === "allow" && input.autoMode !== undefined && !input.autoMode.decider.breakerOpen()) {
402
+ decision = {
403
+ action: "ask",
404
+ message: `tool "${toolName}" sends a message to another agent — routed for classifier review in auto mode`,
405
+ decisionReason: "safety",
406
+ };
407
+ denySource = "safety";
408
+ }
409
+ if (decision.action === "allow" && input.writeProtectionCheck !== undefined) {
410
+ const writeProtectedHit = input.writeProtectionCheck(toolName, policyRewrite !== undefined ? policyRewrite : currentInput);
411
+ if (writeProtectedHit !== null) {
412
+ decision = {
413
+ action: "ask",
414
+ message: `tool "${toolName}" writes to a write-protected path (table entry "${writeProtectedHit.name}") — explicit approval required`,
415
+ decisionReason: "safety",
416
+ };
417
+ denySource = "safety";
418
+ }
419
+ }
401
420
  let orgRealApprovalRequired = false;
402
421
  let orgAskOrigin;
403
422
  let orgTightenCount = 0;
@@ -573,6 +592,7 @@ export async function runToolGate(input) {
573
592
  decision.decisionReason !== "hook" &&
574
593
  hookAsk === undefined &&
575
594
  input.egress !== true &&
595
+ input.peerMessage !== true &&
576
596
  input.sandboxAdmission.boundaryCapable !== true) {
577
597
  const recorded = input.sandboxAdmission.askClassesOf(toolCallId);
578
598
  const admissible = recorded !== undefined && recorded.length > 0 && recorded.every((r) => r.cls === "sandbox_local");
@@ -655,6 +675,7 @@ export async function runToolGate(input) {
655
675
  if (decision.action === "allow" && decision.updatedInput !== undefined) {
656
676
  let editArgs = decision.updatedInput;
657
677
  let editDenied;
678
+ let editRewrittenSinceHuman = false;
658
679
  for (let round = 0;; round++) {
659
680
  if (round >= 3) {
660
681
  editDenied = {
@@ -691,8 +712,10 @@ export async function runToolGate(input) {
691
712
  denySource = "hook";
692
713
  break;
693
714
  }
694
- if (hr.updatedInput !== undefined)
715
+ if (hr.updatedInput !== undefined) {
695
716
  editArgs = hr.updatedInput;
717
+ editRewrittenSinceHuman = true;
718
+ }
696
719
  }
697
720
  }
698
721
  if (!adjudicate && input.orgRules === undefined)
@@ -714,8 +737,10 @@ export async function runToolGate(input) {
714
737
  denySource = "policy";
715
738
  break;
716
739
  }
717
- if (recheck.updatedInput !== undefined)
740
+ if (recheck.updatedInput !== undefined) {
718
741
  editArgs = recheck.updatedInput;
742
+ editRewrittenSinceHuman = true;
743
+ }
719
744
  const orgTightensBefore = orgTightenCount;
720
745
  recheck = await applyOrgLayer(recheck, editArgs);
721
746
  const orgRaisedThisRound = orgTightenCount > orgTightensBefore;
@@ -724,12 +749,16 @@ export async function runToolGate(input) {
724
749
  break;
725
750
  }
726
751
  if (recheck.action === "allow") {
727
- if (recheck.updatedInput !== undefined)
752
+ if (recheck.updatedInput !== undefined) {
728
753
  editArgs = recheck.updatedInput;
754
+ editRewrittenSinceHuman = true;
755
+ }
729
756
  break;
730
757
  }
731
- if (recheck.updatedInput !== undefined)
758
+ if (recheck.updatedInput !== undefined) {
732
759
  editArgs = recheck.updatedInput;
760
+ editRewrittenSinceHuman = true;
761
+ }
733
762
  const rr = await resolveAsk({ ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) }, { toolName, args: editArgs, toolCallId });
734
763
  resolvedApprover = rr.action !== "ask" ? rr.approver : undefined;
735
764
  if (rr.action !== "allow") {
@@ -741,9 +770,21 @@ export async function runToolGate(input) {
741
770
  if (rr.updatedInput === undefined) {
742
771
  if (rr.presentedInput !== undefined)
743
772
  editArgs = rr.presentedInput;
773
+ editRewrittenSinceHuman = false;
744
774
  break;
745
775
  }
746
776
  editArgs = rr.updatedInput;
777
+ editRewrittenSinceHuman = false;
778
+ }
779
+ if (editDenied === undefined && editRewrittenSinceHuman && input.writeProtectionCheck !== undefined) {
780
+ const editHit = input.writeProtectionCheck(toolName, editArgs);
781
+ if (editHit !== null) {
782
+ editDenied = {
783
+ action: "deny",
784
+ 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`,
785
+ };
786
+ denySource = "safety";
787
+ }
747
788
  }
748
789
  decision = editDenied ?? { ...decision, updatedInput: editArgs };
749
790
  }
@@ -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>;
@@ -1,4 +1,13 @@
1
1
  import { assertRetentionPolicy } from "./retention-policy.js";
2
+ export const MAILBOX_TOMBSTONED_RECIPIENT_CODE = "mailbox.recipient_tombstoned";
3
+ export class MailboxStoreError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = "MailboxStoreError";
9
+ }
10
+ }
2
11
  export function newestSentAt(messages) {
3
12
  let newest;
4
13
  for (const m of messages) {
@@ -285,9 +285,9 @@ export interface CcImportLayer {
285
285
  /**
286
286
  * What the import WOULD do, shown before anyone confirms.
287
287
  *
288
- * `uncovered` is a two-key record, not a list: the two layers this version does not read are named in the
288
+ * `uncovered` is a fixed-key record, not a list: everything this version does not import is named in the
289
289
  * TYPE, so an empty list, a missing member or a duplicate is not expressible. A preview that reported only
290
- * the layers it read would be claiming completeness it does not have.
290
+ * what it read would be claiming completeness it does not have.
291
291
  */
292
292
  export interface ImportPreview {
293
293
  candidates: RuleCandidate[];
@@ -303,6 +303,26 @@ export interface ImportPreview {
303
303
  uncovered: {
304
304
  flagSettings: "not-imported-v1";
305
305
  policySettings: "not-imported-v1";
306
+ /**
307
+ * The deny/ask buckets of the layers actually read: how many entries are SITTING THERE, across all
308
+ * layers, that this import deliberately leaves in place (they are the tightening direction and have
309
+ * their own channel — importing them through a loosening lane would be the wrong door).
310
+ *
311
+ * Always present, zero when the buckets are absent or empty: "this settings file has 14 deny entries
312
+ * we did not touch" and "there was nothing there" are different facts, and a key that appears only in
313
+ * the first case makes them indistinguishable for a reader who sees one preview. The count is of
314
+ * ENTRIES IN PLACE, not of a decision — nothing here is skipped-and-lost; `skipped` stays the list of
315
+ * allow-bucket entries that did not become candidates.
316
+ *
317
+ * A bucket that is PRESENT but not an array cannot be counted; it contributes 0 and is disclosed in
318
+ * `skipped` under the layer's path, so a zero is never the report for something unreadable. Same for
319
+ * a layer whose whole `permissions` member is not an object — every bucket in it reads as absent,
320
+ * which is exactly the shape that would otherwise report two clean zeros.
321
+ */
322
+ denyAskBuckets: {
323
+ deny: number;
324
+ ask: number;
325
+ };
306
326
  };
307
327
  }
308
328
  /** What the import ACTUALLY did — a different moment and a different contract from the preview, because
@@ -320,8 +340,11 @@ export interface ImportResult {
320
340
  * Read the allow buckets of the user-editable settings layers and produce a preview plus a PENDING
321
341
  * approval record. Nothing is stored until someone confirms that record and the batch is redeemed.
322
342
  *
323
- * Only the allow bucket is read. The deny/ask buckets are the tightening direction and have their own
324
- * channel; importing them through a loosening lane would be the wrong door.
343
+ * Only the allow bucket is IMPORTED. The deny/ask buckets are the tightening direction and have their own
344
+ * channel; importing them through a loosening lane would be the wrong door. They are still COUNTED and
345
+ * reported (`preview.uncovered.denyAskBuckets`): by-design-not-imported and unnoticed look identical to
346
+ * the person confirming the batch, and only one of them is true here — the same reason the non-Bash allow
347
+ * entries are disclosed rather than dropped silently.
325
348
  */
326
349
  export declare function prepareCcImport(opts: {
327
350
  layers: CcImportLayer[];
@@ -337,12 +337,13 @@ async function applyRedemption(args) {
337
337
  function originOfRecordKind(kind) {
338
338
  return kind === "import" ? "imported-cc" : kind === "starter" ? "starter" : "user";
339
339
  }
340
- const IMPORT_UNCOVERED = { flagSettings: "not-imported-v1", policySettings: "not-imported-v1" };
340
+ const IMPORT_UNCOVERED_LAYERS = { flagSettings: "not-imported-v1", policySettings: "not-imported-v1" };
341
341
  export async function prepareCcImport(opts) {
342
342
  const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareCcImport");
343
343
  const candidates = [];
344
344
  const skipped = [];
345
345
  const layers = [];
346
+ const denyAskBuckets = { deny: 0, ask: 0 };
346
347
  for (const layer of opts.layers) {
347
348
  let raw;
348
349
  try {
@@ -356,14 +357,38 @@ export async function prepareCcImport(opts) {
356
357
  continue;
357
358
  }
358
359
  layers.push({ path: layer.path, layer: layer.layer, found: true });
359
- let allow;
360
+ let container;
360
361
  try {
361
- allow = JSON.parse(raw)?.permissions?.allow;
362
+ container = JSON.parse(raw)?.permissions;
362
363
  }
363
364
  catch (err) {
364
365
  skipped.push({ rule: layer.path, reason: `settings file is not valid JSON (${errText(err)})` });
365
366
  continue;
366
367
  }
368
+ let permissions;
369
+ if (container === undefined || container === null)
370
+ permissions = undefined;
371
+ else if (typeof container === "object" && !Array.isArray(container))
372
+ permissions = container;
373
+ else {
374
+ skipped.push({
375
+ rule: layer.path,
376
+ reason: `settings "permissions" is not an object — no bucket in this layer could be read, so it is reported as unread rather than as empty (it stays in the settings file either way)`,
377
+ });
378
+ continue;
379
+ }
380
+ for (const bucket of ["deny", "ask"]) {
381
+ const entries = permissions?.[bucket];
382
+ if (Array.isArray(entries))
383
+ denyAskBuckets[bucket] += entries.length;
384
+ else if (entries !== undefined && entries !== null) {
385
+ skipped.push({
386
+ rule: layer.path,
387
+ reason: `settings ${bucket} bucket is not an array — its entries could not be counted for the not-imported report (it stays in the settings file either way)`,
388
+ });
389
+ }
390
+ }
391
+ const allow = permissions?.allow;
367
392
  if (!Array.isArray(allow))
368
393
  continue;
369
394
  const scope = layer.layer === "userSettings" ? { kind: "global" } : { kind: "project", root: layer.root };
@@ -401,7 +426,7 @@ export async function prepareCcImport(opts) {
401
426
  createdAt: nowIso(opts.deps),
402
427
  };
403
428
  await opts.deps.approvals.create(record);
404
- return { preview: { candidates, skipped, layers, uncovered: IMPORT_UNCOVERED }, approvalId: record.id };
429
+ return { preview: { candidates, skipped, layers, uncovered: { ...IMPORT_UNCOVERED_LAYERS, denyAskBuckets } }, approvalId: record.id };
405
430
  }
406
431
  export const STARTER_RULES = [
407
432
  "Bash(ls)",
@@ -29,7 +29,13 @@
29
29
  * here) and no leading-env-assignment stripping (`FOO=1 git status` simply does not match
30
30
  * `Bash(git status:*)`) — the last two are strict-side divergences from upstream, registered as such.
31
31
  */
32
- /** The one tool the v1 rule lane speaks for. The field exists on the rule so v2 can widen without a shape change. */
32
+ /** The one tool the v1 rule lane speaks for. The field exists on the rule so v2 can widen without a shape change.
33
+ *
34
+ * design/276 §3.3 (doctrine, for whoever widens this set): a future `SendMessage(…)`-form rule MAY
35
+ * clear the peer-referral ask — that ask is classifier hesitation by construction (#144: allow rules
36
+ * silence the classifier's questions, never a mandated one), so a recorded human yes is exactly what
37
+ * clears it. The org/hook/matchedAskRule immunities stay: those conjuncts live in the gate's
38
+ * persisted-rule lane and do not loosen with this set. */
33
39
  export type PersistedRuleTool = "Bash";
34
40
  /** v1 match forms. `"wildcard"` is reserved for v2 and is not a value this version ever produces. */
35
41
  export type PersistedRuleMatch = "exact" | "prefix";
@@ -28,8 +28,9 @@ import { CHANGED_FILES_MTIME_EPS_MS, fenceMcpServerInstructions, renderAgentList
28
28
  import { inlineUntrusted } from "../untrusted-text.js";
29
29
  import { policyAskClassOf } from "../ask-class.js";
30
30
  import { emitTrace } from "../trace.js";
31
- import { createSessionRulePolicy } from "./session-rule-policy.js";
31
+ import { createSessionRulePolicy, PATH_CONFINABLE_WRITE_TOOLS } from "./session-rule-policy.js";
32
32
  import { cloneObserverInput, createHookEnvCapabilities, createPreToolUseConstraintPolicy, formatHookFeedback, mintHookInvocationIdentity, persistedRuleMandateOf, runToolGate } from "../hooks.js";
33
+ import { createWriteProtectionCheck } from "../write-protect.js";
33
34
  import { orgRuleVerdictFor } from "../permission-rule-org.js";
34
35
  import { CacheBreakDetector, toolsToFingerprintInputs } from "../cache-break-detector.js";
35
36
  import { reservedCollisions, reservedFor } from "../../brain/request-params.js";
@@ -1413,6 +1414,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1413
1414
  toolEffects.set("TaskStop", "write");
1414
1415
  tools.push(firstPartyOffload(createTaskOutputTool({ registry: defaultTaskRegistry, owner: hostTaskId, scope: taskScope, sessionId, workflowStore: deps.workflowRunStore, agentStore: deps.backgroundAgentStore, notificationWired: internals?.onTaskNotification !== undefined, oneShot: spec.oneShot, toolResultStore: offloadStore })), firstPartyOffload(createTaskStopTool({ registry: defaultTaskRegistry, owner: hostTaskId, scope: taskScope, sessionId, workflowStore: deps.workflowRunStore, agentStore: deps.backgroundAgentStore })));
1415
1416
  if (runnerSelf && !(spec.tools ?? []).some((t) => t.name === SEND_MESSAGE_TOOL_NAME)) {
1417
+ toolEffects.set(SEND_MESSAGE_TOOL_NAME, "write");
1418
+ axisExplicitNegatives.set(SEND_MESSAGE_TOOL_NAME, { ...axisExplicitNegatives.get(SEND_MESSAGE_TOOL_NAME), egress: false });
1416
1419
  const delegationForRevive = (spec.tools ?? []).find((t) => t.agentListing !== undefined);
1417
1420
  const reviveSpawn = delegationForRevive !== undefined && deps.backgroundAgentStore !== undefined && deps.mailboxStore !== undefined
1418
1421
  ? async (req) => {
@@ -2813,7 +2816,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2813
2816
  list.push({ cls, layer });
2814
2817
  foldAskClasses.set(toolCallId, list);
2815
2818
  };
2816
- const sandboxBoundaryCapable = (toolName) => egressTools.has(toolName) || toolName.includes("__") || ownToolNames.has(toolName);
2819
+ const sandboxBoundaryCapable = (toolName) => egressTools.has(toolName) || toolName.includes("__") || ownToolNames.has(toolName) || toolName === SEND_MESSAGE_TOOL_NAME;
2817
2820
  const emitSandboxAdmitted = (info) => {
2818
2821
  emitTrace(deps.tracer, () => ({
2819
2822
  kind: "permission.sandbox_admitted",
@@ -4220,13 +4223,16 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4220
4223
  if (eff !== undefined && !toolEffects.has(t.name))
4221
4224
  toolEffects.set(t.name, eff);
4222
4225
  }
4226
+ const writeProtectionCheck = createWriteProtectionCheck(deps.writeProtectedPaths);
4227
+ const writeProtectionArmed = writeProtectionCheck !== undefined && tools.some((t) => PATH_CONFINABLE_WRITE_TOOLS.has(t.name));
4223
4228
  toolCallGateArmedRef.armed =
4224
4229
  effectivePolicy !== undefined ||
4225
4230
  hooks?.preToolUse !== undefined ||
4226
4231
  egressTools.size > 0 ||
4227
4232
  irreversibleTools.size > 0 ||
4228
4233
  spec.enablePlanMode === true ||
4229
- complianceDenies.has("web_fetch");
4234
+ complianceDenies.has("web_fetch") ||
4235
+ writeProtectionArmed;
4230
4236
  if (toolCallGateArmedRef.armed) {
4231
4237
  harness.on("tool_call", async (e) => {
4232
4238
  blockedToolCalls.delete(e.toolCallId);
@@ -4269,6 +4275,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4269
4275
  suspendAsk,
4270
4276
  resolveContentAsk,
4271
4277
  egress: egressTools.has(e.toolName),
4278
+ peerMessage: e.toolName === SEND_MESSAGE_TOOL_NAME,
4279
+ ...(writeProtectionCheck !== undefined ? { writeProtectionCheck } : {}),
4272
4280
  irreversibility: irreversibilityTier.get(e.toolName),
4273
4281
  reversibilityProbe: reversibilityProbes.get(e.toolName),
4274
4282
  abortSignal: abortController.signal,
@@ -39,8 +39,8 @@ function compilePatterns(patterns) {
39
39
  }
40
40
  return out;
41
41
  }
42
- function matchSensitive(canonicalKey, compiled) {
43
- return matchSegmentPatterns(canonicalKey, compiled);
42
+ function matchSensitive(canonicalKey, compiled, aliasResolved) {
43
+ return matchSegmentPatterns(canonicalKey, compiled, { aliasResolved });
44
44
  }
45
45
  export function createSensitivePathPolicy(opts) {
46
46
  const compiled = compilePatterns(opts.patterns);
@@ -64,7 +64,7 @@ export function createSensitivePathPolicy(opts) {
64
64
  }
65
65
  return { action: "allow" };
66
66
  }
67
- const hit = matchSensitive(canon.key, compiled);
67
+ const hit = matchSensitive(canon.key, compiled, canon.aliasResolved === true);
68
68
  if (hit) {
69
69
  return {
70
70
  action: "deny",
@@ -1,4 +1,4 @@
1
- import type { MailboxStore } from "../mailbox-store.js";
1
+ import { type MailboxStore } from "../mailbox-store.js";
2
2
  import { type ContractAssertionRunner } from "./contract-harness.js";
3
3
  /**
4
4
  * design/159 S1 — the cross-backend {@link MailboxStore} contract, extracted VERBATIM from
@@ -46,3 +46,31 @@ export declare function mailboxAckOwnershipContract(mk: () => MailboxStore, runA
46
46
  * 放宽」:核心层没有一条为了任何后端而弱化。第三方后端若有租户轴,应当也挂这一层。
47
47
  */
48
48
  export declare function mailboxBundledOnlyContract(mk: () => MailboxStore, runAssertion?: ContractAssertionRunner): Promise<void>;
49
+ export interface MailboxTombstonedRecipientContractHooks {
50
+ /** Put `(scope, handle)` into the deployment's PRE-DELETE state — whatever that is for this
51
+ * backend (a cascade marking the session row, a `deleting` column, a tombstone table). Core does
52
+ * not name the mechanism, only what `append` must then do. */
53
+ tombstone: (store: MailboxStore, scope: string, handle: string) => Promise<void>;
54
+ runAssertion?: ContractAssertionRunner;
55
+ }
56
+ /**
57
+ * T2 可选能力层(pre-delete 分册)—— 只挂**看得见收件人生命周期**的后端:它的 mailbox 行与某个
58
+ * 删除级联要清掉的 session 行同生共死(server 的留存删除级联是首个此形消费方)。三个捆绑后端都
59
+ * 没有这个视角,因此都不挂——**这不是「做不到所以放宽」**:没有预删除态的后端根本没有可拒的对象,
60
+ * 而有这个态却静默接受的后端,拿到的 seq 是一张级联马上就要撕掉的回执(`append` 是 durable-first,
61
+ * 回执即承诺),且没有任何其它面会说出这件事。
62
+ *
63
+ * 契约不规定态怎么置(那是部署自己的删除协议),只钉「拒」这件事的三条轴 —— **三条一起列在这里,
64
+ * 是因为逐条补的过程本身证明了单看一条会假绿**(每加一条,都有一个能过掉前面全部用例的错误实现被
65
+ * 抓出来):
66
+ * 1. **拒因轴**:必须拒,且每条路径上都带同一个码(空箱与非空箱都算路径);
67
+ * 2. **副作用轴**:被拒的 append 对箱零改动 —— 不入箱(幽灵投递)、不清箱(删除窗口内静默丢)、
68
+ * 也不动活租约(动了就撬开 X-1 的单消费者围栏,同一批消息会被二次投递);
69
+ * 3. **收件人边界轴**:态属于一个 `(scope, handle)` 整体,且这个整体是**单射**的 —— 兄弟 handle
70
+ * 不受牵连、跨租户同名不受牵连、拼键歧义不得把两个收件人折成一个。
71
+ * 新增用例请按这三条轴归位;要加第四条轴,先说清它是哪一类假绿。
72
+ *
73
+ * 受众前提与 {@link mailboxBundledOnlyContract} 同线:后端有自己的租户轴(生命周期可见的后端就是
74
+ * 服务端形态的 store),边界轴的两条用例要用两个 scope 构造。
75
+ */
76
+ export declare function mailboxTombstonedRecipientContract(mk: () => MailboxStore, hooks: MailboxTombstonedRecipientContractHooks): Promise<void>;