@sema-agent/core 7.4.0 → 7.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/dist/agents/cascade.d.ts +2 -1
- package/dist/agents/peer-notices.d.ts +11 -1
- package/dist/agents/peer-session-drain.js +2 -0
- package/dist/agents/verify.d.ts +2 -1
- package/dist/core/ask-origin.d.ts +130 -0
- package/dist/core/ask-origin.js +35 -0
- package/dist/core/auto-mode-arming.d.ts +40 -1
- package/dist/core/auto-mode-arming.js +51 -3
- package/dist/core/auto-mode.d.ts +87 -10
- package/dist/core/auto-mode.js +34 -6
- package/dist/core/checkpoint-store.d.ts +41 -6
- package/dist/core/checkpoint-store.js +8 -0
- package/dist/core/hooks.d.ts +63 -19
- package/dist/core/hooks.js +37 -16
- package/dist/core/mcp.d.ts +47 -55
- package/dist/core/mcp.js +89 -31
- package/dist/core/park-selfcheck.js +3 -0
- package/dist/core/permission-rule-consent.d.ts +2 -11
- package/dist/core/permission-rule-consent.js +13 -62
- package/dist/core/permission-rule-org.d.ts +70 -54
- package/dist/core/permission-rule-org.js +47 -61
- package/dist/core/permission-rule-provider.d.ts +162 -0
- package/dist/core/permission-rule-provider.js +148 -0
- package/dist/core/permission-rule-session.d.ts +23 -19
- package/dist/core/permission-rule-session.js +5 -5
- package/dist/core/permission-rule-store.d.ts +46 -21
- package/dist/core/permission-rule-store.js +13 -6
- package/dist/core/permission-rule-sync.d.ts +2 -1
- package/dist/core/permission-rule-sync.js +11 -0
- package/dist/core/runner/assemble-result.d.ts +3 -2
- package/dist/core/runner/checkpoint-scope.d.ts +32 -0
- package/dist/core/runner/checkpoint-scope.js +4 -0
- package/dist/core/runner/contracts.d.ts +1878 -0
- package/dist/core/runner/contracts.js +1 -0
- package/dist/core/runner/denial-limit-arms.d.ts +57 -31
- package/dist/core/runner/denial-limit-arms.js +42 -17
- package/dist/core/runner/derived-route-fallback.d.ts +34 -0
- package/dist/core/runner/derived-route-fallback.js +16 -0
- package/dist/core/runner/prepare-acquire-reconcile.d.ts +1 -1
- package/dist/core/runner/prepare-announce-once.d.ts +83 -0
- package/dist/core/runner/prepare-announce-once.js +105 -0
- package/dist/core/runner/prepare-caps-and-workflow.d.ts +170 -0
- package/dist/core/runner/prepare-caps-and-workflow.js +255 -0
- package/dist/core/runner/prepare-config-doors.d.ts +2 -10
- package/dist/core/runner/prepare-defer-classify.d.ts +86 -0
- package/dist/core/runner/prepare-defer-classify.js +107 -0
- package/dist/core/runner/prepare-delegation-surface.d.ts +104 -0
- package/dist/core/runner/prepare-delegation-surface.js +144 -0
- package/dist/core/runner/prepare-execution-env.d.ts +54 -0
- package/dist/core/runner/prepare-execution-env.js +86 -0
- package/dist/core/runner/prepare-file-history.d.ts +95 -0
- package/dist/core/runner/prepare-file-history.js +383 -0
- package/dist/core/runner/prepare-hands-readface.d.ts +6 -8
- package/dist/core/runner/prepare-hands-readface.js +3 -3
- package/dist/core/runner/prepare-inherited-gate.d.ts +268 -0
- package/dist/core/runner/prepare-inherited-gate.js +266 -0
- package/dist/core/runner/prepare-listings.d.ts +77 -0
- package/dist/core/runner/prepare-listings.js +76 -0
- package/dist/core/runner/prepare-lsp.d.ts +55 -0
- package/dist/core/runner/prepare-lsp.js +27 -0
- package/dist/core/runner/prepare-memory.d.ts +1 -1
- package/dist/core/runner/prepare-offload-wrappers.d.ts +62 -0
- package/dist/core/runner/prepare-offload-wrappers.js +45 -0
- package/dist/core/runner/prepare-permission-rules.d.ts +132 -0
- package/dist/core/runner/prepare-permission-rules.js +140 -0
- package/dist/core/runner/prepare-project-context.d.ts +131 -0
- package/dist/core/runner/prepare-project-context.js +150 -0
- package/dist/core/runner/prepare-prompt-inputs.d.ts +138 -0
- package/dist/core/runner/prepare-prompt-inputs.js +141 -0
- package/dist/core/runner/prepare-protocol-tools.d.ts +91 -0
- package/dist/core/runner/prepare-protocol-tools.js +182 -0
- package/dist/core/runner/prepare-question-face.d.ts +119 -0
- package/dist/core/runner/prepare-question-face.js +83 -0
- package/dist/core/runner/prepare-run-refs.d.ts +89 -0
- package/dist/core/runner/prepare-run-refs.js +39 -0
- package/dist/core/runner/prepare-safety-scan.d.ts +3 -2
- package/dist/core/runner/prepare-task.d.ts +11 -1815
- package/dist/core/runner/prepare-task.js +138 -2542
- package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +111 -0
- package/dist/core/runner/prepare-tool-disclosure-mount.js +219 -0
- package/dist/core/runner/prepare-wiring-manifest.d.ts +184 -0
- package/dist/core/runner/prepare-wiring-manifest.js +240 -0
- package/dist/core/runner/prepare-workspace-restore.d.ts +1 -27
- package/dist/core/runner/prepare-workspace-restore.js +1 -22
- package/dist/core/runner/rollback-stack.d.ts +32 -0
- package/dist/core/runner/rollback-stack.js +30 -0
- package/dist/core/runner/runtask.d.ts +11 -2
- package/dist/core/runner/runtask.js +27 -9
- package/dist/core/runner/workspace-path.d.ts +33 -0
- package/dist/core/runner/workspace-path.js +22 -0
- package/dist/core/sensitive-path-policy.d.ts +16 -0
- package/dist/core/sensitive-path-policy.js +1 -1
- package/dist/core/tool-policy.d.ts +57 -9
- package/dist/core/tool-policy.js +11 -0
- package/dist/core/types.d.ts +63 -51
- package/dist/core/wiring-manifest.d.ts +40 -3
- package/dist/core/wiring-manifest.js +4 -3
- package/dist/core/write-protect.d.ts +13 -2
- package/dist/core/write-protect.js +58 -29
- package/dist/engine/harness/types.d.ts +38 -16
- package/dist/engine/harness/types.js +25 -1
- package/dist/engine/session/session.d.ts +3 -11
- package/dist/index.d.ts +10 -6
- package/dist/index.js +9 -5
- package/dist/internal/harness.d.ts +1 -0
- package/dist/stores/file/adoption/adopt.d.ts +1 -1
- package/dist/stores/file/adoption/marker.d.ts +1 -1
- package/dist/stores/file/permission-rule-adopt.js +4 -3
- package/dist/stores/file/permission-rule-store.d.ts +65 -25
- package/dist/stores/file/permission-rule-store.js +215 -37
- package/dist/stores/file/task-list-store.d.ts +1 -1
- package/dist/tools/fs/read-face.d.ts +1 -1
- package/package.json +8 -2
- package/test/export-surface.snapshot.json +76 -28
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { escapeForDisclosure } from "./permission-rule-model.js";
|
|
2
|
+
import { PERMISSION_RULE_WRITER, errText, normalizePersistedRule, sameScope, writerOf, } from "./permission-rule-store.js";
|
|
3
|
+
import { UNGOVERNED_ORG_RESOLUTION, compileOrgShadowPredicate, createOrgRulePartition } from "./permission-rule-org.js";
|
|
4
|
+
export function ruleSourceOf(scope) {
|
|
5
|
+
return scope.kind === "global" ? "user" : scope.kind;
|
|
6
|
+
}
|
|
7
|
+
function usableSessionId(sessionId) {
|
|
8
|
+
return typeof sessionId === "string" && sessionId !== "" ? sessionId : undefined;
|
|
9
|
+
}
|
|
10
|
+
const EMPTY_DURABLE = { rules: [], tombstones: [], rev: 0 };
|
|
11
|
+
export function createPermissionRuleStoreProvider(cfg) {
|
|
12
|
+
if (cfg === null || typeof cfg !== "object") {
|
|
13
|
+
throw new Error("createPermissionRuleStoreProvider takes a config object naming the partitions to compose");
|
|
14
|
+
}
|
|
15
|
+
const durable = cfg.durable;
|
|
16
|
+
const org = cfg.org !== undefined ? createOrgRulePartition(cfg.org) : undefined;
|
|
17
|
+
const session = cfg.session;
|
|
18
|
+
if (session !== undefined && (typeof session.read !== "function" || typeof session.apply !== "function")) {
|
|
19
|
+
throw new Error("the session partition must implement read(sessionId) and apply(sessionId, add) — refusing a partial face rather than serving a session source that cannot answer");
|
|
20
|
+
}
|
|
21
|
+
const partitions = Object.freeze({ durable: durable !== undefined, org: org !== undefined, session: session !== undefined });
|
|
22
|
+
const storeOver = (partition) => new UnifiedPermissionRuleStore(partition, org, session);
|
|
23
|
+
const provider = {
|
|
24
|
+
partitions,
|
|
25
|
+
forPrincipal: (principal) => storeOver(durable?.forPrincipal(principal)),
|
|
26
|
+
...(durable?.forLocalOwner !== undefined ? { forLocalOwner: () => storeOver(durable.forLocalOwner()) } : {}),
|
|
27
|
+
};
|
|
28
|
+
return provider;
|
|
29
|
+
}
|
|
30
|
+
class UnifiedPermissionRuleStore {
|
|
31
|
+
durable;
|
|
32
|
+
org;
|
|
33
|
+
session;
|
|
34
|
+
durability;
|
|
35
|
+
fidelity;
|
|
36
|
+
constructor(durable, org, session) {
|
|
37
|
+
this.durable = durable;
|
|
38
|
+
this.org = org;
|
|
39
|
+
this.session = session;
|
|
40
|
+
this.durability = durable?.durability ?? "process-local";
|
|
41
|
+
this.fidelity = durable?.fidelity;
|
|
42
|
+
if (durable?.quarantined !== undefined) {
|
|
43
|
+
this.quarantined = () => durable.quarantined();
|
|
44
|
+
}
|
|
45
|
+
if (durable !== undefined) {
|
|
46
|
+
Object.defineProperty(this, PERMISSION_RULE_WRITER, {
|
|
47
|
+
get: () => {
|
|
48
|
+
const w = writerOf(durable);
|
|
49
|
+
return w === undefined ? undefined : routingWriter(w, session);
|
|
50
|
+
},
|
|
51
|
+
enumerable: false,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
quarantined;
|
|
56
|
+
async effective(query) {
|
|
57
|
+
const disclosures = [];
|
|
58
|
+
const [orgSettled, durableSettled, sessionRows] = await Promise.all([
|
|
59
|
+
this.org !== undefined ? this.org.resolve() : Promise.resolve({ ...UNGOVERNED_ORG_RESOLUTION, rules: [], disclosures: [] }),
|
|
60
|
+
this.durable !== undefined ? this.durable.list().then((v) => ({ ok: true, v }), (e) => ({ ok: false, e })) : Promise.resolve({ ok: true, v: EMPTY_DURABLE }),
|
|
61
|
+
this.readSessionRows(usableSessionId(query?.sessionId), disclosures),
|
|
62
|
+
]);
|
|
63
|
+
const orgResolution = orgSettled;
|
|
64
|
+
if (!durableSettled.ok) {
|
|
65
|
+
return { rules: [], removed: [], org: orgResolution, rev: 0, disclosures, unreadable: errText(durableSettled.e) };
|
|
66
|
+
}
|
|
67
|
+
const durableRead = durableSettled.v;
|
|
68
|
+
const shadowed = compileOrgShadowPredicate(orgResolution.rules);
|
|
69
|
+
const rules = [];
|
|
70
|
+
for (const r of [...sessionRows, ...durableRead.rules]) {
|
|
71
|
+
rules.push({ ...r, source: ruleSourceOf(r.scope), status: shadowed(r) ? "shadowed-by-org" : "live" });
|
|
72
|
+
}
|
|
73
|
+
const removed = [];
|
|
74
|
+
for (const t of durableRead.tombstones) {
|
|
75
|
+
if (durableRead.rules.some((r) => r.rule === t.rule && sameScope(r.scope, t.scope)))
|
|
76
|
+
continue;
|
|
77
|
+
if (removed.some((e) => e.rule === t.rule && sameScope(e.scope, t.scope)))
|
|
78
|
+
continue;
|
|
79
|
+
if (t.scope.kind === "session")
|
|
80
|
+
continue;
|
|
81
|
+
removed.push({ rule: t.rule, scope: t.scope, source: ruleSourceOf(t.scope), status: "removed" });
|
|
82
|
+
}
|
|
83
|
+
return { rules, removed, org: orgResolution, rev: durableRead.rev, disclosures };
|
|
84
|
+
}
|
|
85
|
+
async readSessionRows(sessionId, disclosures) {
|
|
86
|
+
if (this.session === undefined || sessionId === undefined)
|
|
87
|
+
return [];
|
|
88
|
+
try {
|
|
89
|
+
const served = structuredClone(await this.session.read(sessionId));
|
|
90
|
+
const rows = served.filter((r) => {
|
|
91
|
+
const scope = r?.scope;
|
|
92
|
+
return scope?.kind === "session" && scope.sessionId === sessionId && !("reject" in normalizePersistedRule(r));
|
|
93
|
+
});
|
|
94
|
+
if (rows.length < served.length) {
|
|
95
|
+
disclosures.push(`the session rule partition served ${served.length - rows.length} row(s) that are not canonical session rows of this session — dropped, not adjudicated`);
|
|
96
|
+
}
|
|
97
|
+
return rows;
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
disclosures.push(`the session rule partition could not be read: ${escapeForDisclosure(errText(err))}`);
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function routingWriter(durable, session) {
|
|
106
|
+
return {
|
|
107
|
+
nextDot: () => durable.nextDot(),
|
|
108
|
+
readRaw: () => durable.readRaw(),
|
|
109
|
+
apply: async (delta, opts) => {
|
|
110
|
+
const scope = delta.kind === "redemption-add" ? structuredClone(delta.scope) : undefined;
|
|
111
|
+
if (delta.kind !== "redemption-add" || scope?.kind !== "session")
|
|
112
|
+
return durable.apply(delta, opts);
|
|
113
|
+
if (session === undefined) {
|
|
114
|
+
const e = new Error("a session-scope rule has nowhere to land — this deployment configured no session rule partition (createPermissionRuleStoreProvider({ session })); refusing the redemption-add");
|
|
115
|
+
e.code = "unsupported.session_scope_store";
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
const outcome = await session.apply(scope.sessionId, { rule: delta.rule, tool: delta.tool, match: delta.match, command: delta.command, add: delta.add });
|
|
119
|
+
return "refused" in outcome ? { refused: "session_ended", rev: opts.expectedRev } : { rev: opts.expectedRev };
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export async function effectiveOrThrow(store, query) {
|
|
124
|
+
const view = await store.effective(query);
|
|
125
|
+
if (view.unreadable !== undefined)
|
|
126
|
+
throw new Error(view.unreadable);
|
|
127
|
+
return view;
|
|
128
|
+
}
|
|
129
|
+
export async function effectivePermissionRules(opts) {
|
|
130
|
+
return await resolveOwnerStore(opts).effective(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : undefined);
|
|
131
|
+
}
|
|
132
|
+
export function resolveOwnerStore(opts) {
|
|
133
|
+
if (opts.owner !== undefined && opts.owner.kind === "local-owner") {
|
|
134
|
+
if (opts.principal !== undefined) {
|
|
135
|
+
throw new Error("pass either a principal or a local-owner, not both — a bucket has one owner");
|
|
136
|
+
}
|
|
137
|
+
const store = opts.provider.forLocalOwner?.();
|
|
138
|
+
if (store === undefined) {
|
|
139
|
+
throw new Error("this provider has no local-owner bucket (forLocalOwner is not implemented)");
|
|
140
|
+
}
|
|
141
|
+
return store;
|
|
142
|
+
}
|
|
143
|
+
if (opts.owner !== undefined && opts.owner.kind === "principal" && opts.principal !== undefined && opts.owner.principal !== opts.principal) {
|
|
144
|
+
throw new Error(`contradictory identity: principal "${escapeForDisclosure(opts.principal)}" and owner principal "${escapeForDisclosure(opts.owner.principal)}" disagree`);
|
|
145
|
+
}
|
|
146
|
+
const principal = opts.owner?.kind === "principal" ? opts.owner.principal : opts.principal;
|
|
147
|
+
return opts.provider.forPrincipal(principal);
|
|
148
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* design/382 §4.3 — the SESSION-
|
|
2
|
+
* design/382 §4.3 / design/389 — the SESSION partition of the permission-rule store: where a
|
|
3
|
+
* `{kind:"session"}` scoped allow rule lives.
|
|
3
4
|
*
|
|
4
|
-
* A session authorization's home is the session's own
|
|
5
|
-
* ordinary {@link PersistedAllowRule}s (scope `session`),
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* A session authorization's home is the session's own lifetime — never the durable CRDT partition. The
|
|
6
|
+
* rows are ordinary {@link PersistedAllowRule}s (scope `session`), and the unified store's ONE read
|
|
7
|
+
* query (`PermissionRuleStore.effective({ sessionId })`) splices this partition's rows IN FRONT of the
|
|
8
|
+
* durable partition's (the §4.1 narrowest-first reporting order: session > project > global), so every
|
|
9
|
+
* read-side consumer — the gate's conjunction arm, the coverage table, the consent prepare — takes them
|
|
10
|
+
* through the ONE eligibility predicate with zero second judges. The store's routing writer lands a
|
|
11
|
+
* session-scope `redemption-add` here; the durable partition never sees one.
|
|
10
12
|
*
|
|
11
13
|
* ## The contract (design/382 §8-Q6, settled here)
|
|
12
14
|
*
|
|
@@ -20,7 +22,7 @@
|
|
|
20
22
|
*
|
|
21
23
|
* There is deliberately NO delete verb: a session row is not individually deletable — it dies with
|
|
22
24
|
* its session (design/382 §10, ruled). Termination is the HOST's act on its own implementation (see
|
|
23
|
-
* {@link
|
|
25
|
+
* {@link InMemorySessionRulePartition.endSession}), not an engine verb.
|
|
24
26
|
*
|
|
25
27
|
* ## Lifecycle and durability
|
|
26
28
|
*
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
*
|
|
33
35
|
* ## What this is NOT
|
|
34
36
|
*
|
|
35
|
-
* Not a
|
|
37
|
+
* Not a durable backend: no dots are minted here (the redemption leg mints the audit dot exactly as the
|
|
36
38
|
* store leg does), no tombstones exist here, nothing here syncs. Every durable entrance — write
|
|
37
39
|
* deltas, sync in both directions, at-rest bytes — refuses or drops a session row
|
|
38
40
|
* (`permission-rule-store.ts`, the durable two-member face); this module is the OTHER side of that
|
|
@@ -43,7 +45,7 @@ import type { PersistedAllowRule, RuleAdd, PersistedRuleMatch, PersistedRuleTool
|
|
|
43
45
|
* redemption's own add (dot + provenance) — the same fields a `redemption-add` delta carries, minus
|
|
44
46
|
* the scope (the verb's `sessionId` IS the scope, constructed here so a caller cannot land a row
|
|
45
47
|
* whose scope names a different session than the one it addressed). */
|
|
46
|
-
export interface
|
|
48
|
+
export interface SessionRuleAdd {
|
|
47
49
|
rule: string;
|
|
48
50
|
tool: PersistedRuleTool;
|
|
49
51
|
match: PersistedRuleMatch;
|
|
@@ -52,21 +54,23 @@ export interface SessionRuleOverlayAdd {
|
|
|
52
54
|
}
|
|
53
55
|
/** What one overlay apply answered. `session_ended` is the epoch fence speaking: the write arrived at
|
|
54
56
|
* (or after) termination and landed nothing — the redemption reports the member `refused`. */
|
|
55
|
-
export type
|
|
57
|
+
export type SessionRuleApplyResult = {
|
|
56
58
|
landed: true;
|
|
57
59
|
} | {
|
|
58
60
|
refused: "session_ended";
|
|
59
61
|
};
|
|
60
62
|
/**
|
|
61
|
-
* The
|
|
62
|
-
*
|
|
63
|
-
*
|
|
63
|
+
* The session partition a deployment hands to `createPermissionRuleStoreProvider({ session })`. See the
|
|
64
|
+
* module doc for the contract; {@link InMemorySessionRulePartition} is the reference implementation and
|
|
65
|
+
* the semantics. A backend that keeps a session's rows beside its own session record (a host whose
|
|
66
|
+
* sessions are durable) may implement this over that storage — the law is LIFETIME-bound, not
|
|
67
|
+
* medium-bound: no tombstones, no sync, nothing outlives the session.
|
|
64
68
|
*/
|
|
65
|
-
export interface
|
|
69
|
+
export interface SessionRulePartition {
|
|
66
70
|
/** The live session-scoped rows of ONE session. An ended (or never-written) session reads as `[]`. */
|
|
67
71
|
read(sessionId: string): Promise<readonly PersistedAllowRule[]>;
|
|
68
72
|
/** Land one redeemed authorization — idempotent by dot, refused once the session ended. */
|
|
69
|
-
apply(sessionId: string, add:
|
|
73
|
+
apply(sessionId: string, add: SessionRuleAdd): Promise<SessionRuleApplyResult>;
|
|
70
74
|
}
|
|
71
75
|
/**
|
|
72
76
|
* The reference overlay — in-memory, per-process, the semantics every host implementation must keep:
|
|
@@ -85,7 +89,7 @@ export interface SessionRuleOverlay {
|
|
|
85
89
|
* checkpoint is host-owned bytes, and a global row smuggled through this door would ride the
|
|
86
90
|
* merge point straight into adjudication).
|
|
87
91
|
*/
|
|
88
|
-
export declare class
|
|
92
|
+
export declare class InMemorySessionRulePartition implements SessionRulePartition {
|
|
89
93
|
private readonly rows;
|
|
90
94
|
private readonly sealed;
|
|
91
95
|
/** How many times one session's row set has CHANGED. Reading a caller-owned value runs caller code,
|
|
@@ -96,7 +100,7 @@ export declare class InMemorySessionRuleOverlay implements SessionRuleOverlay {
|
|
|
96
100
|
private readonly epochs;
|
|
97
101
|
private bumpEpoch;
|
|
98
102
|
read(sessionId: string): Promise<readonly PersistedAllowRule[]>;
|
|
99
|
-
apply(sessionId: string, add:
|
|
103
|
+
apply(sessionId: string, add: SessionRuleAdd): Promise<SessionRuleApplyResult>;
|
|
100
104
|
/**
|
|
101
105
|
* The termination transfer: SEAL the lifetime epoch first (one atomic membership flip — after this
|
|
102
106
|
* line every in-flight `apply` holding this id answers `session_ended`), THEN drop the rows. The
|
|
@@ -116,7 +120,7 @@ export declare class InMemorySessionRuleOverlay implements SessionRuleOverlay {
|
|
|
116
120
|
* rows (rule text parses, canonical spelling, stored tool/match/command agree with the text): this
|
|
117
121
|
* door takes host-owned checkpoint bytes with no redemption validator in front of them, and a row
|
|
118
122
|
* the engine could not re-project would otherwise sit one merge away from adjudication. Rows this
|
|
119
|
-
* overlay landed itself always pass — {@link
|
|
123
|
+
* overlay landed itself always pass — {@link InMemorySessionRulePartition.apply} runs the same screen,
|
|
120
124
|
* so the two doors agree on what a session row is and a snapshot of this overlay always restores —
|
|
121
125
|
* which is what makes a refusal here foreign damage, refused whole.
|
|
122
126
|
*/
|
|
@@ -7,7 +7,7 @@ function isValidOverlayAdd(add) {
|
|
|
7
7
|
return false;
|
|
8
8
|
return typeof a.createdAt === "string";
|
|
9
9
|
}
|
|
10
|
-
export class
|
|
10
|
+
export class InMemorySessionRulePartition {
|
|
11
11
|
rows = new Map();
|
|
12
12
|
sealed = new Set();
|
|
13
13
|
epochs = new Map();
|
|
@@ -24,15 +24,15 @@ export class InMemorySessionRuleOverlay {
|
|
|
24
24
|
if (this.sealed.has(sessionId))
|
|
25
25
|
return { refused: "session_ended" };
|
|
26
26
|
if (!isValidOverlayAdd(add?.add)) {
|
|
27
|
-
throw new Error("
|
|
27
|
+
throw new Error("SessionRulePartition.apply was handed a malformed add (dot/origin/createdAt) — evidence this shape cannot vouch for must not install");
|
|
28
28
|
}
|
|
29
29
|
const scope = { kind: "session", sessionId };
|
|
30
30
|
if (!isValidConsentScope(scope)) {
|
|
31
|
-
throw new Error("
|
|
31
|
+
throw new Error("SessionRulePartition.apply was addressed with an unusable session id — a session nobody can name is not a scope a grant can live under");
|
|
32
32
|
}
|
|
33
33
|
const projected = { rule: add.rule, tool: add.tool, match: add.match, command: add.command, scope, adds: [add.add] };
|
|
34
34
|
if ("reject" in normalizePersistedRule(projected)) {
|
|
35
|
-
throw new Error("
|
|
35
|
+
throw new Error("SessionRulePartition.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
36
|
}
|
|
37
37
|
const rows = this.rows.get(sessionId) ?? [];
|
|
38
38
|
if (!this.rows.has(sessionId))
|
|
@@ -41,7 +41,7 @@ export class InMemorySessionRuleOverlay {
|
|
|
41
41
|
for (const [heldSession, heldRows] of this.rows) {
|
|
42
42
|
const holder = heldRows.find((r) => r.adds.some(sameDotAs));
|
|
43
43
|
if (holder !== undefined && (heldSession !== sessionId || holder.rule !== add.rule)) {
|
|
44
|
-
throw new Error(`
|
|
44
|
+
throw new Error(`SessionRulePartition.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
45
|
}
|
|
46
46
|
}
|
|
47
47
|
const existing = rows.find((r) => r.rule === add.rule);
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* design/179 §8 — the
|
|
2
|
+
* design/179 §8 / design/389 — the DURABLE partition of the permission-rule store (the OR-Set CRDT that
|
|
3
|
+
* holds the `user` and `project` sources), its backend write face, the pure fold/join law every backend
|
|
4
|
+
* shares, and the removal entry.
|
|
5
|
+
*
|
|
6
|
+
* The host-visible STORE is `permission-rule-provider.ts` (`PermissionRuleStore` — one read query over
|
|
7
|
+
* the four source partitions). What lives here is the partition a backend implements: `list()` over one
|
|
8
|
+
* owner's bucket, the writer handle, and the delta semantics. A backend author implements
|
|
9
|
+
* {@link DurableRulePartitionProvider}; the engine composes it into the store with
|
|
10
|
+
* `createPermissionRuleStoreProvider`.
|
|
3
11
|
*
|
|
4
12
|
* ## Two faces, deliberately unequal
|
|
5
13
|
*
|
|
@@ -47,13 +55,15 @@ export interface StoredAllowRules {
|
|
|
47
55
|
checksum?: string;
|
|
48
56
|
}
|
|
49
57
|
/**
|
|
50
|
-
* The
|
|
58
|
+
* The durable partition's read face: read-only, already anchored to a single owner. Rows carry the two
|
|
59
|
+
* durable scopes only (`global` = the `user` source, `project` = the `project` source); a session row
|
|
60
|
+
* never lives here (design/382 §4.3 — the durable two-member face).
|
|
51
61
|
*
|
|
52
62
|
* A backend that fails to read must not silently report an empty set as if it were an empty store — it
|
|
53
|
-
* throws, and the
|
|
63
|
+
* throws, and the store's caller turns that into zero rules PLUS a loud disclosure. Both outcomes deny
|
|
54
64
|
* more than they allow; only one of them is honest about why.
|
|
55
65
|
*/
|
|
56
|
-
export interface
|
|
66
|
+
export interface DurableRulePartition {
|
|
57
67
|
list(): Promise<StoredAllowRules>;
|
|
58
68
|
/**
|
|
59
69
|
* design/182 §5.2/§8.3 — the quarantine area, for introspection. Rows a sync round moved out of the
|
|
@@ -83,16 +93,18 @@ export type RuleOwner = {
|
|
|
83
93
|
};
|
|
84
94
|
/** Do two owners name the same bucket? */
|
|
85
95
|
export declare function sameRuleOwner(a: RuleOwner, b: RuleOwner): boolean;
|
|
86
|
-
/** Resolves
|
|
87
|
-
|
|
88
|
-
|
|
96
|
+
/** Resolves one owner's durable partition. `undefined` ⇒ a partition that reports zero rules. This is
|
|
97
|
+
* the BACKEND contract (file, in-memory, an out-of-repo SQL twin); the engine never consumes it
|
|
98
|
+
* directly — `createPermissionRuleStoreProvider({ durable })` composes it into the unified store. */
|
|
99
|
+
export interface DurableRulePartitionProvider {
|
|
100
|
+
forPrincipal(principal: string | undefined): DurableRulePartition;
|
|
89
101
|
/**
|
|
90
102
|
* design/182 §4.5 (F-011) — the identity-less LOCAL bucket, for a deployment that explicitly declared
|
|
91
103
|
* local-owner rules. Optional and additive: a provider without it simply has no local-owner form.
|
|
92
104
|
* A file backend resolves a FIXED file name (never a principal-hash path), and after an adoption
|
|
93
105
|
* completed it resolves the ADOPTED principal's bucket forever — the retired bucket never revives.
|
|
94
106
|
*/
|
|
95
|
-
forLocalOwner?():
|
|
107
|
+
forLocalOwner?(): DurableRulePartition;
|
|
96
108
|
}
|
|
97
109
|
/** The outcome of one accepted write. `sync` is present only on a `sync-join` delta — the landing
|
|
98
110
|
* report the disclosure layer reads (design/182 §8.1: quarantine/fence details ride the PutResult). */
|
|
@@ -100,6 +112,21 @@ export interface PutResult {
|
|
|
100
112
|
rev: number;
|
|
101
113
|
sync?: RuleSyncLandingReport;
|
|
102
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* What one `apply` answers. `conflict` is the durable partition's compare-and-set losing; `refused` is
|
|
117
|
+
* the SESSION partition's epoch fence speaking (design/382 §4.3, carried through the unified store's
|
|
118
|
+
* routing writer): a `redemption-add` whose scope names a session that has ended lands nothing — the
|
|
119
|
+
* redemption reports the member `refused`, never a silent success and never a resurrected row. A
|
|
120
|
+
* durable backend never answers `refused` (it holds no session partition); the member exists on the
|
|
121
|
+
* union so the ONE write path the consent protocol takes has one result vocabulary.
|
|
122
|
+
*/
|
|
123
|
+
export type RuleWriteOutcome = PutResult | {
|
|
124
|
+
conflict: true;
|
|
125
|
+
rev: number;
|
|
126
|
+
} | {
|
|
127
|
+
refused: "session_ended";
|
|
128
|
+
rev: number;
|
|
129
|
+
};
|
|
103
130
|
/** Authorization accompanying an add: the redemption that produced it. Carrying the record id makes the
|
|
104
131
|
* add's logical operation identity checkable at the backend, not just at the caller. A v1 caller
|
|
105
132
|
* carries `principal`; the local-owner path (design/182 §4.5) carries `owner` instead — never both
|
|
@@ -179,10 +206,7 @@ export interface PermissionRuleWriter {
|
|
|
179
206
|
nextDot(): Promise<RuleDot>;
|
|
180
207
|
apply(delta: RuleWriteDelta, opts: {
|
|
181
208
|
expectedRev: number;
|
|
182
|
-
}): Promise<
|
|
183
|
-
conflict: true;
|
|
184
|
-
rev: number;
|
|
185
|
-
}>;
|
|
209
|
+
}): Promise<RuleWriteOutcome>;
|
|
186
210
|
/**
|
|
187
211
|
* design/182 §4.2 — the RAW state a sync round exchanges: adds with tombstoned dots NOT pre-filtered
|
|
188
212
|
* (list() filters; a join must not), plus the replica identity, the minted-counter high water and the
|
|
@@ -209,12 +233,14 @@ export interface RawRuleSyncState {
|
|
|
209
233
|
* the consent boundary lives in the engine's wiring, not in this key's visibility).
|
|
210
234
|
*/
|
|
211
235
|
export declare const PERMISSION_RULE_WRITER = "__semaPermissionRuleWriter";
|
|
212
|
-
/** A
|
|
213
|
-
export interface
|
|
236
|
+
/** A partition that also carries the backend write face. */
|
|
237
|
+
export interface WritableDurableRulePartition extends DurableRulePartition {
|
|
214
238
|
readonly [PERMISSION_RULE_WRITER]: PermissionRuleWriter;
|
|
215
239
|
}
|
|
216
|
-
/** The writer of a store, or `undefined` when the backend is read-only from the engine's
|
|
217
|
-
|
|
240
|
+
/** The writer of a store or partition, or `undefined` when the backend is read-only from the engine's
|
|
241
|
+
* side. Duck-typed on the handle, so it answers for the unified `PermissionRuleStore` (whose writer
|
|
242
|
+
* routes by scope) exactly as for a bare durable partition. */
|
|
243
|
+
export declare function writerOf(store: DurableRulePartition | import("./permission-rule-provider.js").PermissionRuleStore): PermissionRuleWriter | undefined;
|
|
218
244
|
/** Do two scopes name the same place? Three members (design/382 §4.1): global, project-by-root,
|
|
219
245
|
* session-by-sessionId. */
|
|
220
246
|
export declare function sameScope(a: RuleScope, b: RuleScope): boolean;
|
|
@@ -494,17 +520,16 @@ export declare function removePersistedRule(opts: {
|
|
|
494
520
|
* `forLocalOwner()` face existed, but removal could not name it, so a local-owner rule was
|
|
495
521
|
* unrevokable through this entry). Same observed-remove/add-wins/stillLive semantics either way. */
|
|
496
522
|
principal: string | RuleOwner;
|
|
497
|
-
provider: PermissionRuleStoreProvider;
|
|
523
|
+
provider: import("./permission-rule-provider.js").PermissionRuleStoreProvider;
|
|
498
524
|
}): Promise<RemoveResult>;
|
|
499
525
|
export declare function errText(err: unknown): string;
|
|
500
|
-
/** A
|
|
501
|
-
|
|
502
|
-
export declare const EMPTY_RULE_STORE: PermissionRuleStore;
|
|
526
|
+
/** A durable partition that always reports zero rules — what an unauthenticated principal resolves to. */
|
|
527
|
+
export declare const EMPTY_DURABLE_RULE_PARTITION: DurableRulePartition;
|
|
503
528
|
/**
|
|
504
529
|
* In-memory backend — the reference implementation of the delta semantics, and the one the tests drive.
|
|
505
530
|
* `process-local` by declaration: it makes no durability promise it cannot keep.
|
|
506
531
|
*/
|
|
507
|
-
export declare class
|
|
532
|
+
export declare class InMemoryDurableRulePartition implements WritableDurableRulePartition {
|
|
508
533
|
private readonly actor;
|
|
509
534
|
private readonly now;
|
|
510
535
|
readonly durability: StoreDurability;
|
|
@@ -394,11 +394,13 @@ export async function removePersistedRule(opts) {
|
|
|
394
394
|
}
|
|
395
395
|
let snapshot;
|
|
396
396
|
try {
|
|
397
|
-
snapshot = await store.
|
|
397
|
+
snapshot = await store.effective();
|
|
398
398
|
}
|
|
399
399
|
catch (err) {
|
|
400
400
|
return { status: "failed", error: `could not read the permission-rule store: ${errText(err)}` };
|
|
401
401
|
}
|
|
402
|
+
if (snapshot.unreadable !== undefined)
|
|
403
|
+
return { status: "failed", error: `could not read the permission-rule store: ${snapshot.unreadable}` };
|
|
402
404
|
const live = snapshot.rules.filter((r) => r.rule === rule && sameScope(r.scope, scope));
|
|
403
405
|
const removedDots = live.flatMap((r) => r.adds.map((a) => a.dot));
|
|
404
406
|
if (removedDots.length === 0)
|
|
@@ -409,10 +411,15 @@ export async function removePersistedRule(opts) {
|
|
|
409
411
|
for (let attempt = 0; attempt < REMOVE_MAX_ATTEMPTS; attempt++) {
|
|
410
412
|
try {
|
|
411
413
|
const res = await writer.apply({ kind: "tighten-delete", tombstone }, { expectedRev });
|
|
414
|
+
if ("refused" in res) {
|
|
415
|
+
return { status: "failed", error: `the store refused the tombstone (${res.refused}) — nothing was removed` };
|
|
416
|
+
}
|
|
412
417
|
if (!("conflict" in res)) {
|
|
413
418
|
let stillLive = false;
|
|
414
419
|
try {
|
|
415
|
-
const after = await store.
|
|
420
|
+
const after = await store.effective();
|
|
421
|
+
if (after.unreadable !== undefined)
|
|
422
|
+
throw new Error(after.unreadable);
|
|
416
423
|
stillLive = after.rules.some((r) => r.rule === rule && sameScope(r.scope, scope) && isRuleLive(r));
|
|
417
424
|
}
|
|
418
425
|
catch {
|
|
@@ -429,10 +436,10 @@ export async function removePersistedRule(opts) {
|
|
|
429
436
|
}
|
|
430
437
|
const reason = lastError !== "" ? lastError : `optimistic-concurrency retries exhausted after ${REMOVE_MAX_ATTEMPTS} attempts`;
|
|
431
438
|
try {
|
|
432
|
-
const after = await
|
|
439
|
+
const after = await writer.readRaw();
|
|
433
440
|
const landed = after.tombstones.some((t) => t.deletedBy.actor === tombstone.deletedBy.actor && t.deletedBy.counter === tombstone.deletedBy.counter);
|
|
434
441
|
return landed
|
|
435
|
-
? { status: "removed", rev: after.rev, stillLive: after.rules.some((r) => r.rule === rule && sameScope(r.scope, scope) && isRuleLive(r)) }
|
|
442
|
+
? { status: "removed", rev: after.rev, stillLive: applyTombstones(after.rules, after.tombstones).some((r) => r.rule === rule && sameScope(r.scope, scope) && isRuleLive(r)) }
|
|
436
443
|
: { status: "failed", error: `${reason} (read-back confirms no tombstone was written)` };
|
|
437
444
|
}
|
|
438
445
|
catch (err) {
|
|
@@ -442,11 +449,11 @@ export async function removePersistedRule(opts) {
|
|
|
442
449
|
export function errText(err) {
|
|
443
450
|
return err instanceof Error ? err.message : String(err);
|
|
444
451
|
}
|
|
445
|
-
export const
|
|
452
|
+
export const EMPTY_DURABLE_RULE_PARTITION = {
|
|
446
453
|
list: async () => ({ rules: [], tombstones: [], rev: 0 }),
|
|
447
454
|
durability: "process-local",
|
|
448
455
|
};
|
|
449
|
-
export class
|
|
456
|
+
export class InMemoryDurableRulePartition {
|
|
450
457
|
actor;
|
|
451
458
|
now;
|
|
452
459
|
durability = "process-local";
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
* level they are buying.
|
|
27
27
|
*/
|
|
28
28
|
import type { PersistedAllowRule, RuleDot, RuleRejectCode, RuleScope, RuleTombstone } from "./permission-rule-model.js";
|
|
29
|
-
import type { PermissionRuleStoreProvider
|
|
29
|
+
import type { PermissionRuleStoreProvider } from "./permission-rule-provider.js";
|
|
30
|
+
import type { RuleOwner, RuleSyncDrop, RuleSyncFrontier, RuleSyncState } from "./permission-rule-store.js";
|
|
30
31
|
import { type TracerHook } from "./trace.js";
|
|
31
32
|
/** The injected HTTP seam — core never bundles a fetch. The deployment owns base URL, auth, TLS and
|
|
32
33
|
* retries; a non-2xx / network failure should THROW (the round then aborts with zero local effects —
|
|
@@ -178,6 +178,17 @@ export async function syncPermissionRules(opts) {
|
|
|
178
178
|
current = await writer.readRaw();
|
|
179
179
|
continue;
|
|
180
180
|
}
|
|
181
|
+
if ("refused" in res) {
|
|
182
|
+
return disclose({
|
|
183
|
+
ok: false,
|
|
184
|
+
pushed: { addDots: raw.rules.reduce((n, r) => n + r.adds.length, 0), tombstones: raw.tombstones.length },
|
|
185
|
+
landed: { newAdds: 0, newTombstones: 0 },
|
|
186
|
+
resurrected: [],
|
|
187
|
+
dropped,
|
|
188
|
+
rev: current.rev,
|
|
189
|
+
warnings: [...warnings, `the store refused the sync landing (${res.refused}) — nothing landed, the local state is unchanged`],
|
|
190
|
+
});
|
|
191
|
+
}
|
|
181
192
|
const landedRaw = await writer.readRaw();
|
|
182
193
|
const postLive = applyTombstones(landedRaw.rules, landedRaw.tombstones);
|
|
183
194
|
const resurrected = [];
|
|
@@ -83,8 +83,9 @@ export interface Stats {
|
|
|
83
83
|
llmRootMicroUsd: number;
|
|
84
84
|
/** Delegated sub-agent (nested) LLM cost (micro-USD) = `stats.nested?.costMicroUsd ?? 0`. */
|
|
85
85
|
nestedSubagentMicroUsd: number;
|
|
86
|
-
/** Post-task memory-consolidation LLM cost (micro-USD) = `stats.memory?.costMicroUsd ?? 0`.
|
|
87
|
-
*
|
|
86
|
+
/** Post-task memory-consolidation LLM cost (micro-USD) = `stats.memory?.costMicroUsd ?? 0`. Permanently
|
|
87
|
+
* 0: the runner-integrated consolidation pass is retired (the public `TaskStats` doc is the one home
|
|
88
|
+
* for what would fill it again). */
|
|
88
89
|
memoryConsolidationMicroUsd: number;
|
|
89
90
|
/** Within-task compaction LLM cost (micro-USD) = the running `compactionMicroUsd` accumulator (the
|
|
90
91
|
* category Framing-4 missed). This IS part of `costMicroUsd`, so it is subtracted from `llmRoot`. */
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable-suspend checkpoint SCOPE vocabulary — the default isolation scope and the ONE derivation of a row's scope —
|
|
3
|
+
* moved whole out of the orchestrator so the wiring-manifest phase (the synthesized durableApproval) and the driver's
|
|
4
|
+
* park-lane mint sites reach DOWN for the same two names. Pure: a constant and a string function over two spec fields.
|
|
5
|
+
*/
|
|
6
|
+
/** design/77 §4.4: the multi-tenant scope used when a durable suspend fires for an IRREVERSIBLE tool in an
|
|
7
|
+
* unattended deployment that did NOT opt into `durableApproval` (so there is no caller-supplied scope). The
|
|
8
|
+
* checkpoint carries this scope; resume reads it back from the checkpoint (`cp.scope`), so it is
|
|
9
|
+
* self-consistent without needing the original `TaskSpec`. Kept distinct from any tenant key to make an
|
|
10
|
+
* unattended irreversible suspend auditable as such. */
|
|
11
|
+
export declare const DEFAULT_IRREVERSIBLE_SCOPE = "irreversible";
|
|
12
|
+
/**
|
|
13
|
+
* design/153 §7.1 — the SINGLE derivation of a durable-suspend checkpoint's scope (both gate mint
|
|
14
|
+
* sites + the bg-watcher park lane's expire compensation/arbitration consume THIS; a re-derived copy
|
|
15
|
+
* that drifted was the exact bug the park tests caught — expire against the wrong scope silently
|
|
16
|
+
* no-ops). `|| DEFAULT` (not `??`): an EMPTY-string principal must not become a shared scope bucket.
|
|
17
|
+
*
|
|
18
|
+
* RB-75 (2026-07-25, red probe): that rule is stated twice in this file and was enforced on ONE of the two
|
|
19
|
+
* inputs — the principal half used `||`, the `durableApproval.scope` half used `??`, so an empty string
|
|
20
|
+
* arriving through the scope field (a config-centre / env lookup that came back blank is the realistic
|
|
21
|
+
* source) sailed through as the literal scope `""`. Two different tenants both landed in that one bucket,
|
|
22
|
+
* where `listByScope("")` reads BOTH their pending approvals — including each summary's `toolInput`
|
|
23
|
+
* preview of the raw arguments — and `reap("")` / `resolve(token, "")` act across both. `durableApproval.
|
|
24
|
+
* scope` is documented as "Multi-tenant isolation key … Required"; a blank string is the absence of an
|
|
25
|
+
* isolation key wearing its clothes, so both halves now fall through to the default isolation scope.
|
|
26
|
+
*/
|
|
27
|
+
export declare function checkpointScopeOf(spec: {
|
|
28
|
+
durableApproval?: {
|
|
29
|
+
scope?: string;
|
|
30
|
+
};
|
|
31
|
+
principal?: string;
|
|
32
|
+
}): string;
|