@sema-agent/core 5.22.0 → 5.23.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 (52) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/agents/subagent.js +3 -2
  3. package/dist/core/governance-codes.js +3 -0
  4. package/dist/core/hooks.d.ts +62 -1
  5. package/dist/core/hooks.js +90 -12
  6. package/dist/core/memory-engine/engine.d.ts +28 -1
  7. package/dist/core/memory-engine/engine.js +62 -3
  8. package/dist/core/memory-engine/index.d.ts +1 -1
  9. package/dist/core/memory-engine/index.js +1 -1
  10. package/dist/core/memory-engine/layout.d.ts +69 -3
  11. package/dist/core/memory-engine/layout.js +75 -6
  12. package/dist/core/permission-rule-consent.js +2 -1
  13. package/dist/core/permission-rule-org.d.ts +36 -2
  14. package/dist/core/permission-rule-org.js +23 -0
  15. package/dist/core/permission-rule-store.js +2 -1
  16. package/dist/core/permission-rule-sync.d.ts +8 -0
  17. package/dist/core/permission-rule-sync.js +35 -6
  18. package/dist/core/runner/prepare-task.d.ts +10 -2
  19. package/dist/core/runner/prepare-task.js +111 -5
  20. package/dist/core/runner/runtask.js +19 -0
  21. package/dist/core/tool-policy.d.ts +37 -4
  22. package/dist/core/tool-policy.js +32 -4
  23. package/dist/core/tool-result-store.d.ts +9 -1
  24. package/dist/core/tool-result-store.js +2 -1
  25. package/dist/core/trace.d.ts +47 -0
  26. package/dist/core/types.d.ts +38 -0
  27. package/dist/core/wiring-manifest.d.ts +16 -1
  28. package/dist/core/wiring-manifest.js +7 -1
  29. package/dist/index.d.ts +5 -3
  30. package/dist/index.js +4 -2
  31. package/dist/orchestration/goal.d.ts +10 -0
  32. package/dist/orchestration/goal.js +6 -5
  33. package/dist/stores/file/adoption/adopt.d.ts +146 -0
  34. package/dist/stores/file/adoption/adopt.js +616 -0
  35. package/dist/stores/file/adoption/marker.d.ts +194 -0
  36. package/dist/stores/file/adoption/marker.js +198 -0
  37. package/dist/stores/file/background-agent-store.js +2 -0
  38. package/dist/stores/file/checkpoint-store.js +2 -0
  39. package/dist/stores/file/file-snapshot-store.js +2 -0
  40. package/dist/stores/file/index.d.ts +2 -0
  41. package/dist/stores/file/index.js +4 -0
  42. package/dist/stores/file/mailbox-store.js +2 -0
  43. package/dist/stores/file/memory-store.js +2 -0
  44. package/dist/stores/file/session-policy-store.js +2 -0
  45. package/dist/stores/file/session-store.js +2 -0
  46. package/dist/stores/file/task-list-store.js +2 -0
  47. package/dist/stores/file/tool-result-store.js +2 -0
  48. package/dist/stores/file/usage-window-store.js +2 -0
  49. package/dist/stores/file/workflow-journal-store.js +2 -0
  50. package/dist/stores/file/workflow-run-store.js +2 -0
  51. package/dist/tools/fs/bash-readonly-classifier.js +59 -10
  52. package/package.json +3 -2
@@ -1,4 +1,4 @@
1
- import { closeSync, constants as fsConstants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
1
+ import { closeSync, constants as fsConstants, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
2
2
  const { O_WRONLY, O_CREAT, O_TRUNC, O_NOFOLLOW } = fsConstants;
3
3
  import { homedir } from "node:os";
4
4
  import { createHash } from "node:crypto";
@@ -984,9 +984,10 @@ function coerceChallenges(raw) {
984
984
  }
985
985
  return raw;
986
986
  }
987
+ export const CHALLENGE_LEDGER_MAX_EVENTS = 10_000;
987
988
  export function appendChallengeEvents(controlDir, events, now) {
988
989
  if (events.length === 0)
989
- return [];
990
+ return { assignments: [], eventCount: undefined };
990
991
  return lockedStrictUpdate(controlDir, CHALLENGES_FILE, "memory challenge ledger", coerceChallenges, (rec) => {
991
992
  const byEventId = new Map(rec.events.map((e) => [e.eventId, e]));
992
993
  const maxGen = new Map();
@@ -1020,17 +1021,17 @@ export function appendChallengeEvents(controlDir, events, now) {
1020
1021
  out.push({ entryId: ev.entryId, eventId: ev.eventId, generation, at: ev.at, replayed: false });
1021
1022
  changed = true;
1022
1023
  }
1023
- return { ...(changed ? { next: rec } : {}), result: out };
1024
+ return { ...(changed ? { next: rec } : {}), result: { assignments: out, eventCount: rec.events.length } };
1024
1025
  });
1025
1026
  }
1026
1027
  export function resolveChallengeEvent(controlDir, entryId, generation, reason, now, requestId) {
1027
1028
  return lockedStrictUpdate(controlDir, CHALLENGES_FILE, "memory challenge ledger", coerceChallenges, (rec) => {
1028
1029
  const opened = rec.events.some((e) => e.kind === "challenge" && e.entryId === entryId && e.generation === generation);
1029
1030
  if (!opened)
1030
- return { result: false };
1031
+ return { result: { resolved: false, eventCount: undefined } };
1031
1032
  const alreadyResolved = rec.events.some((e) => e.kind === "resolve" && e.entryId === entryId && e.generation === generation);
1032
1033
  if (alreadyResolved)
1033
- return { result: true };
1034
+ return { result: { resolved: true, eventCount: undefined } };
1034
1035
  rec.events.push({
1035
1036
  eventId: requestId !== undefined && requestId !== "" ? `${requestId}:${entryId}` : `resolve:${entryId}:${generation}`,
1036
1037
  entryId,
@@ -1039,7 +1040,7 @@ export function resolveChallengeEvent(controlDir, entryId, generation, reason, n
1039
1040
  at: now(),
1040
1041
  reason,
1041
1042
  });
1042
- return { next: rec, result: true };
1043
+ return { next: rec, result: { resolved: true, eventCount: rec.events.length } };
1043
1044
  });
1044
1045
  }
1045
1046
  export function challengedEntryIds(controlDir) {
@@ -1061,6 +1062,74 @@ export function challengedEntryIds(controlDir) {
1061
1062
  export function readChallengeEvents(controlDir) {
1062
1063
  return coerceChallenges(readStrictSidecar(controlDir, CHALLENGES_FILE, "memory challenge ledger")).events;
1063
1064
  }
1065
+ export function isStrictControlPlaneLedgerCorrupt(controlDir, ledger) {
1066
+ const fileName = ledger === "lineage" ? LINEAGE_FILE : CHALLENGES_FILE;
1067
+ const what = ledger === "lineage" ? "memory lineage ledger" : "memory challenge ledger";
1068
+ const coerce = ledger === "lineage" ? coerceLineage : coerceChallenges;
1069
+ try {
1070
+ coerce(readStrictSidecar(controlDir, fileName, what));
1071
+ return false;
1072
+ }
1073
+ catch (err) {
1074
+ if (err instanceof ControlPlaneCorruptError)
1075
+ return true;
1076
+ throw err;
1077
+ }
1078
+ }
1079
+ export function rebuildStrictControlPlaneLedger(controlDir, ledger, now) {
1080
+ const fileName = ledger === "lineage" ? LINEAGE_FILE : CHALLENGES_FILE;
1081
+ const what = ledger === "lineage" ? "memory lineage ledger" : "memory challenge ledger";
1082
+ const coerce = ledger === "lineage" ? coerceLineage : coerceChallenges;
1083
+ const empty = ledger === "lineage" ? { version: 1, committed: {}, pending: {} } : { version: 1, events: [] };
1084
+ ensureDirExists(controlDir);
1085
+ const file = join(controlDir, fileName);
1086
+ const journal = `${file}.journal`;
1087
+ const lock = `${file}.lock`;
1088
+ const token = acquireSidecarLock(lock, { onDeadline: "throw" });
1089
+ try {
1090
+ let healthy = false;
1091
+ try {
1092
+ coerce(readStrictSidecar(controlDir, fileName, what));
1093
+ healthy = true;
1094
+ }
1095
+ catch (err) {
1096
+ if (!(err instanceof ControlPlaneCorruptError))
1097
+ throw err;
1098
+ }
1099
+ if (healthy) {
1100
+ const e = new Error(`${what} reads cleanly — refusing to rebuild it. This call exists to recover a ledger whose corruption already refuses every read; resetting a healthy one would drop taint evidence that is still doing its job.`);
1101
+ e.code = "memory.control_plane_not_corrupt";
1102
+ throw e;
1103
+ }
1104
+ const at = now();
1105
+ const quarantinedTo = [];
1106
+ assertSidecarLockOwnership(lock, token, what);
1107
+ for (const path of [file, journal]) {
1108
+ if (!existsSync(path))
1109
+ continue;
1110
+ let dest = `${path}.corrupt-${at}`;
1111
+ for (let n = 2;; n++) {
1112
+ try {
1113
+ copyFileSync(path, dest, fsConstants.COPYFILE_EXCL);
1114
+ break;
1115
+ }
1116
+ catch (err) {
1117
+ if (err.code !== "EEXIST")
1118
+ throw err;
1119
+ dest = `${path}.corrupt-${at}-${n}`;
1120
+ }
1121
+ }
1122
+ quarantinedTo.push(dest);
1123
+ }
1124
+ assertSidecarLockOwnership(lock, token, what);
1125
+ atomicWriteFileSync(file, `${JSON.stringify(empty, null, 2)}\n`);
1126
+ rmSync(journal, { force: true });
1127
+ return { ledger, quarantinedTo, at };
1128
+ }
1129
+ finally {
1130
+ releaseSidecarLock(lock, token);
1131
+ }
1132
+ }
1064
1133
  function coerceChallengedHistory(raw) {
1065
1134
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
1066
1135
  return {};
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { parseAllowRuleText, suggestRulesForCommand } from "./permission-rule-model.js";
2
3
  import { errText, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
3
4
  export class InMemoryRuleApprovalRecordStore {
@@ -28,7 +29,7 @@ function nowIso(deps) {
28
29
  function mintId(deps, prefix) {
29
30
  if (deps.newId !== undefined)
30
31
  return deps.newId();
31
- return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
32
+ return `${prefix}_${Date.now().toString(36)}_${randomBytes(9).toString("base64url")}`;
32
33
  }
33
34
  function requirePrincipal(principal, entry) {
34
35
  if (typeof principal !== "string" || principal === "") {
@@ -80,8 +80,42 @@ export interface OrgRuleStatePersistence {
80
80
  /** Duck-typed discovery of the persistence face on a store backend, `writerOf` style. */
81
81
  export declare function orgRuleStatePersistenceOf(store: PermissionRuleStore): OrgRuleStatePersistence | undefined;
82
82
  /** The `decisionReason` the consuming gate stamps on the synthetic asks it mints while org adjudication
83
- * is unavailable. Exported so the gate integration and its tests share one spelling. */
84
- export declare const ORG_UNAVAILABLE_DECISION_REASON = "org_unavailable";
83
+ * is unavailable. Exported so the gate integration and its tests share one spelling — and TYPED as the
84
+ * member of {@link import("./tool-policy.js").DecisionReason} it must equal, so the constant and the
85
+ * vocabulary cannot drift into two spellings of one word. */
86
+ export declare const ORG_UNAVAILABLE_DECISION_REASON: Extract<import("./tool-policy.js").DecisionReason, "org_unavailable">;
87
+ /**
88
+ * How long a single org adjudication may take before the consuming gate stops waiting and reads the
89
+ * answer as UNAVAILABLE (its fail-closed word).
90
+ *
91
+ * A bound is MANDATORY rather than optional because of where this call sits: on the hot path of every
92
+ * governed tool call, and — on the durable resume leg — after the checkpoint has been consumed and the
93
+ * start frame emitted. A provider that never settles would otherwise wedge the call past any task
94
+ * deadline (tasks carry no walltime by default), leaving the frame unpaired and the approval spent with
95
+ * nothing retryable. A hang is the one outcome worse than either verdict.
96
+ *
97
+ * The value matches the auto-mode classifier's per-round-trip cap — the other model/network call the
98
+ * permission path makes — because the reasoning is the same: a governance lookup that takes longer than
99
+ * this is indistinguishable from an outage, and an outage is exactly what the unavailable arm is for.
100
+ * The task's own abort signal still applies in parallel; whichever fires first ends the wait.
101
+ */
102
+ export declare const ORG_ADJUDICATION_TIMEOUT_MS = 15000;
103
+ /**
104
+ * Await `p`, but settle with `fallback` if the deadline elapses or the signal fires first.
105
+ *
106
+ * Shared by both org consumption sites so one bound cannot drift into two. Deliberately NOT `.unref()`
107
+ * on the timer: this is a foreground rescue timer, and the caller is awaiting it inside the control
108
+ * flow — an unref'd timer stops firing in exactly the situation it exists for (an otherwise idle loop).
109
+ * A rejecting `p` also lands on the fallback: the callers' fail-closed word is the same either way, and
110
+ * an escaping rejection here would turn a dependency failure into a task crash.
111
+ */
112
+ export declare function settleOrgVerdictWithin<T>(p: Promise<T>, fallback: T, opts: {
113
+ signal?: AbortSignal;
114
+ timeoutMs: number;
115
+ }): Promise<T>;
116
+ /** The `decisionReason` of a decision an ORG RULE produced (a deny, or a non-dismissable ask). Same
117
+ * single-spelling contract as {@link ORG_UNAVAILABLE_DECISION_REASON}. */
118
+ export declare const ORG_RULE_DECISION_REASON: Extract<import("./tool-policy.js").DecisionReason, "org_rule">;
85
119
  /** How far into the future a snapshot's `fetchedAtMs` may sit before it is refused — ordinary NTP-level
86
120
  * clock skew passes; a far-future timestamp (which would satisfy the staleness bound INDEFINITELY,
87
121
  * turning fail-closed into evergreen freshness) does not. */
@@ -7,6 +7,29 @@ export function orgRuleStatePersistenceOf(store) {
7
7
  : undefined;
8
8
  }
9
9
  export const ORG_UNAVAILABLE_DECISION_REASON = "org_unavailable";
10
+ export const ORG_ADJUDICATION_TIMEOUT_MS = 15_000;
11
+ export function settleOrgVerdictWithin(p, fallback, opts) {
12
+ return new Promise((resolve) => {
13
+ let settled = false;
14
+ const finish = (v) => {
15
+ if (settled)
16
+ return;
17
+ settled = true;
18
+ clearTimeout(timer);
19
+ opts.signal?.removeEventListener("abort", onAbort);
20
+ resolve(v);
21
+ };
22
+ const onAbort = () => finish(fallback);
23
+ const timer = setTimeout(() => finish(fallback), opts.timeoutMs);
24
+ if (opts.signal?.aborted === true) {
25
+ finish(fallback);
26
+ return;
27
+ }
28
+ opts.signal?.addEventListener("abort", onAbort);
29
+ p.then(finish, () => finish(fallback));
30
+ });
31
+ }
32
+ export const ORG_RULE_DECISION_REASON = "org_rule";
10
33
  export const ORG_FETCHED_AT_SKEW_ALLOWANCE_MS = 5 * 60_000;
11
34
  export function createOrgRuleOverlay(cfg) {
12
35
  if (cfg.governed !== true) {
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { canonicalize } from "./canonical-json.js";
2
3
  import { isRuleLive, parseAllowRuleText } from "./permission-rule-model.js";
3
4
  export function sameRuleOwner(a, b) {
@@ -396,7 +397,7 @@ export class InMemoryPermissionRuleStore {
396
397
  observedVector;
397
398
  rev = 0;
398
399
  counter = 0;
399
- constructor(actor = `mem-${Math.random().toString(36).slice(2, 10)}`, now = Date.now) {
400
+ constructor(actor = `mem-${randomBytes(6).toString("base64url")}`, now = Date.now) {
400
401
  this.actor = actor;
401
402
  this.now = now;
402
403
  }
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import type { PersistedAllowRule, RuleDot, RuleScope, RuleTombstone } from "./permission-rule-model.js";
29
29
  import type { PermissionRuleStoreProvider, RuleOwner, RuleSyncDrop, RuleSyncFrontier, RuleSyncState } from "./permission-rule-store.js";
30
+ import { type TracerHook } from "./trace.js";
30
31
  /** The injected HTTP seam — core never bundles a fetch. The deployment owns base URL, auth, TLS and
31
32
  * retries; a non-2xx / network failure should THROW (the round then aborts with zero local effects —
32
33
  * the transport runs before any local write). */
@@ -103,6 +104,13 @@ export declare function syncPermissionRules(opts: {
103
104
  transport: PermissionRuleSyncTransport;
104
105
  /** Injected clock — observation timestamps only, never adjudication input. */
105
106
  now?: () => number;
107
+ /** design/182 §4.6 — optional trace sink for the round's DISCLOSURES: one
108
+ * `permission.rule_sync_resurrected` per removed→live transition and one
109
+ * `permission.rule_sync_dropped` per refused/quarantined row. The same facts are already on the
110
+ * returned result; this is the operator channel for a deployment that drives sync outside a task
111
+ * (where there is no `RunnerDeps.tracer` in scope) and does not read the result itself. Absent ⇒ no
112
+ * events, and the round is byte-identical. */
113
+ tracer?: TracerHook;
106
114
  }): Promise<PermissionRuleSyncResult>;
107
115
  interface ParsedRuleSyncResponse {
108
116
  principal: string;
@@ -1,5 +1,6 @@
1
1
  import { parseAllowRuleText } from "./permission-rule-model.js";
2
2
  import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector, sameScope, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
3
+ import { emitTrace } from "./trace.js";
3
4
  export const PERMISSION_RULE_SYNC_PATH = "/v1/permission-rules/sync";
4
5
  export const LOCAL_OWNER_UNSYNCABLE_CODE = "permission_rules.local_owner_unsyncable";
5
6
  function refuseLocalOwnerSync() {
@@ -19,6 +20,34 @@ export async function syncPermissionRules(opts) {
19
20
  if (typeof opts.principal !== "string" || opts.principal === "") {
20
21
  throw new Error("syncPermissionRules requires a verified principal — an unauthenticated deployment has no cloud bucket to sync");
21
22
  }
23
+ const traceClock = opts.now ?? Date.now;
24
+ const disclose = (result) => {
25
+ const tracer = opts.tracer;
26
+ if (tracer === undefined)
27
+ return result;
28
+ for (const r of result.resurrected) {
29
+ emitTrace(tracer, () => ({
30
+ kind: "permission.rule_sync_resurrected",
31
+ version: 1,
32
+ principal: opts.principal,
33
+ rule: r.rule,
34
+ scopeKind: r.scope.kind,
35
+ ts: traceClock(),
36
+ }));
37
+ }
38
+ for (const d of result.dropped) {
39
+ emitTrace(tracer, () => ({
40
+ kind: "permission.rule_sync_dropped",
41
+ version: 1,
42
+ principal: opts.principal,
43
+ rule: d.rule,
44
+ scopeKind: d.scope.kind,
45
+ reason: d.reason,
46
+ ts: traceClock(),
47
+ }));
48
+ }
49
+ return result;
50
+ };
22
51
  const store = opts.provider.forPrincipal(opts.principal);
23
52
  const writer = writerOf(store);
24
53
  if (writer === undefined) {
@@ -111,7 +140,7 @@ export async function syncPermissionRules(opts) {
111
140
  res = await writer.apply(delta, { expectedRev: current.rev });
112
141
  }
113
142
  catch (err) {
114
- return {
143
+ return disclose({
115
144
  ok: false,
116
145
  pushed: { addDots: raw.rules.reduce((n, r) => n + r.adds.length, 0), tombstones: raw.tombstones.length },
117
146
  landed: { newAdds: 0, newTombstones: 0 },
@@ -119,7 +148,7 @@ export async function syncPermissionRules(opts) {
119
148
  dropped,
120
149
  rev: current.rev,
121
150
  warnings: [...warnings, `the store refused the sync landing: ${errText(err)} — nothing landed, the local state is unchanged`],
122
- };
151
+ });
123
152
  }
124
153
  if ("conflict" in res) {
125
154
  current = await writer.readRaw();
@@ -156,7 +185,7 @@ export async function syncPermissionRules(opts) {
156
185
  if (!dropped.some((x) => x.dot.actor === q.dot.actor && x.dot.counter === q.dot.counter && x.reason === q.reason))
157
186
  dropped.push(q);
158
187
  }
159
- return {
188
+ return disclose({
160
189
  ok: dropped.length === 0 && warnings.length === 0,
161
190
  pushed: { addDots: raw.rules.reduce((n, r) => n + r.adds.length, 0), tombstones: raw.tombstones.length },
162
191
  landed: { newAdds, newTombstones },
@@ -164,9 +193,9 @@ export async function syncPermissionRules(opts) {
164
193
  dropped,
165
194
  rev: landedRaw.rev,
166
195
  ...(warnings.length > 0 ? { warnings } : {}),
167
- };
196
+ });
168
197
  }
169
- return {
198
+ return disclose({
170
199
  ok: false,
171
200
  pushed: { addDots: raw.rules.reduce((n, r) => n + r.adds.length, 0), tombstones: raw.tombstones.length },
172
201
  landed: { newAdds: 0, newTombstones: 0 },
@@ -174,7 +203,7 @@ export async function syncPermissionRules(opts) {
174
203
  dropped,
175
204
  rev: current.rev,
176
205
  warnings: [...warnings, `optimistic-concurrency retries exhausted after ${SYNC_MAX_ATTEMPTS} attempts — nothing landed, the local state is unchanged`],
177
- };
206
+ });
178
207
  }
179
208
  function isDot(v) {
180
209
  const d = v;
@@ -8,10 +8,10 @@ import type { HarvestReport, MemorySessionHandle } from "../memory-engine/types.
8
8
  import { StoredSession } from "../session.js";
9
9
  import type { SessionStore } from "../session.js";
10
10
  import { SubagentRetainLedger } from "../../agents/retain-ledger.js";
11
- import type { OnAsk, ToolPolicy } from "../tool-policy.js";
11
+ import type { OnAsk, ToolCallRequest, ToolPolicy } from "../tool-policy.js";
12
12
  import { type ActiveSkillFrame } from "./active-skill-scope.js";
13
13
  import type { SessionPermissionRules } from "../session-policy-store.js";
14
- import { type Hooks } from "../hooks.js";
14
+ import { type Hooks, type OrgGateVerdict } from "../hooks.js";
15
15
  import { type RecoveredOrphan } from "../session-reconcile.js";
16
16
  import { CacheBreakDetector, type ToolFingerprintInput } from "../cache-break-detector.js";
17
17
  import { type BrainCallGuardrailRef } from "../../brain/timeout.js";
@@ -214,6 +214,14 @@ export interface Prepared {
214
214
  /** RB-63: the deployment's own caller policy, re-checked on a durable resume ONLY when the approver
215
215
  * rewrote the pending call's args (see the composition site for why the edit case is special). */
216
216
  basePolicyForResumeEdit?: ToolPolicy;
217
+ /** design/182 §7 — the ORG adjudication face, re-resolved on a durable RESUME before an approved
218
+ * pending call executes. The resume path bypasses the harness gate by design (a human already
219
+ * adjudicated the checkpointed call), which is exactly where org policy skew is most likely: the
220
+ * suspend may have outlived the snapshot revision that was current when it was minted. Present only
221
+ * on a governed deployment. */
222
+ permissionRuleOrg?: {
223
+ adjudicate: (req: ToolCallRequest) => Promise<OrgGateVerdict>;
224
+ };
217
225
  /** Removes the `spec.signal` abort listener on task end (else a long-lived signal leaks listeners). */
218
226
  releaseSignal: () => void;
219
227
  /**
@@ -30,6 +30,7 @@ import { policyAskClassOf } from "../ask-class.js";
30
30
  import { emitTrace } from "../trace.js";
31
31
  import { createSessionRulePolicy } from "./session-rule-policy.js";
32
32
  import { cloneObserverInput, createHookEnvCapabilities, createPreToolUseConstraintPolicy, formatHookFeedback, runToolGate } from "../hooks.js";
33
+ import { orgRuleVerdictFor } from "../permission-rule-org.js";
33
34
  import { reconcileInterruptedSession } from "../session-reconcile.js";
34
35
  import { CacheBreakDetector, toolsToFingerprintInputs } from "../cache-break-detector.js";
35
36
  import { reservedCollisions, reservedFor } from "../../brain/request-params.js";
@@ -3144,12 +3145,22 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3144
3145
  };
3145
3146
  const permissionRuleLane = (() => {
3146
3147
  const provider = deps.permissionRuleStore;
3148
+ const localOwnerDeclared = deps.localOwnerRules === true;
3149
+ if (localOwnerDeclared) {
3150
+ if (provider === undefined) {
3151
+ throw new Error("RunnerDeps.localOwnerRules is declared but no permissionRuleStore provider is wired — there is no bucket for the local owner to hold rules in; refusing rather than running as if the declaration were absent");
3152
+ }
3153
+ if (provider.forLocalOwner === undefined) {
3154
+ throw new Error("RunnerDeps.localOwnerRules is declared but the wired permissionRuleStore provider implements no forLocalOwner() face — a provider without a local-owner bucket cannot honor the declaration; refusing rather than silently resolving zero rules");
3155
+ }
3156
+ }
3147
3157
  if (provider === undefined)
3148
3158
  return undefined;
3149
3159
  const root = taskRootPath;
3150
3160
  return {
3151
3161
  admits: async (req) => {
3152
- if (spec.principal === undefined || spec.principal === "")
3162
+ const anonymous = spec.principal === undefined || spec.principal === "";
3163
+ if (anonymous && !localOwnerDeclared)
3153
3164
  return undefined;
3154
3165
  if (req.toolName !== PERSISTED_RULE_TOOL)
3155
3166
  return undefined;
@@ -3158,7 +3169,10 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3158
3169
  return undefined;
3159
3170
  let listed;
3160
3171
  try {
3161
- listed = await provider.forPrincipal(spec.principal).list();
3172
+ listed = anonymous
3173
+ ?
3174
+ await provider.forLocalOwner().list()
3175
+ : await provider.forPrincipal(spec.principal).list();
3162
3176
  }
3163
3177
  catch (err) {
3164
3178
  emitTrace(deps.tracer, () => ({
@@ -3174,6 +3188,31 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3174
3188
  },
3175
3189
  };
3176
3190
  })();
3191
+ const permissionRuleOrgLane = (() => {
3192
+ const overlay = deps.permissionRuleOrg;
3193
+ if (overlay === undefined)
3194
+ return undefined;
3195
+ return {
3196
+ adjudicate: async (req) => {
3197
+ if (req.toolName === ASK_USER_QUESTION_TOOL_NAME && questionToolMounted)
3198
+ return { status: "available" };
3199
+ let resolution;
3200
+ try {
3201
+ resolution = await overlay.resolve();
3202
+ }
3203
+ catch (err) {
3204
+ return { status: "unavailable", disclosures: [`the org rule overlay threw: ${err instanceof Error ? err.message : String(err)}`] };
3205
+ }
3206
+ if (resolution.status === "unavailable")
3207
+ return { status: "unavailable", disclosures: resolution.disclosures };
3208
+ const command = req.args?.command;
3209
+ if (req.toolName !== PERSISTED_RULE_TOOL || typeof command !== "string")
3210
+ return { status: "available" };
3211
+ const verdict = orgRuleVerdictFor(resolution.rules, { tool: req.toolName, command });
3212
+ return verdict === undefined ? { status: "available" } : { status: "available", verdict };
3213
+ },
3214
+ };
3215
+ })();
3177
3216
  const ruleSuggestionsOf = (toolName, args) => {
3178
3217
  if (permissionRuleLane === undefined || toolName !== PERSISTED_RULE_TOOL)
3179
3218
  return {};
@@ -3621,6 +3660,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3621
3660
  sessionDurability: resolveDeclaredDurability(sessions, "sessionStore"),
3622
3661
  backgroundAgentStoreWired: deps.backgroundAgentStore !== undefined,
3623
3662
  permissionRuleStoreWired: deps.permissionRuleStore !== undefined,
3663
+ permissionRuleSyncWired: deps.permissionRuleSyncWired === true,
3664
+ permissionRuleOrgGoverned: deps.permissionRuleOrg !== undefined,
3624
3665
  hostChildEventSinkWired: deps.onBackgroundChildEvent !== undefined,
3625
3666
  lockedConfigWired: deps.lockedConfig !== undefined,
3626
3667
  complianceWired: deps.compliancePostureResolver !== undefined,
@@ -3837,8 +3878,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3837
3878
  return { ok: false };
3838
3879
  if (memoryEngineSession)
3839
3880
  await memoryEngineSession.harvest("checkpoint");
3840
- try {
3841
- await checkpointStore.put(token, cp);
3881
+ const announceCommittedScreeningPark = () => {
3842
3882
  const committedCount = cp.state.inheritedGate?.parentConstraintCount;
3843
3883
  if (cp.state.inheritedGate?.requiresParentConstraint === true &&
3844
3884
  !screeningParkDisclosedRef.done &&
@@ -3855,10 +3895,59 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3855
3895
  catch {
3856
3896
  }
3857
3897
  }
3898
+ };
3899
+ const confirmPutOutcome = async (putErr) => {
3900
+ if (putErr?.code === "checkpoint.already_exists")
3901
+ return "absent";
3902
+ try {
3903
+ const row = await checkpointStore.get(token);
3904
+ if (row == null)
3905
+ return "absent";
3906
+ const sameCall = (a, b) => a.kind === b.kind && (a.kind !== "tool_approval" || b.kind !== "tool_approval" || a.toolCallId === b.toolCallId);
3907
+ const isOurs = row.scope === cp.scope && row.sessionId === cp.sessionId && row.leafId === cp.leafId && sameCall(row.pendingAction, cp.pendingAction);
3908
+ return isOurs ? "committed" : "absent";
3909
+ }
3910
+ catch (readErr) {
3911
+ deps.onError?.(readErr, { phase: "config", sessionId });
3912
+ return "unknown";
3913
+ }
3914
+ };
3915
+ try {
3916
+ await checkpointStore.put(token, cp);
3917
+ announceCommittedScreeningPark();
3858
3918
  return { ok: true };
3859
3919
  }
3860
3920
  catch (putErr) {
3861
3921
  deps.onError?.(putErr, { phase: "config", sessionId });
3922
+ const confirmed = await confirmPutOutcome(putErr);
3923
+ if (confirmed === "committed") {
3924
+ try {
3925
+ deps.onError?.(new Error(`durable approval checkpoint: the store's write reported a failure but the row EXISTS — the commit ` +
3926
+ `landed and its acknowledgement was lost (a network backend can complete the INSERT and drop the ` +
3927
+ `connection before replying). The run suspends on that row rather than continuing, which would leave ` +
3928
+ `a redeemable pending checkpoint behind. This backend's acknowledgement path is lossy — the operator ` +
3929
+ `face carries the store's own message.`), { phase: "degraded", sessionId, classification: "checkpoint-put-confirmation-lost" });
3930
+ }
3931
+ catch {
3932
+ }
3933
+ announceCommittedScreeningPark();
3934
+ return { ok: true };
3935
+ }
3936
+ if (confirmed === "unknown") {
3937
+ const unknownReason = "the approval checkpoint's state cannot be established (the store rejected the write and then could not be read back; a committed-but-unacknowledged row may exist) — the run is stopped rather than continued past an approval whose durable record is unknown";
3938
+ try {
3939
+ deps.onError?.(new Error(`durable approval checkpoint: ${unknownReason}`), {
3940
+ phase: "degraded",
3941
+ sessionId,
3942
+ classification: "checkpoint-put-outcome-unknown",
3943
+ });
3944
+ }
3945
+ catch {
3946
+ }
3947
+ abortController.abort();
3948
+ void harness.abort();
3949
+ return { ok: false, reason: unknownReason };
3950
+ }
3862
3951
  const reason = "the approval checkpoint could not be persisted (the store rejected the write; the deployment's error face carries the store's own message)";
3863
3952
  if (remoteEnv !== undefined && remoteHandle?.snapshotId !== undefined) {
3864
3953
  const { outcome: back, attempts: backAttempts } = await restoreWorkspaceWithRetry(remoteEnv, remoteHandle.snapshotId, {
@@ -4422,6 +4511,23 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4422
4511
  },
4423
4512
  }
4424
4513
  : {}),
4514
+ ...(permissionRuleOrgLane
4515
+ ? {
4516
+ orgRules: {
4517
+ adjudicate: permissionRuleOrgLane.adjudicate,
4518
+ contentAskToolMounted: questionToolMounted,
4519
+ onUnavailable: (info) => emitTrace(deps.tracer, () => ({
4520
+ kind: "permission.org_snapshot_unavailable",
4521
+ version: 1,
4522
+ taskId: spec.taskId ?? sessionId,
4523
+ toolName: info.toolName,
4524
+ toolCallId: info.toolCallId,
4525
+ message: info.message,
4526
+ ts: Date.now(),
4527
+ })),
4528
+ },
4529
+ }
4530
+ : {}),
4425
4531
  isMarkedUnresolvable: (toolCallId) => inheritedUnavailableAsks.has(toolCallId),
4426
4532
  ...(sandboxAdmissionArmed
4427
4533
  ? {
@@ -4750,7 +4856,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4750
4856
  : undefined;
4751
4857
  overheadState.promptChars = systemPrompt.length;
4752
4858
  const preparedHolder = {};
4753
- const buildPrepared = () => ({ harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp: mcp, ...(a2a !== undefined && a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, approvalSettledBy, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), releaseSignal, settleContentAskBindings, cacheBreakDetector, cacheFingerprint, wiringManifest, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, deferDirectCall, ...(staticFaceForRef.current !== undefined ? { staticFaceFor: staticFaceForRef.current } : {}), ownedEnv, suspendRef, suspendProgressRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, listBackgroundTasks, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
4859
+ const buildPrepared = () => ({ harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp: mcp, ...(a2a !== undefined && a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, approvalSettledBy, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), ...(permissionRuleOrgLane !== undefined ? { permissionRuleOrg: permissionRuleOrgLane } : {}), releaseSignal, settleContentAskBindings, cacheBreakDetector, cacheFingerprint, wiringManifest, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, deferDirectCall, ...(staticFaceForRef.current !== undefined ? { staticFaceFor: staticFaceForRef.current } : {}), ownedEnv, suspendRef, suspendProgressRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, listBackgroundTasks, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
4754
4860
  const prepared = buildPrepared();
4755
4861
  preparedHolder.current = prepared;
4756
4862
  return prepared;
@@ -9,6 +9,7 @@ import { ASK_USER_QUESTION_TOOL_NAME, canonicalizeCapturedPlainData, classifyQue
9
9
  import { boundInputHashOf } from "../canonical-json.js";
10
10
  import { computeCostMicroUsd, modelCostToPricing } from "../pricing.js";
11
11
  import { emitTrace } from "../trace.js";
12
+ import { ORG_ADJUDICATION_TIMEOUT_MS, settleOrgVerdictWithin } from "../permission-rule-org.js";
12
13
  import { emitTaskOutcome } from "../task-outcome.js";
13
14
  import { isDegenerateCutMessage } from "../../brain/terminal-cause.js";
14
15
  import { primaryActivityArg } from "../arg-summary.js";
@@ -4008,6 +4009,24 @@ export class Runner {
4008
4009
  return;
4009
4010
  }
4010
4011
  }
4012
+ if (prepared.permissionRuleOrg !== undefined) {
4013
+ const orgVerdict = prepared.permissionRuleOrg
4014
+ .adjudicate({ toolName: pendingAction.toolName, args: resolvedArgs, toolCallId: pendingAction.toolCallId })
4015
+ .catch(() => ({ status: "unavailable", disclosures: ["the org adjudication face threw on resume"] }));
4016
+ const org = await settleOrgVerdictWithin(orgVerdict, { status: "unavailable", disclosures: [`the org adjudication face did not answer within ${ORG_ADJUDICATION_TIMEOUT_MS}ms (or the task ended first)`] }, { signal: prepared.abortController.signal, timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
4017
+ const blocked = org.status === "unavailable"
4018
+ ? "this deployment is org-governed and cannot currently adjudicate against an organization policy snapshot"
4019
+ : org.verdict?.behavior === "deny"
4020
+ ? `an organization policy rule (${org.verdict.rule}) denies it`
4021
+ : undefined;
4022
+ if (blocked !== undefined) {
4023
+ const orgDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" was not executed: ${blocked}. This approval is spent — the call has to be re-issued and approved again once organization policy permits it.`);
4024
+ emitEnd(true, { content: orgDenial });
4025
+ const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, orgDenial, true));
4026
+ emitCommitted(eid, "toolResult", pendingAction.toolCallId);
4027
+ return;
4028
+ }
4029
+ }
4011
4030
  if (prepared.denyNarrowingPolicy) {
4012
4031
  const narrowed = refuseOutOfContractDecision(await prepared.denyNarrowingPolicy.check({
4013
4032
  toolName: pendingAction.toolName,