@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 +16 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/handlers/lifecycle.ts +1 -1
- package/src/handlers/tool-call.ts +16 -12
- package/src/runtime.ts +3 -3
- package/src/session-rules.ts +54 -0
- package/tests/handlers/before-agent-start.test.ts +3 -4
- package/tests/handlers/input.test.ts +3 -4
- package/tests/handlers/lifecycle.test.ts +7 -8
- package/tests/handlers/tool-call.test.ts +27 -19
- package/tests/runtime.test.ts +5 -4
- package/tests/session-rules.test.ts +225 -0
- package/src/session-approval-cache.ts +0 -81
- package/tests/session-approval-cache.test.ts +0 -131
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-
|
|
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
|
@@ -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.
|
|
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 {
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
249
|
-
deps.runtime.
|
|
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
|
-
!
|
|
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
|
|
343
|
-
deps.runtime.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
77
|
+
sessionRules: {
|
|
78
78
|
approve: vi.fn(),
|
|
79
|
-
|
|
80
|
-
findMatchingPrefix: vi.fn(),
|
|
79
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
81
80
|
clear: vi.fn(),
|
|
82
|
-
} as unknown as ExtensionRuntime["
|
|
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
|
-
|
|
56
|
+
sessionRules: {
|
|
57
57
|
approve: vi.fn(),
|
|
58
|
-
|
|
59
|
-
findMatchingPrefix: vi.fn(),
|
|
58
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
60
59
|
clear: vi.fn(),
|
|
61
|
-
} as unknown as ExtensionRuntime["
|
|
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 {
|
|
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
|
|
62
|
+
function makeSessionRules(): SessionRules {
|
|
63
63
|
return {
|
|
64
64
|
approve: vi.fn(),
|
|
65
|
-
|
|
66
|
-
findMatchingPrefix: vi.fn().mockReturnValue(null),
|
|
65
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
67
66
|
clear: vi.fn(),
|
|
68
|
-
} as unknown as
|
|
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
|
-
|
|
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
|
|
332
|
+
it("clears the session rules", async () => {
|
|
334
333
|
const deps = makeDeps();
|
|
335
334
|
await handleSessionShutdown(deps);
|
|
336
|
-
expect(deps.runtime.
|
|
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
|
-
|
|
77
|
+
sessionRules: {
|
|
78
78
|
approve: vi.fn(),
|
|
79
|
-
|
|
80
|
-
findMatchingPrefix: vi.fn().mockReturnValue(null),
|
|
79
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
81
80
|
clear: vi.fn(),
|
|
82
|
-
} as unknown as ExtensionRuntime["
|
|
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
|
-
|
|
341
|
+
sessionRules: {
|
|
343
342
|
approve: vi.fn(),
|
|
344
|
-
|
|
345
|
-
|
|
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["
|
|
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
|
|
366
|
+
const sessionRules = {
|
|
363
367
|
approve: vi.fn(),
|
|
364
|
-
|
|
365
|
-
findMatchingPrefix: vi.fn().mockReturnValue(null),
|
|
368
|
+
getRuleset: vi.fn().mockReturnValue([]),
|
|
366
369
|
clear: vi.fn(),
|
|
367
|
-
} as unknown as ExtensionRuntime["
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
425
|
+
sessionRules: {
|
|
423
426
|
approve: vi.fn(),
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
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["
|
|
436
|
+
} as unknown as ExtensionRuntime["sessionRules"],
|
|
429
437
|
}),
|
|
430
438
|
getAllTools: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
431
439
|
});
|
package/tests/runtime.test.ts
CHANGED
|
@@ -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-
|
|
72
|
-
|
|
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
|
|
188
|
+
it("creates a sessionRules instance", () => {
|
|
188
189
|
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
189
|
-
expect(runtime.
|
|
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
|
-
});
|