@andares/pi-toolkits 0.9.8 → 0.10.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/README.md +34 -5
- package/README.zh-CN.md +35 -5
- package/package.json +2 -1
- package/src/features/permission/audit.test.ts +159 -0
- package/src/features/permission/audit.ts +145 -0
- package/src/features/permission/index.test.ts +138 -3
- package/src/features/permission/index.ts +108 -12
- package/src/features/permission/policy.ts +39 -3
- package/src/index.ts +6 -2
- package/src/lib/approval-log.test.ts +289 -0
- package/src/lib/approval-log.ts +724 -0
- package/src/lib/gates.audit.test.ts +343 -0
- package/src/lib/gates.ts +291 -10
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* selector, and the rest through the gate's verdicts and the command handler.
|
|
7
7
|
*/
|
|
8
8
|
import { mkdtempSync, realpathSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
12
12
|
import type {
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
ExtensionContext,
|
|
15
15
|
ToolCallEvent,
|
|
16
16
|
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import type { ApprovalLog, ApprovalRecord } from "../../lib/approval-log.js";
|
|
17
18
|
import type { AskDecision, GateHub, ToolGate } from "../../lib/gates.js";
|
|
18
19
|
import {
|
|
19
20
|
clearJailedTool,
|
|
@@ -65,7 +66,7 @@ interface Harness {
|
|
|
65
66
|
banner(): { systemPrompt: string } | undefined;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
function makeHarness(): Harness {
|
|
69
|
+
function makeHarness(audit?: ApprovalLog): Harness {
|
|
69
70
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-test-")));
|
|
70
71
|
const statuses: Harness["statuses"] = [];
|
|
71
72
|
const notes: Harness["notes"] = [];
|
|
@@ -116,7 +117,7 @@ function makeHarness(): Harness {
|
|
|
116
117
|
registerEntryRenderer() {},
|
|
117
118
|
} as unknown as ExtensionAPI;
|
|
118
119
|
|
|
119
|
-
registerPermission(pi, hub);
|
|
120
|
+
registerPermission(pi, hub, { audit });
|
|
120
121
|
const gate = captured[0];
|
|
121
122
|
if (!gate || !commandHandler) throw new Error("permission did not register");
|
|
122
123
|
|
|
@@ -154,6 +155,7 @@ interface AskVerdict {
|
|
|
154
155
|
request: {
|
|
155
156
|
details: readonly string[];
|
|
156
157
|
root: string;
|
|
158
|
+
audit?: ApprovalRecord;
|
|
157
159
|
present(ctx: ExtensionContext): Promise<AskDecision | undefined>;
|
|
158
160
|
apply(decision: AskDecision): void;
|
|
159
161
|
};
|
|
@@ -435,3 +437,136 @@ describe("self-jailed tools", () => {
|
|
|
435
437
|
expect(asAsk(await h.evaluate(customEvent("scratchpad"))).kind).toBe("ask");
|
|
436
438
|
});
|
|
437
439
|
});
|
|
440
|
+
|
|
441
|
+
describe("audit log", () => {
|
|
442
|
+
function makeAudit(): { audit: ApprovalLog; records: ApprovalRecord[] } {
|
|
443
|
+
const records: ApprovalRecord[] = [];
|
|
444
|
+
return {
|
|
445
|
+
records,
|
|
446
|
+
audit: {
|
|
447
|
+
dir: "/tmp/pi-perm-audit",
|
|
448
|
+
mode: "interesting",
|
|
449
|
+
active: true,
|
|
450
|
+
append: (record) => records.push(record),
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
it("attaches the decomposition to an ask verdict", async () => {
|
|
456
|
+
const { audit } = makeAudit();
|
|
457
|
+
const h = makeHarness(audit);
|
|
458
|
+
await h.command("allow allow allow ask");
|
|
459
|
+
const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x")));
|
|
460
|
+
expect(verdict.request.audit).toMatchObject({
|
|
461
|
+
modes: { outWrite: "ask" },
|
|
462
|
+
verdict: {
|
|
463
|
+
action: "ask",
|
|
464
|
+
layer: "mode",
|
|
465
|
+
target: "/tmp/outside/x",
|
|
466
|
+
scope: "out",
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
expect(
|
|
470
|
+
(verdict.request.audit?.units as Array<Record<string, unknown>>)[0],
|
|
471
|
+
).toMatchObject({
|
|
472
|
+
command: "rm",
|
|
473
|
+
opaque: false,
|
|
474
|
+
action: "ask",
|
|
475
|
+
layer: "mode",
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("attaches the deciding layer to a deny verdict", async () => {
|
|
480
|
+
const { audit } = makeAudit();
|
|
481
|
+
const h = makeHarness(audit);
|
|
482
|
+
await h.command("allow allow allow ask");
|
|
483
|
+
const verdict = (await h.evaluate(
|
|
484
|
+
bashEvent(`cat ${homedir()}/.ssh/id_rsa`),
|
|
485
|
+
)) as {
|
|
486
|
+
kind: "block";
|
|
487
|
+
reason: string;
|
|
488
|
+
audit?: ApprovalRecord;
|
|
489
|
+
};
|
|
490
|
+
expect(verdict.kind).toBe("block");
|
|
491
|
+
expect(verdict.audit?.verdict).toMatchObject({
|
|
492
|
+
action: "deny",
|
|
493
|
+
layer: "secret",
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("records mode changes with their source", async () => {
|
|
498
|
+
const { audit, records } = makeAudit();
|
|
499
|
+
const h = makeHarness(audit);
|
|
500
|
+
await h.command("allow allow allow ask");
|
|
501
|
+
expect(records.at(-1)).toMatchObject({
|
|
502
|
+
event: "mode",
|
|
503
|
+
enabled: true,
|
|
504
|
+
source: "args",
|
|
505
|
+
via: "manual",
|
|
506
|
+
modes: { inRead: "allow", outWrite: "ask" },
|
|
507
|
+
});
|
|
508
|
+
await h.command("");
|
|
509
|
+
expect(records.at(-1)).toMatchObject({
|
|
510
|
+
event: "mode",
|
|
511
|
+
enabled: false,
|
|
512
|
+
source: "toggle",
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("marks an autostart-dispatched mode change", async () => {
|
|
517
|
+
const { audit, records } = makeAudit();
|
|
518
|
+
const h = makeHarness(audit);
|
|
519
|
+
markStartupCommand("ask-perm");
|
|
520
|
+
await h.command("allow allow allow ask");
|
|
521
|
+
expect(records.at(-1)).toMatchObject({
|
|
522
|
+
event: "mode",
|
|
523
|
+
via: "autostart",
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe("audit log — session-grant allows", () => {
|
|
529
|
+
function makeAudit(): { audit: ApprovalLog; records: ApprovalRecord[] } {
|
|
530
|
+
const records: ApprovalRecord[] = [];
|
|
531
|
+
return {
|
|
532
|
+
records,
|
|
533
|
+
audit: {
|
|
534
|
+
dir: "/tmp/pi-perm-audit",
|
|
535
|
+
mode: "interesting",
|
|
536
|
+
active: true,
|
|
537
|
+
append: (record) => records.push(record),
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
it("records the repeat call a directory grant made free", async () => {
|
|
543
|
+
const { audit, records } = makeAudit();
|
|
544
|
+
const h = makeHarness(audit);
|
|
545
|
+
await h.command("allow allow allow ask");
|
|
546
|
+
const first = asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x")));
|
|
547
|
+
first.request.apply("allow-session");
|
|
548
|
+
expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
|
|
549
|
+
|
|
550
|
+
const allow = records.find((record) => record.event === "allow");
|
|
551
|
+
expect(allow).toMatchObject({
|
|
552
|
+
event: "allow",
|
|
553
|
+
verdict: { action: "allow", layer: "session-allow", scope: "out" },
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("records a program-level grant on an opaque command too", async () => {
|
|
558
|
+
const { audit, records } = makeAudit();
|
|
559
|
+
const h = makeHarness(audit);
|
|
560
|
+
await h.command("allow allow allow ask");
|
|
561
|
+
const command = 'cd "$DIR" && rm x';
|
|
562
|
+
const first = asAsk(await h.evaluate(bashEvent(command)));
|
|
563
|
+
first.request.apply("allow-session");
|
|
564
|
+
expect(await h.evaluate(bashEvent(command))).toBeUndefined();
|
|
565
|
+
|
|
566
|
+
const allow = records.find((record) => record.event === "allow");
|
|
567
|
+
expect(allow).toMatchObject({
|
|
568
|
+
event: "allow",
|
|
569
|
+
verdict: { action: "allow", layer: "session-command", grantScope: "command" },
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
});
|
|
@@ -22,6 +22,8 @@ import type {
|
|
|
22
22
|
ToolCallEvent,
|
|
23
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { Text } from "@earendil-works/pi-tui";
|
|
25
|
+
import type { ApprovalLog, ApprovalRecord } from "../../lib/approval-log.js";
|
|
26
|
+
import { auditBase, auditTool } from "../../lib/approval-log.js";
|
|
25
27
|
import type { AskRequest, GateHub } from "../../lib/gates.js";
|
|
26
28
|
import { isJailedTool } from "../../lib/tools.js";
|
|
27
29
|
import { consumeStartupCommand } from "../../lib/startup.js";
|
|
@@ -46,6 +48,7 @@ import {
|
|
|
46
48
|
} from "./paths.js";
|
|
47
49
|
import {
|
|
48
50
|
decideProgram,
|
|
51
|
+
decideUnit,
|
|
49
52
|
decisionEffect,
|
|
50
53
|
decisionGrantRoot,
|
|
51
54
|
formatDenial,
|
|
@@ -55,6 +58,7 @@ import {
|
|
|
55
58
|
import { askPermission } from "./prompt.js";
|
|
56
59
|
import { SessionRules } from "./session-rules.js";
|
|
57
60
|
import { parseCommandUnits, type CommandUnit } from "./units.js";
|
|
61
|
+
import { buildPermissionAudit, commandDigest } from "./audit.js";
|
|
58
62
|
|
|
59
63
|
/** Tools that read one path. */
|
|
60
64
|
const READ_PATH_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
@@ -123,7 +127,17 @@ interface NoticeData {
|
|
|
123
127
|
text: string;
|
|
124
128
|
}
|
|
125
129
|
|
|
126
|
-
export
|
|
130
|
+
export interface PermissionOptions {
|
|
131
|
+
/** Write-only audit sink; kept optional so tests need no filesystem. */
|
|
132
|
+
readonly audit?: ApprovalLog;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function registerPermission(
|
|
136
|
+
pi: ExtensionAPI,
|
|
137
|
+
gates: GateHub,
|
|
138
|
+
options: PermissionOptions = {},
|
|
139
|
+
): void {
|
|
140
|
+
const audit = options.audit;
|
|
127
141
|
pi.registerEntryRenderer?.(NOTICE_ENTRY, (entry, _options, theme) => {
|
|
128
142
|
const text = (entry.data as NoticeData | undefined)?.text;
|
|
129
143
|
return text === undefined
|
|
@@ -148,6 +162,22 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
148
162
|
rootsCache = undefined;
|
|
149
163
|
}
|
|
150
164
|
|
|
165
|
+
/** Record an on/off or mode change; never affects the state machine. */
|
|
166
|
+
function recordMode(
|
|
167
|
+
ctx: ExtensionContext,
|
|
168
|
+
state: { enabled: boolean; source: string; via: string },
|
|
169
|
+
): void {
|
|
170
|
+
if (!audit || audit.mode === "off") return;
|
|
171
|
+
audit.append({
|
|
172
|
+
...auditBase(ctx),
|
|
173
|
+
event: "mode",
|
|
174
|
+
enabled: state.enabled,
|
|
175
|
+
modes: { ...modes },
|
|
176
|
+
source: state.source,
|
|
177
|
+
via: state.via,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
151
181
|
/** Session id, or `undefined` when the host exposes none. */
|
|
152
182
|
function sessionIdOf(ctx: ExtensionContext): string | undefined {
|
|
153
183
|
try {
|
|
@@ -234,11 +264,13 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
234
264
|
decision: PolicyDecision,
|
|
235
265
|
fallbackCwd: string,
|
|
236
266
|
units: readonly CommandUnit[],
|
|
267
|
+
payload: ApprovalRecord | undefined,
|
|
237
268
|
): AskRequest {
|
|
238
269
|
const root = decisionGrantRoot(decision, () => fallbackCwd);
|
|
239
270
|
const effect = decisionEffect(decision);
|
|
240
271
|
const scope =
|
|
241
272
|
decision.target?.scope === "out" ? "outside project" : "inside project";
|
|
273
|
+
const title = "Permission required";
|
|
242
274
|
const details = [
|
|
243
275
|
`Command: ${decision.unit.raw || decision.unit.command}`,
|
|
244
276
|
...(decision.target
|
|
@@ -249,12 +281,16 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
249
281
|
? "Session grant would cover: this exact command"
|
|
250
282
|
: `Session grant would cover: ${root}`,
|
|
251
283
|
];
|
|
284
|
+
const auditPayload =
|
|
285
|
+
payload === undefined
|
|
286
|
+
? undefined
|
|
287
|
+
: { ...payload, prompt: [title, ...details] };
|
|
252
288
|
return {
|
|
253
|
-
title
|
|
289
|
+
title,
|
|
254
290
|
details,
|
|
255
291
|
root,
|
|
256
|
-
|
|
257
|
-
|
|
292
|
+
audit: auditPayload,
|
|
293
|
+
present: (ctx) => askPermission(ctx, { title, details }),
|
|
258
294
|
apply: (choice) => {
|
|
259
295
|
if (choice === "allow-session") {
|
|
260
296
|
if (decision.unit.opaque) rules.allowCommand(programKey(units));
|
|
@@ -273,6 +309,20 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
273
309
|
? `${decision.reason ?? "operation"} — denied for this session`
|
|
274
310
|
: (decision.reason ?? "denied by the operator"),
|
|
275
311
|
}),
|
|
312
|
+
settle: (choice) => {
|
|
313
|
+
if (choice !== "allow-session" && choice !== "deny-session") {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
const commandScope = decision.unit.opaque;
|
|
317
|
+
return {
|
|
318
|
+
grant: {
|
|
319
|
+
kind: choice === "allow-session" ? "allow" : "deny",
|
|
320
|
+
scope: commandScope ? "command" : "root",
|
|
321
|
+
effect,
|
|
322
|
+
...(commandScope ? { digest: commandDigest(units) } : { root }),
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
},
|
|
276
326
|
};
|
|
277
327
|
}
|
|
278
328
|
|
|
@@ -298,6 +348,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
298
348
|
handler: async (args, ctx) => {
|
|
299
349
|
// Startup notices are entries; interactive ones stay in-place toasts.
|
|
300
350
|
const startup = consumeStartupCommand("ask-perm");
|
|
351
|
+
const via = startup ? "autostart" : "manual";
|
|
301
352
|
const notice = (text: string): void => {
|
|
302
353
|
if (startup) pi.appendEntry<NoticeData>(NOTICE_ENTRY, { text });
|
|
303
354
|
else ctx.ui.notify(text, "info");
|
|
@@ -322,6 +373,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
322
373
|
if (changed) rules.clear();
|
|
323
374
|
enabled = true;
|
|
324
375
|
syncStatus(ctx);
|
|
376
|
+
recordMode(ctx, { enabled: true, source: "args", via });
|
|
325
377
|
notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
|
|
326
378
|
return;
|
|
327
379
|
}
|
|
@@ -330,6 +382,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
330
382
|
enabled = false;
|
|
331
383
|
rules.clear();
|
|
332
384
|
syncStatus(ctx);
|
|
385
|
+
recordMode(ctx, { enabled: false, source: "toggle", via });
|
|
333
386
|
notice("Permission OFF — no boundary checks in this session.");
|
|
334
387
|
return;
|
|
335
388
|
}
|
|
@@ -346,6 +399,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
346
399
|
rules.clear();
|
|
347
400
|
enabled = true;
|
|
348
401
|
syncStatus(ctx);
|
|
402
|
+
recordMode(ctx, { enabled: true, source: "panel", via });
|
|
349
403
|
notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
|
|
350
404
|
},
|
|
351
405
|
});
|
|
@@ -364,16 +418,58 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
364
418
|
if (isJailedTool(event.toolName)) return undefined;
|
|
365
419
|
const units = unitsFor(event, ctx.cwd);
|
|
366
420
|
if (units.length === 0) return undefined;
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (decision.action === "allow")
|
|
421
|
+
const roots = rootsFor(ctx);
|
|
422
|
+
const policyCtx = { modes, roots, rules };
|
|
423
|
+
const decision = decideProgram(units, policyCtx);
|
|
424
|
+
const logOn = audit !== undefined && audit.mode !== "off";
|
|
425
|
+
|
|
426
|
+
if (decision.action === "allow") {
|
|
427
|
+
// Session-grant allows show how much friction a grant removed; plain
|
|
428
|
+
// mode allows are logged only under `PI_APPROVAL_LOG=all`, otherwise
|
|
429
|
+
// their volume would bury the interesting records. A grant on an
|
|
430
|
+
// opaque program is keyed by its text (`session-command`), so both
|
|
431
|
+
// grant layers must be recorded — otherwise "allow for this session"
|
|
432
|
+
// on a command the parser cannot resolve would leave no trace.
|
|
433
|
+
const granted =
|
|
434
|
+
decision.layer === "session-allow" ||
|
|
435
|
+
decision.layer === "session-command";
|
|
436
|
+
if (logOn && (granted || audit?.mode === "all")) {
|
|
437
|
+
const payload = buildPermissionAudit({
|
|
438
|
+
cwd: ctx.cwd,
|
|
439
|
+
modes,
|
|
440
|
+
roots,
|
|
441
|
+
units,
|
|
442
|
+
unitDecisions: units.map((unit) => decideUnit(unit, policyCtx)),
|
|
443
|
+
decision,
|
|
444
|
+
});
|
|
445
|
+
audit?.append({
|
|
446
|
+
...auditBase(ctx),
|
|
447
|
+
...auditTool(event),
|
|
448
|
+
...payload,
|
|
449
|
+
event: "allow",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
let payload: ApprovalRecord | undefined;
|
|
456
|
+
if (logOn) {
|
|
457
|
+
payload = buildPermissionAudit({
|
|
458
|
+
cwd: ctx.cwd,
|
|
459
|
+
modes,
|
|
460
|
+
roots,
|
|
461
|
+
units,
|
|
462
|
+
unitDecisions: units.map((unit) => decideUnit(unit, policyCtx)),
|
|
463
|
+
decision,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
373
466
|
if (decision.action === "deny") {
|
|
374
|
-
return { kind: "block", reason: formatDenial(decision) };
|
|
467
|
+
return { kind: "block", reason: formatDenial(decision), audit: payload };
|
|
375
468
|
}
|
|
376
|
-
return {
|
|
469
|
+
return {
|
|
470
|
+
kind: "ask",
|
|
471
|
+
request: askRequest(decision, ctx.cwd, units, payload),
|
|
472
|
+
};
|
|
377
473
|
},
|
|
378
474
|
});
|
|
379
475
|
|
|
@@ -30,12 +30,31 @@ export interface PolicyTarget {
|
|
|
30
30
|
|
|
31
31
|
export interface PolicyDecision {
|
|
32
32
|
readonly action: PolicyAction;
|
|
33
|
+
/** Machine-readable reason class, for the audit log and tests. */
|
|
34
|
+
readonly layer: PolicyLayer;
|
|
33
35
|
readonly unit: CommandUnit;
|
|
34
36
|
readonly target?: PolicyTarget;
|
|
35
37
|
/** Human/model-facing explanation for ask and deny. */
|
|
36
38
|
readonly reason?: string;
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Which rule decided a unit: the four modes, one of the fixed tables, a session
|
|
43
|
+
* grant, or a parser fallback. Kept separate from the human-facing reason so
|
|
44
|
+
* the audit log can group decisions without parsing English.
|
|
45
|
+
*/
|
|
46
|
+
export type PolicyLayer =
|
|
47
|
+
| "mode"
|
|
48
|
+
| "secret"
|
|
49
|
+
| "session-allow"
|
|
50
|
+
| "session-deny"
|
|
51
|
+
| "session-command"
|
|
52
|
+
| "danger"
|
|
53
|
+
| "privileged"
|
|
54
|
+
| "opaque"
|
|
55
|
+
| "unresolved"
|
|
56
|
+
| "empty";
|
|
57
|
+
|
|
39
58
|
export interface PolicyContext {
|
|
40
59
|
readonly modes: PermissionModes;
|
|
41
60
|
/** Canonical session roots (working directory plus added directories). */
|
|
@@ -81,6 +100,7 @@ function decidePath(
|
|
|
81
100
|
if (secret !== undefined) {
|
|
82
101
|
return {
|
|
83
102
|
action: "deny",
|
|
103
|
+
layer: "secret",
|
|
84
104
|
unit,
|
|
85
105
|
target: { path: pathValue, effect, scope },
|
|
86
106
|
reason: `${secret} — denied by the fixed safety table`,
|
|
@@ -90,19 +110,23 @@ function decidePath(
|
|
|
90
110
|
if (ctx.rules.denies(effect, pathValue)) {
|
|
91
111
|
return {
|
|
92
112
|
action: "deny",
|
|
113
|
+
layer: "session-deny",
|
|
93
114
|
unit,
|
|
94
115
|
target,
|
|
95
116
|
reason: `${effect} under ${pathValue} was denied for this session`,
|
|
96
117
|
};
|
|
97
118
|
}
|
|
98
119
|
if (ctx.rules.allows(effect, pathValue)) {
|
|
99
|
-
return { action: "allow", unit, target };
|
|
120
|
+
return { action: "allow", layer: "session-allow", unit, target };
|
|
100
121
|
}
|
|
101
122
|
const mode = modeFor(ctx.modes, effect, scope);
|
|
102
|
-
if (mode === "allow")
|
|
123
|
+
if (mode === "allow") {
|
|
124
|
+
return { action: "allow", layer: "mode", unit, target };
|
|
125
|
+
}
|
|
103
126
|
if (mode === "deny") {
|
|
104
127
|
return {
|
|
105
128
|
action: "deny",
|
|
129
|
+
layer: "mode",
|
|
106
130
|
unit,
|
|
107
131
|
target,
|
|
108
132
|
reason: `${scope === "out" ? "outside-project" : "in-project"} ${effect} is set to deny`,
|
|
@@ -110,6 +134,7 @@ function decidePath(
|
|
|
110
134
|
}
|
|
111
135
|
return {
|
|
112
136
|
action: "ask",
|
|
137
|
+
layer: "mode",
|
|
113
138
|
unit,
|
|
114
139
|
target,
|
|
115
140
|
reason:
|
|
@@ -130,6 +155,7 @@ export function decideUnit(
|
|
|
130
155
|
// to grant.
|
|
131
156
|
return {
|
|
132
157
|
action: "ask",
|
|
158
|
+
layer: "opaque",
|
|
133
159
|
unit,
|
|
134
160
|
reason: unit.note ?? "this command could not be analysed",
|
|
135
161
|
};
|
|
@@ -149,6 +175,7 @@ export function decideUnit(
|
|
|
149
175
|
if (secret?.reason !== undefined) {
|
|
150
176
|
return {
|
|
151
177
|
action: "deny",
|
|
178
|
+
layer: "secret",
|
|
152
179
|
unit,
|
|
153
180
|
target: {
|
|
154
181
|
path: secret.target.path,
|
|
@@ -163,6 +190,7 @@ export function decideUnit(
|
|
|
163
190
|
if (baseSecret !== undefined) {
|
|
164
191
|
return {
|
|
165
192
|
action: "deny",
|
|
193
|
+
layer: "secret",
|
|
166
194
|
unit,
|
|
167
195
|
target: {
|
|
168
196
|
path: canonicalBase,
|
|
@@ -221,6 +249,7 @@ export function decideUnit(
|
|
|
221
249
|
}
|
|
222
250
|
return {
|
|
223
251
|
action: "ask",
|
|
252
|
+
layer: danger !== undefined ? "danger" : "privileged",
|
|
224
253
|
unit,
|
|
225
254
|
target,
|
|
226
255
|
reason: danger ?? "privileged command",
|
|
@@ -231,6 +260,7 @@ export function decideUnit(
|
|
|
231
260
|
if (canonicalBase === null) {
|
|
232
261
|
return {
|
|
233
262
|
action: "ask",
|
|
263
|
+
layer: "unresolved",
|
|
234
264
|
unit,
|
|
235
265
|
reason: "the working directory could not be resolved",
|
|
236
266
|
};
|
|
@@ -263,6 +293,7 @@ export function decideProgram(
|
|
|
263
293
|
if (units.length === 0) {
|
|
264
294
|
return {
|
|
265
295
|
action: "allow",
|
|
296
|
+
layer: "empty",
|
|
266
297
|
unit: {
|
|
267
298
|
raw: "",
|
|
268
299
|
headWord: "",
|
|
@@ -284,12 +315,17 @@ export function decideProgram(
|
|
|
284
315
|
if (ctx.rules.deniesCommand(key)) {
|
|
285
316
|
return {
|
|
286
317
|
action: "deny",
|
|
318
|
+
layer: "session-command",
|
|
287
319
|
unit: opaqueUnit,
|
|
288
320
|
reason: "this command was denied for this session",
|
|
289
321
|
};
|
|
290
322
|
}
|
|
291
323
|
if (ctx.rules.allowsCommand(key)) {
|
|
292
|
-
return {
|
|
324
|
+
return {
|
|
325
|
+
action: "allow",
|
|
326
|
+
layer: "session-command",
|
|
327
|
+
unit: opaqueUnit,
|
|
328
|
+
};
|
|
293
329
|
}
|
|
294
330
|
}
|
|
295
331
|
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* each other. The bus is installed once, after every gate is registered.
|
|
19
19
|
*/
|
|
20
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { createApprovalLog } from "./lib/approval-log.js";
|
|
21
22
|
import { createGateHub } from "./lib/gates.js";
|
|
22
23
|
import { registerAsk } from "./features/ask/index.js";
|
|
23
24
|
import { registerAutostart } from "./features/autostart/index.js";
|
|
@@ -28,12 +29,15 @@ import { registerReview } from "./features/review/index.js";
|
|
|
28
29
|
import { registerStash } from "./features/stash/index.js";
|
|
29
30
|
|
|
30
31
|
export default function piToolkits(pi: ExtensionAPI): void {
|
|
31
|
-
|
|
32
|
+
// One rolling audit sink for every gate. Creation is lazy and side-effect
|
|
33
|
+
// free: the directory is only touched when the first record is written.
|
|
34
|
+
const audit = createApprovalLog();
|
|
35
|
+
const gates = createGateHub({ audit });
|
|
32
36
|
|
|
33
37
|
registerAsk(pi, gates);
|
|
34
38
|
// Permission registers its session_start reset before autostart, so a
|
|
35
39
|
// project-declared `/ask-perm ...` runs against a freshly reset mode.
|
|
36
|
-
registerPermission(pi, gates);
|
|
40
|
+
registerPermission(pi, gates, { audit });
|
|
37
41
|
registerAutostart(pi);
|
|
38
42
|
registerCompact(pi);
|
|
39
43
|
registerFavorites(pi);
|