@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.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/config-modal.ts +7 -10
- package/src/config-store.ts +243 -0
- package/src/index.ts +11 -15
- package/src/permission-manager.ts +69 -3
- package/src/permission-prompter.ts +3 -3
- package/src/permission-session.ts +13 -28
- package/src/runtime.ts +34 -203
- package/test/config-modal.test.ts +15 -10
- package/test/config-store.test.ts +452 -0
- package/test/handlers/external-directory-integration.test.ts +81 -176
- package/test/handlers/gates/bash-path.test.ts +26 -44
- package/test/handlers/gates/runner.test.ts +27 -119
- package/test/handlers/tool-call.test.ts +44 -153
- package/test/helpers/gate-fixtures.ts +66 -2
- package/test/helpers/handler-fixtures.ts +83 -2
- package/test/permission-manager-unified.test.ts +159 -1
- package/test/permission-prompter.test.ts +12 -5
- package/test/permission-session.test.ts +111 -120
- package/test/runtime.test.ts +11 -275
|
@@ -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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
config: makeConfigReader({ yoloMode: true }),
|
|
127
134
|
});
|
|
128
135
|
const prompter = new PermissionPrompter(deps);
|
|
129
136
|
|