@gotgenes/pi-permission-system 10.1.0 → 10.3.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.
@@ -30,7 +30,7 @@ import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
30
30
  import type { SessionLogger } from "#src/session-logger";
31
31
  import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
32
32
  import type { ToolRegistry } from "#src/tool-registry";
33
- import type { PermissionCheckResult } from "#src/types";
33
+ import type { PermissionCheckResult, PermissionState } from "#src/types";
34
34
 
35
35
  /**
36
36
  * Precise mock boundary for PermissionGateHandler integration tests.
@@ -220,6 +220,78 @@ export function makeToolRegistry(
220
220
  };
221
221
  }
222
222
 
223
+ /**
224
+ * Surface-dispatching `checkPermission` mock.
225
+ *
226
+ * Builds a `vi.fn()` that returns a `PermissionCheckResult` for each surface,
227
+ * using `bySurface[surface]` when matched and `defaultResult` otherwise.
228
+ * Default fields: `toolName` = the surface string, `source: "tool"`,
229
+ * `origin: "builtin"` — callers override by including the field in the
230
+ * per-surface or default partial (e.g. `{ path: { state: "allow", source: "special" } }`).
231
+ *
232
+ * Return type is intentionally unannotated so callers retain full `vi.fn()`
233
+ * mock access (`mock.calls`, `toHaveBeenCalledWith`, etc.).
234
+ */
235
+ export function makeSurfaceCheck(
236
+ bySurface: Record<
237
+ string,
238
+ Partial<PermissionCheckResult> & { state: PermissionState }
239
+ >,
240
+ defaultResult: Partial<PermissionCheckResult> & { state: PermissionState } = {
241
+ state: "allow",
242
+ },
243
+ ) {
244
+ return vi
245
+ .fn<MockGateHandlerSession["checkPermission"]>()
246
+ .mockImplementation((surface): PermissionCheckResult => {
247
+ const base = bySurface[surface] ?? defaultResult;
248
+ return {
249
+ toolName: surface,
250
+ source: "tool",
251
+ origin: "builtin",
252
+ ...base,
253
+ };
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Bash-surface `checkPermission` mock that dispatches on a command regex.
259
+ *
260
+ * For the `bash` surface: returns a deny result when `opts.deny` matches the
261
+ * command, and an allow result otherwise. For all other surfaces, returns a
262
+ * plain allow result.
263
+ *
264
+ * Return type is intentionally unannotated so callers retain full `vi.fn()`
265
+ * mock access.
266
+ */
267
+ export function makeBashCommandCheck(opts: {
268
+ deny: RegExp;
269
+ denyMatched: string;
270
+ allowMatched?: string;
271
+ }) {
272
+ return vi
273
+ .fn<MockGateHandlerSession["checkPermission"]>()
274
+ .mockImplementation((surface, input): PermissionCheckResult => {
275
+ if (surface === "bash") {
276
+ const command = (input as { command?: string }).command ?? "";
277
+ return opts.deny.test(command)
278
+ ? makeCheckResult({
279
+ state: "deny",
280
+ source: "bash",
281
+ command,
282
+ matchedPattern: opts.denyMatched,
283
+ })
284
+ : makeCheckResult({
285
+ state: "allow",
286
+ source: "bash",
287
+ command,
288
+ matchedPattern: opts.allowMatched,
289
+ });
290
+ }
291
+ return makeCheckResult({ state: "allow" });
292
+ });
293
+ }
294
+
223
295
  /**
224
296
  * Constructs a PermissionGateHandler with mocked collaborators.
225
297
  *
@@ -229,10 +301,19 @@ export function makeToolRegistry(
229
301
  export function makeHandler(overrides?: {
230
302
  session?: Partial<MockGateHandlerSession>;
231
303
  toolRegistry?: Partial<ToolRegistry>;
304
+ /** Sugar: builds the `getAll` mock from a list of tool names. */
305
+ tools?: string[];
232
306
  }) {
233
307
  const session = makeSession(overrides?.session);
234
308
  const events = makeEvents();
235
- const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
309
+ const toolRegistry =
310
+ overrides?.tools !== undefined
311
+ ? makeToolRegistry({
312
+ getAll: vi
313
+ .fn()
314
+ .mockReturnValue(overrides.tools.map((name) => ({ name }))),
315
+ })
316
+ : makeToolRegistry(overrides?.toolRegistry);
236
317
  const pipeline = new ToolCallGatePipeline(session);
237
318
  const skillInputPipeline = new SkillInputGatePipeline(session);
238
319
  const reporter = new GateDecisionReporter(session.logger, events);
@@ -8,7 +8,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { homedir, tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import { describe, expect, it } from "vitest";
11
- import { PermissionManager } from "#src/permission-manager";
11
+ import { getGlobalConfigPath, getProjectConfigPath } from "#src/config-paths";
12
+ import {
13
+ PermissionManager,
14
+ type ScopedPermissionManager,
15
+ } from "#src/permission-manager";
12
16
  import type { Rule, Ruleset } from "#src/rule";
13
17
 
14
18
  // ---------------------------------------------------------------------------
@@ -1349,3 +1353,157 @@ describe("cross-cutting path surface", () => {
1349
1353
  }
1350
1354
  });
1351
1355
  });
1356
+
1357
+ // ---------------------------------------------------------------------------
1358
+ // configureForCwd and agentDir construction
1359
+ // ---------------------------------------------------------------------------
1360
+
1361
+ describe("PermissionManager — configureForCwd and agentDir option", () => {
1362
+ /**
1363
+ * Build a temp agentDir with a global config and an optional cwd with a
1364
+ * project config. Returns the paths and a cleanup function.
1365
+ */
1366
+ function makeAgentDirSetup(opts: {
1367
+ globalPermission: Record<string, unknown>;
1368
+ projectPermission?: Record<string, unknown>;
1369
+ }): {
1370
+ agentDir: string;
1371
+ cwd: string;
1372
+ globalConfigPath: string;
1373
+ projectConfigPath: string;
1374
+ cleanup: () => void;
1375
+ } {
1376
+ const baseDir = mkdtempSync(join(tmpdir(), "pm-agent-dir-test-"));
1377
+ const agentDir = join(baseDir, "agent");
1378
+ const cwd = join(baseDir, "project");
1379
+
1380
+ // Write global config under getGlobalConfigPath(agentDir)
1381
+ const globalConfigPath = getGlobalConfigPath(agentDir);
1382
+ mkdirSync(join(agentDir, "extensions", "pi-permission-system"), {
1383
+ recursive: true,
1384
+ });
1385
+ writeFileSync(
1386
+ globalConfigPath,
1387
+ JSON.stringify({ permission: opts.globalPermission }, null, 2),
1388
+ );
1389
+
1390
+ // Write project config under getProjectConfigPath(cwd)
1391
+ const projectConfigPath = getProjectConfigPath(cwd);
1392
+ mkdirSync(join(cwd, ".pi", "extensions", "pi-permission-system"), {
1393
+ recursive: true,
1394
+ });
1395
+ if (opts.projectPermission) {
1396
+ writeFileSync(
1397
+ projectConfigPath,
1398
+ JSON.stringify({ permission: opts.projectPermission }, null, 2),
1399
+ );
1400
+ }
1401
+
1402
+ return {
1403
+ agentDir,
1404
+ cwd,
1405
+ globalConfigPath,
1406
+ projectConfigPath,
1407
+ cleanup: () => rmSync(baseDir, { recursive: true, force: true }),
1408
+ };
1409
+ }
1410
+
1411
+ it("ScopedPermissionManager is exported and PermissionManager satisfies it", () => {
1412
+ // Type-level assertion: assigning PermissionManager to ScopedPermissionManager compiles.
1413
+ const manager = new PermissionManager({
1414
+ globalConfigPath: "/nonexistent/config.json",
1415
+ agentsDir: "/nonexistent/agents",
1416
+ });
1417
+ const scoped: ScopedPermissionManager = manager;
1418
+ expect(typeof scoped.configureForCwd).toBe("function");
1419
+ expect(typeof scoped.checkPermission).toBe("function");
1420
+ expect(typeof scoped.getToolPermission).toBe("function");
1421
+ expect(typeof scoped.getConfigIssues).toBe("function");
1422
+ expect(typeof scoped.getPolicyCacheStamp).toBe("function");
1423
+ });
1424
+
1425
+ it("construction with { agentDir } reads global config from getGlobalConfigPath(agentDir)", () => {
1426
+ const { agentDir, cleanup } = makeAgentDirSetup({
1427
+ globalPermission: { read: "deny" },
1428
+ });
1429
+ try {
1430
+ const manager = new PermissionManager({ agentDir });
1431
+ const result = manager.checkPermission("read", { path: "foo.txt" });
1432
+ expect(result.state).toBe("deny");
1433
+ } finally {
1434
+ cleanup();
1435
+ }
1436
+ });
1437
+
1438
+ it("configureForCwd(cwd) applies project config (project overrides global)", () => {
1439
+ const { agentDir, cwd, cleanup } = makeAgentDirSetup({
1440
+ globalPermission: { read: "deny" },
1441
+ projectPermission: { read: "allow" },
1442
+ });
1443
+ try {
1444
+ const manager = new PermissionManager({ agentDir });
1445
+ // Before configureForCwd: global policy applies
1446
+ expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1447
+ "deny",
1448
+ );
1449
+
1450
+ manager.configureForCwd(cwd);
1451
+
1452
+ // After configureForCwd: project override applies (last-match-wins)
1453
+ expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1454
+ "allow",
1455
+ );
1456
+ } finally {
1457
+ cleanup();
1458
+ }
1459
+ });
1460
+
1461
+ it("configureForCwd(undefined) reverts to global-only", () => {
1462
+ const { agentDir, cwd, cleanup } = makeAgentDirSetup({
1463
+ globalPermission: { read: "deny" },
1464
+ projectPermission: { read: "allow" },
1465
+ });
1466
+ try {
1467
+ const manager = new PermissionManager({ agentDir });
1468
+ manager.configureForCwd(cwd);
1469
+ expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1470
+ "allow",
1471
+ );
1472
+
1473
+ manager.configureForCwd(undefined);
1474
+
1475
+ // After reverting: global policy applies again
1476
+ expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1477
+ "deny",
1478
+ );
1479
+ } finally {
1480
+ cleanup();
1481
+ }
1482
+ });
1483
+
1484
+ it("configureForCwd clears the resolved-permissions cache", () => {
1485
+ const { agentDir, globalConfigPath, cleanup } = makeAgentDirSetup({
1486
+ globalPermission: { read: "allow" },
1487
+ });
1488
+ try {
1489
+ const manager = new PermissionManager({ agentDir });
1490
+ // Warm the cache
1491
+ expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1492
+ "allow",
1493
+ );
1494
+ // Update global config on disk to deny read
1495
+ writeFileSync(
1496
+ globalConfigPath,
1497
+ JSON.stringify({ permission: { read: "deny" } }, null, 2),
1498
+ );
1499
+ // configureForCwd clears cache + rebuilds loader
1500
+ manager.configureForCwd(undefined);
1501
+ // Should pick up the changed global config
1502
+ expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
1503
+ "deny",
1504
+ );
1505
+ } finally {
1506
+ cleanup();
1507
+ }
1508
+ });
1509
+ });
@@ -7,6 +7,7 @@ const mockRequestApproval = vi.fn();
7
7
  // ── Imports ─────────────────────────────────────────────────────────────────
8
8
 
9
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type { ConfigReader } from "#src/config-store";
10
11
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
11
12
  import type { PermissionPromptDecision } from "#src/permission-dialog";
12
13
  import type { PromptPermissionDetails } from "#src/permission-prompter";
@@ -38,11 +39,17 @@ function makeDetails(
38
39
  };
39
40
  }
40
41
 
42
+ function makeConfigReader(
43
+ config: Partial<typeof DEFAULT_EXTENSION_CONFIG> = {},
44
+ ): ConfigReader {
45
+ return { current: () => ({ ...DEFAULT_EXTENSION_CONFIG, ...config }) };
46
+ }
47
+
41
48
  function makeDeps(
42
49
  overrides?: Partial<PermissionPrompterDeps>,
43
50
  ): PermissionPrompterDeps {
44
51
  return {
45
- getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: false }),
52
+ config: makeConfigReader(),
46
53
  writeReviewLog: vi.fn(),
47
54
  events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
48
55
  forwarder: { requestApproval: mockRequestApproval },
@@ -70,7 +77,7 @@ describe("PermissionPrompter", () => {
70
77
  on: vi.fn().mockReturnValue(() => undefined),
71
78
  };
72
79
  const deps = makeDeps({
73
- getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
80
+ config: makeConfigReader({ yoloMode: true }),
74
81
  events,
75
82
  });
76
83
  const prompter = new PermissionPrompter(deps);
@@ -92,7 +99,7 @@ describe("PermissionPrompter", () => {
92
99
  it("logs permission_request.auto_approved in yolo mode", async () => {
93
100
  const writeReviewLog = vi.fn();
94
101
  const deps = makeDeps({
95
- getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
102
+ config: makeConfigReader({ yoloMode: true }),
96
103
  writeReviewLog,
97
104
  });
98
105
  const prompter = new PermissionPrompter(deps);
@@ -108,7 +115,7 @@ describe("PermissionPrompter", () => {
108
115
  it("does not log permission_request.waiting in yolo mode", async () => {
109
116
  const writeReviewLog = vi.fn();
110
117
  const deps = makeDeps({
111
- getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
118
+ config: makeConfigReader({ yoloMode: true }),
112
119
  writeReviewLog,
113
120
  });
114
121
  const prompter = new PermissionPrompter(deps);
@@ -123,7 +130,7 @@ describe("PermissionPrompter", () => {
123
130
 
124
131
  it("does not call confirmPermission with yoloMode even when ctx has UI", async () => {
125
132
  const deps = makeDeps({
126
- getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
133
+ config: makeConfigReader({ yoloMode: true }),
127
134
  });
128
135
  const prompter = new PermissionPrompter(deps);
129
136