@sema-agent/core 6.0.0 → 7.0.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.
@@ -0,0 +1,121 @@
1
+ import { isValidConsentScope, normalizePersistedRule } from "./permission-rule-store.js";
2
+ function isValidOverlayAdd(add) {
3
+ const a = add;
4
+ if (typeof a?.dot?.actor !== "string" || a.dot.actor === "" || typeof a.dot.counter !== "number" || !Number.isFinite(a.dot.counter))
5
+ return false;
6
+ if (a.origin !== "user" && a.origin !== "imported-cc" && a.origin !== "starter")
7
+ return false;
8
+ return typeof a.createdAt === "string";
9
+ }
10
+ export class InMemorySessionRuleOverlay {
11
+ rows = new Map();
12
+ sealed = new Set();
13
+ epochs = new Map();
14
+ bumpEpoch(sessionId) {
15
+ this.epochs.set(sessionId, (this.epochs.get(sessionId) ?? 0) + 1);
16
+ }
17
+ async read(sessionId) {
18
+ return structuredClone(this.rows.get(sessionId) ?? []);
19
+ }
20
+ async apply(sessionId, add) {
21
+ if (this.sealed.has(sessionId))
22
+ return { refused: "session_ended" };
23
+ add = structuredClone(add);
24
+ if (this.sealed.has(sessionId))
25
+ return { refused: "session_ended" };
26
+ if (!isValidOverlayAdd(add?.add)) {
27
+ throw new Error("SessionRuleOverlay.apply was handed a malformed add (dot/origin/createdAt) — evidence this shape cannot vouch for must not install");
28
+ }
29
+ const scope = { kind: "session", sessionId };
30
+ if (!isValidConsentScope(scope)) {
31
+ throw new Error("SessionRuleOverlay.apply was addressed with an unusable session id — a session nobody can name is not a scope a grant can live under");
32
+ }
33
+ const projected = { rule: add.rule, tool: add.tool, match: add.match, command: add.command, scope, adds: [add.add] };
34
+ if ("reject" in normalizePersistedRule(projected)) {
35
+ throw new Error("SessionRuleOverlay.apply was handed a row whose text/metadata do not survive the canonical re-projection — a row the engine cannot re-project can never adjudicate, and would refuse its own restore");
36
+ }
37
+ const rows = this.rows.get(sessionId) ?? [];
38
+ if (!this.rows.has(sessionId))
39
+ this.rows.set(sessionId, rows);
40
+ const sameDotAs = (a) => a.dot.actor === add.add.dot.actor && a.dot.counter === add.add.dot.counter;
41
+ for (const [heldSession, heldRows] of this.rows) {
42
+ const holder = heldRows.find((r) => r.adds.some(sameDotAs));
43
+ if (holder !== undefined && (heldSession !== sessionId || holder.rule !== add.rule)) {
44
+ throw new Error(`SessionRuleOverlay.apply: dot ${add.add.dot.actor}#${add.add.dot.counter} already vouches for another grant — a dot identifies one add of one (rule, session), ever`);
45
+ }
46
+ }
47
+ const existing = rows.find((r) => r.rule === add.rule);
48
+ if (existing === undefined) {
49
+ rows.push({ rule: add.rule, tool: add.tool, match: add.match, command: add.command, scope, adds: [structuredClone(add.add)] });
50
+ this.bumpEpoch(sessionId);
51
+ return { landed: true };
52
+ }
53
+ if (!existing.adds.some((a) => a.dot.actor === add.add.dot.actor && a.dot.counter === add.add.dot.counter)) {
54
+ existing.adds.push(structuredClone(add.add));
55
+ this.bumpEpoch(sessionId);
56
+ }
57
+ return { landed: true };
58
+ }
59
+ endSession(sessionId) {
60
+ this.sealed.add(sessionId);
61
+ this.rows.delete(sessionId);
62
+ this.bumpEpoch(sessionId);
63
+ }
64
+ async snapshotSession(sessionId) {
65
+ return this.read(sessionId);
66
+ }
67
+ async restoreSession(sessionId, rows) {
68
+ if (this.sealed.has(sessionId)) {
69
+ throw new Error(`session "${sessionId}" has ended — a sealed session's authorizations cannot be restored (termination is one-way)`);
70
+ }
71
+ const epochBeforeRead = this.epochs.get(sessionId) ?? 0;
72
+ rows = structuredClone(rows);
73
+ if (this.sealed.has(sessionId)) {
74
+ throw new Error(`session "${sessionId}" has ended — a sealed session's authorizations cannot be restored (termination is one-way)`);
75
+ }
76
+ if ((this.epochs.get(sessionId) ?? 0) !== epochBeforeRead) {
77
+ throw new Error(`restoreSession("${sessionId}") raced a landing on the same session — refusing the whole restore (this door replaces the session's rows, and a grant that already answered landed must not be erased by a resume)`);
78
+ }
79
+ for (const r of rows) {
80
+ const scope = r?.scope;
81
+ if (scope?.kind !== "session" || scope.sessionId !== sessionId || !isValidConsentScope(scope)) {
82
+ throw new Error(`restoreSession("${sessionId}") was handed a row that is not a session row of that session — refusing the whole restore (a foreign-scope row through this door would reach adjudication)`);
83
+ }
84
+ if ("reject" in normalizePersistedRule(r)) {
85
+ throw new Error(`restoreSession("${sessionId}") was handed a row whose text/metadata do not survive the canonical re-projection — refusing the whole restore (a row the engine cannot re-project must not reach adjudication)`);
86
+ }
87
+ }
88
+ const ownDotRule = new Map();
89
+ for (const held of this.rows.get(sessionId) ?? []) {
90
+ for (const h of held.adds)
91
+ ownDotRule.set(`${h.dot.actor}#${h.dot.counter}`, held.rule);
92
+ }
93
+ const seenDots = new Map();
94
+ for (const r of rows) {
95
+ if (!Array.isArray(r.adds) || r.adds.length === 0 || !r.adds.every(isValidOverlayAdd)) {
96
+ throw new Error(`restoreSession("${sessionId}") was handed a row with malformed add evidence — refusing the whole restore (an add whose dot/origin cannot be read is not a consent anyone can audit)`);
97
+ }
98
+ for (const a of r.adds) {
99
+ const key = `${a.dot.actor}#${a.dot.counter}`;
100
+ const holder = seenDots.get(key);
101
+ if (holder !== undefined && holder !== r.rule) {
102
+ throw new Error(`restoreSession("${sessionId}"): dot ${key} vouches for two different rules — refusing the whole restore`);
103
+ }
104
+ seenDots.set(key, r.rule);
105
+ const ownHolder = ownDotRule.get(key);
106
+ if (ownHolder !== undefined && ownHolder !== r.rule) {
107
+ throw new Error(`restoreSession("${sessionId}"): dot ${key} is live in this session under a different rule — a restore may replay a grant, never reassign its dot; refusing the whole restore`);
108
+ }
109
+ for (const [heldSession, heldRows] of this.rows) {
110
+ if (heldSession === sessionId)
111
+ continue;
112
+ if (heldRows.some((held) => held.adds.some((h) => h.dot.actor === a.dot.actor && h.dot.counter === a.dot.counter))) {
113
+ throw new Error(`restoreSession("${sessionId}"): dot ${key} already vouches for another live session's grant — refusing the whole restore`);
114
+ }
115
+ }
116
+ }
117
+ }
118
+ this.rows.set(sessionId, rows);
119
+ this.bumpEpoch(sessionId);
120
+ }
121
+ }
@@ -215,7 +215,8 @@ export interface WritablePermissionRuleStore extends PermissionRuleStore {
215
215
  }
216
216
  /** The writer of a store, or `undefined` when the backend is read-only from the engine's side. */
217
217
  export declare function writerOf(store: PermissionRuleStore): PermissionRuleWriter | undefined;
218
- /** Do two scopes name the same place? */
218
+ /** Do two scopes name the same place? Three members (design/382 §4.1): global, project-by-root,
219
+ * session-by-sessionId. */
219
220
  export declare function sameScope(a: RuleScope, b: RuleScope): boolean;
220
221
  /**
221
222
  * Apply tombstones to a raw persisted set: an add survives iff its dot appears in no tombstone for the
@@ -238,6 +239,33 @@ export declare function addDotsOf(rules: readonly PersistedAllowRule[]): RuleDot
238
239
  * Every backend calls this before appending a tombstone.
239
240
  */
240
241
  export declare function assertDeleteDeltaCarriesNoAdd(delta: RuleDeleteDelta): void;
242
+ /**
243
+ * design/382 §4.3 — the DURABLE-scope gate of the two direct write arms, shared by every backend
244
+ * (the `assertDeleteDeltaCarriesNoAdd` doctrine: the check is executed, not merely intended, because a
245
+ * union does not constrain a JavaScript caller's payload).
246
+ *
247
+ * A `redemption-add` whose scope — or a `tighten-delete` whose tombstone's scope — is a session scope
248
+ * refuses LOUDLY with the typed code `unsupported.session_scope_store`: a session authorization's home
249
+ * is the session's own overlay, and a session TOMBSTONE is equally unspellable here — session rows are
250
+ * not individually deletable (they die with their session, design/382 §10), so a tombstone naming one
251
+ * is either a foreign writer's fabrication or a caller wiring fault, and both deserve the loud arm.
252
+ * The third arm (`sync-join`) is NOT gated here: its session rows are per-row DROPPED AND DISCLOSED by
253
+ * {@link screenRuleSyncState} (a peer's damage must not veto a whole round the way a local caller's
254
+ * bug must), and a session-scoped QUARANTINE instruction throws inside {@link applySyncJoin} itself.
255
+ *
256
+ * The session member is the LOUD, typed arm; the rest of the face is executed too. A scope that is
257
+ * neither member in well-formed shape — `{kind:"project", root:""}` above all, which `pathWithinRoot`
258
+ * turns into base `/` so the row crosses every project boundary — is a caller wiring fault, refused
259
+ * with the untyped loud arm the sibling {@link assertDeleteDeltaCarriesNoAdd} uses (no closed-set code
260
+ * names it: it is nobody's supported request). The sync arm already refuses the same shape inbound
261
+ * ({@link applySyncJoin} runs {@link isValidDurableScope} on every joined row), so this closes the
262
+ * matching direct-write door rather than inventing a new rule.
263
+ *
264
+ * Exported with the backend contract for the same OPEN-SET duty {@link isValidDurableScope} names:
265
+ * any lane — host-side import doors included — that writes rows into a persisted store carries the
266
+ * durable two-member face.
267
+ */
268
+ export declare function assertWriteDeltaScopeDurable(delta: RuleAddDelta | RuleDeleteDelta): void;
241
269
  /**
242
270
  * The state one sync round exchanges: raw adds (tombstoned dots NOT pre-filtered) plus tombstones.
243
271
  * The live view is always DERIVED (`applyTombstones`); the join below never deletes anything.
@@ -291,6 +319,27 @@ export interface RuleSyncLandingReport {
291
319
  * a dropped tombstone may have covered them — they re-arrive once the peer's state is clean). */
292
320
  withheldAdds: number;
293
321
  }
322
+ /**
323
+ * design/382 §4.3 — the CONSENT face's scope validity: the union's full three members, their shapes
324
+ * executed (a project scope with an empty root would admit every cwd, wider than global, through
325
+ * `pathWithinRoot`'s base construction; an empty sessionId would be a session nobody can name).
326
+ * Read by the consent protocol's entries (prepare's explicit-scope gate, the record integrity walk),
327
+ * and exported for the server's consent-projection twin. NEVER by a durable entrance — that face is
328
+ * {@link isValidDurableScope}, and keeping the two as separate named tables is the design's point:
329
+ * a session row leaking toward the store must meet an explicit refusal at every door, not a shared
330
+ * table someone widened for the other face's sake.
331
+ */
332
+ export declare function isValidConsentScope(scope: RuleScope): boolean;
333
+ /**
334
+ * design/382 §4.3 — the DURABLE face's scope validity: the two members a persisted row may carry,
335
+ * `{global, project}`. A session scope is a WELL-FORMED consent scope that is structurally not a
336
+ * durable one — session authorizations live in the session's own overlay and die with it; letting one
337
+ * into the store (or the sync wire, or the at-rest bytes) would give it exactly the afterlife the
338
+ * dimension is defined not to have. Exported for the server's rule-sync validator (same face, same
339
+ * two members) and for any host-side lane that writes rows into a persisted store — the OPEN-SET
340
+ * duty (design/382 §10): every such entrance, present or future, carries this face.
341
+ */
342
+ export declare function isValidDurableScope(scope: RuleScope): boolean;
294
343
  /** Is `dot` at or below the frontier? Absent actor ⇒ not below (a frontier never covers what it never saw). */
295
344
  export declare function dotAtOrBelowFrontier(dot: RuleDot, frontier: RuleSyncFrontier): boolean;
296
345
  /**
@@ -314,7 +363,21 @@ export declare function normalizePersistedRule(r: PersistedAllowRule): {
314
363
  } | {
315
364
  reject: Extract<RuleSyncDropReason, "invalid_rule_text" | "metadata_mismatch">;
316
365
  };
317
- /** The normalization half exposed for the DISCLOSURE layer: which records a join would refuse, and why. */
366
+ /** The normalization half exposed for the DISCLOSURE layer: which records a join would refuse, and why.
367
+ *
368
+ * design/382 §4.3 — this screen carries the SESSION member of the durable face on every JOIN leg
369
+ * (inbound state, local state, both sides of the pure join): a rule or tombstone row carrying a
370
+ * session scope is dropped and reported (`session_scope_not_durable`), never landed and never allowed
371
+ * to veto the round the way a malformed tombstone does. Dropping a session TOMBSTONE cannot widen:
372
+ * tombstones remove adds only under the SAME (rule, scope), and every session-scope add is dropped by
373
+ * this same screen — the #176 "its cover is not load-bearing" argument, one scope over.
374
+ *
375
+ * The face's OTHER member — a durable scope that is not well-formed, `{kind:"project", root:""}`
376
+ * above all — is executed on the INBOUND legs by {@link applySyncJoin} (its tombstone pre-loop and its
377
+ * rule arm both run {@link isValidDurableScope}) and at the direct write arms by
378
+ * {@link assertWriteDeltaScopeDurable}, not here. Stated so the boundary is readable: a LOCAL row of
379
+ * that shape — reachable only by editing the at-rest bytes, or from a store written before the write
380
+ * arm executed that axis — passes this screen and stays live. */
318
381
  export declare function screenRuleSyncState(state: RuleSyncState): {
319
382
  state: RuleSyncState;
320
383
  rejected: RuleSyncDrop[];
@@ -10,7 +10,11 @@ export function writerOf(store) {
10
10
  return w !== undefined && typeof w.apply === "function" && typeof w.nextDot === "function" ? w : undefined;
11
11
  }
12
12
  export function sameScope(a, b) {
13
- return a.kind === "global" ? b.kind === "global" : b.kind === "project" && a.root === b.root;
13
+ if (a.kind === "global")
14
+ return b.kind === "global";
15
+ if (a.kind === "session")
16
+ return b.kind === "session" && a.sessionId === b.sessionId;
17
+ return b.kind === "project" && a.root === b.root;
14
18
  }
15
19
  function sameDot(a, b) {
16
20
  return a.actor === b.actor && a.counter === b.counter;
@@ -47,7 +51,29 @@ export function assertDeleteDeltaCarriesNoAdd(delta) {
47
51
  throw new Error("a tighten-delete must carry a tombstone naming at least one observed add dot");
48
52
  }
49
53
  }
50
- function isValidScope(scope) {
54
+ export function assertWriteDeltaScopeDurable(delta) {
55
+ const scope = delta.kind === "redemption-add" ? delta.scope : delta.tombstone?.scope;
56
+ if (scope?.kind === "session") {
57
+ const e = new Error(delta.kind === "redemption-add"
58
+ ? "a session-scope rule cannot enter the persisted store — its home is the session's own overlay (design/382 §4.3); refusing the redemption-add"
59
+ : "a session-scope tombstone cannot enter the persisted store — session rows are not individually deletable and die with their session (design/382 §4.3/§10); refusing the tighten-delete");
60
+ e.code = "unsupported.session_scope_store";
61
+ throw e;
62
+ }
63
+ if (!isValidDurableScope(scope)) {
64
+ throw new Error(delta.kind === "redemption-add"
65
+ ? "a redemption-add whose scope is not a well-formed durable scope cannot enter the persisted store (design/382 §4.3: the face is {global, project-with-a-root}) — refusing the write"
66
+ :
67
+ "a tighten-delete must carry a tombstone whose scope is a well-formed durable scope (design/382 §4.3: the face is {global, project-with-a-root}) — refusing the write");
68
+ }
69
+ }
70
+ export function isValidConsentScope(scope) {
71
+ const sc = scope;
72
+ return (sc?.kind === "global" ||
73
+ (sc?.kind === "project" && typeof sc.root === "string" && sc.root !== "") ||
74
+ (sc?.kind === "session" && typeof sc.sessionId === "string" && sc.sessionId !== ""));
75
+ }
76
+ export function isValidDurableScope(scope) {
51
77
  const sc = scope;
52
78
  return sc?.kind === "global" || (sc?.kind === "project" && typeof sc.root === "string" && sc.root !== "");
53
79
  }
@@ -56,7 +82,7 @@ function isValidDot(dot) {
56
82
  return typeof d?.actor === "string" && d.actor !== "" && typeof d.counter === "number" && Number.isFinite(d.counter);
57
83
  }
58
84
  function scopeKey(scope) {
59
- return JSON.stringify(scope.kind === "global" ? ["g"] : ["p", scope.root]);
85
+ return JSON.stringify(scope.kind === "global" ? ["g"] : scope.kind === "session" ? ["s", scope.sessionId] : ["p", scope.root]);
60
86
  }
61
87
  function dotKey(dot) {
62
88
  return JSON.stringify([dot.actor, dot.counter]);
@@ -114,6 +140,11 @@ export function screenRuleSyncState(state) {
114
140
  for (const r of state.rules) {
115
141
  if (!Array.isArray(r.adds) || r.adds.length === 0)
116
142
  continue;
143
+ if (r.scope?.kind === "session") {
144
+ for (const a of r.adds)
145
+ rejected.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "session_scope_not_durable" });
146
+ continue;
147
+ }
117
148
  const n = normalizePersistedRule(r);
118
149
  if ("reject" in n) {
119
150
  for (const a of r.adds)
@@ -122,7 +153,15 @@ export function screenRuleSyncState(state) {
122
153
  }
123
154
  rules.push(r);
124
155
  }
125
- return { state: { rules, tombstones: state.tombstones }, rejected };
156
+ const tombstones = [];
157
+ for (const t of state.tombstones) {
158
+ if (t?.scope?.kind === "session") {
159
+ rejected.push({ rule: t.rule, scope: t.scope, dot: t.deletedBy, reason: "session_scope_not_durable" });
160
+ continue;
161
+ }
162
+ tombstones.push(t);
163
+ }
164
+ return { state: { rules, tombstones }, rejected };
126
165
  }
127
166
  export function joinRuleStates(a, b) {
128
167
  const sa = screenRuleSyncState(a).state;
@@ -183,10 +222,12 @@ export function applySyncJoin(cur, delta, nowMs) {
183
222
  throw new Error("a sync-join delta must carry an inbound state with rules and tombstones arrays");
184
223
  }
185
224
  for (const t of delta.inbound.tombstones) {
225
+ if (t?.scope?.kind === "session")
226
+ continue;
186
227
  if (!Array.isArray(t?.removedDots) || t.removedDots.length === 0) {
187
228
  throw new Error("an inbound tombstone must name at least one observed add dot — refusing the whole sync-join (the client's partitioning must withhold the round's adds alongside a malformed tombstone)");
188
229
  }
189
- if (!isValidScope(t.scope) || !isValidDot(t.deletedBy) || !t.removedDots.every(isValidDot)) {
230
+ if (!isValidDurableScope(t.scope) || !isValidDot(t.deletedBy) || !t.removedDots.every(isValidDot)) {
190
231
  throw new Error("an inbound tombstone carries a malformed scope or dot — refusing the whole sync-join (a tombstone that cannot identity-match what it deletes would land its covered adds live)");
191
232
  }
192
233
  const canonicalTomb = parseAllowRuleText(t.rule);
@@ -229,7 +270,7 @@ export function applySyncJoin(cur, delta, nowMs) {
229
270
  withheldAdds += r.adds.length;
230
271
  continue;
231
272
  }
232
- if (!isValidScope(r.scope)) {
273
+ if (!isValidDurableScope(r.scope)) {
233
274
  for (const a of r.adds)
234
275
  droppedInbound.push({ rule: r.rule, scope: r.scope, dot: a.dot, reason: "metadata_mismatch" });
235
276
  continue;
@@ -288,6 +329,11 @@ export function applySyncJoin(cur, delta, nowMs) {
288
329
  }
289
330
  }
290
331
  for (const q of delta.quarantine ?? []) {
332
+ if (q.scope?.kind === "session") {
333
+ const e = new Error("a quarantine instruction names a session scope — the persisted store never holds a session row (design/382 §4.3); refusing the instruction");
334
+ e.code = "unsupported.session_scope_store";
335
+ throw e;
336
+ }
291
337
  for (const dot of q.dots) {
292
338
  if (joined.tombstones.some((t) => sameDot(t.deletedBy, dot))) {
293
339
  throw new Error("a quarantine instruction may not touch a tombstone — it moves adds only");
@@ -332,6 +378,12 @@ export async function removePersistedRule(opts) {
332
378
  const target = opts.principal;
333
379
  const owner = typeof target === "string" ? { kind: "principal", principal: target } : structuredClone(target);
334
380
  const provider = opts.provider;
381
+ if (scope?.kind === "session") {
382
+ return {
383
+ status: "failed",
384
+ error: "unsupported.session_scope_store: a session-scope rule cannot be removed through the persisted-store entry — session authorizations live in the session's own overlay and end with the session (design/382 §4.3)",
385
+ };
386
+ }
335
387
  if (owner.kind === "local-owner" && provider.forLocalOwner === undefined) {
336
388
  return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be removed through it" };
337
389
  }
@@ -435,10 +487,12 @@ export class InMemoryPermissionRuleStore {
435
487
  if (opts.expectedRev !== this.rev)
436
488
  return { conflict: true, rev: this.rev };
437
489
  if (delta.kind === "redemption-add") {
490
+ assertWriteDeltaScopeDurable(delta);
438
491
  assertRedemptionNotQuarantined(this.quarantinedRows, delta);
439
492
  this.rules = foldDelta(this.rules, delta);
440
493
  }
441
494
  else if (delta.kind === "tighten-delete") {
495
+ assertWriteDeltaScopeDurable(delta);
442
496
  assertDeleteDeltaCarriesNoAdd(delta);
443
497
  this.tombstones = [...this.tombstones, delta.tombstone];
444
498
  }
@@ -134,6 +134,15 @@ interface ParsedRuleSyncResponse {
134
134
  dot: RuleDot;
135
135
  code: RuleRejectCode;
136
136
  }>;
137
+ /** design/382 §4.3 — SESSION-scope tombstones, dropped at the parse as per-row refusals (never
138
+ * `undefined`-malformed): a session row can never land regardless of its dots, so classifying a
139
+ * DAMAGED one as generic-malformed would withhold every valid inbound add on every round — one
140
+ * peer row poisoning sync forever, the exact shape the per-row drop arm exists to avoid. */
141
+ sessionScopeTombstones: Array<{
142
+ rule: string;
143
+ scope: RuleScope;
144
+ dot: RuleDot;
145
+ }>;
137
146
  warnings: string[];
138
147
  }
139
148
  /**
@@ -91,6 +91,9 @@ export async function syncPermissionRules(opts) {
91
91
  for (const t of response.textRefusedTombstones) {
92
92
  dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "invalid_rule_text" });
93
93
  }
94
+ for (const t of response.sessionScopeTombstones) {
95
+ dropped.push({ rule: t.rule, scope: t.scope, dot: t.dot, reason: "session_scope_not_durable" });
96
+ }
94
97
  if (response.textRefusedTombstones.length > 0) {
95
98
  const named = response.textRefusedTombstones
96
99
  .slice(0, DISCLOSED_REFUSED_TOMBSTONES)
@@ -131,6 +134,10 @@ export async function syncPermissionRules(opts) {
131
134
  }
132
135
  const localDots = new Set(raw.rules.flatMap((r) => r.adds.map((a) => JSON.stringify([a.dot.actor, a.dot.counter]))));
133
136
  for (const d of response.dropped) {
137
+ if (d.scope.kind === "session") {
138
+ warnings.push(`the server's dropped list names a session-scope row (${escapeForDisclosure(d.rule)}) — the durable store never holds one, so there is no local row it could mean; ignored`);
139
+ continue;
140
+ }
134
141
  if (localDots.has(JSON.stringify([d.dot.actor, d.dot.counter]))) {
135
142
  const reason = d.reason === "below_gc_frontier" ? "below_gc_frontier" : "server_rejected";
136
143
  quarantine.push({ rule: d.rule, scope: d.scope, dots: [d.dot], reason });
@@ -190,7 +197,7 @@ export async function syncPermissionRules(opts) {
190
197
  const dotKeyOf = (d) => JSON.stringify([d.actor, d.counter]);
191
198
  const preDots = new Set(current.rules.flatMap((r) => r.adds.map((a) => dotKeyOf(a.dot))));
192
199
  const newAdds = landedRaw.rules.flatMap((r) => r.adds).filter((a) => !preDots.has(dotKeyOf(a.dot))).length;
193
- const tombKeyOf = (t) => JSON.stringify([t.rule, t.scope.kind === "global" ? null : t.scope.root, dotKeyOf(t.deletedBy)]);
200
+ const tombKeyOf = (t) => JSON.stringify([t.rule, t.scope.kind, t.scope.kind === "project" ? t.scope.root : t.scope.kind === "session" ? t.scope.sessionId : null, dotKeyOf(t.deletedBy)]);
194
201
  const preTombs = new Set(current.tombstones.map(tombKeyOf));
195
202
  const newTombstones = landedRaw.tombstones.filter((t) => !preTombs.has(tombKeyOf(t))).length;
196
203
  const landedReport = res.sync;
@@ -228,13 +235,23 @@ function pickDot(v) {
228
235
  const counter = d?.counter;
229
236
  return typeof actor === "string" && actor !== "" && typeof counter === "number" && Number.isFinite(counter) ? { actor, counter } : undefined;
230
237
  }
231
- function pickScope(v) {
238
+ function pickScopeCaptured(v) {
232
239
  const s = v;
233
- if (s?.kind === "global")
234
- return { kind: "global" };
235
- if (s?.kind === "project" && typeof s.root === "string" && s.root !== "")
236
- return { kind: "project", root: s.root };
237
- return undefined;
240
+ const kind = s?.kind;
241
+ if (kind === "global")
242
+ return { kind, scope: { kind: "global" } };
243
+ if (kind === "project") {
244
+ const root = s?.root;
245
+ return { kind, scope: typeof root === "string" && root !== "" ? { kind: "project", root } : undefined };
246
+ }
247
+ if (kind === "session") {
248
+ const sessionId = s?.sessionId;
249
+ return { kind, scope: typeof sessionId === "string" && sessionId !== "" ? { kind: "session", sessionId } : undefined };
250
+ }
251
+ return { kind, scope: undefined };
252
+ }
253
+ function pickScope(v) {
254
+ return pickScopeCaptured(v).scope;
238
255
  }
239
256
  function pickAdd(v) {
240
257
  const a = v;
@@ -325,9 +342,11 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
325
342
  }
326
343
  const tombstones = [];
327
344
  const textRefusedTombstones = [];
345
+ const sessionScopeTombstones = [];
328
346
  for (const entry of mergedTombstones) {
329
347
  const t = entry;
330
- const scope = pickScope(t?.scope);
348
+ const pickedScope = pickScopeCaptured(t?.scope);
349
+ const scope = pickedScope.scope;
331
350
  const ruleText = t?.rule;
332
351
  const deletedBy = pickDot(t?.deletedBy);
333
352
  const removedRaw = t?.removedDots;
@@ -342,6 +361,15 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
342
361
  removedDots.push(dot);
343
362
  }
344
363
  }
364
+ if (pickedScope.kind === "session") {
365
+ if (typeof ruleText === "string" && deletedBy !== undefined && scope !== undefined) {
366
+ sessionScopeTombstones.push({ rule: ruleText, scope, dot: deletedBy });
367
+ }
368
+ else {
369
+ warnings.push("the response carried a session-scope tombstone whose identity could not be read — dropped per-row (a session row never lands here, and nothing is withheld for it)");
370
+ }
371
+ continue;
372
+ }
345
373
  if (typeof ruleText !== "string" || scope === undefined || deletedBy === undefined || removedDots.length === 0) {
346
374
  tombstones.push(undefined);
347
375
  continue;
@@ -384,6 +412,7 @@ export function parseRuleSyncResponse(raw, expectedPrincipal) {
384
412
  ...(gcFrontier !== undefined ? { gcFrontier } : {}),
385
413
  dropped,
386
414
  textRefusedTombstones,
415
+ sessionScopeTombstones,
387
416
  warnings,
388
417
  };
389
418
  }
@@ -24,6 +24,7 @@ import { SubagentRetainLedger } from "../../agents/retain-ledger.js";
24
24
  import { askApproverIdentity, carriesBidiControls, checkToolPolicyProjection, combinePolicies, constraintChainDigest, constraintChainEntryOfLayer, isApprovalSettledBy, isAskDenyResolution, screenApproverAttribution, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, describeThrown, refuseOutOfContractDecision, resolveAsk, toolPolicyNameSets, tryCloneArgs } from "../tool-policy.js";
25
25
  const PERSISTED_RULE_TOOL = "Bash";
26
26
  import { findAdmittingRule, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
27
+ import { normalizePersistedRule } from "../permission-rule-store.js";
27
28
  import { ActiveSkillScope, createActiveSkillScopePolicy } from "./active-skill-scope.js";
28
29
  import { CHANGED_FILES_MTIME_EPS_MS, fenceMcpServerInstructions, renderAgentListingDelta } from "./turn-attachments.js";
29
30
  import { inlineUntrusted } from "../untrusted-text.js";
@@ -893,6 +894,37 @@ function explicitlyDeferredMemoryTrio(mounted, roster, deferNames) {
893
894
  function memoryGroupRetractionSet(builtinDeferPairNames, engineTrioInPlay) {
894
895
  return new Set([...builtinDeferPairNames, ...(engineTrioInPlay ? MEMORY_ENGINE_TOOL_NAMES : [])]);
895
896
  }
897
+ async function spliceSessionOverlayRows(overlay, sessionId, persisted, tracer, hostTaskId) {
898
+ if (overlay === undefined)
899
+ return persisted;
900
+ try {
901
+ const served = structuredClone(await overlay.read(sessionId));
902
+ const sessionRows = served.filter((r) => {
903
+ const scope = r?.scope;
904
+ return scope?.kind === "session" && scope.sessionId === sessionId && !("reject" in normalizePersistedRule(r));
905
+ });
906
+ if (sessionRows.length < served.length) {
907
+ emitTrace(tracer, () => ({
908
+ kind: "permission.rule_store_unreadable",
909
+ version: 1,
910
+ taskId: hostTaskId,
911
+ message: `session-rule overlay served ${served.length - sessionRows.length} row(s) that are not canonical session rows of this session — dropped, not adjudicated`,
912
+ ts: Date.now(),
913
+ }));
914
+ }
915
+ return sessionRows.length > 0 ? [...sessionRows, ...persisted] : persisted;
916
+ }
917
+ catch (err) {
918
+ emitTrace(tracer, () => ({
919
+ kind: "permission.rule_store_unreadable",
920
+ version: 1,
921
+ taskId: hostTaskId,
922
+ message: `session-rule overlay: ${err instanceof Error ? err.message : String(err)}`,
923
+ ts: Date.now(),
924
+ }));
925
+ return persisted;
926
+ }
927
+ }
896
928
  function refuseRequireExistingWithoutSession(spec) {
897
929
  if (spec.requireExistingSession && !spec.sessionId) {
898
930
  const e = new Error(`requireExistingSession requires a sessionId — cannot require an existing session without one (design/114 Phase3)`);
@@ -3600,10 +3632,11 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3600
3632
  }));
3601
3633
  return { unreadable: true };
3602
3634
  }
3603
- const admitting = findAdmittingRule(listed.rules, { tool: req.toolName, command, cwd: root });
3635
+ const table = await spliceSessionOverlayRows(deps.sessionPermissionRules, sessionId, listed.rules, deps.tracer, hostTaskId);
3636
+ const admitting = findAdmittingRule(table, { tool: req.toolName, command, cwd: root, sessionId });
3604
3637
  if (admitting !== undefined)
3605
3638
  return persistedRuleHitOf(admitting);
3606
- const coverage = segmentCoverageOf(command, { persisted: listed.rules }, { tool: req.toolName, cwd: root });
3639
+ const coverage = segmentCoverageOf(command, { persisted: table }, { tool: req.toolName, cwd: root, sessionId });
3607
3640
  return coverage !== undefined ? { segmentCoverage: coverage } : undefined;
3608
3641
  },
3609
3642
  };
@@ -34,7 +34,11 @@ function liveDots(s) {
34
34
  return out;
35
35
  }
36
36
  function sameScope(a, b) {
37
- return a.kind === "global" ? b.kind === "global" : b.kind === "project" && a.root === b.root;
37
+ if (a.kind === "global")
38
+ return b.kind === "global";
39
+ if (a.kind === "session")
40
+ return b.kind === "session" && a.sessionId === b.sessionId;
41
+ return b.kind === "project" && a.root === b.root;
38
42
  }
39
43
  function allDots(s) {
40
44
  return new Set(s.rules.flatMap((r) => r.adds.map((a) => `${r.rule}|${a.dot.actor}#${a.dot.counter}`)));
@@ -150,6 +154,16 @@ export async function permissionRuleSyncContract(hooks = {}) {
150
154
  const merged = join(state([rule("Bash(node:*)", [add("z", 1)])]), state());
151
155
  assert.strictEqual(allDots(merged).size, 0, "Bash(node:*) must be refused by the shared validator inside the join");
152
156
  });
157
+ run("durable two-member face (design/382 §4.3): a session-scope row or tombstone never survives a join, and never poisons the round", async () => {
158
+ const sessionScope = { kind: "session", sessionId: "sess-1" };
159
+ const dirty = state([rule("Bash(ls)", [add("z", 1)], sessionScope), rule("Bash(git status)", [add("a", 1)])], [tomb("Bash(pwd)", [["z", 2]], ["z", 3], sessionScope)]);
160
+ const ab = join(dirty, state());
161
+ const ba = join(state(), dirty);
162
+ assert.ok(!allDots(ab).has("Bash(ls)|z#1"), "a session-scope add must not survive the join — the durable face holds two members");
163
+ assert.strictEqual(ab.tombstones.length, 0, "a session-scope tombstone must not survive the join");
164
+ assert.ok(allDots(ab).has("Bash(git status)|a#1"), "dropping the session row must not withhold or disturb the durable rest — per-row, never round-poisoning");
165
+ assert.deepStrictEqual(ab, ba, "the session-scope drop must preserve commutativity");
166
+ });
153
167
  run("identity keys are collision-free: roots and actors containing spaces keep DISTINCT tombstone identities, and the covered add stays dead through an identity join", async () => {
154
168
  const scopeA = { kind: "project", root: "/x" };
155
169
  const scopeB = { kind: "project", root: "/x a" };
@@ -449,7 +449,10 @@ export type TraceEvent = {
449
449
  /** The bucket this round synced. A local-owner bucket cannot sync, so this is always a principal. */
450
450
  principal: string;
451
451
  rule: string;
452
- scopeKind: "global" | "project";
452
+ /** `"session"` is unreachable here in a healthy fleet (design/382 §4.3: a session row never
453
+ * lands, so it can never resurrect) — declared because the sibling drop event names it and a
454
+ * lockstep union is what keeps the two from drifting. */
455
+ scopeKind: "global" | "project" | "session";
453
456
  ts: number;
454
457
  } | {
455
458
  /**
@@ -462,7 +465,9 @@ export type TraceEvent = {
462
465
  version: 1;
463
466
  principal: string;
464
467
  rule: string;
465
- scopeKind: "global" | "project";
468
+ /** `"session"` names the design/382 §4.3 durable-face drop: a peer sent a session-scope row,
469
+ * refused per-row with `session_scope_not_durable`. */
470
+ scopeKind: "global" | "project" | "session";
466
471
  /** Closed reason code ({@link import("./governance-codes.js").RuleSyncDropReason}). */
467
472
  reason: string;
468
473
  ts: number;
@@ -6436,6 +6436,17 @@ export interface RunnerDeps {
6436
6436
  * adopted principal's bucket forever.
6437
6437
  */
6438
6438
  localOwnerRules?: boolean;
6439
+ /**
6440
+ * design/382 §4.3 — the SESSION-RULE OVERLAY: where `{kind:"session"}` scoped allow rules live
6441
+ * (the session's own state, never the persisted store). Wired, the gate's persisted-rule lane
6442
+ * splices the CURRENT session's overlay rows in front of the store's `list()` on every
6443
+ * adjudication — session > project > global, the narrowest-first reporting order — and threads the
6444
+ * session's identity as the eligibility context's third axis; a host lands rows into it through
6445
+ * the consent protocol (`RuleConsentDeps.sessionRules`, a session-scope candidate's redemption).
6446
+ * Omitted ⇒ the session dimension does not exist at this gate and the lane is byte-identical.
6447
+ * See `permission-rule-session.ts` for the contract and the reference implementation.
6448
+ */
6449
+ sessionPermissionRules?: import("./permission-rule-session.js").SessionRuleOverlay;
6439
6450
  /**
6440
6451
  * design/182 §7 — the ORG rule overlay for an org-GOVERNED deployment. Constructed with
6441
6452
  * `createOrgRuleOverlay` (that constructor is the boot gate: a governed declaration with no snapshot
package/dist/index.d.ts CHANGED
@@ -167,8 +167,9 @@ export { createPermissionRulePolicy, validatePermissionRules, parsePermissionRul
167
167
  * allowed and widening is not.
168
168
  */
169
169
  export { parseAllowRuleText, formatAllowRuleText, ruleAdmitsCommand, findAdmittingRule, suggestRulesForCommand, scopeCoversCwd, pathWithinRoot, isRuleLive, renderUntrustedCommandText, BARE_INTERPRETER_NAMES, MAX_RULE_TEXT_CHARS, type PersistedAllowRule, type RuleTombstone, type RuleScope, type RuleDot, type RuleAdd, type RuleAddOrigin, type RuleOffer, type SegmentRuleSuggestion, type SegmentCoverage, type RuleReject, type RuleRejectCode, type ParsedAllowRule, type PersistedRuleTool, type PersistedRuleMatch, } from "./core/permission-rule-model.js";
170
- export { removePersistedRule, applyTombstones, sameScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, type PermissionRuleStore, type PermissionRuleStoreProvider, type StoredAllowRules, type RemoveResult, type PutResult, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, type RuleSyncState, type RuleSyncFrontier, type RuleSyncDrop, type RuleSyncLandingReport, type RuleOwner, type QuarantinedRuleAdd, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, type PermissionRuleWriter, type WritablePermissionRuleStore, type RuleWriteDelta, type RuleAddDelta, type RuleDeleteDelta, type RuleSyncJoinDelta, type RawRuleSyncState, type RedemptionAuthorization, } from "./core/permission-rule-store.js";
170
+ export { removePersistedRule, applyTombstones, sameScope, isValidConsentScope, isValidDurableScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, type PermissionRuleStore, type PermissionRuleStoreProvider, type StoredAllowRules, type RemoveResult, type PutResult, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, type RuleSyncState, type RuleSyncFrontier, type RuleSyncDrop, type RuleSyncLandingReport, type RuleOwner, type QuarantinedRuleAdd, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, type PermissionRuleWriter, type WritablePermissionRuleStore, type RuleWriteDelta, type RuleAddDelta, type RuleDeleteDelta, type RuleSyncJoinDelta, type RawRuleSyncState, type RedemptionAuthorization, } from "./core/permission-rule-store.js";
171
171
  export { syncPermissionRules, parseRuleSyncResponse, PERMISSION_RULE_SYNC_PATH, LOCAL_OWNER_UNSYNCABLE_CODE, type PermissionRuleSyncTransport, type PermissionRuleSyncResult, type RuleSyncRequestBody, type RuleSyncResponseBody, } from "./core/permission-rule-sync.js";
172
+ export { InMemorySessionRuleOverlay, type SessionRuleOverlay, type SessionRuleOverlayAdd, type SessionRuleOverlayApplyResult, } from "./core/permission-rule-session.js";
172
173
  export { createOrgRuleOverlay, orgRuleVerdictFor, effectivePermissionRules, orgRuleStatePersistenceOf, ORG_UNAVAILABLE_DECISION_REASON, ORG_RULE_DECISION_REASON, ORG_ADJUDICATION_TIMEOUT_MS, type OrgPermissionRule, type OrgRuleSnapshot, type OrgRuleSnapshotProvider, type OrgRuleStatePersistence, type PersistedOrgRuleState, type OrgRuleOverlay, type OrgOverlayResolution, type OrgOverlayStatus, type EffectivePermissionRule, } from "./core/permission-rule-org.js";
173
174
  export { RULE_SYNC_DROP_CODES, type RuleSyncDropReason, type RuleQuarantineReason } from "./core/governance-codes.js";
174
175
  export { prepareCardApproval, confirmRuleApproval, type ConfirmResult, type ConfirmRefusalReason, precheckEditedRuleText, type EditedRuleTextPrecheck, redeemRuleTicket, redeemRuleBatch, prepareCcImport, prepareStarterBatch, mintRuleTicket, STARTER_RULES, InMemoryRuleApprovalRecordStore, ruleOffersOfRecord, type RuleTicket, type RuleCandidate, type RuleApprovalKind, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleOffer2, type StaleRuleApprovalRecord, type RuleConsentDeps, type RedeemResult, type RedeemedBatchMember, type CcImportLayer, type ImportedSettingsLayer, type ImportPreview, } from "./core/permission-rule-consent.js";
package/dist/index.js CHANGED
@@ -127,8 +127,9 @@ export { AUTO_MODE_ARMING_RECIPE_VERSION, autoModeArmingRecipeOf, sanitizeAutoMo
127
127
  export { rebuildAutoModeDecider, } from "./core/auto-mode-rebuild.js";
128
128
  export { createPermissionRulePolicy, validatePermissionRules, parsePermissionRule, wildcardMatch, isNamespacedCoveringRuleName, namespacedRuleNameCovers, } from "./core/permission-rules.js";
129
129
  export { parseAllowRuleText, formatAllowRuleText, ruleAdmitsCommand, findAdmittingRule, suggestRulesForCommand, scopeCoversCwd, pathWithinRoot, isRuleLive, renderUntrustedCommandText, BARE_INTERPRETER_NAMES, MAX_RULE_TEXT_CHARS, } from "./core/permission-rule-model.js";
130
- export { removePersistedRule, applyTombstones, sameScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, } from "./core/permission-rule-store.js";
130
+ export { removePersistedRule, applyTombstones, sameScope, isValidConsentScope, isValidDurableScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, } from "./core/permission-rule-store.js";
131
131
  export { syncPermissionRules, parseRuleSyncResponse, PERMISSION_RULE_SYNC_PATH, LOCAL_OWNER_UNSYNCABLE_CODE, } from "./core/permission-rule-sync.js";
132
+ export { InMemorySessionRuleOverlay, } from "./core/permission-rule-session.js";
132
133
  export { createOrgRuleOverlay, orgRuleVerdictFor, effectivePermissionRules, orgRuleStatePersistenceOf, ORG_UNAVAILABLE_DECISION_REASON, ORG_RULE_DECISION_REASON, ORG_ADJUDICATION_TIMEOUT_MS, } from "./core/permission-rule-org.js";
133
134
  export { RULE_SYNC_DROP_CODES } from "./core/governance-codes.js";
134
135
  export { prepareCardApproval, confirmRuleApproval, precheckEditedRuleText, redeemRuleTicket, redeemRuleBatch, prepareCcImport, prepareStarterBatch, mintRuleTicket, STARTER_RULES, InMemoryRuleApprovalRecordStore, ruleOffersOfRecord, } from "./core/permission-rule-consent.js";