@komarspn/pi-permission-system 16.0.2
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 +2234 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/config/config.example.json +39 -0
- package/package.json +82 -0
- package/schemas/permissions.schema.json +158 -0
- package/src/active-agent.ts +72 -0
- package/src/async-cache.ts +21 -0
- package/src/bash-arity.ts +210 -0
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/canonicalize-path.ts +30 -0
- package/src/common.ts +121 -0
- package/src/config-loader.ts +432 -0
- package/src/config-modal.ts +259 -0
- package/src/config-paths.ts +47 -0
- package/src/config-reporter.ts +34 -0
- package/src/config-store.ts +222 -0
- package/src/decision-audit.ts +75 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +232 -0
- package/src/expand-home.ts +28 -0
- package/src/extension-config.ts +79 -0
- package/src/extension-paths.ts +66 -0
- package/src/forwarded-permissions/io.ts +404 -0
- package/src/forwarded-permissions/permission-forwarder.ts +580 -0
- package/src/forwarding-manager.ts +74 -0
- package/src/gate-prompter.ts +12 -0
- package/src/handlers/before-agent-start.ts +94 -0
- package/src/handlers/gates/bash-command.ts +75 -0
- package/src/handlers/gates/bash-external-directory.ts +127 -0
- package/src/handlers/gates/bash-path-extractor.ts +15 -0
- package/src/handlers/gates/bash-path.ts +152 -0
- package/src/handlers/gates/bash-program.ts +1143 -0
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/gates/descriptor.ts +81 -0
- package/src/handlers/gates/external-directory-messages.ts +20 -0
- package/src/handlers/gates/external-directory.ts +133 -0
- package/src/handlers/gates/helpers.ts +76 -0
- package/src/handlers/gates/path.ts +91 -0
- package/src/handlers/gates/runner.ts +186 -0
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +46 -0
- package/src/handlers/gates/skill-read.ts +87 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
- package/src/handlers/gates/tool.ts +102 -0
- package/src/handlers/gates/types.ts +13 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/lifecycle.ts +95 -0
- package/src/handlers/permission-gate-handler.ts +190 -0
- package/src/handlers/tool-call-boundary.ts +91 -0
- package/src/index.ts +225 -0
- package/src/input-normalizer.ts +157 -0
- package/src/logging.ts +113 -0
- package/src/mcp-targets.ts +170 -0
- package/src/node-modules-discovery.ts +76 -0
- package/src/normalize.ts +43 -0
- package/src/path-utils.ts +355 -0
- package/src/pattern-suggest.ts +132 -0
- package/src/permission-dialog.ts +138 -0
- package/src/permission-event-rpc.ts +223 -0
- package/src/permission-events.ts +266 -0
- package/src/permission-forwarding.ts +188 -0
- package/src/permission-gate.ts +94 -0
- package/src/permission-manager.ts +392 -0
- package/src/permission-merge.ts +32 -0
- package/src/permission-prompter.ts +142 -0
- package/src/permission-prompts.ts +93 -0
- package/src/permission-resolver.ts +109 -0
- package/src/permission-session.ts +189 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +63 -0
- package/src/persistent-approval-recorder.ts +139 -0
- package/src/policy-loader.ts +350 -0
- package/src/prompting-gateway.ts +104 -0
- package/src/rule.ts +188 -0
- package/src/scope-merge.ts +72 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +163 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-approval.ts +43 -0
- package/src/session-logger.ts +91 -0
- package/src/session-rules.ts +79 -0
- package/src/skill-prompt-sanitizer.ts +292 -0
- package/src/status.ts +35 -0
- package/src/subagent-context.ts +104 -0
- package/src/subagent-lifecycle-events.ts +72 -0
- package/src/subagent-registry.ts +105 -0
- package/src/synthesize.ts +92 -0
- package/src/system-prompt-sanitizer.ts +274 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/src/tool-input-formatter-registry.ts +67 -0
- package/src/tool-input-preview.ts +34 -0
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +207 -0
- package/src/tool-registry.ts +148 -0
- package/src/types.ts +64 -0
- package/src/wildcard-matcher.ts +120 -0
- package/src/yolo-mode.ts +30 -0
- package/test/active-agent.test.ts +155 -0
- package/test/async-cache.test.ts +48 -0
- package/test/bash-arity.test.ts +144 -0
- package/test/bash-external-directory.test.ts +956 -0
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/canonicalize-path.test.ts +93 -0
- package/test/common.test.ts +287 -0
- package/test/composition-root.test.ts +603 -0
- package/test/config-loader.test.ts +740 -0
- package/test/config-modal.test.ts +320 -0
- package/test/config-paths.test.ts +83 -0
- package/test/config-pipeline.test.ts +90 -0
- package/test/config-reporter.test.ts +147 -0
- package/test/config-store.test.ts +466 -0
- package/test/decision-audit.test.ts +72 -0
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +656 -0
- package/test/detect-permissive-bash-fallback.test.ts +56 -0
- package/test/expand-home.test.ts +93 -0
- package/test/extension-config.test.ts +129 -0
- package/test/extension-paths.test.ts +108 -0
- package/test/forwarded-permissions/io.test.ts +251 -0
- package/test/forwarding-manager.test.ts +194 -0
- package/test/handlers/before-agent-start.test.ts +317 -0
- package/test/handlers/external-directory-integration.test.ts +623 -0
- package/test/handlers/external-directory-session-dedup.test.ts +430 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
- package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
- package/test/handlers/gates/bash-command.test.ts +191 -0
- package/test/handlers/gates/bash-external-directory.test.ts +269 -0
- package/test/handlers/gates/bash-path.test.ts +337 -0
- package/test/handlers/gates/bash-program.test.ts +410 -0
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/gates/external-directory-messages.test.ts +61 -0
- package/test/handlers/gates/external-directory.test.ts +259 -0
- package/test/handlers/gates/helpers.test.ts +177 -0
- package/test/handlers/gates/path.test.ts +294 -0
- package/test/handlers/gates/runner.test.ts +447 -0
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +131 -0
- package/test/handlers/gates/skill-read.test.ts +158 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
- package/test/handlers/gates/tool.test.ts +223 -0
- package/test/handlers/input-events.test.ts +168 -0
- package/test/handlers/input.test.ts +199 -0
- package/test/handlers/lifecycle.test.ts +221 -0
- package/test/handlers/tool-call-boundary.test.ts +145 -0
- package/test/handlers/tool-call-events.test.ts +277 -0
- package/test/handlers/tool-call.test.ts +395 -0
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/helpers/gate-fixtures.ts +323 -0
- package/test/helpers/handler-fixtures.ts +335 -0
- package/test/helpers/make-fake-pi.ts +100 -0
- package/test/helpers/manager-harness.ts +112 -0
- package/test/helpers/session-fixtures.ts +204 -0
- package/test/input-normalizer.test.ts +367 -0
- package/test/logging.test.ts +51 -0
- package/test/mcp-targets.test.ts +233 -0
- package/test/node-modules-discovery.test.ts +97 -0
- package/test/normalize.test.ts +247 -0
- package/test/path-utils.test.ts +650 -0
- package/test/pattern-suggest.test.ts +248 -0
- package/test/permission-dialog.test.ts +241 -0
- package/test/permission-event-rpc.test.ts +541 -0
- package/test/permission-events.test.ts +402 -0
- package/test/permission-forwarder.test.ts +369 -0
- package/test/permission-forwarding.test.ts +315 -0
- package/test/permission-gate.test.ts +305 -0
- package/test/permission-manager-unified.test.ts +3368 -0
- package/test/permission-merge.test.ts +61 -0
- package/test/permission-prompter.test.ts +518 -0
- package/test/permission-prompts.test.ts +363 -0
- package/test/permission-resolver.test.ts +265 -0
- package/test/permission-session.test.ts +363 -0
- package/test/permission-ui-prompt.test.ts +146 -0
- package/test/permissions-service.test.ts +177 -0
- package/test/persistent-approval-recorder.test.ts +133 -0
- package/test/pi-infrastructure-read.test.ts +369 -0
- package/test/policy-loader.test.ts +561 -0
- package/test/prompting-gateway.test.ts +230 -0
- package/test/rule.test.ts +604 -0
- package/test/scope-merge.test.ts +116 -0
- package/test/service-lifecycle.test.ts +163 -0
- package/test/service.test.ts +308 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-logger.test.ts +200 -0
- package/test/session-rules.test.ts +304 -0
- package/test/session-start.test.ts +112 -0
- package/test/skill-prompt-sanitizer.test.ts +374 -0
- package/test/status.test.ts +10 -0
- package/test/subagent-context.test.ts +326 -0
- package/test/subagent-lifecycle-events.test.ts +132 -0
- package/test/subagent-registry.test.ts +145 -0
- package/test/synthesize.test.ts +300 -0
- package/test/system-prompt-sanitizer.test.ts +382 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-input-preview.test.ts +129 -0
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/test/tool-preview-formatter.test.ts +458 -0
- package/test/tool-registry.test.ts +197 -0
- package/test/wildcard-matcher.test.ts +424 -0
- package/test/yolo-mode.test.ts +188 -0
|
@@ -0,0 +1,3368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests verifying the unified checkPermission() path.
|
|
3
|
+
*
|
|
4
|
+
* Step 5: session rules concatenated into the composed ruleset.
|
|
5
|
+
* Step 6: all five surfaces produce identical decisions to the old branching code.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { describe, expect, it, test } from "vitest";
|
|
11
|
+
import {
|
|
12
|
+
getGlobalConfigPath,
|
|
13
|
+
getProjectAgentsDir,
|
|
14
|
+
getProjectConfigPath,
|
|
15
|
+
} from "#src/config-paths";
|
|
16
|
+
import {
|
|
17
|
+
PermissionManager,
|
|
18
|
+
type ScopedPermissionManager,
|
|
19
|
+
} from "#src/permission-manager";
|
|
20
|
+
import type { Rule, Ruleset } from "#src/rule";
|
|
21
|
+
import {
|
|
22
|
+
createManager,
|
|
23
|
+
createManagerWithProject,
|
|
24
|
+
} from "#test/helpers/manager-harness";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** Manager backed by a missing config file — universal default is "ask". */
|
|
31
|
+
function makeManager(
|
|
32
|
+
mcpServerNames: readonly string[] = [],
|
|
33
|
+
): PermissionManager {
|
|
34
|
+
return new PermissionManager({
|
|
35
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
36
|
+
agentsDir: "/nonexistent/agents",
|
|
37
|
+
mcpServerNames: [...mcpServerNames],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Manager backed by a real on-disk config file written to a temp directory.
|
|
43
|
+
* Returns the manager and a cleanup function.
|
|
44
|
+
*/
|
|
45
|
+
function makeManagerWithConfig(
|
|
46
|
+
permission: Record<string, unknown>,
|
|
47
|
+
mcpServerNames: readonly string[] = [],
|
|
48
|
+
): { manager: PermissionManager; cleanup: () => void } {
|
|
49
|
+
const baseDir = mkdtempSync(join(tmpdir(), "pm-unified-test-"));
|
|
50
|
+
const agentsDir = join(baseDir, "agents");
|
|
51
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
52
|
+
const globalConfigPath = join(baseDir, "config.json");
|
|
53
|
+
writeFileSync(globalConfigPath, JSON.stringify({ permission }, null, 2));
|
|
54
|
+
const manager = new PermissionManager({
|
|
55
|
+
globalConfigPath,
|
|
56
|
+
agentsDir,
|
|
57
|
+
mcpServerNames: [...mcpServerNames],
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
manager,
|
|
61
|
+
cleanup: () => rmSync(baseDir, { recursive: true, force: true }),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sessionAllow = (surface: string, pattern: string): Rule => ({
|
|
66
|
+
surface,
|
|
67
|
+
pattern,
|
|
68
|
+
action: "allow",
|
|
69
|
+
layer: "session",
|
|
70
|
+
origin: "session",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Step 5: session rules concatenated — wins over config/default
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
describe("checkPermission — session rules", () => {
|
|
78
|
+
it("session rule wins over the universal default (external_directory)", () => {
|
|
79
|
+
const manager = makeManager();
|
|
80
|
+
const sessionRules: Ruleset = [
|
|
81
|
+
sessionAllow("external_directory", "/other/project"),
|
|
82
|
+
];
|
|
83
|
+
const result = manager.checkPermission(
|
|
84
|
+
"external_directory",
|
|
85
|
+
{ path: "/other/project" },
|
|
86
|
+
undefined,
|
|
87
|
+
sessionRules,
|
|
88
|
+
);
|
|
89
|
+
expect(result.state).toBe("allow");
|
|
90
|
+
expect(result.source).toBe("session");
|
|
91
|
+
expect(result.matchedPattern).toBe("/other/project");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("session rule wins over the universal default (skill)", () => {
|
|
95
|
+
const manager = makeManager();
|
|
96
|
+
const sessionRules: Ruleset = [sessionAllow("skill", "librarian")];
|
|
97
|
+
const result = manager.checkPermission(
|
|
98
|
+
"skill",
|
|
99
|
+
{ name: "librarian" },
|
|
100
|
+
undefined,
|
|
101
|
+
sessionRules,
|
|
102
|
+
);
|
|
103
|
+
expect(result.state).toBe("allow");
|
|
104
|
+
expect(result.source).toBe("session");
|
|
105
|
+
expect(result.matchedPattern).toBe("librarian");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("session rule wins over the universal default (bash)", () => {
|
|
109
|
+
const manager = makeManager();
|
|
110
|
+
const sessionRules: Ruleset = [sessionAllow("bash", "git status")];
|
|
111
|
+
const result = manager.checkPermission(
|
|
112
|
+
"bash",
|
|
113
|
+
{ command: "git status" },
|
|
114
|
+
undefined,
|
|
115
|
+
sessionRules,
|
|
116
|
+
);
|
|
117
|
+
expect(result.state).toBe("allow");
|
|
118
|
+
expect(result.source).toBe("session");
|
|
119
|
+
expect(result.matchedPattern).toBe("git status");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("session rule wins over the universal default (tool — read)", () => {
|
|
123
|
+
const manager = makeManager();
|
|
124
|
+
const sessionRules: Ruleset = [sessionAllow("read", "*")];
|
|
125
|
+
const result = manager.checkPermission("read", {}, undefined, sessionRules);
|
|
126
|
+
expect(result.state).toBe("allow");
|
|
127
|
+
expect(result.source).toBe("session");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("session rule wins over the universal default (mcp)", () => {
|
|
131
|
+
const manager = makeManager();
|
|
132
|
+
const sessionRules: Ruleset = [sessionAllow("mcp", "mcp_status")];
|
|
133
|
+
const result = manager.checkPermission("mcp", {}, undefined, sessionRules);
|
|
134
|
+
expect(result.state).toBe("allow");
|
|
135
|
+
expect(result.source).toBe("session");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("no session rules — falls through to default (ask)", () => {
|
|
139
|
+
const manager = makeManager();
|
|
140
|
+
const result = manager.checkPermission("read", {}, undefined, []);
|
|
141
|
+
expect(result.state).toBe("ask");
|
|
142
|
+
expect(result.source).not.toBe("session");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("session rule with narrower pattern does not block a broader command not in session", () => {
|
|
146
|
+
const manager = makeManager();
|
|
147
|
+
// Only "git status" is session-approved; "git push" should fall through to default.
|
|
148
|
+
const sessionRules: Ruleset = [sessionAllow("bash", "git status")];
|
|
149
|
+
const result = manager.checkPermission(
|
|
150
|
+
"bash",
|
|
151
|
+
{ command: "git push origin main" },
|
|
152
|
+
undefined,
|
|
153
|
+
sessionRules,
|
|
154
|
+
);
|
|
155
|
+
expect(result.state).toBe("ask");
|
|
156
|
+
expect(result.source).not.toBe("session");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("session wildcard pattern matches multiple commands", () => {
|
|
160
|
+
const manager = makeManager();
|
|
161
|
+
const sessionRules: Ruleset = [sessionAllow("bash", "git *")];
|
|
162
|
+
const push = manager.checkPermission(
|
|
163
|
+
"bash",
|
|
164
|
+
{ command: "git push origin main" },
|
|
165
|
+
undefined,
|
|
166
|
+
sessionRules,
|
|
167
|
+
);
|
|
168
|
+
const status = manager.checkPermission(
|
|
169
|
+
"bash",
|
|
170
|
+
{ command: "git status" },
|
|
171
|
+
undefined,
|
|
172
|
+
sessionRules,
|
|
173
|
+
);
|
|
174
|
+
expect(push.state).toBe("allow");
|
|
175
|
+
expect(push.source).toBe("session");
|
|
176
|
+
expect(status.state).toBe("allow");
|
|
177
|
+
expect(status.source).toBe("session");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Step 6: source field and matchedPattern for all five surfaces
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
describe("checkPermission — source derivation and matchedPattern", () => {
|
|
186
|
+
describe("external_directory (special surface)", () => {
|
|
187
|
+
it("source is 'special' for a config-matched path", () => {
|
|
188
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
189
|
+
"*": "ask",
|
|
190
|
+
external_directory: { "/trusted/*": "allow" },
|
|
191
|
+
});
|
|
192
|
+
try {
|
|
193
|
+
const result = manager.checkPermission("external_directory", {
|
|
194
|
+
path: "/trusted/repo",
|
|
195
|
+
});
|
|
196
|
+
expect(result.state).toBe("allow");
|
|
197
|
+
expect(result.source).toBe("special");
|
|
198
|
+
expect(result.matchedPattern).toBe("/trusted/*");
|
|
199
|
+
} finally {
|
|
200
|
+
cleanup();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("source is 'special' even for a default match (no config rule)", () => {
|
|
205
|
+
const manager = makeManager();
|
|
206
|
+
const result = manager.checkPermission("external_directory", {
|
|
207
|
+
path: "/some/path",
|
|
208
|
+
});
|
|
209
|
+
expect(result.state).toBe("ask");
|
|
210
|
+
expect(result.source).toBe("special");
|
|
211
|
+
expect(result.matchedPattern).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("matchedPattern is undefined for a default match", () => {
|
|
215
|
+
const manager = makeManager();
|
|
216
|
+
const result = manager.checkPermission("external_directory", {
|
|
217
|
+
path: "/unknown",
|
|
218
|
+
});
|
|
219
|
+
expect(result.matchedPattern).toBeUndefined();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("skill surface", () => {
|
|
224
|
+
it("source is 'skill' for a config-matched skill name", () => {
|
|
225
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
226
|
+
"*": "ask",
|
|
227
|
+
skill: { librarian: "allow" },
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
const result = manager.checkPermission("skill", { name: "librarian" });
|
|
231
|
+
expect(result.state).toBe("allow");
|
|
232
|
+
expect(result.source).toBe("skill");
|
|
233
|
+
expect(result.matchedPattern).toBe("librarian");
|
|
234
|
+
} finally {
|
|
235
|
+
cleanup();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("source is 'skill' even for a default match", () => {
|
|
240
|
+
const manager = makeManager();
|
|
241
|
+
const result = manager.checkPermission("skill", { name: "unknown" });
|
|
242
|
+
expect(result.state).toBe("ask");
|
|
243
|
+
expect(result.source).toBe("skill");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("bash surface", () => {
|
|
248
|
+
it("source is 'bash' and command is included in result", () => {
|
|
249
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
250
|
+
"*": "ask",
|
|
251
|
+
bash: { "git *": "allow" },
|
|
252
|
+
});
|
|
253
|
+
try {
|
|
254
|
+
const result = manager.checkPermission("bash", {
|
|
255
|
+
command: "git status",
|
|
256
|
+
});
|
|
257
|
+
expect(result.state).toBe("allow");
|
|
258
|
+
expect(result.source).toBe("bash");
|
|
259
|
+
expect(result.command).toBe("git status");
|
|
260
|
+
expect(result.matchedPattern).toBe("git *");
|
|
261
|
+
} finally {
|
|
262
|
+
cleanup();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("source is 'bash' even for a default match, command is empty string", () => {
|
|
267
|
+
const manager = makeManager();
|
|
268
|
+
const result = manager.checkPermission("bash", {});
|
|
269
|
+
expect(result.source).toBe("bash");
|
|
270
|
+
expect(result.command).toBe("");
|
|
271
|
+
expect(result.matchedPattern).toBeUndefined();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("mcp surface", () => {
|
|
276
|
+
it("source is 'mcp' for a config-matched target", () => {
|
|
277
|
+
const { manager, cleanup } = makeManagerWithConfig(
|
|
278
|
+
{ "*": "ask", mcp: { exa_search: "allow" } },
|
|
279
|
+
["exa"],
|
|
280
|
+
);
|
|
281
|
+
try {
|
|
282
|
+
const result = manager.checkPermission("mcp", {
|
|
283
|
+
tool: "exa:search",
|
|
284
|
+
server: "exa",
|
|
285
|
+
});
|
|
286
|
+
expect(result.state).toBe("allow");
|
|
287
|
+
expect(result.source).toBe("mcp");
|
|
288
|
+
expect(result.matchedPattern).toBe("exa_search");
|
|
289
|
+
expect(result.target).toBeDefined();
|
|
290
|
+
} finally {
|
|
291
|
+
cleanup();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("source is 'default' when all targets match only the synthesized default", () => {
|
|
296
|
+
const manager = makeManager();
|
|
297
|
+
const result = manager.checkPermission("mcp", { tool: "exa:search" });
|
|
298
|
+
expect(result.state).toBe("ask");
|
|
299
|
+
expect(result.source).toBe("default");
|
|
300
|
+
expect(result.matchedPattern).toBeUndefined();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("target field is set for a matched mcp call", () => {
|
|
304
|
+
const { manager, cleanup } = makeManagerWithConfig(
|
|
305
|
+
{ "*": "ask", mcp: { mcp_status: "allow" } },
|
|
306
|
+
[],
|
|
307
|
+
);
|
|
308
|
+
try {
|
|
309
|
+
const result = manager.checkPermission("mcp", {});
|
|
310
|
+
expect(result.target).toBeDefined();
|
|
311
|
+
expect(result.source).toBe("mcp");
|
|
312
|
+
} finally {
|
|
313
|
+
cleanup();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("tool surfaces", () => {
|
|
319
|
+
it("built-in tool: source is always 'tool' (config match)", () => {
|
|
320
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
321
|
+
"*": "ask",
|
|
322
|
+
read: "allow",
|
|
323
|
+
});
|
|
324
|
+
try {
|
|
325
|
+
const result = manager.checkPermission("read", {});
|
|
326
|
+
expect(result.state).toBe("allow");
|
|
327
|
+
expect(result.source).toBe("tool");
|
|
328
|
+
} finally {
|
|
329
|
+
cleanup();
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("built-in tool: source is 'tool' even for a default match", () => {
|
|
334
|
+
const manager = makeManager();
|
|
335
|
+
const result = manager.checkPermission("read", {});
|
|
336
|
+
expect(result.state).toBe("ask");
|
|
337
|
+
expect(result.source).toBe("tool");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("extension tool: source is 'default' when no config rule matches", () => {
|
|
341
|
+
const manager = makeManager();
|
|
342
|
+
const result = manager.checkPermission("my_custom_tool", {});
|
|
343
|
+
expect(result.state).toBe("ask");
|
|
344
|
+
expect(result.source).toBe("default");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("extension tool: source is 'tool' when a config rule matches", () => {
|
|
348
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
349
|
+
"*": "ask",
|
|
350
|
+
my_custom_tool: "allow",
|
|
351
|
+
});
|
|
352
|
+
try {
|
|
353
|
+
const result = manager.checkPermission("my_custom_tool", {});
|
|
354
|
+
expect(result.state).toBe("allow");
|
|
355
|
+
expect(result.source).toBe("tool");
|
|
356
|
+
} finally {
|
|
357
|
+
cleanup();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("matchedPattern for session rules across surfaces", () => {
|
|
363
|
+
it("matchedPattern is the session rule pattern for a session match (bash)", () => {
|
|
364
|
+
const manager = makeManager();
|
|
365
|
+
const sessionRules: Ruleset = [sessionAllow("bash", "git *")];
|
|
366
|
+
const result = manager.checkPermission(
|
|
367
|
+
"bash",
|
|
368
|
+
{ command: "git status" },
|
|
369
|
+
undefined,
|
|
370
|
+
sessionRules,
|
|
371
|
+
);
|
|
372
|
+
expect(result.matchedPattern).toBe("git *");
|
|
373
|
+
expect(result.source).toBe("session");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("matchedPattern is the session rule pattern for a session match (skill)", () => {
|
|
377
|
+
const manager = makeManager();
|
|
378
|
+
const sessionRules: Ruleset = [sessionAllow("skill", "librarian")];
|
|
379
|
+
const result = manager.checkPermission(
|
|
380
|
+
"skill",
|
|
381
|
+
{ name: "librarian" },
|
|
382
|
+
undefined,
|
|
383
|
+
sessionRules,
|
|
384
|
+
);
|
|
385
|
+
expect(result.matchedPattern).toBe("librarian");
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Home directory expansion in external_directory patterns
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
describe("checkPermission — home path expansion in external_directory rules", () => {
|
|
395
|
+
it("~/glob pattern allows a path under the real home directory", () => {
|
|
396
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
397
|
+
"*": "ask",
|
|
398
|
+
external_directory: { "~/trusted/*": "allow" },
|
|
399
|
+
});
|
|
400
|
+
try {
|
|
401
|
+
const result = manager.checkPermission("external_directory", {
|
|
402
|
+
path: join(homedir(), "trusted/repo"),
|
|
403
|
+
});
|
|
404
|
+
expect(result.state).toBe("allow");
|
|
405
|
+
expect(result.source).toBe("special");
|
|
406
|
+
expect(result.matchedPattern).toBe("~/trusted/*");
|
|
407
|
+
} finally {
|
|
408
|
+
cleanup();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("$HOME/glob pattern allows a path under the real home directory", () => {
|
|
413
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
414
|
+
"*": "ask",
|
|
415
|
+
external_directory: { "$HOME/trusted/*": "allow" },
|
|
416
|
+
});
|
|
417
|
+
try {
|
|
418
|
+
const result = manager.checkPermission("external_directory", {
|
|
419
|
+
path: join(homedir(), "trusted/repo"),
|
|
420
|
+
});
|
|
421
|
+
expect(result.state).toBe("allow");
|
|
422
|
+
expect(result.source).toBe("special");
|
|
423
|
+
expect(result.matchedPattern).toBe("$HOME/trusted/*");
|
|
424
|
+
} finally {
|
|
425
|
+
cleanup();
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("~/glob deny rule blocks a path under home", () => {
|
|
430
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
431
|
+
"*": "allow",
|
|
432
|
+
external_directory: { "~/private/*": "deny" },
|
|
433
|
+
});
|
|
434
|
+
try {
|
|
435
|
+
const result = manager.checkPermission("external_directory", {
|
|
436
|
+
path: join(homedir(), "private/secrets.txt"),
|
|
437
|
+
});
|
|
438
|
+
expect(result.state).toBe("deny");
|
|
439
|
+
expect(result.matchedPattern).toBe("~/private/*");
|
|
440
|
+
} finally {
|
|
441
|
+
cleanup();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("~/glob pattern does not match a path outside home", () => {
|
|
446
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
447
|
+
"*": "ask",
|
|
448
|
+
external_directory: { "~/trusted/*": "allow" },
|
|
449
|
+
});
|
|
450
|
+
try {
|
|
451
|
+
const result = manager.checkPermission("external_directory", {
|
|
452
|
+
path: "/tmp/not-home/file",
|
|
453
|
+
});
|
|
454
|
+
// Falls back to the "*": "ask" default — no allow from the ~/trusted/* rule.
|
|
455
|
+
expect(result.state).toBe("ask");
|
|
456
|
+
expect(result.matchedPattern).toBeUndefined();
|
|
457
|
+
} finally {
|
|
458
|
+
cleanup();
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// Rule origin provenance
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Build a manager with a global config and an optional project config.
|
|
469
|
+
* Returns the manager and a cleanup function.
|
|
470
|
+
*/
|
|
471
|
+
function makeManagerWithScopes(
|
|
472
|
+
globalPermission: Record<string, unknown>,
|
|
473
|
+
projectPermission?: Record<string, unknown>,
|
|
474
|
+
): { manager: PermissionManager; cleanup: () => void } {
|
|
475
|
+
const baseDir = mkdtempSync(join(tmpdir(), "pm-provenance-test-"));
|
|
476
|
+
const agentsDir = join(baseDir, "agents");
|
|
477
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
478
|
+
const globalConfigPath = join(baseDir, "global-config.json");
|
|
479
|
+
writeFileSync(
|
|
480
|
+
globalConfigPath,
|
|
481
|
+
JSON.stringify({ permission: globalPermission }, null, 2),
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
let projectGlobalConfigPath: string | undefined;
|
|
485
|
+
if (projectPermission !== undefined) {
|
|
486
|
+
projectGlobalConfigPath = join(baseDir, "project-config.json");
|
|
487
|
+
writeFileSync(
|
|
488
|
+
projectGlobalConfigPath,
|
|
489
|
+
JSON.stringify({ permission: projectPermission }, null, 2),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const manager = new PermissionManager({
|
|
494
|
+
globalConfigPath,
|
|
495
|
+
agentsDir,
|
|
496
|
+
projectGlobalConfigPath,
|
|
497
|
+
});
|
|
498
|
+
return {
|
|
499
|
+
manager,
|
|
500
|
+
cleanup: () => rmSync(baseDir, { recursive: true, force: true }),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
describe("checkPermission — rule origin provenance", () => {
|
|
505
|
+
it("single-scope global: config rule has origin 'global'", () => {
|
|
506
|
+
const { manager, cleanup } = makeManagerWithScopes({ read: "allow" });
|
|
507
|
+
try {
|
|
508
|
+
const result = manager.checkPermission("read", {});
|
|
509
|
+
expect(result.state).toBe("allow");
|
|
510
|
+
expect(result.origin).toBe("global");
|
|
511
|
+
} finally {
|
|
512
|
+
cleanup();
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("single-scope global with pattern map: origin is 'global'", () => {
|
|
517
|
+
const { manager, cleanup } = makeManagerWithScopes({
|
|
518
|
+
bash: { "git *": "allow" },
|
|
519
|
+
});
|
|
520
|
+
try {
|
|
521
|
+
const result = manager.checkPermission("bash", { command: "git status" });
|
|
522
|
+
expect(result.state).toBe("allow");
|
|
523
|
+
expect(result.origin).toBe("global");
|
|
524
|
+
} finally {
|
|
525
|
+
cleanup();
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("project overrides global: winning rule has origin 'project'", () => {
|
|
530
|
+
const { manager, cleanup } = makeManagerWithScopes(
|
|
531
|
+
{ read: "ask" },
|
|
532
|
+
{ read: "allow" },
|
|
533
|
+
);
|
|
534
|
+
try {
|
|
535
|
+
const result = manager.checkPermission("read", {});
|
|
536
|
+
expect(result.state).toBe("allow");
|
|
537
|
+
expect(result.origin).toBe("project");
|
|
538
|
+
} finally {
|
|
539
|
+
cleanup();
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("both-object merge: patterns retain their own origins", () => {
|
|
544
|
+
// global defines bash["git *"] = allow; project adds bash["rm *"] = deny.
|
|
545
|
+
// Both patterns should survive with their own origins.
|
|
546
|
+
const { manager, cleanup } = makeManagerWithScopes(
|
|
547
|
+
{ bash: { "git *": "allow" } },
|
|
548
|
+
{ bash: { "rm *": "deny" } },
|
|
549
|
+
);
|
|
550
|
+
try {
|
|
551
|
+
const gitResult = manager.checkPermission("bash", {
|
|
552
|
+
command: "git status",
|
|
553
|
+
});
|
|
554
|
+
expect(gitResult.state).toBe("allow");
|
|
555
|
+
expect(gitResult.origin).toBe("global");
|
|
556
|
+
|
|
557
|
+
const rmResult = manager.checkPermission("bash", {
|
|
558
|
+
command: "rm -rf /",
|
|
559
|
+
});
|
|
560
|
+
expect(rmResult.state).toBe("deny");
|
|
561
|
+
expect(rmResult.origin).toBe("project");
|
|
562
|
+
} finally {
|
|
563
|
+
cleanup();
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("both-object merge: project pattern overrides global pattern for same key", () => {
|
|
568
|
+
// Both scopes define bash["git *"]; project wins for that pattern.
|
|
569
|
+
const { manager, cleanup } = makeManagerWithScopes(
|
|
570
|
+
{ bash: { "git *": "ask" } },
|
|
571
|
+
{ bash: { "git *": "allow" } },
|
|
572
|
+
);
|
|
573
|
+
try {
|
|
574
|
+
const result = manager.checkPermission("bash", {
|
|
575
|
+
command: "git status",
|
|
576
|
+
});
|
|
577
|
+
expect(result.state).toBe("allow");
|
|
578
|
+
expect(result.origin).toBe("project");
|
|
579
|
+
} finally {
|
|
580
|
+
cleanup();
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("string replaces object: all patterns from replacing scope get origin 'project'", () => {
|
|
585
|
+
// global defines bash as an object; project replaces with string "allow".
|
|
586
|
+
const { manager, cleanup } = makeManagerWithScopes(
|
|
587
|
+
{ bash: { "git *": "ask", "npm *": "ask" } },
|
|
588
|
+
{ bash: "allow" },
|
|
589
|
+
);
|
|
590
|
+
try {
|
|
591
|
+
// The catch-all "*" now comes from the project scope.
|
|
592
|
+
const result = manager.checkPermission("bash", {
|
|
593
|
+
command: "anything",
|
|
594
|
+
});
|
|
595
|
+
expect(result.state).toBe("allow");
|
|
596
|
+
expect(result.origin).toBe("project");
|
|
597
|
+
} finally {
|
|
598
|
+
cleanup();
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("object replaces string: all patterns from replacing scope get origin 'project'", () => {
|
|
603
|
+
// global defines read as a string "ask"; project replaces with object.
|
|
604
|
+
const { manager, cleanup } = makeManagerWithScopes(
|
|
605
|
+
{ read: "ask" },
|
|
606
|
+
{ read: { "*": "allow" } },
|
|
607
|
+
);
|
|
608
|
+
try {
|
|
609
|
+
const result = manager.checkPermission("read", {});
|
|
610
|
+
expect(result.state).toBe("allow");
|
|
611
|
+
expect(result.origin).toBe("project");
|
|
612
|
+
} finally {
|
|
613
|
+
cleanup();
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("no config match: origin is 'builtin' (default layer)", () => {
|
|
618
|
+
// No config — falls back to synthesized default.
|
|
619
|
+
const manager = makeManager();
|
|
620
|
+
const result = manager.checkPermission("read", {});
|
|
621
|
+
expect(result.state).toBe("ask");
|
|
622
|
+
expect(result.origin).toBe("builtin");
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("session rule: origin is 'session'", () => {
|
|
626
|
+
const manager = makeManager();
|
|
627
|
+
const sessionRules: Ruleset = [
|
|
628
|
+
{
|
|
629
|
+
surface: "read",
|
|
630
|
+
pattern: "*",
|
|
631
|
+
action: "allow",
|
|
632
|
+
layer: "session",
|
|
633
|
+
origin: "session",
|
|
634
|
+
},
|
|
635
|
+
];
|
|
636
|
+
const result = manager.checkPermission("read", {}, undefined, sessionRules);
|
|
637
|
+
expect(result.state).toBe("allow");
|
|
638
|
+
expect(result.source).toBe("session");
|
|
639
|
+
expect(result.origin).toBe("session");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("universal fallback (*) set in global config carries origin 'global'", () => {
|
|
643
|
+
const { manager, cleanup } = makeManagerWithScopes({ "*": "allow" });
|
|
644
|
+
try {
|
|
645
|
+
// No explicit surface rule — hits the synthesized default derived from "*".
|
|
646
|
+
const result = manager.checkPermission("read", {});
|
|
647
|
+
expect(result.state).toBe("allow");
|
|
648
|
+
expect(result.origin).toBe("global");
|
|
649
|
+
} finally {
|
|
650
|
+
cleanup();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("universal fallback (*) overridden by project carries origin 'project'", () => {
|
|
655
|
+
const { manager, cleanup } = makeManagerWithScopes(
|
|
656
|
+
{ "*": "ask" },
|
|
657
|
+
{ "*": "allow" },
|
|
658
|
+
);
|
|
659
|
+
try {
|
|
660
|
+
const result = manager.checkPermission("read", {});
|
|
661
|
+
expect(result.state).toBe("allow");
|
|
662
|
+
expect(result.origin).toBe("project");
|
|
663
|
+
} finally {
|
|
664
|
+
cleanup();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("built-in fallback (no * in any config): origin is 'builtin'", () => {
|
|
669
|
+
// Manager with no config file — built-in "ask" default.
|
|
670
|
+
const manager = makeManager();
|
|
671
|
+
const result = manager.checkPermission("read", {});
|
|
672
|
+
expect(result.state).toBe("ask");
|
|
673
|
+
expect(result.origin).toBe("builtin");
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
678
|
+
// In-memory PolicyLoader stub tests — no filesystem required
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
import type { PolicyLoader } from "#src/permission-manager";
|
|
682
|
+
import type { ResolvedPolicyPaths } from "#src/policy-loader";
|
|
683
|
+
import type { PermissionCheckResult, ScopeConfig } from "#src/types";
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Minimal in-memory PolicyLoader for testing merge + evaluation logic
|
|
687
|
+
* without touching the filesystem.
|
|
688
|
+
*/
|
|
689
|
+
function createInMemoryPolicyLoader(
|
|
690
|
+
scopes: {
|
|
691
|
+
global?: ScopeConfig;
|
|
692
|
+
project?: ScopeConfig;
|
|
693
|
+
agent?: Record<string, ScopeConfig>;
|
|
694
|
+
projectAgent?: Record<string, ScopeConfig>;
|
|
695
|
+
} = {},
|
|
696
|
+
mcpServerNames: readonly string[] = [],
|
|
697
|
+
): PolicyLoader {
|
|
698
|
+
const issues: string[] = [];
|
|
699
|
+
return {
|
|
700
|
+
loadGlobalConfig: () => scopes.global ?? ({} as const),
|
|
701
|
+
loadProjectConfig: () => scopes.project ?? ({} as const),
|
|
702
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
|
|
703
|
+
loadAgentConfig: (name?: string) => (name && scopes.agent?.[name]) || {},
|
|
704
|
+
loadProjectAgentConfig: (name?: string) =>
|
|
705
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
|
|
706
|
+
(name && scopes.projectAgent?.[name]) || {},
|
|
707
|
+
getConfiguredMcpServerNames: () => mcpServerNames,
|
|
708
|
+
getCacheStamp: () => "in-memory",
|
|
709
|
+
getConfigIssues: () => issues,
|
|
710
|
+
getResolvedPolicyPaths: (): ResolvedPolicyPaths => ({
|
|
711
|
+
globalConfigPath: "/in-memory/config.json",
|
|
712
|
+
globalConfigExists: true,
|
|
713
|
+
projectConfigPath: null,
|
|
714
|
+
projectConfigExists: false,
|
|
715
|
+
agentsDir: "/in-memory/agents",
|
|
716
|
+
agentsDirExists: false,
|
|
717
|
+
projectAgentsDir: null,
|
|
718
|
+
projectAgentsDirExists: false,
|
|
719
|
+
}),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Create a PermissionManager backed by an in-memory PolicyLoader. */
|
|
724
|
+
function makeInMemoryManager(
|
|
725
|
+
scopes: Parameters<typeof createInMemoryPolicyLoader>[0] = {},
|
|
726
|
+
mcpServerNames: readonly string[] = [],
|
|
727
|
+
): PermissionManager {
|
|
728
|
+
return new PermissionManager({
|
|
729
|
+
policyLoader: createInMemoryPolicyLoader(scopes, mcpServerNames),
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
describe("PermissionManager with in-memory PolicyLoader", () => {
|
|
734
|
+
describe("universal fallback", () => {
|
|
735
|
+
it("defaults to ask when no config is provided", () => {
|
|
736
|
+
const manager = makeInMemoryManager();
|
|
737
|
+
const result = manager.checkPermission("read", {});
|
|
738
|
+
expect(result.state).toBe("ask");
|
|
739
|
+
expect(result.origin).toBe("builtin");
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("respects permission['*'] = 'allow' from global config", () => {
|
|
743
|
+
const manager = makeInMemoryManager({
|
|
744
|
+
global: { permission: { "*": "allow" } },
|
|
745
|
+
});
|
|
746
|
+
const result = manager.checkPermission("read", {});
|
|
747
|
+
expect(result.state).toBe("allow");
|
|
748
|
+
expect(result.origin).toBe("global");
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("respects permission['*'] = 'deny' from global config", () => {
|
|
752
|
+
const manager = makeInMemoryManager({
|
|
753
|
+
global: { permission: { "*": "deny" } },
|
|
754
|
+
});
|
|
755
|
+
const result = manager.checkPermission("write", {});
|
|
756
|
+
expect(result.state).toBe("deny");
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
describe("surface routing", () => {
|
|
761
|
+
it("bash surface routes correctly", () => {
|
|
762
|
+
const manager = makeInMemoryManager({
|
|
763
|
+
global: {
|
|
764
|
+
permission: { "*": "ask", bash: { "git *": "allow" } },
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
const result = manager.checkPermission("bash", {
|
|
768
|
+
command: "git status",
|
|
769
|
+
});
|
|
770
|
+
expect(result.state).toBe("allow");
|
|
771
|
+
expect(result.source).toBe("bash");
|
|
772
|
+
expect(result.matchedPattern).toBe("git *");
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it("tool surface routes correctly for built-in tools", () => {
|
|
776
|
+
const manager = makeInMemoryManager({
|
|
777
|
+
global: { permission: { "*": "deny", read: "allow" } },
|
|
778
|
+
});
|
|
779
|
+
const result = manager.checkPermission("read", {});
|
|
780
|
+
expect(result.state).toBe("allow");
|
|
781
|
+
expect(result.source).toBe("tool");
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("skill surface routes correctly", () => {
|
|
785
|
+
const manager = makeInMemoryManager({
|
|
786
|
+
global: {
|
|
787
|
+
permission: { "*": "ask", skill: { librarian: "allow" } },
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
const result = manager.checkPermission("skill", { name: "librarian" });
|
|
791
|
+
expect(result.state).toBe("allow");
|
|
792
|
+
expect(result.source).toBe("skill");
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it("mcp surface routes correctly", () => {
|
|
796
|
+
const manager = makeInMemoryManager(
|
|
797
|
+
{
|
|
798
|
+
global: {
|
|
799
|
+
permission: { "*": "ask", mcp: { exa_search: "allow" } },
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
["exa"],
|
|
803
|
+
);
|
|
804
|
+
const result = manager.checkPermission("mcp", {
|
|
805
|
+
tool: "exa:search",
|
|
806
|
+
server: "exa",
|
|
807
|
+
});
|
|
808
|
+
expect(result.state).toBe("allow");
|
|
809
|
+
expect(result.source).toBe("mcp");
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("external_directory surface routes correctly", () => {
|
|
813
|
+
const manager = makeInMemoryManager({
|
|
814
|
+
global: {
|
|
815
|
+
permission: {
|
|
816
|
+
"*": "ask",
|
|
817
|
+
external_directory: { "/trusted/*": "allow" },
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
const result = manager.checkPermission("external_directory", {
|
|
822
|
+
path: "/trusted/repo",
|
|
823
|
+
});
|
|
824
|
+
expect(result.state).toBe("allow");
|
|
825
|
+
expect(result.source).toBe("special");
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it("extension tools use 'default' source when no config rule matches", () => {
|
|
829
|
+
const manager = makeInMemoryManager({
|
|
830
|
+
global: { permission: { "*": "ask" } },
|
|
831
|
+
});
|
|
832
|
+
const result = manager.checkPermission("my_custom_tool", {});
|
|
833
|
+
expect(result.state).toBe("ask");
|
|
834
|
+
expect(result.source).toBe("default");
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
describe("multi-scope merge", () => {
|
|
839
|
+
it("project overrides global", () => {
|
|
840
|
+
const manager = makeInMemoryManager({
|
|
841
|
+
global: { permission: { read: "ask" } },
|
|
842
|
+
project: { permission: { read: "allow" } },
|
|
843
|
+
});
|
|
844
|
+
const result = manager.checkPermission("read", {});
|
|
845
|
+
expect(result.state).toBe("allow");
|
|
846
|
+
expect(result.origin).toBe("project");
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it("agent overrides project", () => {
|
|
850
|
+
const manager = makeInMemoryManager({
|
|
851
|
+
global: { permission: { read: "ask" } },
|
|
852
|
+
project: { permission: { read: "allow" } },
|
|
853
|
+
agent: { coder: { permission: { read: "deny" } } },
|
|
854
|
+
});
|
|
855
|
+
const result = manager.checkPermission("read", {}, "coder");
|
|
856
|
+
expect(result.state).toBe("deny");
|
|
857
|
+
expect(result.origin).toBe("agent");
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it("project-agent overrides agent", () => {
|
|
861
|
+
const manager = makeInMemoryManager({
|
|
862
|
+
global: { permission: { read: "deny" } },
|
|
863
|
+
agent: { coder: { permission: { read: "deny" } } },
|
|
864
|
+
projectAgent: { coder: { permission: { read: "allow" } } },
|
|
865
|
+
});
|
|
866
|
+
const result = manager.checkPermission("read", {}, "coder");
|
|
867
|
+
expect(result.state).toBe("allow");
|
|
868
|
+
expect(result.origin).toBe("project-agent");
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it("deep-shallow merge preserves patterns from different scopes", () => {
|
|
872
|
+
const manager = makeInMemoryManager({
|
|
873
|
+
global: { permission: { bash: { "git *": "allow" } } },
|
|
874
|
+
project: { permission: { bash: { "rm *": "deny" } } },
|
|
875
|
+
});
|
|
876
|
+
const gitResult = manager.checkPermission("bash", {
|
|
877
|
+
command: "git status",
|
|
878
|
+
});
|
|
879
|
+
expect(gitResult.state).toBe("allow");
|
|
880
|
+
expect(gitResult.origin).toBe("global");
|
|
881
|
+
|
|
882
|
+
const rmResult = manager.checkPermission("bash", {
|
|
883
|
+
command: "rm -rf /",
|
|
884
|
+
});
|
|
885
|
+
expect(rmResult.state).toBe("deny");
|
|
886
|
+
expect(rmResult.origin).toBe("project");
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it("string replaces object in override scope", () => {
|
|
890
|
+
const manager = makeInMemoryManager({
|
|
891
|
+
global: {
|
|
892
|
+
permission: { bash: { "git *": "ask", "npm *": "ask" } },
|
|
893
|
+
},
|
|
894
|
+
project: { permission: { bash: "allow" } },
|
|
895
|
+
});
|
|
896
|
+
const result = manager.checkPermission("bash", { command: "anything" });
|
|
897
|
+
expect(result.state).toBe("allow");
|
|
898
|
+
expect(result.origin).toBe("project");
|
|
899
|
+
});
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
describe("session rule composition", () => {
|
|
903
|
+
it("session rule wins over config", () => {
|
|
904
|
+
const manager = makeInMemoryManager({
|
|
905
|
+
global: { permission: { "*": "deny" } },
|
|
906
|
+
});
|
|
907
|
+
const sessionRules: Ruleset = [sessionAllow("read", "*")];
|
|
908
|
+
const result = manager.checkPermission(
|
|
909
|
+
"read",
|
|
910
|
+
{},
|
|
911
|
+
undefined,
|
|
912
|
+
sessionRules,
|
|
913
|
+
);
|
|
914
|
+
expect(result.state).toBe("allow");
|
|
915
|
+
expect(result.source).toBe("session");
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("session rule does not bleed across surfaces", () => {
|
|
919
|
+
const manager = makeInMemoryManager({
|
|
920
|
+
global: { permission: { "*": "ask" } },
|
|
921
|
+
});
|
|
922
|
+
const sessionRules: Ruleset = [sessionAllow("bash", "git *")];
|
|
923
|
+
const bashResult = manager.checkPermission(
|
|
924
|
+
"bash",
|
|
925
|
+
{ command: "git status" },
|
|
926
|
+
undefined,
|
|
927
|
+
sessionRules,
|
|
928
|
+
);
|
|
929
|
+
expect(bashResult.state).toBe("allow");
|
|
930
|
+
|
|
931
|
+
const readResult = manager.checkPermission(
|
|
932
|
+
"read",
|
|
933
|
+
{},
|
|
934
|
+
undefined,
|
|
935
|
+
sessionRules,
|
|
936
|
+
);
|
|
937
|
+
expect(readResult.state).toBe("ask");
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
describe("origin tracking", () => {
|
|
942
|
+
it("universal fallback from project carries origin 'project'", () => {
|
|
943
|
+
const manager = makeInMemoryManager({
|
|
944
|
+
global: { permission: { "*": "ask" } },
|
|
945
|
+
project: { permission: { "*": "allow" } },
|
|
946
|
+
});
|
|
947
|
+
const result = manager.checkPermission("read", {});
|
|
948
|
+
expect(result.state).toBe("allow");
|
|
949
|
+
expect(result.origin).toBe("project");
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it("session origin is 'session'", () => {
|
|
953
|
+
const manager = makeInMemoryManager();
|
|
954
|
+
const sessionRules: Ruleset = [sessionAllow("read", "*")];
|
|
955
|
+
const result = manager.checkPermission(
|
|
956
|
+
"read",
|
|
957
|
+
{},
|
|
958
|
+
undefined,
|
|
959
|
+
sessionRules,
|
|
960
|
+
);
|
|
961
|
+
expect(result.origin).toBe("session");
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
describe("getToolPermission", () => {
|
|
966
|
+
it("returns tool-level state for built-in tools", () => {
|
|
967
|
+
const manager = makeInMemoryManager({
|
|
968
|
+
global: { permission: { "*": "deny", read: "allow" } },
|
|
969
|
+
});
|
|
970
|
+
expect(manager.getToolPermission("read")).toBe("allow");
|
|
971
|
+
expect(manager.getToolPermission("write")).toBe("deny");
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
it("returns tool-level state for bash surface", () => {
|
|
975
|
+
const manager = makeInMemoryManager({
|
|
976
|
+
global: { permission: { "*": "deny", bash: "allow" } },
|
|
977
|
+
});
|
|
978
|
+
expect(manager.getToolPermission("bash")).toBe("allow");
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
describe("getComposedConfigRules", () => {
|
|
983
|
+
it("returns only config-layer rules", () => {
|
|
984
|
+
const manager = makeInMemoryManager({
|
|
985
|
+
global: {
|
|
986
|
+
permission: { "*": "ask", bash: { "git *": "allow" } },
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
const rules = manager.getComposedConfigRules();
|
|
990
|
+
expect(rules.every((r) => r.layer === "config")).toBe(true);
|
|
991
|
+
expect(
|
|
992
|
+
rules.some((r) => r.surface === "bash" && r.pattern === "git *"),
|
|
993
|
+
).toBe(true);
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
// ---------------------------------------------------------------------------
|
|
999
|
+
// Per-tool path patterns (#147)
|
|
1000
|
+
// ---------------------------------------------------------------------------
|
|
1001
|
+
|
|
1002
|
+
describe("checkPermission — per-tool path patterns", () => {
|
|
1003
|
+
it("denies read of .env when path pattern matches", () => {
|
|
1004
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1005
|
+
read: { "*": "allow", "*.env": "deny" },
|
|
1006
|
+
});
|
|
1007
|
+
try {
|
|
1008
|
+
const result = manager.checkPermission("read", { path: ".env" });
|
|
1009
|
+
expect(result.state).toBe("deny");
|
|
1010
|
+
expect(result.matchedPattern).toBe("*.env");
|
|
1011
|
+
} finally {
|
|
1012
|
+
cleanup();
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("allows read of non-.env file when .env is denied", () => {
|
|
1017
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1018
|
+
read: { "*": "allow", "*.env": "deny" },
|
|
1019
|
+
});
|
|
1020
|
+
try {
|
|
1021
|
+
const result = manager.checkPermission("read", {
|
|
1022
|
+
path: "src/main.ts",
|
|
1023
|
+
});
|
|
1024
|
+
expect(result.state).toBe("allow");
|
|
1025
|
+
} finally {
|
|
1026
|
+
cleanup();
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
it("allows write to src/ when only src/ is allowed", () => {
|
|
1031
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1032
|
+
write: { "*": "deny", "src/*": "allow" },
|
|
1033
|
+
});
|
|
1034
|
+
try {
|
|
1035
|
+
const result = manager.checkPermission("write", {
|
|
1036
|
+
path: "src/main.ts",
|
|
1037
|
+
});
|
|
1038
|
+
expect(result.state).toBe("allow");
|
|
1039
|
+
expect(result.matchedPattern).toBe("src/*");
|
|
1040
|
+
} finally {
|
|
1041
|
+
cleanup();
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it("denies write outside src/ when only src/ is allowed", () => {
|
|
1046
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1047
|
+
write: { "*": "deny", "src/*": "allow" },
|
|
1048
|
+
});
|
|
1049
|
+
try {
|
|
1050
|
+
const result = manager.checkPermission("write", {
|
|
1051
|
+
path: "vendor/lib.ts",
|
|
1052
|
+
});
|
|
1053
|
+
expect(result.state).toBe("deny");
|
|
1054
|
+
} finally {
|
|
1055
|
+
cleanup();
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
it("backward compat: 'read': 'allow' allows read of any path", () => {
|
|
1060
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1061
|
+
read: "allow",
|
|
1062
|
+
});
|
|
1063
|
+
try {
|
|
1064
|
+
const result = manager.checkPermission("read", { path: ".env" });
|
|
1065
|
+
expect(result.state).toBe("allow");
|
|
1066
|
+
} finally {
|
|
1067
|
+
cleanup();
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it("backward compat: 'read': 'deny' denies read of any path", () => {
|
|
1072
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1073
|
+
read: "deny",
|
|
1074
|
+
});
|
|
1075
|
+
try {
|
|
1076
|
+
const result = manager.checkPermission("read", {
|
|
1077
|
+
path: "src/main.ts",
|
|
1078
|
+
});
|
|
1079
|
+
expect(result.state).toBe("deny");
|
|
1080
|
+
} finally {
|
|
1081
|
+
cleanup();
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
it("session rule for specific path overrides config deny", () => {
|
|
1086
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1087
|
+
read: { "*": "allow", "*.env": "deny" },
|
|
1088
|
+
});
|
|
1089
|
+
try {
|
|
1090
|
+
const sessionRules: Ruleset = [sessionAllow("read", ".env")];
|
|
1091
|
+
const result = manager.checkPermission(
|
|
1092
|
+
"read",
|
|
1093
|
+
{ path: ".env" },
|
|
1094
|
+
undefined,
|
|
1095
|
+
sessionRules,
|
|
1096
|
+
);
|
|
1097
|
+
expect(result.state).toBe("allow");
|
|
1098
|
+
expect(result.source).toBe("session");
|
|
1099
|
+
} finally {
|
|
1100
|
+
cleanup();
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it("falls back to '*' when input.path is missing", () => {
|
|
1105
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1106
|
+
read: { "*": "allow", "*.env": "deny" },
|
|
1107
|
+
});
|
|
1108
|
+
try {
|
|
1109
|
+
const result = manager.checkPermission("read", {});
|
|
1110
|
+
expect(result.state).toBe("allow");
|
|
1111
|
+
} finally {
|
|
1112
|
+
cleanup();
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it("getToolPermission still returns surface-level state (not path-specific)", () => {
|
|
1117
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1118
|
+
read: { "*": "allow", "*.env": "deny" },
|
|
1119
|
+
});
|
|
1120
|
+
try {
|
|
1121
|
+
const toolState = manager.getToolPermission("read");
|
|
1122
|
+
expect(toolState).toBe("allow");
|
|
1123
|
+
} finally {
|
|
1124
|
+
cleanup();
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// ---------------------------------------------------------------------------
|
|
1130
|
+
// Cross-cutting path surface (#148)
|
|
1131
|
+
// ---------------------------------------------------------------------------
|
|
1132
|
+
|
|
1133
|
+
describe("cross-cutting path surface", () => {
|
|
1134
|
+
it("denies .env via the path surface", () => {
|
|
1135
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1136
|
+
path: { "*": "allow", "*.env": "deny" },
|
|
1137
|
+
read: "allow",
|
|
1138
|
+
});
|
|
1139
|
+
try {
|
|
1140
|
+
const result = manager.checkPermission("path", { path: ".env" });
|
|
1141
|
+
expect(result.state).toBe("deny");
|
|
1142
|
+
expect(result.matchedPattern).toBe("*.env");
|
|
1143
|
+
} finally {
|
|
1144
|
+
cleanup();
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it("allows non-matching paths via the path surface", () => {
|
|
1149
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1150
|
+
path: { "*": "allow", "*.env": "deny" },
|
|
1151
|
+
read: "allow",
|
|
1152
|
+
});
|
|
1153
|
+
try {
|
|
1154
|
+
const result = manager.checkPermission("path", { path: "README.md" });
|
|
1155
|
+
expect(result.state).toBe("allow");
|
|
1156
|
+
} finally {
|
|
1157
|
+
cleanup();
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it("path surface does not interfere with per-tool rules", () => {
|
|
1162
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1163
|
+
path: { "*": "allow" },
|
|
1164
|
+
read: { "*": "allow", "*.secret": "deny" },
|
|
1165
|
+
});
|
|
1166
|
+
try {
|
|
1167
|
+
// path surface allows, per-tool denies
|
|
1168
|
+
const readResult = manager.checkPermission("read", {
|
|
1169
|
+
path: "data.secret",
|
|
1170
|
+
});
|
|
1171
|
+
expect(readResult.state).toBe("deny");
|
|
1172
|
+
// path surface also allows
|
|
1173
|
+
const pathResult = manager.checkPermission("path", {
|
|
1174
|
+
path: "data.secret",
|
|
1175
|
+
});
|
|
1176
|
+
expect(pathResult.state).toBe("allow");
|
|
1177
|
+
} finally {
|
|
1178
|
+
cleanup();
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
it("getToolPermission('path') returns catch-all action", () => {
|
|
1183
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1184
|
+
path: { "*": "allow", "*.env": "deny" },
|
|
1185
|
+
});
|
|
1186
|
+
try {
|
|
1187
|
+
const toolState = manager.getToolPermission("path");
|
|
1188
|
+
expect(toolState).toBe("allow");
|
|
1189
|
+
} finally {
|
|
1190
|
+
cleanup();
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
it("session approval on path surface overrides config deny", () => {
|
|
1195
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1196
|
+
path: { "*": "allow", "*.env": "deny" },
|
|
1197
|
+
});
|
|
1198
|
+
try {
|
|
1199
|
+
const sessionRules: Ruleset = [sessionAllow("path", "/project/.env")];
|
|
1200
|
+
const result = manager.checkPermission(
|
|
1201
|
+
"path",
|
|
1202
|
+
{ path: "/project/.env" },
|
|
1203
|
+
undefined,
|
|
1204
|
+
sessionRules,
|
|
1205
|
+
);
|
|
1206
|
+
expect(result.state).toBe("allow");
|
|
1207
|
+
expect(result.source).toBe("session");
|
|
1208
|
+
} finally {
|
|
1209
|
+
cleanup();
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it("configs without path key behave identically (no path gate fires)", () => {
|
|
1214
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1215
|
+
read: "allow",
|
|
1216
|
+
});
|
|
1217
|
+
try {
|
|
1218
|
+
// path surface falls through to universal default
|
|
1219
|
+
const result = manager.checkPermission("path", { path: ".env" });
|
|
1220
|
+
expect(result.state).toBe("ask");
|
|
1221
|
+
} finally {
|
|
1222
|
+
cleanup();
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it("universal default produces undefined matchedPattern for gate skip (#58)", () => {
|
|
1227
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1228
|
+
"*": "ask",
|
|
1229
|
+
read: "allow",
|
|
1230
|
+
find: "allow",
|
|
1231
|
+
});
|
|
1232
|
+
try {
|
|
1233
|
+
// No explicit "path" key → matchedPattern must be undefined so the
|
|
1234
|
+
// path gate skips (describePathGate returns null).
|
|
1235
|
+
const result = manager.checkPermission("path", {
|
|
1236
|
+
path: "src/main.ts",
|
|
1237
|
+
});
|
|
1238
|
+
expect(result.state).toBe("ask");
|
|
1239
|
+
expect(result.matchedPattern).toBeUndefined();
|
|
1240
|
+
|
|
1241
|
+
// Meanwhile the tool-level check should allow read.
|
|
1242
|
+
const readResult = manager.checkPermission("read", {
|
|
1243
|
+
path: "src/main.ts",
|
|
1244
|
+
});
|
|
1245
|
+
expect(readResult.state).toBe("allow");
|
|
1246
|
+
expect(readResult.matchedPattern).toBe("*");
|
|
1247
|
+
} finally {
|
|
1248
|
+
cleanup();
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
// ── Deny-with-reason ────────────────────────────────────────────────────
|
|
1253
|
+
|
|
1254
|
+
it("deny-with-reason: reason threads through to PermissionCheckResult", () => {
|
|
1255
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1256
|
+
bash: { "npm *": { action: "deny", reason: "Use pnpm instead" } },
|
|
1257
|
+
});
|
|
1258
|
+
try {
|
|
1259
|
+
const result = manager.checkPermission("bash", {
|
|
1260
|
+
command: "npm install",
|
|
1261
|
+
});
|
|
1262
|
+
expect(result.state).toBe("deny");
|
|
1263
|
+
expect(result.reason).toBe("Use pnpm instead");
|
|
1264
|
+
expect(result.matchedPattern).toBe("npm *");
|
|
1265
|
+
} finally {
|
|
1266
|
+
cleanup();
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
it("deny-without-reason: reason is undefined in PermissionCheckResult", () => {
|
|
1271
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1272
|
+
bash: { "rm -rf *": "deny" },
|
|
1273
|
+
});
|
|
1274
|
+
try {
|
|
1275
|
+
const result = manager.checkPermission("bash", { command: "rm -rf /" });
|
|
1276
|
+
expect(result.state).toBe("deny");
|
|
1277
|
+
expect(result.reason).toBeUndefined();
|
|
1278
|
+
} finally {
|
|
1279
|
+
cleanup();
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
it("deny-with-reason on a non-bash surface", () => {
|
|
1284
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1285
|
+
read: {
|
|
1286
|
+
"*.env": {
|
|
1287
|
+
action: "deny",
|
|
1288
|
+
reason: "Environment files contain secrets",
|
|
1289
|
+
},
|
|
1290
|
+
},
|
|
1291
|
+
});
|
|
1292
|
+
try {
|
|
1293
|
+
const result = manager.checkPermission("read", { path: ".env" });
|
|
1294
|
+
expect(result.state).toBe("deny");
|
|
1295
|
+
expect(result.reason).toBe("Environment files contain secrets");
|
|
1296
|
+
expect(result.matchedPattern).toBe("*.env");
|
|
1297
|
+
} finally {
|
|
1298
|
+
cleanup();
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
it("non-string reason falls through to the default (malformed config)", () => {
|
|
1303
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1304
|
+
bash: { "npm *": { action: "deny", reason: 42 } },
|
|
1305
|
+
});
|
|
1306
|
+
try {
|
|
1307
|
+
const result = manager.checkPermission("bash", {
|
|
1308
|
+
command: "npm install",
|
|
1309
|
+
});
|
|
1310
|
+
expect(result.state).toBe("ask");
|
|
1311
|
+
expect(result.reason).toBeUndefined();
|
|
1312
|
+
} finally {
|
|
1313
|
+
cleanup();
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
// ── Last-match-wins ordering ────────────────────────────────────────────
|
|
1318
|
+
|
|
1319
|
+
it("last-match-wins: catch-all after deny overrides the deny", () => {
|
|
1320
|
+
// Classic misconfiguration: deny is before allow, so allow wins.
|
|
1321
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1322
|
+
path: { "*.env": "deny", "*": "allow" },
|
|
1323
|
+
});
|
|
1324
|
+
try {
|
|
1325
|
+
const result = manager.checkPermission("path", { path: ".env" });
|
|
1326
|
+
// "*" is last and matches .env → allow (the deny is shadowed)
|
|
1327
|
+
expect(result.state).toBe("allow");
|
|
1328
|
+
} finally {
|
|
1329
|
+
cleanup();
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
it("last-match-wins: deny after catch-all blocks the path", () => {
|
|
1334
|
+
// Correct ordering: catch-all first, specific deny after.
|
|
1335
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1336
|
+
path: { "*": "allow", "*.env": "deny" },
|
|
1337
|
+
});
|
|
1338
|
+
try {
|
|
1339
|
+
const result = manager.checkPermission("path", { path: ".env" });
|
|
1340
|
+
expect(result.state).toBe("deny");
|
|
1341
|
+
} finally {
|
|
1342
|
+
cleanup();
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
// ── .env.example override recipe ────────────────────────────────────────
|
|
1347
|
+
|
|
1348
|
+
it(".env.example override: denies .env and .env.local, allows .env.example", () => {
|
|
1349
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1350
|
+
path: {
|
|
1351
|
+
"*": "allow",
|
|
1352
|
+
"*.env": "deny",
|
|
1353
|
+
"*.env.*": "deny",
|
|
1354
|
+
"*.env.example": "allow",
|
|
1355
|
+
},
|
|
1356
|
+
});
|
|
1357
|
+
try {
|
|
1358
|
+
expect(manager.checkPermission("path", { path: ".env" }).state).toBe(
|
|
1359
|
+
"deny",
|
|
1360
|
+
);
|
|
1361
|
+
expect(
|
|
1362
|
+
manager.checkPermission("path", { path: ".env.local" }).state,
|
|
1363
|
+
).toBe("deny");
|
|
1364
|
+
expect(
|
|
1365
|
+
manager.checkPermission("path", { path: ".env.production" }).state,
|
|
1366
|
+
).toBe("deny");
|
|
1367
|
+
expect(manager.checkPermission("path", { path: "src/.env" }).state).toBe(
|
|
1368
|
+
"deny",
|
|
1369
|
+
);
|
|
1370
|
+
expect(
|
|
1371
|
+
manager.checkPermission("path", { path: ".env.example" }).state,
|
|
1372
|
+
).toBe("allow");
|
|
1373
|
+
expect(manager.checkPermission("path", { path: "README.md" }).state).toBe(
|
|
1374
|
+
"allow",
|
|
1375
|
+
);
|
|
1376
|
+
} finally {
|
|
1377
|
+
cleanup();
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
// ── Universal fallback interaction ──────────────────────────────────────
|
|
1382
|
+
|
|
1383
|
+
it("universal '*': 'allow' with no path key makes the path gate transparent", () => {
|
|
1384
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1385
|
+
"*": "allow",
|
|
1386
|
+
});
|
|
1387
|
+
try {
|
|
1388
|
+
const result = manager.checkPermission("path", { path: ".env" });
|
|
1389
|
+
expect(result.state).toBe("allow");
|
|
1390
|
+
} finally {
|
|
1391
|
+
cleanup();
|
|
1392
|
+
}
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
it("universal '*': 'deny' with no path key denies via path surface too", () => {
|
|
1396
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1397
|
+
"*": "deny",
|
|
1398
|
+
});
|
|
1399
|
+
try {
|
|
1400
|
+
const result = manager.checkPermission("path", { path: ".env" });
|
|
1401
|
+
expect(result.state).toBe("deny");
|
|
1402
|
+
} finally {
|
|
1403
|
+
cleanup();
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
// ── Composition: path allows, per-tool denies ──────────────────────────
|
|
1408
|
+
|
|
1409
|
+
it("per-tool deny still blocks even when path surface allows", () => {
|
|
1410
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1411
|
+
path: { "*": "allow" },
|
|
1412
|
+
read: "deny",
|
|
1413
|
+
});
|
|
1414
|
+
try {
|
|
1415
|
+
// path gate passes (allow), but tool gate denies
|
|
1416
|
+
const pathResult = manager.checkPermission("path", {
|
|
1417
|
+
path: "secret.txt",
|
|
1418
|
+
});
|
|
1419
|
+
expect(pathResult.state).toBe("allow");
|
|
1420
|
+
const readResult = manager.checkPermission("read", {
|
|
1421
|
+
path: "secret.txt",
|
|
1422
|
+
});
|
|
1423
|
+
expect(readResult.state).toBe("deny");
|
|
1424
|
+
} finally {
|
|
1425
|
+
cleanup();
|
|
1426
|
+
}
|
|
1427
|
+
});
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
// ---------------------------------------------------------------------------
|
|
1431
|
+
// Home-expansion in path values (issue #350)
|
|
1432
|
+
// ---------------------------------------------------------------------------
|
|
1433
|
+
|
|
1434
|
+
describe("cross-cutting path surface — home-expanded values", () => {
|
|
1435
|
+
it("~/... path value is denied by a ~/* rule (reported footgun)", () => {
|
|
1436
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1437
|
+
path: { "*": "allow", "~/.ssh/*": "deny" },
|
|
1438
|
+
});
|
|
1439
|
+
try {
|
|
1440
|
+
const result = manager.checkPermission("path", {
|
|
1441
|
+
path: "~/.ssh/config",
|
|
1442
|
+
});
|
|
1443
|
+
expect(result.state).toBe("deny");
|
|
1444
|
+
expect(result.matchedPattern).toBe("~/.ssh/*");
|
|
1445
|
+
} finally {
|
|
1446
|
+
cleanup();
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
it("$HOME/... path value is denied by a ~/* rule", () => {
|
|
1451
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1452
|
+
path: { "*": "allow", "~/.ssh/*": "deny" },
|
|
1453
|
+
});
|
|
1454
|
+
try {
|
|
1455
|
+
const result = manager.checkPermission("path", {
|
|
1456
|
+
path: `${homedir()}/.ssh/config`,
|
|
1457
|
+
});
|
|
1458
|
+
expect(result.state).toBe("deny");
|
|
1459
|
+
expect(result.matchedPattern).toBe("~/.ssh/*");
|
|
1460
|
+
} finally {
|
|
1461
|
+
cleanup();
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
it("$HOME/... path value matches a $HOME/* pattern rule", () => {
|
|
1466
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1467
|
+
path: { "*": "allow", "$HOME/.ssh/*": "deny" },
|
|
1468
|
+
});
|
|
1469
|
+
try {
|
|
1470
|
+
const result = manager.checkPermission("path", {
|
|
1471
|
+
path: "$HOME/.ssh/config",
|
|
1472
|
+
});
|
|
1473
|
+
expect(result.state).toBe("deny");
|
|
1474
|
+
expect(result.matchedPattern).toBe("$HOME/.ssh/*");
|
|
1475
|
+
} finally {
|
|
1476
|
+
cleanup();
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
it("already-absolute home path is still denied by ~/* rule", () => {
|
|
1481
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1482
|
+
path: { "*": "allow", "~/.ssh/*": "deny" },
|
|
1483
|
+
});
|
|
1484
|
+
try {
|
|
1485
|
+
const result = manager.checkPermission("path", {
|
|
1486
|
+
path: `${homedir()}/.ssh/config`,
|
|
1487
|
+
});
|
|
1488
|
+
expect(result.state).toBe("deny");
|
|
1489
|
+
} finally {
|
|
1490
|
+
cleanup();
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
it("non-home value is unchanged — .env still matches *.env", () => {
|
|
1495
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1496
|
+
path: { "*": "allow", "*.env": "deny" },
|
|
1497
|
+
});
|
|
1498
|
+
try {
|
|
1499
|
+
const result = manager.checkPermission("path", { path: ".env" });
|
|
1500
|
+
expect(result.state).toBe("deny");
|
|
1501
|
+
expect(result.matchedPattern).toBe("*.env");
|
|
1502
|
+
} finally {
|
|
1503
|
+
cleanup();
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
it("per-tool read surface denies ~/... path with a ~/* rule", () => {
|
|
1508
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1509
|
+
"*": "allow",
|
|
1510
|
+
read: { "*": "allow", "~/.ssh/*": "deny" },
|
|
1511
|
+
});
|
|
1512
|
+
try {
|
|
1513
|
+
const result = manager.checkPermission("read", {
|
|
1514
|
+
path: "~/.ssh/config",
|
|
1515
|
+
});
|
|
1516
|
+
expect(result.state).toBe("deny");
|
|
1517
|
+
expect(result.matchedPattern).toBe("~/.ssh/*");
|
|
1518
|
+
} finally {
|
|
1519
|
+
cleanup();
|
|
1520
|
+
}
|
|
1521
|
+
});
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
// ---------------------------------------------------------------------------
|
|
1525
|
+
// configureForCwd and agentDir construction
|
|
1526
|
+
// ---------------------------------------------------------------------------
|
|
1527
|
+
|
|
1528
|
+
describe("PermissionManager — configureForCwd and agentDir option", () => {
|
|
1529
|
+
/**
|
|
1530
|
+
* Build a temp agentDir with a global config and an optional cwd with a
|
|
1531
|
+
* project config. Returns the paths and a cleanup function.
|
|
1532
|
+
*/
|
|
1533
|
+
function makeAgentDirSetup(opts: {
|
|
1534
|
+
globalPermission: Record<string, unknown>;
|
|
1535
|
+
projectPermission?: Record<string, unknown>;
|
|
1536
|
+
}): {
|
|
1537
|
+
agentDir: string;
|
|
1538
|
+
cwd: string;
|
|
1539
|
+
globalConfigPath: string;
|
|
1540
|
+
projectConfigPath: string;
|
|
1541
|
+
cleanup: () => void;
|
|
1542
|
+
} {
|
|
1543
|
+
const baseDir = mkdtempSync(join(tmpdir(), "pm-agent-dir-test-"));
|
|
1544
|
+
const agentDir = join(baseDir, "agent");
|
|
1545
|
+
const cwd = join(baseDir, "project");
|
|
1546
|
+
|
|
1547
|
+
// Write global config under getGlobalConfigPath(agentDir)
|
|
1548
|
+
const globalConfigPath = getGlobalConfigPath(agentDir);
|
|
1549
|
+
mkdirSync(join(agentDir, "extensions", "pi-permission-system"), {
|
|
1550
|
+
recursive: true,
|
|
1551
|
+
});
|
|
1552
|
+
writeFileSync(
|
|
1553
|
+
globalConfigPath,
|
|
1554
|
+
JSON.stringify({ permission: opts.globalPermission }, null, 2),
|
|
1555
|
+
);
|
|
1556
|
+
|
|
1557
|
+
// Write project config under getProjectConfigPath(cwd)
|
|
1558
|
+
const projectConfigPath = getProjectConfigPath(cwd);
|
|
1559
|
+
mkdirSync(join(cwd, ".pi", "extensions", "pi-permission-system"), {
|
|
1560
|
+
recursive: true,
|
|
1561
|
+
});
|
|
1562
|
+
if (opts.projectPermission) {
|
|
1563
|
+
writeFileSync(
|
|
1564
|
+
projectConfigPath,
|
|
1565
|
+
JSON.stringify({ permission: opts.projectPermission }, null, 2),
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
return {
|
|
1570
|
+
agentDir,
|
|
1571
|
+
cwd,
|
|
1572
|
+
globalConfigPath,
|
|
1573
|
+
projectConfigPath,
|
|
1574
|
+
cleanup: () => rmSync(baseDir, { recursive: true, force: true }),
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
it("ScopedPermissionManager is exported and PermissionManager satisfies it", () => {
|
|
1579
|
+
// Type-level assertion: assigning PermissionManager to ScopedPermissionManager compiles.
|
|
1580
|
+
const manager = new PermissionManager({
|
|
1581
|
+
globalConfigPath: "/nonexistent/config.json",
|
|
1582
|
+
agentsDir: "/nonexistent/agents",
|
|
1583
|
+
});
|
|
1584
|
+
const scoped: ScopedPermissionManager = manager;
|
|
1585
|
+
expect(typeof scoped.configureForCwd).toBe("function");
|
|
1586
|
+
expect(typeof scoped.checkPermission).toBe("function");
|
|
1587
|
+
expect(typeof scoped.getToolPermission).toBe("function");
|
|
1588
|
+
expect(typeof scoped.getConfigIssues).toBe("function");
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
it("construction with { agentDir } reads global config from getGlobalConfigPath(agentDir)", () => {
|
|
1592
|
+
const { agentDir, cleanup } = makeAgentDirSetup({
|
|
1593
|
+
globalPermission: { read: "deny" },
|
|
1594
|
+
});
|
|
1595
|
+
try {
|
|
1596
|
+
const manager = new PermissionManager({ agentDir });
|
|
1597
|
+
const result = manager.checkPermission("read", { path: "foo.txt" });
|
|
1598
|
+
expect(result.state).toBe("deny");
|
|
1599
|
+
} finally {
|
|
1600
|
+
cleanup();
|
|
1601
|
+
}
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
it("configureForCwd(cwd) applies project config (project overrides global)", () => {
|
|
1605
|
+
const { agentDir, cwd, cleanup } = makeAgentDirSetup({
|
|
1606
|
+
globalPermission: { read: "deny" },
|
|
1607
|
+
projectPermission: { read: "allow" },
|
|
1608
|
+
});
|
|
1609
|
+
try {
|
|
1610
|
+
const manager = new PermissionManager({ agentDir });
|
|
1611
|
+
// Before configureForCwd: global policy applies
|
|
1612
|
+
expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
|
|
1613
|
+
"deny",
|
|
1614
|
+
);
|
|
1615
|
+
|
|
1616
|
+
manager.configureForCwd(cwd);
|
|
1617
|
+
|
|
1618
|
+
// After configureForCwd: project override applies (last-match-wins)
|
|
1619
|
+
expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
|
|
1620
|
+
"allow",
|
|
1621
|
+
);
|
|
1622
|
+
} finally {
|
|
1623
|
+
cleanup();
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
it("configureForCwd(undefined) reverts to global-only", () => {
|
|
1628
|
+
const { agentDir, cwd, cleanup } = makeAgentDirSetup({
|
|
1629
|
+
globalPermission: { read: "deny" },
|
|
1630
|
+
projectPermission: { read: "allow" },
|
|
1631
|
+
});
|
|
1632
|
+
try {
|
|
1633
|
+
const manager = new PermissionManager({ agentDir });
|
|
1634
|
+
manager.configureForCwd(cwd);
|
|
1635
|
+
expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
|
|
1636
|
+
"allow",
|
|
1637
|
+
);
|
|
1638
|
+
|
|
1639
|
+
manager.configureForCwd(undefined);
|
|
1640
|
+
|
|
1641
|
+
// After reverting: global policy applies again
|
|
1642
|
+
expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
|
|
1643
|
+
"deny",
|
|
1644
|
+
);
|
|
1645
|
+
} finally {
|
|
1646
|
+
cleanup();
|
|
1647
|
+
}
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
it("configureForCwd clears the resolved-permissions cache", () => {
|
|
1651
|
+
const { agentDir, globalConfigPath, cleanup } = makeAgentDirSetup({
|
|
1652
|
+
globalPermission: { read: "allow" },
|
|
1653
|
+
});
|
|
1654
|
+
try {
|
|
1655
|
+
const manager = new PermissionManager({ agentDir });
|
|
1656
|
+
// Warm the cache
|
|
1657
|
+
expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
|
|
1658
|
+
"allow",
|
|
1659
|
+
);
|
|
1660
|
+
// Update global config on disk to deny read
|
|
1661
|
+
writeFileSync(
|
|
1662
|
+
globalConfigPath,
|
|
1663
|
+
JSON.stringify({ permission: { read: "deny" } }, null, 2),
|
|
1664
|
+
);
|
|
1665
|
+
// configureForCwd clears cache + rebuilds loader
|
|
1666
|
+
manager.configureForCwd(undefined);
|
|
1667
|
+
// Should pick up the changed global config
|
|
1668
|
+
expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
|
|
1669
|
+
"deny",
|
|
1670
|
+
);
|
|
1671
|
+
} finally {
|
|
1672
|
+
cleanup();
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
it("configureForCwd(cwd) derives projectAgentsDir at <cwd>/.pi/agents (regression: #428)", () => {
|
|
1677
|
+
// Bug: old code derived <cwd>/.pi/agent/agents instead of <cwd>/.pi/agents.
|
|
1678
|
+
// This test pins the correct path and verifies agentsDir is unchanged.
|
|
1679
|
+
const { agentDir, cwd, cleanup } = makeAgentDirSetup({
|
|
1680
|
+
globalPermission: { read: "allow" },
|
|
1681
|
+
});
|
|
1682
|
+
try {
|
|
1683
|
+
const manager = new PermissionManager({ agentDir });
|
|
1684
|
+
manager.configureForCwd(cwd);
|
|
1685
|
+
const paths = manager.getResolvedPolicyPaths();
|
|
1686
|
+
expect(paths.projectAgentsDir).toBe(getProjectAgentsDir(cwd));
|
|
1687
|
+
expect(paths.agentsDir).toBe(join(agentDir, "agents"));
|
|
1688
|
+
} finally {
|
|
1689
|
+
cleanup();
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
it("configureForCwd(cwd) enforces permission: frontmatter from <cwd>/.pi/agents/<agent>.md (regression: #428)", () => {
|
|
1694
|
+
// Bug: wrong directory meant project-agent frontmatter was never loaded.
|
|
1695
|
+
const { agentDir, cwd, cleanup } = makeAgentDirSetup({
|
|
1696
|
+
globalPermission: { read: "allow" },
|
|
1697
|
+
});
|
|
1698
|
+
try {
|
|
1699
|
+
// Write a project agent definition with a deny override.
|
|
1700
|
+
const projectAgentsDir = getProjectAgentsDir(cwd);
|
|
1701
|
+
mkdirSync(projectAgentsDir, { recursive: true });
|
|
1702
|
+
writeFileSync(
|
|
1703
|
+
join(projectAgentsDir, "coder.md"),
|
|
1704
|
+
"---\npermission:\n read: deny\n---\n# Coder\n",
|
|
1705
|
+
);
|
|
1706
|
+
|
|
1707
|
+
const manager = new PermissionManager({ agentDir });
|
|
1708
|
+
manager.configureForCwd(cwd);
|
|
1709
|
+
|
|
1710
|
+
// Without an agent name: global allow applies.
|
|
1711
|
+
expect(manager.checkPermission("read", { path: "foo.txt" }).state).toBe(
|
|
1712
|
+
"allow",
|
|
1713
|
+
);
|
|
1714
|
+
// With the "coder" agent: project-agent deny overrides global allow.
|
|
1715
|
+
expect(
|
|
1716
|
+
manager.checkPermission("read", { path: "foo.txt" }, "coder").state,
|
|
1717
|
+
).toBe("deny");
|
|
1718
|
+
} finally {
|
|
1719
|
+
cleanup();
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
// ---------------------------------------------------------------------------
|
|
1725
|
+
// Project-level and per-agent config scope — moved from catch-all (#342)
|
|
1726
|
+
// ---------------------------------------------------------------------------
|
|
1727
|
+
|
|
1728
|
+
test("Project-level config overrides base bash patterns", () => {
|
|
1729
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1730
|
+
{
|
|
1731
|
+
permission: {
|
|
1732
|
+
"*": "allow",
|
|
1733
|
+
bash: { "*": "ask", "rm -rf *": "deny" },
|
|
1734
|
+
},
|
|
1735
|
+
},
|
|
1736
|
+
{},
|
|
1737
|
+
{
|
|
1738
|
+
projectConfig: {
|
|
1739
|
+
permission: { bash: { "rm -rf build": "allow" } },
|
|
1740
|
+
},
|
|
1741
|
+
},
|
|
1742
|
+
);
|
|
1743
|
+
|
|
1744
|
+
try {
|
|
1745
|
+
const allowed = manager.checkPermission("bash", {
|
|
1746
|
+
command: "rm -rf build",
|
|
1747
|
+
});
|
|
1748
|
+
expect(allowed.state).toBe("allow");
|
|
1749
|
+
expect(allowed.matchedPattern).toBe("rm -rf build");
|
|
1750
|
+
|
|
1751
|
+
const denied = manager.checkPermission("bash", {
|
|
1752
|
+
command: "rm -rf node_modules",
|
|
1753
|
+
});
|
|
1754
|
+
expect(denied.state).toBe("deny");
|
|
1755
|
+
expect(denied.matchedPattern).toBe("rm -rf *");
|
|
1756
|
+
} finally {
|
|
1757
|
+
cleanup();
|
|
1758
|
+
}
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
test("System-agent config overrides project-level bash patterns", () => {
|
|
1762
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1763
|
+
{
|
|
1764
|
+
permission: { "*": "allow", bash: "ask" },
|
|
1765
|
+
},
|
|
1766
|
+
{
|
|
1767
|
+
reviewer: `---
|
|
1768
|
+
name: reviewer
|
|
1769
|
+
permission:
|
|
1770
|
+
bash:
|
|
1771
|
+
"git log *": allow
|
|
1772
|
+
---
|
|
1773
|
+
`,
|
|
1774
|
+
},
|
|
1775
|
+
{
|
|
1776
|
+
projectConfig: {
|
|
1777
|
+
permission: { bash: { "git *": "deny" } },
|
|
1778
|
+
},
|
|
1779
|
+
},
|
|
1780
|
+
);
|
|
1781
|
+
|
|
1782
|
+
try {
|
|
1783
|
+
const allowed = manager.checkPermission(
|
|
1784
|
+
"bash",
|
|
1785
|
+
{ command: "git log --oneline" },
|
|
1786
|
+
"reviewer",
|
|
1787
|
+
);
|
|
1788
|
+
expect(allowed.state).toBe("allow");
|
|
1789
|
+
expect(allowed.matchedPattern).toBe("git log *");
|
|
1790
|
+
|
|
1791
|
+
const denied = manager.checkPermission(
|
|
1792
|
+
"bash",
|
|
1793
|
+
{ command: "git status" },
|
|
1794
|
+
"reviewer",
|
|
1795
|
+
);
|
|
1796
|
+
expect(denied.state).toBe("deny");
|
|
1797
|
+
expect(denied.matchedPattern).toBe("git *");
|
|
1798
|
+
} finally {
|
|
1799
|
+
cleanup();
|
|
1800
|
+
}
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
test("Project-agent config overrides system-agent tool rules", () => {
|
|
1804
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1805
|
+
{
|
|
1806
|
+
permission: { "*": "ask" },
|
|
1807
|
+
},
|
|
1808
|
+
{
|
|
1809
|
+
reviewer: `---
|
|
1810
|
+
name: reviewer
|
|
1811
|
+
permission:
|
|
1812
|
+
read: deny
|
|
1813
|
+
---
|
|
1814
|
+
`,
|
|
1815
|
+
},
|
|
1816
|
+
{
|
|
1817
|
+
projectAgentFiles: {
|
|
1818
|
+
reviewer: `---
|
|
1819
|
+
name: reviewer
|
|
1820
|
+
permission:
|
|
1821
|
+
read: allow
|
|
1822
|
+
---
|
|
1823
|
+
`,
|
|
1824
|
+
},
|
|
1825
|
+
},
|
|
1826
|
+
);
|
|
1827
|
+
|
|
1828
|
+
try {
|
|
1829
|
+
const result = manager.checkPermission("read", {}, "reviewer");
|
|
1830
|
+
expect(result.state).toBe("allow");
|
|
1831
|
+
expect(result.source).toBe("tool");
|
|
1832
|
+
} finally {
|
|
1833
|
+
cleanup();
|
|
1834
|
+
}
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
test("Full precedence chain base < project < system-agent < project-agent for universal default", () => {
|
|
1838
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1839
|
+
{
|
|
1840
|
+
permission: { "*": "deny" },
|
|
1841
|
+
},
|
|
1842
|
+
{
|
|
1843
|
+
reviewer: `---
|
|
1844
|
+
name: reviewer
|
|
1845
|
+
permission:
|
|
1846
|
+
"*": ask
|
|
1847
|
+
---
|
|
1848
|
+
`,
|
|
1849
|
+
},
|
|
1850
|
+
{
|
|
1851
|
+
projectConfig: {
|
|
1852
|
+
permission: { "*": "allow" },
|
|
1853
|
+
},
|
|
1854
|
+
projectAgentFiles: {
|
|
1855
|
+
reviewer: `---
|
|
1856
|
+
name: reviewer
|
|
1857
|
+
permission:
|
|
1858
|
+
"*": deny
|
|
1859
|
+
---
|
|
1860
|
+
`,
|
|
1861
|
+
},
|
|
1862
|
+
},
|
|
1863
|
+
);
|
|
1864
|
+
|
|
1865
|
+
try {
|
|
1866
|
+
const reviewerResult = manager.checkPermission(
|
|
1867
|
+
"custom_extension_tool",
|
|
1868
|
+
{},
|
|
1869
|
+
"reviewer",
|
|
1870
|
+
);
|
|
1871
|
+
expect(reviewerResult.state).toBe("deny");
|
|
1872
|
+
expect(reviewerResult.source).toBe("default");
|
|
1873
|
+
|
|
1874
|
+
const globalResult = manager.checkPermission("custom_extension_tool", {});
|
|
1875
|
+
expect(globalResult.state).toBe("allow");
|
|
1876
|
+
expect(globalResult.source).toBe("default");
|
|
1877
|
+
} finally {
|
|
1878
|
+
cleanup();
|
|
1879
|
+
}
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
test("Project-agent applies even without a matching system-agent file", () => {
|
|
1883
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
1884
|
+
{
|
|
1885
|
+
permission: { "*": "allow" },
|
|
1886
|
+
},
|
|
1887
|
+
{},
|
|
1888
|
+
{
|
|
1889
|
+
projectAgentFiles: {
|
|
1890
|
+
reviewer: `---
|
|
1891
|
+
name: reviewer
|
|
1892
|
+
permission:
|
|
1893
|
+
read: deny
|
|
1894
|
+
---
|
|
1895
|
+
`,
|
|
1896
|
+
},
|
|
1897
|
+
},
|
|
1898
|
+
);
|
|
1899
|
+
|
|
1900
|
+
try {
|
|
1901
|
+
const agentResult = manager.checkPermission("read", {}, "reviewer");
|
|
1902
|
+
expect(agentResult.state).toBe("deny");
|
|
1903
|
+
expect(agentResult.source).toBe("tool");
|
|
1904
|
+
|
|
1905
|
+
const globalResult = manager.checkPermission("read", {});
|
|
1906
|
+
expect(globalResult.state).toBe("allow");
|
|
1907
|
+
expect(globalResult.source).toBe("tool");
|
|
1908
|
+
} finally {
|
|
1909
|
+
cleanup();
|
|
1910
|
+
}
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
// ---------------------------------------------------------------------------
|
|
1914
|
+
// PermissionManager surface resolution — moved from catch-all (#342)
|
|
1915
|
+
// ---------------------------------------------------------------------------
|
|
1916
|
+
|
|
1917
|
+
test("PermissionManager canonical built-in permission checking", () => {
|
|
1918
|
+
const { manager, cleanup } = createManager({
|
|
1919
|
+
permission: { "*": "deny", read: "allow" },
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
try {
|
|
1923
|
+
const readResult = manager.checkPermission("read", {});
|
|
1924
|
+
expect(readResult.state).toBe("allow");
|
|
1925
|
+
expect(readResult.source).toBe("tool");
|
|
1926
|
+
|
|
1927
|
+
const writeResult = manager.checkPermission("write", {});
|
|
1928
|
+
expect(writeResult.state).toBe("deny");
|
|
1929
|
+
expect(writeResult.source).toBe("tool");
|
|
1930
|
+
} finally {
|
|
1931
|
+
cleanup();
|
|
1932
|
+
}
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
test("multiline bash command resolves to allow via universal fallback", () => {
|
|
1936
|
+
// Regression test for #73: node -e "..." with embedded newlines was
|
|
1937
|
+
// falling through to the hard-coded 'ask' default because wildcardMatch
|
|
1938
|
+
// used /^.*$/ (no dotAll), which does not match '\n'.
|
|
1939
|
+
const { manager, cleanup } = createManager({
|
|
1940
|
+
permission: {
|
|
1941
|
+
"*": "allow",
|
|
1942
|
+
bash: { "rm -rf *": "deny", "sudo *": "ask" },
|
|
1943
|
+
},
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
try {
|
|
1947
|
+
const command =
|
|
1948
|
+
"node -e \"\nimport('x').then(() => {\n console.log('done');\n});\n\"";
|
|
1949
|
+
const result = manager.checkPermission("bash", { command });
|
|
1950
|
+
expect(result.state).toBe("allow");
|
|
1951
|
+
} finally {
|
|
1952
|
+
cleanup();
|
|
1953
|
+
}
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
test("Bash specific deny patterns override catch-all within the same config", () => {
|
|
1957
|
+
// In the flat format, patterns within a surface map are ordered by insertion.
|
|
1958
|
+
// Last-match-wins means specific patterns placed AFTER the catch-all override it.
|
|
1959
|
+
const { manager, cleanup } = createManager({
|
|
1960
|
+
permission: {
|
|
1961
|
+
"*": "ask",
|
|
1962
|
+
bash: { "*": "allow", "rm -rf *": "deny" },
|
|
1963
|
+
},
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
try {
|
|
1967
|
+
const denied = manager.checkPermission("bash", {
|
|
1968
|
+
command: "rm -rf build",
|
|
1969
|
+
});
|
|
1970
|
+
expect(denied.state).toBe("deny");
|
|
1971
|
+
expect(denied.source).toBe("bash");
|
|
1972
|
+
expect(denied.matchedPattern).toBe("rm -rf *");
|
|
1973
|
+
|
|
1974
|
+
const allowed = manager.checkPermission("bash", { command: "echo hello" });
|
|
1975
|
+
expect(allowed.state).toBe("allow");
|
|
1976
|
+
expect(allowed.source).toBe("bash");
|
|
1977
|
+
expect(allowed.matchedPattern).toBe("*");
|
|
1978
|
+
} finally {
|
|
1979
|
+
cleanup();
|
|
1980
|
+
}
|
|
1981
|
+
});
|
|
1982
|
+
|
|
1983
|
+
test("MCP wildcard matching uses the registered mcp tool", () => {
|
|
1984
|
+
const { manager, cleanup } = createManager({
|
|
1985
|
+
permission: {
|
|
1986
|
+
"*": "ask",
|
|
1987
|
+
mcp: { "*": "deny", "research_*": "ask", "research_query-*": "allow" },
|
|
1988
|
+
},
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
try {
|
|
1992
|
+
const queryDocs = manager.checkPermission("mcp", {
|
|
1993
|
+
tool: "research:query-docs",
|
|
1994
|
+
});
|
|
1995
|
+
expect(queryDocs.state).toBe("allow");
|
|
1996
|
+
expect(queryDocs.source).toBe("mcp");
|
|
1997
|
+
expect(queryDocs.matchedPattern).toBe("research_query-*");
|
|
1998
|
+
expect(queryDocs.target).toBe("research_query-docs");
|
|
1999
|
+
|
|
2000
|
+
const resolve2 = manager.checkPermission("mcp", {
|
|
2001
|
+
tool: "research:resolve-context",
|
|
2002
|
+
});
|
|
2003
|
+
expect(resolve2.state).toBe("ask");
|
|
2004
|
+
expect(resolve2.matchedPattern).toBe("research_*");
|
|
2005
|
+
expect(resolve2.target).toBe("research_resolve-context");
|
|
2006
|
+
|
|
2007
|
+
const unknown = manager.checkPermission("mcp", {
|
|
2008
|
+
tool: "search:provider",
|
|
2009
|
+
});
|
|
2010
|
+
expect(unknown.state).toBe("deny");
|
|
2011
|
+
expect(unknown.matchedPattern).toBe("*");
|
|
2012
|
+
expect(unknown.target).toBe("search_provider");
|
|
2013
|
+
} finally {
|
|
2014
|
+
cleanup();
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
test("Arbitrary extension tools use exact-name tool permissions instead of MCP fallback", () => {
|
|
2019
|
+
const { manager, cleanup } = createManager({
|
|
2020
|
+
permission: {
|
|
2021
|
+
"*": "deny",
|
|
2022
|
+
third_party_tool: "allow",
|
|
2023
|
+
mcp: { "*": "deny" },
|
|
2024
|
+
},
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
try {
|
|
2028
|
+
const allowed = manager.checkPermission("third_party_tool", {});
|
|
2029
|
+
expect(allowed.state).toBe("allow");
|
|
2030
|
+
expect(allowed.source).toBe("tool");
|
|
2031
|
+
|
|
2032
|
+
// another_extension_tool has no explicit rule — falls through to the
|
|
2033
|
+
// universal default (permission["*"] = "deny") with source "default".
|
|
2034
|
+
const fallback = manager.checkPermission("another_extension_tool", {});
|
|
2035
|
+
expect(fallback.state).toBe("deny");
|
|
2036
|
+
expect(fallback.source).toBe("default");
|
|
2037
|
+
} finally {
|
|
2038
|
+
cleanup();
|
|
2039
|
+
}
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
test("Skill permission matching", () => {
|
|
2043
|
+
const { manager, cleanup } = createManager({
|
|
2044
|
+
permission: {
|
|
2045
|
+
"*": "ask",
|
|
2046
|
+
skill: {
|
|
2047
|
+
"*": "ask",
|
|
2048
|
+
"web-*": "deny",
|
|
2049
|
+
"requesting-code-review": "allow",
|
|
2050
|
+
},
|
|
2051
|
+
},
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
try {
|
|
2055
|
+
const allowed = manager.checkPermission("skill", {
|
|
2056
|
+
name: "requesting-code-review",
|
|
2057
|
+
});
|
|
2058
|
+
expect(allowed.state).toBe("allow");
|
|
2059
|
+
expect(allowed.matchedPattern).toBe("requesting-code-review");
|
|
2060
|
+
expect(allowed.source).toBe("skill");
|
|
2061
|
+
|
|
2062
|
+
const denied = manager.checkPermission("skill", {
|
|
2063
|
+
name: "web-design-guidelines",
|
|
2064
|
+
});
|
|
2065
|
+
expect(denied.state).toBe("deny");
|
|
2066
|
+
expect(denied.matchedPattern).toBe("web-*");
|
|
2067
|
+
|
|
2068
|
+
const fallback = manager.checkPermission("skill", {
|
|
2069
|
+
name: "unknown-skill",
|
|
2070
|
+
});
|
|
2071
|
+
expect(fallback.state).toBe("ask");
|
|
2072
|
+
expect(fallback.matchedPattern).toBe("*");
|
|
2073
|
+
} finally {
|
|
2074
|
+
cleanup();
|
|
2075
|
+
}
|
|
2076
|
+
});
|
|
2077
|
+
|
|
2078
|
+
test("MCP proxy tool infers server-prefixed aliases from configured server names", () => {
|
|
2079
|
+
const { manager, cleanup } = createManager(
|
|
2080
|
+
{
|
|
2081
|
+
permission: {
|
|
2082
|
+
"*": "ask",
|
|
2083
|
+
mcp: { "exa_*": "deny", exa_get_code_context_exa: "allow" },
|
|
2084
|
+
},
|
|
2085
|
+
},
|
|
2086
|
+
{},
|
|
2087
|
+
{ mcpServerNames: ["exa"] },
|
|
2088
|
+
);
|
|
2089
|
+
|
|
2090
|
+
try {
|
|
2091
|
+
const result = manager.checkPermission("mcp", {
|
|
2092
|
+
tool: "get_code_context_exa",
|
|
2093
|
+
});
|
|
2094
|
+
expect(result.state).toBe("allow");
|
|
2095
|
+
expect(result.source).toBe("mcp");
|
|
2096
|
+
expect(result.matchedPattern).toBe("exa_get_code_context_exa");
|
|
2097
|
+
expect(result.target).toBe("exa_get_code_context_exa");
|
|
2098
|
+
} finally {
|
|
2099
|
+
cleanup();
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
test("MCP server names in settings.json are not used — only mcp.json is consulted", () => {
|
|
2104
|
+
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-test-"));
|
|
2105
|
+
const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
|
|
2106
|
+
const mcpConfigPath = join(baseDir, "mcp.json");
|
|
2107
|
+
const settingsJsonPath = join(baseDir, "settings.json");
|
|
2108
|
+
const agentsDir = join(baseDir, "agents");
|
|
2109
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
2110
|
+
|
|
2111
|
+
const config: ScopeConfig = {
|
|
2112
|
+
permission: { "*": "ask", mcp: { "legacy-server_*": "allow" } },
|
|
2113
|
+
};
|
|
2114
|
+
|
|
2115
|
+
writeFileSync(
|
|
2116
|
+
globalConfigPath,
|
|
2117
|
+
`${JSON.stringify(config, null, 2)}\n`,
|
|
2118
|
+
"utf8",
|
|
2119
|
+
);
|
|
2120
|
+
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: {} }), "utf8");
|
|
2121
|
+
writeFileSync(
|
|
2122
|
+
settingsJsonPath,
|
|
2123
|
+
JSON.stringify({ mcpServers: { "legacy-server": {} } }),
|
|
2124
|
+
"utf8",
|
|
2125
|
+
);
|
|
2126
|
+
|
|
2127
|
+
const manager = new PermissionManager({
|
|
2128
|
+
globalConfigPath,
|
|
2129
|
+
agentsDir,
|
|
2130
|
+
globalMcpConfigPath: mcpConfigPath,
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
try {
|
|
2134
|
+
const result = manager.checkPermission("mcp", {
|
|
2135
|
+
tool: "some_tool_legacy-server",
|
|
2136
|
+
});
|
|
2137
|
+
expect(result.state).toBe("ask");
|
|
2138
|
+
} finally {
|
|
2139
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
2140
|
+
}
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
test("MCP describe mode normalizes qualified tool names without duplicating server prefixes", () => {
|
|
2144
|
+
const { manager, cleanup } = createManager(
|
|
2145
|
+
{
|
|
2146
|
+
permission: {
|
|
2147
|
+
"*": "ask",
|
|
2148
|
+
mcp: { "exa_*": "deny", exa_web_search_exa: "allow" },
|
|
2149
|
+
},
|
|
2150
|
+
},
|
|
2151
|
+
{},
|
|
2152
|
+
{ mcpServerNames: ["exa"] },
|
|
2153
|
+
);
|
|
2154
|
+
|
|
2155
|
+
try {
|
|
2156
|
+
const result = manager.checkPermission("mcp", {
|
|
2157
|
+
describe: "exa:web_search_exa",
|
|
2158
|
+
server: "exa",
|
|
2159
|
+
});
|
|
2160
|
+
expect(result.state).toBe("allow");
|
|
2161
|
+
expect(result.source).toBe("mcp");
|
|
2162
|
+
expect(result.matchedPattern).toBe("exa_web_search_exa");
|
|
2163
|
+
expect(result.target).toBe("exa_web_search_exa");
|
|
2164
|
+
} finally {
|
|
2165
|
+
cleanup();
|
|
2166
|
+
}
|
|
2167
|
+
});
|
|
2168
|
+
|
|
2169
|
+
test("Canonical tools map directly without legacy aliases", () => {
|
|
2170
|
+
const { manager, cleanup } = createManager({
|
|
2171
|
+
permission: { "*": "ask", find: "allow", ls: "deny" },
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
try {
|
|
2175
|
+
const findResult = manager.checkPermission("find", {});
|
|
2176
|
+
expect(findResult.state).toBe("allow");
|
|
2177
|
+
expect(findResult.source).toBe("tool");
|
|
2178
|
+
|
|
2179
|
+
const lsResult = manager.checkPermission("ls", {});
|
|
2180
|
+
expect(lsResult.state).toBe("deny");
|
|
2181
|
+
expect(lsResult.source).toBe("tool");
|
|
2182
|
+
} finally {
|
|
2183
|
+
cleanup();
|
|
2184
|
+
}
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
test("mcp catch-all acts as fallback for unmatched MCP targets", () => {
|
|
2188
|
+
const { manager, cleanup } = createManager(
|
|
2189
|
+
{
|
|
2190
|
+
permission: { "*": "ask" },
|
|
2191
|
+
},
|
|
2192
|
+
{
|
|
2193
|
+
reviewer: `---
|
|
2194
|
+
name: reviewer
|
|
2195
|
+
permission:
|
|
2196
|
+
mcp: allow
|
|
2197
|
+
---
|
|
2198
|
+
`,
|
|
2199
|
+
},
|
|
2200
|
+
);
|
|
2201
|
+
|
|
2202
|
+
try {
|
|
2203
|
+
const result = manager.checkPermission(
|
|
2204
|
+
"mcp",
|
|
2205
|
+
{ tool: "exa:web_search_exa" },
|
|
2206
|
+
"reviewer",
|
|
2207
|
+
);
|
|
2208
|
+
expect(result.state).toBe("allow");
|
|
2209
|
+
expect(result.source).toBe("mcp");
|
|
2210
|
+
expect(result.target).toBe("exa_web_search_exa");
|
|
2211
|
+
} finally {
|
|
2212
|
+
cleanup();
|
|
2213
|
+
}
|
|
2214
|
+
});
|
|
2215
|
+
|
|
2216
|
+
test("specific MCP rules override mcp catch-all", () => {
|
|
2217
|
+
const { manager, cleanup } = createManager(
|
|
2218
|
+
{
|
|
2219
|
+
permission: { "*": "ask" },
|
|
2220
|
+
},
|
|
2221
|
+
{
|
|
2222
|
+
reviewer: `---
|
|
2223
|
+
name: reviewer
|
|
2224
|
+
permission:
|
|
2225
|
+
mcp:
|
|
2226
|
+
"*": allow
|
|
2227
|
+
exa_web_search_exa: deny
|
|
2228
|
+
---
|
|
2229
|
+
`,
|
|
2230
|
+
},
|
|
2231
|
+
{ mcpServerNames: ["exa"] },
|
|
2232
|
+
);
|
|
2233
|
+
|
|
2234
|
+
try {
|
|
2235
|
+
const result = manager.checkPermission(
|
|
2236
|
+
"mcp",
|
|
2237
|
+
{ tool: "web_search_exa" },
|
|
2238
|
+
"reviewer",
|
|
2239
|
+
);
|
|
2240
|
+
expect(result.state).toBe("deny");
|
|
2241
|
+
expect(result.source).toBe("mcp");
|
|
2242
|
+
expect(result.matchedPattern).toBe("exa_web_search_exa");
|
|
2243
|
+
expect(result.target).toBe("exa_web_search_exa");
|
|
2244
|
+
} finally {
|
|
2245
|
+
cleanup();
|
|
2246
|
+
}
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
test("specific MCP rules still win when mcp catch-all is deny", () => {
|
|
2250
|
+
const { manager, cleanup } = createManager(
|
|
2251
|
+
{
|
|
2252
|
+
permission: { "*": "ask" },
|
|
2253
|
+
},
|
|
2254
|
+
{
|
|
2255
|
+
reviewer: `---
|
|
2256
|
+
name: reviewer
|
|
2257
|
+
permission:
|
|
2258
|
+
mcp:
|
|
2259
|
+
"*": deny
|
|
2260
|
+
exa_web_search_exa: allow
|
|
2261
|
+
---
|
|
2262
|
+
`,
|
|
2263
|
+
},
|
|
2264
|
+
{ mcpServerNames: ["exa"] },
|
|
2265
|
+
);
|
|
2266
|
+
|
|
2267
|
+
try {
|
|
2268
|
+
const allowed = manager.checkPermission(
|
|
2269
|
+
"mcp",
|
|
2270
|
+
{ tool: "web_search_exa" },
|
|
2271
|
+
"reviewer",
|
|
2272
|
+
);
|
|
2273
|
+
expect(allowed.state).toBe("allow");
|
|
2274
|
+
expect(allowed.source).toBe("mcp");
|
|
2275
|
+
expect(allowed.matchedPattern).toBe("exa_web_search_exa");
|
|
2276
|
+
expect(allowed.target).toBe("exa_web_search_exa");
|
|
2277
|
+
|
|
2278
|
+
const fallback = manager.checkPermission(
|
|
2279
|
+
"mcp",
|
|
2280
|
+
{ tool: "other_exa" },
|
|
2281
|
+
"reviewer",
|
|
2282
|
+
);
|
|
2283
|
+
expect(fallback.state).toBe("deny");
|
|
2284
|
+
expect(fallback.source).toBe("mcp");
|
|
2285
|
+
expect(fallback.target).toBe("exa_other_exa");
|
|
2286
|
+
} finally {
|
|
2287
|
+
cleanup();
|
|
2288
|
+
}
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
test("mcp catch-all in agent frontmatter overrides global default", () => {
|
|
2292
|
+
const { manager, cleanup } = createManager(
|
|
2293
|
+
{
|
|
2294
|
+
permission: { "*": "deny" },
|
|
2295
|
+
},
|
|
2296
|
+
{
|
|
2297
|
+
reviewer: `---
|
|
2298
|
+
name: reviewer
|
|
2299
|
+
permission:
|
|
2300
|
+
mcp: allow
|
|
2301
|
+
---
|
|
2302
|
+
`,
|
|
2303
|
+
},
|
|
2304
|
+
);
|
|
2305
|
+
|
|
2306
|
+
try {
|
|
2307
|
+
const readResult = manager.checkPermission("read", {}, "reviewer");
|
|
2308
|
+
expect(readResult.state).toBe("deny");
|
|
2309
|
+
expect(readResult.source).toBe("tool");
|
|
2310
|
+
|
|
2311
|
+
const mcpResult = manager.checkPermission(
|
|
2312
|
+
"mcp",
|
|
2313
|
+
{ tool: "exa:web_search_exa" },
|
|
2314
|
+
"reviewer",
|
|
2315
|
+
);
|
|
2316
|
+
expect(mcpResult.state).toBe("allow");
|
|
2317
|
+
expect(mcpResult.source).toBe("mcp");
|
|
2318
|
+
} finally {
|
|
2319
|
+
cleanup();
|
|
2320
|
+
}
|
|
2321
|
+
});
|
|
2322
|
+
|
|
2323
|
+
test("Agent frontmatter canonical tools resolve correctly", () => {
|
|
2324
|
+
const { manager, cleanup } = createManager(
|
|
2325
|
+
{
|
|
2326
|
+
permission: { "*": "deny" },
|
|
2327
|
+
},
|
|
2328
|
+
{
|
|
2329
|
+
reviewer: `---
|
|
2330
|
+
name: reviewer
|
|
2331
|
+
permission:
|
|
2332
|
+
find: allow
|
|
2333
|
+
ls: deny
|
|
2334
|
+
---
|
|
2335
|
+
`,
|
|
2336
|
+
},
|
|
2337
|
+
);
|
|
2338
|
+
|
|
2339
|
+
try {
|
|
2340
|
+
const findResult = manager.checkPermission("find", {}, "reviewer");
|
|
2341
|
+
expect(findResult.state).toBe("allow");
|
|
2342
|
+
expect(findResult.source).toBe("tool");
|
|
2343
|
+
|
|
2344
|
+
const lsResult = manager.checkPermission("ls", {}, "reviewer");
|
|
2345
|
+
expect(lsResult.state).toBe("deny");
|
|
2346
|
+
expect(lsResult.source).toBe("tool");
|
|
2347
|
+
} finally {
|
|
2348
|
+
cleanup();
|
|
2349
|
+
}
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
test("All surface names work in agent frontmatter flat permission format", () => {
|
|
2353
|
+
const { manager, cleanup } = createManager(
|
|
2354
|
+
{
|
|
2355
|
+
permission: { "*": "deny" },
|
|
2356
|
+
},
|
|
2357
|
+
{
|
|
2358
|
+
reviewer: `---
|
|
2359
|
+
name: reviewer
|
|
2360
|
+
permission:
|
|
2361
|
+
find: allow
|
|
2362
|
+
task: allow
|
|
2363
|
+
mcp: allow
|
|
2364
|
+
---
|
|
2365
|
+
`,
|
|
2366
|
+
},
|
|
2367
|
+
);
|
|
2368
|
+
|
|
2369
|
+
try {
|
|
2370
|
+
const findResult = manager.checkPermission("find", {}, "reviewer");
|
|
2371
|
+
expect(findResult.state).toBe("allow");
|
|
2372
|
+
expect(findResult.source).toBe("tool");
|
|
2373
|
+
|
|
2374
|
+
const taskResult = manager.checkPermission("task", {}, "reviewer");
|
|
2375
|
+
expect(taskResult.state).toBe("allow");
|
|
2376
|
+
expect(taskResult.source).toBe("tool");
|
|
2377
|
+
|
|
2378
|
+
const mcpResult = manager.checkPermission(
|
|
2379
|
+
"mcp",
|
|
2380
|
+
{ tool: "exa:web_search_exa" },
|
|
2381
|
+
"reviewer",
|
|
2382
|
+
);
|
|
2383
|
+
expect(mcpResult.state).toBe("allow");
|
|
2384
|
+
} finally {
|
|
2385
|
+
cleanup();
|
|
2386
|
+
}
|
|
2387
|
+
});
|
|
2388
|
+
|
|
2389
|
+
test("task uses exact-name tool permissions like any registered extension tool", () => {
|
|
2390
|
+
const { manager, cleanup } = createManager({
|
|
2391
|
+
permission: { "*": "deny", task: "allow" },
|
|
2392
|
+
});
|
|
2393
|
+
|
|
2394
|
+
try {
|
|
2395
|
+
const taskResult = manager.checkPermission("task", {});
|
|
2396
|
+
expect(taskResult.state).toBe("allow");
|
|
2397
|
+
expect(taskResult.source).toBe("tool");
|
|
2398
|
+
} finally {
|
|
2399
|
+
cleanup();
|
|
2400
|
+
}
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
test("getToolPermission returns tool-level policy for canonical and extension tools", () => {
|
|
2404
|
+
const { manager, cleanup } = createManager(
|
|
2405
|
+
{
|
|
2406
|
+
permission: { "*": "ask" },
|
|
2407
|
+
},
|
|
2408
|
+
{
|
|
2409
|
+
reviewer: `---
|
|
2410
|
+
name: reviewer
|
|
2411
|
+
permission:
|
|
2412
|
+
bash: deny
|
|
2413
|
+
read: deny
|
|
2414
|
+
task: allow
|
|
2415
|
+
---
|
|
2416
|
+
`,
|
|
2417
|
+
},
|
|
2418
|
+
);
|
|
2419
|
+
|
|
2420
|
+
try {
|
|
2421
|
+
const bashPermission = manager.getToolPermission("bash", "reviewer");
|
|
2422
|
+
expect(bashPermission).toBe("deny");
|
|
2423
|
+
|
|
2424
|
+
const taskPermission = manager.getToolPermission("task", "reviewer");
|
|
2425
|
+
expect(taskPermission).toBe("allow");
|
|
2426
|
+
|
|
2427
|
+
const readPermission = manager.getToolPermission("read", "reviewer");
|
|
2428
|
+
expect(readPermission).toBe("deny");
|
|
2429
|
+
|
|
2430
|
+
const defaultBashPermission = manager.getToolPermission("bash");
|
|
2431
|
+
expect(defaultBashPermission).toBe("ask");
|
|
2432
|
+
|
|
2433
|
+
const { manager: manager2, cleanup: cleanup2 } = createManager({
|
|
2434
|
+
permission: { "*": "deny", bash: "allow" },
|
|
2435
|
+
});
|
|
2436
|
+
|
|
2437
|
+
try {
|
|
2438
|
+
const globalBashPermission = manager2.getToolPermission("bash");
|
|
2439
|
+
expect(globalBashPermission).toBe("allow");
|
|
2440
|
+
} finally {
|
|
2441
|
+
cleanup2();
|
|
2442
|
+
}
|
|
2443
|
+
} finally {
|
|
2444
|
+
cleanup();
|
|
2445
|
+
}
|
|
2446
|
+
});
|
|
2447
|
+
|
|
2448
|
+
test("getToolPermission supports arbitrary extension tool names", () => {
|
|
2449
|
+
const { manager, cleanup } = createManager({
|
|
2450
|
+
permission: { "*": "deny", third_party_tool: "allow" },
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2453
|
+
try {
|
|
2454
|
+
const explicitPermission = manager.getToolPermission("third_party_tool");
|
|
2455
|
+
expect(explicitPermission).toBe("allow");
|
|
2456
|
+
|
|
2457
|
+
const fallbackPermission = manager.getToolPermission(
|
|
2458
|
+
"missing_extension_tool",
|
|
2459
|
+
);
|
|
2460
|
+
expect(fallbackPermission).toBe("deny");
|
|
2461
|
+
} finally {
|
|
2462
|
+
cleanup();
|
|
2463
|
+
}
|
|
2464
|
+
});
|
|
2465
|
+
|
|
2466
|
+
// ---------------------------------------------------------------------------
|
|
2467
|
+
// external_directory config resolution and pattern maps — moved from catch-all (#342)
|
|
2468
|
+
// ---------------------------------------------------------------------------
|
|
2469
|
+
|
|
2470
|
+
test("external_directory permission falls back to universal default when not explicitly configured", () => {
|
|
2471
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2472
|
+
|
|
2473
|
+
try {
|
|
2474
|
+
const result = manager.checkPermission("external_directory", {});
|
|
2475
|
+
expect(result.state).toBe("ask");
|
|
2476
|
+
expect(result.source).toBe("special");
|
|
2477
|
+
expect(result.matchedPattern).toBe(undefined);
|
|
2478
|
+
} finally {
|
|
2479
|
+
cleanup();
|
|
2480
|
+
}
|
|
2481
|
+
});
|
|
2482
|
+
|
|
2483
|
+
test("external_directory permission respects explicit deny", () => {
|
|
2484
|
+
const { manager, cleanup } = createManager({
|
|
2485
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2486
|
+
});
|
|
2487
|
+
|
|
2488
|
+
try {
|
|
2489
|
+
const result = manager.checkPermission("external_directory", {});
|
|
2490
|
+
expect(result.state).toBe("deny");
|
|
2491
|
+
expect(result.source).toBe("special");
|
|
2492
|
+
expect(result.matchedPattern).toBe("*");
|
|
2493
|
+
} finally {
|
|
2494
|
+
cleanup();
|
|
2495
|
+
}
|
|
2496
|
+
});
|
|
2497
|
+
|
|
2498
|
+
test("external_directory permission can be explicitly allowed", () => {
|
|
2499
|
+
const { manager, cleanup } = createManager({
|
|
2500
|
+
permission: { "*": "allow", external_directory: "allow" },
|
|
2501
|
+
});
|
|
2502
|
+
|
|
2503
|
+
try {
|
|
2504
|
+
const result = manager.checkPermission("external_directory", {});
|
|
2505
|
+
expect(result.state).toBe("allow");
|
|
2506
|
+
expect(result.source).toBe("special");
|
|
2507
|
+
expect(result.matchedPattern).toBe("*");
|
|
2508
|
+
} finally {
|
|
2509
|
+
cleanup();
|
|
2510
|
+
}
|
|
2511
|
+
});
|
|
2512
|
+
|
|
2513
|
+
test("external_directory permission respects per-agent override", () => {
|
|
2514
|
+
const { manager, cleanup } = createManager(
|
|
2515
|
+
{
|
|
2516
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2517
|
+
},
|
|
2518
|
+
{
|
|
2519
|
+
trusted: `---
|
|
2520
|
+
name: trusted
|
|
2521
|
+
permission:
|
|
2522
|
+
external_directory: allow
|
|
2523
|
+
---
|
|
2524
|
+
`,
|
|
2525
|
+
},
|
|
2526
|
+
);
|
|
2527
|
+
|
|
2528
|
+
try {
|
|
2529
|
+
const globalResult = manager.checkPermission("external_directory", {});
|
|
2530
|
+
expect(globalResult.state).toBe("deny");
|
|
2531
|
+
|
|
2532
|
+
const agentResult = manager.checkPermission(
|
|
2533
|
+
"external_directory",
|
|
2534
|
+
{},
|
|
2535
|
+
"trusted",
|
|
2536
|
+
);
|
|
2537
|
+
expect(agentResult.state).toBe("allow");
|
|
2538
|
+
expect(agentResult.source).toBe("special");
|
|
2539
|
+
} finally {
|
|
2540
|
+
cleanup();
|
|
2541
|
+
}
|
|
2542
|
+
});
|
|
2543
|
+
|
|
2544
|
+
test("external_directory permission is not affected by unrelated surface keys", () => {
|
|
2545
|
+
const { manager, cleanup } = createManager({
|
|
2546
|
+
permission: { "*": "allow", external_directory: "allow" },
|
|
2547
|
+
});
|
|
2548
|
+
|
|
2549
|
+
try {
|
|
2550
|
+
const extResult = manager.checkPermission("external_directory", {});
|
|
2551
|
+
expect(extResult.state).toBe("allow");
|
|
2552
|
+
expect(extResult.matchedPattern).toBe("*");
|
|
2553
|
+
} finally {
|
|
2554
|
+
cleanup();
|
|
2555
|
+
}
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
test("skill pattern map in agent frontmatter overrides global skill policy", () => {
|
|
2559
|
+
const { manager, cleanup } = createManager(
|
|
2560
|
+
{
|
|
2561
|
+
permission: { "*": "deny", skill: "deny" },
|
|
2562
|
+
},
|
|
2563
|
+
{
|
|
2564
|
+
reviewer: `---
|
|
2565
|
+
name: reviewer
|
|
2566
|
+
permission:
|
|
2567
|
+
skill:
|
|
2568
|
+
"*": ask
|
|
2569
|
+
"pi-*": allow
|
|
2570
|
+
---
|
|
2571
|
+
`,
|
|
2572
|
+
},
|
|
2573
|
+
);
|
|
2574
|
+
|
|
2575
|
+
try {
|
|
2576
|
+
const allowed = manager.checkPermission(
|
|
2577
|
+
"skill",
|
|
2578
|
+
{ name: "pi-code-review" },
|
|
2579
|
+
"reviewer",
|
|
2580
|
+
);
|
|
2581
|
+
expect(allowed.state).toBe("allow");
|
|
2582
|
+
expect(allowed.matchedPattern).toBe("pi-*");
|
|
2583
|
+
expect(allowed.source).toBe("skill");
|
|
2584
|
+
|
|
2585
|
+
const asked = manager.checkPermission(
|
|
2586
|
+
"skill",
|
|
2587
|
+
{ name: "other-skill" },
|
|
2588
|
+
"reviewer",
|
|
2589
|
+
);
|
|
2590
|
+
expect(asked.state).toBe("ask");
|
|
2591
|
+
expect(asked.matchedPattern).toBe("*");
|
|
2592
|
+
|
|
2593
|
+
const denied = manager.checkPermission("skill", { name: "pi-code-review" });
|
|
2594
|
+
expect(denied.state).toBe("deny");
|
|
2595
|
+
expect(denied.source).toBe("skill");
|
|
2596
|
+
} finally {
|
|
2597
|
+
cleanup();
|
|
2598
|
+
}
|
|
2599
|
+
});
|
|
2600
|
+
|
|
2601
|
+
test("external_directory pattern map in agent frontmatter overrides global policy", () => {
|
|
2602
|
+
const { manager, cleanup } = createManager(
|
|
2603
|
+
{
|
|
2604
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2605
|
+
},
|
|
2606
|
+
{
|
|
2607
|
+
trusted: `---
|
|
2608
|
+
name: trusted
|
|
2609
|
+
permission:
|
|
2610
|
+
external_directory:
|
|
2611
|
+
"*": deny
|
|
2612
|
+
"~/Downloads/*": allow
|
|
2613
|
+
---
|
|
2614
|
+
`,
|
|
2615
|
+
},
|
|
2616
|
+
);
|
|
2617
|
+
|
|
2618
|
+
try {
|
|
2619
|
+
const allowed = manager.checkPermission(
|
|
2620
|
+
"external_directory",
|
|
2621
|
+
{ path: `${homedir()}/Downloads/file.txt` },
|
|
2622
|
+
"trusted",
|
|
2623
|
+
);
|
|
2624
|
+
expect(allowed.state).toBe("allow");
|
|
2625
|
+
expect(allowed.matchedPattern).toBe("~/Downloads/*");
|
|
2626
|
+
expect(allowed.source).toBe("special");
|
|
2627
|
+
|
|
2628
|
+
const denied = manager.checkPermission(
|
|
2629
|
+
"external_directory",
|
|
2630
|
+
{ path: `${homedir()}/Documents/secret.txt` },
|
|
2631
|
+
"trusted",
|
|
2632
|
+
);
|
|
2633
|
+
expect(denied.state).toBe("deny");
|
|
2634
|
+
expect(denied.matchedPattern).toBe("*");
|
|
2635
|
+
|
|
2636
|
+
const globalDenied = manager.checkPermission("external_directory", {});
|
|
2637
|
+
expect(globalDenied.state).toBe("deny");
|
|
2638
|
+
expect(globalDenied.source).toBe("special");
|
|
2639
|
+
} finally {
|
|
2640
|
+
cleanup();
|
|
2641
|
+
}
|
|
2642
|
+
});
|
|
2643
|
+
|
|
2644
|
+
test("project-agent frontmatter skill rules override global-agent frontmatter skill rules", () => {
|
|
2645
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
2646
|
+
{
|
|
2647
|
+
permission: { "*": "deny" },
|
|
2648
|
+
},
|
|
2649
|
+
{
|
|
2650
|
+
analyst: `---
|
|
2651
|
+
name: analyst
|
|
2652
|
+
permission:
|
|
2653
|
+
skill:
|
|
2654
|
+
"*": ask
|
|
2655
|
+
---
|
|
2656
|
+
`,
|
|
2657
|
+
},
|
|
2658
|
+
{
|
|
2659
|
+
projectAgentFiles: {
|
|
2660
|
+
analyst: `---
|
|
2661
|
+
name: analyst
|
|
2662
|
+
permission:
|
|
2663
|
+
skill:
|
|
2664
|
+
"pi-*": allow
|
|
2665
|
+
"*": deny
|
|
2666
|
+
---
|
|
2667
|
+
`,
|
|
2668
|
+
},
|
|
2669
|
+
},
|
|
2670
|
+
);
|
|
2671
|
+
|
|
2672
|
+
try {
|
|
2673
|
+
const allowed = manager.checkPermission(
|
|
2674
|
+
"skill",
|
|
2675
|
+
{ name: "pi-code-review" },
|
|
2676
|
+
"analyst",
|
|
2677
|
+
);
|
|
2678
|
+
expect(allowed.state).toBe("allow");
|
|
2679
|
+
expect(allowed.matchedPattern).toBe("pi-*");
|
|
2680
|
+
|
|
2681
|
+
const denied = manager.checkPermission(
|
|
2682
|
+
"skill",
|
|
2683
|
+
{ name: "other-skill" },
|
|
2684
|
+
"analyst",
|
|
2685
|
+
);
|
|
2686
|
+
expect(denied.state).toBe("deny");
|
|
2687
|
+
expect(denied.matchedPattern).toBe("*");
|
|
2688
|
+
} finally {
|
|
2689
|
+
cleanup();
|
|
2690
|
+
}
|
|
2691
|
+
});
|
|
2692
|
+
|
|
2693
|
+
test("project-agent frontmatter external_directory rules override global-agent frontmatter rules", () => {
|
|
2694
|
+
const { manager, cleanup } = createManagerWithProject(
|
|
2695
|
+
{
|
|
2696
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2697
|
+
},
|
|
2698
|
+
{
|
|
2699
|
+
analyst: `---
|
|
2700
|
+
name: analyst
|
|
2701
|
+
permission:
|
|
2702
|
+
external_directory: ask
|
|
2703
|
+
---
|
|
2704
|
+
`,
|
|
2705
|
+
},
|
|
2706
|
+
{
|
|
2707
|
+
projectAgentFiles: {
|
|
2708
|
+
analyst: `---
|
|
2709
|
+
name: analyst
|
|
2710
|
+
permission:
|
|
2711
|
+
external_directory: allow
|
|
2712
|
+
---
|
|
2713
|
+
`,
|
|
2714
|
+
},
|
|
2715
|
+
},
|
|
2716
|
+
);
|
|
2717
|
+
|
|
2718
|
+
try {
|
|
2719
|
+
const result = manager.checkPermission("external_directory", {}, "analyst");
|
|
2720
|
+
expect(result.state).toBe("allow");
|
|
2721
|
+
expect(result.source).toBe("special");
|
|
2722
|
+
|
|
2723
|
+
const globalResult = manager.checkPermission("external_directory", {});
|
|
2724
|
+
expect(globalResult.state).toBe("deny");
|
|
2725
|
+
} finally {
|
|
2726
|
+
cleanup();
|
|
2727
|
+
}
|
|
2728
|
+
});
|
|
2729
|
+
|
|
2730
|
+
// ---------------------------------------------------------------------------
|
|
2731
|
+
// PI_CODING_AGENT_DIR support — moved from catch-all (#342)
|
|
2732
|
+
// ---------------------------------------------------------------------------
|
|
2733
|
+
|
|
2734
|
+
test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
|
|
2735
|
+
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-envdir-"));
|
|
2736
|
+
const agentsDir = join(baseDir, "agents");
|
|
2737
|
+
const newConfigPath = getGlobalConfigPath(baseDir);
|
|
2738
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
2739
|
+
mkdirSync(dirname(newConfigPath), { recursive: true });
|
|
2740
|
+
|
|
2741
|
+
const config: ScopeConfig = {
|
|
2742
|
+
permission: { "*": "deny", read: "allow" },
|
|
2743
|
+
};
|
|
2744
|
+
writeFileSync(newConfigPath, JSON.stringify(config), "utf8");
|
|
2745
|
+
|
|
2746
|
+
const original = process.env.PI_CODING_AGENT_DIR;
|
|
2747
|
+
process.env.PI_CODING_AGENT_DIR = baseDir;
|
|
2748
|
+
try {
|
|
2749
|
+
const manager = new PermissionManager();
|
|
2750
|
+
const result = manager.checkPermission("read", {});
|
|
2751
|
+
expect(result.state).toBe("allow");
|
|
2752
|
+
|
|
2753
|
+
const result2 = manager.checkPermission("write", {});
|
|
2754
|
+
expect(result2.state).toBe("deny");
|
|
2755
|
+
} finally {
|
|
2756
|
+
if (original !== undefined) {
|
|
2757
|
+
process.env.PI_CODING_AGENT_DIR = original;
|
|
2758
|
+
} else {
|
|
2759
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
2760
|
+
}
|
|
2761
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
2762
|
+
}
|
|
2763
|
+
});
|
|
2764
|
+
|
|
2765
|
+
// ---------------------------------------------------------------------------
|
|
2766
|
+
// getConfigIssues — moved from catch-all (#342)
|
|
2767
|
+
// ---------------------------------------------------------------------------
|
|
2768
|
+
|
|
2769
|
+
test("PermissionManager.getConfigIssues returns empty array for clean config", () => {
|
|
2770
|
+
const config: ScopeConfig = {
|
|
2771
|
+
permission: { "*": "ask", external_directory: "ask" },
|
|
2772
|
+
};
|
|
2773
|
+
const { manager, cleanup } = createManager(config);
|
|
2774
|
+
try {
|
|
2775
|
+
const issues = manager.getConfigIssues();
|
|
2776
|
+
expect(issues.length).toBe(0);
|
|
2777
|
+
} finally {
|
|
2778
|
+
cleanup();
|
|
2779
|
+
}
|
|
2780
|
+
});
|
|
2781
|
+
|
|
2782
|
+
test("PermissionManager.getConfigIssues returns empty array for empty config", () => {
|
|
2783
|
+
const { manager, cleanup } = createManager({});
|
|
2784
|
+
try {
|
|
2785
|
+
const issues = manager.getConfigIssues();
|
|
2786
|
+
expect(issues.length).toBe(0);
|
|
2787
|
+
} finally {
|
|
2788
|
+
cleanup();
|
|
2789
|
+
}
|
|
2790
|
+
});
|
|
2791
|
+
|
|
2792
|
+
// ---------------------------------------------------------------------------
|
|
2793
|
+
// Session-aware checkPermission() — moved from catch-all (#342)
|
|
2794
|
+
// ---------------------------------------------------------------------------
|
|
2795
|
+
|
|
2796
|
+
test("checkPermission returns source 'session' when session rules cover the external_directory path", () => {
|
|
2797
|
+
const { manager, cleanup } = createManager({
|
|
2798
|
+
permission: { "*": "allow" },
|
|
2799
|
+
});
|
|
2800
|
+
|
|
2801
|
+
try {
|
|
2802
|
+
const sessionRules = [
|
|
2803
|
+
{
|
|
2804
|
+
surface: "external_directory",
|
|
2805
|
+
pattern: "/other/project/*",
|
|
2806
|
+
action: "allow" as const,
|
|
2807
|
+
layer: "session" as const,
|
|
2808
|
+
origin: "session" as const,
|
|
2809
|
+
},
|
|
2810
|
+
];
|
|
2811
|
+
|
|
2812
|
+
const result = manager.checkPermission(
|
|
2813
|
+
"external_directory",
|
|
2814
|
+
{ path: "/other/project/src/foo.ts" },
|
|
2815
|
+
undefined,
|
|
2816
|
+
sessionRules,
|
|
2817
|
+
);
|
|
2818
|
+
expect(result.state).toBe("allow");
|
|
2819
|
+
expect(result.source).toBe("session");
|
|
2820
|
+
expect(result.matchedPattern).toBe("/other/project/*");
|
|
2821
|
+
} finally {
|
|
2822
|
+
cleanup();
|
|
2823
|
+
}
|
|
2824
|
+
});
|
|
2825
|
+
|
|
2826
|
+
test("checkPermission falls back to config policy when session rules do not cover the path", () => {
|
|
2827
|
+
const { manager, cleanup } = createManager({
|
|
2828
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2829
|
+
});
|
|
2830
|
+
|
|
2831
|
+
try {
|
|
2832
|
+
const sessionRules = [
|
|
2833
|
+
{
|
|
2834
|
+
surface: "external_directory",
|
|
2835
|
+
pattern: "/other/project/*",
|
|
2836
|
+
action: "allow" as const,
|
|
2837
|
+
layer: "session" as const,
|
|
2838
|
+
origin: "session" as const,
|
|
2839
|
+
},
|
|
2840
|
+
];
|
|
2841
|
+
|
|
2842
|
+
const result = manager.checkPermission(
|
|
2843
|
+
"external_directory",
|
|
2844
|
+
{ path: "/completely/different/path.ts" },
|
|
2845
|
+
undefined,
|
|
2846
|
+
sessionRules,
|
|
2847
|
+
);
|
|
2848
|
+
expect(result.state).toBe("deny");
|
|
2849
|
+
expect(result.source).toBe("special");
|
|
2850
|
+
} finally {
|
|
2851
|
+
cleanup();
|
|
2852
|
+
}
|
|
2853
|
+
});
|
|
2854
|
+
|
|
2855
|
+
test("checkPermission with empty session rules is identical to call without sessionRules arg", () => {
|
|
2856
|
+
const { manager, cleanup } = createManager({
|
|
2857
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2858
|
+
});
|
|
2859
|
+
|
|
2860
|
+
try {
|
|
2861
|
+
const withEmpty = manager.checkPermission(
|
|
2862
|
+
"external_directory",
|
|
2863
|
+
{ path: "/other/project/foo.ts" },
|
|
2864
|
+
undefined,
|
|
2865
|
+
[],
|
|
2866
|
+
);
|
|
2867
|
+
const withoutArg = manager.checkPermission("external_directory", {
|
|
2868
|
+
path: "/other/project/foo.ts",
|
|
2869
|
+
});
|
|
2870
|
+
const expected: PermissionCheckResult = {
|
|
2871
|
+
toolName: "external_directory",
|
|
2872
|
+
state: "deny",
|
|
2873
|
+
matchedPattern: "*",
|
|
2874
|
+
source: "special",
|
|
2875
|
+
origin: "global",
|
|
2876
|
+
};
|
|
2877
|
+
expect(withEmpty).toEqual(expected);
|
|
2878
|
+
expect(withoutArg).toEqual(expected);
|
|
2879
|
+
} finally {
|
|
2880
|
+
cleanup();
|
|
2881
|
+
}
|
|
2882
|
+
});
|
|
2883
|
+
|
|
2884
|
+
test("session rules for one surface do not affect checks on other surfaces", () => {
|
|
2885
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2886
|
+
|
|
2887
|
+
try {
|
|
2888
|
+
const sessionRules = [
|
|
2889
|
+
{
|
|
2890
|
+
surface: "external_directory",
|
|
2891
|
+
pattern: "/other/project/*",
|
|
2892
|
+
action: "allow" as const,
|
|
2893
|
+
layer: "session" as const,
|
|
2894
|
+
origin: "session" as const,
|
|
2895
|
+
},
|
|
2896
|
+
];
|
|
2897
|
+
|
|
2898
|
+
const bashResult = manager.checkPermission(
|
|
2899
|
+
"bash",
|
|
2900
|
+
{ command: "git status" },
|
|
2901
|
+
undefined,
|
|
2902
|
+
sessionRules,
|
|
2903
|
+
);
|
|
2904
|
+
expect(bashResult.state).toBe("ask");
|
|
2905
|
+
expect(bashResult.source).toBe("bash");
|
|
2906
|
+
|
|
2907
|
+
const mcpResult = manager.checkPermission(
|
|
2908
|
+
"mcp",
|
|
2909
|
+
{ tool: "exa:search" },
|
|
2910
|
+
undefined,
|
|
2911
|
+
sessionRules,
|
|
2912
|
+
);
|
|
2913
|
+
expect(mcpResult.state).toBe("ask");
|
|
2914
|
+
expect(mcpResult.source).toBe("default");
|
|
2915
|
+
} finally {
|
|
2916
|
+
cleanup();
|
|
2917
|
+
}
|
|
2918
|
+
});
|
|
2919
|
+
|
|
2920
|
+
test("session rules override config deny for external_directory", () => {
|
|
2921
|
+
const { manager, cleanup } = createManager({
|
|
2922
|
+
permission: { "*": "allow", external_directory: "deny" },
|
|
2923
|
+
});
|
|
2924
|
+
|
|
2925
|
+
try {
|
|
2926
|
+
const sessionRules = [
|
|
2927
|
+
{
|
|
2928
|
+
surface: "external_directory",
|
|
2929
|
+
pattern: "/other/project/*",
|
|
2930
|
+
action: "allow" as const,
|
|
2931
|
+
layer: "session" as const,
|
|
2932
|
+
origin: "session" as const,
|
|
2933
|
+
},
|
|
2934
|
+
];
|
|
2935
|
+
|
|
2936
|
+
const result = manager.checkPermission(
|
|
2937
|
+
"external_directory",
|
|
2938
|
+
{ path: "/other/project/src/foo.ts" },
|
|
2939
|
+
undefined,
|
|
2940
|
+
sessionRules,
|
|
2941
|
+
);
|
|
2942
|
+
expect(result.state).toBe("allow");
|
|
2943
|
+
expect(result.source).toBe("session");
|
|
2944
|
+
} finally {
|
|
2945
|
+
cleanup();
|
|
2946
|
+
}
|
|
2947
|
+
});
|
|
2948
|
+
|
|
2949
|
+
test("checkPermission returns source 'session' for bash when session rules match", () => {
|
|
2950
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2951
|
+
|
|
2952
|
+
try {
|
|
2953
|
+
const sessionRules = [
|
|
2954
|
+
{
|
|
2955
|
+
surface: "bash",
|
|
2956
|
+
pattern: "git *",
|
|
2957
|
+
action: "allow" as const,
|
|
2958
|
+
layer: "session" as const,
|
|
2959
|
+
origin: "session" as const,
|
|
2960
|
+
},
|
|
2961
|
+
];
|
|
2962
|
+
|
|
2963
|
+
const result = manager.checkPermission(
|
|
2964
|
+
"bash",
|
|
2965
|
+
{ command: "git status --short" },
|
|
2966
|
+
undefined,
|
|
2967
|
+
sessionRules,
|
|
2968
|
+
);
|
|
2969
|
+
expect(result.state).toBe("allow");
|
|
2970
|
+
expect(result.source).toBe("session");
|
|
2971
|
+
expect(result.matchedPattern).toBe("git *");
|
|
2972
|
+
} finally {
|
|
2973
|
+
cleanup();
|
|
2974
|
+
}
|
|
2975
|
+
});
|
|
2976
|
+
|
|
2977
|
+
test("checkPermission returns source 'session' for bash when session rule is exact match", () => {
|
|
2978
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
2979
|
+
|
|
2980
|
+
try {
|
|
2981
|
+
const sessionRules = [
|
|
2982
|
+
{
|
|
2983
|
+
surface: "bash",
|
|
2984
|
+
pattern: "ls",
|
|
2985
|
+
action: "allow" as const,
|
|
2986
|
+
layer: "session" as const,
|
|
2987
|
+
origin: "session" as const,
|
|
2988
|
+
},
|
|
2989
|
+
];
|
|
2990
|
+
|
|
2991
|
+
const result = manager.checkPermission(
|
|
2992
|
+
"bash",
|
|
2993
|
+
{ command: "ls" },
|
|
2994
|
+
undefined,
|
|
2995
|
+
sessionRules,
|
|
2996
|
+
);
|
|
2997
|
+
expect(result.state).toBe("allow");
|
|
2998
|
+
expect(result.source).toBe("session");
|
|
2999
|
+
} finally {
|
|
3000
|
+
cleanup();
|
|
3001
|
+
}
|
|
3002
|
+
});
|
|
3003
|
+
|
|
3004
|
+
test("checkPermission falls back to config for bash when session rules do not match the command", () => {
|
|
3005
|
+
const { manager, cleanup } = createManager({ permission: { bash: "deny" } });
|
|
3006
|
+
|
|
3007
|
+
try {
|
|
3008
|
+
const sessionRules = [
|
|
3009
|
+
{
|
|
3010
|
+
surface: "bash",
|
|
3011
|
+
pattern: "git *",
|
|
3012
|
+
action: "allow" as const,
|
|
3013
|
+
layer: "session" as const,
|
|
3014
|
+
origin: "session" as const,
|
|
3015
|
+
},
|
|
3016
|
+
];
|
|
3017
|
+
|
|
3018
|
+
const result = manager.checkPermission(
|
|
3019
|
+
"bash",
|
|
3020
|
+
{ command: "npm run build" },
|
|
3021
|
+
undefined,
|
|
3022
|
+
sessionRules,
|
|
3023
|
+
);
|
|
3024
|
+
expect(result.state).toBe("deny");
|
|
3025
|
+
expect(result.source).toBe("bash");
|
|
3026
|
+
} finally {
|
|
3027
|
+
cleanup();
|
|
3028
|
+
}
|
|
3029
|
+
});
|
|
3030
|
+
|
|
3031
|
+
test("checkPermission returns source 'session' for mcp when session rules match the target", () => {
|
|
3032
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
3033
|
+
|
|
3034
|
+
try {
|
|
3035
|
+
const sessionRules = [
|
|
3036
|
+
{
|
|
3037
|
+
surface: "mcp",
|
|
3038
|
+
pattern: "exa:*",
|
|
3039
|
+
action: "allow" as const,
|
|
3040
|
+
layer: "session" as const,
|
|
3041
|
+
origin: "session" as const,
|
|
3042
|
+
},
|
|
3043
|
+
];
|
|
3044
|
+
|
|
3045
|
+
const result = manager.checkPermission(
|
|
3046
|
+
"mcp",
|
|
3047
|
+
{ tool: "exa:search" },
|
|
3048
|
+
undefined,
|
|
3049
|
+
sessionRules,
|
|
3050
|
+
);
|
|
3051
|
+
expect(result.state).toBe("allow");
|
|
3052
|
+
expect(result.source).toBe("session");
|
|
3053
|
+
} finally {
|
|
3054
|
+
cleanup();
|
|
3055
|
+
}
|
|
3056
|
+
});
|
|
3057
|
+
|
|
3058
|
+
test("checkPermission returns source 'session' for skill when session rules match", () => {
|
|
3059
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
3060
|
+
|
|
3061
|
+
try {
|
|
3062
|
+
const sessionRules = [
|
|
3063
|
+
{
|
|
3064
|
+
surface: "skill",
|
|
3065
|
+
pattern: "librarian",
|
|
3066
|
+
action: "allow" as const,
|
|
3067
|
+
layer: "session" as const,
|
|
3068
|
+
origin: "session" as const,
|
|
3069
|
+
},
|
|
3070
|
+
];
|
|
3071
|
+
|
|
3072
|
+
const result = manager.checkPermission(
|
|
3073
|
+
"skill",
|
|
3074
|
+
{ name: "librarian" },
|
|
3075
|
+
undefined,
|
|
3076
|
+
sessionRules,
|
|
3077
|
+
);
|
|
3078
|
+
expect(result.state).toBe("allow");
|
|
3079
|
+
expect(result.source).toBe("session");
|
|
3080
|
+
expect(result.matchedPattern).toBe("librarian");
|
|
3081
|
+
} finally {
|
|
3082
|
+
cleanup();
|
|
3083
|
+
}
|
|
3084
|
+
});
|
|
3085
|
+
|
|
3086
|
+
test("checkPermission returns source 'session' for tool surface when session rules match", () => {
|
|
3087
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
3088
|
+
|
|
3089
|
+
try {
|
|
3090
|
+
const sessionRules = [
|
|
3091
|
+
{
|
|
3092
|
+
surface: "read",
|
|
3093
|
+
pattern: "*",
|
|
3094
|
+
action: "allow" as const,
|
|
3095
|
+
layer: "session" as const,
|
|
3096
|
+
origin: "session" as const,
|
|
3097
|
+
},
|
|
3098
|
+
];
|
|
3099
|
+
|
|
3100
|
+
const result = manager.checkPermission("read", {}, undefined, sessionRules);
|
|
3101
|
+
expect(result.state).toBe("allow");
|
|
3102
|
+
expect(result.source).toBe("session");
|
|
3103
|
+
} finally {
|
|
3104
|
+
cleanup();
|
|
3105
|
+
}
|
|
3106
|
+
});
|
|
3107
|
+
|
|
3108
|
+
test("bash session rules do not bleed into mcp checks", () => {
|
|
3109
|
+
const { manager, cleanup } = createManager({ permission: {} });
|
|
3110
|
+
|
|
3111
|
+
try {
|
|
3112
|
+
const sessionRules = [
|
|
3113
|
+
{
|
|
3114
|
+
surface: "bash",
|
|
3115
|
+
pattern: "git *",
|
|
3116
|
+
action: "allow" as const,
|
|
3117
|
+
layer: "session" as const,
|
|
3118
|
+
origin: "session" as const,
|
|
3119
|
+
},
|
|
3120
|
+
];
|
|
3121
|
+
|
|
3122
|
+
const result = manager.checkPermission(
|
|
3123
|
+
"mcp",
|
|
3124
|
+
{ tool: "exa:search" },
|
|
3125
|
+
undefined,
|
|
3126
|
+
sessionRules,
|
|
3127
|
+
);
|
|
3128
|
+
expect(result.source).not.toBe("session");
|
|
3129
|
+
} finally {
|
|
3130
|
+
cleanup();
|
|
3131
|
+
}
|
|
3132
|
+
});
|
|
3133
|
+
|
|
3134
|
+
// ---------------------------------------------------------------------------
|
|
3135
|
+
// getResolvedPolicyPaths — moved from catch-all (#342)
|
|
3136
|
+
// ---------------------------------------------------------------------------
|
|
3137
|
+
|
|
3138
|
+
test("getResolvedPolicyPaths returns correct paths and existence when files exist", () => {
|
|
3139
|
+
const tempDir = mkdtempSync(join(tmpdir(), "policy-paths-exist-"));
|
|
3140
|
+
try {
|
|
3141
|
+
const globalConfigPath = join(tempDir, "pi-permissions.jsonc");
|
|
3142
|
+
const agentsDir = join(tempDir, "agents");
|
|
3143
|
+
const projectConfigPath = join(tempDir, "project", "pi-permissions.jsonc");
|
|
3144
|
+
const projectAgentsDir = join(tempDir, "project", "agents");
|
|
3145
|
+
|
|
3146
|
+
writeFileSync(globalConfigPath, "{}", "utf-8");
|
|
3147
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
3148
|
+
mkdirSync(join(tempDir, "project"), { recursive: true });
|
|
3149
|
+
writeFileSync(projectConfigPath, "{}", "utf-8");
|
|
3150
|
+
mkdirSync(projectAgentsDir, { recursive: true });
|
|
3151
|
+
|
|
3152
|
+
const pm = new PermissionManager({
|
|
3153
|
+
globalConfigPath,
|
|
3154
|
+
agentsDir,
|
|
3155
|
+
projectGlobalConfigPath: projectConfigPath,
|
|
3156
|
+
projectAgentsDir,
|
|
3157
|
+
});
|
|
3158
|
+
|
|
3159
|
+
const result = pm.getResolvedPolicyPaths();
|
|
3160
|
+
|
|
3161
|
+
expect(result.globalConfigPath).toBe(globalConfigPath);
|
|
3162
|
+
expect(result.globalConfigExists).toBe(true);
|
|
3163
|
+
expect(result.projectConfigPath).toBe(projectConfigPath);
|
|
3164
|
+
expect(result.projectConfigExists).toBe(true);
|
|
3165
|
+
expect(result.agentsDir).toBe(agentsDir);
|
|
3166
|
+
expect(result.agentsDirExists).toBe(true);
|
|
3167
|
+
expect(result.projectAgentsDir).toBe(projectAgentsDir);
|
|
3168
|
+
expect(result.projectAgentsDirExists).toBe(true);
|
|
3169
|
+
} finally {
|
|
3170
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
3171
|
+
}
|
|
3172
|
+
});
|
|
3173
|
+
|
|
3174
|
+
test("getResolvedPolicyPaths returns false for missing files and null for absent project paths", () => {
|
|
3175
|
+
const tempDir = mkdtempSync(join(tmpdir(), "policy-paths-missing-"));
|
|
3176
|
+
try {
|
|
3177
|
+
const globalConfigPath = join(tempDir, "does-not-exist.jsonc");
|
|
3178
|
+
const agentsDir = join(tempDir, "no-agents");
|
|
3179
|
+
|
|
3180
|
+
const pm = new PermissionManager({
|
|
3181
|
+
globalConfigPath,
|
|
3182
|
+
agentsDir,
|
|
3183
|
+
});
|
|
3184
|
+
|
|
3185
|
+
const result = pm.getResolvedPolicyPaths();
|
|
3186
|
+
|
|
3187
|
+
expect(result.globalConfigPath).toBe(globalConfigPath);
|
|
3188
|
+
expect(result.globalConfigExists).toBe(false);
|
|
3189
|
+
expect(result.projectConfigPath).toBe(null);
|
|
3190
|
+
expect(result.projectConfigExists).toBe(false);
|
|
3191
|
+
expect(result.agentsDir).toBe(agentsDir);
|
|
3192
|
+
expect(result.agentsDirExists).toBe(false);
|
|
3193
|
+
expect(result.projectAgentsDir).toBe(null);
|
|
3194
|
+
expect(result.projectAgentsDirExists).toBe(false);
|
|
3195
|
+
} finally {
|
|
3196
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
3197
|
+
}
|
|
3198
|
+
});
|
|
3199
|
+
|
|
3200
|
+
describe("checkPermission — cwd-aware path policy values", () => {
|
|
3201
|
+
const cwd = "/workspace/project";
|
|
3202
|
+
|
|
3203
|
+
it("matches a relative read input against an absolute allowlist", () => {
|
|
3204
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3205
|
+
read: { "*": "ask", [`${cwd}/*`]: "allow" },
|
|
3206
|
+
});
|
|
3207
|
+
try {
|
|
3208
|
+
manager.configureForCwd(cwd);
|
|
3209
|
+
const result = manager.checkPermission("read", { path: "src/App.jsx" });
|
|
3210
|
+
expect(result.state).toBe("allow");
|
|
3211
|
+
expect(result.matchedPattern).toBe(`${cwd}/*`);
|
|
3212
|
+
} finally {
|
|
3213
|
+
cleanup();
|
|
3214
|
+
}
|
|
3215
|
+
});
|
|
3216
|
+
|
|
3217
|
+
it("keeps legacy relative path rules working after configureForCwd", () => {
|
|
3218
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3219
|
+
read: { "*": "allow", "src/*": "deny" },
|
|
3220
|
+
});
|
|
3221
|
+
try {
|
|
3222
|
+
manager.configureForCwd(cwd);
|
|
3223
|
+
const result = manager.checkPermission("read", { path: "src/App.jsx" });
|
|
3224
|
+
expect(result.state).toBe("deny");
|
|
3225
|
+
expect(result.matchedPattern).toBe("src/*");
|
|
3226
|
+
} finally {
|
|
3227
|
+
cleanup();
|
|
3228
|
+
}
|
|
3229
|
+
});
|
|
3230
|
+
|
|
3231
|
+
it("preserves last-match-wins across absolute and relative aliases", () => {
|
|
3232
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3233
|
+
read: {
|
|
3234
|
+
"*": "ask",
|
|
3235
|
+
[`${cwd}/*`]: "allow",
|
|
3236
|
+
"src/*": "deny",
|
|
3237
|
+
},
|
|
3238
|
+
});
|
|
3239
|
+
try {
|
|
3240
|
+
manager.configureForCwd(cwd);
|
|
3241
|
+
const result = manager.checkPermission("read", { path: "src/App.jsx" });
|
|
3242
|
+
// The later "src/*" deny wins over the earlier absolute allow.
|
|
3243
|
+
expect(result.state).toBe("deny");
|
|
3244
|
+
expect(result.matchedPattern).toBe("src/*");
|
|
3245
|
+
} finally {
|
|
3246
|
+
cleanup();
|
|
3247
|
+
}
|
|
3248
|
+
});
|
|
3249
|
+
|
|
3250
|
+
it("matches the cross-cutting path surface against absolute allowlists", () => {
|
|
3251
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3252
|
+
path: { "*": "ask", [`${cwd}/*`]: "allow" },
|
|
3253
|
+
});
|
|
3254
|
+
try {
|
|
3255
|
+
manager.configureForCwd(cwd);
|
|
3256
|
+
const result = manager.checkPermission("path", { path: "src/App.jsx" });
|
|
3257
|
+
expect(result.state).toBe("allow");
|
|
3258
|
+
expect(result.matchedPattern).toBe(`${cwd}/*`);
|
|
3259
|
+
} finally {
|
|
3260
|
+
cleanup();
|
|
3261
|
+
}
|
|
3262
|
+
});
|
|
3263
|
+
});
|
|
3264
|
+
|
|
3265
|
+
describe("checkPathPolicy", () => {
|
|
3266
|
+
const cwd = "/workspace/project";
|
|
3267
|
+
|
|
3268
|
+
it("evaluates precomputed policy values against the path surface", () => {
|
|
3269
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3270
|
+
path: { "*": "ask", [`${cwd}/*`]: "allow" },
|
|
3271
|
+
});
|
|
3272
|
+
try {
|
|
3273
|
+
const result = manager.checkPathPolicy([
|
|
3274
|
+
`${cwd}/src/App.jsx`,
|
|
3275
|
+
"src/App.jsx",
|
|
3276
|
+
]);
|
|
3277
|
+
expect(result.state).toBe("allow");
|
|
3278
|
+
expect(result.matchedPattern).toBe(`${cwd}/*`);
|
|
3279
|
+
expect(result.source).toBe("special");
|
|
3280
|
+
expect(result.toolName).toBe("path");
|
|
3281
|
+
} finally {
|
|
3282
|
+
cleanup();
|
|
3283
|
+
}
|
|
3284
|
+
});
|
|
3285
|
+
|
|
3286
|
+
it("preserves last-match-wins across the provided aliases", () => {
|
|
3287
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3288
|
+
path: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny" },
|
|
3289
|
+
});
|
|
3290
|
+
try {
|
|
3291
|
+
const result = manager.checkPathPolicy([
|
|
3292
|
+
`${cwd}/src/App.jsx`,
|
|
3293
|
+
"src/App.jsx",
|
|
3294
|
+
]);
|
|
3295
|
+
expect(result.state).toBe("deny");
|
|
3296
|
+
expect(result.matchedPattern).toBe("src/*");
|
|
3297
|
+
} finally {
|
|
3298
|
+
cleanup();
|
|
3299
|
+
}
|
|
3300
|
+
});
|
|
3301
|
+
|
|
3302
|
+
it("applies session rules over config", () => {
|
|
3303
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3304
|
+
path: { "*": "ask", "src/*": "deny" },
|
|
3305
|
+
});
|
|
3306
|
+
try {
|
|
3307
|
+
const sessionRules: Ruleset = [sessionAllow("path", "src/*")];
|
|
3308
|
+
const result = manager.checkPathPolicy(
|
|
3309
|
+
["src/App.jsx"],
|
|
3310
|
+
undefined,
|
|
3311
|
+
sessionRules,
|
|
3312
|
+
);
|
|
3313
|
+
expect(result.state).toBe("allow");
|
|
3314
|
+
expect(result.source).toBe("session");
|
|
3315
|
+
} finally {
|
|
3316
|
+
cleanup();
|
|
3317
|
+
}
|
|
3318
|
+
});
|
|
3319
|
+
|
|
3320
|
+
it("falls back to the catch-all for an empty value list", () => {
|
|
3321
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3322
|
+
path: { "*": "deny" },
|
|
3323
|
+
});
|
|
3324
|
+
try {
|
|
3325
|
+
const result = manager.checkPathPolicy([]);
|
|
3326
|
+
expect(result.state).toBe("deny");
|
|
3327
|
+
expect(result.matchedPattern).toBe("*");
|
|
3328
|
+
} finally {
|
|
3329
|
+
cleanup();
|
|
3330
|
+
}
|
|
3331
|
+
});
|
|
3332
|
+
|
|
3333
|
+
it("evaluates against the external_directory surface when one is provided", () => {
|
|
3334
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3335
|
+
external_directory: { "*": "ask", "/tmp/*": "allow" },
|
|
3336
|
+
});
|
|
3337
|
+
try {
|
|
3338
|
+
const result = manager.checkPathPolicy(
|
|
3339
|
+
["/tmp/x"],
|
|
3340
|
+
undefined,
|
|
3341
|
+
undefined,
|
|
3342
|
+
"external_directory",
|
|
3343
|
+
);
|
|
3344
|
+
expect(result.state).toBe("allow");
|
|
3345
|
+
expect(result.matchedPattern).toBe("/tmp/*");
|
|
3346
|
+
expect(result.source).toBe("special");
|
|
3347
|
+
expect(result.toolName).toBe("external_directory");
|
|
3348
|
+
} finally {
|
|
3349
|
+
cleanup();
|
|
3350
|
+
}
|
|
3351
|
+
});
|
|
3352
|
+
|
|
3353
|
+
it("defaults to the path surface when no surface is provided", () => {
|
|
3354
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3355
|
+
external_directory: { "*": "ask", "/tmp/*": "allow" },
|
|
3356
|
+
path: { "*": "allow" },
|
|
3357
|
+
});
|
|
3358
|
+
try {
|
|
3359
|
+
// No path rule denies; the external_directory allow must NOT apply here.
|
|
3360
|
+
const result = manager.checkPathPolicy(["/tmp/x"]);
|
|
3361
|
+
expect(result.toolName).toBe("path");
|
|
3362
|
+
expect(result.state).toBe("allow");
|
|
3363
|
+
expect(result.matchedPattern).toBe("*");
|
|
3364
|
+
} finally {
|
|
3365
|
+
cleanup();
|
|
3366
|
+
}
|
|
3367
|
+
});
|
|
3368
|
+
});
|