@gotgenes/pi-permission-system 3.9.0 → 3.11.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 +36 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/defaults.ts +6 -0
- package/src/handlers/lifecycle.ts +1 -1
- package/src/handlers/tool-call.ts +20 -19
- package/src/permission-manager.ts +110 -128
- package/src/rule.ts +5 -0
- package/src/runtime.ts +3 -3
- package/src/session-rules.ts +54 -0
- package/src/synthesize.ts +152 -0
- package/src/types.ts +1 -1
- 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/permission-system.test.ts +195 -1
- package/tests/rule.test.ts +31 -0
- package/tests/runtime.test.ts +5 -4
- package/tests/session-rules.test.ts +226 -0
- package/tests/synthesize.test.ts +413 -0
- package/src/session-approval-cache.ts +0 -81
- package/tests/session-approval-cache.test.ts +0 -131
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Rule, Ruleset } from "./rule";
|
|
2
|
+
import type { PermissionDefaultPolicy, PermissionState } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert the merged `defaultPolicy` into catch-all rules at the lowest
|
|
6
|
+
* priority position in the composed ruleset.
|
|
7
|
+
*
|
|
8
|
+
* Produces 5 rules:
|
|
9
|
+
* 1. `{ surface: "*", pattern: "*" }` — universal fallback (tools default)
|
|
10
|
+
* 2. `{ surface: "bash", pattern: "*" }` — bash default
|
|
11
|
+
* 3. `{ surface: "mcp", pattern: "*" }` — mcp default
|
|
12
|
+
* 4. `{ surface: "skill", pattern: "*" }` — skill default
|
|
13
|
+
* 5. `{ surface: "special", pattern: "*" }` — special / external_directory default
|
|
14
|
+
*
|
|
15
|
+
* All rules carry `layer: "default"`. `evaluate()` ignores this field.
|
|
16
|
+
* The specific per-surface rules come after the universal rule so they win
|
|
17
|
+
* via last-match-wins when a surface-specific default differs from the
|
|
18
|
+
* tools default.
|
|
19
|
+
*/
|
|
20
|
+
export function synthesizeDefaults(defaults: PermissionDefaultPolicy): Ruleset {
|
|
21
|
+
return [
|
|
22
|
+
{ surface: "*", pattern: "*", action: defaults.tools, layer: "default" },
|
|
23
|
+
{ surface: "bash", pattern: "*", action: defaults.bash, layer: "default" },
|
|
24
|
+
{ surface: "mcp", pattern: "*", action: defaults.mcp, layer: "default" },
|
|
25
|
+
{
|
|
26
|
+
surface: "skill",
|
|
27
|
+
pattern: "*",
|
|
28
|
+
action: defaults.skills,
|
|
29
|
+
layer: "default",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
surface: "special",
|
|
33
|
+
pattern: "*",
|
|
34
|
+
action: defaults.special,
|
|
35
|
+
layer: "default",
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Per-scope override shape — the relevant keys extracted from `tools`.
|
|
42
|
+
* `undefined` means the scope did not configure that override.
|
|
43
|
+
*/
|
|
44
|
+
export interface OverrideScope {
|
|
45
|
+
bash?: PermissionState;
|
|
46
|
+
mcp?: PermissionState;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Convert per-scope `tools.bash` / `tools.mcp` entries into catch-all rules
|
|
51
|
+
* placed between defaults and config rules.
|
|
52
|
+
*
|
|
53
|
+
* Scopes must be passed in precedence order (lowest first, e.g. global →
|
|
54
|
+
* project → agent → project-agent). Later scopes produce later rules and
|
|
55
|
+
* therefore win via last-match-wins — identical to the current last-scope-wins
|
|
56
|
+
* logic for `bashDefault` / `mcpToolLevel`.
|
|
57
|
+
*
|
|
58
|
+
* Only scopes that explicitly define a value contribute a rule; `undefined`
|
|
59
|
+
* entries are skipped.
|
|
60
|
+
*
|
|
61
|
+
* All rules carry `layer: "override"`.
|
|
62
|
+
*/
|
|
63
|
+
export function synthesizeOverrides(
|
|
64
|
+
scopes: ReadonlyArray<OverrideScope>,
|
|
65
|
+
): Ruleset {
|
|
66
|
+
const rules: Rule[] = [];
|
|
67
|
+
for (const scope of scopes) {
|
|
68
|
+
if (scope.bash !== undefined) {
|
|
69
|
+
rules.push({
|
|
70
|
+
surface: "bash",
|
|
71
|
+
pattern: "*",
|
|
72
|
+
action: scope.bash,
|
|
73
|
+
layer: "override",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (scope.mcp !== undefined) {
|
|
77
|
+
rules.push({
|
|
78
|
+
surface: "mcp",
|
|
79
|
+
pattern: "*",
|
|
80
|
+
action: scope.mcp,
|
|
81
|
+
layer: "override",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return rules;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* MCP metadata operation targets that are auto-allowed when any explicit MCP
|
|
90
|
+
* allow rule exists in the config layer.
|
|
91
|
+
*/
|
|
92
|
+
const MCP_BASELINE_TARGETS: readonly string[] = [
|
|
93
|
+
"mcp_status",
|
|
94
|
+
"mcp_list",
|
|
95
|
+
"mcp_search",
|
|
96
|
+
"mcp_describe",
|
|
97
|
+
"mcp_connect",
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Conditionally synthesize MCP baseline auto-allow rules.
|
|
102
|
+
*
|
|
103
|
+
* Emits allow rules for the 5 MCP metadata targets only when `configRules`
|
|
104
|
+
* contains at least one `surface: "mcp", action: "allow"` rule. This replicates
|
|
105
|
+
* the `hasAnyMcpAllowRule` heuristic as actual rules.
|
|
106
|
+
*
|
|
107
|
+
* When `defaults.mcp === "allow"`, the synthesized default catch-all already
|
|
108
|
+
* covers all MCP targets — no separate baseline rules are needed (and this
|
|
109
|
+
* function is not called in that case).
|
|
110
|
+
*
|
|
111
|
+
* Baseline rules are placed BEFORE override rules in the composed array so
|
|
112
|
+
* that `tools.mcp` overrides beat baseline (preserving current behaviour where
|
|
113
|
+
* an explicit `tools.mcp` value always terminates the MCP decision).
|
|
114
|
+
*
|
|
115
|
+
* All rules carry `layer: "baseline"`.
|
|
116
|
+
*/
|
|
117
|
+
export function synthesizeBaseline(configRules: Ruleset): Ruleset {
|
|
118
|
+
const hasAnyMcpAllow = configRules.some(
|
|
119
|
+
(r) => r.surface === "mcp" && r.action === "allow",
|
|
120
|
+
);
|
|
121
|
+
if (!hasAnyMcpAllow) {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
return MCP_BASELINE_TARGETS.map(
|
|
125
|
+
(target): Rule => ({
|
|
126
|
+
surface: "mcp",
|
|
127
|
+
pattern: target,
|
|
128
|
+
action: "allow",
|
|
129
|
+
layer: "baseline",
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Concatenate all rule layers into a single flat ruleset.
|
|
136
|
+
*
|
|
137
|
+
* Priority order (lowest → highest, i.e. earlier index → later index):
|
|
138
|
+
* defaults → baseline → overrides → config
|
|
139
|
+
*
|
|
140
|
+
* Session rules are NOT included here — they are appended at call-time inside
|
|
141
|
+
* `checkPermission()` so that the cached composed ruleset remains session-agnostic.
|
|
142
|
+
*
|
|
143
|
+
* `evaluate()` scans from the end, so later layers override earlier ones.
|
|
144
|
+
*/
|
|
145
|
+
export function composeRuleset(
|
|
146
|
+
defaults: Ruleset,
|
|
147
|
+
baseline: Ruleset,
|
|
148
|
+
overrides: Ruleset,
|
|
149
|
+
config: Ruleset,
|
|
150
|
+
): Ruleset {
|
|
151
|
+
return [...defaults, ...baseline, ...overrides, ...config];
|
|
152
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -41,5 +41,5 @@ export interface PermissionCheckResult {
|
|
|
41
41
|
matchedPattern?: string;
|
|
42
42
|
command?: string;
|
|
43
43
|
target?: string;
|
|
44
|
-
source: "tool" | "bash" | "mcp" | "skill" | "special" | "default";
|
|
44
|
+
source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
|
|
45
45
|
}
|
|
@@ -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
|
});
|
|
@@ -46,7 +46,11 @@ import {
|
|
|
46
46
|
checkRequestedToolRegistration,
|
|
47
47
|
getToolNameFromValue,
|
|
48
48
|
} from "../src/tool-registry";
|
|
49
|
-
import type {
|
|
49
|
+
import type {
|
|
50
|
+
PermissionCheckResult,
|
|
51
|
+
PermissionState,
|
|
52
|
+
ScopeConfig,
|
|
53
|
+
} from "../src/types";
|
|
50
54
|
import {
|
|
51
55
|
canResolveAskPermissionRequest,
|
|
52
56
|
shouldAutoApprovePermissionState,
|
|
@@ -2969,3 +2973,193 @@ test("session approval: regular 'Yes' does not create session approval", async (
|
|
|
2969
2973
|
rmSync(rootDir, { recursive: true, force: true });
|
|
2970
2974
|
}
|
|
2971
2975
|
});
|
|
2976
|
+
|
|
2977
|
+
// ---------------------------------------------------------------------------
|
|
2978
|
+
// Session-aware checkPermission() integration
|
|
2979
|
+
// ---------------------------------------------------------------------------
|
|
2980
|
+
|
|
2981
|
+
test("checkPermission returns source 'session' when session rules cover the external_directory path", () => {
|
|
2982
|
+
const { manager, cleanup } = createManager({
|
|
2983
|
+
defaultPolicy: {
|
|
2984
|
+
tools: "allow",
|
|
2985
|
+
bash: "allow",
|
|
2986
|
+
mcp: "allow",
|
|
2987
|
+
skills: "allow",
|
|
2988
|
+
special: "ask",
|
|
2989
|
+
},
|
|
2990
|
+
});
|
|
2991
|
+
|
|
2992
|
+
try {
|
|
2993
|
+
const sessionRules = [
|
|
2994
|
+
{
|
|
2995
|
+
surface: "external_directory",
|
|
2996
|
+
pattern: "/other/project/*",
|
|
2997
|
+
action: "allow" as const,
|
|
2998
|
+
layer: "session" as const,
|
|
2999
|
+
},
|
|
3000
|
+
];
|
|
3001
|
+
|
|
3002
|
+
const result = manager.checkPermission(
|
|
3003
|
+
"external_directory",
|
|
3004
|
+
{ path: "/other/project/src/foo.ts" },
|
|
3005
|
+
undefined,
|
|
3006
|
+
sessionRules,
|
|
3007
|
+
);
|
|
3008
|
+
assert.equal(result.state, "allow");
|
|
3009
|
+
assert.equal(result.source, "session");
|
|
3010
|
+
assert.equal(result.matchedPattern, "/other/project/*");
|
|
3011
|
+
} finally {
|
|
3012
|
+
cleanup();
|
|
3013
|
+
}
|
|
3014
|
+
});
|
|
3015
|
+
|
|
3016
|
+
test("checkPermission falls back to config policy when session rules do not cover the path", () => {
|
|
3017
|
+
const { manager, cleanup } = createManager({
|
|
3018
|
+
defaultPolicy: {
|
|
3019
|
+
tools: "allow",
|
|
3020
|
+
bash: "allow",
|
|
3021
|
+
mcp: "allow",
|
|
3022
|
+
skills: "allow",
|
|
3023
|
+
special: "deny",
|
|
3024
|
+
},
|
|
3025
|
+
});
|
|
3026
|
+
|
|
3027
|
+
try {
|
|
3028
|
+
const sessionRules = [
|
|
3029
|
+
{
|
|
3030
|
+
surface: "external_directory",
|
|
3031
|
+
pattern: "/other/project/*",
|
|
3032
|
+
action: "allow" as const,
|
|
3033
|
+
layer: "session" as const,
|
|
3034
|
+
},
|
|
3035
|
+
];
|
|
3036
|
+
|
|
3037
|
+
// Path NOT under /other/project/ — session rules don't match.
|
|
3038
|
+
const result = manager.checkPermission(
|
|
3039
|
+
"external_directory",
|
|
3040
|
+
{ path: "/completely/different/path.ts" },
|
|
3041
|
+
undefined,
|
|
3042
|
+
sessionRules,
|
|
3043
|
+
);
|
|
3044
|
+
assert.equal(result.state, "deny");
|
|
3045
|
+
assert.equal(result.source, "special");
|
|
3046
|
+
} finally {
|
|
3047
|
+
cleanup();
|
|
3048
|
+
}
|
|
3049
|
+
});
|
|
3050
|
+
|
|
3051
|
+
test("checkPermission with empty session rules is identical to call without sessionRules arg", () => {
|
|
3052
|
+
const { manager, cleanup } = createManager({
|
|
3053
|
+
defaultPolicy: {
|
|
3054
|
+
tools: "allow",
|
|
3055
|
+
bash: "allow",
|
|
3056
|
+
mcp: "allow",
|
|
3057
|
+
skills: "allow",
|
|
3058
|
+
special: "ask",
|
|
3059
|
+
},
|
|
3060
|
+
special: { external_directory: "deny" },
|
|
3061
|
+
});
|
|
3062
|
+
|
|
3063
|
+
try {
|
|
3064
|
+
const withEmpty = manager.checkPermission(
|
|
3065
|
+
"external_directory",
|
|
3066
|
+
{ path: "/other/project/foo.ts" },
|
|
3067
|
+
undefined,
|
|
3068
|
+
[],
|
|
3069
|
+
);
|
|
3070
|
+
const withoutArg = manager.checkPermission("external_directory", {
|
|
3071
|
+
path: "/other/project/foo.ts",
|
|
3072
|
+
});
|
|
3073
|
+
const expected: PermissionCheckResult = {
|
|
3074
|
+
toolName: "external_directory",
|
|
3075
|
+
state: "deny",
|
|
3076
|
+
matchedPattern: "external_directory",
|
|
3077
|
+
source: "special",
|
|
3078
|
+
};
|
|
3079
|
+
assert.deepEqual(withEmpty, expected);
|
|
3080
|
+
assert.deepEqual(withoutArg, expected);
|
|
3081
|
+
} finally {
|
|
3082
|
+
cleanup();
|
|
3083
|
+
}
|
|
3084
|
+
});
|
|
3085
|
+
|
|
3086
|
+
test("session rules for one surface do not affect checks on other surfaces", () => {
|
|
3087
|
+
const { manager, cleanup } = createManager({
|
|
3088
|
+
defaultPolicy: {
|
|
3089
|
+
tools: "ask",
|
|
3090
|
+
bash: "ask",
|
|
3091
|
+
mcp: "ask",
|
|
3092
|
+
skills: "ask",
|
|
3093
|
+
special: "ask",
|
|
3094
|
+
},
|
|
3095
|
+
});
|
|
3096
|
+
|
|
3097
|
+
try {
|
|
3098
|
+
const sessionRules = [
|
|
3099
|
+
{
|
|
3100
|
+
surface: "external_directory",
|
|
3101
|
+
pattern: "/other/project/*",
|
|
3102
|
+
action: "allow" as const,
|
|
3103
|
+
layer: "session" as const,
|
|
3104
|
+
},
|
|
3105
|
+
];
|
|
3106
|
+
|
|
3107
|
+
// Bash check — session rules should not affect bash decisions.
|
|
3108
|
+
const bashResult = manager.checkPermission(
|
|
3109
|
+
"bash",
|
|
3110
|
+
{ command: "git status" },
|
|
3111
|
+
undefined,
|
|
3112
|
+
sessionRules,
|
|
3113
|
+
);
|
|
3114
|
+
assert.equal(bashResult.state, "ask");
|
|
3115
|
+
assert.equal(bashResult.source, "bash");
|
|
3116
|
+
|
|
3117
|
+
// MCP check — session rules should not affect MCP decisions.
|
|
3118
|
+
const mcpResult = manager.checkPermission(
|
|
3119
|
+
"mcp",
|
|
3120
|
+
{ tool: "exa:search" },
|
|
3121
|
+
undefined,
|
|
3122
|
+
sessionRules,
|
|
3123
|
+
);
|
|
3124
|
+
assert.equal(mcpResult.state, "ask");
|
|
3125
|
+
assert.equal(mcpResult.source, "default");
|
|
3126
|
+
} finally {
|
|
3127
|
+
cleanup();
|
|
3128
|
+
}
|
|
3129
|
+
});
|
|
3130
|
+
|
|
3131
|
+
test("session rules override config deny for external_directory", () => {
|
|
3132
|
+
const { manager, cleanup } = createManager({
|
|
3133
|
+
defaultPolicy: {
|
|
3134
|
+
tools: "allow",
|
|
3135
|
+
bash: "allow",
|
|
3136
|
+
mcp: "allow",
|
|
3137
|
+
skills: "allow",
|
|
3138
|
+
special: "ask",
|
|
3139
|
+
},
|
|
3140
|
+
special: { external_directory: "deny" },
|
|
3141
|
+
});
|
|
3142
|
+
|
|
3143
|
+
try {
|
|
3144
|
+
const sessionRules = [
|
|
3145
|
+
{
|
|
3146
|
+
surface: "external_directory",
|
|
3147
|
+
pattern: "/other/project/*",
|
|
3148
|
+
action: "allow" as const,
|
|
3149
|
+
layer: "session" as const,
|
|
3150
|
+
},
|
|
3151
|
+
];
|
|
3152
|
+
|
|
3153
|
+
// Session approval overrides config deny for the covered path.
|
|
3154
|
+
const result = manager.checkPermission(
|
|
3155
|
+
"external_directory",
|
|
3156
|
+
{ path: "/other/project/src/foo.ts" },
|
|
3157
|
+
undefined,
|
|
3158
|
+
sessionRules,
|
|
3159
|
+
);
|
|
3160
|
+
assert.equal(result.state, "allow");
|
|
3161
|
+
assert.equal(result.source, "session");
|
|
3162
|
+
} finally {
|
|
3163
|
+
cleanup();
|
|
3164
|
+
}
|
|
3165
|
+
});
|
package/tests/rule.test.ts
CHANGED
|
@@ -137,4 +137,35 @@ describe("evaluate", () => {
|
|
|
137
137
|
expect(result.pattern).toBe("git status");
|
|
138
138
|
expect(result.action).toBe("ask");
|
|
139
139
|
});
|
|
140
|
+
|
|
141
|
+
test("rule.layer is ignored by evaluate() — matching is identical with or without it", () => {
|
|
142
|
+
const withLayer: Rule = {
|
|
143
|
+
surface: "bash",
|
|
144
|
+
pattern: "git *",
|
|
145
|
+
action: "allow",
|
|
146
|
+
layer: "config",
|
|
147
|
+
};
|
|
148
|
+
const withoutLayer: Rule = {
|
|
149
|
+
surface: "bash",
|
|
150
|
+
pattern: "git *",
|
|
151
|
+
action: "allow",
|
|
152
|
+
};
|
|
153
|
+
const withDefault: Rule = {
|
|
154
|
+
surface: "bash",
|
|
155
|
+
pattern: "*",
|
|
156
|
+
action: "ask",
|
|
157
|
+
layer: "default",
|
|
158
|
+
};
|
|
159
|
+
// Both rules with and without layer field produce the same match.
|
|
160
|
+
expect(evaluate("bash", "git status", [withLayer]).action).toBe("allow");
|
|
161
|
+
expect(evaluate("bash", "git status", [withoutLayer]).action).toBe("allow");
|
|
162
|
+
// Layer metadata does not affect last-match-wins ordering.
|
|
163
|
+
const ruleset: Rule[] = [withDefault, withLayer];
|
|
164
|
+
expect(evaluate("bash", "git status", ruleset)).toEqual(withLayer);
|
|
165
|
+
// A rule with layer: "default" still wins if it is last in the array.
|
|
166
|
+
const reversedRuleset: Rule[] = [withLayer, withDefault];
|
|
167
|
+
expect(evaluate("bash", "git status", reversedRuleset)).toEqual(
|
|
168
|
+
withDefault,
|
|
169
|
+
);
|
|
170
|
+
});
|
|
140
171
|
});
|
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 ──────────────────────────────────────────
|