@gotgenes/pi-permission-system 5.5.0 → 5.6.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 +23 -0
- package/package.json +1 -1
- package/src/handlers/before-agent-start.ts +7 -7
- package/src/handlers/gates/bash-external-directory.ts +70 -67
- package/src/handlers/gates/descriptor.ts +115 -0
- package/src/handlers/gates/external-directory.ts +62 -130
- package/src/handlers/gates/index.ts +12 -4
- package/src/handlers/gates/runner.ts +144 -0
- package/src/handlers/gates/skill-read.ts +40 -59
- package/src/handlers/gates/tool.ts +35 -104
- package/src/handlers/gates/types.ts +0 -2
- package/src/handlers/input.ts +3 -3
- package/src/handlers/lifecycle.ts +21 -21
- package/src/handlers/tool-call.ts +121 -20
- package/src/handlers/types.ts +20 -7
- package/src/index.ts +6 -1
- package/src/runtime.ts +17 -9
- package/tests/handlers/before-agent-start.test.ts +17 -27
- package/tests/handlers/gates/bash-external-directory.test.ts +129 -184
- package/tests/handlers/gates/external-directory.test.ts +118 -264
- package/tests/handlers/gates/runner.test.ts +361 -0
- package/tests/handlers/gates/skill-read.test.ts +86 -137
- package/tests/handlers/gates/tool.test.ts +109 -346
- package/tests/handlers/input-events.test.ts +10 -21
- package/tests/handlers/input.test.ts +26 -43
- package/tests/handlers/lifecycle.test.ts +47 -66
- package/tests/handlers/tool-call-events.test.ts +29 -40
- package/tests/handlers/tool-call.test.ts +19 -30
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import {
|
|
2
|
+
import { describeBashExternalDirectoryGate } from "../../../src/handlers/gates/bash-external-directory";
|
|
3
|
+
import type {
|
|
4
|
+
GateBypass,
|
|
5
|
+
GateDescriptor,
|
|
6
|
+
} from "../../../src/handlers/gates/descriptor";
|
|
7
|
+
import {
|
|
8
|
+
isGateBypass,
|
|
9
|
+
isGateDescriptor,
|
|
10
|
+
} from "../../../src/handlers/gates/descriptor";
|
|
4
11
|
import type { ToolCallContext } from "../../../src/handlers/gates/types";
|
|
5
|
-
import type { HandlerDeps } from "../../../src/handlers/types";
|
|
6
|
-
import type { PermissionEventBus } from "../../../src/permission-events";
|
|
7
12
|
import type { PermissionCheckResult } from "../../../src/types";
|
|
8
13
|
|
|
9
14
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
@@ -32,216 +37,156 @@ function makeCheckResult(
|
|
|
32
37
|
};
|
|
33
38
|
}
|
|
34
39
|
|
|
35
|
-
function makeEvents(): PermissionEventBus {
|
|
36
|
-
return { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function makeRuntime(
|
|
40
|
-
overrides: Record<string, unknown> = {},
|
|
41
|
-
): HandlerDeps["runtime"] {
|
|
42
|
-
return {
|
|
43
|
-
config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
|
|
44
|
-
runtimeContext: {} as HandlerDeps["runtime"]["runtimeContext"],
|
|
45
|
-
permissionManager: {
|
|
46
|
-
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
47
|
-
},
|
|
48
|
-
sessionRules: {
|
|
49
|
-
approve: vi.fn(),
|
|
50
|
-
getRuleset: vi.fn().mockReturnValue([]),
|
|
51
|
-
clear: vi.fn(),
|
|
52
|
-
},
|
|
53
|
-
writeReviewLog: vi.fn(),
|
|
54
|
-
...overrides,
|
|
55
|
-
} as unknown as HandlerDeps["runtime"];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function makeDeps(overrides: Record<string, unknown> = {}): HandlerDeps {
|
|
59
|
-
const { runtime: runtimeOverrides, events, ...rest } = overrides;
|
|
60
|
-
return {
|
|
61
|
-
runtime: makeRuntime(runtimeOverrides as Record<string, unknown>),
|
|
62
|
-
events: events ?? makeEvents(),
|
|
63
|
-
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
64
|
-
promptPermission: vi
|
|
65
|
-
.fn()
|
|
66
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
67
|
-
...rest,
|
|
68
|
-
} as unknown as HandlerDeps;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
40
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
72
41
|
|
|
73
|
-
describe("
|
|
42
|
+
describe("describeBashExternalDirectoryGate", () => {
|
|
74
43
|
it("returns null when tool is not bash", async () => {
|
|
75
|
-
const
|
|
76
|
-
|
|
44
|
+
const result = await describeBashExternalDirectoryGate(
|
|
45
|
+
makeTcc({ toolName: "read" }),
|
|
46
|
+
vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
47
|
+
vi.fn().mockReturnValue([]),
|
|
48
|
+
);
|
|
77
49
|
expect(result).toBeNull();
|
|
78
50
|
});
|
|
79
51
|
|
|
80
52
|
it("returns null when no CWD", async () => {
|
|
81
|
-
const
|
|
82
|
-
|
|
53
|
+
const result = await describeBashExternalDirectoryGate(
|
|
54
|
+
makeTcc({ cwd: undefined }),
|
|
55
|
+
vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
56
|
+
vi.fn().mockReturnValue([]),
|
|
57
|
+
);
|
|
83
58
|
expect(result).toBeNull();
|
|
84
59
|
});
|
|
85
60
|
|
|
86
61
|
it("returns null when command has no external paths", async () => {
|
|
87
|
-
const
|
|
88
|
-
|
|
62
|
+
const result = await describeBashExternalDirectoryGate(
|
|
63
|
+
makeTcc({ input: { command: "ls -la" } }),
|
|
64
|
+
vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
65
|
+
vi.fn().mockReturnValue([]),
|
|
66
|
+
);
|
|
89
67
|
expect(result).toBeNull();
|
|
90
68
|
});
|
|
91
69
|
|
|
92
|
-
it("returns
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
70
|
+
it("returns GateBypass when all external paths are session-covered", async () => {
|
|
71
|
+
const checkPermission = vi
|
|
72
|
+
.fn()
|
|
73
|
+
.mockReturnValue(makeCheckResult("allow", { source: "session" }));
|
|
74
|
+
const result = await describeBashExternalDirectoryGate(
|
|
75
|
+
makeTcc(),
|
|
76
|
+
checkPermission,
|
|
77
|
+
vi.fn().mockReturnValue([]),
|
|
78
|
+
);
|
|
79
|
+
expect(result).not.toBeNull();
|
|
80
|
+
expect(isGateBypass(result)).toBe(true);
|
|
81
|
+
const bypass = result as GateBypass;
|
|
82
|
+
expect(bypass.action).toBe("allow");
|
|
83
|
+
expect(bypass.log).toMatchObject({
|
|
84
|
+
event: "permission_request.session_approved",
|
|
85
|
+
details: expect.objectContaining({ resolution: "session_approved" }),
|
|
103
86
|
});
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns GateDescriptor with multi-pattern sessionApproval for uncovered paths", async () => {
|
|
90
|
+
const checkPermission = vi.fn().mockReturnValue(makeCheckResult("ask"));
|
|
91
|
+
const result = await describeBashExternalDirectoryGate(
|
|
92
|
+
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
|
93
|
+
checkPermission,
|
|
94
|
+
vi.fn().mockReturnValue([]),
|
|
110
95
|
);
|
|
96
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
97
|
+
const desc = result as GateDescriptor;
|
|
98
|
+
expect(desc.sessionApproval).toBeDefined();
|
|
99
|
+
expect(desc.sessionApproval).toHaveProperty("patterns");
|
|
100
|
+
const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
|
|
101
|
+
expect(patterns.length).toBeGreaterThan(0);
|
|
111
102
|
});
|
|
112
103
|
|
|
113
|
-
it("
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const result = await
|
|
123
|
-
|
|
104
|
+
it("uses config-level checkPermission for the policy state", async () => {
|
|
105
|
+
const checkPermission = vi
|
|
106
|
+
.fn()
|
|
107
|
+
.mockImplementation((surface: string, input: Record<string, unknown>) => {
|
|
108
|
+
// Path-specific check returns session for coverage filtering
|
|
109
|
+
if (input.path) return makeCheckResult("allow", { source: "special" });
|
|
110
|
+
// Config-level check (no path) returns deny
|
|
111
|
+
return makeCheckResult("deny");
|
|
112
|
+
});
|
|
113
|
+
const result = await describeBashExternalDirectoryGate(
|
|
114
|
+
makeTcc(),
|
|
115
|
+
checkPermission,
|
|
116
|
+
vi.fn().mockReturnValue([]),
|
|
117
|
+
);
|
|
118
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
119
|
+
// The descriptor should carry the deny state from the config-level check
|
|
120
|
+
// (it will be checked as preCheck by the runner)
|
|
121
|
+
const desc = result as GateDescriptor;
|
|
122
|
+
expect(desc.preCheck?.state).toBe("deny");
|
|
124
123
|
});
|
|
125
124
|
|
|
126
|
-
it("
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
permissionManager: {
|
|
135
|
-
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
136
|
-
},
|
|
137
|
-
sessionRules,
|
|
138
|
-
},
|
|
139
|
-
promptPermission: vi
|
|
140
|
-
.fn()
|
|
141
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
142
|
-
});
|
|
143
|
-
const tcc = makeTcc();
|
|
144
|
-
const result = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
145
|
-
expect(result).toEqual({ action: "allow" });
|
|
146
|
-
expect(sessionRules.approve).not.toHaveBeenCalled();
|
|
125
|
+
it("descriptor surface is 'external_directory'", async () => {
|
|
126
|
+
const result = await describeBashExternalDirectoryGate(
|
|
127
|
+
makeTcc(),
|
|
128
|
+
vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
129
|
+
vi.fn().mockReturnValue([]),
|
|
130
|
+
);
|
|
131
|
+
const desc = result as GateDescriptor;
|
|
132
|
+
expect(desc.surface).toBe("external_directory");
|
|
147
133
|
});
|
|
148
134
|
|
|
149
|
-
it("
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
permissionManager: {
|
|
158
|
-
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
159
|
-
},
|
|
160
|
-
sessionRules,
|
|
161
|
-
},
|
|
162
|
-
promptPermission: vi
|
|
163
|
-
.fn()
|
|
164
|
-
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
165
|
-
});
|
|
166
|
-
// Command referencing two external paths
|
|
167
|
-
const tcc = makeTcc({
|
|
168
|
-
input: {
|
|
169
|
-
command: "diff /outside/a.ts /outside/b.ts",
|
|
170
|
-
},
|
|
171
|
-
});
|
|
172
|
-
const result = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
173
|
-
expect(result).toEqual({ action: "allow" });
|
|
174
|
-
// Each uncovered path gets its own session rule
|
|
175
|
-
expect(sessionRules.approve).toHaveBeenCalledTimes(2);
|
|
176
|
-
for (const call of (sessionRules.approve as ReturnType<typeof vi.fn>).mock
|
|
177
|
-
.calls) {
|
|
178
|
-
expect(call[0]).toBe("external_directory");
|
|
179
|
-
}
|
|
135
|
+
it("descriptor decision surface is 'external_directory'", async () => {
|
|
136
|
+
const result = await describeBashExternalDirectoryGate(
|
|
137
|
+
makeTcc(),
|
|
138
|
+
vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
139
|
+
vi.fn().mockReturnValue([]),
|
|
140
|
+
);
|
|
141
|
+
const desc = result as GateDescriptor;
|
|
142
|
+
expect(desc.decision.surface).toBe("external_directory");
|
|
180
143
|
});
|
|
181
144
|
|
|
182
|
-
it("
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
192
|
-
});
|
|
193
|
-
const tcc = makeTcc();
|
|
194
|
-
const result = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
195
|
-
expect(result).toMatchObject({ action: "block" });
|
|
145
|
+
it("messages contain the command", async () => {
|
|
146
|
+
const result = await describeBashExternalDirectoryGate(
|
|
147
|
+
makeTcc({ input: { command: "cat /outside/file.ts" } }),
|
|
148
|
+
vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
149
|
+
vi.fn().mockReturnValue([]),
|
|
150
|
+
);
|
|
151
|
+
const desc = result as GateDescriptor;
|
|
152
|
+
expect(desc.messages.denyReason).toContain("cat /outside/file.ts");
|
|
153
|
+
expect(desc.messages.unavailableReason).toContain("cat /outside/file.ts");
|
|
196
154
|
});
|
|
197
155
|
|
|
198
|
-
it("
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
156
|
+
it("promptDetails includes command and tool_call source", async () => {
|
|
157
|
+
const result = await describeBashExternalDirectoryGate(
|
|
158
|
+
makeTcc({ agentName: "agent-1", toolCallId: "tc-5" }),
|
|
159
|
+
vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
160
|
+
vi.fn().mockReturnValue([]),
|
|
161
|
+
);
|
|
162
|
+
const desc = result as GateDescriptor;
|
|
163
|
+
expect(desc.promptDetails).toMatchObject({
|
|
164
|
+
source: "tool_call",
|
|
165
|
+
agentName: "agent-1",
|
|
166
|
+
toolCallId: "tc-5",
|
|
167
|
+
toolName: "bash",
|
|
168
|
+
command: "cat /outside/project/file.ts",
|
|
206
169
|
});
|
|
207
|
-
const tcc = makeTcc();
|
|
208
|
-
const result = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
209
|
-
expect(result).toMatchObject({ action: "block" });
|
|
210
170
|
});
|
|
211
171
|
|
|
212
|
-
it("only
|
|
213
|
-
// First call (for getRuleset path filter): session covers /outside/a.ts
|
|
214
|
-
// Second call (for config-level policy): returns ask
|
|
172
|
+
it("only includes uncovered paths when some are session-covered", async () => {
|
|
215
173
|
const checkPermission = vi
|
|
216
174
|
.fn()
|
|
217
|
-
.mockImplementation(
|
|
218
|
-
(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
permissionManager: { checkPermission },
|
|
234
|
-
},
|
|
235
|
-
promptPermission: vi
|
|
236
|
-
.fn()
|
|
237
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
238
|
-
});
|
|
239
|
-
const tcc = makeTcc({
|
|
240
|
-
input: { command: "diff /outside/a.ts /outside/b.ts" },
|
|
241
|
-
});
|
|
242
|
-
const result = await evaluateBashExternalDirectoryGate(tcc, deps);
|
|
243
|
-
expect(result).toEqual({ action: "allow" });
|
|
244
|
-
// The prompt should have been called (for uncovered /outside/b.ts)
|
|
245
|
-
expect(deps.promptPermission).toHaveBeenCalled();
|
|
175
|
+
.mockImplementation((surface: string, input: Record<string, unknown>) => {
|
|
176
|
+
if (input.path === "/outside/a.ts") {
|
|
177
|
+
return makeCheckResult("allow", { source: "session" });
|
|
178
|
+
}
|
|
179
|
+
return makeCheckResult("ask");
|
|
180
|
+
});
|
|
181
|
+
const result = await describeBashExternalDirectoryGate(
|
|
182
|
+
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
|
183
|
+
checkPermission,
|
|
184
|
+
vi.fn().mockReturnValue([]),
|
|
185
|
+
);
|
|
186
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
187
|
+
const desc = result as GateDescriptor;
|
|
188
|
+
// Should have patterns only for the uncovered path
|
|
189
|
+
const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
|
|
190
|
+
expect(patterns.length).toBe(1);
|
|
246
191
|
});
|
|
247
192
|
});
|