@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
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
GateDescriptor,
|
|
5
|
+
GateRunnerDeps,
|
|
6
|
+
} from "../../../src/handlers/gates/descriptor";
|
|
7
|
+
import { runGateCheck } from "../../../src/handlers/gates/runner";
|
|
8
|
+
import type { PermissionCheckResult } from "../../../src/types";
|
|
9
|
+
|
|
10
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function makeDescriptor(
|
|
13
|
+
overrides: Partial<GateDescriptor> = {},
|
|
14
|
+
): GateDescriptor {
|
|
15
|
+
return {
|
|
16
|
+
surface: "read",
|
|
17
|
+
input: {},
|
|
18
|
+
messages: {
|
|
19
|
+
denyReason: "Tool 'read' is denied.",
|
|
20
|
+
unavailableReason: "No UI available.",
|
|
21
|
+
userDeniedReason: (d) => `User denied. ${d.denialReason ?? ""}`,
|
|
22
|
+
},
|
|
23
|
+
promptDetails: {
|
|
24
|
+
source: "tool_call",
|
|
25
|
+
agentName: null,
|
|
26
|
+
message: "Allow tool 'read'?",
|
|
27
|
+
toolCallId: "tc-1",
|
|
28
|
+
toolName: "read",
|
|
29
|
+
},
|
|
30
|
+
logContext: {
|
|
31
|
+
source: "tool_call",
|
|
32
|
+
toolCallId: "tc-1",
|
|
33
|
+
toolName: "read",
|
|
34
|
+
},
|
|
35
|
+
decision: {
|
|
36
|
+
surface: "read",
|
|
37
|
+
value: "read",
|
|
38
|
+
},
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeCheckResult(
|
|
44
|
+
state: "allow" | "deny" | "ask",
|
|
45
|
+
overrides: Partial<PermissionCheckResult> = {},
|
|
46
|
+
): PermissionCheckResult {
|
|
47
|
+
return {
|
|
48
|
+
state,
|
|
49
|
+
toolName: "read",
|
|
50
|
+
source: "tool",
|
|
51
|
+
origin: "builtin",
|
|
52
|
+
matchedPattern: "*",
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeRunnerDeps(
|
|
58
|
+
overrides: Partial<GateRunnerDeps> = {},
|
|
59
|
+
): GateRunnerDeps {
|
|
60
|
+
return {
|
|
61
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
|
|
62
|
+
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
63
|
+
approveSessionRule: vi.fn(),
|
|
64
|
+
writeReviewLog: vi.fn(),
|
|
65
|
+
emitDecision: vi.fn(),
|
|
66
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
67
|
+
promptPermission: vi
|
|
68
|
+
.fn()
|
|
69
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── tests ──────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe("runGateCheck", () => {
|
|
77
|
+
it("returns allow and emits policy_allow when policy is allow", async () => {
|
|
78
|
+
const deps = makeRunnerDeps();
|
|
79
|
+
const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
80
|
+
expect(result).toEqual({ action: "allow" });
|
|
81
|
+
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
82
|
+
expect.objectContaining({
|
|
83
|
+
surface: "read",
|
|
84
|
+
value: "read",
|
|
85
|
+
result: "allow",
|
|
86
|
+
resolution: "policy_allow",
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns block and emits policy_deny when policy is deny", async () => {
|
|
92
|
+
const deps = makeRunnerDeps({
|
|
93
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("deny")),
|
|
94
|
+
});
|
|
95
|
+
const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
96
|
+
expect(result).toMatchObject({ action: "block" });
|
|
97
|
+
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
98
|
+
expect.objectContaining({
|
|
99
|
+
result: "deny",
|
|
100
|
+
resolution: "policy_deny",
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
expect(deps.writeReviewLog).toHaveBeenCalledWith(
|
|
104
|
+
"permission_request.blocked",
|
|
105
|
+
expect.objectContaining({ resolution: "policy_denied" }),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns allow and emits session_approved on session hit", async () => {
|
|
110
|
+
const deps = makeRunnerDeps({
|
|
111
|
+
checkPermission: vi.fn().mockReturnValue(
|
|
112
|
+
makeCheckResult("allow", {
|
|
113
|
+
source: "session",
|
|
114
|
+
matchedPattern: "git *",
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
});
|
|
118
|
+
const result = await runGateCheck(
|
|
119
|
+
makeDescriptor({
|
|
120
|
+
surface: "bash",
|
|
121
|
+
input: { command: "git status" },
|
|
122
|
+
decision: { surface: "bash", value: "git status" },
|
|
123
|
+
}),
|
|
124
|
+
null,
|
|
125
|
+
"tc-1",
|
|
126
|
+
deps,
|
|
127
|
+
);
|
|
128
|
+
expect(result).toEqual({ action: "allow" });
|
|
129
|
+
expect(deps.writeReviewLog).toHaveBeenCalledWith(
|
|
130
|
+
"permission_request.session_approved",
|
|
131
|
+
expect.objectContaining({
|
|
132
|
+
resolution: "session_approved",
|
|
133
|
+
sessionApprovalPattern: "git *",
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
137
|
+
expect.objectContaining({
|
|
138
|
+
resolution: "session_approved",
|
|
139
|
+
matchedPattern: "git *",
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("returns allow and emits user_approved when ask + user approves", async () => {
|
|
145
|
+
const deps = makeRunnerDeps({
|
|
146
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
147
|
+
promptPermission: vi
|
|
148
|
+
.fn()
|
|
149
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
150
|
+
});
|
|
151
|
+
const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
152
|
+
expect(result).toEqual({ action: "allow" });
|
|
153
|
+
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
154
|
+
expect.objectContaining({
|
|
155
|
+
result: "allow",
|
|
156
|
+
resolution: "user_approved",
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns allow, emits user_approved_for_session, and records session rule on approved_for_session", async () => {
|
|
162
|
+
const deps = makeRunnerDeps({
|
|
163
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
164
|
+
promptPermission: vi
|
|
165
|
+
.fn()
|
|
166
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
167
|
+
});
|
|
168
|
+
const descriptor = makeDescriptor({
|
|
169
|
+
sessionApproval: { surface: "read", pattern: "*" },
|
|
170
|
+
});
|
|
171
|
+
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
172
|
+
expect(result).toEqual({ action: "allow" });
|
|
173
|
+
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
174
|
+
expect.objectContaining({
|
|
175
|
+
resolution: "user_approved_for_session",
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
expect(deps.approveSessionRule).toHaveBeenCalledWith("read", "*");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("calls approveSessionRule once per pattern when sessionApproval has multiple patterns", async () => {
|
|
182
|
+
const deps = makeRunnerDeps({
|
|
183
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
184
|
+
promptPermission: vi
|
|
185
|
+
.fn()
|
|
186
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
187
|
+
});
|
|
188
|
+
const descriptor = makeDescriptor({
|
|
189
|
+
sessionApproval: {
|
|
190
|
+
surface: "external_directory",
|
|
191
|
+
patterns: ["/outside/a/*", "/outside/b/*"],
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
195
|
+
expect(result).toEqual({ action: "allow" });
|
|
196
|
+
expect(deps.approveSessionRule).toHaveBeenCalledTimes(2);
|
|
197
|
+
expect(deps.approveSessionRule).toHaveBeenCalledWith(
|
|
198
|
+
"external_directory",
|
|
199
|
+
"/outside/a/*",
|
|
200
|
+
);
|
|
201
|
+
expect(deps.approveSessionRule).toHaveBeenCalledWith(
|
|
202
|
+
"external_directory",
|
|
203
|
+
"/outside/b/*",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("returns block and emits user_denied when ask + user denies", async () => {
|
|
208
|
+
const deps = makeRunnerDeps({
|
|
209
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
210
|
+
promptPermission: vi
|
|
211
|
+
.fn()
|
|
212
|
+
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
213
|
+
});
|
|
214
|
+
const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
215
|
+
expect(result).toMatchObject({ action: "block" });
|
|
216
|
+
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
217
|
+
expect.objectContaining({
|
|
218
|
+
result: "deny",
|
|
219
|
+
resolution: "user_denied",
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("returns block and emits confirmation_unavailable when ask + no UI", async () => {
|
|
225
|
+
const deps = makeRunnerDeps({
|
|
226
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
227
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
228
|
+
});
|
|
229
|
+
const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
230
|
+
expect(result).toMatchObject({ action: "block" });
|
|
231
|
+
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
232
|
+
expect.objectContaining({
|
|
233
|
+
result: "deny",
|
|
234
|
+
resolution: "confirmation_unavailable",
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("emits auto_approved resolution when decision has autoApproved flag", async () => {
|
|
240
|
+
const deps = makeRunnerDeps({
|
|
241
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
242
|
+
promptPermission: vi.fn().mockResolvedValue({
|
|
243
|
+
approved: true,
|
|
244
|
+
state: "approved",
|
|
245
|
+
autoApproved: true,
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
249
|
+
expect(result).toEqual({ action: "allow" });
|
|
250
|
+
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
251
|
+
expect.objectContaining({
|
|
252
|
+
resolution: "auto_approved",
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("uses preResolved.state instead of calling checkPermission", async () => {
|
|
258
|
+
const deps = makeRunnerDeps();
|
|
259
|
+
const descriptor = makeDescriptor({
|
|
260
|
+
preResolved: { state: "deny" },
|
|
261
|
+
});
|
|
262
|
+
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
263
|
+
expect(result).toMatchObject({ action: "block" });
|
|
264
|
+
expect(deps.checkPermission).not.toHaveBeenCalled();
|
|
265
|
+
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
266
|
+
expect.objectContaining({
|
|
267
|
+
resolution: "policy_deny",
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("uses preResolved.state allow without calling checkPermission", async () => {
|
|
273
|
+
const deps = makeRunnerDeps();
|
|
274
|
+
const descriptor = makeDescriptor({
|
|
275
|
+
preResolved: { state: "allow" },
|
|
276
|
+
});
|
|
277
|
+
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
278
|
+
expect(result).toEqual({ action: "allow" });
|
|
279
|
+
expect(deps.checkPermission).not.toHaveBeenCalled();
|
|
280
|
+
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
281
|
+
expect.objectContaining({
|
|
282
|
+
resolution: "policy_allow",
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("passes agentName to checkPermission and decision event", async () => {
|
|
288
|
+
const deps = makeRunnerDeps();
|
|
289
|
+
const result = await runGateCheck(
|
|
290
|
+
makeDescriptor(),
|
|
291
|
+
"test-agent",
|
|
292
|
+
"tc-1",
|
|
293
|
+
deps,
|
|
294
|
+
);
|
|
295
|
+
expect(result).toEqual({ action: "allow" });
|
|
296
|
+
expect(deps.checkPermission).toHaveBeenCalledWith(
|
|
297
|
+
"read",
|
|
298
|
+
{},
|
|
299
|
+
"test-agent",
|
|
300
|
+
[],
|
|
301
|
+
);
|
|
302
|
+
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
303
|
+
expect.objectContaining({
|
|
304
|
+
agentName: "test-agent",
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("passes requestId from toolCallId to promptPermission", async () => {
|
|
310
|
+
const deps = makeRunnerDeps({
|
|
311
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
312
|
+
});
|
|
313
|
+
await runGateCheck(makeDescriptor(), null, "tc-42", deps);
|
|
314
|
+
expect(deps.promptPermission).toHaveBeenCalledWith(
|
|
315
|
+
expect.objectContaining({ requestId: "tc-42" }),
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("does not call approveSessionRule when user approves once (no sessionApproval)", async () => {
|
|
320
|
+
const deps = makeRunnerDeps({
|
|
321
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
322
|
+
promptPermission: vi
|
|
323
|
+
.fn()
|
|
324
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
325
|
+
});
|
|
326
|
+
await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
327
|
+
expect(deps.approveSessionRule).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("uses preCheck result directly instead of calling checkPermission", async () => {
|
|
331
|
+
const deps = makeRunnerDeps();
|
|
332
|
+
const descriptor = makeDescriptor({
|
|
333
|
+
preCheck: makeCheckResult("deny", {
|
|
334
|
+
origin: "global",
|
|
335
|
+
matchedPattern: "rm *",
|
|
336
|
+
}),
|
|
337
|
+
});
|
|
338
|
+
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
339
|
+
expect(result).toMatchObject({ action: "block" });
|
|
340
|
+
expect(deps.checkPermission).not.toHaveBeenCalled();
|
|
341
|
+
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
342
|
+
expect.objectContaining({
|
|
343
|
+
resolution: "policy_deny",
|
|
344
|
+
origin: "global",
|
|
345
|
+
matchedPattern: "rm *",
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("does not call approveSessionRule when user approves for session but no sessionApproval on descriptor", async () => {
|
|
351
|
+
const deps = makeRunnerDeps({
|
|
352
|
+
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
353
|
+
promptPermission: vi
|
|
354
|
+
.fn()
|
|
355
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
356
|
+
});
|
|
357
|
+
// No sessionApproval on descriptor
|
|
358
|
+
await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
359
|
+
expect(deps.approveSessionRule).not.toHaveBeenCalled();
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import type { GateDescriptor } from "../../../src/handlers/gates/descriptor";
|
|
4
|
+
import { describeSkillReadGate } from "../../../src/handlers/gates/skill-read";
|
|
4
5
|
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
6
|
import type { SkillPromptEntry } from "../../../src/skill-prompt-sanitizer";
|
|
8
7
|
|
|
9
8
|
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
@@ -40,165 +39,115 @@ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
|
40
39
|
};
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
function makeEvents(): PermissionEventBus {
|
|
44
|
-
return { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function makeRuntime(
|
|
48
|
-
overrides: Record<string, unknown> = {},
|
|
49
|
-
): HandlerDeps["runtime"] {
|
|
50
|
-
return {
|
|
51
|
-
activeSkillEntries: [],
|
|
52
|
-
writeReviewLog: vi.fn(),
|
|
53
|
-
...overrides,
|
|
54
|
-
} as unknown as HandlerDeps["runtime"];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function makeDeps(overrides: Record<string, unknown> = {}): HandlerDeps {
|
|
58
|
-
const { runtime: runtimeOverrides, events, ...rest } = overrides;
|
|
59
|
-
return {
|
|
60
|
-
runtime: makeRuntime(runtimeOverrides as Record<string, unknown>),
|
|
61
|
-
events: events ?? makeEvents(),
|
|
62
|
-
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
63
|
-
promptPermission: vi
|
|
64
|
-
.fn()
|
|
65
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
66
|
-
...rest,
|
|
67
|
-
} as unknown as HandlerDeps;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
42
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
71
43
|
|
|
72
|
-
describe("
|
|
73
|
-
it("returns null when tool is not read",
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
});
|
|
78
|
-
const result = await evaluateSkillReadGate(tcc, deps);
|
|
44
|
+
describe("describeSkillReadGate", () => {
|
|
45
|
+
it("returns null when tool is not read", () => {
|
|
46
|
+
const result = describeSkillReadGate(makeTcc({ toolName: "write" }), () => [
|
|
47
|
+
makeSkillEntry(),
|
|
48
|
+
]);
|
|
79
49
|
expect(result).toBeNull();
|
|
80
50
|
});
|
|
81
51
|
|
|
82
|
-
it("returns null when no active skill entries",
|
|
83
|
-
const
|
|
84
|
-
const deps = makeDeps({ runtime: { activeSkillEntries: [] } });
|
|
85
|
-
const result = await evaluateSkillReadGate(tcc, deps);
|
|
52
|
+
it("returns null when no active skill entries", () => {
|
|
53
|
+
const result = describeSkillReadGate(makeTcc(), () => []);
|
|
86
54
|
expect(result).toBeNull();
|
|
87
55
|
});
|
|
88
56
|
|
|
89
|
-
it("returns null when read path does not match any skill",
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const result = await evaluateSkillReadGate(tcc, deps);
|
|
57
|
+
it("returns null when read path does not match any skill", () => {
|
|
58
|
+
const result = describeSkillReadGate(
|
|
59
|
+
makeTcc({ input: { path: "/test/project/src/index.ts" } }),
|
|
60
|
+
() => [makeSkillEntry()],
|
|
61
|
+
);
|
|
95
62
|
expect(result).toBeNull();
|
|
96
63
|
});
|
|
97
64
|
|
|
98
|
-
it("returns
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
const result = await evaluateSkillReadGate(tcc, deps);
|
|
106
|
-
expect(result).toEqual({ action: "allow" });
|
|
65
|
+
it("returns null when input has no path", () => {
|
|
66
|
+
const result = describeSkillReadGate(makeTcc({ input: {} }), () => [
|
|
67
|
+
makeSkillEntry(),
|
|
68
|
+
]);
|
|
69
|
+
expect(result).toBeNull();
|
|
107
70
|
});
|
|
108
71
|
|
|
109
|
-
it("returns
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
});
|
|
116
|
-
const result = await evaluateSkillReadGate(tcc, deps);
|
|
117
|
-
expect(result).toMatchObject({ action: "block" });
|
|
72
|
+
it("returns GateDescriptor with preResolved.state matching skill entry state (ask)", () => {
|
|
73
|
+
const result = describeSkillReadGate(makeTcc(), () => [
|
|
74
|
+
makeSkillEntry({ state: "ask" }),
|
|
75
|
+
]);
|
|
76
|
+
expect(result).not.toBeNull();
|
|
77
|
+
const desc = result as GateDescriptor;
|
|
78
|
+
expect(desc.preResolved).toEqual({ state: "ask" });
|
|
118
79
|
});
|
|
119
80
|
|
|
120
|
-
it("returns
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
.fn()
|
|
128
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
129
|
-
});
|
|
130
|
-
const result = await evaluateSkillReadGate(tcc, deps);
|
|
131
|
-
expect(result).toEqual({ action: "allow" });
|
|
81
|
+
it("returns GateDescriptor with preResolved.state matching skill entry state (allow)", () => {
|
|
82
|
+
const result = describeSkillReadGate(makeTcc(), () => [
|
|
83
|
+
makeSkillEntry({ state: "allow" }),
|
|
84
|
+
]);
|
|
85
|
+
expect(result).not.toBeNull();
|
|
86
|
+
const desc = result as GateDescriptor;
|
|
87
|
+
expect(desc.preResolved).toEqual({ state: "allow" });
|
|
132
88
|
});
|
|
133
89
|
|
|
134
|
-
it("returns
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
.fn()
|
|
142
|
-
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
143
|
-
});
|
|
144
|
-
const result = await evaluateSkillReadGate(tcc, deps);
|
|
145
|
-
expect(result).toMatchObject({ action: "block" });
|
|
90
|
+
it("returns GateDescriptor with preResolved.state matching skill entry state (deny)", () => {
|
|
91
|
+
const result = describeSkillReadGate(makeTcc(), () => [
|
|
92
|
+
makeSkillEntry({ state: "deny" }),
|
|
93
|
+
]);
|
|
94
|
+
expect(result).not.toBeNull();
|
|
95
|
+
const desc = result as GateDescriptor;
|
|
96
|
+
expect(desc.preResolved).toEqual({ state: "deny" });
|
|
146
97
|
});
|
|
147
98
|
|
|
148
|
-
it("
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
99
|
+
it("decision surface is 'skill' and decision value is the skill name", () => {
|
|
100
|
+
const result = describeSkillReadGate(makeTcc(), () => [
|
|
101
|
+
makeSkillEntry({ name: "my-skill" }),
|
|
102
|
+
]) as GateDescriptor;
|
|
103
|
+
expect(result.decision.surface).toBe("skill");
|
|
104
|
+
expect(result.decision.value).toBe("my-skill");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("messages contain the skill name", () => {
|
|
108
|
+
const result = describeSkillReadGate(makeTcc(), () => [
|
|
109
|
+
makeSkillEntry({ name: "librarian" }),
|
|
110
|
+
]) as GateDescriptor;
|
|
111
|
+
expect(result.messages.denyReason).toContain("librarian");
|
|
112
|
+
expect(result.messages.unavailableReason).toContain("librarian");
|
|
113
|
+
const deniedMsg = result.messages.userDeniedReason({
|
|
114
|
+
approved: false,
|
|
115
|
+
state: "denied",
|
|
155
116
|
});
|
|
156
|
-
|
|
157
|
-
expect(result).toMatchObject({ action: "block" });
|
|
117
|
+
expect(deniedMsg).toContain("librarian");
|
|
158
118
|
});
|
|
159
119
|
|
|
160
|
-
it("
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
120
|
+
it("promptDetails includes skill_read source and skillName", () => {
|
|
121
|
+
const result = describeSkillReadGate(
|
|
122
|
+
makeTcc({ agentName: "test-agent", toolCallId: "tc-42" }),
|
|
123
|
+
() => [makeSkillEntry({ name: "my-skill" })],
|
|
124
|
+
) as GateDescriptor;
|
|
125
|
+
expect(result.promptDetails).toMatchObject({
|
|
126
|
+
source: "skill_read",
|
|
127
|
+
agentName: "test-agent",
|
|
128
|
+
toolCallId: "tc-42",
|
|
129
|
+
toolName: "read",
|
|
130
|
+
skillName: "my-skill",
|
|
168
131
|
});
|
|
169
|
-
|
|
170
|
-
expect(events.emit).toHaveBeenCalledWith(
|
|
171
|
-
"permissions:decision",
|
|
172
|
-
expect.objectContaining({
|
|
173
|
-
surface: "skill",
|
|
174
|
-
value: "librarian",
|
|
175
|
-
result: "deny",
|
|
176
|
-
resolution: "policy_deny",
|
|
177
|
-
origin: null,
|
|
178
|
-
agentName: "test-agent",
|
|
179
|
-
matchedPattern: null,
|
|
180
|
-
}),
|
|
181
|
-
);
|
|
132
|
+
expect(result.promptDetails.message).toBeDefined();
|
|
182
133
|
});
|
|
183
134
|
|
|
184
|
-
it("
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
135
|
+
it("logContext includes skill_read source and skillName", () => {
|
|
136
|
+
const result = describeSkillReadGate(
|
|
137
|
+
makeTcc({ agentName: "agent-1" }),
|
|
138
|
+
() => [makeSkillEntry({ name: "librarian" })],
|
|
139
|
+
) as GateDescriptor;
|
|
140
|
+
expect(result.logContext).toMatchObject({
|
|
141
|
+
source: "skill_read",
|
|
142
|
+
skillName: "librarian",
|
|
143
|
+
agentName: "agent-1",
|
|
192
144
|
});
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
resolution: "policy_allow",
|
|
201
|
-
}),
|
|
202
|
-
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("surface is 'skill' on the descriptor", () => {
|
|
148
|
+
const result = describeSkillReadGate(makeTcc(), () => [
|
|
149
|
+
makeSkillEntry(),
|
|
150
|
+
]) as GateDescriptor;
|
|
151
|
+
expect(result.surface).toBe("skill");
|
|
203
152
|
});
|
|
204
153
|
});
|