@gotgenes/pi-permission-system 3.9.0 → 3.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/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.10.0](https://github.com/gotgenes/pi-permission-system/compare/v3.9.0...v3.10.0) (2026-05-04)
9
+
10
+
11
+ ### Features
12
+
13
+ * migrate tool_call external_directory to SessionRules ([42c2bd9](https://github.com/gotgenes/pi-permission-system/commit/42c2bd91dbc35c6e4343133fb907f43a6a2550bf))
14
+ * remove SessionApprovalCache ([9d5a5be](https://github.com/gotgenes/pi-permission-system/commit/9d5a5be8251491a66b2826183ca22cbd5a232374))
15
+ * replace SessionApprovalCache with SessionRules in runtime ([4cec9c5](https://github.com/gotgenes/pi-permission-system/commit/4cec9c553779afa8a5fb62bf2ffbd35a43af3e23))
16
+
17
+
18
+ ### Documentation
19
+
20
+ * plan replace SessionApprovalCache with session Ruleset ([#57](https://github.com/gotgenes/pi-permission-system/issues/57)) ([ed1cefe](https://github.com/gotgenes/pi-permission-system/commit/ed1cefec2fd81542084460eb02cd3706b7093c07))
21
+ * **retro:** add retro notes for issue [#56](https://github.com/gotgenes/pi-permission-system/issues/56) ([f97f65c](https://github.com/gotgenes/pi-permission-system/commit/f97f65c448bd907866042bf9804378f441ae7c36))
22
+ * update session approval references ([#57](https://github.com/gotgenes/pi-permission-system/issues/57)) ([40e5e89](https://github.com/gotgenes/pi-permission-system/commit/40e5e89bf29b404b36fedaa48c896391d30574f6))
23
+
8
24
  ## [3.9.0](https://github.com/gotgenes/pi-permission-system/compare/v3.8.0...v3.9.0) (2026-05-03)
9
25
 
10
26
 
package/README.md CHANGED
@@ -529,7 +529,7 @@ This makes it easy to verify which files the extension actually loaded:
529
529
  index.ts → Root Pi entrypoint shim
530
530
  src/
531
531
  ├── index.ts → Extension bootstrap, permission checks, readable prompts, review logging, reload handling, and subagent forwarding
532
- ├── session-approval-cache.ts → Ephemeral session-scoped approval cache for external-directory access
532
+ ├── session-rules.ts → Ephemeral session-scoped approval rules (Ruleset-based, external-directory access)
533
533
  ├── config-loader.ts → Unified config loader, merger, and legacy-path detection
534
534
  ├── config-paths.ts → Path derivation for global, project, and legacy config locations
535
535
  ├── config-reporter.ts → Resolved config path reporting for diagnostic logs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -76,6 +76,6 @@ export async function handleSessionShutdown(deps: HandlerDeps): Promise<void> {
76
76
  deps.runtime.activeSkillEntries = [];
77
77
  deps.runtime.lastActiveToolsCacheKey = null;
78
78
  deps.runtime.lastPromptStateCacheKey = null;
79
- deps.runtime.sessionApprovalCache.clear();
79
+ deps.runtime.sessionRules.clear();
80
80
  deps.stopForwardedPermissionPolling();
81
81
  }
@@ -29,7 +29,8 @@ import {
29
29
  formatUnknownToolReason,
30
30
  formatUserDeniedReason,
31
31
  } from "../permission-prompts";
32
- import { deriveApprovalPrefix } from "../session-approval-cache";
32
+ import { evaluate } from "../rule";
33
+ import { deriveApprovalPattern } from "../session-rules";
33
34
  import { findSkillPathMatch } from "../skill-prompt-sanitizer";
34
35
  import { getPermissionLogContext } from "../tool-input-preview";
35
36
  import {
@@ -169,12 +170,15 @@ export async function handleToolCall(
169
170
  externalDirectoryPath,
170
171
  ctx.cwd,
171
172
  );
172
- const sessionPrefix = deps.runtime.sessionApprovalCache.findMatchingPrefix(
173
+ const sessionRuleset = deps.runtime.sessionRules.getRuleset();
174
+ const sessionMatch = evaluate(
173
175
  "external_directory",
174
176
  normalizedExtPath,
177
+ sessionRuleset,
175
178
  );
179
+ const isSessionApproved = sessionRuleset.includes(sessionMatch);
176
180
 
177
- if (sessionPrefix) {
181
+ if (isSessionApproved) {
178
182
  deps.runtime.writeReviewLog("permission_request.session_approved", {
179
183
  source: "tool_call",
180
184
  toolCallId: (event as { toolCallId: string }).toolCallId,
@@ -182,7 +186,7 @@ export async function handleToolCall(
182
186
  agentName,
183
187
  path: externalDirectoryPath,
184
188
  resolution: "session_approved",
185
- sessionApprovalPrefix: sessionPrefix,
189
+ sessionApprovalPattern: sessionMatch.pattern,
186
190
  });
187
191
  // Fall through to normal permission check
188
192
  } else {
@@ -245,8 +249,8 @@ export async function handleToolCall(
245
249
  }
246
250
 
247
251
  if (extDirDecision?.state === "approved_for_session") {
248
- const prefix = deriveApprovalPrefix(normalizedExtPath);
249
- deps.runtime.sessionApprovalCache.approve("external_directory", prefix);
252
+ const pattern = deriveApprovalPattern(normalizedExtPath);
253
+ deps.runtime.sessionRules.approve("external_directory", pattern);
250
254
  }
251
255
  }
252
256
  // Fall through to normal permission check
@@ -261,9 +265,12 @@ export async function handleToolCall(
261
265
  ctx.cwd,
262
266
  );
263
267
  if (externalPaths.length > 0) {
268
+ const bashSessionRuleset = deps.runtime.sessionRules.getRuleset();
264
269
  const uncoveredPaths = externalPaths.filter(
265
270
  (p) =>
266
- !deps.runtime.sessionApprovalCache.has("external_directory", p),
271
+ !bashSessionRuleset.includes(
272
+ evaluate("external_directory", p, bashSessionRuleset),
273
+ ),
267
274
  );
268
275
 
269
276
  if (uncoveredPaths.length === 0) {
@@ -339,11 +346,8 @@ export async function handleToolCall(
339
346
 
340
347
  if (bashExtDecision?.state === "approved_for_session") {
341
348
  for (const extPath of uncoveredPaths) {
342
- const prefix = deriveApprovalPrefix(extPath);
343
- deps.runtime.sessionApprovalCache.approve(
344
- "external_directory",
345
- prefix,
346
- );
349
+ const pattern = deriveApprovalPattern(extPath);
350
+ deps.runtime.sessionRules.approve("external_directory", pattern);
347
351
  }
348
352
  }
349
353
  }
package/src/runtime.ts CHANGED
@@ -44,7 +44,7 @@ import { createPermissionSystemLogger } from "./logging";
44
44
  import type { PermissionPromptDecision } from "./permission-dialog";
45
45
  import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
46
46
  import { PermissionManager } from "./permission-manager";
47
- import { SessionApprovalCache } from "./session-approval-cache";
47
+ import { SessionRules } from "./session-rules";
48
48
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
49
49
  import { syncPermissionSystemStatus } from "./status";
50
50
  import { isSubagentExecutionContext } from "./subagent-context";
@@ -78,7 +78,7 @@ export interface ExtensionRuntime {
78
78
  lastActiveToolsCacheKey: string | null;
79
79
  lastPromptStateCacheKey: string | null;
80
80
  lastConfigWarning: string | null;
81
- readonly sessionApprovalCache: SessionApprovalCache;
81
+ readonly sessionRules: SessionRules;
82
82
 
83
83
  // ── Forwarding polling state ───────────────────────────────────────────
84
84
  permissionForwardingContext: ExtensionContext | null;
@@ -432,7 +432,7 @@ export function createExtensionRuntime(options?: {
432
432
  lastActiveToolsCacheKey: null,
433
433
  lastPromptStateCacheKey: null,
434
434
  lastConfigWarning: null,
435
- sessionApprovalCache: new SessionApprovalCache(),
435
+ sessionRules: new SessionRules(),
436
436
  permissionForwardingContext: null,
437
437
  permissionForwardingTimer: null,
438
438
  isProcessingForwardedRequests: false,
@@ -0,0 +1,54 @@
1
+ import { dirname, sep } from "node:path";
2
+
3
+ import type { Ruleset } from "./rule";
4
+
5
+ /**
6
+ * Ephemeral in-memory store of session-scoped permission approvals.
7
+ *
8
+ * Each approval is stored as a `Rule` with `action: "allow"`, making the
9
+ * ruleset directly usable with `evaluate()` — no custom matching engine needed.
10
+ *
11
+ * Cleared on session_shutdown — never persisted to disk.
12
+ */
13
+ export class SessionRules {
14
+ private rules: Ruleset = [];
15
+
16
+ /** Record a wildcard pattern as approved for the given surface. */
17
+ approve(surface: string, pattern: string): void {
18
+ this.rules.push({ surface, pattern, action: "allow" });
19
+ }
20
+
21
+ /** Return a defensive copy of the current session ruleset. */
22
+ getRuleset(): Ruleset {
23
+ return [...this.rules];
24
+ }
25
+
26
+ /** Remove all session approvals. */
27
+ clear(): void {
28
+ this.rules = [];
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Derive the wildcard glob pattern to approve from a normalized path.
34
+ *
35
+ * Returns `<parent-dir>/*` so that `evaluate()` / `wildcardMatch()` matches
36
+ * all paths under the approved directory — identical semantics to the former
37
+ * `SessionApprovalCache` prefix matching, using the unified wildcard engine.
38
+ *
39
+ * For paths that already end with a separator (directories), the separator
40
+ * is treated as the directory boundary and `*` is appended directly.
41
+ */
42
+ export function deriveApprovalPattern(normalizedPath: string): string {
43
+ // If the path already ends with a separator, it's a directory — glob its contents.
44
+ if (normalizedPath.endsWith(sep)) {
45
+ return `${normalizedPath}*`;
46
+ }
47
+ const dir = dirname(normalizedPath);
48
+ if (dir === normalizedPath) {
49
+ // Root path — dirname('/') === '/'
50
+ return `${dir}*`;
51
+ }
52
+ const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
53
+ return `${prefix}*`;
54
+ }
@@ -74,12 +74,11 @@ function makeRuntime(
74
74
  lastActiveToolsCacheKey: null,
75
75
  lastPromptStateCacheKey: null,
76
76
  lastConfigWarning: null,
77
- sessionApprovalCache: {
77
+ sessionRules: {
78
78
  approve: vi.fn(),
79
- has: vi.fn(),
80
- findMatchingPrefix: vi.fn(),
79
+ getRuleset: vi.fn().mockReturnValue([]),
81
80
  clear: vi.fn(),
82
- } as unknown as ExtensionRuntime["sessionApprovalCache"],
81
+ } as unknown as ExtensionRuntime["sessionRules"],
83
82
  permissionForwardingContext: null,
84
83
  permissionForwardingTimer: null,
85
84
  isProcessingForwardedRequests: false,
@@ -53,12 +53,11 @@ function makeRuntime(
53
53
  lastActiveToolsCacheKey: null,
54
54
  lastPromptStateCacheKey: null,
55
55
  lastConfigWarning: null,
56
- sessionApprovalCache: {
56
+ sessionRules: {
57
57
  approve: vi.fn(),
58
- has: vi.fn(),
59
- findMatchingPrefix: vi.fn(),
58
+ getRuleset: vi.fn().mockReturnValue([]),
60
59
  clear: vi.fn(),
61
- } as unknown as ExtensionRuntime["sessionApprovalCache"],
60
+ } as unknown as ExtensionRuntime["sessionRules"],
62
61
  permissionForwardingContext: null,
63
62
  permissionForwardingTimer: null,
64
63
  isProcessingForwardedRequests: false,
@@ -8,7 +8,7 @@ import {
8
8
  import type { HandlerDeps } from "../../src/handlers/types";
9
9
  import type { PermissionManager } from "../../src/permission-manager";
10
10
  import type { ExtensionRuntime } from "../../src/runtime";
11
- import type { SessionApprovalCache } from "../../src/session-approval-cache";
11
+ import type { SessionRules } from "../../src/session-rules";
12
12
  import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
13
13
 
14
14
  // ── active-agent stub ──────────────────────────────────────────────────────
@@ -59,13 +59,12 @@ function makePermissionManager(
59
59
  };
60
60
  }
61
61
 
62
- function makeSessionApprovalCache(): SessionApprovalCache {
62
+ function makeSessionRules(): SessionRules {
63
63
  return {
64
64
  approve: vi.fn(),
65
- has: vi.fn().mockReturnValue(false),
66
- findMatchingPrefix: vi.fn().mockReturnValue(null),
65
+ getRuleset: vi.fn().mockReturnValue([]),
67
66
  clear: vi.fn(),
68
- } as unknown as SessionApprovalCache;
67
+ } as unknown as SessionRules;
69
68
  }
70
69
 
71
70
  function makeRuntime(
@@ -85,7 +84,7 @@ function makeRuntime(
85
84
  lastActiveToolsCacheKey: null,
86
85
  lastPromptStateCacheKey: null,
87
86
  lastConfigWarning: null,
88
- sessionApprovalCache: makeSessionApprovalCache(),
87
+ sessionRules: makeSessionRules(),
89
88
  permissionForwardingContext: null,
90
89
  permissionForwardingTimer: null,
91
90
  isProcessingForwardedRequests: false,
@@ -330,10 +329,10 @@ describe("handleSessionShutdown", () => {
330
329
  expect(deps.runtime.lastPromptStateCacheKey).toBeNull();
331
330
  });
332
331
 
333
- it("clears the session approval cache", async () => {
332
+ it("clears the session rules", async () => {
334
333
  const deps = makeDeps();
335
334
  await handleSessionShutdown(deps);
336
- expect(deps.runtime.sessionApprovalCache.clear).toHaveBeenCalledOnce();
335
+ expect(deps.runtime.sessionRules.clear).toHaveBeenCalledOnce();
337
336
  });
338
337
 
339
338
  it("stops forwarded permission polling", async () => {
@@ -74,12 +74,11 @@ function makeRuntime(
74
74
  lastActiveToolsCacheKey: null,
75
75
  lastPromptStateCacheKey: null,
76
76
  lastConfigWarning: null,
77
- sessionApprovalCache: {
77
+ sessionRules: {
78
78
  approve: vi.fn(),
79
- has: vi.fn().mockReturnValue(false),
80
- findMatchingPrefix: vi.fn().mockReturnValue(null),
79
+ getRuleset: vi.fn().mockReturnValue([]),
81
80
  clear: vi.fn(),
82
- } as unknown as ExtensionRuntime["sessionApprovalCache"],
81
+ } as unknown as ExtensionRuntime["sessionRules"],
83
82
  permissionForwardingContext: null,
84
83
  permissionForwardingTimer: null,
85
84
  isProcessingForwardedRequests: false,
@@ -339,12 +338,17 @@ describe("handleToolCall — external-directory gate", () => {
339
338
  it("allows when session has an existing approval for the external path", async () => {
340
339
  const deps = makeDeps({
341
340
  runtime: makeRuntime({
342
- sessionApprovalCache: {
341
+ sessionRules: {
343
342
  approve: vi.fn(),
344
- has: vi.fn().mockReturnValue(false),
345
- findMatchingPrefix: vi.fn().mockReturnValue("/outside/project/"),
343
+ getRuleset: vi.fn().mockReturnValue([
344
+ {
345
+ surface: "external_directory",
346
+ pattern: "/outside/project/*",
347
+ action: "allow",
348
+ },
349
+ ]),
346
350
  clear: vi.fn(),
347
- } as unknown as ExtensionRuntime["sessionApprovalCache"],
351
+ } as unknown as ExtensionRuntime["sessionRules"],
348
352
  }),
349
353
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
350
354
  });
@@ -359,18 +363,17 @@ describe("handleToolCall — external-directory gate", () => {
359
363
  });
360
364
 
361
365
  it("approves session when user selects approved_for_session", async () => {
362
- const approveCache = {
366
+ const sessionRules = {
363
367
  approve: vi.fn(),
364
- has: vi.fn().mockReturnValue(false),
365
- findMatchingPrefix: vi.fn().mockReturnValue(null),
368
+ getRuleset: vi.fn().mockReturnValue([]),
366
369
  clear: vi.fn(),
367
- } as unknown as ExtensionRuntime["sessionApprovalCache"];
370
+ } as unknown as ExtensionRuntime["sessionRules"];
368
371
  const deps = makeDeps({
369
372
  runtime: makeRuntime({
370
373
  permissionManager: {
371
374
  checkPermission: vi.fn().mockReturnValue(makePermissionResult("ask")),
372
375
  } as unknown as ExtensionRuntime["permissionManager"],
373
- sessionApprovalCache: approveCache,
376
+ sessionRules,
374
377
  }),
375
378
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
376
379
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
@@ -385,7 +388,7 @@ describe("handleToolCall — external-directory gate", () => {
385
388
  input: { path: "/outside/project/file.ts" },
386
389
  };
387
390
  await handleToolCall(deps, event, makeCtx());
388
- expect(approveCache.approve).toHaveBeenCalledWith(
391
+ expect(sessionRules.approve).toHaveBeenCalledWith(
389
392
  "external_directory",
390
393
  expect.any(String),
391
394
  );
@@ -419,13 +422,18 @@ describe("handleToolCall — bash external-directory gate", () => {
419
422
  it("skips bash external gate when all referenced paths are session-approved", async () => {
420
423
  const deps = makeDeps({
421
424
  runtime: makeRuntime({
422
- sessionApprovalCache: {
425
+ sessionRules: {
423
426
  approve: vi.fn(),
424
- // All paths are covered
425
- has: vi.fn().mockReturnValue(true),
426
- findMatchingPrefix: vi.fn().mockReturnValue(null),
427
+ // /outside/project/* covers /outside/project/file.ts
428
+ getRuleset: vi.fn().mockReturnValue([
429
+ {
430
+ surface: "external_directory",
431
+ pattern: "/outside/project/*",
432
+ action: "allow",
433
+ },
434
+ ]),
427
435
  clear: vi.fn(),
428
- } as unknown as ExtensionRuntime["sessionApprovalCache"],
436
+ } as unknown as ExtensionRuntime["sessionRules"],
429
437
  }),
430
438
  getAllTools: vi.fn().mockReturnValue([{ name: "bash" }]),
431
439
  });
@@ -68,8 +68,9 @@ vi.mock("../src/subagent-context", () => ({
68
68
  isSubagentExecutionContext: vi.fn().mockReturnValue(false),
69
69
  }));
70
70
 
71
- vi.mock("../src/session-approval-cache", () => ({
72
- SessionApprovalCache: vi.fn(),
71
+ vi.mock("../src/session-rules", () => ({
72
+ SessionRules: vi.fn(),
73
+ deriveApprovalPattern: vi.fn(),
73
74
  }));
74
75
 
75
76
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
@@ -184,9 +185,9 @@ describe("createExtensionRuntime", () => {
184
185
  expect(runtime.isProcessingForwardedRequests).toBe(false);
185
186
  });
186
187
 
187
- it("creates a sessionApprovalCache instance", () => {
188
+ it("creates a sessionRules instance", () => {
188
189
  const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
189
- expect(runtime.sessionApprovalCache).toBeDefined();
190
+ expect(runtime.sessionRules).toBeDefined();
190
191
  });
191
192
 
192
193
  // ── Mutable state is writable ──────────────────────────────────────────
@@ -0,0 +1,225 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { evaluate } from "../src/rule";
4
+ import { deriveApprovalPattern, SessionRules } from "../src/session-rules";
5
+
6
+ // ── SessionRules ───────────────────────────────────────────────────────────
7
+
8
+ describe("SessionRules", () => {
9
+ describe("getRuleset", () => {
10
+ it("returns an empty ruleset initially", () => {
11
+ const rules = new SessionRules();
12
+ expect(rules.getRuleset()).toEqual([]);
13
+ });
14
+
15
+ it("returns a ruleset containing approved rules", () => {
16
+ const rules = new SessionRules();
17
+ rules.approve("external_directory", "/other/project/*");
18
+ expect(rules.getRuleset()).toEqual([
19
+ {
20
+ surface: "external_directory",
21
+ pattern: "/other/project/*",
22
+ action: "allow",
23
+ },
24
+ ]);
25
+ });
26
+
27
+ it("returns a defensive copy — mutations do not affect internal state", () => {
28
+ const rules = new SessionRules();
29
+ rules.approve("external_directory", "/other/project/*");
30
+ const copy = rules.getRuleset();
31
+ copy.push({ surface: "bash", pattern: "*", action: "deny" });
32
+ expect(rules.getRuleset()).toHaveLength(1);
33
+ });
34
+
35
+ it("accumulates multiple approved patterns", () => {
36
+ const rules = new SessionRules();
37
+ rules.approve("external_directory", "/project-a/*");
38
+ rules.approve("external_directory", "/project-b/*");
39
+ expect(rules.getRuleset()).toHaveLength(2);
40
+ });
41
+ });
42
+
43
+ describe("clear", () => {
44
+ it("removes all session rules", () => {
45
+ const rules = new SessionRules();
46
+ rules.approve("external_directory", "/other/project/*");
47
+ rules.approve("external_directory", "/another/path/*");
48
+ rules.clear();
49
+ expect(rules.getRuleset()).toEqual([]);
50
+ });
51
+
52
+ it("allows new approvals after clearing", () => {
53
+ const rules = new SessionRules();
54
+ rules.approve("external_directory", "/old/path/*");
55
+ rules.clear();
56
+ rules.approve("external_directory", "/new/path/*");
57
+ expect(rules.getRuleset()).toHaveLength(1);
58
+ expect(rules.getRuleset()[0].pattern).toBe("/new/path/*");
59
+ });
60
+ });
61
+
62
+ describe("evaluate() integration", () => {
63
+ it("returns allow for a path under an approved directory", () => {
64
+ const session = new SessionRules();
65
+ session.approve("external_directory", "/other/project/*");
66
+ const result = evaluate(
67
+ "external_directory",
68
+ "/other/project/src/foo.ts",
69
+ session.getRuleset(),
70
+ );
71
+ expect(result.action).toBe("allow");
72
+ });
73
+
74
+ it("returns ask (default) for a path outside approved directories", () => {
75
+ const session = new SessionRules();
76
+ session.approve("external_directory", "/other/project/*");
77
+ const result = evaluate(
78
+ "external_directory",
79
+ "/other/unrelated/file.ts",
80
+ session.getRuleset(),
81
+ );
82
+ // No rule matches — evaluate returns synthetic rule with default action "ask"
83
+ expect(result.action).toBe("ask");
84
+ });
85
+
86
+ it("does not match a sibling directory that shares a string prefix", () => {
87
+ const session = new SessionRules();
88
+ session.approve("external_directory", "/other/project/*");
89
+ const result = evaluate(
90
+ "external_directory",
91
+ "/other/project-b/foo.ts",
92
+ session.getRuleset(),
93
+ );
94
+ expect(result.action).toBe("ask");
95
+ });
96
+
97
+ it("matches the directory itself (trailing slash)", () => {
98
+ const session = new SessionRules();
99
+ session.approve("external_directory", "/other/project/src/*");
100
+ // The * in wildcardMatch maps to .* which matches zero chars — so /src/ is covered.
101
+ const result = evaluate(
102
+ "external_directory",
103
+ "/other/project/src/",
104
+ session.getRuleset(),
105
+ );
106
+ expect(result.action).toBe("allow");
107
+ });
108
+
109
+ it("handles multiple approved directories", () => {
110
+ const session = new SessionRules();
111
+ session.approve("external_directory", "/project-a/*");
112
+ session.approve("external_directory", "/project-b/*");
113
+ expect(
114
+ evaluate(
115
+ "external_directory",
116
+ "/project-a/foo.ts",
117
+ session.getRuleset(),
118
+ ).action,
119
+ ).toBe("allow");
120
+ expect(
121
+ evaluate(
122
+ "external_directory",
123
+ "/project-b/bar.ts",
124
+ session.getRuleset(),
125
+ ).action,
126
+ ).toBe("allow");
127
+ expect(
128
+ evaluate(
129
+ "external_directory",
130
+ "/project-c/baz.ts",
131
+ session.getRuleset(),
132
+ ).action,
133
+ ).toBe("ask");
134
+ });
135
+
136
+ it("does not match a different surface", () => {
137
+ const session = new SessionRules();
138
+ session.approve("external_directory", "/other/project/*");
139
+ const result = evaluate(
140
+ "bash",
141
+ "/other/project/foo.ts",
142
+ session.getRuleset(),
143
+ );
144
+ expect(result.action).toBe("ask");
145
+ });
146
+
147
+ it("returns allow after clearing and re-approving", () => {
148
+ const session = new SessionRules();
149
+ session.approve("external_directory", "/old/project/*");
150
+ session.clear();
151
+ session.approve("external_directory", "/new/project/*");
152
+ expect(
153
+ evaluate(
154
+ "external_directory",
155
+ "/old/project/file.ts",
156
+ session.getRuleset(),
157
+ ).action,
158
+ ).toBe("ask");
159
+ expect(
160
+ evaluate(
161
+ "external_directory",
162
+ "/new/project/file.ts",
163
+ session.getRuleset(),
164
+ ).action,
165
+ ).toBe("allow");
166
+ });
167
+ });
168
+ });
169
+
170
+ // ── deriveApprovalPattern ──────────────────────────────────────────────────
171
+
172
+ describe("deriveApprovalPattern", () => {
173
+ it("returns parent directory glob for a file path", () => {
174
+ expect(deriveApprovalPattern("/other/project/src/foo.ts")).toBe(
175
+ "/other/project/src/*",
176
+ );
177
+ });
178
+
179
+ it("returns directory glob when path already ends with separator", () => {
180
+ expect(deriveApprovalPattern("/other/project/src/")).toBe(
181
+ "/other/project/src/*",
182
+ );
183
+ });
184
+
185
+ it("returns parent directory glob for a directory-like path without trailing separator", () => {
186
+ // Cannot distinguish dir from file — dirname is the safe choice
187
+ expect(deriveApprovalPattern("/other/project/src")).toBe(
188
+ "/other/project/*",
189
+ );
190
+ });
191
+
192
+ it("handles root path", () => {
193
+ expect(deriveApprovalPattern("/")).toBe("/*");
194
+ });
195
+
196
+ it("handles single-level path", () => {
197
+ expect(deriveApprovalPattern("/foo")).toBe("/*");
198
+ });
199
+
200
+ it("produces a pattern that matches paths under the approved directory", () => {
201
+ const pattern = deriveApprovalPattern("/other/project/src/foo.ts");
202
+ const session = new SessionRules();
203
+ session.approve("external_directory", pattern);
204
+ expect(
205
+ evaluate(
206
+ "external_directory",
207
+ "/other/project/src/bar.ts",
208
+ session.getRuleset(),
209
+ ).action,
210
+ ).toBe("allow");
211
+ });
212
+
213
+ it("produces a pattern that does not match sibling directories", () => {
214
+ const pattern = deriveApprovalPattern("/other/project/src/foo.ts");
215
+ const session = new SessionRules();
216
+ session.approve("external_directory", pattern);
217
+ expect(
218
+ evaluate(
219
+ "external_directory",
220
+ "/other/project/lib/bar.ts",
221
+ session.getRuleset(),
222
+ ).action,
223
+ ).toBe("ask");
224
+ });
225
+ });
@@ -1,81 +0,0 @@
1
- import { dirname, sep } from "node:path";
2
-
3
- import { isPathWithinDirectory } from "./external-directory";
4
-
5
- /**
6
- * Ephemeral in-memory cache of session-scoped permission approvals.
7
- * Keyed by permission surface (e.g. "external_directory"), values are
8
- * normalized directory prefixes that have been approved for the session.
9
- *
10
- * Cleared on session_shutdown — never persisted to disk.
11
- */
12
- export class SessionApprovalCache {
13
- private approvals = new Map<string, Set<string>>();
14
-
15
- /** Record a directory prefix as approved for the given surface. */
16
- approve(surface: string, prefix: string): void {
17
- let prefixes = this.approvals.get(surface);
18
- if (!prefixes) {
19
- prefixes = new Set();
20
- this.approvals.set(surface, prefixes);
21
- }
22
- prefixes.add(prefix);
23
- }
24
-
25
- /**
26
- * Check whether a path falls under any approved prefix for the given surface.
27
- * Uses `isPathWithinDirectory()` for correct separator-aware prefix matching.
28
- */
29
- has(surface: string, path: string): boolean {
30
- const prefixes = this.approvals.get(surface);
31
- if (!prefixes) {
32
- return false;
33
- }
34
- for (const prefix of prefixes) {
35
- if (isPathWithinDirectory(path, prefix)) {
36
- return true;
37
- }
38
- }
39
- return false;
40
- }
41
-
42
- /** Find and return the matching approved prefix, or null if none matches. */
43
- findMatchingPrefix(surface: string, path: string): string | null {
44
- const prefixes = this.approvals.get(surface);
45
- if (!prefixes) {
46
- return null;
47
- }
48
- for (const prefix of prefixes) {
49
- if (isPathWithinDirectory(path, prefix)) {
50
- return prefix;
51
- }
52
- }
53
- return null;
54
- }
55
-
56
- /** Remove all session approvals. */
57
- clear(): void {
58
- this.approvals.clear();
59
- }
60
- }
61
-
62
- /**
63
- * Derive the directory prefix to approve from a normalized path.
64
- * Returns `dirname(path)` with a trailing separator so that
65
- * prefix matching via `isPathWithinDirectory()` works correctly.
66
- *
67
- * For paths that already end with a separator (directories),
68
- * the trailing separator is stripped by dirname and re-added.
69
- */
70
- export function deriveApprovalPrefix(normalizedPath: string): string {
71
- // If the path already ends with a separator, it's a directory — return as-is.
72
- if (normalizedPath.endsWith(sep)) {
73
- return normalizedPath;
74
- }
75
- const dir = dirname(normalizedPath);
76
- if (dir === normalizedPath) {
77
- // Root path — dirname('/') === '/'
78
- return dir;
79
- }
80
- return dir.endsWith(sep) ? dir : `${dir}${sep}`;
81
- }
@@ -1,131 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
-
3
- // Mock node:os so tilde-expansion is deterministic across platforms.
4
- vi.mock("node:os", () => {
5
- const homedir = vi.fn(() => "/mock/home");
6
- return {
7
- homedir,
8
- default: { homedir },
9
- };
10
- });
11
-
12
- import {
13
- deriveApprovalPrefix,
14
- SessionApprovalCache,
15
- } from "../src/session-approval-cache";
16
-
17
- describe("SessionApprovalCache", () => {
18
- describe("approve and has", () => {
19
- it("returns false when no approvals exist", () => {
20
- const cache = new SessionApprovalCache();
21
- expect(cache.has("external_directory", "/some/path")).toBe(false);
22
- });
23
-
24
- it("returns true for a path under an approved prefix", () => {
25
- const cache = new SessionApprovalCache();
26
- cache.approve("external_directory", "/other/project/src/");
27
- expect(cache.has("external_directory", "/other/project/src/foo.ts")).toBe(
28
- true,
29
- );
30
- });
31
-
32
- it("returns true for the exact approved prefix path", () => {
33
- const cache = new SessionApprovalCache();
34
- cache.approve("external_directory", "/other/project/src/");
35
- expect(cache.has("external_directory", "/other/project/src/")).toBe(true);
36
- });
37
-
38
- it("returns false for a path outside the approved prefix", () => {
39
- const cache = new SessionApprovalCache();
40
- cache.approve("external_directory", "/other/project/src/");
41
- expect(cache.has("external_directory", "/other/project/lib/foo.ts")).toBe(
42
- false,
43
- );
44
- });
45
-
46
- it("returns false for a sibling directory that shares a string prefix", () => {
47
- const cache = new SessionApprovalCache();
48
- cache.approve("external_directory", "/other/project/");
49
- // /other/project-b/ should NOT match /other/project/
50
- expect(cache.has("external_directory", "/other/project-b/foo.ts")).toBe(
51
- false,
52
- );
53
- });
54
-
55
- it("handles multiple approved prefixes for the same surface", () => {
56
- const cache = new SessionApprovalCache();
57
- cache.approve("external_directory", "/other/project-a/");
58
- cache.approve("external_directory", "/other/project-b/");
59
- expect(cache.has("external_directory", "/other/project-a/foo.ts")).toBe(
60
- true,
61
- );
62
- expect(cache.has("external_directory", "/other/project-b/bar.ts")).toBe(
63
- true,
64
- );
65
- expect(cache.has("external_directory", "/other/project-c/baz.ts")).toBe(
66
- false,
67
- );
68
- });
69
-
70
- it("does not duplicate identical prefixes", () => {
71
- const cache = new SessionApprovalCache();
72
- cache.approve("external_directory", "/other/project/");
73
- cache.approve("external_directory", "/other/project/");
74
- // Set semantics — just verify it still works
75
- expect(cache.has("external_directory", "/other/project/foo.ts")).toBe(
76
- true,
77
- );
78
- });
79
- });
80
-
81
- describe("surface isolation", () => {
82
- it("does not match across different surface types", () => {
83
- const cache = new SessionApprovalCache();
84
- cache.approve("external_directory", "/other/project/");
85
- expect(cache.has("some_other_surface", "/other/project/foo.ts")).toBe(
86
- false,
87
- );
88
- });
89
- });
90
-
91
- describe("clear", () => {
92
- it("removes all approvals", () => {
93
- const cache = new SessionApprovalCache();
94
- cache.approve("external_directory", "/other/project/");
95
- cache.approve("some_surface", "/another/path/");
96
- cache.clear();
97
- expect(cache.has("external_directory", "/other/project/foo.ts")).toBe(
98
- false,
99
- );
100
- expect(cache.has("some_surface", "/another/path/file")).toBe(false);
101
- });
102
- });
103
- });
104
-
105
- describe("deriveApprovalPrefix", () => {
106
- it("returns parent directory with trailing separator for a file path", () => {
107
- expect(deriveApprovalPrefix("/other/project/src/foo.ts")).toBe(
108
- "/other/project/src/",
109
- );
110
- });
111
-
112
- it("returns the directory itself with trailing separator for a directory path", () => {
113
- expect(deriveApprovalPrefix("/other/project/src/")).toBe(
114
- "/other/project/src/",
115
- );
116
- });
117
-
118
- it("returns the directory itself when path has no trailing separator", () => {
119
- // For a path like /other/project/src (directory), dirname gives /other/project
120
- // but we can't distinguish dir from file without stat. dirname is the safe choice.
121
- expect(deriveApprovalPrefix("/other/project/src")).toBe("/other/project/");
122
- });
123
-
124
- it("handles root path", () => {
125
- expect(deriveApprovalPrefix("/")).toBe("/");
126
- });
127
-
128
- it("handles single-level path", () => {
129
- expect(deriveApprovalPrefix("/foo")).toBe("/");
130
- });
131
- });