@sema-agent/core 5.21.0 → 5.22.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 +66 -0
- package/dist/agents/send-message-tool.js +6 -3
- package/dist/agents/subagent.d.ts +6 -0
- package/dist/agents/subagent.js +45 -4
- package/dist/brain/errors.d.ts +20 -0
- package/dist/brain/errors.js +40 -0
- package/dist/brain/retry.d.ts +16 -2
- package/dist/brain/retry.js +3 -2
- package/dist/brain/status-sink.d.ts +9 -2
- package/dist/brain/stream-engine.d.ts +22 -0
- package/dist/brain/stream-engine.js +41 -10
- package/dist/core/ask-class.d.ts +48 -0
- package/dist/core/ask-class.js +33 -0
- package/dist/core/checkpoint-store.d.ts +103 -10
- package/dist/core/checkpoint-store.js +3 -1
- package/dist/core/governance-codes.d.ts +38 -0
- package/dist/core/governance-codes.js +11 -0
- package/dist/core/hooks.d.ts +39 -0
- package/dist/core/hooks.js +26 -2
- package/dist/core/locked-config.d.ts +7 -1
- package/dist/core/locked-config.js +2 -1
- package/dist/core/memory-engine/delegation-provenance.d.ts +62 -0
- package/dist/core/memory-engine/delegation-provenance.js +26 -0
- package/dist/core/memory-engine/engine.d.ts +67 -1
- package/dist/core/memory-engine/engine.js +270 -12
- package/dist/core/memory-engine/header-hints.d.ts +30 -0
- package/dist/core/memory-engine/header-hints.js +41 -0
- package/dist/core/memory-engine/index.d.ts +3 -2
- package/dist/core/memory-engine/index.js +3 -2
- package/dist/core/memory-engine/layout.d.ts +166 -0
- package/dist/core/memory-engine/layout.js +399 -0
- package/dist/core/memory-engine/tools.d.ts +30 -0
- package/dist/core/memory-engine/tools.js +108 -17
- package/dist/core/permission-rule-consent.d.ts +25 -9
- package/dist/core/permission-rule-consent.js +91 -20
- package/dist/core/permission-rule-model.d.ts +9 -1
- package/dist/core/permission-rule-model.js +2 -2
- package/dist/core/permission-rule-org.d.ts +161 -0
- package/dist/core/permission-rule-org.js +211 -0
- package/dist/core/permission-rule-store.d.ts +249 -6
- package/dist/core/permission-rule-store.js +313 -3
- package/dist/core/permission-rule-sync.d.ts +131 -0
- package/dist/core/permission-rule-sync.js +314 -0
- package/dist/core/runner/prepare-memory.js +35 -8
- package/dist/core/runner/prepare-task.d.ts +54 -1
- package/dist/core/runner/prepare-task.js +246 -27
- package/dist/core/runner/runtask.js +147 -6
- package/dist/core/shared-memory/contract.js +19 -4
- package/dist/core/shared-memory/normalize.d.ts +3 -1
- package/dist/core/shared-memory/tools.js +73 -17
- package/dist/core/shared-memory/types.d.ts +27 -1
- package/dist/core/store-contracts/permission-rule-sync-contract.d.ts +33 -0
- package/dist/core/store-contracts/permission-rule-sync-contract.js +186 -0
- package/dist/core/task-notification.d.ts +5 -2
- package/dist/core/task-registry-agent.d.ts +1 -1
- package/dist/core/task-registry-agent.js +6 -2
- package/dist/core/task-registry-shared.d.ts +9 -2
- package/dist/core/task-registry.d.ts +9 -3
- package/dist/core/task-registry.js +2 -0
- package/dist/core/tool-policy.d.ts +120 -2
- package/dist/core/tool-policy.js +116 -6
- package/dist/core/trace.d.ts +32 -1
- package/dist/core/types.d.ts +56 -3
- package/dist/index.d.ts +12 -7
- package/dist/index.js +10 -5
- package/dist/stores/file/checkpoint-store.d.ts +4 -0
- package/dist/stores/file/checkpoint-store.js +1 -0
- package/dist/stores/file/permission-rule-adopt.d.ts +62 -0
- package/dist/stores/file/permission-rule-adopt.js +95 -0
- package/dist/stores/file/permission-rule-store.d.ts +80 -2
- package/dist/stores/file/permission-rule-store.js +189 -46
- package/dist/tools/fs/fs-search-tools.js +0 -1
- package/package.json +1 -1
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* defended against: for a file backend, host = user, no more and no less.
|
|
29
29
|
*/
|
|
30
30
|
import { type RuleScope, type RuleDot } from "./permission-rule-model.js";
|
|
31
|
-
import type { PermissionRuleStoreProvider } from "./permission-rule-store.js";
|
|
31
|
+
import type { PermissionRuleStoreProvider, RuleOwner } from "./permission-rule-store.js";
|
|
32
32
|
/** One candidate rule inside an approval record: the exact text and where it would apply. */
|
|
33
33
|
export interface RuleCandidate {
|
|
34
34
|
rule: string;
|
|
@@ -36,10 +36,18 @@ export interface RuleCandidate {
|
|
|
36
36
|
}
|
|
37
37
|
/** What the approval record is for — it decides the provenance the minted adds carry. */
|
|
38
38
|
export type RuleApprovalKind = "card" | "import" | "starter";
|
|
39
|
-
/** A durable record of one pending/settled human decision about minting rules.
|
|
39
|
+
/** A durable record of one pending/settled human decision about minting rules.
|
|
40
|
+
*
|
|
41
|
+
* Identity (design/182 §4.5, additive): a v1 record carries `principal` and nothing else — it stays
|
|
42
|
+
* valid forever and projects at runtime to a principal-kind {@link RuleOwner} (zero migration; pending
|
|
43
|
+
* and redeemed-but-unlanded records redeem across the upgrade unchanged). Only the local-owner path
|
|
44
|
+
* mints records with the `owner` field and NO principal. A record carrying both, disagreeing, is
|
|
45
|
+
* contradictory and unredeemable. */
|
|
40
46
|
export interface RuleApprovalRecord {
|
|
41
47
|
id: string;
|
|
42
|
-
principal
|
|
48
|
+
principal?: string;
|
|
49
|
+
/** Structural owner (additive). Absent on every record a v1 tree minted. */
|
|
50
|
+
owner?: RuleOwner;
|
|
43
51
|
kind: RuleApprovalKind;
|
|
44
52
|
state: "pending" | "approved" | "redeemed";
|
|
45
53
|
candidates: RuleCandidate[];
|
|
@@ -118,7 +126,9 @@ export declare function mintRuleTicket(recordId: string, candidateIndex: number)
|
|
|
118
126
|
* engine at all.
|
|
119
127
|
*/
|
|
120
128
|
export declare function prepareCardApproval(opts: {
|
|
121
|
-
principal
|
|
129
|
+
principal?: string;
|
|
130
|
+
/** design/182 §4.5 (additive): the structural owner — only the local-owner path needs it. */
|
|
131
|
+
owner?: RuleOwner;
|
|
122
132
|
/** The tool whose ask this card belongs to. Only the shell tool the rule lane speaks for is accepted. */
|
|
123
133
|
toolName: string;
|
|
124
134
|
/** The command as adjudicated — the same bytes the ask carried. */
|
|
@@ -143,7 +153,9 @@ export declare function prepareCardApproval(opts: {
|
|
|
143
153
|
*/
|
|
144
154
|
export declare function confirmRuleApproval(opts: {
|
|
145
155
|
approvalId: string;
|
|
146
|
-
principal
|
|
156
|
+
principal?: string;
|
|
157
|
+
/** design/182 §4.5 (additive): the structural owner — only the local-owner path needs it. */
|
|
158
|
+
owner?: RuleOwner;
|
|
147
159
|
/**
|
|
148
160
|
* REQUIRED for a card record: the index of the option the person chose. A card presents alternatives of
|
|
149
161
|
* different breadth, so "they said yes" is not an answer on its own — "they said yes to THIS one" is.
|
|
@@ -182,7 +194,8 @@ export type RedeemResult = {
|
|
|
182
194
|
*/
|
|
183
195
|
export declare function redeemRuleTicket(opts: {
|
|
184
196
|
ticket: RuleTicket;
|
|
185
|
-
principal
|
|
197
|
+
principal?: string;
|
|
198
|
+
owner?: RuleOwner;
|
|
186
199
|
deps: RuleConsentDeps;
|
|
187
200
|
}): Promise<RedeemResult>;
|
|
188
201
|
/** The three user-editable settings layers this version reads. */
|
|
@@ -238,7 +251,8 @@ export interface ImportResult {
|
|
|
238
251
|
*/
|
|
239
252
|
export declare function prepareCcImport(opts: {
|
|
240
253
|
layers: CcImportLayer[];
|
|
241
|
-
principal
|
|
254
|
+
principal?: string;
|
|
255
|
+
owner?: RuleOwner;
|
|
242
256
|
deps: RuleConsentDeps;
|
|
243
257
|
}): Promise<{
|
|
244
258
|
preview: ImportPreview;
|
|
@@ -256,7 +270,8 @@ export declare const STARTER_RULES: readonly string[];
|
|
|
256
270
|
/** Prepare the starter batch as a pending approval record. Nothing takes effect without confirmation;
|
|
257
271
|
* an unconfirmed deployment simply starts with an empty rule set. */
|
|
258
272
|
export declare function prepareStarterBatch(opts: {
|
|
259
|
-
principal
|
|
273
|
+
principal?: string;
|
|
274
|
+
owner?: RuleOwner;
|
|
260
275
|
deps: RuleConsentDeps;
|
|
261
276
|
}): Promise<{
|
|
262
277
|
preview: RuleCandidate[];
|
|
@@ -268,7 +283,8 @@ export declare function prepareStarterBatch(opts: {
|
|
|
268
283
|
*/
|
|
269
284
|
export declare function redeemRuleBatch(opts: {
|
|
270
285
|
approvalId: string;
|
|
271
|
-
principal
|
|
286
|
+
principal?: string;
|
|
287
|
+
owner?: RuleOwner;
|
|
272
288
|
deps: RuleConsentDeps;
|
|
273
289
|
}): Promise<ImportResult | {
|
|
274
290
|
status: "refused";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseAllowRuleText, suggestRulesForCommand } from "./permission-rule-model.js";
|
|
2
|
-
import { errText, sameScope, writerOf } from "./permission-rule-store.js";
|
|
2
|
+
import { errText, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js";
|
|
3
3
|
export class InMemoryRuleApprovalRecordStore {
|
|
4
4
|
rows = new Map();
|
|
5
5
|
async get(id) {
|
|
@@ -36,6 +36,49 @@ function requirePrincipal(principal, entry) {
|
|
|
36
36
|
}
|
|
37
37
|
return principal;
|
|
38
38
|
}
|
|
39
|
+
function resolveCallerOwner(principal, owner, entry) {
|
|
40
|
+
if (owner !== undefined) {
|
|
41
|
+
if (owner.kind === "principal") {
|
|
42
|
+
const p = requirePrincipal(owner.principal, entry);
|
|
43
|
+
if (typeof principal === "string" && principal !== "" && principal !== p) {
|
|
44
|
+
throw new Error(`${entry}: contradictory identity — principal "${principal}" and owner principal "${p}" disagree`);
|
|
45
|
+
}
|
|
46
|
+
return { kind: "principal", principal: p };
|
|
47
|
+
}
|
|
48
|
+
if (typeof principal === "string" && principal !== "") {
|
|
49
|
+
throw new Error(`${entry}: contradictory identity — a principal and a local-owner were both given; a bucket has one owner`);
|
|
50
|
+
}
|
|
51
|
+
return { kind: "local-owner" };
|
|
52
|
+
}
|
|
53
|
+
return { kind: "principal", principal: requirePrincipal(principal, entry) };
|
|
54
|
+
}
|
|
55
|
+
function ownerOfRecord(rec) {
|
|
56
|
+
if (rec.owner !== undefined) {
|
|
57
|
+
if (rec.owner.kind === "principal") {
|
|
58
|
+
if (typeof rec.owner.principal !== "string" || rec.owner.principal === "")
|
|
59
|
+
return undefined;
|
|
60
|
+
if (typeof rec.principal === "string" && rec.principal !== "" && rec.principal !== rec.owner.principal)
|
|
61
|
+
return undefined;
|
|
62
|
+
return rec.owner;
|
|
63
|
+
}
|
|
64
|
+
if (typeof rec.principal === "string" && rec.principal !== "")
|
|
65
|
+
return undefined;
|
|
66
|
+
return { kind: "local-owner" };
|
|
67
|
+
}
|
|
68
|
+
return typeof rec.principal === "string" && rec.principal !== "" ? { kind: "principal", principal: rec.principal } : undefined;
|
|
69
|
+
}
|
|
70
|
+
function recordIdentityOf(owner) {
|
|
71
|
+
return owner.kind === "principal" ? { principal: owner.principal } : { owner: { kind: "local-owner" } };
|
|
72
|
+
}
|
|
73
|
+
function storeForOwner(provider, owner) {
|
|
74
|
+
if (owner.kind === "principal")
|
|
75
|
+
return provider.forPrincipal(owner.principal);
|
|
76
|
+
const store = provider.forLocalOwner?.();
|
|
77
|
+
if (store === undefined) {
|
|
78
|
+
throw new Error("this deployment's permission-rule provider has no local-owner bucket (forLocalOwner is not implemented)");
|
|
79
|
+
}
|
|
80
|
+
return store;
|
|
81
|
+
}
|
|
39
82
|
export function mintRuleTicket(recordId, candidateIndex) {
|
|
40
83
|
return `rt.${candidateIndex}.${recordId}`;
|
|
41
84
|
}
|
|
@@ -46,7 +89,7 @@ function parseRuleTicket(ticket) {
|
|
|
46
89
|
return { index: Number(m[1]), recordId: m[2] };
|
|
47
90
|
}
|
|
48
91
|
export async function prepareCardApproval(opts) {
|
|
49
|
-
const
|
|
92
|
+
const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareCardApproval");
|
|
50
93
|
if ("candidates" in opts) {
|
|
51
94
|
const e = new Error("prepareCardApproval does not accept caller candidates — card options are minted by the engine from the adjudicated command");
|
|
52
95
|
e.code = "config.invalid_argument";
|
|
@@ -60,7 +103,7 @@ export async function prepareCardApproval(opts) {
|
|
|
60
103
|
return undefined;
|
|
61
104
|
const record = {
|
|
62
105
|
id: mintId(opts.deps, "rar"),
|
|
63
|
-
|
|
106
|
+
...recordIdentityOf(owner),
|
|
64
107
|
kind: "card",
|
|
65
108
|
state: "pending",
|
|
66
109
|
rev: 0,
|
|
@@ -74,10 +117,11 @@ export async function prepareCardApproval(opts) {
|
|
|
74
117
|
}
|
|
75
118
|
const CARD_RULE_TOOL = "Bash";
|
|
76
119
|
export async function confirmRuleApproval(opts) {
|
|
77
|
-
const
|
|
120
|
+
const caller = resolveCallerOwner(opts.principal, opts.owner, "confirmRuleApproval");
|
|
78
121
|
const no = (reason) => ({ ok: false, reason });
|
|
79
122
|
const rec = await opts.deps.approvals.get(opts.approvalId);
|
|
80
|
-
|
|
123
|
+
const recOwner = rec === undefined ? undefined : ownerOfRecord(rec);
|
|
124
|
+
if (rec === undefined || recOwner === undefined || !sameRuleOwner(recOwner, caller))
|
|
81
125
|
return no("record_not_found");
|
|
82
126
|
if (rec.kind === "card") {
|
|
83
127
|
const chosen = opts.selectedCandidate;
|
|
@@ -101,14 +145,15 @@ export async function confirmRuleApproval(opts) {
|
|
|
101
145
|
return won ? { ok: true } : no("conflict");
|
|
102
146
|
}
|
|
103
147
|
export async function redeemRuleTicket(opts) {
|
|
104
|
-
const
|
|
148
|
+
const caller = resolveCallerOwner(opts.principal, opts.owner, "redeemRuleTicket");
|
|
105
149
|
const parsed = parseRuleTicket(opts.ticket);
|
|
106
150
|
if (parsed === undefined)
|
|
107
151
|
return { status: "refused", reason: "malformed ticket" };
|
|
108
152
|
const rec = await opts.deps.approvals.get(parsed.recordId);
|
|
109
153
|
if (rec === undefined)
|
|
110
154
|
return { status: "refused", reason: "no such approval record" };
|
|
111
|
-
|
|
155
|
+
const recOwner = ownerOfRecord(rec);
|
|
156
|
+
if (recOwner === undefined || !sameRuleOwner(recOwner, caller))
|
|
112
157
|
return { status: "refused", reason: "the ticket belongs to a different principal" };
|
|
113
158
|
if (rec.state === "pending")
|
|
114
159
|
return { status: "refused", reason: "the approval record has not been confirmed" };
|
|
@@ -121,7 +166,13 @@ export async function redeemRuleTicket(opts) {
|
|
|
121
166
|
const parsedRule = parseAllowRuleText(candidate.rule);
|
|
122
167
|
if ("reject" in parsedRule)
|
|
123
168
|
return { status: "refused", reason: `${parsedRule.reject.code}: ${parsedRule.reject.message}` };
|
|
124
|
-
|
|
169
|
+
let store;
|
|
170
|
+
try {
|
|
171
|
+
store = storeForOwner(opts.deps.provider, caller);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
return { status: "refused", reason: errText(err) };
|
|
175
|
+
}
|
|
125
176
|
const writer = writerOf(store);
|
|
126
177
|
if (writer === undefined)
|
|
127
178
|
return { status: "refused", reason: "the resolved permission-rule store has no write face" };
|
|
@@ -140,14 +191,20 @@ export async function redeemRuleTicket(opts) {
|
|
|
140
191
|
const theirs = again?.redeemedDots?.[parsed.index];
|
|
141
192
|
if (theirs === undefined)
|
|
142
193
|
return { status: "refused", reason: "the approval record changed state concurrently" };
|
|
143
|
-
return await applyRedemption({
|
|
194
|
+
return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot: theirs, recordId: rec.id, kind: rec.kind, replay: true });
|
|
144
195
|
}
|
|
145
196
|
}
|
|
146
|
-
return await applyRedemption({
|
|
197
|
+
return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot, recordId: rec.id, kind: rec.kind, replay: known !== undefined });
|
|
147
198
|
}
|
|
148
199
|
const REDEEM_MAX_ATTEMPTS = 8;
|
|
149
200
|
async function applyRedemption(args) {
|
|
150
|
-
|
|
201
|
+
let store;
|
|
202
|
+
try {
|
|
203
|
+
store = storeForOwner(args.deps.provider, args.owner);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
return { status: "refused", reason: errText(err) };
|
|
207
|
+
}
|
|
151
208
|
const writer = writerOf(store);
|
|
152
209
|
if (writer === undefined)
|
|
153
210
|
return { status: "refused", reason: "the resolved permission-rule store has no write face" };
|
|
@@ -170,7 +227,10 @@ async function applyRedemption(args) {
|
|
|
170
227
|
match: args.parsedRule.match,
|
|
171
228
|
command: args.parsedRule.command,
|
|
172
229
|
add: { dot: args.dot, origin, createdAt: nowIso(args.deps) },
|
|
173
|
-
redemption: {
|
|
230
|
+
redemption: {
|
|
231
|
+
recordId: args.recordId,
|
|
232
|
+
...(args.owner.kind === "principal" ? { principal: args.owner.principal } : { owner: args.owner }),
|
|
233
|
+
},
|
|
174
234
|
}, { expectedRev: rev });
|
|
175
235
|
}
|
|
176
236
|
catch (err) {
|
|
@@ -187,7 +247,7 @@ function originOfRecordKind(kind) {
|
|
|
187
247
|
}
|
|
188
248
|
const IMPORT_UNCOVERED = { flagSettings: "not-imported-v1", policySettings: "not-imported-v1" };
|
|
189
249
|
export async function prepareCcImport(opts) {
|
|
190
|
-
const
|
|
250
|
+
const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareCcImport");
|
|
191
251
|
const candidates = [];
|
|
192
252
|
const skipped = [];
|
|
193
253
|
const layers = [];
|
|
@@ -234,7 +294,7 @@ export async function prepareCcImport(opts) {
|
|
|
234
294
|
}
|
|
235
295
|
const record = {
|
|
236
296
|
id: mintId(opts.deps, "rar"),
|
|
237
|
-
|
|
297
|
+
...recordIdentityOf(owner),
|
|
238
298
|
kind: "import",
|
|
239
299
|
state: "pending",
|
|
240
300
|
rev: 0,
|
|
@@ -256,7 +316,7 @@ export const STARTER_RULES = [
|
|
|
256
316
|
"Bash(npm --version)",
|
|
257
317
|
];
|
|
258
318
|
export async function prepareStarterBatch(opts) {
|
|
259
|
-
const
|
|
319
|
+
const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareStarterBatch");
|
|
260
320
|
const candidates = [];
|
|
261
321
|
for (const text of STARTER_RULES) {
|
|
262
322
|
const parsed = parseAllowRuleText(text);
|
|
@@ -266,7 +326,7 @@ export async function prepareStarterBatch(opts) {
|
|
|
266
326
|
}
|
|
267
327
|
const record = {
|
|
268
328
|
id: mintId(opts.deps, "rar"),
|
|
269
|
-
|
|
329
|
+
...recordIdentityOf(owner),
|
|
270
330
|
kind: "starter",
|
|
271
331
|
state: "pending",
|
|
272
332
|
rev: 0,
|
|
@@ -277,11 +337,12 @@ export async function prepareStarterBatch(opts) {
|
|
|
277
337
|
return { preview: candidates, approvalId: record.id };
|
|
278
338
|
}
|
|
279
339
|
export async function redeemRuleBatch(opts) {
|
|
280
|
-
const
|
|
340
|
+
const caller = resolveCallerOwner(opts.principal, opts.owner, "redeemRuleBatch");
|
|
281
341
|
const rec = await opts.deps.approvals.get(opts.approvalId);
|
|
282
342
|
if (rec === undefined)
|
|
283
343
|
return { status: "refused", reason: "no such approval record" };
|
|
284
|
-
|
|
344
|
+
const recOwner = ownerOfRecord(rec);
|
|
345
|
+
if (recOwner === undefined || !sameRuleOwner(recOwner, caller))
|
|
285
346
|
return { status: "refused", reason: "the record belongs to a different principal" };
|
|
286
347
|
if (rec.kind === "card")
|
|
287
348
|
return { status: "refused", reason: "a card record is redeemed by its chosen ticket, not as a batch" };
|
|
@@ -291,7 +352,13 @@ export async function redeemRuleBatch(opts) {
|
|
|
291
352
|
const deduped = [];
|
|
292
353
|
const skippedAtRedeem = [];
|
|
293
354
|
let rev = 0;
|
|
294
|
-
|
|
355
|
+
let store;
|
|
356
|
+
try {
|
|
357
|
+
store = storeForOwner(opts.deps.provider, caller);
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
return { status: "refused", reason: errText(err) };
|
|
361
|
+
}
|
|
295
362
|
for (let i = 0; i < rec.candidates.length; i++) {
|
|
296
363
|
const candidate = rec.candidates[i];
|
|
297
364
|
let alreadyThere = false;
|
|
@@ -303,7 +370,11 @@ export async function redeemRuleBatch(opts) {
|
|
|
303
370
|
catch (err) {
|
|
304
371
|
return { status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` };
|
|
305
372
|
}
|
|
306
|
-
const res = await redeemRuleTicket({
|
|
373
|
+
const res = await redeemRuleTicket({
|
|
374
|
+
ticket: mintRuleTicket(rec.id, i),
|
|
375
|
+
...(caller.kind === "principal" ? { principal: caller.principal } : { owner: caller }),
|
|
376
|
+
deps: opts.deps,
|
|
377
|
+
});
|
|
307
378
|
if (res.status === "refused") {
|
|
308
379
|
skippedAtRedeem.push({ rule: candidate.rule, reason: res.reason });
|
|
309
380
|
continue;
|
|
@@ -126,8 +126,16 @@ export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
|
|
|
126
126
|
* This is the ONE validator every entry point shares — minting a suggestion, importing another
|
|
127
127
|
* product's settings, reloading a store file, and any future backend. A shape refused here cannot enter
|
|
128
128
|
* the store through any door.
|
|
129
|
+
*
|
|
130
|
+
* `direction` (design/182 §7, additive): the bare-interpreter-prefix refusal is an ALLOW-side argument
|
|
131
|
+
* — one approval click cannot be read as authorizing arbitrary programs. An org deny/ask rule has the
|
|
132
|
+
* opposite polarity: `Bash(node:*)` as a DENY is a legitimately wide tightening, and refusing it would
|
|
133
|
+
* silently disarm exactly the policy an administrator most wants. `"tighten"` therefore admits the
|
|
134
|
+
* interpreter-headed prefix form and changes nothing else; the default stays the allow-side floor.
|
|
129
135
|
*/
|
|
130
|
-
export declare function parseAllowRuleText(text: string
|
|
136
|
+
export declare function parseAllowRuleText(text: string, opts?: {
|
|
137
|
+
direction?: "allow" | "tighten";
|
|
138
|
+
}): {
|
|
131
139
|
rule: ParsedAllowRule;
|
|
132
140
|
} | {
|
|
133
141
|
reject: RuleReject;
|
|
@@ -43,7 +43,7 @@ function foldSpacing(s) {
|
|
|
43
43
|
function reject(code, message) {
|
|
44
44
|
return { reject: { code, message } };
|
|
45
45
|
}
|
|
46
|
-
export function parseAllowRuleText(text) {
|
|
46
|
+
export function parseAllowRuleText(text, opts) {
|
|
47
47
|
if (text.length > MAX_RULE_TEXT_CHARS) {
|
|
48
48
|
return reject("invalid.too_long", `rule text exceeds ${MAX_RULE_TEXT_CHARS} characters`);
|
|
49
49
|
}
|
|
@@ -67,7 +67,7 @@ export function parseAllowRuleText(text) {
|
|
|
67
67
|
? reject("invalid.empty_command", `rule "${text}" names no command`)
|
|
68
68
|
: reject("invalid.not_simple_command", `rule "${text}" is not a single simple command (${floor.reject})`);
|
|
69
69
|
}
|
|
70
|
-
if (match === "prefix" && BARE_INTERPRETER_NAMES.has(floor.name)) {
|
|
70
|
+
if (match === "prefix" && BARE_INTERPRETER_NAMES.has(floor.name) && opts?.direction !== "tighten") {
|
|
71
71
|
return reject("invalid.bare_interpreter_prefix", `prefix rule "${text}" is headed by the interpreter "${floor.name}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)`);
|
|
72
72
|
}
|
|
73
73
|
const command = foldSpacing(body);
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/182 §7 — the ORGANIZATION rule layer: core's half of the contract.
|
|
3
|
+
*
|
|
4
|
+
* Org rules and personal rules are opposite in polarity (deny/ask vs allow), authority (a server-side
|
|
5
|
+
* administrator publish vs one human click per add) and evolution (whole-snapshot replacement vs
|
|
6
|
+
* convergent merge). So the org layer has NO CRDT, no tombstones and no join: the server is the single
|
|
7
|
+
* authority, a client holds a read-only versioned snapshot, an update is a whole replacement (revision
|
|
8
|
+
* strictly ordered), and a rollback is a NEW revision carrying old content. Cross-layer tombstones are
|
|
9
|
+
* structurally impossible — an org rule never enters `PersistedAllowRule`, and an org snapshot replace
|
|
10
|
+
* never touches a personal store's bytes. The two layers meet only at the CONSUMPTION site, where org
|
|
11
|
+
* deny/ask folds strict-side: **org deny > org ask (non-dismissable) > personal allow rule > bare ask**.
|
|
12
|
+
*
|
|
13
|
+
* ## Availability semantics (the fail-closed shape, ruled through three review rounds)
|
|
14
|
+
*
|
|
15
|
+
* A deployment DECLARES governance (`governed: true`) — governance is never inferred from wiring, so a
|
|
16
|
+
* forgotten provider is a loud boot contradiction instead of a silently ungoverned deployment. Under
|
|
17
|
+
* that declaration the org adjudication surface must ALWAYS be effective:
|
|
18
|
+
* - the last successfully installed snapshot persists durably (`PersistedOrgRuleState`) and keeps
|
|
19
|
+
* adjudicating while the provider is unreachable, up to a deployment-configured staleness bound;
|
|
20
|
+
* - beyond the bound (or before any snapshot was ever installed) the resolution is `unavailable`, and
|
|
21
|
+
* the CONSUMING gate must fail the ENTIRE decision boundary closed: every terminal allow — policy
|
|
22
|
+
* allow and default allow included — tightens to an ask carrying `decisionReason: "org_unavailable"`
|
|
23
|
+
* AND `requiresRealApproval: true` (the same non-dismissable bit an org ask itself carries; without
|
|
24
|
+
* it a blanket `onAsk: "allow"` deployment would clear the synthetic ask — a third fail-open path),
|
|
25
|
+
* with the personal-rule lane and the auto-mode classifier both disarmed. That obligation lives at
|
|
26
|
+
* the gate integration; THIS module's contract is to report `unavailable` honestly and never lapse
|
|
27
|
+
* into an empty-rules answer that reads as "not governed".
|
|
28
|
+
*
|
|
29
|
+
* ## Anti-rollback
|
|
30
|
+
*
|
|
31
|
+
* `revision` is an orderable server-monotone number, and the installed high-water mark persists with
|
|
32
|
+
* the snapshot in ONE atomic write: a replayed older snapshot (fresh `fetchedAtMs`, old content) is
|
|
33
|
+
* refused and disclosed — identity comparison alone cannot catch it, and an in-memory-only mark would
|
|
34
|
+
* accept revision N−1 after a restart. Signature / predecessor chaining belongs to the privilege-
|
|
35
|
+
* separation ticket; the high-water mark is the floor that needs no key distribution.
|
|
36
|
+
*/
|
|
37
|
+
import { type RuleScope } from "./permission-rule-model.js";
|
|
38
|
+
import { type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleOwner } from "./permission-rule-store.js";
|
|
39
|
+
/** One org rule. There is structurally no allow bucket (design/179 §9: the org layer only tightens). */
|
|
40
|
+
export interface OrgPermissionRule {
|
|
41
|
+
rule: string;
|
|
42
|
+
behavior: "deny" | "ask";
|
|
43
|
+
}
|
|
44
|
+
/** The read-only versioned snapshot a client holds. */
|
|
45
|
+
export interface OrgRuleSnapshot {
|
|
46
|
+
/** Server-monotone orderable revision. A snapshot below the persisted high-water mark is refused. */
|
|
47
|
+
revision: number;
|
|
48
|
+
rules: OrgPermissionRule[];
|
|
49
|
+
/** Reserved for the privilege-separation ticket's signed-snapshot contract. Carried, not verified here. */
|
|
50
|
+
signature?: string;
|
|
51
|
+
/** Observation timestamp (epoch ms) — staleness input only, never adjudication input. */
|
|
52
|
+
fetchedAtMs: number;
|
|
53
|
+
}
|
|
54
|
+
/** Where the deployment fetches the current snapshot. `undefined` = the provider has none to give. */
|
|
55
|
+
export interface OrgRuleSnapshotProvider {
|
|
56
|
+
current(): Promise<OrgRuleSnapshot | undefined>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The durable org state: last-known-good snapshot + the anti-rollback high-water mark, installed in one
|
|
60
|
+
* atomic write. SQL twin note (server/org ticket): `revision_high_water` and `installed_at_ms` are
|
|
61
|
+
* structured columns (`_ms` suffix for epoch milliseconds); the snapshot itself is a JSON blob — it is
|
|
62
|
+
* installed and read whole, never row-queried.
|
|
63
|
+
*/
|
|
64
|
+
export interface PersistedOrgRuleState {
|
|
65
|
+
revisionHighWater: number;
|
|
66
|
+
snapshot: OrgRuleSnapshot;
|
|
67
|
+
installedAtMs: number;
|
|
68
|
+
}
|
|
69
|
+
/** The durable home of {@link PersistedOrgRuleState}. The file rule-store backend implements it over an
|
|
70
|
+
* org block published with the same O_EXCL/fsync/rename discipline as the rule file itself.
|
|
71
|
+
*
|
|
72
|
+
* CONTRACT: `installOrgState` must REFUSE (throw) an install that would LOWER the persisted
|
|
73
|
+
* `revisionHighWater` — the overlay serializes its own resolves, but two overlay instances over one
|
|
74
|
+
* durable home can interleave read-then-write, and the backend's atomic write is the one place the
|
|
75
|
+
* lowering can be caught. The file backend enforces this under its write serialization. */
|
|
76
|
+
export interface OrgRuleStatePersistence {
|
|
77
|
+
readOrgState(): Promise<PersistedOrgRuleState | undefined>;
|
|
78
|
+
installOrgState(state: PersistedOrgRuleState): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
/** Duck-typed discovery of the persistence face on a store backend, `writerOf` style. */
|
|
81
|
+
export declare function orgRuleStatePersistenceOf(store: PermissionRuleStore): OrgRuleStatePersistence | undefined;
|
|
82
|
+
/** The `decisionReason` the consuming gate stamps on the synthetic asks it mints while org adjudication
|
|
83
|
+
* is unavailable. Exported so the gate integration and its tests share one spelling. */
|
|
84
|
+
export declare const ORG_UNAVAILABLE_DECISION_REASON = "org_unavailable";
|
|
85
|
+
/** How far into the future a snapshot's `fetchedAtMs` may sit before it is refused — ordinary NTP-level
|
|
86
|
+
* clock skew passes; a far-future timestamp (which would satisfy the staleness bound INDEFINITELY,
|
|
87
|
+
* turning fail-closed into evergreen freshness) does not. */
|
|
88
|
+
export declare const ORG_FETCHED_AT_SKEW_ALLOWANCE_MS: number;
|
|
89
|
+
export type OrgOverlayStatus = "fresh" | "last-known-good" | "unavailable";
|
|
90
|
+
/** What one resolution reports. `rules` is empty ⟺ status `unavailable` or the snapshot is empty —
|
|
91
|
+
* the two are distinguished by `status`, never by the emptiness of the list. */
|
|
92
|
+
export interface OrgOverlayResolution {
|
|
93
|
+
status: OrgOverlayStatus;
|
|
94
|
+
rules: OrgPermissionRule[];
|
|
95
|
+
revision?: number;
|
|
96
|
+
/** Honest-disclosure lines: provider failures, refused snapshots, rollback refusals. */
|
|
97
|
+
disclosures: string[];
|
|
98
|
+
}
|
|
99
|
+
export interface OrgRuleOverlay {
|
|
100
|
+
resolve(): Promise<OrgOverlayResolution>;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Construct the org overlay for a GOVERNED deployment. This constructor IS the boot gate:
|
|
104
|
+
* - `governed` must be literally `true` — the overlay does not exist for ungoverned deployments
|
|
105
|
+
* (their decision path stays byte-identical to a build without this module);
|
|
106
|
+
* - a governed declaration WITHOUT a provider is a configuration contradiction and refuses to boot —
|
|
107
|
+
* a silently ungoverned "governed" deployment is precisely the failure this bit exists to prevent;
|
|
108
|
+
* - `stalenessBoundMs` follows the knob discipline: a non-number, NaN, non-finite or non-positive
|
|
109
|
+
* bound is refused loudly, never coerced.
|
|
110
|
+
*/
|
|
111
|
+
export declare function createOrgRuleOverlay(cfg: {
|
|
112
|
+
governed: true;
|
|
113
|
+
provider?: OrgRuleSnapshotProvider;
|
|
114
|
+
/** Durable LKG + high-water home. Omitted ⇒ process-local only, DISCLOSED on every resolution: a
|
|
115
|
+
* restart then forgets both the last-known-good snapshot and the anti-rollback mark. */
|
|
116
|
+
persistence?: OrgRuleStatePersistence;
|
|
117
|
+
stalenessBoundMs: number;
|
|
118
|
+
now?: () => number;
|
|
119
|
+
}): OrgRuleOverlay;
|
|
120
|
+
/**
|
|
121
|
+
* design/182 §7.2 — which org rule speaks for this command, if any. Deny outranks ask; within a
|
|
122
|
+
* behavior the first textual match wins (reporting order only — all denies are the same one answer).
|
|
123
|
+
* Matching uses the SAME parser and matcher the personal lane uses (tighten direction: an
|
|
124
|
+
* interpreter-headed prefix deny like `Bash(node:*)` is a legitimately wide tightening and matches).
|
|
125
|
+
* A rule text the validator refuses never gets here — snapshot validation refuses the whole snapshot,
|
|
126
|
+
* so an unenforceable deny cannot install as policy; the skip below is a defensive floor only.
|
|
127
|
+
*/
|
|
128
|
+
export declare function orgRuleVerdictFor(rules: readonly OrgPermissionRule[], call: {
|
|
129
|
+
tool: string;
|
|
130
|
+
command: string;
|
|
131
|
+
}): {
|
|
132
|
+
behavior: "deny" | "ask";
|
|
133
|
+
rule: string;
|
|
134
|
+
} | undefined;
|
|
135
|
+
/** The effective status of one personal rule under the current org overlay — computed, never stored. */
|
|
136
|
+
export interface EffectivePermissionRule {
|
|
137
|
+
rule: string;
|
|
138
|
+
scope: RuleScope;
|
|
139
|
+
status: "live" | "shadowed-by-org" | "removed";
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* design/182 §7.3 — the introspection face: every personal rule with its effective status under the org
|
|
143
|
+
* overlay. An org deny does not delete a personal rule — the personal rule is the user's approval
|
|
144
|
+
* record, and the org's power is to OVERRIDE it, not to forge its removal — so a shadowed rule reports
|
|
145
|
+
* `shadowed-by-org` and comes back by itself when the org deny is withdrawn. A rule whose every add is
|
|
146
|
+
* tombstoned reports `removed` (its tombstone identity is still visible in the store).
|
|
147
|
+
*
|
|
148
|
+
* The shadow predicate: an org DENY rule admits the personal rule's command pattern (for a prefix
|
|
149
|
+
* personal rule, the org deny admits its prefix body — a wider org deny shadows every narrower allow
|
|
150
|
+
* under it). org ASK rules do not shadow: the personal lane never consumes a `requiresRealApproval`
|
|
151
|
+
* ask, so the two never actually meet on one call.
|
|
152
|
+
*/
|
|
153
|
+
export declare function effectivePermissionRules(opts: {
|
|
154
|
+
provider: PermissionRuleStoreProvider;
|
|
155
|
+
principal?: string;
|
|
156
|
+
owner?: RuleOwner;
|
|
157
|
+
orgSnapshot?: OrgRuleSnapshot;
|
|
158
|
+
}): Promise<EffectivePermissionRule[]>;
|
|
159
|
+
/** Does this store carry a write face? Re-exported convenience for org-ticket integrations that need to
|
|
160
|
+
* distinguish a read-only projection from a writable personal bucket without importing the writer seam. */
|
|
161
|
+
export declare function isWritablePermissionRuleStore(store: PermissionRuleStore): boolean;
|