@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.
- package/CHANGELOG.md +12 -0
- package/dist/core/governance-codes.d.ts +11 -2
- package/dist/core/governance-codes.js +1 -0
- package/dist/core/permission-rule-consent.d.ts +42 -2
- package/dist/core/permission-rule-consent.js +93 -11
- package/dist/core/permission-rule-model.d.ts +51 -9
- package/dist/core/permission-rule-model.js +4 -2
- package/dist/core/permission-rule-session.d.ts +124 -0
- package/dist/core/permission-rule-session.js +121 -0
- package/dist/core/permission-rule-store.d.ts +65 -2
- package/dist/core/permission-rule-store.js +60 -6
- package/dist/core/permission-rule-sync.d.ts +9 -0
- package/dist/core/permission-rule-sync.js +37 -8
- package/dist/core/runner/prepare-task.js +35 -2
- package/dist/core/store-contracts/permission-rule-sync-contract.js +15 -1
- package/dist/core/trace.d.ts +7 -2
- package/dist/core/types.d.ts +11 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/stores/file/permission-rule-store.d.ts +11 -0
- package/dist/stores/file/permission-rule-store.js +22 -9
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +15 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.0.0 — 2026-08-30
|
|
4
|
+
|
|
5
|
+
> Ships as its own major immediately after 6.0.0 (ruled: the three-dimensional rule scope travels alone so downstream adapts to it in isolation). The 6.0.0 artifact is this same tree with the design/382 P1 surface excised whole (type-driven surgery in an isolated worktree, tag v6.0.0); `npm run handoff:diff v6.0.0 v7.0.0` reproduces exactly this surface.
|
|
6
|
+
|
|
7
|
+
### BREAKING (design/382 P1 — the three-dimensional rule scope)
|
|
8
|
+
- **`RuleScope` grows a third member** `{ kind: "session"; sessionId }` (design/382 §4): exhaustive switches over the two-member set no longer compile; `sameScope`/`scopeKey` answer three members. Org is deliberately NOT a member — an approval card is structurally unable to spell an org-wide grant. Two NAMED validation faces replace any one-table reading: `isValidConsentScope` (three members; the consent protocol and its server projection) vs `isValidDurableScope` (two members; the persisted store and rule-sync — a session row is refused at every durable entrance).
|
|
9
|
+
- **The consent scope default flips** (#490②): `prepareCardApproval` no longer defaults an absent `scope` to global. Explicit scope wins (validated; garbage refuses `config.invalid_argument`); absent scope with `cwd` present derives `{ kind: "project", root: cwd }`; BOTH absent refuses loudly with the new code `config.missing_scope`. Hosts relying on absent-scope-⇒-global must pass `{kind:"global"}` explicitly or thread the adjudicated cwd — the failure direction is toward asking, never a silent widening. Starter batch stays literal global; import scope mapping unchanged.
|
|
10
|
+
- **A session row is refused at every durable entrance** (§4.3, the open-set duty): `redemption-add`/`tighten-delete` deltas throw typed `unsupported.session_scope_store` (new shared backend assert `assertWriteDeltaScopeDurable`); sync-join drops session rules AND tombstones per-row with the new closed-set reason `session_scope_not_durable` (`RULE_SYNC_DROP_CODES` +1; `RuleQuarantineReason` excludes it — the quarantine area is itself durable) without withholding the round; a session-scope quarantine instruction throws; the file backend screens at-rest bytes off every read face including the sync outbound `readRaw`; `removePersistedRule` refuses a session scope — a session rule is not individually deletable, it dies with its session. The `permission.rule_sync_resurrected/dropped` trace events' `scopeKind` widens to include `"session"`.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **The session-rule overlay** (`permission-rule-session.ts`): `SessionRuleOverlay` contract + `InMemorySessionRuleOverlay` reference implementation (apply idempotent by dot; termination seals the lifetime epoch FIRST then clears — a late in-flight write refuses `session_ended`; `snapshotSession`/`restoreSession` carry rows across park/resume, restore fail-closed on scope and on the canonical re-projection). `RuleConsentDeps.sessionRules` lands session-scope redemptions there (the store write leg is never taken; dot-first crash order and re-apply-on-replay preserved); `RunnerDeps.sessionPermissionRules` splices the current session's rows in front of the persisted `list()` at the gate lane (narrowest-first report order; `sessionId` becomes the eligibility context's third axis, fail-closed when unthreaded). Overlay-served rows pass the same `normalizePersistedRule` re-projection screen the sync face runs on store rows, at all three re-entry doors (gate lane, prepare coverage, restore). New exports: `isValidConsentScope`, `isValidDurableScope`, `assertWriteDeltaScopeDurable`, `SessionRuleOverlay`, `SessionRuleOverlayAdd`, `SessionRuleOverlayApplyResult`, `InMemorySessionRuleOverlay`. The rule-sync contract kit grows the durable-two-member-face conformance vector (server twin duty, mechanized).
|
|
14
|
+
|
|
3
15
|
## 6.0.0 — 2026-08-30
|
|
4
16
|
|
|
5
17
|
### BREAKING
|
|
@@ -62,12 +62,21 @@ export declare const RULE_SYNC_DROP_CODES: {
|
|
|
62
62
|
/** The server refused this local row (its response `dropped` names it); it leaves the live view so
|
|
63
63
|
* it does not ride — and get refused on — every future round. */
|
|
64
64
|
readonly server_rejected: "local-quarantined";
|
|
65
|
+
/** design/382 §4.3 — the row carries a SESSION scope, which the durable face (store, sync wire,
|
|
66
|
+
* at-rest bytes) structurally never holds: a session authorization lives in its session's overlay
|
|
67
|
+
* and dies with it. The row is dropped and disclosed, never landed and never quarantined (the
|
|
68
|
+
* quarantine area is itself durable — parking a session row there would persist it). Inbound-
|
|
69
|
+
* refused in every reachable case: the direct write arms refuse session scopes LOUDLY
|
|
70
|
+
* (`unsupported.session_scope_store`) before one can become local. */
|
|
71
|
+
readonly session_scope_not_durable: "inbound-refused";
|
|
65
72
|
};
|
|
66
73
|
/** Every reason a sync round may drop or quarantine a record. Closed set; free text is not a member. */
|
|
67
74
|
export type RuleSyncDropReason = keyof typeof RULE_SYNC_DROP_CODES;
|
|
68
75
|
/** The subset that may appear on a LOCAL quarantined row (design/182 §8.1 `quarantine` instruction /
|
|
69
|
-
* fence arm / local screening). `own_actor_forged` is inbound-only by construction
|
|
70
|
-
|
|
76
|
+
* fence arm / local screening). `own_actor_forged` is inbound-only by construction;
|
|
77
|
+
* `session_scope_not_durable` never quarantines — the quarantine area is durable, and a session row
|
|
78
|
+
* parked there would be a session row persisted (design/382 §4.3). */
|
|
79
|
+
export type RuleQuarantineReason = Exclude<RuleSyncDropReason, "own_actor_forged" | "session_scope_not_durable">;
|
|
71
80
|
/**
|
|
72
81
|
* WHO a notice code is for. `"user"` = a session-scoped disclosure the end user of that session is
|
|
73
82
|
* entitled to see (safe to project onto that session's event stream); `"operator"` = a
|
|
@@ -90,6 +90,7 @@ export const RULE_SYNC_DROP_CODES = {
|
|
|
90
90
|
dot_identity_conflict: "inbound-refused",
|
|
91
91
|
below_gc_frontier: "local-quarantined",
|
|
92
92
|
server_rejected: "local-quarantined",
|
|
93
|
+
session_scope_not_durable: "inbound-refused",
|
|
93
94
|
};
|
|
94
95
|
export const ENGINE_NOTICE_CODES = [
|
|
95
96
|
"config.autocompact_window_clamped",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { type RuleOffer, type RuleRejectCode, type RuleScope, type RuleDot } from "./permission-rule-model.js";
|
|
31
31
|
import type { PermissionRuleStoreProvider, RuleOwner } from "./permission-rule-store.js";
|
|
32
|
+
import type { SessionRuleOverlay } from "./permission-rule-session.js";
|
|
32
33
|
/** One candidate rule inside an approval record: the exact text and where it would apply. */
|
|
33
34
|
export interface RuleCandidate {
|
|
34
35
|
rule: string;
|
|
@@ -199,6 +200,15 @@ export interface RuleConsentDeps {
|
|
|
199
200
|
* happened, and withholding the receipt helps no one.
|
|
200
201
|
*/
|
|
201
202
|
cardEdits?: boolean;
|
|
203
|
+
/**
|
|
204
|
+
* design/382 §4.3 — the SESSION-RULE OVERLAY: where a `{kind:"session"}` scoped candidate lands at
|
|
205
|
+
* redemption (the store write leg is never taken for one), and where the prepare-time coverage read
|
|
206
|
+
* merges a session's standing rows from. Host-provided, session-lifetime, never synced — see
|
|
207
|
+
* `permission-rule-session.ts` for the contract and the reference implementation. Absent ⇒ a
|
|
208
|
+
* session-scope candidate's redemption refuses loudly (there is nowhere for it to land), and
|
|
209
|
+
* coverage reads see no session rows — both fail toward asking.
|
|
210
|
+
*/
|
|
211
|
+
sessionRules?: SessionRuleOverlay;
|
|
202
212
|
}
|
|
203
213
|
/** In-memory approval records — the test backend and the reference CAS semantics, the stale-row
|
|
204
214
|
* envelope read included. */
|
|
@@ -257,8 +267,27 @@ export declare function prepareCardApproval(opts: {
|
|
|
257
267
|
/** The ask's call id and argument digest, recorded for reconciliation. */
|
|
258
268
|
toolCallId?: string;
|
|
259
269
|
boundInputHash?: string;
|
|
260
|
-
/**
|
|
270
|
+
/**
|
|
271
|
+
* Where a redeemed rule would apply — any of the three consent dimensions (design/382 §4.1).
|
|
272
|
+
*
|
|
273
|
+
* design/382 §4.4 (#490②, BREAKING B2) — the DEFAULT is no longer global:
|
|
274
|
+
* · present ⇒ used as given (the explicit channel; all three members legal, garbage refused loudly);
|
|
275
|
+
* · absent with `cwd` present ⇒ `{ kind: "project", root: cwd }`. Root = the adjudicated call's
|
|
276
|
+
* cwd is a deliberately NARROW default — the engine is a library and repository semantics are
|
|
277
|
+
* host business; a host wanting the true project root passes an explicit scope. The narrow cost
|
|
278
|
+
* is more asks (a nested-directory rule does not cover siblings), the safe direction;
|
|
279
|
+
* · both absent ⇒ a LOUD refusal (`config.missing_scope`): silent-global was the #490② defect,
|
|
280
|
+
* silent-project has no root to anchor, and "where does this consent land" is not guessable.
|
|
281
|
+
*/
|
|
261
282
|
scope?: RuleScope;
|
|
283
|
+
/**
|
|
284
|
+
* design/382 §4.3 — the SESSION IDENTITY of the adjudicated call, threaded by the caller from the
|
|
285
|
+
* original call context exactly like `cwd` (never inferred from the process). It is the coverage
|
|
286
|
+
* read's third eligibility axis: with it, a standing session rule of that session counts as
|
|
287
|
+
* coverage; without it, no session rule covers anything (fail-closed). Independent of `scope` on
|
|
288
|
+
* purpose — `scope` names where a NEW consent would land, this names where the CALL is running.
|
|
289
|
+
*/
|
|
290
|
+
sessionId?: string;
|
|
262
291
|
/**
|
|
263
292
|
* The working directory of the ADJUDICATED CALL — the same task root the gate's lane judged
|
|
264
293
|
* with — threaded by the caller from the original call context. Never inferred from `scope`
|
|
@@ -400,7 +429,12 @@ export type EditedRuleTextPrecheck = {
|
|
|
400
429
|
* so the surface's move is to not offer the edit box at all.
|
|
401
430
|
*/
|
|
402
431
|
export declare function precheckEditedRuleText(text: string, command: string): EditedRuleTextPrecheck;
|
|
403
|
-
/** What a redemption produced. `alreadyRedeemed` marks the replay path — the same dot, no second rule.
|
|
432
|
+
/** What a redemption produced. `alreadyRedeemed` marks the replay path — the same dot, no second rule.
|
|
433
|
+
*
|
|
434
|
+
* `rev` is the DURABLE store's revision. A session-scope redemption (design/382 §4.3) lands in the
|
|
435
|
+
* session overlay and does not move it: its `rev` is a best-effort read of the store's current
|
|
436
|
+
* revision (0 when the store could not be read — the landing is in the overlay either way, and the
|
|
437
|
+
* durable revision is reporting, not the landing's identity). */
|
|
404
438
|
export type RedeemResult = {
|
|
405
439
|
status: "redeemed";
|
|
406
440
|
rule: string;
|
|
@@ -508,6 +542,12 @@ export interface ImportPreview {
|
|
|
508
542
|
* `false` AND `deduped` — the two axes answer different questions and neither implies the other).
|
|
509
543
|
* A refused row always carries its `reason`. `deduped` counts as LANDED: an equivalent rule
|
|
510
544
|
* already standing means the consent is already in effect.
|
|
545
|
+
*
|
|
546
|
+
* design/382 §4.3 — on a SESSION-scope member the two landed words mean the same thing about a
|
|
547
|
+
* different home: landed in / already in the session's OVERLAY (a session rule's home store), never
|
|
548
|
+
* the persisted store. The words state that the landing happened; DURABILITY is what the member's own
|
|
549
|
+
* `scope` says, so a consumer never has to guess it off the status (deliberately no fourth status
|
|
550
|
+
* value — the closed set names landing outcomes, not storage classes).
|
|
511
551
|
*/
|
|
512
552
|
export type RedeemedBatchMember = {
|
|
513
553
|
readonly candidateIndex: number;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { escapeForDisclosure, hasUnrenderableCharacters, parseAllowRuleText, ruleAdmitsCommand, segmentCoverageOf, suggestRulesForCommand, translateImportedWildcardRule, } from "./permission-rule-model.js";
|
|
3
|
-
import { errText, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
|
|
3
|
+
import { errText, isValidConsentScope, normalizePersistedRule, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
|
|
4
4
|
export class InMemoryRuleApprovalRecordStore {
|
|
5
5
|
rows = new Map();
|
|
6
6
|
async get(id) {
|
|
@@ -121,6 +121,9 @@ function approvalRecordDamageOf(rec) {
|
|
|
121
121
|
if (typeof cand !== "object" || cand === null || typeof cand.rule !== "string") {
|
|
122
122
|
return `offer ${at} references candidate ${member}, which is not a candidate row`;
|
|
123
123
|
}
|
|
124
|
+
if (!isValidConsentScope(cand.scope)) {
|
|
125
|
+
return `offer ${at} references candidate ${member}, whose scope is not a consent-face scope (global, project, or session)`;
|
|
126
|
+
}
|
|
124
127
|
if (seen.has(member))
|
|
125
128
|
return `batch offer ${at} references candidate ${member} twice`;
|
|
126
129
|
seen.add(member);
|
|
@@ -196,11 +199,42 @@ export async function prepareCardApproval(opts) {
|
|
|
196
199
|
}
|
|
197
200
|
if (opts.toolName !== CARD_RULE_TOOL)
|
|
198
201
|
return undefined;
|
|
199
|
-
const scope =
|
|
202
|
+
const scope = (() => {
|
|
203
|
+
if (opts.scope !== undefined) {
|
|
204
|
+
if (!isValidConsentScope(opts.scope)) {
|
|
205
|
+
const e = new Error("prepareCardApproval got a scope that is not a consent-face scope (global, project with a non-empty root, or session with a non-empty sessionId) — refusing to guess where this consent should land");
|
|
206
|
+
e.code = "config.invalid_argument";
|
|
207
|
+
throw e;
|
|
208
|
+
}
|
|
209
|
+
return opts.scope;
|
|
210
|
+
}
|
|
211
|
+
if (opts.cwd !== undefined) {
|
|
212
|
+
if (typeof opts.cwd !== "string" || opts.cwd === "") {
|
|
213
|
+
const e = new Error("prepareCardApproval got a cwd that is not a usable path — it cannot anchor the derived project scope, and refusing beats minting a rule scoped to garbage");
|
|
214
|
+
e.code = "config.invalid_argument";
|
|
215
|
+
throw e;
|
|
216
|
+
}
|
|
217
|
+
return { kind: "project", root: opts.cwd };
|
|
218
|
+
}
|
|
219
|
+
const e = new Error("prepareCardApproval was given neither a scope nor a cwd — there is nowhere for this consent to land, and no direction of silence is honest (config.missing_scope): pass an explicit scope, or thread the adjudicated call's cwd");
|
|
220
|
+
e.code = "config.missing_scope";
|
|
221
|
+
throw e;
|
|
222
|
+
})();
|
|
200
223
|
let coverage;
|
|
201
224
|
try {
|
|
202
225
|
const listed = await storeForOwner(opts.deps.provider, owner).list();
|
|
203
|
-
|
|
226
|
+
let table = listed.rules;
|
|
227
|
+
const overlay = opts.deps.sessionRules;
|
|
228
|
+
if (overlay !== undefined && opts.sessionId !== undefined) {
|
|
229
|
+
const served = structuredClone(await overlay.read(opts.sessionId));
|
|
230
|
+
const sessionRows = served.filter((r) => {
|
|
231
|
+
const scope = r?.scope;
|
|
232
|
+
return scope?.kind === "session" && scope.sessionId === opts.sessionId && !("reject" in normalizePersistedRule(r));
|
|
233
|
+
});
|
|
234
|
+
if (sessionRows.length > 0)
|
|
235
|
+
table = [...sessionRows, ...listed.rules];
|
|
236
|
+
}
|
|
237
|
+
coverage = segmentCoverageOf(opts.command, { persisted: table }, { tool: CARD_RULE_TOOL, cwd: opts.cwd, sessionId: opts.sessionId });
|
|
204
238
|
}
|
|
205
239
|
catch {
|
|
206
240
|
coverage = undefined;
|
|
@@ -497,6 +531,41 @@ async function applyRedemption(args) {
|
|
|
497
531
|
if (writer === undefined)
|
|
498
532
|
return { status: "refused", reason: "the resolved permission-rule store has no write face" };
|
|
499
533
|
const origin = originOfRecordKind(args.kind);
|
|
534
|
+
if (args.candidate.scope.kind === "session") {
|
|
535
|
+
const overlay = args.deps.sessionRules;
|
|
536
|
+
if (overlay === undefined) {
|
|
537
|
+
return {
|
|
538
|
+
status: "refused",
|
|
539
|
+
reason: "this deployment wired no session-rule overlay (RuleConsentDeps.sessionRules) — a session-scope authorization has nowhere to land",
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
let outcome;
|
|
543
|
+
try {
|
|
544
|
+
outcome = await overlay.apply(args.candidate.scope.sessionId, {
|
|
545
|
+
rule: args.parsedRule.rule,
|
|
546
|
+
tool: args.parsedRule.tool,
|
|
547
|
+
match: args.parsedRule.match,
|
|
548
|
+
command: args.parsedRule.command,
|
|
549
|
+
add: { dot: args.dot, origin, createdAt: nowIso(args.deps) },
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
return { status: "refused", reason: `the session-rule overlay refused the write: ${errText(err)}` };
|
|
554
|
+
}
|
|
555
|
+
if ("refused" in outcome) {
|
|
556
|
+
return {
|
|
557
|
+
status: "refused",
|
|
558
|
+
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)",
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
let rev = 0;
|
|
562
|
+
try {
|
|
563
|
+
rev = (await store.list()).rev;
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
}
|
|
567
|
+
return { status: "redeemed", rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev, alreadyRedeemed: args.replay };
|
|
568
|
+
}
|
|
500
569
|
for (let attempt = 0; attempt < REDEEM_MAX_ATTEMPTS; attempt++) {
|
|
501
570
|
let rev;
|
|
502
571
|
try {
|
|
@@ -717,14 +786,27 @@ export async function redeemRuleBatch(opts) {
|
|
|
717
786
|
const candidate = rec.candidates[candidateIndex];
|
|
718
787
|
const row = { candidateIndex, rule: candidate.rule, scope: candidate.scope };
|
|
719
788
|
let alreadyThere = false;
|
|
720
|
-
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
789
|
+
if (candidate.scope.kind === "session") {
|
|
790
|
+
const sessionScope = candidate.scope;
|
|
791
|
+
try {
|
|
792
|
+
const overlayRows = opts.deps.sessionRules === undefined ? [] : await opts.deps.sessionRules.read(sessionScope.sessionId);
|
|
793
|
+
alreadyThere = overlayRows.some((r) => r.rule === candidate.rule && sameScope(r.scope, sessionScope));
|
|
794
|
+
}
|
|
795
|
+
catch (err) {
|
|
796
|
+
members.push({ ...row, status: "refused", reason: `could not read the session-rule overlay: ${errText(err)}` });
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
724
799
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
800
|
+
else {
|
|
801
|
+
try {
|
|
802
|
+
const snap = await store.list();
|
|
803
|
+
rev = snap.rev;
|
|
804
|
+
alreadyThere = snap.rules.some((r) => r.rule === candidate.rule && sameScope(r.scope, candidate.scope));
|
|
805
|
+
}
|
|
806
|
+
catch (err) {
|
|
807
|
+
members.push({ ...row, status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` });
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
728
810
|
}
|
|
729
811
|
const res = await redeemRuleTicket({
|
|
730
812
|
ticket: mintRuleTicket(rec.id, candidateIndex),
|
|
@@ -735,7 +817,7 @@ export async function redeemRuleBatch(opts) {
|
|
|
735
817
|
members.push({ ...row, status: "refused", reason: res.reason });
|
|
736
818
|
continue;
|
|
737
819
|
}
|
|
738
|
-
rev = res.rev;
|
|
820
|
+
rev = candidate.scope.kind === "session" ? Math.max(rev, res.rev) : res.rev;
|
|
739
821
|
members.push({ ...row, status: alreadyThere ? "deduped" : "persisted", alreadyRedeemed: res.alreadyRedeemed, dot: res.dot });
|
|
740
822
|
}
|
|
741
823
|
return { members, rev };
|
|
@@ -69,18 +69,43 @@ export type PersistedRuleTool = "Bash";
|
|
|
69
69
|
/** v1 match forms. `"wildcard"` is reserved for v2 and is not a value this version ever produces. */
|
|
70
70
|
export type PersistedRuleMatch = "exact" | "prefix";
|
|
71
71
|
/**
|
|
72
|
-
* Where a rule applies
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
72
|
+
* Where a rule applies — design/382 §4.1, the THREE consent dimensions.
|
|
73
|
+
*
|
|
74
|
+
* · `global` — the personal-global dimension: this OWNER's rules, everywhere (the bucket is already
|
|
75
|
+
* per-owner, {@link import("./permission-rule-store.js").RuleOwner}). The member keeps its historical
|
|
76
|
+
* spelling on purpose: renaming it `personal-global` would be a no-benefit migration of every stored
|
|
77
|
+
* row; the "personal" half lives in documentation and card wording, not in the wire byte.
|
|
78
|
+
* · `project` — applies ONLY inside `root`. `root` is expected to be already canonicalized (realpath)
|
|
79
|
+
* by whoever constructs the scope; the matcher compares with word-boundary containment, never a bare
|
|
80
|
+
* `startsWith` (`/a` must not reach `/ab`).
|
|
81
|
+
* · `session` — applies ONLY to calls of the ONE session it names, and lives in that session's own
|
|
82
|
+
* state (the host's session-rule overlay), NEVER in the persisted store: every durable entrance —
|
|
83
|
+
* the write deltas, sync in both directions, at-rest bytes — refuses or drops a session row
|
|
84
|
+
* (design/382 §4.3, the durable two-member face). `sessionId` uniqueness is the HOST's duty (the
|
|
85
|
+
* `principal` posture): reusing an id is a declaration that it is the same session. A session row
|
|
86
|
+
* cannot be individually deleted — it dies with its session (design/382 §10, ruled; the durable
|
|
87
|
+
* removal entry refuses it rather than answering a false no-op).
|
|
88
|
+
*
|
|
89
|
+
* ORG is deliberately NOT a member (design/382 §4.1): an approval card must be structurally unable to
|
|
90
|
+
* spell an org-wide grant — org governance is a deny/ask snapshot published by an admin surface, with
|
|
91
|
+
* no allow bucket at all. Unspellable beats runtime-refused; the consent validators refuse the spelling
|
|
92
|
+
* anyway if a foreign writer plants it.
|
|
93
|
+
*
|
|
94
|
+
* TWO NAMED VALIDATION FACES read this union (design/382 §4.3): the CONSENT face (prepare candidates,
|
|
95
|
+
* approval records, confirm/redeem, batch outcomes) admits all three members
|
|
96
|
+
* ({@link import("./permission-rule-store.js").isValidConsentScope}); the DURABLE face (the persisted
|
|
97
|
+
* store's write deltas, rule-sync in both directions, at-rest decode) admits only
|
|
98
|
+
* `{global, project}` ({@link import("./permission-rule-store.js").isValidDurableScope}). One table
|
|
99
|
+
* "for both" is exactly what would let a session row leak into the store silently.
|
|
78
100
|
*/
|
|
79
101
|
export type RuleScope = {
|
|
80
102
|
kind: "global";
|
|
81
103
|
} | {
|
|
82
104
|
kind: "project";
|
|
83
105
|
root: string;
|
|
106
|
+
} | {
|
|
107
|
+
kind: "session";
|
|
108
|
+
sessionId: string;
|
|
84
109
|
};
|
|
85
110
|
/** The immutable causal identity of one add: a replica identity plus a monotonic counter. */
|
|
86
111
|
export interface RuleDot {
|
|
@@ -441,9 +466,16 @@ export declare function ruleLaneSegmentsOf(command: string): readonly string[] |
|
|
|
441
466
|
* not contain `/ab`. Both sides are expected to be canonical already.
|
|
442
467
|
*/
|
|
443
468
|
export declare function pathWithinRoot(path: string, root: string): boolean;
|
|
444
|
-
/** Does a rule's scope cover a task running in `cwd
|
|
445
|
-
* without one it covers nothing
|
|
446
|
-
|
|
469
|
+
/** Does a rule's scope cover a task running in `cwd` (and, for the session dimension, in the session
|
|
470
|
+
* named by `sessionId`)? A project rule needs a cwd to compare against; without one it covers nothing
|
|
471
|
+
* (fail-closed). A session rule (design/382 §4.3) covers a call iff the call's `sessionId` equals the
|
|
472
|
+
* scope's — and a call that carries NO session identity is covered by no session rule, the same
|
|
473
|
+
* fail-closed arm as project-without-cwd: an axis the caller did not thread is an axis the rule
|
|
474
|
+
* cannot speak on. `sessionId` is threaded from the ORIGINAL call context by the caller (the 375
|
|
475
|
+
* r3-② cwd posture), never inferred from the process. ONE function on purpose: this is the scope
|
|
476
|
+
* conjunct of the single eligibility predicate ({@link eligibleContext}), and a sister predicate
|
|
477
|
+
* would be the second fact source the 375 §5.1 discipline exists to forbid. */
|
|
478
|
+
export declare function scopeCoversCwd(scope: RuleScope, cwd: string | undefined, sessionId?: string): boolean;
|
|
447
479
|
/** Is this rule live — i.e. does it still carry at least one add? Deleted adds are removed by the store
|
|
448
480
|
* when tombstones are applied, so a rule with an empty `adds` is a rule that no longer exists. */
|
|
449
481
|
export declare function isRuleLive(rule: PersistedAllowRule): boolean;
|
|
@@ -459,6 +491,12 @@ export declare function isRuleLive(rule: PersistedAllowRule): boolean;
|
|
|
459
491
|
*
|
|
460
492
|
* `scope` rides beside the rule rather than inside it for the PROPOSED half's sake: a
|
|
461
493
|
* {@link ParsedAllowRule} carries no scope, so a proposal is judged against the scope it would LAND in.
|
|
494
|
+
*
|
|
495
|
+
* design/382 §4.3 — the call context carries a THIRD axis, `sessionId` (optional: an existing caller
|
|
496
|
+
* that threads only `{tool, cwd}` still compiles and still means what it meant — no session rule can
|
|
497
|
+
* cover its calls, the fail-closed direction). It is the session dimension's whole eligibility story:
|
|
498
|
+
* every consumer of this ONE predicate (the gate's conjunction arm, the coverage table, the consent
|
|
499
|
+
* prepare) inherits it without a second judge existing anywhere.
|
|
462
500
|
*/
|
|
463
501
|
export declare function eligibleContext(rule: {
|
|
464
502
|
tool: string;
|
|
@@ -466,6 +504,7 @@ export declare function eligibleContext(rule: {
|
|
|
466
504
|
}, call: {
|
|
467
505
|
tool: string;
|
|
468
506
|
cwd: string | undefined;
|
|
507
|
+
sessionId?: string;
|
|
469
508
|
}): boolean;
|
|
470
509
|
/**
|
|
471
510
|
* design/375 §5.1 — MAY this PERSISTED rule participate in admitting this call at all? The liveness
|
|
@@ -481,6 +520,7 @@ export declare function eligibleContext(rule: {
|
|
|
481
520
|
export declare function eligiblePersisted(rule: PersistedAllowRule, call: {
|
|
482
521
|
tool: string;
|
|
483
522
|
cwd: string | undefined;
|
|
523
|
+
sessionId?: string;
|
|
484
524
|
}): boolean;
|
|
485
525
|
/**
|
|
486
526
|
* Find the rules that admit this command for a task in `cwd`, or `undefined`.
|
|
@@ -515,6 +555,7 @@ export declare function findAdmittingRule(rules: readonly PersistedAllowRule[],
|
|
|
515
555
|
tool: string;
|
|
516
556
|
command: string;
|
|
517
557
|
cwd: string | undefined;
|
|
558
|
+
sessionId?: string;
|
|
518
559
|
}): readonly PersistedAllowRule[] | undefined;
|
|
519
560
|
/**
|
|
520
561
|
* design/375 §5.2 — the per-segment coverage table for `command`: which segments an eligible rule
|
|
@@ -555,6 +596,7 @@ export declare function segmentCoverageOf(command: string, rules: {
|
|
|
555
596
|
}, call: {
|
|
556
597
|
tool: string;
|
|
557
598
|
cwd: string | undefined;
|
|
599
|
+
sessionId?: string;
|
|
558
600
|
}): readonly SegmentCoverage[] | undefined;
|
|
559
601
|
/** One row of a per-segment coverage table (see {@link segmentCoverageOf}): the FOLDED, trimmed
|
|
560
602
|
* segment text (a display/correlation seat, never adjudication input) and whether an eligible rule
|
|
@@ -295,16 +295,18 @@ export function pathWithinRoot(path, root) {
|
|
|
295
295
|
const base = root.endsWith("/") ? root : root + "/";
|
|
296
296
|
return path.startsWith(base);
|
|
297
297
|
}
|
|
298
|
-
export function scopeCoversCwd(scope, cwd) {
|
|
298
|
+
export function scopeCoversCwd(scope, cwd, sessionId) {
|
|
299
299
|
if (scope.kind === "global")
|
|
300
300
|
return true;
|
|
301
|
+
if (scope.kind === "session")
|
|
302
|
+
return sessionId !== undefined && sessionId === scope.sessionId;
|
|
301
303
|
return cwd !== undefined && pathWithinRoot(cwd, scope.root);
|
|
302
304
|
}
|
|
303
305
|
export function isRuleLive(rule) {
|
|
304
306
|
return rule.adds.length > 0;
|
|
305
307
|
}
|
|
306
308
|
export function eligibleContext(rule, call) {
|
|
307
|
-
return rule.tool === call.tool && scopeCoversCwd(rule.scope, call.cwd);
|
|
309
|
+
return rule.tool === call.tool && scopeCoversCwd(rule.scope, call.cwd, call.sessionId);
|
|
308
310
|
}
|
|
309
311
|
export function eligiblePersisted(rule, call) {
|
|
310
312
|
return isRuleLive(rule) && eligibleContext(rule, call);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/382 §4.3 — the SESSION-RULE OVERLAY: where a `{kind:"session"}` scoped allow rule lives.
|
|
3
|
+
*
|
|
4
|
+
* A session authorization's home is the session's own state — never the persisted store. The rows are
|
|
5
|
+
* ordinary {@link PersistedAllowRule}s (scope `session`), so every read-side consumer — the gate's
|
|
6
|
+
* conjunction arm, the coverage table, the consent prepare — takes them through the ONE eligibility
|
|
7
|
+
* predicate with zero second judges: the merge point splices overlay rows IN FRONT of the persisted
|
|
8
|
+
* `list()` (the §4.1 narrowest-first reporting order: session > project > global), and everything
|
|
9
|
+
* downstream is unchanged.
|
|
10
|
+
*
|
|
11
|
+
* ## The contract (design/382 §8-Q6, settled here)
|
|
12
|
+
*
|
|
13
|
+
* TWO verbs, and deliberately no third:
|
|
14
|
+
* · `read(sessionId)` — the live rows of ONE session, session-scoped, that session's only.
|
|
15
|
+
* · `apply(sessionId, add)` — land one redeemed authorization. Idempotent BY DOT (the redemption
|
|
16
|
+
* replay contract: `alreadyRedeemed` marks the dot as RECORDED, not the overlay as LANDED, so a
|
|
17
|
+
* replay must RE-APPLY, and this verb is what makes re-applying safe); refuses `session_ended`
|
|
18
|
+
* once the session's lifetime epoch is sealed — a late in-flight write after termination must not
|
|
19
|
+
* revive an authorization for a session that no longer exists.
|
|
20
|
+
*
|
|
21
|
+
* There is deliberately NO delete verb: a session row is not individually deletable — it dies with
|
|
22
|
+
* its session (design/382 §10, ruled). Termination is the HOST's act on its own implementation (see
|
|
23
|
+
* {@link InMemorySessionRuleOverlay.endSession}), not an engine verb.
|
|
24
|
+
*
|
|
25
|
+
* ## Lifecycle and durability
|
|
26
|
+
*
|
|
27
|
+
* The overlay lives WITH the session: a host that parks/checkpoints a session serializes its rows
|
|
28
|
+
* beside it and rehydrates them on resume (`snapshotSession`/`restoreSession` on the reference
|
|
29
|
+
* implementation) — that is how a session authorization survives a park without ever touching the
|
|
30
|
+
* persisted store, and why it still ends when the session ends. `sessionId` uniqueness is the host's
|
|
31
|
+
* duty (the `principal` posture): reusing an id declares "the same session".
|
|
32
|
+
*
|
|
33
|
+
* ## What this is NOT
|
|
34
|
+
*
|
|
35
|
+
* Not a store backend: no dots are minted here (the redemption leg mints the audit dot exactly as the
|
|
36
|
+
* store leg does), no tombstones exist here, nothing here syncs. Every durable entrance — write
|
|
37
|
+
* deltas, sync in both directions, at-rest bytes — refuses or drops a session row
|
|
38
|
+
* (`permission-rule-store.ts`, the durable two-member face); this module is the OTHER side of that
|
|
39
|
+
* wall, and the wall is the design.
|
|
40
|
+
*/
|
|
41
|
+
import type { PersistedAllowRule, RuleAdd, PersistedRuleMatch, PersistedRuleTool } from "./permission-rule-model.js";
|
|
42
|
+
/** One redeemed authorization landing in a session's overlay: the rule's canonical shape plus the
|
|
43
|
+
* redemption's own add (dot + provenance) — the same fields a `redemption-add` delta carries, minus
|
|
44
|
+
* the scope (the verb's `sessionId` IS the scope, constructed here so a caller cannot land a row
|
|
45
|
+
* whose scope names a different session than the one it addressed). */
|
|
46
|
+
export interface SessionRuleOverlayAdd {
|
|
47
|
+
rule: string;
|
|
48
|
+
tool: PersistedRuleTool;
|
|
49
|
+
match: PersistedRuleMatch;
|
|
50
|
+
command: string;
|
|
51
|
+
add: RuleAdd;
|
|
52
|
+
}
|
|
53
|
+
/** What one overlay apply answered. `session_ended` is the epoch fence speaking: the write arrived at
|
|
54
|
+
* (or after) termination and landed nothing — the redemption reports the member `refused`. */
|
|
55
|
+
export type SessionRuleOverlayApplyResult = {
|
|
56
|
+
landed: true;
|
|
57
|
+
} | {
|
|
58
|
+
refused: "session_ended";
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The host-provided seam the consent lane and the gate's rule lane consume
|
|
62
|
+
* (`RuleConsentDeps.sessionRules`, `RunnerDeps.sessionPermissionRules`). See the module doc for the
|
|
63
|
+
* contract; {@link InMemorySessionRuleOverlay} is the reference implementation and the semantics.
|
|
64
|
+
*/
|
|
65
|
+
export interface SessionRuleOverlay {
|
|
66
|
+
/** The live session-scoped rows of ONE session. An ended (or never-written) session reads as `[]`. */
|
|
67
|
+
read(sessionId: string): Promise<readonly PersistedAllowRule[]>;
|
|
68
|
+
/** Land one redeemed authorization — idempotent by dot, refused once the session ended. */
|
|
69
|
+
apply(sessionId: string, add: SessionRuleOverlayAdd): Promise<SessionRuleOverlayApplyResult>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The reference overlay — in-memory, per-process, the semantics every host implementation must keep:
|
|
73
|
+
*
|
|
74
|
+
* · APPLY is idempotent by dot identity (a replayed redemption re-submits its recorded dot and
|
|
75
|
+
* changes nothing — the store's `foldDelta` discipline, verbatim);
|
|
76
|
+
* · TERMINATION seals FIRST, clears SECOND ({@link endSession}): the sealed-set membership is the
|
|
77
|
+
* session's lifetime epoch, flipped atomically before any state is dropped, so an in-flight write
|
|
78
|
+
* that lost the race lands on the seal and answers `session_ended` — never on a half-cleared
|
|
79
|
+
* session, and never as a row that outlives its session (design/382 §4.3, the r2-⑥ fence);
|
|
80
|
+
* · a sealed session never reopens — `apply` and `restoreSession` both refuse it, so "session ended"
|
|
81
|
+
* is a one-way fact exactly like a quarantined dot's;
|
|
82
|
+
* · SNAPSHOT/RESTORE ({@link snapshotSession}/{@link restoreSession}) are the park/resume carriage:
|
|
83
|
+
* the host serializes a session's rows into its checkpoint and rehydrates them on resume. Restore
|
|
84
|
+
* is fail-closed on shape — every row must be a session row of THE session being restored (a
|
|
85
|
+
* checkpoint is host-owned bytes, and a global row smuggled through this door would ride the
|
|
86
|
+
* merge point straight into adjudication).
|
|
87
|
+
*/
|
|
88
|
+
export declare class InMemorySessionRuleOverlay implements SessionRuleOverlay {
|
|
89
|
+
private readonly rows;
|
|
90
|
+
private readonly sealed;
|
|
91
|
+
/** How many times one session's row set has CHANGED. Reading a caller-owned value runs caller code,
|
|
92
|
+
* so a verb that reads its argument and then replaces a session's rows wholesale must be able to
|
|
93
|
+
* tell that something landed in between — otherwise it overwrites a grant that was already answered
|
|
94
|
+
* `landed:true`, and a consent nobody withdrew stops existing. Bumped by every landing, restore and
|
|
95
|
+
* termination; read across `restoreSession`'s snapshot. */
|
|
96
|
+
private readonly epochs;
|
|
97
|
+
private bumpEpoch;
|
|
98
|
+
read(sessionId: string): Promise<readonly PersistedAllowRule[]>;
|
|
99
|
+
apply(sessionId: string, add: SessionRuleOverlayAdd): Promise<SessionRuleOverlayApplyResult>;
|
|
100
|
+
/**
|
|
101
|
+
* The termination transfer: SEAL the lifetime epoch first (one atomic membership flip — after this
|
|
102
|
+
* line every in-flight `apply` holding this id answers `session_ended`), THEN drop the rows. The
|
|
103
|
+
* order is the whole fence: clear-then-seal would leave a window in which a late write re-grows a
|
|
104
|
+
* rule set for a session that is already gone. Idempotent; a never-seen id seals to the same place.
|
|
105
|
+
*/
|
|
106
|
+
endSession(sessionId: string): void;
|
|
107
|
+
/** The park half of the checkpoint carriage: this session's rows, serializable as-is. */
|
|
108
|
+
snapshotSession(sessionId: string): Promise<readonly PersistedAllowRule[]>;
|
|
109
|
+
/**
|
|
110
|
+
* The resume half. Fail-closed on every axis: a sealed session never rehydrates (termination is
|
|
111
|
+
* one-way), and every row must be a well-formed SESSION row of exactly this session — anything else
|
|
112
|
+
* in the checkpoint bytes is refused whole, loudly, because the merge point trusts this overlay to
|
|
113
|
+
* serve only what its contract says it holds.
|
|
114
|
+
*
|
|
115
|
+
* The rows also pass the SAME `normalizePersistedRule` re-projection the sync face runs on store
|
|
116
|
+
* rows (rule text parses, canonical spelling, stored tool/match/command agree with the text): this
|
|
117
|
+
* door takes host-owned checkpoint bytes with no redemption validator in front of them, and a row
|
|
118
|
+
* the engine could not re-project would otherwise sit one merge away from adjudication. Rows this
|
|
119
|
+
* overlay landed itself always pass — {@link InMemorySessionRuleOverlay.apply} runs the same screen,
|
|
120
|
+
* so the two doors agree on what a session row is and a snapshot of this overlay always restores —
|
|
121
|
+
* which is what makes a refusal here foreign damage, refused whole.
|
|
122
|
+
*/
|
|
123
|
+
restoreSession(sessionId: string, rows: readonly PersistedAllowRule[]): Promise<void>;
|
|
124
|
+
}
|