@sema-agent/core 5.37.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +151 -0
  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/subagent.js +6 -0
  5. package/dist/agents/teacher.js +12 -3
  6. package/dist/agents/team.d.ts +7 -1
  7. package/dist/agents/team.js +11 -9
  8. package/dist/agents/verify.js +12 -3
  9. package/dist/core/auto-mode-prompt-assets.d.ts +5 -3
  10. package/dist/core/auto-mode-prompt-assets.js +1 -1
  11. package/dist/core/checkpoint-store.d.ts +26 -1
  12. package/dist/core/hooks.d.ts +152 -2
  13. package/dist/core/hooks.js +65 -7
  14. package/dist/core/mailbox-store.d.ts +39 -0
  15. package/dist/core/mailbox-store.js +9 -0
  16. package/dist/core/permission-rule-consent.d.ts +27 -4
  17. package/dist/core/permission-rule-consent.js +29 -4
  18. package/dist/core/permission-rule-model.d.ts +7 -1
  19. package/dist/core/runner/prepare-config-doors.d.ts +17 -0
  20. package/dist/core/runner/prepare-config-doors.js +33 -2
  21. package/dist/core/runner/prepare-task.d.ts +17 -2
  22. package/dist/core/runner/prepare-task.js +135 -44
  23. package/dist/core/runner/runtask.js +46 -11
  24. package/dist/core/sensitive-path-policy.js +3 -3
  25. package/dist/core/store-contracts/mailbox-store-contract.d.ts +29 -1
  26. package/dist/core/store-contracts/mailbox-store-contract.js +78 -0
  27. package/dist/core/tool-model-gate.d.ts +125 -0
  28. package/dist/core/tool-model-gate.js +303 -0
  29. package/dist/core/tool-policy.d.ts +1 -1
  30. package/dist/core/types.d.ts +210 -1
  31. package/dist/core/types.js +21 -0
  32. package/dist/core/untrusted-text.d.ts +1 -1
  33. package/dist/core/write-protect.d.ts +93 -0
  34. package/dist/core/write-protect.js +194 -0
  35. package/dist/index.d.ts +7 -5
  36. package/dist/index.js +5 -3
  37. package/dist/orchestration/builtin-workflows.d.ts +68 -6
  38. package/dist/orchestration/builtin-workflows.js +26 -9
  39. package/dist/orchestration/governance-baseline-validity.d.ts +44 -0
  40. package/dist/orchestration/governance-baseline-validity.js +55 -0
  41. package/dist/orchestration/run-workflow-tool.d.ts +10 -1
  42. package/dist/orchestration/run-workflow-tool.js +99 -31
  43. package/dist/orchestration/workflow-script-runner.js +9 -4
  44. package/dist/orchestration/workflow-script-store.d.ts +8 -3
  45. package/dist/prompts/coordinator.d.ts +4 -1
  46. package/dist/prompts/coordinator.js +8 -0
  47. package/dist/prompts/default.d.ts +14 -4
  48. package/dist/prompts/default.js +2 -1
  49. package/dist/scenarios/full-body.d.ts +5 -0
  50. package/dist/scenarios/full-body.js +8 -4
  51. package/dist/tools/fs/fs-shared.d.ts +3 -2
  52. package/dist/tools/fs/fs-shared.js +19 -9
  53. package/dist/tools/fs/read-deny.d.ts +15 -5
  54. package/dist/tools/fs/read-deny.js +33 -12
  55. package/dist/tools/fs/safety.d.ts +4 -1
  56. package/dist/tools/fs/safety.js +4 -2
  57. package/package.json +1 -1
  58. package/test/export-surface.snapshot.json +24 -1
@@ -2,6 +2,7 @@ import type { DocumentContent, ImageContent, TextContent } from "../internal/llm
2
2
  import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
3
3
  import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  import { type AskClass } from "./ask-class.js";
5
+ import type { WiringLegKind } from "./wiring-manifest.js";
5
6
  /**
6
7
  * In-process hook seam (design/37) — a provider-agnostic interception layer modeled on CC's hooks,
7
8
  * kept process-internal (no shell/HTTP executors, no settings files). The original tool-call trio:
@@ -67,7 +68,10 @@ export interface Hooks {
67
68
  */
68
69
  preToolUseObservational?: true;
69
70
  postToolUse?(toolName: string, input: unknown, output: HookToolOutput, ctx: HookToolContext): PostToolUseResult | undefined | Promise<PostToolUseResult | undefined>;
70
- userPromptSubmit?(prompt: string): UserPromptSubmitResult | undefined | Promise<UserPromptSubmitResult | undefined>;
71
+ userPromptSubmit?(prompt: string,
72
+ /** #281 件A (additive): the run/leg identity envelope — an implementation declaring only
73
+ * `(prompt)` keeps working. Always supplied on the engine's emission. */
74
+ ctx?: UserPromptSubmitContext): UserPromptSubmitResult | undefined | Promise<UserPromptSubmitResult | undefined>;
71
75
  /**
72
76
  * roadmap #5 (CC 198 Stop hook, :473831-): runs when the agent WOULD OTHERWISE END its run (no
73
77
  * more tool calls, steering and follow-up queues dry). Return `{ block: reason }` to PUSH BACK —
@@ -106,7 +110,11 @@ export interface Hooks {
106
110
  */
107
111
  postToolBatch?(batch: PostToolBatchCall[], meta?: {
108
112
  injectedThisTurn: "final_verification" | "finalize";
109
- }): PostToolBatchResult | undefined | Promise<PostToolBatchResult | undefined>;
113
+ },
114
+ /** #281 件A (additive): the run/leg identity envelope, on its OWN parameter — `meta`'s presence
115
+ * is an existing signal ("the engine injected at this boundary") that must not become
116
+ * always-true just because identity rides along. Always supplied on the engine's emission. */
117
+ ctx?: PostToolBatchContext): PostToolBatchResult | undefined | Promise<PostToolBatchResult | undefined>;
110
118
  /**
111
119
  * design/134 (CC PreCompact parity): runs before each compaction, AFTER the trigger gate and a
112
120
  * valid cut point are confirmed (so every preCompact corresponds to a compaction that would
@@ -177,6 +185,10 @@ export interface Hooks {
177
185
  export type PermissionDeniedSource = "policy" | "hook" | "safety" | "shellGate" | "planMode" | "classifier" | "org";
178
186
  /** The payload a {@link Hooks.permissionDenied} callback observes (CC-exact fields + `source`). */
179
187
  export interface PermissionDeniedPayload {
188
+ /** #281 件A — the run/leg identity envelope. Present on every ENGINE emission (the gate's single
189
+ * deny exit, the hook-crash intercept, the plan-mode and compliance denies); absent only when a
190
+ * host drives {@link runToolGate} without `ToolGateInput.identity`. */
191
+ identity?: HookInvocationIdentity;
180
192
  toolName: string;
181
193
  /** The FINAL (post-hook-rewrite / post-policy-rewrite) args the chain adjudicated — what would have
182
194
  * executed; not necessarily the model's original args. */
@@ -198,6 +210,9 @@ export interface PermissionDeniedPayload {
198
210
  export declare function cloneObserverInput(input: unknown): unknown;
199
211
  /** Context for {@link Hooks.stopFailure} — aligned with the TaskResult error face (observe-only). */
200
212
  export interface StopFailureContext {
213
+ /** #281 件A — the run/leg identity envelope (always present on the engine's emission; the seat is
214
+ * optional only because the envelope's whole contract is). See {@link HookInvocationIdentity}. */
215
+ identity?: HookInvocationIdentity;
201
216
  /** Human-readable error message (the assembled `errorMessage`; any `[code]` prefix already stripped). */
202
217
  error: string;
203
218
  /** Machine-readable kind — the assembled `errorCode` lifted from the brain's `[code]` prefix
@@ -242,6 +257,10 @@ export interface PostToolBatchResult {
242
257
  }
243
258
  /** Context for {@link Hooks.preCompact} (design/134). */
244
259
  export interface PreCompactContext {
260
+ /** #281 件A — the run/leg identity envelope. Present when the Runner drives the compaction (its
261
+ * wrapper injects it); honestly absent when a deployment drives the compaction module directly
262
+ * (presence law arm 3 on {@link HookInvocationIdentity}). */
263
+ identity?: HookInvocationIdentity;
245
264
  /** "auto" = threshold-triggered; "manual" = /compact; "forced" = promptTooLong recovery or
246
265
  * trim-pressure propagation (block is ignored on forced — the compaction is not optional). */
247
266
  trigger: "auto" | "manual" | "forced";
@@ -260,6 +279,8 @@ export interface PreCompactResult {
260
279
  }
261
280
  /** Context for {@link Hooks.postCompact} (design/134, observe-only). */
262
281
  export interface PostCompactContext {
282
+ /** #281 件A — same presence law as {@link PreCompactContext.identity}. */
283
+ identity?: HookInvocationIdentity;
263
284
  trigger: "auto" | "manual" | "forced";
264
285
  /** The conversation summary the compaction produced (CC `compact_summary` parity). */
265
286
  summary: string;
@@ -268,6 +289,11 @@ export interface PostCompactContext {
268
289
  }
269
290
  /** Context for the {@link Hooks.stop} hook (CC `stop_hook_active` parity). */
270
291
  export interface StopHookContext {
292
+ /** #281 件A — the run/leg identity envelope. The load-bearing member for THIS seat: a process-level
293
+ * `deps.hooks.stop` fires in root, delegated-child and resume legs alike, and previously could not
294
+ * tell which run it was being asked to end (matrix row 10/13 residual). Always present on the
295
+ * engine's stop-gate emission. */
296
+ identity?: HookInvocationIdentity;
271
297
  /** True when this run is already continuing because a previous stop() blocked — check it and
272
298
  * return success (undefined) once your condition can't be improved, or you will loop to the cap. */
273
299
  stopHookActive: boolean;
@@ -394,10 +420,94 @@ export interface HookEnvCapabilities {
394
420
  * {@link createCwdReader} carries the one live read under the same rules.
395
421
  */
396
422
  export declare function createHookEnvCapabilities(env: ExecutionEnv): HookEnvCapabilities;
423
+ /**
424
+ * #281 件A — the UNIFIED IDENTITY ENVELOPE every hook invocation can carry (`ctx.identity`): which
425
+ * run, which leg, and where in the delegation tree the hook is firing. The CC analog is
426
+ * `createBaseHookInput`'s per-event base fields; the members here are sema's OWN identity facts
427
+ * (session/leg/delegation axes), not a transcription of CC's field names.
428
+ *
429
+ * WHY: `deps.hooks` is PROCESS-level — one `stop`/`preToolUse`/`permissionDenied` seat serves the
430
+ * root run, every delegated child and every resume leg at once, and before this envelope none of
431
+ * those invocations could say which leg they belonged to. Additive: every seat is
432
+ * `identity?: HookInvocationIdentity`, so a consumer that never reads it sees nothing new.
433
+ *
434
+ * PRESENCE LAW (which invocations carry it — stated here once, referenced per seat):
435
+ * · PRESENT on every ENGINE-driven invocation of every hook seat: the tool gate's PreToolUse
436
+ * screenings (phase 1 + the approval-edit re-screen), postToolUse / postToolUseFailure,
437
+ * postToolBatch, userPromptSubmit, stop, stopFailure, preCompact / postCompact (the Runner's
438
+ * wrapper injects it), and every engine `permissionDenied` emission (gate deny exit, hook-crash
439
+ * intercept, plan-mode deny, compliance deny). The engine mints ONE frozen envelope per prepared
440
+ * leg ({@link mintHookInvocationIdentity} — single mint home) and every station reuses it.
441
+ * · HONESTLY ABSENT (no fabrication) at exactly four places, each structurally unable to know:
442
+ * 1. a HOST driving the exported {@link runToolGate} directly without supplying
443
+ * `ToolGateInput.identity` — the engine cannot know a leg it is not running;
444
+ * 2. an inherited PreToolUse screening face riding a DESCENDANT's constraint fold
445
+ * ({@link createPreToolUseConstraintPolicy}) — the fold consumes it as a `ToolPolicy`, whose
446
+ * request shape carries no leg identity, and stamping the INSTALL-site leg would name the
447
+ * wrong run;
448
+ * 3. a deployment driving the compaction module directly with its own `preCompact`/`postCompact`
449
+ * options — those contexts are built below the runner, which is where the envelope lives;
450
+ * 4. the PRE-CAS durable-resume observation of a declared-observational PreToolUse face
451
+ * (resumeStream's edited-args leg) — it runs BEFORE the resume leg prepares, so the leg's
452
+ * envelope does not exist yet, and minting a partial one off the checkpoint would guess at
453
+ * the delegation axes (the row persists `isDelegatedChild` alone; `insideFork`/`agentName`/
454
+ * `parentToolCallId` are internals-only, and a wrong guess is worse than an honest
455
+ * absence). The SAME face is re-consulted with full identity by the resumed leg's own gate.
456
+ *
457
+ * CONSUMER NOTE — the minted object is FROZEN and carries a NULL PROTOTYPE (same rule and same
458
+ * trade as {@link HookEnvCapabilities}: absence must be a fact about this object, not about what
459
+ * `Object.prototype` happens to hold). Read members directly (`identity.agentName`), test presence
460
+ * with `in` or `!== undefined`; do NOT call inherited methods on it — `identity.hasOwnProperty(...)`
461
+ * or implicit string coercion would throw, exactly as on the env capability face.
462
+ */
463
+ export interface HookInvocationIdentity {
464
+ /** The run's session id (uuid domain) — the same key `TaskResult.sessionId` reports. */
465
+ readonly sessionId: string;
466
+ /** The run's unified task id (`spec.taskId ?? sessionId` — the value TaskEvent's `sourceTaskId`
467
+ * and `task_progress` frames key on). Equal to {@link sessionId} when no task id was declared. */
468
+ readonly taskId: string;
469
+ /** Which prepared leg this invocation belongs to — the wiring manifest's own `leg.kind`
470
+ * derivation, verbatim ({@link WiringLegKind}: `"root" | "child" | "resume"`). NOTE the fork
471
+ * lane's legs read `"child"` here like any other delegation; {@link insideFork} is the
472
+ * fork-lane discriminator. */
473
+ readonly legKind: WiringLegKind;
474
+ /** RB-204 delegation fact, verbatim: `true` iff this leg runs as a delegated child (every core
475
+ * spawn lane — sync/steer/background/fork/workflow — sets it; a top-level run reads `false`). */
476
+ readonly isDelegatedChild: boolean;
477
+ /** Present (`true`) iff this leg IS a forked child (`Agent(subagent_type:"fork")` — the
478
+ * design/110 trusted internals fact). Absent everywhere else. */
479
+ readonly insideFork?: true;
480
+ /** The delegated child's display name, when one was threaded at spawn (taskName / agent-type).
481
+ * UNTRUSTED display text (model-chosen) — already single-line-bounded at mint (same
482
+ * `inlineUntrusted` treatment as the `task_progress` name lane); never an identity key. */
483
+ readonly agentName?: string;
484
+ /** The spawning Agent tool call's own id, when this leg runs under one — the delegation-chain
485
+ * position anchor ({@link import("./types.js").TaskEvent}'s `parentToolCallId` twin). Absent on
486
+ * a top-level run and on a directly-started workflow child (no launching tool call — no id is
487
+ * fabricated). */
488
+ readonly parentToolCallId?: string;
489
+ }
490
+ /**
491
+ * #281 件A — the ONE construction home of {@link HookInvocationIdentity} (domain-lexicon: one mint
492
+ * site; the runner calls it once per prepared leg and every hook station reuses the same object).
493
+ * Frozen AND null-prototype: the envelope is handed to arbitrarily many deployment callbacks, and a
494
+ * hook mutating `ctx.identity` must not rewrite what a later hook (or the delegation-lifecycle
495
+ * observer) reads. The null prototype is the {@link createHookEnvCapabilities} rule applied to the
496
+ * other absence-signaling face this module mints — "absent member" must be a fact about THIS object,
497
+ * and a frozen object with an ordinary prototype still answers `insideFork`/`agentName` reads (and
498
+ * `in` probes) from `Object.prototype`, so a prototype write elsewhere in the process could hand
499
+ * every leg a delegation axis the mint never stamped (codex r1). `agentName` is sanitized here — it
500
+ * is the only member whose value a model influences.
501
+ */
502
+ export declare function mintHookInvocationIdentity(facts: HookInvocationIdentity): HookInvocationIdentity;
397
503
  /** Identifying context passed to tool hooks. */
398
504
  export interface HookToolContext {
399
505
  toolCallId: string;
400
506
  toolName: string;
507
+ /** #281 件A — the run/leg identity envelope; see {@link HookInvocationIdentity} for the presence
508
+ * law (present on engine-driven gates; absent on a host-driven gate without
509
+ * `ToolGateInput.identity` and on the delegation-fold twin). */
510
+ identity?: HookInvocationIdentity;
401
511
  /**
402
512
  * The read-only path-resolution face over the env the hands run against (see
403
513
  * {@link HookEnvCapabilities} for the why, and for why it is capabilities rather than the env object).
@@ -450,6 +560,18 @@ export interface PostToolUseResult {
450
560
  /** Appended to the result as a `<system-reminder>` the model can read. */
451
561
  additionalContext?: string;
452
562
  }
563
+ /** #281 件A — the context of a {@link Hooks.userPromptSubmit} invocation (second parameter,
564
+ * additive). Carries only the identity envelope today; an object so later additions stay additive. */
565
+ export interface UserPromptSubmitContext {
566
+ /** The run/leg identity envelope — see {@link HookInvocationIdentity} for the presence law. */
567
+ identity?: HookInvocationIdentity;
568
+ }
569
+ /** #281 件A — the context of a {@link Hooks.postToolBatch} invocation (third parameter, additive —
570
+ * deliberately NOT folded into `meta`, whose presence already means "the engine injected here"). */
571
+ export interface PostToolBatchContext {
572
+ /** The run/leg identity envelope — see {@link HookInvocationIdentity} for the presence law. */
573
+ identity?: HookInvocationIdentity;
574
+ }
453
575
  /** A UserPromptSubmit hook result: block the submission, or inject context ahead of the prompt. */
454
576
  export interface UserPromptSubmitResult {
455
577
  /** Block submission entirely; the task fails with this model-readable reason. */
@@ -672,6 +794,11 @@ export interface ToolGateInput {
672
794
  toolName: string;
673
795
  input: Record<string, unknown>;
674
796
  };
797
+ /** #281 件A — the run/leg identity envelope, put verbatim on every {@link HookToolContext} this
798
+ * gate call builds and on every {@link PermissionDeniedPayload} it emits. The Runner supplies its
799
+ * per-leg mint; a host driving the gate directly may omit it, and the contexts then carry no
800
+ * identity (honest absence — this layer never fabricates a leg). */
801
+ identity?: HookInvocationIdentity;
675
802
  preToolUse?: Hooks["preToolUse"];
676
803
  /** The read-only env capability face put on every {@link HookToolContext} this gate call
677
804
  * builds ({@link HookEnvCapabilities}). Built ONCE per task by the runner (after the env is minted) and
@@ -811,6 +938,29 @@ export interface ToolGateInput {
811
938
  * per-tool mark — attributes a tighten-deny to `source:"shellGate"` instead of `"safety"`.
812
939
  */
813
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;
814
964
  /**
815
965
  * design/143 批2 ([672]-A, CC 2.1.207 auto mode): when present, a surviving `ask` is routed to the
816
966
  * small-model policy CLASSIFIER before any human/durable resolution:
@@ -78,6 +78,18 @@ export function createHookEnvCapabilities(env) {
78
78
  }
79
79
  return Object.freeze(face);
80
80
  }
81
+ const IDENTITY_AGENT_NAME_MAX = 80;
82
+ export function mintHookInvocationIdentity(facts) {
83
+ return Object.freeze(Object.assign(Object.create(null), {
84
+ sessionId: facts.sessionId,
85
+ taskId: facts.taskId,
86
+ legKind: facts.legKind,
87
+ isDelegatedChild: facts.isDelegatedChild,
88
+ ...(facts.insideFork === true ? { insideFork: true } : {}),
89
+ ...(facts.agentName !== undefined ? { agentName: inlineUntrusted(facts.agentName.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}),
90
+ ...(facts.parentToolCallId !== undefined ? { parentToolCallId: facts.parentToolCallId } : {}),
91
+ }));
92
+ }
81
93
  export function formatHookFeedback(text) {
82
94
  return `<system-reminder>\n${text}\n</system-reminder>`;
83
95
  }
@@ -270,7 +282,12 @@ export function persistedRuleMandateOf(marks) {
270
282
  export async function runToolGate(input) {
271
283
  const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
272
284
  const { toolCallId, toolName } = event;
273
- const hookCtx = () => ({ toolCallId, toolName, ...(input.hookEnv !== undefined ? { env: input.hookEnv } : {}) });
285
+ const hookCtx = () => ({
286
+ toolCallId,
287
+ toolName,
288
+ ...(input.hookEnv !== undefined ? { env: input.hookEnv } : {}),
289
+ ...(input.identity !== undefined ? { identity: input.identity } : {}),
290
+ });
274
291
  let currentInput = event.input;
275
292
  const preToolContext = [];
276
293
  let hookAsk;
@@ -285,7 +302,7 @@ export async function runToolGate(input) {
285
302
  const reason = preToolUseCrashReason(`this call to "${toolName}"`, err);
286
303
  traceHookCrash(input, err, notifier);
287
304
  if (input.permissionDenied) {
288
- await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook" }), "toolGate.permissionDenied");
305
+ await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook", ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
289
306
  }
290
307
  return { block: true, reason: formatHookFeedback(reason), preToolContext };
291
308
  }
@@ -381,6 +398,25 @@ export async function runToolGate(input) {
381
398
  denySource = input.shellGated === true ? "shellGate" : "safety";
382
399
  }
383
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
+ }
384
420
  let orgRealApprovalRequired = false;
385
421
  let orgAskOrigin;
386
422
  let orgTightenCount = 0;
@@ -556,6 +592,7 @@ export async function runToolGate(input) {
556
592
  decision.decisionReason !== "hook" &&
557
593
  hookAsk === undefined &&
558
594
  input.egress !== true &&
595
+ input.peerMessage !== true &&
559
596
  input.sandboxAdmission.boundaryCapable !== true) {
560
597
  const recorded = input.sandboxAdmission.askClassesOf(toolCallId);
561
598
  const admissible = recorded !== undefined && recorded.length > 0 && recorded.every((r) => r.cls === "sandbox_local");
@@ -638,6 +675,7 @@ export async function runToolGate(input) {
638
675
  if (decision.action === "allow" && decision.updatedInput !== undefined) {
639
676
  let editArgs = decision.updatedInput;
640
677
  let editDenied;
678
+ let editRewrittenSinceHuman = false;
641
679
  for (let round = 0;; round++) {
642
680
  if (round >= 3) {
643
681
  editDenied = {
@@ -674,8 +712,10 @@ export async function runToolGate(input) {
674
712
  denySource = "hook";
675
713
  break;
676
714
  }
677
- if (hr.updatedInput !== undefined)
715
+ if (hr.updatedInput !== undefined) {
678
716
  editArgs = hr.updatedInput;
717
+ editRewrittenSinceHuman = true;
718
+ }
679
719
  }
680
720
  }
681
721
  if (!adjudicate && input.orgRules === undefined)
@@ -697,8 +737,10 @@ export async function runToolGate(input) {
697
737
  denySource = "policy";
698
738
  break;
699
739
  }
700
- if (recheck.updatedInput !== undefined)
740
+ if (recheck.updatedInput !== undefined) {
701
741
  editArgs = recheck.updatedInput;
742
+ editRewrittenSinceHuman = true;
743
+ }
702
744
  const orgTightensBefore = orgTightenCount;
703
745
  recheck = await applyOrgLayer(recheck, editArgs);
704
746
  const orgRaisedThisRound = orgTightenCount > orgTightensBefore;
@@ -707,12 +749,16 @@ export async function runToolGate(input) {
707
749
  break;
708
750
  }
709
751
  if (recheck.action === "allow") {
710
- if (recheck.updatedInput !== undefined)
752
+ if (recheck.updatedInput !== undefined) {
711
753
  editArgs = recheck.updatedInput;
754
+ editRewrittenSinceHuman = true;
755
+ }
712
756
  break;
713
757
  }
714
- if (recheck.updatedInput !== undefined)
758
+ if (recheck.updatedInput !== undefined) {
715
759
  editArgs = recheck.updatedInput;
760
+ editRewrittenSinceHuman = true;
761
+ }
716
762
  const rr = await resolveAsk({ ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) }, { toolName, args: editArgs, toolCallId });
717
763
  resolvedApprover = rr.action !== "ask" ? rr.approver : undefined;
718
764
  if (rr.action !== "allow") {
@@ -724,9 +770,21 @@ export async function runToolGate(input) {
724
770
  if (rr.updatedInput === undefined) {
725
771
  if (rr.presentedInput !== undefined)
726
772
  editArgs = rr.presentedInput;
773
+ editRewrittenSinceHuman = false;
727
774
  break;
728
775
  }
729
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
+ }
730
788
  }
731
789
  decision = editDenied ?? { ...decision, updatedInput: editArgs };
732
790
  }
@@ -741,7 +799,7 @@ export async function runToolGate(input) {
741
799
  currentInput = decision.updatedInput;
742
800
  }
743
801
  if (input.permissionDenied) {
744
- await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource }), "toolGate.permissionDenied");
802
+ await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
745
803
  }
746
804
  const denySettledBy = decision.settledBy;
747
805
  const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
@@ -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";
@@ -107,6 +107,10 @@ export interface PrepareConfigDoorsResult {
107
107
  exclude: readonly string[] | undefined;
108
108
  defer: readonly string[] | undefined;
109
109
  alwaysLoad: readonly string[] | undefined;
110
+ /** design/277 — the model-gate restore selector ({@link TaskSpec.restoreGatedTools}), fourth
111
+ * seat of the same frozen task-start snapshot: the gate decision and the delegation carrier
112
+ * read THIS, never the live spec. */
113
+ restoreGated: readonly string[] | true | undefined;
110
114
  };
111
115
  /** owned — the profile half of the RB-50 single decision point (model-independent by contract). */
112
116
  promptProfile: "simple" | "classic";
@@ -127,6 +131,19 @@ export interface PrepareConfigDoorsResult {
127
131
  compModel: Model | undefined;
128
132
  /** owned — the mitigations half of the RB-50 decision point (model-family fact). */
129
133
  fableMitigations: boolean;
134
+ /** owned, frozen — design/277 model-gate DISCLOSURE data (the application itself already
135
+ * happened on {@link spec}: its `tools` is the survivor rebind when anything was removed).
136
+ * prepareTask emits the notices from this after the doors return (the onNotice station);
137
+ * the doors stay side-effect-free on the announcement axis. */
138
+ modelGate: {
139
+ /** gate-class → removed wire names (sorted, unique); empty map = nothing removed. */
140
+ removedByClass: ReadonlyMap<string, readonly string[]>;
141
+ /** stamped classes with no merged-table row (inert tags, fail-open — announce material). */
142
+ unknownClasses: readonly string[];
143
+ /** a not-in-force `SEMA_TOOL_MODEL_GATE` value outside the closed set (discard-announce
144
+ * material; the in-force arm never lands here — it throws at the door). */
145
+ discardedEnvRaw: string | undefined;
146
+ };
130
147
  /** owned — validated deployment governance windows (undefined = ungoverned). */
131
148
  usageWindows: readonly UsageWindow[] | undefined;
132
149
  /** owned, out-param cell — created EMPTY here; the brain-call wiring later installs into