@gotgenes/pi-permission-system 5.15.0 → 5.17.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 +35 -0
- package/README.md +14 -0
- package/config/config.example.json +7 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +8 -1
- package/src/handlers/gates/bash-path-extractor.ts +75 -0
- package/src/handlers/gates/bash-path.ts +146 -0
- package/src/handlers/gates/helpers.ts +4 -1
- package/src/handlers/gates/index.ts +2 -0
- package/src/handlers/gates/path.ts +104 -0
- package/src/handlers/gates/tool.ts +25 -9
- package/src/handlers/permission-gate-handler.ts +46 -0
- package/src/input-normalizer.ts +13 -2
- package/src/pattern-suggest.ts +12 -2
- package/src/permission-manager.ts +1 -1
- package/src/rule.ts +27 -0
- package/tests/bash-external-directory.test.ts +81 -1
- package/tests/handlers/external-directory-integration.test.ts +84 -3
- package/tests/handlers/gates/bash-path.test.ts +260 -0
- package/tests/handlers/gates/helpers.test.ts +15 -2
- package/tests/handlers/gates/path.test.ts +149 -0
- package/tests/handlers/tool-call.test.ts +78 -0
- package/tests/input-normalizer.test.ts +65 -4
- package/tests/pattern-suggest.test.ts +40 -12
- package/tests/permission-manager-unified.test.ts +341 -0
- package/tests/rule.test.ts +77 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { GateDescriptor } from "../../../src/handlers/gates/descriptor";
|
|
4
|
+
import { isGateDescriptor } from "../../../src/handlers/gates/descriptor";
|
|
5
|
+
import { describePathGate } from "../../../src/handlers/gates/path";
|
|
6
|
+
import type { ToolCallContext } from "../../../src/handlers/gates/types";
|
|
7
|
+
import type { PermissionCheckResult } from "../../../src/types";
|
|
8
|
+
|
|
9
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
12
|
+
return {
|
|
13
|
+
toolName: "read",
|
|
14
|
+
agentName: null,
|
|
15
|
+
input: { path: ".env" },
|
|
16
|
+
toolCallId: "tc-1",
|
|
17
|
+
cwd: "/test/project",
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeCheckResult(
|
|
23
|
+
overrides: Partial<PermissionCheckResult> = {},
|
|
24
|
+
): PermissionCheckResult {
|
|
25
|
+
return {
|
|
26
|
+
toolName: "path",
|
|
27
|
+
state: "allow",
|
|
28
|
+
source: "special",
|
|
29
|
+
origin: "global",
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type CheckPermissionFn = (
|
|
35
|
+
surface: string,
|
|
36
|
+
input: unknown,
|
|
37
|
+
agentName?: string,
|
|
38
|
+
sessionRules?: unknown[],
|
|
39
|
+
) => PermissionCheckResult;
|
|
40
|
+
|
|
41
|
+
// ── tests ──────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe("describePathGate", () => {
|
|
44
|
+
it("returns null for non-path-bearing tools", () => {
|
|
45
|
+
const checkPermission = vi.fn<CheckPermissionFn>();
|
|
46
|
+
const result = describePathGate(
|
|
47
|
+
makeTcc({ toolName: "bash", input: { command: "ls" } }),
|
|
48
|
+
checkPermission,
|
|
49
|
+
);
|
|
50
|
+
expect(result).toBeNull();
|
|
51
|
+
expect(checkPermission).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns null when tool has no extractable path", () => {
|
|
55
|
+
const checkPermission = vi.fn<CheckPermissionFn>();
|
|
56
|
+
const result = describePathGate(
|
|
57
|
+
makeTcc({ toolName: "read", input: {} }),
|
|
58
|
+
checkPermission,
|
|
59
|
+
);
|
|
60
|
+
expect(result).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns null when path check result is allow", () => {
|
|
64
|
+
const checkPermission = vi
|
|
65
|
+
.fn<CheckPermissionFn>()
|
|
66
|
+
.mockReturnValue(makeCheckResult({ state: "allow" }));
|
|
67
|
+
const result = describePathGate(makeTcc(), checkPermission);
|
|
68
|
+
expect(result).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns GateDescriptor when path check result is deny", () => {
|
|
72
|
+
const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
|
|
73
|
+
makeCheckResult({
|
|
74
|
+
state: "deny",
|
|
75
|
+
matchedPattern: "*.env",
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
const result = describePathGate(makeTcc(), checkPermission);
|
|
79
|
+
expect(result).not.toBeNull();
|
|
80
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
81
|
+
const desc = result as GateDescriptor;
|
|
82
|
+
expect(desc.surface).toBe("path");
|
|
83
|
+
expect(desc.preCheck?.state).toBe("deny");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns GateDescriptor when path check result is ask", () => {
|
|
87
|
+
const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
|
|
88
|
+
makeCheckResult({
|
|
89
|
+
state: "ask",
|
|
90
|
+
matchedPattern: "*.env",
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
const result = describePathGate(makeTcc(), checkPermission);
|
|
94
|
+
expect(result).not.toBeNull();
|
|
95
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
96
|
+
const desc = result as GateDescriptor;
|
|
97
|
+
expect(desc.surface).toBe("path");
|
|
98
|
+
expect(desc.preCheck?.state).toBe("ask");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("descriptor has correct session approval surface and pattern", () => {
|
|
102
|
+
const checkPermission = vi
|
|
103
|
+
.fn<CheckPermissionFn>()
|
|
104
|
+
.mockReturnValue(makeCheckResult({ state: "ask" }));
|
|
105
|
+
const result = describePathGate(
|
|
106
|
+
makeTcc({ input: { path: "/test/project/src/.env" } }),
|
|
107
|
+
checkPermission,
|
|
108
|
+
) as GateDescriptor;
|
|
109
|
+
expect(result.sessionApproval).toBeDefined();
|
|
110
|
+
expect(result.sessionApproval).toHaveProperty("surface", "path");
|
|
111
|
+
expect(result.sessionApproval).toHaveProperty("pattern");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("descriptor messages reference the file path", () => {
|
|
115
|
+
const checkPermission = vi
|
|
116
|
+
.fn<CheckPermissionFn>()
|
|
117
|
+
.mockReturnValue(makeCheckResult({ state: "deny" }));
|
|
118
|
+
const result = describePathGate(
|
|
119
|
+
makeTcc(),
|
|
120
|
+
checkPermission,
|
|
121
|
+
) as GateDescriptor;
|
|
122
|
+
expect(result.messages.denyReason).toContain(".env");
|
|
123
|
+
expect(result.messages.unavailableReason).toContain(".env");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("descriptor decision uses surface 'path' and the file path as value", () => {
|
|
127
|
+
const checkPermission = vi
|
|
128
|
+
.fn<CheckPermissionFn>()
|
|
129
|
+
.mockReturnValue(makeCheckResult({ state: "deny" }));
|
|
130
|
+
const result = describePathGate(
|
|
131
|
+
makeTcc(),
|
|
132
|
+
checkPermission,
|
|
133
|
+
) as GateDescriptor;
|
|
134
|
+
expect(result.decision.surface).toBe("path");
|
|
135
|
+
expect(result.decision.value).toBe(".env");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("passes agentName to checkPermission", () => {
|
|
139
|
+
const checkPermission = vi
|
|
140
|
+
.fn<CheckPermissionFn>()
|
|
141
|
+
.mockReturnValue(makeCheckResult({ state: "allow" }));
|
|
142
|
+
describePathGate(makeTcc({ agentName: "my-agent" }), checkPermission);
|
|
143
|
+
expect(checkPermission).toHaveBeenCalledWith(
|
|
144
|
+
"path",
|
|
145
|
+
{ path: ".env" },
|
|
146
|
+
"my-agent",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -297,3 +297,81 @@ describe("handleToolCall — bash external-directory gate", () => {
|
|
|
297
297
|
expect(result).toMatchObject({ block: true });
|
|
298
298
|
});
|
|
299
299
|
});
|
|
300
|
+
|
|
301
|
+
// ── path gate (tools) ─────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
describe("handleToolCall — path gate (tools)", () => {
|
|
304
|
+
it("blocks a read of .env when path surface denies *.env", async () => {
|
|
305
|
+
const checkPermission = vi
|
|
306
|
+
.fn()
|
|
307
|
+
.mockImplementation(
|
|
308
|
+
(surface: string, _input: unknown, _agentName?: string) => {
|
|
309
|
+
if (surface === "path") {
|
|
310
|
+
return makePermissionResult("deny");
|
|
311
|
+
}
|
|
312
|
+
return makePermissionResult("allow");
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
const { handler } = makeHandler({
|
|
316
|
+
session: { checkPermission },
|
|
317
|
+
toolRegistry: {
|
|
318
|
+
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
const event = {
|
|
322
|
+
type: "tool_call",
|
|
323
|
+
toolCallId: "tc-path",
|
|
324
|
+
name: "read",
|
|
325
|
+
input: { path: ".env" },
|
|
326
|
+
};
|
|
327
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
328
|
+
expect(result).toMatchObject({ block: true });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("allows a read when path surface allows", async () => {
|
|
332
|
+
const { handler } = makeHandler({
|
|
333
|
+
toolRegistry: {
|
|
334
|
+
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
const event = {
|
|
338
|
+
type: "tool_call",
|
|
339
|
+
toolCallId: "tc-path-ok",
|
|
340
|
+
name: "read",
|
|
341
|
+
input: { path: "src/index.ts" },
|
|
342
|
+
};
|
|
343
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
344
|
+
expect(result).toEqual({});
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ── bash path gate ────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
describe("handleToolCall — bash path gate", () => {
|
|
351
|
+
it("blocks a bash command accessing .env when path surface denies", async () => {
|
|
352
|
+
const checkPermission = vi
|
|
353
|
+
.fn()
|
|
354
|
+
.mockImplementation(
|
|
355
|
+
(surface: string, _input: unknown, _agentName?: string) => {
|
|
356
|
+
if (surface === "path") {
|
|
357
|
+
return makePermissionResult("deny");
|
|
358
|
+
}
|
|
359
|
+
return makePermissionResult("allow");
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
const { handler } = makeHandler({
|
|
363
|
+
session: { checkPermission },
|
|
364
|
+
toolRegistry: {
|
|
365
|
+
getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
const event = {
|
|
369
|
+
type: "tool_call",
|
|
370
|
+
toolCallId: "tc-bash-path",
|
|
371
|
+
name: "bash",
|
|
372
|
+
input: { command: "cat .env" },
|
|
373
|
+
};
|
|
374
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
375
|
+
expect(result).toMatchObject({ block: true });
|
|
376
|
+
});
|
|
377
|
+
});
|
|
@@ -3,6 +3,30 @@ import { normalizeInput } from "../src/input-normalizer";
|
|
|
3
3
|
import { createMcpPermissionTargets } from "../src/mcp-targets";
|
|
4
4
|
|
|
5
5
|
describe("normalizeInput — non-MCP surfaces", () => {
|
|
6
|
+
describe("special / path", () => {
|
|
7
|
+
it("uses path from input as the lookup value", () => {
|
|
8
|
+
const result = normalizeInput("path", { path: ".env" }, []);
|
|
9
|
+
expect(result.surface).toBe("path");
|
|
10
|
+
expect(result.values).toEqual([".env"]);
|
|
11
|
+
expect(result.resultExtras).toEqual({});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("falls back to '*' when path is missing", () => {
|
|
15
|
+
const result = normalizeInput("path", {}, []);
|
|
16
|
+
expect(result.values).toEqual(["*"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("falls back to '*' when path is not a string", () => {
|
|
20
|
+
const result = normalizeInput("path", { path: 42 }, []);
|
|
21
|
+
expect(result.values).toEqual(["*"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("handles null input", () => {
|
|
25
|
+
const result = normalizeInput("path", null, []);
|
|
26
|
+
expect(result.values).toEqual(["*"]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
6
30
|
describe("special / external_directory", () => {
|
|
7
31
|
it("uses path from input as the lookup value", () => {
|
|
8
32
|
const result = normalizeInput(
|
|
@@ -71,22 +95,59 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
71
95
|
});
|
|
72
96
|
});
|
|
73
97
|
|
|
74
|
-
describe("
|
|
75
|
-
it("uses
|
|
98
|
+
describe("path-bearing tools (read, write, edit, grep, find, ls)", () => {
|
|
99
|
+
it("uses input.path as the lookup value when path is present", () => {
|
|
76
100
|
for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
|
|
77
|
-
const result = normalizeInput(
|
|
101
|
+
const result = normalizeInput(
|
|
102
|
+
tool,
|
|
103
|
+
{ path: "/project/src/main.ts" },
|
|
104
|
+
[],
|
|
105
|
+
);
|
|
78
106
|
expect(result.surface).toBe(tool);
|
|
79
|
-
expect(result.values).toEqual(["
|
|
107
|
+
expect(result.values).toEqual(["/project/src/main.ts"]);
|
|
80
108
|
expect(result.resultExtras).toEqual({});
|
|
81
109
|
}
|
|
82
110
|
});
|
|
83
111
|
|
|
112
|
+
it("falls back to '*' when input.path is missing", () => {
|
|
113
|
+
for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
|
|
114
|
+
const result = normalizeInput(tool, {}, []);
|
|
115
|
+
expect(result.values).toEqual(["*"]);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("falls back to '*' when input.path is empty string", () => {
|
|
120
|
+
const result = normalizeInput("read", { path: "" }, []);
|
|
121
|
+
expect(result.values).toEqual(["*"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("falls back to '*' when input.path is not a string", () => {
|
|
125
|
+
const result = normalizeInput("write", { path: 42 }, []);
|
|
126
|
+
expect(result.values).toEqual(["*"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("falls back to '*' when input is null", () => {
|
|
130
|
+
const result = normalizeInput("edit", null, []);
|
|
131
|
+
expect(result.values).toEqual(["*"]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("extension tools (non-path-bearing)", () => {
|
|
84
136
|
it("uses '*' as the lookup value for extension tools", () => {
|
|
85
137
|
const result = normalizeInput("my_extension_tool", { some: "input" }, []);
|
|
86
138
|
expect(result.surface).toBe("my_extension_tool");
|
|
87
139
|
expect(result.values).toEqual(["*"]);
|
|
88
140
|
expect(result.resultExtras).toEqual({});
|
|
89
141
|
});
|
|
142
|
+
|
|
143
|
+
it("uses '*' even when extension tool has a path field", () => {
|
|
144
|
+
const result = normalizeInput(
|
|
145
|
+
"my_extension_tool",
|
|
146
|
+
{ path: "/some/path" },
|
|
147
|
+
[],
|
|
148
|
+
);
|
|
149
|
+
expect(result.values).toEqual(["*"]);
|
|
150
|
+
});
|
|
90
151
|
});
|
|
91
152
|
});
|
|
92
153
|
|
|
@@ -121,28 +121,51 @@ describe("suggestSessionPattern", () => {
|
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
-
describe("tool surfaces", () => {
|
|
125
|
-
it("returns
|
|
126
|
-
const result = suggestSessionPattern("read", "
|
|
127
|
-
expect(result).toMatchObject({
|
|
124
|
+
describe("path-bearing tool surfaces", () => {
|
|
125
|
+
it("returns directory-scoped pattern for read with a file path", () => {
|
|
126
|
+
const result = suggestSessionPattern("read", "/outside/project/file.ts");
|
|
127
|
+
expect(result).toMatchObject({
|
|
128
|
+
surface: "read",
|
|
129
|
+
pattern: "/outside/project/*",
|
|
130
|
+
});
|
|
128
131
|
});
|
|
129
132
|
|
|
130
|
-
it("returns
|
|
131
|
-
const result = suggestSessionPattern("write", "
|
|
132
|
-
expect(result).toMatchObject({
|
|
133
|
+
it("returns directory-scoped pattern for write with a file path", () => {
|
|
134
|
+
const result = suggestSessionPattern("write", "src/main.ts");
|
|
135
|
+
expect(result).toMatchObject({
|
|
136
|
+
surface: "write",
|
|
137
|
+
pattern: "src/*",
|
|
138
|
+
});
|
|
133
139
|
});
|
|
134
140
|
|
|
135
|
-
it("returns *
|
|
136
|
-
const result = suggestSessionPattern("
|
|
137
|
-
expect(result).toMatchObject({ surface: "
|
|
141
|
+
it("returns * when value is '*' (fallback)", () => {
|
|
142
|
+
const result = suggestSessionPattern("read", "*");
|
|
143
|
+
expect(result).toMatchObject({ surface: "read", pattern: "*" });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("label includes the path pattern for path-bearing tools", () => {
|
|
147
|
+
const result = suggestSessionPattern("read", "/tmp/data/file.txt");
|
|
148
|
+
expect(result.label).toBe(
|
|
149
|
+
'Yes, allow read "/tmp/data/*" for this session',
|
|
150
|
+
);
|
|
138
151
|
});
|
|
139
152
|
|
|
140
|
-
it("label shows tool name
|
|
153
|
+
it("label shows tool name when pattern is *", () => {
|
|
141
154
|
const result = suggestSessionPattern("find", "*");
|
|
142
155
|
expect(result.label).toBe('Yes, allow tool "find" for this session');
|
|
143
156
|
});
|
|
144
157
|
});
|
|
145
158
|
|
|
159
|
+
describe("non-path-bearing tool surfaces", () => {
|
|
160
|
+
it("returns * for extension tools", () => {
|
|
161
|
+
const result = suggestSessionPattern("my_extension_tool", "*");
|
|
162
|
+
expect(result).toMatchObject({
|
|
163
|
+
surface: "my_extension_tool",
|
|
164
|
+
pattern: "*",
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
146
169
|
describe("label field", () => {
|
|
147
170
|
it("bash label includes surface prefix and pattern", () => {
|
|
148
171
|
const result = suggestSessionPattern("bash", "git status");
|
|
@@ -173,7 +196,12 @@ describe("suggestSessionPattern", () => {
|
|
|
173
196
|
);
|
|
174
197
|
});
|
|
175
198
|
|
|
176
|
-
it("tool label
|
|
199
|
+
it("path-bearing tool label includes path pattern", () => {
|
|
200
|
+
const result = suggestSessionPattern("edit", "src/file.ts");
|
|
201
|
+
expect(result.label).toBe('Yes, allow edit "src/*" for this session');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("tool label shows tool name when value is *", () => {
|
|
177
205
|
const result = suggestSessionPattern("edit", "*");
|
|
178
206
|
expect(result.label).toBe('Yes, allow tool "edit" for this session');
|
|
179
207
|
});
|