@gotgenes/pi-permission-system 12.0.0 → 13.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +7 -9
- package/config/config.example.json +2 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +28 -2
- package/src/common.ts +17 -1
- package/src/config-loader.ts +9 -5
- package/src/config-modal.ts +3 -7
- package/src/denial-messages.ts +2 -1
- package/src/extension-config.ts +11 -25
- package/src/handlers/gates/bash-path-extractor.ts +0 -12
- package/src/handlers/gates/bash-path.ts +12 -10
- package/src/handlers/gates/bash-program.ts +52 -11
- package/src/handlers/gates/bash-token-classification.ts +1 -1
- package/src/index.ts +4 -2
- package/src/input-normalizer.ts +17 -11
- package/src/normalize.ts +12 -2
- package/src/path-utils.ts +81 -0
- package/src/permission-manager.ts +82 -17
- package/src/permission-resolver.ts +24 -0
- package/src/permission-session.ts +2 -4
- package/src/rule.ts +63 -11
- package/src/types.ts +18 -3
- package/test/bash-external-directory.test.ts +1 -81
- package/test/common.test.ts +28 -0
- package/test/config-loader.test.ts +43 -0
- package/test/config-modal.test.ts +7 -10
- package/test/config-pipeline.test.ts +90 -0
- package/test/denial-messages.test.ts +61 -0
- package/test/extension-config.test.ts +0 -58
- package/test/handlers/gates/bash-path.test.ts +45 -2
- package/test/handlers/gates/bash-program.test.ts +44 -11
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +1 -1
- package/test/helpers/gate-fixtures.ts +23 -3
- package/test/helpers/handler-fixtures.ts +14 -2
- package/test/helpers/session-fixtures.ts +14 -0
- package/test/input-normalizer.test.ts +52 -0
- package/test/normalize.test.ts +81 -0
- package/test/path-utils.test.ts +72 -0
- package/test/permission-manager-unified.test.ts +199 -0
- package/test/permission-resolver.test.ts +69 -0
- package/test/rule.test.ts +135 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { loadAndMergeConfigs } from "#src/config-loader";
|
|
7
|
+
import { normalizePermissionSystemConfig } from "#src/extension-config";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Full-pipeline seam tests: write a temp config.json → loadAndMergeConfigs →
|
|
11
|
+
* normalizePermissionSystemConfig → assert values survive end to end.
|
|
12
|
+
*
|
|
13
|
+
* These tests guard the seam between the two normalizers — the class of bug
|
|
14
|
+
* fixed in #332, where a field declared on PermissionSystemExtensionConfig was
|
|
15
|
+
* silently dropped by the UnifiedPermissionConfig intermediate.
|
|
16
|
+
*/
|
|
17
|
+
describe("config pipeline seam", () => {
|
|
18
|
+
let tempDir: string;
|
|
19
|
+
let agentDir: string;
|
|
20
|
+
let cwd: string;
|
|
21
|
+
let extensionRoot: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
tempDir = mkdtempSync(join(tmpdir(), "config-pipeline-test-"));
|
|
25
|
+
agentDir = join(tempDir, "agent");
|
|
26
|
+
cwd = join(tempDir, "project");
|
|
27
|
+
extensionRoot = join(tempDir, "ext");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function writeGlobal(content: Record<string, unknown>): void {
|
|
35
|
+
const dir = join(agentDir, "extensions", "pi-permission-system");
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
writeFileSync(join(dir, "config.json"), JSON.stringify(content));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it("runtime knob and preview-length field both survive the full pipeline", () => {
|
|
41
|
+
writeGlobal({
|
|
42
|
+
debugLog: true,
|
|
43
|
+
toolInputPreviewMaxLength: 1000,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
47
|
+
const config = normalizePermissionSystemConfig(mergeResult.merged);
|
|
48
|
+
|
|
49
|
+
expect(config.debugLog).toBe(true);
|
|
50
|
+
expect(config.toolInputPreviewMaxLength).toBe(1000);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("text summary length field survives the full pipeline", () => {
|
|
54
|
+
writeGlobal({
|
|
55
|
+
toolTextSummaryMaxLength: 250,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
59
|
+
const config = normalizePermissionSystemConfig(mergeResult.merged);
|
|
60
|
+
|
|
61
|
+
expect(config.toolTextSummaryMaxLength).toBe(250);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("project config overrides global preview-length field end to end", () => {
|
|
65
|
+
writeGlobal({ toolInputPreviewMaxLength: 200 });
|
|
66
|
+
const projectDir = join(cwd, ".pi", "extensions", "pi-permission-system");
|
|
67
|
+
mkdirSync(projectDir, { recursive: true });
|
|
68
|
+
writeFileSync(
|
|
69
|
+
join(projectDir, "config.json"),
|
|
70
|
+
JSON.stringify({ toolInputPreviewMaxLength: 500 }),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
74
|
+
const config = normalizePermissionSystemConfig(mergeResult.merged);
|
|
75
|
+
|
|
76
|
+
expect(config.toolInputPreviewMaxLength).toBe(500);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("defaults apply when config file is absent", () => {
|
|
80
|
+
// No config files written — agentDir and cwd directories don't exist.
|
|
81
|
+
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
82
|
+
const config = normalizePermissionSystemConfig(mergeResult.merged);
|
|
83
|
+
|
|
84
|
+
expect(config.debugLog).toBe(false);
|
|
85
|
+
expect(config.permissionReviewLog).toBe(true);
|
|
86
|
+
expect(config.yoloMode).toBe(false);
|
|
87
|
+
expect(config.toolInputPreviewMaxLength).toBeUndefined();
|
|
88
|
+
expect(config.toolTextSummaryMaxLength).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -114,6 +114,67 @@ describe("formatDenyReason", () => {
|
|
|
114
114
|
);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
test("bash with a custom reason appended after the period", () => {
|
|
118
|
+
expect(
|
|
119
|
+
formatDenyReason(
|
|
120
|
+
toolCtx(
|
|
121
|
+
toolCheck("bash", {
|
|
122
|
+
command: "npm install",
|
|
123
|
+
matchedPattern: "npm *",
|
|
124
|
+
reason: "Use pnpm instead",
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
).toBe(
|
|
129
|
+
"[pi-permission-system] is not permitted to run 'bash' command 'npm install' (matched 'npm *'). Reason: Use pnpm instead.",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("custom reason with no matched pattern", () => {
|
|
134
|
+
expect(
|
|
135
|
+
formatDenyReason(
|
|
136
|
+
toolCtx(
|
|
137
|
+
toolCheck("write", {
|
|
138
|
+
reason: "Write access is disabled for security",
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
).toBe(
|
|
143
|
+
"[pi-permission-system] is not permitted to run 'write'. Reason: Write access is disabled for security.",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("custom reason is included alongside the agent name", () => {
|
|
148
|
+
expect(
|
|
149
|
+
formatDenyReason(
|
|
150
|
+
toolCtx(
|
|
151
|
+
toolCheck("bash", {
|
|
152
|
+
command: "yarn build",
|
|
153
|
+
matchedPattern: "yarn *",
|
|
154
|
+
reason: "Use pnpm instead",
|
|
155
|
+
}),
|
|
156
|
+
"dev-agent",
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
).toBe(
|
|
160
|
+
"[pi-permission-system] Agent 'dev-agent' is not permitted to run 'bash' command 'yarn build' (matched 'yarn *'). Reason: Use pnpm instead.",
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("custom reason on an MCP target", () => {
|
|
165
|
+
expect(
|
|
166
|
+
formatDenyReason(
|
|
167
|
+
toolCtx(
|
|
168
|
+
mcpCheck("server:deploy", {
|
|
169
|
+
reason: "Deploy requires approval from a senior engineer",
|
|
170
|
+
}),
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
).toBe(
|
|
174
|
+
"[pi-permission-system] is not permitted to run MCP target 'server:deploy'. Reason: Deploy requires approval from a senior engineer.",
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
117
178
|
test("MCP source with target on non-mcp toolName", () => {
|
|
118
179
|
expect(
|
|
119
180
|
formatDenyReason(
|
|
@@ -103,26 +103,6 @@ describe("normalizePermissionSystemConfig", () => {
|
|
|
103
103
|
expect(result.yoloMode).toBe(false);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
it("coerces non-boolean values to their defaults", () => {
|
|
107
|
-
const result = normalizePermissionSystemConfig({
|
|
108
|
-
debugLog: "yes",
|
|
109
|
-
permissionReviewLog: 1,
|
|
110
|
-
yoloMode: null,
|
|
111
|
-
});
|
|
112
|
-
expect(result.debugLog).toBe(false);
|
|
113
|
-
expect(result.permissionReviewLog).toBe(true);
|
|
114
|
-
expect(result.yoloMode).toBe(false);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("handles null/undefined input gracefully", () => {
|
|
118
|
-
const result = normalizePermissionSystemConfig(null);
|
|
119
|
-
expect(result).toEqual({
|
|
120
|
-
debugLog: false,
|
|
121
|
-
permissionReviewLog: true,
|
|
122
|
-
yoloMode: false,
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
106
|
it("includes toolInputPreviewMaxLength when a valid positive integer is provided", () => {
|
|
127
107
|
const result = normalizePermissionSystemConfig({
|
|
128
108
|
toolInputPreviewMaxLength: 400,
|
|
@@ -135,25 +115,6 @@ describe("normalizePermissionSystemConfig", () => {
|
|
|
135
115
|
expect("toolInputPreviewMaxLength" in result).toBe(false);
|
|
136
116
|
});
|
|
137
117
|
|
|
138
|
-
it("omits toolInputPreviewMaxLength for invalid values", () => {
|
|
139
|
-
expect(
|
|
140
|
-
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 0 })
|
|
141
|
-
.toolInputPreviewMaxLength,
|
|
142
|
-
).toBeUndefined();
|
|
143
|
-
expect(
|
|
144
|
-
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: -1 })
|
|
145
|
-
.toolInputPreviewMaxLength,
|
|
146
|
-
).toBeUndefined();
|
|
147
|
-
expect(
|
|
148
|
-
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 200.5 })
|
|
149
|
-
.toolInputPreviewMaxLength,
|
|
150
|
-
).toBeUndefined();
|
|
151
|
-
expect(
|
|
152
|
-
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: "200" })
|
|
153
|
-
.toolInputPreviewMaxLength,
|
|
154
|
-
).toBeUndefined();
|
|
155
|
-
});
|
|
156
|
-
|
|
157
118
|
it("includes toolTextSummaryMaxLength when a valid positive integer is provided", () => {
|
|
158
119
|
const result = normalizePermissionSystemConfig({
|
|
159
120
|
toolTextSummaryMaxLength: 120,
|
|
@@ -165,23 +126,4 @@ describe("normalizePermissionSystemConfig", () => {
|
|
|
165
126
|
const result = normalizePermissionSystemConfig({});
|
|
166
127
|
expect("toolTextSummaryMaxLength" in result).toBe(false);
|
|
167
128
|
});
|
|
168
|
-
|
|
169
|
-
it("omits toolTextSummaryMaxLength for invalid values", () => {
|
|
170
|
-
expect(
|
|
171
|
-
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 0 })
|
|
172
|
-
.toolTextSummaryMaxLength,
|
|
173
|
-
).toBeUndefined();
|
|
174
|
-
expect(
|
|
175
|
-
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: -1 })
|
|
176
|
-
.toolTextSummaryMaxLength,
|
|
177
|
-
).toBeUndefined();
|
|
178
|
-
expect(
|
|
179
|
-
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 80.1 })
|
|
180
|
-
.toolTextSummaryMaxLength,
|
|
181
|
-
).toBeUndefined();
|
|
182
|
-
expect(
|
|
183
|
-
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: true })
|
|
184
|
-
.toolTextSummaryMaxLength,
|
|
185
|
-
).toBeUndefined();
|
|
186
|
-
});
|
|
187
129
|
});
|
|
@@ -215,6 +215,49 @@ describe("describeBashPathGate", () => {
|
|
|
215
215
|
expect(desc.preCheck?.state).toBe("deny");
|
|
216
216
|
expect(desc.decision.value).toBe(".env");
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
it("resolves cd-aware policy values while keeping the raw prompt token", async () => {
|
|
220
|
+
const resolver = makeResolver(
|
|
221
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
222
|
+
);
|
|
223
|
+
const result = (await describeGate(
|
|
224
|
+
makeTcc({
|
|
225
|
+
input: { command: "cd nested && cat src/file.txt" },
|
|
226
|
+
cwd: "/test/project",
|
|
227
|
+
}),
|
|
228
|
+
resolver,
|
|
229
|
+
)) as GateDescriptor;
|
|
230
|
+
|
|
231
|
+
expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
|
|
232
|
+
[
|
|
233
|
+
"/test/project/nested/src/file.txt",
|
|
234
|
+
"nested/src/file.txt",
|
|
235
|
+
"src/file.txt",
|
|
236
|
+
],
|
|
237
|
+
undefined,
|
|
238
|
+
);
|
|
239
|
+
// The raw token drives the prompt, denial context, and session approval.
|
|
240
|
+
expect(result.denialContext).toMatchObject({ pathValue: "src/file.txt" });
|
|
241
|
+
expect(result.decision.value).toBe("src/file.txt");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("does not resolve relative policy values through an unknown cd", async () => {
|
|
245
|
+
const resolver = makeResolver(
|
|
246
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
247
|
+
);
|
|
248
|
+
await describeGate(
|
|
249
|
+
makeTcc({
|
|
250
|
+
input: { command: 'cd "$DIR" && cat src/foo.ts' },
|
|
251
|
+
cwd: "/test/project",
|
|
252
|
+
}),
|
|
253
|
+
resolver,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
|
|
257
|
+
["src/foo.ts"],
|
|
258
|
+
undefined,
|
|
259
|
+
);
|
|
260
|
+
});
|
|
218
261
|
});
|
|
219
262
|
|
|
220
263
|
// Home-relative path characterization (#350) ──────────────────────────────
|
|
@@ -229,7 +272,7 @@ describe("describeBashPathGate — home-relative paths", () => {
|
|
|
229
272
|
// cat ~/.ssh/config → token "~/.ssh/config" extracted.
|
|
230
273
|
const resolver = makePathDispatchResolver(
|
|
231
274
|
{
|
|
232
|
-
"
|
|
275
|
+
"/mock/home/.ssh/config": makeCheckResult({
|
|
233
276
|
state: "deny",
|
|
234
277
|
matchedPattern: "~/.ssh/*",
|
|
235
278
|
}),
|
|
@@ -253,7 +296,7 @@ describe("describeBashPathGate — home-relative paths", () => {
|
|
|
253
296
|
it("extracts $HOME/... token and builds descriptor on deny", async () => {
|
|
254
297
|
const resolver = makePathDispatchResolver(
|
|
255
298
|
{
|
|
256
|
-
"
|
|
299
|
+
"/mock/home/.ssh/config": makeCheckResult({
|
|
257
300
|
state: "deny",
|
|
258
301
|
matchedPattern: "$HOME/.ssh/*",
|
|
259
302
|
}),
|
|
@@ -13,20 +13,50 @@ vi.mock("node:fs", () => ({
|
|
|
13
13
|
import { BashProgram } from "#src/handlers/gates/bash-program";
|
|
14
14
|
|
|
15
15
|
describe("BashProgram", () => {
|
|
16
|
-
describe("
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
describe("pathRuleCandidates", () => {
|
|
17
|
+
const cwd = "/projects/my-app";
|
|
18
|
+
|
|
19
|
+
it("adds absolute and relative policy values for relative tokens", async () => {
|
|
20
|
+
const program = await BashProgram.parse("cat src/foo.ts");
|
|
21
|
+
expect(program.pathRuleCandidates(cwd)).toEqual([
|
|
22
|
+
{
|
|
23
|
+
token: "src/foo.ts",
|
|
24
|
+
policyValues: ["/projects/my-app/src/foo.ts", "src/foo.ts"],
|
|
25
|
+
},
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns the literal token only when no cwd is provided", async () => {
|
|
30
|
+
const program = await BashProgram.parse("cat src/foo.ts");
|
|
31
|
+
expect(program.pathRuleCandidates()).toEqual([
|
|
32
|
+
{ token: "src/foo.ts", policyValues: ["src/foo.ts"] },
|
|
33
|
+
]);
|
|
20
34
|
});
|
|
21
35
|
|
|
22
|
-
it("
|
|
23
|
-
const program = await BashProgram.parse("
|
|
24
|
-
|
|
36
|
+
it("resolves tokens after literal cd against the effective directory", async () => {
|
|
37
|
+
const program = await BashProgram.parse("cd nested && cat src/file.txt");
|
|
38
|
+
const fileCandidate = program
|
|
39
|
+
.pathRuleCandidates(cwd)
|
|
40
|
+
.find((candidate) => candidate.token === "src/file.txt");
|
|
41
|
+
expect(fileCandidate).toEqual({
|
|
42
|
+
token: "src/file.txt",
|
|
43
|
+
policyValues: [
|
|
44
|
+
"/projects/my-app/nested/src/file.txt",
|
|
45
|
+
"nested/src/file.txt",
|
|
46
|
+
"src/file.txt",
|
|
47
|
+
],
|
|
48
|
+
});
|
|
25
49
|
});
|
|
26
50
|
|
|
27
|
-
it("
|
|
28
|
-
const program = await BashProgram.parse("
|
|
29
|
-
|
|
51
|
+
it("does not absolute-allow relative tokens after unknown cd", async () => {
|
|
52
|
+
const program = await BashProgram.parse('cd "$DIR" && cat src/foo.ts');
|
|
53
|
+
const fileCandidate = program
|
|
54
|
+
.pathRuleCandidates(cwd)
|
|
55
|
+
.find((candidate) => candidate.token === "src/foo.ts");
|
|
56
|
+
expect(fileCandidate).toEqual({
|
|
57
|
+
token: "src/foo.ts",
|
|
58
|
+
policyValues: ["src/foo.ts"],
|
|
59
|
+
});
|
|
30
60
|
});
|
|
31
61
|
});
|
|
32
62
|
|
|
@@ -322,7 +352,10 @@ describe("BashProgram", () => {
|
|
|
322
352
|
|
|
323
353
|
it("derives both slices from a single parse", async () => {
|
|
324
354
|
const program = await BashProgram.parse("cat .env /etc/hosts");
|
|
325
|
-
expect(program.
|
|
355
|
+
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
356
|
+
".env",
|
|
357
|
+
"/etc/hosts",
|
|
358
|
+
]);
|
|
326
359
|
const external = program.externalPaths("/projects/my-app");
|
|
327
360
|
expect(external).toContain("/etc/hosts");
|
|
328
361
|
expect(external).not.toContain(".env");
|
|
@@ -23,7 +23,7 @@ vi.mock("#src/handlers/gates/bash-program", () => ({
|
|
|
23
23
|
function makeMockBashProgram() {
|
|
24
24
|
return {
|
|
25
25
|
commands: vi.fn<() => []>(() => []),
|
|
26
|
-
|
|
26
|
+
pathRuleCandidates: vi.fn<() => []>(() => []),
|
|
27
27
|
externalPaths: vi.fn<() => []>(() => []),
|
|
28
28
|
};
|
|
29
29
|
}
|
|
@@ -26,10 +26,13 @@ import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
|
26
26
|
*/
|
|
27
27
|
export function makeResolver(defaultCheck?: PermissionCheckResult) {
|
|
28
28
|
const resolve = vi.fn<ScopedPermissionResolver["resolve"]>();
|
|
29
|
+
const resolvePathPolicy =
|
|
30
|
+
vi.fn<ScopedPermissionResolver["resolvePathPolicy"]>();
|
|
29
31
|
if (defaultCheck) {
|
|
30
32
|
resolve.mockReturnValue(defaultCheck);
|
|
33
|
+
resolvePathPolicy.mockReturnValue(defaultCheck);
|
|
31
34
|
}
|
|
32
|
-
return { resolve };
|
|
35
|
+
return { resolve, resolvePathPolicy };
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/**
|
|
@@ -92,6 +95,7 @@ export function makeGateRunner(
|
|
|
92
95
|
overrides: {
|
|
93
96
|
resolveResult?: PermissionCheckResult;
|
|
94
97
|
resolve?: ScopedPermissionResolver["resolve"];
|
|
98
|
+
resolvePathPolicy?: ScopedPermissionResolver["resolvePathPolicy"];
|
|
95
99
|
recordSessionApproval?: SessionApprovalRecorder["recordSessionApproval"];
|
|
96
100
|
canConfirm?: GatePrompter["canConfirm"];
|
|
97
101
|
prompt?: GatePrompter["prompt"];
|
|
@@ -106,6 +110,13 @@ export function makeGateRunner(
|
|
|
106
110
|
.mockReturnValue(
|
|
107
111
|
overrides.resolveResult ?? makeCheckResult({ matchedPattern: "*" }),
|
|
108
112
|
);
|
|
113
|
+
const resolvePathPolicy =
|
|
114
|
+
overrides.resolvePathPolicy ??
|
|
115
|
+
vi
|
|
116
|
+
.fn<ScopedPermissionResolver["resolvePathPolicy"]>()
|
|
117
|
+
.mockReturnValue(
|
|
118
|
+
overrides.resolveResult ?? makeCheckResult({ matchedPattern: "*" }),
|
|
119
|
+
);
|
|
109
120
|
const recordSessionApproval =
|
|
110
121
|
overrides.recordSessionApproval ??
|
|
111
122
|
(vi.fn() as SessionApprovalRecorder["recordSessionApproval"]);
|
|
@@ -118,7 +129,7 @@ export function makeGateRunner(
|
|
|
118
129
|
.fn<GatePrompter["prompt"]>()
|
|
119
130
|
.mockResolvedValue({ approved: true, state: "approved" });
|
|
120
131
|
const runner = new GateRunner(
|
|
121
|
-
{ resolve },
|
|
132
|
+
{ resolve, resolvePathPolicy },
|
|
122
133
|
{ recordSessionApproval },
|
|
123
134
|
{ canConfirm, prompt },
|
|
124
135
|
reporter,
|
|
@@ -127,6 +138,7 @@ export function makeGateRunner(
|
|
|
127
138
|
runner,
|
|
128
139
|
deps: {
|
|
129
140
|
resolve,
|
|
141
|
+
resolvePathPolicy,
|
|
130
142
|
recordSessionApproval,
|
|
131
143
|
canConfirm,
|
|
132
144
|
prompt,
|
|
@@ -212,7 +224,15 @@ export function makePathDispatchResolver(
|
|
|
212
224
|
}
|
|
213
225
|
return defaultResult;
|
|
214
226
|
});
|
|
215
|
-
|
|
227
|
+
const resolvePathPolicy =
|
|
228
|
+
vi.fn<ScopedPermissionResolver["resolvePathPolicy"]>();
|
|
229
|
+
resolvePathPolicy.mockImplementation((values) => {
|
|
230
|
+
for (const value of values) {
|
|
231
|
+
if (value in byPath) return byPath[value];
|
|
232
|
+
}
|
|
233
|
+
return defaultResult;
|
|
234
|
+
});
|
|
235
|
+
return { resolve, resolvePathPolicy };
|
|
216
236
|
}
|
|
217
237
|
|
|
218
238
|
/**
|
|
@@ -236,9 +236,21 @@ export function makeHandler(overrides?: {
|
|
|
236
236
|
|
|
237
237
|
// Apply session override bag to the real collaborators.
|
|
238
238
|
const so = overrides?.session;
|
|
239
|
-
|
|
239
|
+
const surfaceCheck = so?.checkPermission;
|
|
240
|
+
if (surfaceCheck) {
|
|
240
241
|
vi.mocked(permissionManager.checkPermission).mockImplementation(
|
|
241
|
-
|
|
242
|
+
surfaceCheck,
|
|
243
|
+
);
|
|
244
|
+
// The bash path gate resolves through checkPathPolicy; route it through
|
|
245
|
+
// the same surface dispatcher so `path` overrides apply to bash tokens.
|
|
246
|
+
vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
|
|
247
|
+
(values, agentName, sessionRules) =>
|
|
248
|
+
surfaceCheck(
|
|
249
|
+
"path",
|
|
250
|
+
{ path: values[0] ?? "*" },
|
|
251
|
+
agentName,
|
|
252
|
+
sessionRules,
|
|
253
|
+
),
|
|
242
254
|
);
|
|
243
255
|
}
|
|
244
256
|
if (so?.getActiveSkillEntries) {
|
|
@@ -102,6 +102,20 @@ export function makeFakePermissionManager() {
|
|
|
102
102
|
source: "tool",
|
|
103
103
|
origin: "builtin",
|
|
104
104
|
}),
|
|
105
|
+
checkPathPolicy: vi
|
|
106
|
+
.fn<
|
|
107
|
+
(
|
|
108
|
+
values: readonly string[],
|
|
109
|
+
agentName?: string,
|
|
110
|
+
sessionRules?: Ruleset,
|
|
111
|
+
) => PermissionCheckResult
|
|
112
|
+
>()
|
|
113
|
+
.mockReturnValue({
|
|
114
|
+
state: "allow",
|
|
115
|
+
toolName: "path",
|
|
116
|
+
source: "special",
|
|
117
|
+
origin: "builtin",
|
|
118
|
+
}),
|
|
105
119
|
getToolPermission: vi
|
|
106
120
|
.fn<(toolName: string, agentName?: string) => PermissionState>()
|
|
107
121
|
.mockReturnValue("allow"),
|
|
@@ -68,6 +68,32 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
68
68
|
const result = normalizeInput("path", {}, []);
|
|
69
69
|
expect(result.values).toEqual(["*"]);
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
it("adds cwd-normalized and relative aliases when cwd is provided", () => {
|
|
73
|
+
const result = normalizeInput(
|
|
74
|
+
"path",
|
|
75
|
+
{ path: "src/App.jsx" },
|
|
76
|
+
[],
|
|
77
|
+
"/workspace/project",
|
|
78
|
+
);
|
|
79
|
+
expect(result.values).toEqual([
|
|
80
|
+
"/workspace/project/src/App.jsx",
|
|
81
|
+
"src/App.jsx",
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("ignores a user-supplied string pathPolicyValues field", () => {
|
|
86
|
+
const result = normalizeInput(
|
|
87
|
+
"path",
|
|
88
|
+
{ path: "src/App.jsx", pathPolicyValues: ["/etc/shadow"] },
|
|
89
|
+
[],
|
|
90
|
+
"/workspace/project",
|
|
91
|
+
);
|
|
92
|
+
expect(result.values).toEqual([
|
|
93
|
+
"/workspace/project/src/App.jsx",
|
|
94
|
+
"src/App.jsx",
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
71
97
|
});
|
|
72
98
|
|
|
73
99
|
describe("special / external_directory", () => {
|
|
@@ -119,6 +145,19 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
119
145
|
);
|
|
120
146
|
expect(result.values).toEqual([join("/mock/home", "dev/project")]);
|
|
121
147
|
});
|
|
148
|
+
|
|
149
|
+
it("adds cwd-normalized and relative aliases when cwd is provided", () => {
|
|
150
|
+
const result = normalizeInput(
|
|
151
|
+
"external_directory",
|
|
152
|
+
{ path: "src/App.jsx" },
|
|
153
|
+
[],
|
|
154
|
+
"/workspace/project",
|
|
155
|
+
);
|
|
156
|
+
expect(result.values).toEqual([
|
|
157
|
+
"/workspace/project/src/App.jsx",
|
|
158
|
+
"src/App.jsx",
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
122
161
|
});
|
|
123
162
|
|
|
124
163
|
describe("skill", () => {
|
|
@@ -206,6 +245,19 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
206
245
|
const result = normalizeInput("write", { path: "$HOME/.ssh/config" }, []);
|
|
207
246
|
expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
|
|
208
247
|
});
|
|
248
|
+
|
|
249
|
+
it("adds cwd-normalized and relative aliases when cwd is provided", () => {
|
|
250
|
+
const result = normalizeInput(
|
|
251
|
+
"read",
|
|
252
|
+
{ path: "src/App.jsx" },
|
|
253
|
+
[],
|
|
254
|
+
"/workspace/project",
|
|
255
|
+
);
|
|
256
|
+
expect(result.values).toEqual([
|
|
257
|
+
"/workspace/project/src/App.jsx",
|
|
258
|
+
"src/App.jsx",
|
|
259
|
+
]);
|
|
260
|
+
});
|
|
209
261
|
});
|
|
210
262
|
|
|
211
263
|
describe("extension tools (non-path-bearing)", () => {
|
package/test/normalize.test.ts
CHANGED
|
@@ -163,4 +163,85 @@ describe("normalizeFlatConfig", () => {
|
|
|
163
163
|
]);
|
|
164
164
|
});
|
|
165
165
|
});
|
|
166
|
+
|
|
167
|
+
describe("deny with reason", () => {
|
|
168
|
+
test("{ action: 'deny', reason } produces a deny rule carrying the reason", () => {
|
|
169
|
+
const result = normalizeFlatConfig({
|
|
170
|
+
bash: { "npm *": { action: "deny", reason: "Use pnpm instead" } },
|
|
171
|
+
});
|
|
172
|
+
expect(result).toEqual([
|
|
173
|
+
{
|
|
174
|
+
surface: "bash",
|
|
175
|
+
pattern: "npm *",
|
|
176
|
+
action: "deny",
|
|
177
|
+
reason: "Use pnpm instead",
|
|
178
|
+
origin: "builtin",
|
|
179
|
+
},
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("{ action: 'deny' } without a reason produces a deny rule without reason", () => {
|
|
184
|
+
const result = normalizeFlatConfig({
|
|
185
|
+
bash: { "rm -rf *": { action: "deny" } },
|
|
186
|
+
});
|
|
187
|
+
expect(result).toEqual([
|
|
188
|
+
{
|
|
189
|
+
surface: "bash",
|
|
190
|
+
pattern: "rm -rf *",
|
|
191
|
+
action: "deny",
|
|
192
|
+
origin: "builtin",
|
|
193
|
+
},
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("deny-with-reason and plain strings coexist in the same surface", () => {
|
|
198
|
+
const result = normalizeFlatConfig({
|
|
199
|
+
bash: {
|
|
200
|
+
"git *": "allow",
|
|
201
|
+
"npm *": { action: "deny", reason: "Use pnpm" },
|
|
202
|
+
"*": "ask",
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
expect(result).toEqual([
|
|
206
|
+
{
|
|
207
|
+
surface: "bash",
|
|
208
|
+
pattern: "git *",
|
|
209
|
+
action: "allow",
|
|
210
|
+
origin: "builtin",
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
surface: "bash",
|
|
214
|
+
pattern: "npm *",
|
|
215
|
+
action: "deny",
|
|
216
|
+
reason: "Use pnpm",
|
|
217
|
+
origin: "builtin",
|
|
218
|
+
},
|
|
219
|
+
{ surface: "bash", pattern: "*", action: "ask", origin: "builtin" },
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("top-level deny-with-reason object is treated as a pattern map", () => {
|
|
224
|
+
// At the surface level, { action: "deny", reason: "..." } is parsed as a
|
|
225
|
+
// pattern→action map: "action" is a pattern key with action "deny", and
|
|
226
|
+
// "reason" maps to a non-PermissionState string that is dropped.
|
|
227
|
+
const result = normalizeFlatConfig({
|
|
228
|
+
bash: { action: "deny", reason: "Not allowed" } as never,
|
|
229
|
+
});
|
|
230
|
+
expect(result).toEqual([
|
|
231
|
+
{
|
|
232
|
+
surface: "bash",
|
|
233
|
+
pattern: "action",
|
|
234
|
+
action: "deny",
|
|
235
|
+
origin: "builtin",
|
|
236
|
+
},
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("non-string reason is rejected (malformed config)", () => {
|
|
241
|
+
const result = normalizeFlatConfig({
|
|
242
|
+
bash: { "npm *": { action: "deny", reason: 42 } as never },
|
|
243
|
+
});
|
|
244
|
+
expect(result).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
166
247
|
});
|