@gotgenes/pi-permission-system 5.5.1 → 5.6.1
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/README.md +1 -0
- package/package.json +1 -1
- package/src/handlers/gates/bash-external-directory.ts +70 -65
- package/src/handlers/gates/descriptor.ts +115 -0
- package/src/handlers/gates/external-directory.ts +63 -122
- package/src/handlers/gates/index.ts +12 -4
- package/src/handlers/gates/runner.ts +144 -0
- package/src/handlers/gates/skill-read.ts +37 -54
- package/src/handlers/gates/tool.ts +35 -97
- package/src/handlers/gates/types.ts +0 -77
- package/src/handlers/tool-call.ts +89 -58
- package/tests/handlers/gates/bash-external-directory.test.ts +128 -126
- package/tests/handlers/gates/external-directory.test.ts +117 -188
- package/tests/handlers/gates/runner.test.ts +361 -0
- package/tests/handlers/gates/skill-read.test.ts +87 -123
- package/tests/handlers/gates/tool.test.ts +119 -112
|
@@ -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,10 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
ToolCallContext,
|
|
7
|
-
} from "../../../src/handlers/gates/types";
|
|
3
|
+
import type { GateDescriptor } from "../../../src/handlers/gates/descriptor";
|
|
4
|
+
import { describeSkillReadGate } from "../../../src/handlers/gates/skill-read";
|
|
5
|
+
import type { ToolCallContext } from "../../../src/handlers/gates/types";
|
|
8
6
|
import type { SkillPromptEntry } from "../../../src/skill-prompt-sanitizer";
|
|
9
7
|
|
|
10
8
|
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
@@ -41,149 +39,115 @@ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
|
41
39
|
};
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
function makeSkillReadGateDeps(
|
|
45
|
-
overrides: Partial<SkillReadGateDeps> = {},
|
|
46
|
-
): SkillReadGateDeps {
|
|
47
|
-
return {
|
|
48
|
-
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
49
|
-
writeReviewLog: vi.fn(),
|
|
50
|
-
emitDecision: vi.fn(),
|
|
51
|
-
canConfirm: vi.fn().mockReturnValue(true),
|
|
52
|
-
promptPermission: vi
|
|
53
|
-
.fn()
|
|
54
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
55
|
-
...overrides,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
42
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
60
43
|
|
|
61
|
-
describe("
|
|
62
|
-
it("returns null when tool is not read",
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
});
|
|
67
|
-
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
|
+
]);
|
|
68
49
|
expect(result).toBeNull();
|
|
69
50
|
});
|
|
70
51
|
|
|
71
|
-
it("returns null when no active skill entries",
|
|
72
|
-
const
|
|
73
|
-
const deps = makeSkillReadGateDeps({
|
|
74
|
-
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
75
|
-
});
|
|
76
|
-
const result = await evaluateSkillReadGate(tcc, deps);
|
|
52
|
+
it("returns null when no active skill entries", () => {
|
|
53
|
+
const result = describeSkillReadGate(makeTcc(), () => []);
|
|
77
54
|
expect(result).toBeNull();
|
|
78
55
|
});
|
|
79
56
|
|
|
80
|
-
it("returns null when read path does not match any skill",
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
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
|
+
);
|
|
86
62
|
expect(result).toBeNull();
|
|
87
63
|
});
|
|
88
64
|
|
|
89
|
-
it("returns
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.mockReturnValue([makeSkillEntry({ state: "allow" })]),
|
|
95
|
-
});
|
|
96
|
-
const result = await evaluateSkillReadGate(tcc, deps);
|
|
97
|
-
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();
|
|
98
70
|
});
|
|
99
71
|
|
|
100
|
-
it("returns
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
});
|
|
107
|
-
const result = await evaluateSkillReadGate(tcc, deps);
|
|
108
|
-
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" });
|
|
109
79
|
});
|
|
110
80
|
|
|
111
|
-
it("returns
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.fn()
|
|
119
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
120
|
-
});
|
|
121
|
-
const result = await evaluateSkillReadGate(tcc, deps);
|
|
122
|
-
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" });
|
|
123
88
|
});
|
|
124
89
|
|
|
125
|
-
it("returns
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const result =
|
|
136
|
-
|
|
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" });
|
|
97
|
+
});
|
|
98
|
+
|
|
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");
|
|
137
105
|
});
|
|
138
106
|
|
|
139
|
-
it("
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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",
|
|
146
116
|
});
|
|
147
|
-
|
|
148
|
-
expect(result).toMatchObject({ action: "block" });
|
|
117
|
+
expect(deniedMsg).toContain("librarian");
|
|
149
118
|
});
|
|
150
119
|
|
|
151
|
-
it("
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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",
|
|
157
131
|
});
|
|
158
|
-
|
|
159
|
-
expect(deps.emitDecision).toHaveBeenCalledWith(
|
|
160
|
-
expect.objectContaining({
|
|
161
|
-
surface: "skill",
|
|
162
|
-
value: "librarian",
|
|
163
|
-
result: "deny",
|
|
164
|
-
resolution: "policy_deny",
|
|
165
|
-
origin: null,
|
|
166
|
-
agentName: "test-agent",
|
|
167
|
-
matchedPattern: null,
|
|
168
|
-
}),
|
|
169
|
-
);
|
|
132
|
+
expect(result.promptDetails.message).toBeDefined();
|
|
170
133
|
});
|
|
171
134
|
|
|
172
|
-
it("
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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",
|
|
178
144
|
});
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}),
|
|
187
|
-
);
|
|
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");
|
|
188
152
|
});
|
|
189
153
|
});
|