@sema-agent/core 7.24.4 → 7.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/agents/peer-session-drain.d.ts +16 -1
  3. package/dist/agents/peer-session-drain.js +16 -1
  4. package/dist/agents/send-message-tool.d.ts +10 -2
  5. package/dist/agents/send-message-tool.js +18 -7
  6. package/dist/agents/subagent.js +13 -10
  7. package/dist/core/arg-summary.d.ts +0 -7
  8. package/dist/core/arg-summary.js +2 -1
  9. package/dist/core/background-agent-store.d.ts +3 -3
  10. package/dist/core/checkpoint-store.d.ts +1 -0
  11. package/dist/core/delegation-frames.d.ts +5 -0
  12. package/dist/core/gate-fold.js +85 -0
  13. package/dist/core/gate-lanes.js +23 -1
  14. package/dist/core/gate-outcome.d.ts +8 -2
  15. package/dist/core/gate-outcome.js +3 -1
  16. package/dist/core/hooks.d.ts +29 -0
  17. package/dist/core/hooks.js +2 -0
  18. package/dist/core/mailbox-store.d.ts +5 -0
  19. package/dist/core/mailbox-store.js +1 -1
  20. package/dist/core/parent-spec-seat.d.ts +7 -1
  21. package/dist/core/parent-spec-seat.js +2 -0
  22. package/dist/core/permission-rule-consent.js +29 -47
  23. package/dist/core/permission-rule-provider.js +4 -3
  24. package/dist/core/permission-rule-store.d.ts +143 -7
  25. package/dist/core/permission-rule-store.js +165 -15
  26. package/dist/core/runner/contracts.d.ts +36 -2
  27. package/dist/core/runner/gate-exit.d.ts +14 -0
  28. package/dist/core/runner/gate-exit.js +4 -0
  29. package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
  30. package/dist/core/runner/prepare-delegation-surface.js +2 -1
  31. package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
  32. package/dist/core/runner/prepare-gate-stations.js +7 -4
  33. package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
  34. package/dist/core/runner/prepare-hands-readface.js +25 -10
  35. package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
  36. package/dist/core/runner/prepare-inherited-gate.js +11 -6
  37. package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
  38. package/dist/core/runner/prepare-policy-chain.js +14 -4
  39. package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
  40. package/dist/core/runner/prepare-suspend-saga.js +4 -3
  41. package/dist/core/runner/prepare-task.js +10 -9
  42. package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
  43. package/dist/core/runner/prepare-wiring-manifest.js +8 -4
  44. package/dist/core/runner/resume-apply.js +2 -0
  45. package/dist/core/runner/run-harness-handlers.js +1 -0
  46. package/dist/core/runner/run-identity-wiring.d.ts +2 -2
  47. package/dist/core/runner/run-leg.js +26 -0
  48. package/dist/core/runner/run-notification-lane.d.ts +2 -2
  49. package/dist/core/runner/run-notification-lane.js +2 -1
  50. package/dist/core/runner/run-settle-and-teardown.js +1 -0
  51. package/dist/core/runner/session-rule-policy.d.ts +3 -4
  52. package/dist/core/runner/session-rule-policy.js +11 -3
  53. package/dist/core/session-policy-store.d.ts +27 -3
  54. package/dist/core/session-policy-store.js +12 -4
  55. package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
  56. package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
  57. package/dist/core/store-contracts/peer-directory-contract.js +82 -0
  58. package/dist/core/task-event.d.ts +6 -0
  59. package/dist/core/task-notification.d.ts +79 -4
  60. package/dist/core/task-notification.js +90 -1
  61. package/dist/core/task-registry-agent.js +1 -2
  62. package/dist/core/task-registry-monitor.js +2 -1
  63. package/dist/core/task-registry-shared.d.ts +53 -5
  64. package/dist/core/task-registry-shared.js +13 -2
  65. package/dist/core/task-registry.js +8 -5
  66. package/dist/core/task-spec.d.ts +12 -5
  67. package/dist/core/tool-catalog-entries.js +1 -0
  68. package/dist/core/tool-policy.js +30 -23
  69. package/dist/core/tool-spec.d.ts +1 -1
  70. package/dist/core/wiring-manifest.d.ts +2 -2
  71. package/dist/engine/loop/agent-loop.js +1 -1
  72. package/dist/engine/loop/types.d.ts +4 -0
  73. package/dist/index.d.ts +6 -3
  74. package/dist/index.js +4 -3
  75. package/dist/prompts/coordinator.d.ts +2 -1
  76. package/dist/stores/file/adoption/adopt.js +23 -1
  77. package/dist/stores/file/permission-rule-store.js +3 -1
  78. package/dist/stores/file/session-policy-store.d.ts +31 -0
  79. package/dist/stores/file/session-policy-store.js +87 -7
  80. package/dist/tools/fs/bash-lexer.d.ts +13 -0
  81. package/dist/tools/fs/bash-lexer.js +61 -0
  82. package/dist/tools/fs/bash-program-position.d.ts +36 -29
  83. package/dist/tools/fs/bash-program-position.js +33 -13
  84. package/dist/tools/fs/bash-readonly-classifier.d.ts +26 -313
  85. package/dist/tools/fs/bash-readonly-classifier.js +81 -782
  86. package/dist/tools/fs/fs-bash.d.ts +43 -207
  87. package/dist/tools/fs/fs-bash.js +20 -341
  88. package/dist/tools/fs/index.d.ts +12 -13
  89. package/dist/tools/fs/index.js +3 -4
  90. package/dist/tools/fs/read-deny.d.ts +25 -0
  91. package/dist/tools/fs/read-deny.js +19 -0
  92. package/dist/tools/fs/safety.js +2 -6
  93. package/dist/tools/fs/shell-read-check.d.ts +99 -0
  94. package/dist/tools/fs/shell-read-check.js +229 -0
  95. package/dist/tools/fs/shell-read-establish.d.ts +70 -115
  96. package/dist/tools/fs/shell-read-establish.js +216 -102
  97. package/dist/tools/fs/shell-read-judge.d.ts +231 -0
  98. package/dist/tools/fs/shell-read-judge.js +170 -0
  99. package/dist/tools/fs/shell-read-plan.d.ts +3 -0
  100. package/dist/tools/fs/shell-read-plan.js +88 -27
  101. package/dist/tools/monitor.d.ts +5 -5
  102. package/dist/tools/monitor.js +3 -5
  103. package/dist/tools/task-list.d.ts +4 -0
  104. package/package.json +1 -1
  105. package/test/export-surface.snapshot.json +19 -3
  106. package/wire-changes/core@7.25.0.json +671 -0
  107. package/wire-changes/core@7.25.1.json +64 -0
@@ -48,6 +48,24 @@ export async function runGateFold(pass) {
48
48
  ...(pass.hookAsk.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
49
49
  };
50
50
  }
51
+ if ((pass.decision.action === "allow" || pass.decision.action === "ask") && input.shellReadCheck !== undefined) {
52
+ const station = input.shellReadCheck;
53
+ const judged = station.judge(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput, input.trackedCwd?.(), pass.callSignal ?? input.abortSignal, { toolCallId });
54
+ if (judged !== null) {
55
+ let verdict;
56
+ try {
57
+ verdict = await withGateTimeout(judged, boundedAwaitDeadlineMs(), pass.callSignal ?? input.abortSignal, "shellReadStation");
58
+ }
59
+ catch (err) {
60
+ const kind = gateWaitFailureOf(err);
61
+ if (kind !== "aborted" && input.abortSignal?.aborted !== true) {
62
+ traceHookCrash(input, new Error(`shell read station ${kind === "timeout" ? "timed out" : "threw"} for tool "${toolName}" (toolCallId ${toolCallId}) — where this call reads could not be established: ${thrownText(err)}`), notifier);
63
+ }
64
+ verdict = station.unanswered(toolName, kind === "timeout" ? "the read station timed out" : "the read station could not answer");
65
+ }
66
+ applyShellReadVerdict(pass, verdict, toolName, input.shellGated === true);
67
+ }
68
+ }
51
69
  if (input.egress && pass.decision.action === "allow") {
52
70
  pass.decision = {
53
71
  action: "ask",
@@ -59,6 +77,14 @@ export async function runGateFold(pass) {
59
77
  const alreadyAsking = pass.decision.action === "ask";
60
78
  if ((pass.decision.action === "allow" || alreadyAsking) && (input.irreversibility === "always" || input.irreversibility === "maybe")) {
61
79
  let tighten = !alreadyAsking;
80
+ const withdrawStationOffer = () => {
81
+ if (alreadyAsking && pass.shellReadOffer === true && pass.decision.action === "ask" && pass.decision.probeReadRootCandidate !== undefined) {
82
+ const { probeReadRootCandidate: _withdrawn, ...kept } = pass.decision;
83
+ pass.decision = kept;
84
+ }
85
+ };
86
+ if (input.irreversibility === "always" || !input.reversibilityProbe)
87
+ withdrawStationOffer();
62
88
  let probeReason;
63
89
  let probeCause;
64
90
  let probeMandated;
@@ -80,11 +106,13 @@ export async function runGateFold(pass) {
80
106
  if (verdict?.boundaryDeclined === true)
81
107
  pass.probeBoundaryDeclined = true;
82
108
  probeReadRootCandidate = normalizeReadRootCandidate(verdict?.readRootCandidate);
109
+ withdrawStationOffer();
83
110
  }
84
111
  }
85
112
  catch (err) {
86
113
  reversible = false;
87
114
  pass.probeUnanswered = true;
115
+ withdrawStationOffer();
88
116
  if (input.abortSignal?.aborted !== true)
89
117
  traceHookCrash(input, err, notifier);
90
118
  }
@@ -140,3 +168,60 @@ export async function runGateFold(pass) {
140
168
  }
141
169
  return await pass.afterFold(pass);
142
170
  }
171
+ function shellReadAskMessage(toolName, finding) {
172
+ return finding === "outside_roots"
173
+ ? `tool "${toolName}" reads outside the directories allowed for this session — explicit approval required`
174
+ : `tool "${toolName}" reads where the session's read boundary could not establish what is read — explicit approval required`;
175
+ }
176
+ function applyShellReadVerdict(pass, verdict, toolName, shellGated) {
177
+ const account = verdict.sentence !== undefined && verdict.sentence.trim().length > 0 ? inlineUntrusted(verdict.sentence, PROBE_REASON_MAX) : undefined;
178
+ switch (verdict.disposition) {
179
+ case "vouch":
180
+ return;
181
+ case "refuse":
182
+ pass.decision = { action: "deny", message: verdict.sentence ?? `tool "${toolName}" reads what the session's read boundary refuses` };
183
+ pass.deniedBy = "read_boundary";
184
+ if (verdict.details !== undefined)
185
+ pass.denyDetails = verdict.details;
186
+ return;
187
+ case "mandated": {
188
+ if (verdict.details !== undefined)
189
+ pass.shellReadDetails = verdict.details;
190
+ if (pass.decision.action !== "allow") {
191
+ if (pass.decision.action === "ask")
192
+ pass.decision = { ...pass.decision, probeMandated: true };
193
+ return;
194
+ }
195
+ const candidate = normalizeReadRootCandidate(verdict.readRootCandidate);
196
+ pass.decision = {
197
+ action: "ask",
198
+ message: shellReadAskMessage(toolName, verdict.finding),
199
+ decisionReason: "safety",
200
+ probeMandated: true,
201
+ ...(account !== undefined ? { probeReason: account } : {}),
202
+ ...(candidate !== undefined ? { probeReadRootCandidate: candidate } : {}),
203
+ };
204
+ if (candidate !== undefined)
205
+ pass.shellReadOffer = true;
206
+ pass.tightenedBy = shellGated ? "shell_gate" : "safety";
207
+ return;
208
+ }
209
+ case "ordinary": {
210
+ if (pass.decision.action === "allow") {
211
+ pass.decision = {
212
+ action: "ask",
213
+ message: shellReadAskMessage(toolName, verdict.finding),
214
+ decisionReason: "safety",
215
+ ...(account !== undefined ? { probeReason: account } : {}),
216
+ };
217
+ pass.tightenedBy = shellGated ? "shell_gate" : "safety";
218
+ }
219
+ pass.probeBoundaryDeclined = true;
220
+ return;
221
+ }
222
+ default: {
223
+ const exhaustive = verdict.disposition;
224
+ return exhaustive;
225
+ }
226
+ }
227
+ }
@@ -11,7 +11,7 @@ import { catalogRuleFaceOf } from "./tool-registry.js";
11
11
  import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
12
12
  import { exitGate } from "./runner/gate-exit.js";
13
13
  import { traceHookCrash } from "./gate-crash-trace.js";
14
- import { createGateDeadline, awaitWriteProtectionJudgment } from "./gate-bounded-await.js";
14
+ import { createGateDeadline, awaitWriteProtectionJudgment, withGateTimeout } from "./gate-bounded-await.js";
15
15
  import { thrownText } from "./thrown-value.js";
16
16
  function isPlainOwnRecord(x) {
17
17
  try {
@@ -691,6 +691,28 @@ export async function runGateLanes(pass) {
691
691
  pass.deniedBy = "write_protection";
692
692
  }
693
693
  }
694
+ if (editDenied === undefined && input.shellReadCheck !== undefined) {
695
+ const station = input.shellReadCheck;
696
+ const judged = station.judge(toolName, editArgs, input.trackedCwd?.(), callSignal ?? input.abortSignal, { seat: "rejudge", toolCallId });
697
+ if (judged !== null) {
698
+ let verdict;
699
+ try {
700
+ verdict = await withGateTimeout(judged, boundedAwaitDeadlineMs(), callSignal ?? input.abortSignal, "shellReadStation");
701
+ }
702
+ catch {
703
+ verdict = station.unanswered(toolName, "the read station could not answer");
704
+ }
705
+ if (verdict.disposition !== "vouch") {
706
+ editDenied = {
707
+ action: "deny",
708
+ message: verdict.sentence ?? `the approved edit for "${toolName}" reads where the read boundary does not vouch — denied fail-closed; re-submit the command as a fresh call and answer its card`,
709
+ };
710
+ pass.deniedBy = "read_boundary";
711
+ if (verdict.details !== undefined)
712
+ pass.denyDetails = verdict.details;
713
+ }
714
+ }
715
+ }
694
716
  pass.decision = editDenied ?? { ...pass.decision, updatedInput: editArgs };
695
717
  }
696
718
  else if (pass.decision.action === "allow" && pass.decision.updatedInput === undefined && resolved.presentedInput !== undefined) {
@@ -154,8 +154,11 @@ export type SettlementRefusalTableCoversEveryKind = AssertAllKeysHandled<Exclude
154
154
  * path that no approval covers.
155
155
  * - `ask_resolution` — the ask's own settlement is the refusal (a person said no, a window elapsed,
156
156
  * nobody could be asked…); the detail is on {@link GateOutcome.settlement}.
157
+ * - `read_boundary` — the shell read station refused: a deny-listed read (refused on every seat, with no
158
+ * ask), a read the read-only shell face may not make, or a rewritten / re-established command whose reads
159
+ * no longer are what a person approved.
157
160
  */
158
- export declare const DENIED_BY_VALUES: readonly ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
161
+ export declare const DENIED_BY_VALUES: readonly ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution", "read_boundary"];
159
162
  export type DeniedBy = (typeof DENIED_BY_VALUES)[number];
160
163
  /** Membership test for {@link DeniedBy}. */
161
164
  export declare function isDeniedBy(v: unknown): v is DeniedBy;
@@ -165,7 +168,8 @@ export declare function isDeniedBy(v: unknown): v is DeniedBy;
165
168
  * classifier, plan mode and compliance run before any ask exists, and `ask_resolution` IS the
166
169
  * settlement, not a veto of it. `persisted_rule` = a person's own standing DENY row (their settings'
167
170
  * deny list, imported) — the personal-store sibling of `org`, and like it re-judged on an edited
168
- * command. */
171
+ * command. `read_boundary` vetoes too: the rewrite re-judge and the pre-spawn re-establishment run after
172
+ * the person's yes, on bytes or targets the person never saw. */
169
173
  export declare const DENIED_BY_MAY_VETO: {
170
174
  readonly policy: true;
171
175
  readonly hook: true;
@@ -176,6 +180,7 @@ export declare const DENIED_BY_MAY_VETO: {
176
180
  readonly compliance: false;
177
181
  readonly write_protection: true;
178
182
  readonly ask_resolution: false;
183
+ readonly read_boundary: true;
179
184
  };
180
185
  /** Compile-time fence: `never` while every deny layer has a veto row. */
181
186
  export type VetoTableCoversEveryDeniedBy = AssertAllKeysHandled<Exclude<DeniedBy, keyof typeof DENIED_BY_MAY_VETO>>;
@@ -209,6 +214,7 @@ export declare const DENIED_BY_MAY_CARRY_CAUSE: {
209
214
  readonly compliance: false;
210
215
  readonly write_protection: false;
211
216
  readonly ask_resolution: false;
217
+ readonly read_boundary: false;
212
218
  };
213
219
  /** Compile-time fence: `never` while every deny layer has a cause row. */
214
220
  export type CauseTableCoversEveryDeniedBy = AssertAllKeysHandled<Exclude<DeniedBy, keyof typeof DENIED_BY_MAY_CARRY_CAUSE>>;
@@ -39,7 +39,7 @@ export const SETTLEMENT_IS_REFUSAL = {
39
39
  blanket_allow_refused: true,
40
40
  task_aborted: true,
41
41
  };
42
- export const DENIED_BY_VALUES = ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
42
+ export const DENIED_BY_VALUES = ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution", "read_boundary"];
43
43
  const DENIED_BY_SET = new Set(DENIED_BY_VALUES);
44
44
  export function isDeniedBy(v) {
45
45
  return DENIED_BY_SET.has(v);
@@ -54,6 +54,7 @@ export const DENIED_BY_MAY_VETO = {
54
54
  compliance: false,
55
55
  write_protection: true,
56
56
  ask_resolution: false,
57
+ read_boundary: true,
57
58
  };
58
59
  export const CLASSIFIER_DENY_CAUSES = ["unavailable", "parse_error"];
59
60
  const CLASSIFIER_DENY_CAUSE_SET = new Set(CLASSIFIER_DENY_CAUSES);
@@ -70,6 +71,7 @@ export const DENIED_BY_MAY_CARRY_CAUSE = {
70
71
  compliance: false,
71
72
  write_protection: false,
72
73
  ask_resolution: false,
74
+ read_boundary: false,
73
75
  };
74
76
  export function screenGateOutcome(g) {
75
77
  const defects = [];
@@ -732,6 +732,16 @@ export interface HookInvocationIdentity {
732
732
  * UNTRUSTED display text (model-chosen) — already single-line-bounded at mint (same
733
733
  * `inlineUntrusted` treatment as the `task_progress` name lane); never an identity key. */
734
734
  readonly agentName?: string;
735
+ /** The EXPLICIT `Agent({name})` spawn identity, when the spawn carried one — sema's teammate key
736
+ * (validated at spawn: `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`, never `main`). The same string the
737
+ * child's roster row, its uplink `from` label and its `task_progress.spawnName` carry, so a hook
738
+ * seat binds "which teammate" by string equality. ABSENT = an unnamed delegation (not a
739
+ * teammate); {@link agentName} above stays the DISPLAY label and is never an identity key. */
740
+ readonly spawnName?: string;
741
+ /** The RESOLVED subagent type the leg was spawned as (`"general-purpose"` / `"fork"` / a roster
742
+ * definition's name — the fleet row's `agentType` word, never the description). Absent on a
743
+ * top-level run and on a leg whose spawner threaded no type (a workflow-lane agent). */
744
+ readonly agentType?: string;
735
745
  /** The spawning Agent tool call's own id, when this leg runs under one — the delegation-chain
736
746
  * position anchor ({@link import("./types.js").TaskEvent}'s `parentToolCallId` twin). Absent on
737
747
  * a top-level run and on a directly-started workflow child (no launching tool call — no id is
@@ -919,6 +929,11 @@ export interface ToolGateResult {
919
929
  gate?: GateOutcome;
920
930
  /** Rewritten args to execute with (re-validated by the loop); omitted when nothing rewrote. */
921
931
  updatedInput?: unknown;
932
+ /** A blocking refusal's structured details, when the refusing station minted any (the shell read station's
933
+ * `read_path_denied` / `readonly_out_of_root` cards): the loop attaches them to the error result it mints
934
+ * from `reason`, so the refusal reaches `tool_end.structured` / `errorCode` exactly as the tool's own
935
+ * refusal of the same finding did. Absent on every other block. */
936
+ details?: unknown;
922
937
  /**
923
938
  * design/45: the gate routed a policy `ask` to a **durable suspension** (no synchronous approver).
924
939
  * `suspendAsk` already persisted the checkpoint and aborted the run; the caller records this so the
@@ -1362,6 +1377,20 @@ export interface ToolGateInput {
1362
1377
  * point there), a promise for a covered write. Absent ⇒ no table (`[]`) and the decision path is byte-identical.
1363
1378
  */
1364
1379
  writeProtectionCheck?: import("./write-protect.js").WriteProtectionCheck;
1380
+ /**
1381
+ * The shell READ STATION — ENGINE-BUILT by the hands phase (`createShellReadStation`,
1382
+ * `src/tools/fs/shell-read-check.ts`) for the shell-running tools of this leg, never a deployment callback.
1383
+ * Judges the FINAL args of a shell call: ONE parse of the command into its read candidates, each
1384
+ * established against the execution environment (symlinks resolved, patterns expanded, traversal roots
1385
+ * enumerated, within a bound) and judged by the leg's read deny seat and its containment roots. A
1386
+ * POLICY-class member of the fold: a deny-listed read is REFUSED (`deniedBy: "read_boundary"`, composing
1387
+ * under `deny > ask > allow`), an out-of-root or unestablishable read tightens a surviving allow to a
1388
+ * MANDATED ask (or marks an existing ask mandated), a read the station declined to judge tightens to an
1389
+ * ordinary ask on the classify seat and a mandated one on the off seat, and a vouched call is untouched.
1390
+ * `null` SYNCHRONOUSLY for a call it does not cover (no suspension point there), a promise for a shell
1391
+ * call. Absent ⇒ no station (no shell mounted) and the decision path is byte-identical.
1392
+ */
1393
+ shellReadCheck?: import("../tools/fs/shell-read-check.js").ShellReadCheck;
1365
1394
  /**
1366
1395
  * design/143 批2 (A, CC 2.1.207 auto mode): when present, a surviving `ask` is routed to the
1367
1396
  * small-model policy CLASSIFIER before any human/durable resolution:
@@ -167,6 +167,8 @@ export function mintHookInvocationIdentity(facts) {
167
167
  isDelegatedChild: facts.isDelegatedChild,
168
168
  ...(facts.insideFork === true ? { insideFork: true } : {}),
169
169
  ...(facts.agentName !== undefined ? { agentName: inlineUntrusted(facts.agentName.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}),
170
+ ...(facts.spawnName !== undefined ? { spawnName: inlineUntrusted(facts.spawnName.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}),
171
+ ...(facts.agentType !== undefined ? { agentType: inlineUntrusted(facts.agentType.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}),
170
172
  ...(facts.parentToolCallId !== undefined ? { parentToolCallId: facts.parentToolCallId } : {}),
171
173
  }));
172
174
  }
@@ -25,6 +25,11 @@ export interface MailboxMessage {
25
25
  export interface MailboxPeerMeta {
26
26
  /** The sending session's id (reply routing). */
27
27
  fromSession?: string;
28
+ /** The delegation-tree ROOT the sender belongs to, stamped by the park seat of a session that
29
+ * received the frame through its own child uplink — never by the sender. A recipient whose
30
+ * session IS this root reads the record as a teammate's words (same tree = same admission as the
31
+ * live uplink: the admission gate runs, the cross-session parity hold does not). */
32
+ fromRoot?: string;
28
33
  /** The sender's permission-mode class at send time — the drain judges parity against the RECEIVER. */
29
34
  fromMode?: MailboxPeerFromMode;
30
35
  /** The admission gate's sender key (rate/dedup axis). */
@@ -4,7 +4,7 @@ export const MAILBOX_PEER_RECORD_KINDS = ["peer_message", "idle_notice", "delive
4
4
  export const MAILBOX_PEER_NOTICE_STATES = ["held", "denied", "expired", "delivered", "refused", "dropped", "idle", "exited", "unavailable"];
5
5
  export const MAILBOX_INVALID_PEER_META_CODE = "mailbox.invalid_peer_meta";
6
6
  export const MAILBOX_CROSS_PROCESS_UNSAFE_CODE = "mailbox.cross_process_unsafe";
7
- const PEER_META_STRING_KEYS = ["fromSession", "senderKey", "fromScope", "gateReceiptId"];
7
+ const PEER_META_STRING_KEYS = ["fromSession", "fromRoot", "senderKey", "fromScope", "gateReceiptId"];
8
8
  export function readMailboxPeerMeta(raw) {
9
9
  if (raw === undefined)
10
10
  return undefined;
@@ -9,10 +9,11 @@
9
9
  * The checkpoint seat (`./checkpoint-seat.ts`) is the same shape for one field; this seat carries the rest.
10
10
  */
11
11
  import type { OnQuestion } from "./ask-question.js";
12
+ import type { Hooks } from "./hooks.js";
12
13
  import type { ToolExecuteContext } from "./tool-spec.js";
13
14
  import type { TaskSpec } from "./types.js";
14
15
  /** The fields of a spec the seat carries — one `Pick`, shared by the mint's input and the apply's target. */
15
- export type ParentSpecSeatFields = Pick<TaskSpec, "principal" | "oneShot" | "clientContext" | "onQuestion" | "additionalDirectories" | "additionalReadDirectories" | "envFacts" | "memoryPersistenceCapable" | "memory">;
16
+ export type ParentSpecSeatFields = Pick<TaskSpec, "principal" | "oneShot" | "clientContext" | "onQuestion" | "hooks" | "additionalDirectories" | "additionalReadDirectories" | "envFacts" | "memoryPersistenceCapable" | "memory">;
16
17
  /**
17
18
  * The seat a run hands its children. Every member is the parent's own value, frozen at mint (arrays copied and
18
19
  * frozen; the facts object copied). Members present iff the parent carries them — an absent member is "nothing to
@@ -22,6 +23,10 @@ export type ParentSpecSeatFields = Pick<TaskSpec, "principal" | "oneShot" | "cli
22
23
  * · `clientContext` — the user's TZ/email facts (the child's environment block localizes the same way).
23
24
  * · `onQuestion` — the parent's FROZEN question face (`spec.onQuestion ?? deps.onQuestion`, the value the parent's
24
25
  * own AskUserQuestion resolves at): the child's question tool MOUNTS on it, so without it the child has no tool.
26
+ * · `hooks` — the parent's SPEC-level hook seams (`spec.hooks`, the request-level lifecycle hooks a host mounts from
27
+ * the user's settings). A deployment's `RunnerDeps.hooks` already serve every leg; the spec-level seams did not,
28
+ * so a `stop` seat a user wired ran for the leader and never for its teammates (CC runs `SubagentStop` from the
29
+ * same settings). Same law as `onQuestion`: a child's own value wins, the seat fills an unset one.
25
30
  * · `additionalDirectories` / `additionalReadDirectories` — the fs-fence widening (deployment property).
26
31
  * · `envFacts` — the sandbox facts (profile / egress / scratchpad).
27
32
  * · `memoryPersistenceCapable` — the deployment's DECLARED persistence statement: `false` is a REMOVAL (floor),
@@ -34,6 +39,7 @@ export interface ParentSpecSeat {
34
39
  readonly oneShot?: true;
35
40
  readonly clientContext?: TaskSpec["clientContext"];
36
41
  readonly onQuestion?: OnQuestion;
42
+ readonly hooks?: Hooks;
37
43
  readonly additionalDirectories?: readonly string[];
38
44
  readonly additionalReadDirectories?: readonly string[];
39
45
  readonly envFacts?: TaskSpec["envFacts"];
@@ -4,6 +4,7 @@ export function parentSpecSeatOf(spec, frozenOnQuestion) {
4
4
  ...(spec.oneShot === true ? { oneShot: true } : {}),
5
5
  ...(spec.clientContext !== undefined ? { clientContext: spec.clientContext } : {}),
6
6
  ...(frozenOnQuestion !== undefined ? { onQuestion: frozenOnQuestion } : {}),
7
+ ...(spec.hooks !== undefined ? { hooks: spec.hooks } : {}),
7
8
  ...(spec.additionalDirectories !== undefined ? { additionalDirectories: Object.freeze([...spec.additionalDirectories]) } : {}),
8
9
  ...(spec.additionalReadDirectories !== undefined ? { additionalReadDirectories: Object.freeze([...spec.additionalReadDirectories]) } : {}),
9
10
  ...(spec.envFacts !== undefined ? { envFacts: Object.freeze({ ...spec.envFacts }) } : {}),
@@ -20,6 +21,7 @@ export function applyParentSpecSeat(child, seat) {
20
21
  ...fill("principal", seat.principal),
21
22
  ...fill("clientContext", seat.clientContext),
22
23
  ...fill("onQuestion", seat.onQuestion),
24
+ ...fill("hooks", seat.hooks),
23
25
  ...fill("additionalDirectories", (seat.additionalDirectories !== undefined ? [...seat.additionalDirectories] : undefined)),
24
26
  ...fill("additionalReadDirectories", (seat.additionalReadDirectories !== undefined ? [...seat.additionalReadDirectories] : undefined)),
25
27
  ...fill("envFacts", (seat.envFacts !== undefined ? { ...seat.envFacts } : undefined)),
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { escapeForDisclosure, hasUnrenderableCharacters, parseRuleText, ruleAdmitsCommand, ruleBreadthWarningsOf, segmentCoverageOf, suggestRulesForCommand, RULE_BEHAVIORS, RULE_BEHAVIOR_PRECEDENCE, isRuleBehavior, } from "./permission-rule-model.js";
3
3
  import { effectiveOrThrow } from "./permission-rule-provider.js";
4
- import { errText, isValidConsentScope, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
4
+ import { errText, isValidConsentScope, landRuleAdd, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
5
5
  export class InMemoryRuleApprovalRecordStore {
6
6
  rows = new Map();
7
7
  async get(id) {
@@ -600,7 +600,6 @@ export async function redeemRuleTicket(opts) {
600
600
  }
601
601
  return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot, recordId: rec.id, kind: rec.kind, replay: known !== undefined, ...(siblingsOf !== undefined ? { siblingsOf } : {}) });
602
602
  }
603
- const REDEEM_MAX_ATTEMPTS = 8;
604
603
  async function tighteningSiblingsStand(rec, offer, index, store, durableSnapshot) {
605
604
  for (const member of offerMembersOf(offer)) {
606
605
  if (member === index)
@@ -630,52 +629,35 @@ async function applyRedemption(args) {
630
629
  if (writer === undefined)
631
630
  return { status: "refused", reason: "the resolved permission-rule store has no write face" };
632
631
  const origin = originOfRecordKind(args.kind);
633
- for (let attempt = 0; attempt < REDEEM_MAX_ATTEMPTS; attempt++) {
634
- let rev;
635
- try {
636
- const snapshot = await effectiveOrThrow(store);
637
- rev = snapshot.rev;
638
- if (args.siblingsOf !== undefined && !(await tighteningSiblingsStand(args.siblingsOf.rec, args.siblingsOf.offer, args.siblingsOf.index, store, snapshot))) {
639
- return {
640
- status: "refused",
641
- reason: "withheld: this allow belongs to a batch whose deny/ask members do not all stand in the store — an allow must not stand wider than the list that was confirmed; redeem the batch (redeemRuleBatch) so the tightening members land first, or draw a fresh card",
642
- };
632
+ const siblings = args.siblingsOf;
633
+ const landing = await landRuleAdd({
634
+ store,
635
+ writer,
636
+ delta: {
637
+ kind: "redemption-add",
638
+ behavior: args.parsedRule.behavior,
639
+ rule: args.parsedRule.rule,
640
+ scope: args.candidate.scope,
641
+ tool: args.parsedRule.tool,
642
+ match: args.parsedRule.match,
643
+ command: args.parsedRule.command,
644
+ add: { dot: args.dot, origin, createdAt: nowIso(args.deps) },
645
+ redemption: {
646
+ recordId: args.recordId,
647
+ ...(args.owner.kind === "principal" ? { principal: args.owner.principal } : { owner: args.owner }),
648
+ },
649
+ },
650
+ ...(siblings !== undefined
651
+ ? {
652
+ preflight: async (snapshot) => (await tighteningSiblingsStand(siblings.rec, siblings.offer, siblings.index, store, snapshot))
653
+ ? undefined
654
+ : "withheld: this allow belongs to a batch whose deny/ask members do not all stand in the store — an allow must not stand wider than the list that was confirmed; redeem the batch (redeemRuleBatch) so the tightening members land first, or draw a fresh card",
643
655
  }
644
- }
645
- catch (err) {
646
- return { status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` };
647
- }
648
- let res;
649
- try {
650
- res = await writer.apply({
651
- kind: "redemption-add",
652
- behavior: args.parsedRule.behavior,
653
- rule: args.parsedRule.rule,
654
- scope: args.candidate.scope,
655
- tool: args.parsedRule.tool,
656
- match: args.parsedRule.match,
657
- command: args.parsedRule.command,
658
- add: { dot: args.dot, origin, createdAt: nowIso(args.deps) },
659
- redemption: {
660
- recordId: args.recordId,
661
- ...(args.owner.kind === "principal" ? { principal: args.owner.principal } : { owner: args.owner }),
662
- },
663
- }, { expectedRev: rev });
664
- }
665
- catch (err) {
666
- return { status: "refused", reason: `the permission-rule store refused the write: ${errText(err)}` };
667
- }
668
- if ("refused" in res) {
669
- return {
670
- status: "refused",
671
- reason: "the session this authorization targets has ended — a session rule dies with its session, and a late or replayed redemption cannot revive it (re-trigger the command in a live session for a fresh card)",
672
- };
673
- }
674
- if (!("conflict" in res)) {
675
- return { status: "redeemed", behavior: args.parsedRule.behavior, rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev: res.rev, alreadyRedeemed: args.replay };
676
- }
677
- }
678
- return { status: "refused", reason: `optimistic-concurrency retries exhausted after ${REDEEM_MAX_ATTEMPTS} attempts` };
656
+ : {}),
657
+ });
658
+ if (!landing.landed)
659
+ return { status: "refused", reason: landing.reason };
660
+ return { status: "redeemed", behavior: args.parsedRule.behavior, rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev: landing.rev, alreadyRedeemed: args.replay };
679
661
  }
680
662
  function originOfRecordKind(kind) {
681
663
  return kind === "import" ? "imported-cc" : kind === "starter" ? "starter" : "user";
@@ -111,11 +111,12 @@ function routingWriter(durable, session) {
111
111
  nextDot: () => durable.nextDot(),
112
112
  readRaw: () => durable.readRaw(),
113
113
  apply: async (delta, opts) => {
114
- const scope = delta.kind === "redemption-add" ? structuredClone(delta.scope) : undefined;
115
- if (delta.kind !== "redemption-add" || scope?.kind !== "session")
114
+ const isAdd = delta.kind === "redemption-add" || delta.kind === "tighten-add";
115
+ const scope = isAdd ? structuredClone(delta.scope) : undefined;
116
+ if (!isAdd || scope?.kind !== "session")
116
117
  return durable.apply(delta, opts);
117
118
  if (session === undefined) {
118
- const e = new Error("a session-scope rule has nowhere to land — this deployment configured no session rule partition (createPermissionRuleStoreProvider({ session })); refusing the redemption-add");
119
+ const e = new Error(`a session-scope rule has nowhere to land — this deployment configured no session rule partition (createPermissionRuleStoreProvider({ session })); refusing the ${delta.kind}`);
119
120
  e.code = "unsupported.session_scope_store";
120
121
  throw e;
121
122
  }