@sema-agent/core 5.17.0 → 5.18.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 +79 -0
- package/dist/agents/subagent.js +24 -0
- package/dist/core/auto-compaction.d.ts +6 -0
- package/dist/core/auto-compaction.js +15 -1
- package/dist/core/checkpoint-store.d.ts +1 -0
- package/dist/core/governance-codes.js +1 -0
- package/dist/core/hooks.d.ts +8 -0
- package/dist/core/hooks.js +17 -0
- package/dist/core/mcp.js +3 -0
- package/dist/core/memory-engine/content-origin.d.ts +27 -0
- package/dist/core/memory-engine/content-origin.js +38 -0
- package/dist/core/memory-engine/engine.d.ts +12 -2
- package/dist/core/memory-engine/engine.js +172 -12
- package/dist/core/memory-engine/file-backend.d.ts +4 -0
- package/dist/core/memory-engine/file-backend.js +25 -3
- package/dist/core/memory-engine/index.d.ts +2 -1
- package/dist/core/memory-engine/index.js +2 -1
- package/dist/core/memory-engine/layout.d.ts +16 -0
- package/dist/core/memory-engine/layout.js +90 -2
- package/dist/core/memory-engine/sync-client.d.ts +1 -0
- package/dist/core/memory-engine/sync-client.js +23 -5
- package/dist/core/memory-engine/tools.d.ts +55 -0
- package/dist/core/memory-engine/tools.js +307 -0
- package/dist/core/memory-engine/types.d.ts +1 -1
- package/dist/core/memory.d.ts +4 -0
- package/dist/core/memory.js +15 -2
- package/dist/core/permission-rule-consent.d.ts +131 -0
- package/dist/core/permission-rule-consent.js +307 -0
- package/dist/core/permission-rule-model.d.ts +66 -0
- package/dist/core/permission-rule-model.js +135 -0
- package/dist/core/permission-rule-store.d.ts +89 -0
- package/dist/core/permission-rule-store.js +145 -0
- package/dist/core/permission-rules.d.ts +3 -2
- package/dist/core/permission-rules.js +9 -4
- package/dist/core/runner/prepare-memory.d.ts +3 -1
- package/dist/core/runner/prepare-memory.js +54 -14
- package/dist/core/runner/prepare-task.d.ts +11 -0
- package/dist/core/runner/prepare-task.js +192 -10
- package/dist/core/runner/runtask.js +24 -0
- package/dist/core/runner/tool-output-projection.js +1 -1
- package/dist/core/tool-policy.d.ts +8 -1
- package/dist/core/tool-policy.js +36 -0
- package/dist/core/tools.js +1 -0
- package/dist/core/trace.d.ts +20 -0
- package/dist/core/types.d.ts +14 -0
- package/dist/core/wiring-manifest.d.ts +5 -1
- package/dist/core/wiring-manifest.js +2 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -1
- package/dist/stores/file/permission-rule-store.d.ts +32 -0
- package/dist/stores/file/permission-rule-store.js +213 -0
- package/dist/tools/fs/fs-bash.js +12 -5
- package/dist/tools/fs/fs-shared.d.ts +12 -0
- package/dist/tools/fs/fs-shared.js +65 -1
- package/dist/tools/web.js +2 -0
- package/package.json +1 -1
package/dist/core/memory.d.ts
CHANGED
|
@@ -94,6 +94,8 @@ export interface NormalizedMemorySpec {
|
|
|
94
94
|
writeScope: string | null;
|
|
95
95
|
enabled: boolean;
|
|
96
96
|
scopeContract?: "v2";
|
|
97
|
+
trustedTools?: string[];
|
|
98
|
+
execIsExternalContent?: boolean;
|
|
97
99
|
scopeOrigins?: Readonly<Record<string, "deployment" | "request">>;
|
|
98
100
|
}
|
|
99
101
|
export type MemorySpecInput = {
|
|
@@ -101,6 +103,8 @@ export type MemorySpecInput = {
|
|
|
101
103
|
writeScope?: string | null;
|
|
102
104
|
enabled?: boolean;
|
|
103
105
|
scopeContract?: "v2";
|
|
106
|
+
trustedTools?: ReadonlyArray<string>;
|
|
107
|
+
execIsExternalContent?: boolean;
|
|
104
108
|
scopeOrigins?: Readonly<Record<string, "deployment" | "request">>;
|
|
105
109
|
};
|
|
106
110
|
export declare function normalizeMemorySpec(input: MemorySpecInput | undefined): NormalizedMemorySpec | undefined;
|
package/dist/core/memory.js
CHANGED
|
@@ -384,13 +384,26 @@ export function normalizeMemorySpec(input) {
|
|
|
384
384
|
else {
|
|
385
385
|
writeScope = scopes[scopes.length - 1];
|
|
386
386
|
}
|
|
387
|
+
const trustedTools = [];
|
|
388
|
+
const trustedSeen = new Set();
|
|
389
|
+
for (const t of input.trustedTools ?? []) {
|
|
390
|
+
const name = typeof t === "string" ? t.trim() : "";
|
|
391
|
+
if (!name || trustedSeen.has(name))
|
|
392
|
+
continue;
|
|
393
|
+
trustedSeen.add(name);
|
|
394
|
+
trustedTools.push(name);
|
|
395
|
+
}
|
|
396
|
+
const contentSafety = {
|
|
397
|
+
...(trustedTools.length > 0 ? { trustedTools } : {}),
|
|
398
|
+
...(input.execIsExternalContent === true ? { execIsExternalContent: true } : {}),
|
|
399
|
+
};
|
|
387
400
|
const origins = input.scopeOrigins !== undefined ? { scopeOrigins: input.scopeOrigins } : {};
|
|
388
401
|
if (input.scopeContract === "v2") {
|
|
389
402
|
for (const key of writeScope !== null ? [...scopes, writeScope] : scopes)
|
|
390
403
|
parseScopeKey(key);
|
|
391
|
-
return { scopes, writeScope, enabled: input.enabled !== false, scopeContract: "v2", ...origins };
|
|
404
|
+
return { scopes, writeScope, enabled: input.enabled !== false, scopeContract: "v2", ...contentSafety, ...origins };
|
|
392
405
|
}
|
|
393
|
-
return { scopes, writeScope, enabled: input.enabled !== false, ...origins };
|
|
406
|
+
return { scopes, writeScope, enabled: input.enabled !== false, ...contentSafety, ...origins };
|
|
394
407
|
}
|
|
395
408
|
export const MAX_MEMORY_BYTES = 100 * 1024;
|
|
396
409
|
export const MEMORY_WRAPPER_TAGS = ["user_memory"];
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { type RuleScope, type RuleDot } from "./permission-rule-model.js";
|
|
2
|
+
import type { PermissionRuleStoreProvider } from "./permission-rule-store.js";
|
|
3
|
+
export interface RuleCandidate {
|
|
4
|
+
rule: string;
|
|
5
|
+
scope: RuleScope;
|
|
6
|
+
}
|
|
7
|
+
export type RuleApprovalKind = "card" | "import" | "starter";
|
|
8
|
+
export interface RuleApprovalRecord {
|
|
9
|
+
id: string;
|
|
10
|
+
principal: string;
|
|
11
|
+
kind: RuleApprovalKind;
|
|
12
|
+
state: "pending" | "approved" | "redeemed";
|
|
13
|
+
candidates: RuleCandidate[];
|
|
14
|
+
createdAt: string;
|
|
15
|
+
toolCallId?: string;
|
|
16
|
+
boundInputHash?: string;
|
|
17
|
+
rev: number;
|
|
18
|
+
selectedCandidate?: number;
|
|
19
|
+
redeemedDots?: Record<number, RuleDot>;
|
|
20
|
+
}
|
|
21
|
+
export interface RuleApprovalRecordStore {
|
|
22
|
+
get(id: string): Promise<RuleApprovalRecord | undefined>;
|
|
23
|
+
cas(id: string, expectRev: number, next: RuleApprovalRecord): Promise<boolean>;
|
|
24
|
+
create(record: RuleApprovalRecord): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export interface RuleConsentDeps {
|
|
27
|
+
provider: PermissionRuleStoreProvider;
|
|
28
|
+
approvals: RuleApprovalRecordStore;
|
|
29
|
+
now?: () => Date;
|
|
30
|
+
newId?: () => string;
|
|
31
|
+
}
|
|
32
|
+
export declare class InMemoryRuleApprovalRecordStore implements RuleApprovalRecordStore {
|
|
33
|
+
private readonly rows;
|
|
34
|
+
get(id: string): Promise<RuleApprovalRecord | undefined>;
|
|
35
|
+
create(record: RuleApprovalRecord): Promise<void>;
|
|
36
|
+
cas(id: string, expectRev: number, next: RuleApprovalRecord): Promise<boolean>;
|
|
37
|
+
size(): number;
|
|
38
|
+
}
|
|
39
|
+
export type RuleTicket = string;
|
|
40
|
+
export declare function mintRuleTicket(recordId: string, candidateIndex: number): RuleTicket;
|
|
41
|
+
export declare function prepareCardApproval(opts: {
|
|
42
|
+
principal: string;
|
|
43
|
+
toolName: string;
|
|
44
|
+
command: string;
|
|
45
|
+
toolCallId?: string;
|
|
46
|
+
boundInputHash?: string;
|
|
47
|
+
scope?: RuleScope;
|
|
48
|
+
deps: RuleConsentDeps;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
approvalId: string;
|
|
51
|
+
tickets: RuleTicket[];
|
|
52
|
+
candidates: RuleCandidate[];
|
|
53
|
+
} | undefined>;
|
|
54
|
+
export declare function confirmRuleApproval(opts: {
|
|
55
|
+
approvalId: string;
|
|
56
|
+
principal: string;
|
|
57
|
+
selectedCandidate?: number;
|
|
58
|
+
deps: RuleConsentDeps;
|
|
59
|
+
}): Promise<boolean>;
|
|
60
|
+
export type RedeemResult = {
|
|
61
|
+
status: "redeemed";
|
|
62
|
+
rule: string;
|
|
63
|
+
scope: RuleScope;
|
|
64
|
+
dot: RuleDot;
|
|
65
|
+
rev: number;
|
|
66
|
+
alreadyRedeemed: boolean;
|
|
67
|
+
} | {
|
|
68
|
+
status: "refused";
|
|
69
|
+
reason: string;
|
|
70
|
+
};
|
|
71
|
+
export declare function redeemRuleTicket(opts: {
|
|
72
|
+
ticket: RuleTicket;
|
|
73
|
+
principal: string;
|
|
74
|
+
deps: RuleConsentDeps;
|
|
75
|
+
}): Promise<RedeemResult>;
|
|
76
|
+
export type ImportedSettingsLayer = "userSettings" | "projectSettings" | "localSettings";
|
|
77
|
+
export interface CcImportLayer {
|
|
78
|
+
layer: ImportedSettingsLayer;
|
|
79
|
+
path: string;
|
|
80
|
+
root: string;
|
|
81
|
+
readFile: (path: string) => Promise<string | undefined>;
|
|
82
|
+
}
|
|
83
|
+
export interface ImportPreview {
|
|
84
|
+
candidates: RuleCandidate[];
|
|
85
|
+
skipped: Array<{
|
|
86
|
+
rule: string;
|
|
87
|
+
reason: string;
|
|
88
|
+
}>;
|
|
89
|
+
layers: Array<{
|
|
90
|
+
path: string;
|
|
91
|
+
layer: ImportedSettingsLayer;
|
|
92
|
+
found: boolean;
|
|
93
|
+
}>;
|
|
94
|
+
uncovered: {
|
|
95
|
+
flagSettings: "not-imported-v1";
|
|
96
|
+
policySettings: "not-imported-v1";
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export interface ImportResult {
|
|
100
|
+
persisted: RuleCandidate[];
|
|
101
|
+
deduped: RuleCandidate[];
|
|
102
|
+
skippedAtRedeem: Array<{
|
|
103
|
+
rule: string;
|
|
104
|
+
reason: string;
|
|
105
|
+
}>;
|
|
106
|
+
rev: number;
|
|
107
|
+
}
|
|
108
|
+
export declare function prepareCcImport(opts: {
|
|
109
|
+
layers: CcImportLayer[];
|
|
110
|
+
principal: string;
|
|
111
|
+
deps: RuleConsentDeps;
|
|
112
|
+
}): Promise<{
|
|
113
|
+
preview: ImportPreview;
|
|
114
|
+
approvalId: string;
|
|
115
|
+
}>;
|
|
116
|
+
export declare const STARTER_RULES: readonly string[];
|
|
117
|
+
export declare function prepareStarterBatch(opts: {
|
|
118
|
+
principal: string;
|
|
119
|
+
deps: RuleConsentDeps;
|
|
120
|
+
}): Promise<{
|
|
121
|
+
preview: RuleCandidate[];
|
|
122
|
+
approvalId: string;
|
|
123
|
+
}>;
|
|
124
|
+
export declare function redeemRuleBatch(opts: {
|
|
125
|
+
approvalId: string;
|
|
126
|
+
principal: string;
|
|
127
|
+
deps: RuleConsentDeps;
|
|
128
|
+
}): Promise<ImportResult | {
|
|
129
|
+
status: "refused";
|
|
130
|
+
reason: string;
|
|
131
|
+
}>;
|
|
@@ -0,0 +1,307 @@
|
|
|
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 (opts.toolName !== CARD_RULE_TOOL)
|
|
51
|
+
return undefined;
|
|
52
|
+
const scope = opts.scope ?? { kind: "global" };
|
|
53
|
+
const candidates = suggestRulesForCommand(opts.command).map((s) => ({ rule: s.rule, scope }));
|
|
54
|
+
if (candidates.length === 0)
|
|
55
|
+
return undefined;
|
|
56
|
+
const record = {
|
|
57
|
+
id: mintId(opts.deps, "rar"),
|
|
58
|
+
principal,
|
|
59
|
+
kind: "card",
|
|
60
|
+
state: "pending",
|
|
61
|
+
rev: 0,
|
|
62
|
+
candidates,
|
|
63
|
+
createdAt: nowIso(opts.deps),
|
|
64
|
+
...(opts.toolCallId !== undefined ? { toolCallId: opts.toolCallId } : {}),
|
|
65
|
+
...(opts.boundInputHash !== undefined ? { boundInputHash: opts.boundInputHash } : {}),
|
|
66
|
+
};
|
|
67
|
+
await opts.deps.approvals.create(record);
|
|
68
|
+
return { approvalId: record.id, tickets: candidates.map((_, i) => mintRuleTicket(record.id, i)), candidates };
|
|
69
|
+
}
|
|
70
|
+
const CARD_RULE_TOOL = "Bash";
|
|
71
|
+
export async function confirmRuleApproval(opts) {
|
|
72
|
+
const principal = requirePrincipal(opts.principal, "confirmRuleApproval");
|
|
73
|
+
const rec = await opts.deps.approvals.get(opts.approvalId);
|
|
74
|
+
if (rec === undefined || rec.principal !== principal)
|
|
75
|
+
return false;
|
|
76
|
+
if (rec.kind === "card") {
|
|
77
|
+
const chosen = opts.selectedCandidate;
|
|
78
|
+
if (chosen === undefined || !Number.isInteger(chosen) || rec.candidates[chosen] === undefined)
|
|
79
|
+
return false;
|
|
80
|
+
if (rec.state === "approved" || rec.state === "redeemed")
|
|
81
|
+
return rec.selectedCandidate === chosen;
|
|
82
|
+
return await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved", selectedCandidate: chosen });
|
|
83
|
+
}
|
|
84
|
+
if (opts.selectedCandidate !== undefined)
|
|
85
|
+
return false;
|
|
86
|
+
if (rec.state === "approved")
|
|
87
|
+
return true;
|
|
88
|
+
if (rec.state !== "pending")
|
|
89
|
+
return false;
|
|
90
|
+
return await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved" });
|
|
91
|
+
}
|
|
92
|
+
export async function redeemRuleTicket(opts) {
|
|
93
|
+
const principal = requirePrincipal(opts.principal, "redeemRuleTicket");
|
|
94
|
+
const parsed = parseRuleTicket(opts.ticket);
|
|
95
|
+
if (parsed === undefined)
|
|
96
|
+
return { status: "refused", reason: "malformed ticket" };
|
|
97
|
+
const rec = await opts.deps.approvals.get(parsed.recordId);
|
|
98
|
+
if (rec === undefined)
|
|
99
|
+
return { status: "refused", reason: "no such approval record" };
|
|
100
|
+
if (rec.principal !== principal)
|
|
101
|
+
return { status: "refused", reason: "the ticket belongs to a different principal" };
|
|
102
|
+
if (rec.state === "pending")
|
|
103
|
+
return { status: "refused", reason: "the approval record has not been confirmed" };
|
|
104
|
+
const candidate = rec.candidates[parsed.index];
|
|
105
|
+
if (candidate === undefined)
|
|
106
|
+
return { status: "refused", reason: "the ticket names no candidate on this record" };
|
|
107
|
+
if (rec.kind === "card" && rec.selectedCandidate !== parsed.index) {
|
|
108
|
+
return { status: "refused", reason: "the ticket names an option the person did not choose" };
|
|
109
|
+
}
|
|
110
|
+
const parsedRule = parseAllowRuleText(candidate.rule);
|
|
111
|
+
if ("reject" in parsedRule)
|
|
112
|
+
return { status: "refused", reason: `${parsedRule.reject.code}: ${parsedRule.reject.message}` };
|
|
113
|
+
const store = opts.deps.provider.forPrincipal(principal);
|
|
114
|
+
const writer = writerOf(store);
|
|
115
|
+
if (writer === undefined)
|
|
116
|
+
return { status: "refused", reason: "the resolved permission-rule store has no write face" };
|
|
117
|
+
const known = rec.redeemedDots?.[parsed.index];
|
|
118
|
+
const dot = known ?? (await writer.nextDot());
|
|
119
|
+
if (known === undefined) {
|
|
120
|
+
const next = {
|
|
121
|
+
...rec,
|
|
122
|
+
rev: rec.rev + 1,
|
|
123
|
+
state: "redeemed",
|
|
124
|
+
redeemedDots: { ...(rec.redeemedDots ?? {}), [parsed.index]: dot },
|
|
125
|
+
};
|
|
126
|
+
const ok = await opts.deps.approvals.cas(rec.id, rec.rev, next);
|
|
127
|
+
if (!ok) {
|
|
128
|
+
const again = await opts.deps.approvals.get(rec.id);
|
|
129
|
+
const theirs = again?.redeemedDots?.[parsed.index];
|
|
130
|
+
if (theirs === undefined)
|
|
131
|
+
return { status: "refused", reason: "the approval record changed state concurrently" };
|
|
132
|
+
return await applyRedemption({ ...opts, principal, candidate, parsedRule: parsedRule.rule, dot: theirs, recordId: rec.id, kind: rec.kind, replay: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return await applyRedemption({ ...opts, principal, candidate, parsedRule: parsedRule.rule, dot, recordId: rec.id, kind: rec.kind, replay: known !== undefined });
|
|
136
|
+
}
|
|
137
|
+
const REDEEM_MAX_ATTEMPTS = 8;
|
|
138
|
+
async function applyRedemption(args) {
|
|
139
|
+
const store = args.deps.provider.forPrincipal(args.principal);
|
|
140
|
+
const writer = writerOf(store);
|
|
141
|
+
if (writer === undefined)
|
|
142
|
+
return { status: "refused", reason: "the resolved permission-rule store has no write face" };
|
|
143
|
+
const origin = originOfRecordKind(args.kind);
|
|
144
|
+
for (let attempt = 0; attempt < REDEEM_MAX_ATTEMPTS; attempt++) {
|
|
145
|
+
let rev;
|
|
146
|
+
try {
|
|
147
|
+
rev = (await store.list()).rev;
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
return { status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` };
|
|
151
|
+
}
|
|
152
|
+
let res;
|
|
153
|
+
try {
|
|
154
|
+
res = await writer.apply({
|
|
155
|
+
kind: "redemption-add",
|
|
156
|
+
rule: args.parsedRule.rule,
|
|
157
|
+
scope: args.candidate.scope,
|
|
158
|
+
tool: args.parsedRule.tool,
|
|
159
|
+
match: args.parsedRule.match,
|
|
160
|
+
command: args.parsedRule.command,
|
|
161
|
+
add: { dot: args.dot, origin, createdAt: nowIso(args.deps) },
|
|
162
|
+
redemption: { recordId: args.recordId, principal: args.principal },
|
|
163
|
+
}, { expectedRev: rev });
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return { status: "refused", reason: `the permission-rule store refused the write: ${errText(err)}` };
|
|
167
|
+
}
|
|
168
|
+
if (!("conflict" in res)) {
|
|
169
|
+
return { status: "redeemed", rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev: res.rev, alreadyRedeemed: args.replay };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { status: "refused", reason: `optimistic-concurrency retries exhausted after ${REDEEM_MAX_ATTEMPTS} attempts` };
|
|
173
|
+
}
|
|
174
|
+
function originOfRecordKind(kind) {
|
|
175
|
+
return kind === "import" ? "imported-cc" : kind === "starter" ? "starter" : "user";
|
|
176
|
+
}
|
|
177
|
+
const IMPORT_UNCOVERED = { flagSettings: "not-imported-v1", policySettings: "not-imported-v1" };
|
|
178
|
+
export async function prepareCcImport(opts) {
|
|
179
|
+
const principal = requirePrincipal(opts.principal, "prepareCcImport");
|
|
180
|
+
const candidates = [];
|
|
181
|
+
const skipped = [];
|
|
182
|
+
const layers = [];
|
|
183
|
+
for (const layer of opts.layers) {
|
|
184
|
+
let raw;
|
|
185
|
+
try {
|
|
186
|
+
raw = await layer.readFile(layer.path);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
raw = undefined;
|
|
190
|
+
}
|
|
191
|
+
if (raw === undefined) {
|
|
192
|
+
layers.push({ path: layer.path, layer: layer.layer, found: false });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
layers.push({ path: layer.path, layer: layer.layer, found: true });
|
|
196
|
+
let allow;
|
|
197
|
+
try {
|
|
198
|
+
allow = JSON.parse(raw)?.permissions?.allow;
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
skipped.push({ rule: layer.path, reason: `settings file is not valid JSON (${errText(err)})` });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!Array.isArray(allow))
|
|
205
|
+
continue;
|
|
206
|
+
const scope = layer.layer === "userSettings" ? { kind: "global" } : { kind: "project", root: layer.root };
|
|
207
|
+
for (const entry of allow) {
|
|
208
|
+
if (typeof entry !== "string") {
|
|
209
|
+
skipped.push({ rule: String(entry), reason: "settings entry is not a string" });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (!entry.startsWith("Bash("))
|
|
213
|
+
continue;
|
|
214
|
+
const parsed = parseAllowRuleText(entry);
|
|
215
|
+
if ("reject" in parsed) {
|
|
216
|
+
skipped.push({ rule: entry, reason: `${parsed.reject.code}: ${parsed.reject.message}` });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (candidates.some((c) => c.rule === parsed.rule.rule && sameScope(c.scope, scope)))
|
|
220
|
+
continue;
|
|
221
|
+
candidates.push({ rule: parsed.rule.rule, scope });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const record = {
|
|
225
|
+
id: mintId(opts.deps, "rar"),
|
|
226
|
+
principal,
|
|
227
|
+
kind: "import",
|
|
228
|
+
state: "pending",
|
|
229
|
+
rev: 0,
|
|
230
|
+
candidates,
|
|
231
|
+
createdAt: nowIso(opts.deps),
|
|
232
|
+
};
|
|
233
|
+
await opts.deps.approvals.create(record);
|
|
234
|
+
return { preview: { candidates, skipped, layers, uncovered: IMPORT_UNCOVERED }, approvalId: record.id };
|
|
235
|
+
}
|
|
236
|
+
export const STARTER_RULES = [
|
|
237
|
+
"Bash(ls)",
|
|
238
|
+
"Bash(ls -la)",
|
|
239
|
+
"Bash(pwd)",
|
|
240
|
+
"Bash(whoami)",
|
|
241
|
+
"Bash(uname -a)",
|
|
242
|
+
"Bash(date)",
|
|
243
|
+
"Bash(df -h)",
|
|
244
|
+
"Bash(node --version)",
|
|
245
|
+
"Bash(npm --version)",
|
|
246
|
+
];
|
|
247
|
+
export async function prepareStarterBatch(opts) {
|
|
248
|
+
const principal = requirePrincipal(opts.principal, "prepareStarterBatch");
|
|
249
|
+
const candidates = [];
|
|
250
|
+
for (const text of STARTER_RULES) {
|
|
251
|
+
const parsed = parseAllowRuleText(text);
|
|
252
|
+
if ("reject" in parsed)
|
|
253
|
+
throw new Error(`starter rule "${text}" is not a valid rule: ${parsed.reject.message}`);
|
|
254
|
+
candidates.push({ rule: parsed.rule.rule, scope: { kind: "global" } });
|
|
255
|
+
}
|
|
256
|
+
const record = {
|
|
257
|
+
id: mintId(opts.deps, "rar"),
|
|
258
|
+
principal,
|
|
259
|
+
kind: "starter",
|
|
260
|
+
state: "pending",
|
|
261
|
+
rev: 0,
|
|
262
|
+
candidates,
|
|
263
|
+
createdAt: nowIso(opts.deps),
|
|
264
|
+
};
|
|
265
|
+
await opts.deps.approvals.create(record);
|
|
266
|
+
return { preview: candidates, approvalId: record.id };
|
|
267
|
+
}
|
|
268
|
+
export async function redeemRuleBatch(opts) {
|
|
269
|
+
const principal = requirePrincipal(opts.principal, "redeemRuleBatch");
|
|
270
|
+
const rec = await opts.deps.approvals.get(opts.approvalId);
|
|
271
|
+
if (rec === undefined)
|
|
272
|
+
return { status: "refused", reason: "no such approval record" };
|
|
273
|
+
if (rec.principal !== principal)
|
|
274
|
+
return { status: "refused", reason: "the record belongs to a different principal" };
|
|
275
|
+
if (rec.kind === "card")
|
|
276
|
+
return { status: "refused", reason: "a card record is redeemed by its chosen ticket, not as a batch" };
|
|
277
|
+
if (rec.state === "pending")
|
|
278
|
+
return { status: "refused", reason: "the approval record has not been confirmed" };
|
|
279
|
+
const persisted = [];
|
|
280
|
+
const deduped = [];
|
|
281
|
+
const skippedAtRedeem = [];
|
|
282
|
+
let rev = 0;
|
|
283
|
+
const store = opts.deps.provider.forPrincipal(principal);
|
|
284
|
+
for (let i = 0; i < rec.candidates.length; i++) {
|
|
285
|
+
const candidate = rec.candidates[i];
|
|
286
|
+
let alreadyThere = false;
|
|
287
|
+
try {
|
|
288
|
+
const snap = await store.list();
|
|
289
|
+
rev = snap.rev;
|
|
290
|
+
alreadyThere = snap.rules.some((r) => r.rule === candidate.rule && sameScope(r.scope, candidate.scope));
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
return { status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` };
|
|
294
|
+
}
|
|
295
|
+
const res = await redeemRuleTicket({ ticket: mintRuleTicket(rec.id, i), principal, deps: opts.deps });
|
|
296
|
+
if (res.status === "refused") {
|
|
297
|
+
skippedAtRedeem.push({ rule: candidate.rule, reason: res.reason });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
rev = res.rev;
|
|
301
|
+
if (alreadyThere)
|
|
302
|
+
deduped.push(candidate);
|
|
303
|
+
else
|
|
304
|
+
persisted.push(candidate);
|
|
305
|
+
}
|
|
306
|
+
return { persisted, deduped, skippedAtRedeem, rev };
|
|
307
|
+
}
|
|
@@ -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
|
+
}
|