@sema-agent/core 5.17.0 → 5.18.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/dist/agents/subagent.js +24 -0
  3. package/dist/core/auto-compaction.d.ts +6 -0
  4. package/dist/core/auto-compaction.js +15 -1
  5. package/dist/core/checkpoint-store.d.ts +4 -2
  6. package/dist/core/governance-codes.js +1 -0
  7. package/dist/core/hooks.d.ts +9 -0
  8. package/dist/core/hooks.js +21 -0
  9. package/dist/core/mcp.js +3 -0
  10. package/dist/core/memory-engine/content-origin.d.ts +27 -0
  11. package/dist/core/memory-engine/content-origin.js +38 -0
  12. package/dist/core/memory-engine/engine.d.ts +12 -2
  13. package/dist/core/memory-engine/engine.js +172 -12
  14. package/dist/core/memory-engine/file-backend.d.ts +4 -0
  15. package/dist/core/memory-engine/file-backend.js +25 -3
  16. package/dist/core/memory-engine/index.d.ts +2 -1
  17. package/dist/core/memory-engine/index.js +2 -1
  18. package/dist/core/memory-engine/layout.d.ts +16 -0
  19. package/dist/core/memory-engine/layout.js +90 -2
  20. package/dist/core/memory-engine/sync-client.d.ts +1 -0
  21. package/dist/core/memory-engine/sync-client.js +23 -5
  22. package/dist/core/memory-engine/tools.d.ts +55 -0
  23. package/dist/core/memory-engine/tools.js +307 -0
  24. package/dist/core/memory-engine/types.d.ts +1 -1
  25. package/dist/core/memory.d.ts +4 -0
  26. package/dist/core/memory.js +15 -2
  27. package/dist/core/permission-rule-consent.d.ts +138 -0
  28. package/dist/core/permission-rule-consent.js +318 -0
  29. package/dist/core/permission-rule-model.d.ts +66 -0
  30. package/dist/core/permission-rule-model.js +135 -0
  31. package/dist/core/permission-rule-store.d.ts +89 -0
  32. package/dist/core/permission-rule-store.js +145 -0
  33. package/dist/core/permission-rules.d.ts +3 -2
  34. package/dist/core/permission-rules.js +9 -4
  35. package/dist/core/runner/prepare-memory.d.ts +3 -1
  36. package/dist/core/runner/prepare-memory.js +54 -14
  37. package/dist/core/runner/prepare-task.d.ts +12 -0
  38. package/dist/core/runner/prepare-task.js +206 -12
  39. package/dist/core/runner/runtask.d.ts +3 -1
  40. package/dist/core/runner/runtask.js +47 -5
  41. package/dist/core/runner/tool-output-projection.js +1 -1
  42. package/dist/core/tool-policy.d.ts +13 -1
  43. package/dist/core/tool-policy.js +93 -12
  44. package/dist/core/tools.js +1 -0
  45. package/dist/core/trace.d.ts +20 -0
  46. package/dist/core/types.d.ts +15 -0
  47. package/dist/core/wiring-manifest.d.ts +5 -1
  48. package/dist/core/wiring-manifest.js +2 -0
  49. package/dist/index.d.ts +7 -3
  50. package/dist/index.js +6 -2
  51. package/dist/stores/file/permission-rule-store.d.ts +32 -0
  52. package/dist/stores/file/permission-rule-store.js +213 -0
  53. package/dist/tools/fs/fs-bash.js +12 -5
  54. package/dist/tools/fs/fs-shared.d.ts +12 -0
  55. package/dist/tools/fs/fs-shared.js +65 -1
  56. package/dist/tools/web.js +2 -0
  57. package/package.json +1 -1
@@ -0,0 +1,318 @@
1
+ import { parseAllowRuleText, suggestRulesForCommand } from "./permission-rule-model.js";
2
+ import { errText, sameScope, writerOf } from "./permission-rule-store.js";
3
+ export class InMemoryRuleApprovalRecordStore {
4
+ rows = new Map();
5
+ async get(id) {
6
+ const r = this.rows.get(id);
7
+ return r === undefined ? undefined : structuredClone(r);
8
+ }
9
+ async create(record) {
10
+ if (this.rows.has(record.id))
11
+ throw new Error(`approval record ${record.id} already exists`);
12
+ this.rows.set(record.id, structuredClone(record));
13
+ }
14
+ async cas(id, expectRev, next) {
15
+ const cur = this.rows.get(id);
16
+ if (cur === undefined || cur.rev !== expectRev)
17
+ return false;
18
+ this.rows.set(id, structuredClone(next));
19
+ return true;
20
+ }
21
+ size() {
22
+ return this.rows.size;
23
+ }
24
+ }
25
+ function nowIso(deps) {
26
+ return (deps.now?.() ?? new Date()).toISOString();
27
+ }
28
+ function mintId(deps, prefix) {
29
+ if (deps.newId !== undefined)
30
+ return deps.newId();
31
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
32
+ }
33
+ function requirePrincipal(principal, entry) {
34
+ if (typeof principal !== "string" || principal === "") {
35
+ throw new Error(`${entry} requires a verified principal — refusing to create an approval record without one`);
36
+ }
37
+ return principal;
38
+ }
39
+ export function mintRuleTicket(recordId, candidateIndex) {
40
+ return `rt.${candidateIndex}.${recordId}`;
41
+ }
42
+ function parseRuleTicket(ticket) {
43
+ const m = /^rt\.(\d+)\.(.+)$/.exec(ticket);
44
+ if (m === null)
45
+ return undefined;
46
+ return { index: Number(m[1]), recordId: m[2] };
47
+ }
48
+ export async function prepareCardApproval(opts) {
49
+ const principal = requirePrincipal(opts.principal, "prepareCardApproval");
50
+ if ("candidates" in opts) {
51
+ const e = new Error("prepareCardApproval does not accept caller candidates — card options are minted by the engine from the adjudicated command");
52
+ e.code = "config.invalid_argument";
53
+ throw e;
54
+ }
55
+ if (opts.toolName !== CARD_RULE_TOOL)
56
+ return undefined;
57
+ const scope = opts.scope ?? { kind: "global" };
58
+ const candidates = suggestRulesForCommand(opts.command).map((s) => ({ rule: s.rule, scope }));
59
+ if (candidates.length === 0)
60
+ return undefined;
61
+ const record = {
62
+ id: mintId(opts.deps, "rar"),
63
+ principal,
64
+ kind: "card",
65
+ state: "pending",
66
+ rev: 0,
67
+ candidates,
68
+ createdAt: nowIso(opts.deps),
69
+ ...(opts.toolCallId !== undefined ? { toolCallId: opts.toolCallId } : {}),
70
+ ...(opts.boundInputHash !== undefined ? { boundInputHash: opts.boundInputHash } : {}),
71
+ };
72
+ await opts.deps.approvals.create(record);
73
+ return { approvalId: record.id, tickets: candidates.map((_, i) => mintRuleTicket(record.id, i)), candidates };
74
+ }
75
+ const CARD_RULE_TOOL = "Bash";
76
+ export async function confirmRuleApproval(opts) {
77
+ const principal = requirePrincipal(opts.principal, "confirmRuleApproval");
78
+ const no = (reason) => ({ ok: false, reason });
79
+ const rec = await opts.deps.approvals.get(opts.approvalId);
80
+ if (rec === undefined || rec.principal !== principal)
81
+ return no("record_not_found");
82
+ if (rec.kind === "card") {
83
+ const chosen = opts.selectedCandidate;
84
+ if (chosen === undefined)
85
+ return no("selection_missing");
86
+ if (!Number.isInteger(chosen) || rec.candidates[chosen] === undefined)
87
+ return no("selection_invalid");
88
+ if (rec.state === "approved" || rec.state === "redeemed") {
89
+ return rec.selectedCandidate === chosen ? { ok: true } : no("selection_mismatch");
90
+ }
91
+ const won = await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved", selectedCandidate: chosen });
92
+ return won ? { ok: true } : no("conflict");
93
+ }
94
+ if (opts.selectedCandidate !== undefined)
95
+ return no("batch_takes_no_selection");
96
+ if (rec.state === "approved")
97
+ return { ok: true };
98
+ if (rec.state !== "pending")
99
+ return no("not_pending");
100
+ const won = await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved" });
101
+ return won ? { ok: true } : no("conflict");
102
+ }
103
+ export async function redeemRuleTicket(opts) {
104
+ const principal = requirePrincipal(opts.principal, "redeemRuleTicket");
105
+ const parsed = parseRuleTicket(opts.ticket);
106
+ if (parsed === undefined)
107
+ return { status: "refused", reason: "malformed ticket" };
108
+ const rec = await opts.deps.approvals.get(parsed.recordId);
109
+ if (rec === undefined)
110
+ return { status: "refused", reason: "no such approval record" };
111
+ if (rec.principal !== principal)
112
+ return { status: "refused", reason: "the ticket belongs to a different principal" };
113
+ if (rec.state === "pending")
114
+ return { status: "refused", reason: "the approval record has not been confirmed" };
115
+ const candidate = rec.candidates[parsed.index];
116
+ if (candidate === undefined)
117
+ return { status: "refused", reason: "the ticket names no candidate on this record" };
118
+ if (rec.kind === "card" && rec.selectedCandidate !== parsed.index) {
119
+ return { status: "refused", reason: "the ticket names an option the person did not choose" };
120
+ }
121
+ const parsedRule = parseAllowRuleText(candidate.rule);
122
+ if ("reject" in parsedRule)
123
+ return { status: "refused", reason: `${parsedRule.reject.code}: ${parsedRule.reject.message}` };
124
+ const store = opts.deps.provider.forPrincipal(principal);
125
+ const writer = writerOf(store);
126
+ if (writer === undefined)
127
+ return { status: "refused", reason: "the resolved permission-rule store has no write face" };
128
+ const known = rec.redeemedDots?.[parsed.index];
129
+ const dot = known ?? (await writer.nextDot());
130
+ if (known === undefined) {
131
+ const next = {
132
+ ...rec,
133
+ rev: rec.rev + 1,
134
+ state: "redeemed",
135
+ redeemedDots: { ...(rec.redeemedDots ?? {}), [parsed.index]: dot },
136
+ };
137
+ const ok = await opts.deps.approvals.cas(rec.id, rec.rev, next);
138
+ if (!ok) {
139
+ const again = await opts.deps.approvals.get(rec.id);
140
+ const theirs = again?.redeemedDots?.[parsed.index];
141
+ if (theirs === undefined)
142
+ return { status: "refused", reason: "the approval record changed state concurrently" };
143
+ return await applyRedemption({ ...opts, principal, candidate, parsedRule: parsedRule.rule, dot: theirs, recordId: rec.id, kind: rec.kind, replay: true });
144
+ }
145
+ }
146
+ return await applyRedemption({ ...opts, principal, candidate, parsedRule: parsedRule.rule, dot, recordId: rec.id, kind: rec.kind, replay: known !== undefined });
147
+ }
148
+ const REDEEM_MAX_ATTEMPTS = 8;
149
+ async function applyRedemption(args) {
150
+ const store = args.deps.provider.forPrincipal(args.principal);
151
+ const writer = writerOf(store);
152
+ if (writer === undefined)
153
+ return { status: "refused", reason: "the resolved permission-rule store has no write face" };
154
+ const origin = originOfRecordKind(args.kind);
155
+ for (let attempt = 0; attempt < REDEEM_MAX_ATTEMPTS; attempt++) {
156
+ let rev;
157
+ try {
158
+ rev = (await store.list()).rev;
159
+ }
160
+ catch (err) {
161
+ return { status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` };
162
+ }
163
+ let res;
164
+ try {
165
+ res = await writer.apply({
166
+ kind: "redemption-add",
167
+ rule: args.parsedRule.rule,
168
+ scope: args.candidate.scope,
169
+ tool: args.parsedRule.tool,
170
+ match: args.parsedRule.match,
171
+ command: args.parsedRule.command,
172
+ add: { dot: args.dot, origin, createdAt: nowIso(args.deps) },
173
+ redemption: { recordId: args.recordId, principal: args.principal },
174
+ }, { expectedRev: rev });
175
+ }
176
+ catch (err) {
177
+ return { status: "refused", reason: `the permission-rule store refused the write: ${errText(err)}` };
178
+ }
179
+ if (!("conflict" in res)) {
180
+ return { status: "redeemed", rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev: res.rev, alreadyRedeemed: args.replay };
181
+ }
182
+ }
183
+ return { status: "refused", reason: `optimistic-concurrency retries exhausted after ${REDEEM_MAX_ATTEMPTS} attempts` };
184
+ }
185
+ function originOfRecordKind(kind) {
186
+ return kind === "import" ? "imported-cc" : kind === "starter" ? "starter" : "user";
187
+ }
188
+ const IMPORT_UNCOVERED = { flagSettings: "not-imported-v1", policySettings: "not-imported-v1" };
189
+ export async function prepareCcImport(opts) {
190
+ const principal = requirePrincipal(opts.principal, "prepareCcImport");
191
+ const candidates = [];
192
+ const skipped = [];
193
+ const layers = [];
194
+ for (const layer of opts.layers) {
195
+ let raw;
196
+ try {
197
+ raw = await layer.readFile(layer.path);
198
+ }
199
+ catch {
200
+ raw = undefined;
201
+ }
202
+ if (raw === undefined) {
203
+ layers.push({ path: layer.path, layer: layer.layer, found: false });
204
+ continue;
205
+ }
206
+ layers.push({ path: layer.path, layer: layer.layer, found: true });
207
+ let allow;
208
+ try {
209
+ allow = JSON.parse(raw)?.permissions?.allow;
210
+ }
211
+ catch (err) {
212
+ skipped.push({ rule: layer.path, reason: `settings file is not valid JSON (${errText(err)})` });
213
+ continue;
214
+ }
215
+ if (!Array.isArray(allow))
216
+ continue;
217
+ const scope = layer.layer === "userSettings" ? { kind: "global" } : { kind: "project", root: layer.root };
218
+ for (const entry of allow) {
219
+ if (typeof entry !== "string") {
220
+ skipped.push({ rule: String(entry), reason: "settings entry is not a string" });
221
+ continue;
222
+ }
223
+ if (!entry.startsWith("Bash("))
224
+ continue;
225
+ const parsed = parseAllowRuleText(entry);
226
+ if ("reject" in parsed) {
227
+ skipped.push({ rule: entry, reason: `${parsed.reject.code}: ${parsed.reject.message}` });
228
+ continue;
229
+ }
230
+ if (candidates.some((c) => c.rule === parsed.rule.rule && sameScope(c.scope, scope)))
231
+ continue;
232
+ candidates.push({ rule: parsed.rule.rule, scope });
233
+ }
234
+ }
235
+ const record = {
236
+ id: mintId(opts.deps, "rar"),
237
+ principal,
238
+ kind: "import",
239
+ state: "pending",
240
+ rev: 0,
241
+ candidates,
242
+ createdAt: nowIso(opts.deps),
243
+ };
244
+ await opts.deps.approvals.create(record);
245
+ return { preview: { candidates, skipped, layers, uncovered: IMPORT_UNCOVERED }, approvalId: record.id };
246
+ }
247
+ export const STARTER_RULES = [
248
+ "Bash(ls)",
249
+ "Bash(ls -la)",
250
+ "Bash(pwd)",
251
+ "Bash(whoami)",
252
+ "Bash(uname -a)",
253
+ "Bash(date)",
254
+ "Bash(df -h)",
255
+ "Bash(node --version)",
256
+ "Bash(npm --version)",
257
+ ];
258
+ export async function prepareStarterBatch(opts) {
259
+ const principal = requirePrincipal(opts.principal, "prepareStarterBatch");
260
+ const candidates = [];
261
+ for (const text of STARTER_RULES) {
262
+ const parsed = parseAllowRuleText(text);
263
+ if ("reject" in parsed)
264
+ throw new Error(`starter rule "${text}" is not a valid rule: ${parsed.reject.message}`);
265
+ candidates.push({ rule: parsed.rule.rule, scope: { kind: "global" } });
266
+ }
267
+ const record = {
268
+ id: mintId(opts.deps, "rar"),
269
+ principal,
270
+ kind: "starter",
271
+ state: "pending",
272
+ rev: 0,
273
+ candidates,
274
+ createdAt: nowIso(opts.deps),
275
+ };
276
+ await opts.deps.approvals.create(record);
277
+ return { preview: candidates, approvalId: record.id };
278
+ }
279
+ export async function redeemRuleBatch(opts) {
280
+ const principal = requirePrincipal(opts.principal, "redeemRuleBatch");
281
+ const rec = await opts.deps.approvals.get(opts.approvalId);
282
+ if (rec === undefined)
283
+ return { status: "refused", reason: "no such approval record" };
284
+ if (rec.principal !== principal)
285
+ return { status: "refused", reason: "the record belongs to a different principal" };
286
+ if (rec.kind === "card")
287
+ return { status: "refused", reason: "a card record is redeemed by its chosen ticket, not as a batch" };
288
+ if (rec.state === "pending")
289
+ return { status: "refused", reason: "the approval record has not been confirmed" };
290
+ const persisted = [];
291
+ const deduped = [];
292
+ const skippedAtRedeem = [];
293
+ let rev = 0;
294
+ const store = opts.deps.provider.forPrincipal(principal);
295
+ for (let i = 0; i < rec.candidates.length; i++) {
296
+ const candidate = rec.candidates[i];
297
+ let alreadyThere = false;
298
+ try {
299
+ const snap = await store.list();
300
+ rev = snap.rev;
301
+ alreadyThere = snap.rules.some((r) => r.rule === candidate.rule && sameScope(r.scope, candidate.scope));
302
+ }
303
+ catch (err) {
304
+ return { status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` };
305
+ }
306
+ const res = await redeemRuleTicket({ ticket: mintRuleTicket(rec.id, i), principal, deps: opts.deps });
307
+ if (res.status === "refused") {
308
+ skippedAtRedeem.push({ rule: candidate.rule, reason: res.reason });
309
+ continue;
310
+ }
311
+ rev = res.rev;
312
+ if (alreadyThere)
313
+ deduped.push(candidate);
314
+ else
315
+ persisted.push(candidate);
316
+ }
317
+ return { persisted, deduped, skippedAtRedeem, rev };
318
+ }
@@ -0,0 +1,66 @@
1
+ export type PersistedRuleTool = "Bash";
2
+ export type PersistedRuleMatch = "exact" | "prefix";
3
+ export type RuleScope = {
4
+ kind: "global";
5
+ } | {
6
+ kind: "project";
7
+ root: string;
8
+ };
9
+ export interface RuleDot {
10
+ actor: string;
11
+ counter: number;
12
+ }
13
+ export type RuleAddOrigin = "user" | "imported-cc" | "starter";
14
+ export interface RuleAdd {
15
+ dot: RuleDot;
16
+ origin: RuleAddOrigin;
17
+ createdAt: string;
18
+ }
19
+ export interface PersistedAllowRule {
20
+ rule: string;
21
+ tool: PersistedRuleTool;
22
+ match: PersistedRuleMatch;
23
+ command: string;
24
+ scope: RuleScope;
25
+ adds: RuleAdd[];
26
+ }
27
+ export interface RuleTombstone {
28
+ rule: string;
29
+ scope: RuleScope;
30
+ removedDots: RuleDot[];
31
+ deletedBy: RuleDot;
32
+ }
33
+ export type RuleRejectCode = "invalid.grammar" | "invalid.empty_command" | "invalid.not_simple_command" | "invalid.bare_interpreter_prefix" | "invalid.unbalanced_quotes" | "invalid.too_long" | "unsupported.tool" | "unsupported.wildcard";
34
+ export interface RuleReject {
35
+ code: RuleRejectCode;
36
+ message: string;
37
+ }
38
+ export interface ParsedAllowRule {
39
+ rule: string;
40
+ tool: PersistedRuleTool;
41
+ match: PersistedRuleMatch;
42
+ command: string;
43
+ }
44
+ export declare const MAX_RULE_TEXT_CHARS = 512;
45
+ export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
46
+ export declare function parseAllowRuleText(text: string): {
47
+ rule: ParsedAllowRule;
48
+ } | {
49
+ reject: RuleReject;
50
+ };
51
+ export declare function formatAllowRuleText(command: string, match: PersistedRuleMatch): string;
52
+ export declare function ruleAdmitsCommand(rule: Pick<PersistedAllowRule, "match" | "command">, command: string): boolean;
53
+ export declare function pathWithinRoot(path: string, root: string): boolean;
54
+ export declare function scopeCoversCwd(scope: RuleScope, cwd: string | undefined): boolean;
55
+ export declare function isRuleLive(rule: PersistedAllowRule): boolean;
56
+ export declare function findAdmittingRule(rules: readonly PersistedAllowRule[], call: {
57
+ tool: string;
58
+ command: string;
59
+ cwd: string | undefined;
60
+ }): PersistedAllowRule | undefined;
61
+ export interface RuleSuggestion {
62
+ rule: string;
63
+ match: PersistedRuleMatch;
64
+ command: string;
65
+ }
66
+ export declare function suggestRulesForCommand(command: string): RuleSuggestion[];
@@ -0,0 +1,135 @@
1
+ import { parsePermissionRule } from "./permission-rules.js";
2
+ import { parseLeadingCommandName } from "../tools/fs/bash-readonly-classifier.js";
3
+ export const MAX_RULE_TEXT_CHARS = 512;
4
+ export const BARE_INTERPRETER_NAMES = new Set([
5
+ "bash", "sh", "zsh", "ksh", "csh", "tcsh", "dash", "fish",
6
+ "node", "deno", "bun", "python", "python2", "python3", "perl", "ruby", "php",
7
+ "osascript", "env", "eval", "exec", "xargs", "nohup", "sudo", "doas", "su", "ssh",
8
+ ]);
9
+ function foldSpacing(s) {
10
+ let out = "";
11
+ let quote;
12
+ let pendingGap = false;
13
+ for (const ch of s) {
14
+ if (quote !== undefined) {
15
+ if (ch === quote)
16
+ quote = undefined;
17
+ out += ch;
18
+ continue;
19
+ }
20
+ if (ch === "'" || ch === '"') {
21
+ if (pendingGap) {
22
+ if (out !== "")
23
+ out += " ";
24
+ pendingGap = false;
25
+ }
26
+ quote = ch;
27
+ out += ch;
28
+ continue;
29
+ }
30
+ if (ch === " " || ch === "\t") {
31
+ pendingGap = true;
32
+ continue;
33
+ }
34
+ if (pendingGap) {
35
+ if (out !== "")
36
+ out += " ";
37
+ pendingGap = false;
38
+ }
39
+ out += ch;
40
+ }
41
+ return quote === undefined ? out : undefined;
42
+ }
43
+ function reject(code, message) {
44
+ return { reject: { code, message } };
45
+ }
46
+ export function parseAllowRuleText(text) {
47
+ if (text.length > MAX_RULE_TEXT_CHARS) {
48
+ return reject("invalid.too_long", `rule text exceeds ${MAX_RULE_TEXT_CHARS} characters`);
49
+ }
50
+ const parsed = parsePermissionRule(text);
51
+ if (parsed.ruleContent === undefined) {
52
+ return reject("invalid.grammar", `"${text}" is not a Tool(content) rule — a bare tool name claims the whole tool and is not a command rule`);
53
+ }
54
+ if (parsed.toolName !== "Bash") {
55
+ return reject("unsupported.tool", `only Bash rules are supported in this version (got "${parsed.toolName}")`);
56
+ }
57
+ const content = parsed.ruleContent;
58
+ const prefixBody = /^(.+):\*$/.exec(content)?.[1];
59
+ const match = prefixBody !== undefined ? "prefix" : "exact";
60
+ const body = prefixBody ?? content;
61
+ if (body.includes("*")) {
62
+ return reject("unsupported.wildcard", `wildcard rule forms are not supported in this version ("${text}")`);
63
+ }
64
+ const floor = parseLeadingCommandName(body);
65
+ if ("reject" in floor) {
66
+ return body.trim() === ""
67
+ ? reject("invalid.empty_command", `rule "${text}" names no command`)
68
+ : reject("invalid.not_simple_command", `rule "${text}" is not a single simple command (${floor.reject})`);
69
+ }
70
+ if (match === "prefix" && BARE_INTERPRETER_NAMES.has(floor.name)) {
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
+ }
73
+ const command = foldSpacing(body);
74
+ if (command === undefined) {
75
+ return reject("invalid.unbalanced_quotes", `rule "${text}" has unbalanced quoting, so the command it names is not determinable`);
76
+ }
77
+ return { rule: { rule: formatAllowRuleText(command, match), tool: "Bash", match, command } };
78
+ }
79
+ export function formatAllowRuleText(command, match) {
80
+ return `Bash(${command}${match === "prefix" ? ":*" : ""})`;
81
+ }
82
+ export function ruleAdmitsCommand(rule, command) {
83
+ const floor = parseLeadingCommandName(command);
84
+ if ("reject" in floor)
85
+ return false;
86
+ const folded = foldSpacing(command);
87
+ if (folded === undefined)
88
+ return false;
89
+ if (rule.match === "exact")
90
+ return folded === rule.command;
91
+ return folded === rule.command || folded.startsWith(rule.command + " ");
92
+ }
93
+ export function pathWithinRoot(path, root) {
94
+ if (path === root)
95
+ return true;
96
+ const base = root.endsWith("/") ? root : root + "/";
97
+ return path.startsWith(base);
98
+ }
99
+ export function scopeCoversCwd(scope, cwd) {
100
+ if (scope.kind === "global")
101
+ return true;
102
+ return cwd !== undefined && pathWithinRoot(cwd, scope.root);
103
+ }
104
+ export function isRuleLive(rule) {
105
+ return rule.adds.length > 0;
106
+ }
107
+ export function findAdmittingRule(rules, call) {
108
+ const floor = parseLeadingCommandName(call.command);
109
+ if ("reject" in floor)
110
+ return undefined;
111
+ for (const rule of rules) {
112
+ if (!isRuleLive(rule))
113
+ continue;
114
+ if (rule.tool !== call.tool)
115
+ continue;
116
+ if (!scopeCoversCwd(rule.scope, call.cwd))
117
+ continue;
118
+ if (ruleAdmitsCommand(rule, call.command))
119
+ return rule;
120
+ }
121
+ return undefined;
122
+ }
123
+ export function suggestRulesForCommand(command) {
124
+ const floor = parseLeadingCommandName(command);
125
+ if ("reject" in floor)
126
+ return [];
127
+ const folded = foldSpacing(command);
128
+ if (folded === undefined)
129
+ return [];
130
+ const out = [];
131
+ const exact = parseAllowRuleText(formatAllowRuleText(folded, "exact"));
132
+ if ("rule" in exact)
133
+ out.push({ rule: exact.rule.rule, match: "exact", command: exact.rule.command });
134
+ return out;
135
+ }
@@ -0,0 +1,89 @@
1
+ import type { PersistedAllowRule, RuleAdd, RuleDot, RuleScope, RuleTombstone } from "./permission-rule-model.js";
2
+ import type { StoreDurability, StoreFidelity } from "./checkpoint-store.js";
3
+ export interface StoredAllowRules {
4
+ rules: PersistedAllowRule[];
5
+ tombstones: RuleTombstone[];
6
+ rev: number;
7
+ checksum?: string;
8
+ }
9
+ export interface PermissionRuleStore {
10
+ list(): Promise<StoredAllowRules>;
11
+ readonly durability?: StoreDurability;
12
+ readonly fidelity?: StoreFidelity;
13
+ }
14
+ export interface PermissionRuleStoreProvider {
15
+ forPrincipal(principal: string | undefined): PermissionRuleStore;
16
+ }
17
+ export interface PutResult {
18
+ rev: number;
19
+ }
20
+ export interface RedemptionAuthorization {
21
+ recordId: string;
22
+ principal: string;
23
+ }
24
+ export interface RuleAddDelta {
25
+ kind: "redemption-add";
26
+ rule: string;
27
+ scope: RuleScope;
28
+ tool: PersistedAllowRule["tool"];
29
+ match: PersistedAllowRule["match"];
30
+ command: string;
31
+ add: RuleAdd;
32
+ redemption: RedemptionAuthorization;
33
+ }
34
+ export interface RuleDeleteDelta {
35
+ kind: "tighten-delete";
36
+ tombstone: RuleTombstone;
37
+ }
38
+ export type RuleWriteDelta = RuleAddDelta | RuleDeleteDelta;
39
+ export interface PermissionRuleWriter {
40
+ nextDot(): Promise<RuleDot>;
41
+ apply(delta: RuleWriteDelta, opts: {
42
+ expectedRev: number;
43
+ }): Promise<PutResult | {
44
+ conflict: true;
45
+ rev: number;
46
+ }>;
47
+ }
48
+ export declare const PERMISSION_RULE_WRITER = "__semaPermissionRuleWriter";
49
+ export interface WritablePermissionRuleStore extends PermissionRuleStore {
50
+ readonly [PERMISSION_RULE_WRITER]: PermissionRuleWriter;
51
+ }
52
+ export declare function writerOf(store: PermissionRuleStore): PermissionRuleWriter | undefined;
53
+ export declare function sameScope(a: RuleScope, b: RuleScope): boolean;
54
+ export declare function applyTombstones(rules: readonly PersistedAllowRule[], tombstones: readonly RuleTombstone[]): PersistedAllowRule[];
55
+ export declare function foldDelta(rules: readonly PersistedAllowRule[], delta: RuleAddDelta): PersistedAllowRule[];
56
+ export declare function addDotsOf(rules: readonly PersistedAllowRule[]): RuleDot[];
57
+ export declare function assertDeleteDeltaCarriesNoAdd(delta: RuleDeleteDelta): void;
58
+ export declare function ruleStoreChecksum(payload: unknown): Promise<string>;
59
+ export type RemoveResult = {
60
+ status: "removed";
61
+ rev: number;
62
+ stillLive: boolean;
63
+ } | {
64
+ status: "no-op";
65
+ rev: number;
66
+ } | {
67
+ status: "failed";
68
+ error: string;
69
+ };
70
+ export declare function removePersistedRule(opts: {
71
+ rule: string;
72
+ scope: RuleScope;
73
+ principal: string;
74
+ provider: PermissionRuleStoreProvider;
75
+ }): Promise<RemoveResult>;
76
+ export declare function errText(err: unknown): string;
77
+ export declare const EMPTY_RULE_STORE: PermissionRuleStore;
78
+ export declare class InMemoryPermissionRuleStore implements WritablePermissionRuleStore {
79
+ private readonly actor;
80
+ readonly durability: StoreDurability;
81
+ readonly fidelity: StoreFidelity;
82
+ private rules;
83
+ private tombstones;
84
+ private rev;
85
+ private counter;
86
+ constructor(actor?: string);
87
+ list(): Promise<StoredAllowRules>;
88
+ readonly [PERMISSION_RULE_WRITER]: PermissionRuleWriter;
89
+ }