@gotgenes/pi-permission-system 3.8.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 +37 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/defaults.ts +60 -0
- package/src/handlers/lifecycle.ts +1 -1
- package/src/handlers/tool-call.ts +16 -12
- package/src/index.ts +1 -4
- package/src/normalize.ts +70 -0
- package/src/permission-manager.ts +127 -254
- package/src/rule.ts +7 -23
- package/src/runtime.ts +3 -3
- package/src/session-rules.ts +54 -0
- package/src/types.ts +13 -18
- package/tests/defaults.test.ts +105 -0
- package/tests/handlers/before-agent-start.test.ts +4 -5
- 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/normalize.test.ts +121 -0
- package/tests/permission-system.test.ts +11 -39
- package/tests/rule.test.ts +24 -42
- package/tests/runtime.test.ts +5 -4
- package/tests/session-rules.test.ts +225 -0
- package/tests/session-start.test.ts +2 -2
- package/src/bash-filter.ts +0 -51
- package/src/session-approval-cache.ts +0 -81
- package/tests/bash-filter.test.ts +0 -142
- package/tests/session-approval-cache.test.ts +0 -131
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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -9,16 +9,8 @@ export type BuiltInToolName =
|
|
|
9
9
|
| "find"
|
|
10
10
|
| "ls";
|
|
11
11
|
|
|
12
|
-
export type ToolPermissions = Record<string, PermissionState>;
|
|
13
|
-
|
|
14
|
-
export type BashPermissions = Record<string, PermissionState>;
|
|
15
|
-
|
|
16
|
-
export type SkillPermissions = Record<string, PermissionState>;
|
|
17
|
-
|
|
18
12
|
export type SpecialPermissionName = "external_directory";
|
|
19
13
|
|
|
20
|
-
export type SpecialPermissions = Record<string, PermissionState>;
|
|
21
|
-
|
|
22
14
|
export interface PermissionDefaultPolicy {
|
|
23
15
|
tools: PermissionState;
|
|
24
16
|
bash: PermissionState;
|
|
@@ -27,17 +19,20 @@ export interface PermissionDefaultPolicy {
|
|
|
27
19
|
special: PermissionState;
|
|
28
20
|
}
|
|
29
21
|
|
|
30
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Per-scope permission config shape after loading and validation.
|
|
24
|
+
* All fields optional — each scope may define a subset of the policy.
|
|
25
|
+
*
|
|
26
|
+
* This replaces the former AgentPermissions / GlobalPermissionConfig
|
|
27
|
+
* interfaces (removed in #56).
|
|
28
|
+
*/
|
|
29
|
+
export interface ScopeConfig {
|
|
31
30
|
defaultPolicy?: Partial<PermissionDefaultPolicy>;
|
|
32
|
-
tools?:
|
|
33
|
-
bash?:
|
|
34
|
-
mcp?:
|
|
35
|
-
skills?:
|
|
36
|
-
special?:
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface GlobalPermissionConfig extends AgentPermissions {
|
|
40
|
-
defaultPolicy: PermissionDefaultPolicy;
|
|
31
|
+
tools?: Record<string, PermissionState>;
|
|
32
|
+
bash?: Record<string, PermissionState>;
|
|
33
|
+
mcp?: Record<string, PermissionState>;
|
|
34
|
+
skills?: Record<string, PermissionState>;
|
|
35
|
+
special?: Record<string, PermissionState>;
|
|
41
36
|
}
|
|
42
37
|
|
|
43
38
|
export interface PermissionCheckResult {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_POLICY,
|
|
4
|
+
getSurfaceDefault,
|
|
5
|
+
mergeDefaults,
|
|
6
|
+
} from "../src/defaults";
|
|
7
|
+
import type { PermissionDefaultPolicy } from "../src/types";
|
|
8
|
+
|
|
9
|
+
const SPECIAL_KEYS = new Set(["external_directory"]);
|
|
10
|
+
|
|
11
|
+
describe("getSurfaceDefault", () => {
|
|
12
|
+
const defaults: PermissionDefaultPolicy = {
|
|
13
|
+
tools: "allow",
|
|
14
|
+
bash: "deny",
|
|
15
|
+
mcp: "ask",
|
|
16
|
+
skills: "allow",
|
|
17
|
+
special: "deny",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
test("returns defaults.bash for surface 'bash'", () => {
|
|
21
|
+
expect(getSurfaceDefault("bash", defaults, SPECIAL_KEYS)).toBe("deny");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("returns defaults.mcp for surface 'mcp'", () => {
|
|
25
|
+
expect(getSurfaceDefault("mcp", defaults, SPECIAL_KEYS)).toBe("ask");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns defaults.skills for surface 'skill'", () => {
|
|
29
|
+
expect(getSurfaceDefault("skill", defaults, SPECIAL_KEYS)).toBe("allow");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns defaults.special for special-key surfaces", () => {
|
|
33
|
+
expect(
|
|
34
|
+
getSurfaceDefault("external_directory", defaults, SPECIAL_KEYS),
|
|
35
|
+
).toBe("deny");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("returns defaults.tools for tool-name surfaces", () => {
|
|
39
|
+
expect(getSurfaceDefault("read", defaults, SPECIAL_KEYS)).toBe("allow");
|
|
40
|
+
expect(getSurfaceDefault("write", defaults, SPECIAL_KEYS)).toBe("allow");
|
|
41
|
+
expect(getSurfaceDefault("edit", defaults, SPECIAL_KEYS)).toBe("allow");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns defaults.tools for unknown surfaces (least privilege via tools default)", () => {
|
|
45
|
+
expect(
|
|
46
|
+
getSurfaceDefault("unknown_extension_tool", defaults, SPECIAL_KEYS),
|
|
47
|
+
).toBe("allow");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("uses DEFAULT_POLICY when no overrides exist", () => {
|
|
51
|
+
expect(getSurfaceDefault("bash", DEFAULT_POLICY, SPECIAL_KEYS)).toBe("ask");
|
|
52
|
+
expect(getSurfaceDefault("read", DEFAULT_POLICY, SPECIAL_KEYS)).toBe("ask");
|
|
53
|
+
expect(
|
|
54
|
+
getSurfaceDefault("external_directory", DEFAULT_POLICY, SPECIAL_KEYS),
|
|
55
|
+
).toBe("ask");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("mergeDefaults", () => {
|
|
60
|
+
test("returns DEFAULT_POLICY when called with no partials", () => {
|
|
61
|
+
expect(mergeDefaults()).toEqual(DEFAULT_POLICY);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("overrides specific fields from a single partial", () => {
|
|
65
|
+
const result = mergeDefaults({ tools: "allow", bash: "deny" });
|
|
66
|
+
expect(result).toEqual({
|
|
67
|
+
tools: "allow",
|
|
68
|
+
bash: "deny",
|
|
69
|
+
mcp: "ask",
|
|
70
|
+
skills: "ask",
|
|
71
|
+
special: "ask",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("later partials override earlier ones", () => {
|
|
76
|
+
const global: Partial<PermissionDefaultPolicy> = { tools: "allow" };
|
|
77
|
+
const project: Partial<PermissionDefaultPolicy> = { tools: "deny" };
|
|
78
|
+
const result = mergeDefaults(global, project);
|
|
79
|
+
expect(result.tools).toBe("deny");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("merges across multiple partials", () => {
|
|
83
|
+
const global: Partial<PermissionDefaultPolicy> = {
|
|
84
|
+
tools: "allow",
|
|
85
|
+
bash: "allow",
|
|
86
|
+
};
|
|
87
|
+
const project: Partial<PermissionDefaultPolicy> = { bash: "deny" };
|
|
88
|
+
const agent: Partial<PermissionDefaultPolicy> = { mcp: "allow" };
|
|
89
|
+
const result = mergeDefaults(global, project, agent);
|
|
90
|
+
expect(result).toEqual({
|
|
91
|
+
tools: "allow",
|
|
92
|
+
bash: "deny",
|
|
93
|
+
mcp: "allow",
|
|
94
|
+
skills: "ask",
|
|
95
|
+
special: "ask",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("undefined fields in later partials do not override earlier values", () => {
|
|
100
|
+
const global: Partial<PermissionDefaultPolicy> = { tools: "allow" };
|
|
101
|
+
const project: Partial<PermissionDefaultPolicy> = { bash: "deny" };
|
|
102
|
+
const result = mergeDefaults(global, project);
|
|
103
|
+
expect(result.tools).toBe("allow");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
handleBeforeAgentStart,
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { normalizeConfig } from "../src/normalize";
|
|
3
|
+
|
|
4
|
+
describe("normalizeConfig", () => {
|
|
5
|
+
describe("tools entries", () => {
|
|
6
|
+
test("converts tools entries to tool-name-as-surface rules", () => {
|
|
7
|
+
const result = normalizeConfig({
|
|
8
|
+
tools: { read: "allow", write: "deny" },
|
|
9
|
+
});
|
|
10
|
+
expect(result).toEqual([
|
|
11
|
+
{ surface: "read", pattern: "*", action: "allow" },
|
|
12
|
+
{ surface: "write", pattern: "*", action: "deny" },
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("tools.bash is excluded (handled as fallback override)", () => {
|
|
17
|
+
const result = normalizeConfig({
|
|
18
|
+
tools: { bash: "allow", read: "allow" },
|
|
19
|
+
});
|
|
20
|
+
expect(result).toEqual([
|
|
21
|
+
{ surface: "read", pattern: "*", action: "allow" },
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("tools.mcp is excluded (handled as fallback override)", () => {
|
|
26
|
+
const result = normalizeConfig({
|
|
27
|
+
tools: { mcp: "ask", read: "allow" },
|
|
28
|
+
});
|
|
29
|
+
expect(result).toEqual([
|
|
30
|
+
{ surface: "read", pattern: "*", action: "allow" },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("bash entries", () => {
|
|
36
|
+
test("converts bash entries to surface 'bash' rules", () => {
|
|
37
|
+
const result = normalizeConfig({
|
|
38
|
+
bash: { "git *": "allow", "rm -rf *": "deny" },
|
|
39
|
+
});
|
|
40
|
+
expect(result).toEqual([
|
|
41
|
+
{ surface: "bash", pattern: "git *", action: "allow" },
|
|
42
|
+
{ surface: "bash", pattern: "rm -rf *", action: "deny" },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("mcp entries", () => {
|
|
48
|
+
test("converts mcp entries to surface 'mcp' rules", () => {
|
|
49
|
+
const result = normalizeConfig({
|
|
50
|
+
mcp: { "exa:*": "allow", mcp_status: "allow" },
|
|
51
|
+
});
|
|
52
|
+
expect(result).toEqual([
|
|
53
|
+
{ surface: "mcp", pattern: "exa:*", action: "allow" },
|
|
54
|
+
{ surface: "mcp", pattern: "mcp_status", action: "allow" },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("skills entries", () => {
|
|
60
|
+
test("converts skills entries to surface 'skill' rules", () => {
|
|
61
|
+
const result = normalizeConfig({
|
|
62
|
+
skills: { "*": "ask", librarian: "allow" },
|
|
63
|
+
});
|
|
64
|
+
expect(result).toEqual([
|
|
65
|
+
{ surface: "skill", pattern: "*", action: "ask" },
|
|
66
|
+
{ surface: "skill", pattern: "librarian", action: "allow" },
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("special entries", () => {
|
|
72
|
+
test("converts special entries to surface 'special' with key as pattern", () => {
|
|
73
|
+
const result = normalizeConfig({
|
|
74
|
+
special: { external_directory: "ask" },
|
|
75
|
+
});
|
|
76
|
+
expect(result).toEqual([
|
|
77
|
+
{ surface: "special", pattern: "external_directory", action: "ask" },
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("ordering", () => {
|
|
83
|
+
test("tools.bash excluded; bash entries come after tools", () => {
|
|
84
|
+
const result = normalizeConfig({
|
|
85
|
+
tools: { bash: "allow", read: "deny" },
|
|
86
|
+
bash: { "git *": "ask" },
|
|
87
|
+
});
|
|
88
|
+
expect(result).toEqual([
|
|
89
|
+
{ surface: "read", pattern: "*", action: "deny" },
|
|
90
|
+
{ surface: "bash", pattern: "git *", action: "ask" },
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("full ordering: tools → bash → mcp → skills → special", () => {
|
|
95
|
+
const result = normalizeConfig({
|
|
96
|
+
tools: { read: "allow" },
|
|
97
|
+
bash: { "git *": "allow" },
|
|
98
|
+
mcp: { "exa:*": "allow" },
|
|
99
|
+
skills: { librarian: "allow" },
|
|
100
|
+
special: { external_directory: "ask" },
|
|
101
|
+
});
|
|
102
|
+
expect(result).toEqual([
|
|
103
|
+
{ surface: "read", pattern: "*", action: "allow" },
|
|
104
|
+
{ surface: "bash", pattern: "git *", action: "allow" },
|
|
105
|
+
{ surface: "mcp", pattern: "exa:*", action: "allow" },
|
|
106
|
+
{ surface: "skill", pattern: "librarian", action: "allow" },
|
|
107
|
+
{ surface: "special", pattern: "external_directory", action: "ask" },
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("empty and missing sections", () => {
|
|
113
|
+
test("empty config produces empty ruleset", () => {
|
|
114
|
+
expect(normalizeConfig({})).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("undefined sections are skipped", () => {
|
|
118
|
+
expect(normalizeConfig({ tools: undefined })).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { dirname, join, resolve } from "node:path";
|
|
12
12
|
import { test } from "vitest";
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
import {
|
|
15
15
|
createActiveToolsCacheKey,
|
|
16
16
|
createBeforeAgentStartPromptStateKey,
|
|
@@ -46,11 +46,7 @@ import {
|
|
|
46
46
|
checkRequestedToolRegistration,
|
|
47
47
|
getToolNameFromValue,
|
|
48
48
|
} from "../src/tool-registry";
|
|
49
|
-
import type {
|
|
50
|
-
AgentPermissions,
|
|
51
|
-
GlobalPermissionConfig,
|
|
52
|
-
PermissionState,
|
|
53
|
-
} from "../src/types";
|
|
49
|
+
import type { PermissionState, ScopeConfig } from "../src/types";
|
|
54
50
|
import {
|
|
55
51
|
canResolveAskPermissionRequest,
|
|
56
52
|
shouldAutoApprovePermissionState,
|
|
@@ -61,7 +57,7 @@ type CreateManagerOptions = {
|
|
|
61
57
|
};
|
|
62
58
|
|
|
63
59
|
function createManager(
|
|
64
|
-
config:
|
|
60
|
+
config: ScopeConfig,
|
|
65
61
|
agentFiles: Record<string, string> = {},
|
|
66
62
|
options: CreateManagerOptions = {},
|
|
67
63
|
) {
|
|
@@ -146,7 +142,7 @@ async function withIsolatedSubagentEnv<T>(
|
|
|
146
142
|
}
|
|
147
143
|
|
|
148
144
|
function createToolCallHarness(
|
|
149
|
-
config:
|
|
145
|
+
config: ScopeConfig,
|
|
150
146
|
toolNames: readonly string[],
|
|
151
147
|
options: ExtensionHarnessOptions = {},
|
|
152
148
|
): ExtensionHarness {
|
|
@@ -652,30 +648,6 @@ test("Permission-system logger respects debug toggle and keeps review log enable
|
|
|
652
648
|
}
|
|
653
649
|
});
|
|
654
650
|
|
|
655
|
-
test("BashFilter uses opencode-style last-match hierarchy", () => {
|
|
656
|
-
const filter = new BashFilter(
|
|
657
|
-
{
|
|
658
|
-
"*": "ask",
|
|
659
|
-
"git *": "deny",
|
|
660
|
-
"git status *": "ask",
|
|
661
|
-
"git status": "allow",
|
|
662
|
-
},
|
|
663
|
-
"deny",
|
|
664
|
-
);
|
|
665
|
-
|
|
666
|
-
const exact = filter.check("git status");
|
|
667
|
-
assert.equal(exact.state, "allow");
|
|
668
|
-
assert.equal(exact.matchedPattern, "git status");
|
|
669
|
-
|
|
670
|
-
const subcommand = filter.check("git status --short");
|
|
671
|
-
assert.equal(subcommand.state, "ask");
|
|
672
|
-
assert.equal(subcommand.matchedPattern, "git status *");
|
|
673
|
-
|
|
674
|
-
const generic = filter.check("git commit -m test");
|
|
675
|
-
assert.equal(generic.state, "deny");
|
|
676
|
-
assert.equal(generic.matchedPattern, "git *");
|
|
677
|
-
});
|
|
678
|
-
|
|
679
651
|
test("PermissionManager canonical built-in permission checking", () => {
|
|
680
652
|
const { manager, cleanup } = createManager({
|
|
681
653
|
defaultPolicy: {
|
|
@@ -910,7 +882,7 @@ test("MCP server names in settings.json are not used — only mcp.json is consul
|
|
|
910
882
|
// which matches this rule and returns "allow".
|
|
911
883
|
// After the fix, settings.json is ignored, so no server name is derived and the
|
|
912
884
|
// result falls through to the default mcp policy ("ask").
|
|
913
|
-
const config:
|
|
885
|
+
const config: ScopeConfig = {
|
|
914
886
|
defaultPolicy: {
|
|
915
887
|
tools: "ask",
|
|
916
888
|
bash: "ask",
|
|
@@ -1510,12 +1482,12 @@ test("Permission forwarding rejects unresolved sentinel session ids", () => {
|
|
|
1510
1482
|
});
|
|
1511
1483
|
|
|
1512
1484
|
type CreateManagerWithProjectOptions = CreateManagerOptions & {
|
|
1513
|
-
projectConfig?:
|
|
1485
|
+
projectConfig?: ScopeConfig;
|
|
1514
1486
|
projectAgentFiles?: Record<string, string>;
|
|
1515
1487
|
};
|
|
1516
1488
|
|
|
1517
1489
|
function createManagerWithProject(
|
|
1518
|
-
config:
|
|
1490
|
+
config: ScopeConfig,
|
|
1519
1491
|
agentFiles: Record<string, string> = {},
|
|
1520
1492
|
options: CreateManagerWithProjectOptions = {},
|
|
1521
1493
|
) {
|
|
@@ -1807,7 +1779,7 @@ test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
|
|
|
1807
1779
|
mkdirSync(agentsDir, { recursive: true });
|
|
1808
1780
|
mkdirSync(dirname(newConfigPath), { recursive: true });
|
|
1809
1781
|
|
|
1810
|
-
const config:
|
|
1782
|
+
const config: ScopeConfig = {
|
|
1811
1783
|
defaultPolicy: {
|
|
1812
1784
|
tools: "deny",
|
|
1813
1785
|
bash: "deny",
|
|
@@ -2609,7 +2581,7 @@ test("normalizeRawPermission emits no issues when special is absent", () => {
|
|
|
2609
2581
|
});
|
|
2610
2582
|
|
|
2611
2583
|
test("PermissionManager.getConfigIssues returns deprecation for tool_call_limit in global config", () => {
|
|
2612
|
-
const config:
|
|
2584
|
+
const config: ScopeConfig = {
|
|
2613
2585
|
defaultPolicy: {
|
|
2614
2586
|
tools: "ask",
|
|
2615
2587
|
bash: "ask",
|
|
@@ -2634,7 +2606,7 @@ test("PermissionManager.getConfigIssues returns deprecation for tool_call_limit
|
|
|
2634
2606
|
});
|
|
2635
2607
|
|
|
2636
2608
|
test("PermissionManager.getConfigIssues returns empty array for clean config", () => {
|
|
2637
|
-
const config:
|
|
2609
|
+
const config: ScopeConfig = {
|
|
2638
2610
|
defaultPolicy: {
|
|
2639
2611
|
tools: "ask",
|
|
2640
2612
|
bash: "ask",
|
|
@@ -2660,7 +2632,7 @@ test("PermissionManager.getConfigIssues returns empty array for clean config", (
|
|
|
2660
2632
|
// --- doom_loop config-loader deprecation tests (#54) ---
|
|
2661
2633
|
|
|
2662
2634
|
test("PermissionManager.getConfigIssues returns deprecation for doom_loop in global config", () => {
|
|
2663
|
-
const config:
|
|
2635
|
+
const config: ScopeConfig = {
|
|
2664
2636
|
defaultPolicy: {
|
|
2665
2637
|
tools: "ask",
|
|
2666
2638
|
bash: "ask",
|