@andares/pi-toolkits 0.9.7 → 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.
@@ -6,14 +6,15 @@
6
6
  * summaries produced by the configured compact model are formatted exactly
7
7
  * like pi's own compaction output. Only the executing model differs.
8
8
  * Verified byte-identical against every published version of the supported
9
- * window (1.0.0 → 1.3.0): dist/core/compaction/compaction.js and
10
- * core/usage-totals.js are unchanged files across that span, and the only
11
- * utils.js change (1.2.0 dropping assistant thinking from
9
+ * window (1.1.0 → 1.4.0): core/usage-totals.js is unchanged across that
10
+ * span, the only utils.js change (1.2.0 dropping assistant thinking from
12
11
  * serializeConversation) does not touch the strings mirrored here — that
13
- * serializer is imported from the host, not mirrored. 0.87.0 shipped a
14
- * different split-turn prefix prompt; the text mirrored here is 0.87.1's
15
- * fix, and the mirror is the only variant any supported host gets —
16
- * intentional.
12
+ * serializer is imported from the host, not mirrored — and compaction.js
13
+ * changed once, in 1.4.0, in the output budget and the split-turn focus
14
+ * handling (both adopted here, see buildTurnPrefixPromptText). 0.87.0
15
+ * shipped a different split-turn prefix prompt; the text mirrored here is
16
+ * 0.87.1's fix, and the mirror is the only variant any supported host gets
17
+ * — intentional.
17
18
  *
18
19
  * Update these strings whenever pi changes its built-in prompts (diff
19
20
  * dist/core/compaction/compaction.js between versions).
@@ -146,11 +147,21 @@ export function buildHistoryPromptText(
146
147
 
147
148
  /**
148
149
  * Build the split-turn prefix prompt text (pi 0.87.1
149
- * generateTurnPrefixSummary): `# Conversation` block followed by
150
- * `# Instructions`.
150
+ * generateTurnPrefixSummary, extended in 1.4.0): `# Conversation` block
151
+ * followed by `# Instructions`, with the `/compact <instructions>` focus
152
+ * appended as `Additional focus: ...`. 1.4.0 added that last line (#11058:
153
+ * the focus used to be dropped when the split turn was the only new
154
+ * content since the last compaction); we use the fixed form on every host.
151
155
  */
152
- export function buildTurnPrefixPromptText(conversationText: string): string {
153
- return `# Conversation\n${conversationText}\n\n# Instructions\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
156
+ export function buildTurnPrefixPromptText(
157
+ conversationText: string,
158
+ customInstructions?: string,
159
+ ): string {
160
+ let promptText = `# Conversation\n${conversationText}\n\n# Instructions\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
161
+ if (customInstructions) {
162
+ promptText = `${promptText}\n\nAdditional focus: ${customInstructions}`;
163
+ }
164
+ return promptText;
154
165
  }
155
166
 
156
167
  /** File-operation sets extracted by pi's prepareCompaction. */
@@ -203,7 +214,7 @@ export function formatFileOperations(
203
214
  /**
204
215
  * Combine two provider usage records field-wise (pi 0.87.1 compaction.js
205
216
  * combineUsage, moved verbatim to core/usage-totals.js in 0.99.0 and
206
- * unchanged through 1.3.0): used when
217
+ * unchanged through 1.4.0): used when
207
218
  * a split turn produces two summarization calls.
208
219
  */
209
220
  export function combineUsage<T extends UsageLike>(first: T, second: T): T {
@@ -8,8 +8,8 @@
8
8
  * bindings — only the cycle semantics change (patch-cycle.ts), so no
9
9
  * keybinding conflicts arise.
10
10
  *
11
- * Verified against every published version of the supported window
12
- * (1.0.0 → 1.3.0) keybinding
11
+ * Verified against every published version of the supported range
12
+ * (0.99.0 → 1.4.0) keybinding
13
13
  * tables + pi-tui input dispatch on WSL2 + Windows Terminal; re-run
14
14
  * `pnpm check:keybindings` after every host upgrade before trusting this.
15
15
  */
@@ -30,11 +30,12 @@ export const FAVORITES_FILE = "pi-toolkits-favorites.json";
30
30
  * Windows/WSL `tui.altScreen.search` resolves to ctrl+f, and
31
31
  * `TuiAltScreen.handleViewportInput()` checks it before
32
32
  * `shouldDeferViewportInputToOverlay()`, so the keystroke opens transcript
33
- * search and the selector never sees it (verified in pi-tui 1.3.0
33
+ * search and the selector never sees it (verified in pi-tui 1.4.0
34
34
  * `dist/tui-alt-screen.js`: the search check is the first key branch and
35
- * returns `{ consume: true }` unconditionally).
35
+ * returns `{ consume: true }` unconditionally; the 1.4.0 changes to that
36
+ * file are exit/teardown only).
36
37
  *
37
- * Why ctrl+alt+f was discarded: clean at the pi layer across 1.0–1.3 (only
38
+ * Why ctrl+alt+f was discarded: clean at the pi layer across 1.1–1.4 (only
38
39
  * ctrl+alt+] is taken from that family) and byte-distinguishable without
39
40
  * kitty — but grabbed at the OS/app layer on the author's machine by a
40
41
  * Windows screenshot tool, so it never reached the terminal. Lesson: a key
@@ -15,7 +15,8 @@
15
15
  * model available" feedback); otherwise jump to it
16
16
  * - the apply logic mirrors pi's _cycleScopedModel/_cycleAvailableModel
17
17
  * (byte-identical across every published version in the supported window
18
- * 1.0.0 → 1.3.0:
18
+ * 1.1.0 → 1.4.0 — `cycleModel` itself is untouched by 1.4.0's recovery and
19
+ * tool-restore rewrites:
19
20
  * state swap, session persistence, default-model persistence only when
20
21
  * `options.persist` is set, thinking-level re-clamp, model_select emit
21
22
  * with source "cycle")
@@ -31,9 +32,9 @@ import { getFavoritesStore, modelKey } from "./store.js";
31
32
 
32
33
  /**
33
34
  * pi's `Model<any>` — the exact type of `ModelCycleResult.model`
34
- * (pi-coding-agent 1.0–1.3; the field is unchanged across that span).
35
+ * (pi-coding-agent 1.1–1.4; the field is unchanged across that span).
35
36
  * Derived here instead of importing `Model` from @earendil-works/pi-ai,
36
- * whose index.d.ts (1.0 through 1.3.0 alike) re-exports it via an
37
+ * whose index.d.ts (1.1 through 1.4.0 alike) re-exports it via an
37
38
  * extensioned `"./types.ts"` specifier that not every TS resolver substitutes
38
39
  * to `types.d.ts` (tsc 5.9 bundler does; other tsserver builds don't).
39
40
  */
@@ -21,8 +21,8 @@
21
21
  * upgraded internals), patches are skipped with a warning — the extension
22
22
  * keeps loading and ctrl+p falls back to stock behavior. The prototype
23
23
  * shape touched here (handleInput/updateList/filterModels and the instance
24
- * fields below) is byte-identical across every published version in
25
- * pi 1.0.0 → 1.3.0 (and 0.99.0 → 1.2.0 before the floor moved).
24
+ * fields below) is byte-identical across the mandatory window
25
+ * pi 1.1.0 → 1.4.0 (and exercised by the 0.99.0 → 1.0.4 backward probes).
26
26
  */
27
27
  import {
28
28
  ModelSelectorComponent,
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Permission audit payload: the decomposition, the scope/effect of every
3
+ * target, and the machine-readable rule layer must survive into the log.
4
+ */
5
+ import { mkdtempSync, realpathSync } from "node:fs";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { describe, expect, it } from "vitest";
9
+ import { buildPermissionAudit, commandDigest } from "./audit.js";
10
+ import { DEFAULT_MODES, type PermissionModes } from "./constants.js";
11
+ import { canonicalize } from "./paths.js";
12
+ import {
13
+ decideProgram,
14
+ decideUnit,
15
+ type PolicyContext,
16
+ } from "./policy.js";
17
+ import { SessionRules } from "./session-rules.js";
18
+ import { parseCommandUnits } from "./units.js";
19
+
20
+ function project(): string {
21
+ return realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-audit-")));
22
+ }
23
+
24
+ function makeContext(
25
+ cwd: string,
26
+ modes: PermissionModes = DEFAULT_MODES,
27
+ rules = new SessionRules(),
28
+ ): PolicyContext {
29
+ return { modes, roots: [canonicalize(cwd)], rules };
30
+ }
31
+
32
+ function audit(command: string, ctx: PolicyContext, prompt?: readonly string[]) {
33
+ const units = parseCommandUnits(command, ctx.roots[0] ?? "/");
34
+ return buildPermissionAudit({
35
+ cwd: ctx.roots[0] ?? "/",
36
+ modes: ctx.modes,
37
+ roots: ctx.roots,
38
+ units,
39
+ unitDecisions: units.map((unit) => decideUnit(unit, ctx)),
40
+ decision: decideProgram(units, ctx),
41
+ ...(prompt === undefined ? {} : { prompt }),
42
+ });
43
+ }
44
+
45
+ describe("permission audit payload", () => {
46
+ it("records the decomposition, target scope and deciding layer", () => {
47
+ const cwd = project();
48
+ const payload = audit("rm -rf /tmp/outside/x", makeContext(cwd));
49
+
50
+ expect(payload.modes).toEqual({
51
+ inRead: "allow",
52
+ inWrite: "allow",
53
+ outRead: "allow",
54
+ outWrite: "ask",
55
+ });
56
+ expect(payload.roots).toEqual([canonicalize(cwd)]);
57
+ expect(payload.units).toEqual([
58
+ expect.objectContaining({
59
+ head: "rm",
60
+ command: "rm",
61
+ args: ["-rf", "/tmp/outside/x"],
62
+ base: canonicalize(cwd),
63
+ effect: "write",
64
+ opaque: false,
65
+ action: "ask",
66
+ layer: "mode",
67
+ targets: [
68
+ {
69
+ path: "/tmp/outside/x",
70
+ canonical: "/tmp/outside/x",
71
+ effect: "write",
72
+ source: "operand",
73
+ scope: "out",
74
+ },
75
+ ],
76
+ }),
77
+ ]);
78
+ expect(payload.verdict).toMatchObject({
79
+ action: "ask",
80
+ layer: "mode",
81
+ unit: 0,
82
+ target: "/tmp/outside/x",
83
+ effect: "write",
84
+ scope: "out",
85
+ grantScope: "root",
86
+ grantRoot: "/tmp/outside",
87
+ });
88
+ });
89
+
90
+ it("keeps the secret-table layer for a denied credential read", () => {
91
+ const cwd = project();
92
+ const payload = audit(`cat ${homedir()}/.ssh/id_rsa`, makeContext(cwd));
93
+ expect(payload.verdict).toMatchObject({
94
+ action: "deny",
95
+ layer: "secret",
96
+ scope: "out",
97
+ effect: "read",
98
+ });
99
+ expect(String((payload.verdict as Record<string, unknown>).reason)).toContain(
100
+ "fixed safety table",
101
+ );
102
+ });
103
+
104
+ it("marks an opaque command and records a command-scoped grant", () => {
105
+ const cwd = project();
106
+ const command = 'git -C "$DIR" log';
107
+ const ctx = makeContext(cwd);
108
+ const units = parseCommandUnits(command, cwd);
109
+ const payload = audit(command, ctx);
110
+
111
+ expect(units[0]?.opaque).toBe(true);
112
+ expect((payload.units as Array<Record<string, unknown>>)[0]).toMatchObject({
113
+ opaque: true,
114
+ action: "ask",
115
+ layer: "opaque",
116
+ });
117
+ expect(payload.verdict).toMatchObject({
118
+ action: "ask",
119
+ layer: "opaque",
120
+ grantScope: "command",
121
+ grantDigest: commandDigest(units),
122
+ });
123
+ expect(
124
+ (payload.verdict as Record<string, unknown>).grantRoot,
125
+ ).toBeUndefined();
126
+ });
127
+
128
+ it("records an in-project mode allow with its layer", () => {
129
+ const cwd = project();
130
+ const payload = audit(`rm -rf ${cwd}/build`, makeContext(cwd));
131
+ expect(payload.verdict).toMatchObject({
132
+ action: "allow",
133
+ layer: "mode",
134
+ scope: "in",
135
+ });
136
+ });
137
+
138
+ it("carries the exact prompt lines when asked", () => {
139
+ const cwd = project();
140
+ const payload = audit("rm -rf /tmp/outside/x", makeContext(cwd), [
141
+ "Permission required",
142
+ "Command: rm -rf /tmp/outside/x",
143
+ ]);
144
+ expect(payload.prompt).toEqual([
145
+ "Permission required",
146
+ "Command: rm -rf /tmp/outside/x",
147
+ ]);
148
+ });
149
+
150
+ it("clips long arguments but keeps the target", () => {
151
+ const cwd = project();
152
+ const longArg = `--flag=${"x".repeat(2000)}`;
153
+ const payload = audit(`rm -rf /tmp/outside/x ${longArg}`, makeContext(cwd));
154
+ const unit = (payload.units as Array<Record<string, unknown>>)[0];
155
+ const args = unit.args as string[];
156
+ expect(args[2]).toContain("[+");
157
+ expect(unit.targets).toHaveLength(1);
158
+ });
159
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * permission feature — the audit payload.
3
+ *
4
+ * The gate bus owns the record envelope (session, tool call, answer, outcome);
5
+ * this module contributes the part only the permission layer knows: the four
6
+ * modes and session roots in force, the bash decomposition, and which rule
7
+ * produced the verdict.
8
+ *
9
+ * The payload is deliberately lossless for *policy analysis* — every unit and
10
+ * every target survives, including the parser's `opaque` note — while being
11
+ * bounded: units, targets, args and long strings are capped, and the final
12
+ * serializer drops detail with a visible `truncated` marker rather than
13
+ * silently cutting a field. File contents never enter this payload.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import type { ApprovalRecord } from "../../lib/approval-log.js";
17
+ import { clipText } from "../../lib/approval-log.js";
18
+ import type { PermissionModes } from "./constants.js";
19
+ import { canonicalize, insideAnyRoot } from "./paths.js";
20
+ import {
21
+ decisionGrantRoot,
22
+ type PolicyDecision,
23
+ } from "./policy.js";
24
+ import type { CommandUnit } from "./units.js";
25
+
26
+ const MAX_UNITS = 32;
27
+ const MAX_TARGETS = 32;
28
+ const MAX_ARGS = 64;
29
+ const ARG_CHARS = 512;
30
+ const RAW_CHARS = 8192;
31
+ const PATH_CHARS = 1024;
32
+ const NOTE_CHARS = 512;
33
+ const REASON_CHARS = 2048;
34
+
35
+ export interface PermissionAuditInput {
36
+ readonly cwd: string;
37
+ readonly modes: PermissionModes;
38
+ /** Canonical session roots (working directory plus added directories). */
39
+ readonly roots: readonly string[];
40
+ readonly units: readonly CommandUnit[];
41
+ /** Verdict for each unit, in the same order as {@link units}. */
42
+ readonly unitDecisions: readonly PolicyDecision[];
43
+ /** Aggregate verdict (the strictest unit wins). */
44
+ readonly decision: PolicyDecision;
45
+ /** Exact dialog lines, when this payload belongs to an ask. */
46
+ readonly prompt?: readonly string[];
47
+ }
48
+
49
+ /** Short digest of the command text, for grouping opaque grants. */
50
+ export function commandDigest(units: readonly CommandUnit[]): string {
51
+ return createHash("sha256")
52
+ .update(units.map((unit) => unit.raw).join(" ; "))
53
+ .digest("hex")
54
+ .slice(0, 16);
55
+ }
56
+
57
+ function serializeUnit(
58
+ unit: CommandUnit,
59
+ decision: PolicyDecision | undefined,
60
+ roots: readonly string[],
61
+ ): ApprovalRecord {
62
+ const targets = unit.targets.slice(0, MAX_TARGETS).map((target) => {
63
+ const canonical = canonicalize(target.path);
64
+ return {
65
+ path: clipText(target.path, PATH_CHARS),
66
+ canonical: clipText(canonical, PATH_CHARS),
67
+ effect: target.effect,
68
+ source: target.source,
69
+ scope: insideAnyRoot(roots, canonical) ? "in" : "out",
70
+ };
71
+ });
72
+ return {
73
+ raw: clipText(unit.raw, RAW_CHARS),
74
+ head: unit.headWord,
75
+ command: unit.command,
76
+ args: unit.args.slice(0, MAX_ARGS).map((arg) => clipText(arg, ARG_CHARS)),
77
+ base: unit.base === null ? null : clipText(canonicalize(unit.base), PATH_CHARS),
78
+ effect: unit.effect,
79
+ opaque: unit.opaque,
80
+ ...(unit.note === undefined ? {} : { note: clipText(unit.note, NOTE_CHARS) }),
81
+ ...(decision === undefined
82
+ ? {}
83
+ : { action: decision.action, layer: decision.layer }),
84
+ targets,
85
+ };
86
+ }
87
+
88
+ function serializeVerdict(
89
+ input: PermissionAuditInput,
90
+ ): ApprovalRecord {
91
+ const decision = input.decision;
92
+ const unitIndex = input.units.indexOf(decision.unit);
93
+ const target = decision.target;
94
+ const grantScope = decision.unit.opaque ? "command" : "root";
95
+ return {
96
+ action: decision.action,
97
+ layer: decision.layer,
98
+ ...(unitIndex < 0 ? {} : { unit: unitIndex }),
99
+ ...(target === undefined
100
+ ? {}
101
+ : {
102
+ target: clipText(target.path, PATH_CHARS),
103
+ effect: target.effect,
104
+ scope: target.scope,
105
+ }),
106
+ grantScope,
107
+ ...(grantScope === "root"
108
+ ? {
109
+ grantRoot: clipText(
110
+ decisionGrantRoot(decision, () => input.cwd),
111
+ PATH_CHARS,
112
+ ),
113
+ }
114
+ : { grantDigest: commandDigest(input.units) }),
115
+ ...(decision.reason === undefined
116
+ ? {}
117
+ : { reason: clipText(decision.reason, REASON_CHARS) }),
118
+ };
119
+ }
120
+
121
+ /** Structured audit fields for one permission decision. */
122
+ export function buildPermissionAudit(
123
+ input: PermissionAuditInput,
124
+ ): ApprovalRecord {
125
+ const unitDecisions = input.unitDecisions;
126
+ return {
127
+ modes: {
128
+ inRead: input.modes.inRead,
129
+ inWrite: input.modes.inWrite,
130
+ outRead: input.modes.outRead,
131
+ outWrite: input.modes.outWrite,
132
+ },
133
+ roots: input.roots
134
+ .slice(0, 16)
135
+ .map((root) => clipText(root, PATH_CHARS)),
136
+ units: input.units
137
+ .slice(0, MAX_UNITS)
138
+ .map((unit, index) => serializeUnit(unit, unitDecisions[index], input.roots)),
139
+ ...(input.units.length > MAX_UNITS
140
+ ? { unitsOmitted: input.units.length - MAX_UNITS }
141
+ : {}),
142
+ verdict: serializeVerdict(input),
143
+ ...(input.prompt === undefined ? {} : { prompt: [...input.prompt] }),
144
+ };
145
+ }
@@ -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
+ });