@gotgenes/pi-permission-system 5.16.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 +20 -0
- package/README.md +8 -3
- package/config/config.example.json +3 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +4 -2
- package/src/handlers/gates/bash-path-extractor.ts +75 -0
- package/src/handlers/gates/bash-path.ts +146 -0
- package/src/handlers/gates/index.ts +2 -0
- package/src/handlers/gates/path.ts +104 -0
- package/src/handlers/permission-gate-handler.ts +46 -0
- package/src/input-normalizer.ts +1 -1
- 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/path.test.ts +149 -0
- package/tests/handlers/tool-call.test.ts +78 -0
- package/tests/input-normalizer.test.ts +24 -0
- package/tests/permission-manager-unified.test.ts +210 -0
- package/tests/rule.test.ts +77 -1
|
@@ -47,9 +47,29 @@ function makeCheckPermission(
|
|
|
47
47
|
return vi
|
|
48
48
|
.fn()
|
|
49
49
|
.mockImplementation((surface: string): PermissionCheckResult => {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
if (surface === "external_directory") {
|
|
51
|
+
return {
|
|
52
|
+
state: externalDirectoryState,
|
|
53
|
+
toolName: surface,
|
|
54
|
+
source: "tool",
|
|
55
|
+
origin: "builtin",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// The cross-cutting path gate runs before ext-dir; keep it transparent.
|
|
59
|
+
if (surface === "path") {
|
|
60
|
+
return {
|
|
61
|
+
state: "allow",
|
|
62
|
+
toolName: surface,
|
|
63
|
+
source: "special",
|
|
64
|
+
origin: "builtin",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
state: toolState,
|
|
69
|
+
toolName: surface,
|
|
70
|
+
source: "tool",
|
|
71
|
+
origin: "builtin",
|
|
72
|
+
};
|
|
53
73
|
});
|
|
54
74
|
}
|
|
55
75
|
|
|
@@ -294,6 +314,67 @@ describe("external_directory policy state — allow", () => {
|
|
|
294
314
|
});
|
|
295
315
|
});
|
|
296
316
|
|
|
317
|
+
// #144: allow external reads, gate external writes
|
|
318
|
+
describe("external_directory — allow external reads, gate external writes (#144)", () => {
|
|
319
|
+
it("allows read of external path when external_directory and read are both allow", async () => {
|
|
320
|
+
const { handler } = makeHandler({
|
|
321
|
+
session: { checkPermission: makeCheckPermission("allow", "allow") },
|
|
322
|
+
});
|
|
323
|
+
const event = makeToolCallEvent("read", { path: EXTERNAL_PATH });
|
|
324
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
325
|
+
expect(result).toEqual({});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("prompts for write to external path when external_directory allows but write is ask", async () => {
|
|
329
|
+
const prompt = vi
|
|
330
|
+
.fn()
|
|
331
|
+
.mockResolvedValue({ approved: true, state: "approved" });
|
|
332
|
+
const { handler } = makeHandler({
|
|
333
|
+
session: {
|
|
334
|
+
checkPermission: makeCheckPermission("allow", "ask"),
|
|
335
|
+
prompt,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
const event = makeToolCallEvent("write", { path: EXTERNAL_PATH });
|
|
339
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
340
|
+
// external_directory passes; write gate prompts and user approves
|
|
341
|
+
expect(result).toEqual({});
|
|
342
|
+
expect(prompt).toHaveBeenCalledOnce();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("blocks write to external path when external_directory allows but write is deny", async () => {
|
|
346
|
+
const { handler } = makeHandler({
|
|
347
|
+
session: { checkPermission: makeCheckPermission("allow", "deny") },
|
|
348
|
+
});
|
|
349
|
+
const event = makeToolCallEvent("write", { path: EXTERNAL_PATH });
|
|
350
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
351
|
+
expect(result.block).toBe(true);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("emits separate decision events for external_directory and write surfaces", async () => {
|
|
355
|
+
const { handler, events } = makeHandler({
|
|
356
|
+
session: { checkPermission: makeCheckPermission("allow", "deny") },
|
|
357
|
+
});
|
|
358
|
+
const event = makeToolCallEvent("write", { path: EXTERNAL_PATH });
|
|
359
|
+
await handler.handleToolCall(event, makeCtx());
|
|
360
|
+
const decisions = getDecisionEvents(events);
|
|
361
|
+
const extDirDecision = decisions.find(
|
|
362
|
+
(d) => d.surface === "external_directory",
|
|
363
|
+
);
|
|
364
|
+
const writeDecision = decisions.find((d) => d.surface === "write");
|
|
365
|
+
expect(extDirDecision).toMatchObject({
|
|
366
|
+
surface: "external_directory",
|
|
367
|
+
result: "allow",
|
|
368
|
+
resolution: "policy_allow",
|
|
369
|
+
});
|
|
370
|
+
expect(writeDecision).toMatchObject({
|
|
371
|
+
surface: "write",
|
|
372
|
+
result: "deny",
|
|
373
|
+
resolution: "policy_deny",
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
297
378
|
describe("external_directory policy state — deny", () => {
|
|
298
379
|
it("blocks with reason containing the external path", async () => {
|
|
299
380
|
const { handler } = makeHandler({
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
4
|
+
vi.mock("node:os", () => {
|
|
5
|
+
const homedir = vi.fn(() => "/mock/home");
|
|
6
|
+
return {
|
|
7
|
+
homedir,
|
|
8
|
+
default: { homedir },
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
import { describeBashPathGate } from "../../../src/handlers/gates/bash-path";
|
|
13
|
+
import type {
|
|
14
|
+
GateBypass,
|
|
15
|
+
GateDescriptor,
|
|
16
|
+
} from "../../../src/handlers/gates/descriptor";
|
|
17
|
+
import {
|
|
18
|
+
isGateBypass,
|
|
19
|
+
isGateDescriptor,
|
|
20
|
+
} from "../../../src/handlers/gates/descriptor";
|
|
21
|
+
import type { ToolCallContext } from "../../../src/handlers/gates/types";
|
|
22
|
+
import type { Rule } from "../../../src/rule";
|
|
23
|
+
import type { PermissionCheckResult } from "../../../src/types";
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
32
|
+
return {
|
|
33
|
+
toolName: "bash",
|
|
34
|
+
agentName: null,
|
|
35
|
+
input: { command: "cat .env" },
|
|
36
|
+
toolCallId: "tc-1",
|
|
37
|
+
cwd: "/test/project",
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeCheckResult(
|
|
43
|
+
overrides: Partial<PermissionCheckResult> = {},
|
|
44
|
+
): PermissionCheckResult {
|
|
45
|
+
return {
|
|
46
|
+
toolName: "path",
|
|
47
|
+
state: "allow",
|
|
48
|
+
source: "special",
|
|
49
|
+
origin: "global",
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type CheckPermissionFn = (
|
|
55
|
+
surface: string,
|
|
56
|
+
input: unknown,
|
|
57
|
+
agentName?: string,
|
|
58
|
+
sessionRules?: Rule[],
|
|
59
|
+
) => PermissionCheckResult;
|
|
60
|
+
|
|
61
|
+
// ── tests ──────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe("describeBashPathGate", () => {
|
|
64
|
+
it("returns null for non-bash tools", async () => {
|
|
65
|
+
const checkPermission = vi.fn<CheckPermissionFn>();
|
|
66
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
67
|
+
const result = await describeBashPathGate(
|
|
68
|
+
makeTcc({ toolName: "read", input: { path: ".env" } }),
|
|
69
|
+
checkPermission,
|
|
70
|
+
getSessionRuleset,
|
|
71
|
+
);
|
|
72
|
+
expect(result).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns null when no tokens are extracted", async () => {
|
|
76
|
+
const checkPermission = vi.fn<CheckPermissionFn>();
|
|
77
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
78
|
+
const result = await describeBashPathGate(
|
|
79
|
+
makeTcc({ input: { command: "echo hello" } }),
|
|
80
|
+
checkPermission,
|
|
81
|
+
getSessionRuleset,
|
|
82
|
+
);
|
|
83
|
+
expect(result).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns null when all tokens evaluate to allow", async () => {
|
|
87
|
+
const checkPermission = vi
|
|
88
|
+
.fn<CheckPermissionFn>()
|
|
89
|
+
.mockReturnValue(makeCheckResult({ state: "allow" }));
|
|
90
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
91
|
+
const result = await describeBashPathGate(
|
|
92
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
93
|
+
checkPermission,
|
|
94
|
+
getSessionRuleset,
|
|
95
|
+
);
|
|
96
|
+
expect(result).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns GateDescriptor when a token evaluates to deny", async () => {
|
|
100
|
+
const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
|
|
101
|
+
makeCheckResult({
|
|
102
|
+
state: "deny",
|
|
103
|
+
matchedPattern: "*.env",
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
107
|
+
const result = await describeBashPathGate(
|
|
108
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
109
|
+
checkPermission,
|
|
110
|
+
getSessionRuleset,
|
|
111
|
+
);
|
|
112
|
+
expect(result).not.toBeNull();
|
|
113
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
114
|
+
const desc = result as GateDescriptor;
|
|
115
|
+
expect(desc.surface).toBe("path");
|
|
116
|
+
expect(desc.preCheck?.state).toBe("deny");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns GateDescriptor when a token evaluates to ask", async () => {
|
|
120
|
+
const checkPermission = vi
|
|
121
|
+
.fn<CheckPermissionFn>()
|
|
122
|
+
.mockReturnValue(makeCheckResult({ state: "ask" }));
|
|
123
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
124
|
+
const result = await describeBashPathGate(
|
|
125
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
126
|
+
checkPermission,
|
|
127
|
+
getSessionRuleset,
|
|
128
|
+
);
|
|
129
|
+
expect(result).not.toBeNull();
|
|
130
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
131
|
+
const desc = result as GateDescriptor;
|
|
132
|
+
expect(desc.preCheck?.state).toBe("ask");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("descriptor includes triggering token in prompt message", async () => {
|
|
136
|
+
const checkPermission = vi
|
|
137
|
+
.fn<CheckPermissionFn>()
|
|
138
|
+
.mockReturnValue(makeCheckResult({ state: "deny" }));
|
|
139
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
140
|
+
const result = (await describeBashPathGate(
|
|
141
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
142
|
+
checkPermission,
|
|
143
|
+
getSessionRuleset,
|
|
144
|
+
)) as GateDescriptor;
|
|
145
|
+
expect(result.messages.denyReason).toContain(".env");
|
|
146
|
+
expect(result.promptDetails.message).toContain(".env");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("descriptor decision uses surface 'path'", async () => {
|
|
150
|
+
const checkPermission = vi
|
|
151
|
+
.fn<CheckPermissionFn>()
|
|
152
|
+
.mockReturnValue(makeCheckResult({ state: "deny" }));
|
|
153
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
154
|
+
const result = (await describeBashPathGate(
|
|
155
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
156
|
+
checkPermission,
|
|
157
|
+
getSessionRuleset,
|
|
158
|
+
)) as GateDescriptor;
|
|
159
|
+
expect(result.decision.surface).toBe("path");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns GateBypass when session rule covers the path", async () => {
|
|
163
|
+
const checkPermission = vi
|
|
164
|
+
.fn<CheckPermissionFn>()
|
|
165
|
+
.mockReturnValue(makeCheckResult({ state: "allow", source: "session" }));
|
|
166
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([
|
|
167
|
+
{
|
|
168
|
+
surface: "path",
|
|
169
|
+
pattern: "*",
|
|
170
|
+
action: "allow",
|
|
171
|
+
layer: "session",
|
|
172
|
+
origin: "session",
|
|
173
|
+
},
|
|
174
|
+
]);
|
|
175
|
+
const result = await describeBashPathGate(
|
|
176
|
+
makeTcc({ input: { command: "cat .env" } }),
|
|
177
|
+
checkPermission,
|
|
178
|
+
getSessionRuleset,
|
|
179
|
+
);
|
|
180
|
+
expect(result).not.toBeNull();
|
|
181
|
+
expect(isGateBypass(result)).toBe(true);
|
|
182
|
+
expect((result as GateBypass).action).toBe("allow");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns null when command is missing", async () => {
|
|
186
|
+
const checkPermission = vi.fn<CheckPermissionFn>();
|
|
187
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
188
|
+
const result = await describeBashPathGate(
|
|
189
|
+
makeTcc({ input: {} }),
|
|
190
|
+
checkPermission,
|
|
191
|
+
getSessionRuleset,
|
|
192
|
+
);
|
|
193
|
+
expect(result).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("evaluates most restrictive across multiple tokens", async () => {
|
|
197
|
+
const checkPermission = vi
|
|
198
|
+
.fn<CheckPermissionFn>()
|
|
199
|
+
.mockImplementation((_surface, input) => {
|
|
200
|
+
const record = input as Record<string, unknown>;
|
|
201
|
+
if (record.path === "src/foo.ts") {
|
|
202
|
+
return makeCheckResult({ state: "allow" });
|
|
203
|
+
}
|
|
204
|
+
return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
|
|
205
|
+
});
|
|
206
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
207
|
+
const result = await describeBashPathGate(
|
|
208
|
+
makeTcc({ input: { command: "cat src/foo.ts .env" } }),
|
|
209
|
+
checkPermission,
|
|
210
|
+
getSessionRuleset,
|
|
211
|
+
);
|
|
212
|
+
expect(result).not.toBeNull();
|
|
213
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
214
|
+
expect((result as GateDescriptor).preCheck?.state).toBe("deny");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("deny wins in multi-token: cp .env README.md", async () => {
|
|
218
|
+
const checkPermission = vi
|
|
219
|
+
.fn<CheckPermissionFn>()
|
|
220
|
+
.mockImplementation((_surface, input) => {
|
|
221
|
+
const record = input as Record<string, unknown>;
|
|
222
|
+
if (record.path === ".env") {
|
|
223
|
+
return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
|
|
224
|
+
}
|
|
225
|
+
return makeCheckResult({ state: "allow" });
|
|
226
|
+
});
|
|
227
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
228
|
+
const result = await describeBashPathGate(
|
|
229
|
+
makeTcc({ input: { command: "cp .env README.md" } }),
|
|
230
|
+
checkPermission,
|
|
231
|
+
getSessionRuleset,
|
|
232
|
+
);
|
|
233
|
+
expect(result).not.toBeNull();
|
|
234
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
235
|
+
const desc = result as GateDescriptor;
|
|
236
|
+
expect(desc.preCheck?.state).toBe("deny");
|
|
237
|
+
expect(desc.decision.value).toBe(".env");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("extracts redirect target: echo test > .env triggers deny", async () => {
|
|
241
|
+
const checkPermission = vi
|
|
242
|
+
.fn<CheckPermissionFn>()
|
|
243
|
+
.mockImplementation((_surface, input) => {
|
|
244
|
+
const record = input as Record<string, unknown>;
|
|
245
|
+
if (record.path === ".env") {
|
|
246
|
+
return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
|
|
247
|
+
}
|
|
248
|
+
return makeCheckResult({ state: "allow" });
|
|
249
|
+
});
|
|
250
|
+
const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
|
|
251
|
+
const result = await describeBashPathGate(
|
|
252
|
+
makeTcc({ input: { command: "echo test > .env" } }),
|
|
253
|
+
checkPermission,
|
|
254
|
+
getSessionRuleset,
|
|
255
|
+
);
|
|
256
|
+
expect(result).not.toBeNull();
|
|
257
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
258
|
+
expect((result as GateDescriptor).preCheck?.state).toBe("deny");
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -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(
|