@gotgenes/pi-permission-system 8.0.0 → 8.2.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 +21 -0
- package/config/config.example.json +3 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +12 -0
- package/src/extension-config.ts +23 -0
- package/src/handlers/gates/bash-external-directory.ts +2 -4
- package/src/handlers/gates/bash-path.ts +2 -4
- package/src/handlers/gates/descriptor.ts +6 -6
- package/src/handlers/gates/external-directory.ts +2 -4
- package/src/handlers/gates/helpers.ts +30 -1
- package/src/handlers/gates/path.ts +2 -4
- package/src/handlers/gates/runner.ts +29 -56
- package/src/handlers/gates/tool.ts +9 -6
- package/src/handlers/permission-gate-handler.ts +110 -141
- package/src/permission-manager.ts +6 -49
- package/src/permission-prompts.ts +5 -2
- package/src/permission-session.ts +3 -2
- package/src/scope-merge.ts +72 -0
- package/src/session-approval.ts +43 -0
- package/src/session-rules.ts +13 -0
- package/src/tool-input-preview.ts +0 -116
- package/src/tool-preview-formatter.ts +188 -0
- package/test/extension-config.test.ts +93 -0
- package/test/handlers/external-directory-integration.test.ts +3 -1
- package/test/handlers/external-directory-session-dedup.test.ts +17 -12
- package/test/handlers/gates/bash-external-directory.test.ts +11 -9
- package/test/handlers/gates/external-directory.test.ts +2 -5
- package/test/handlers/gates/helpers.test.ts +81 -0
- package/test/handlers/gates/path.test.ts +2 -2
- package/test/handlers/gates/runner.test.ts +18 -23
- package/test/handlers/gates/tool.test.ts +31 -4
- package/test/handlers/input-events.test.ts +1 -1
- package/test/handlers/input.test.ts +1 -1
- package/test/handlers/tool-call-events.test.ts +3 -2
- package/test/handlers/tool-call.test.ts +3 -2
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/permission-prompts.test.ts +66 -38
- package/test/permission-session.test.ts +6 -3
- package/test/scope-merge.test.ts +116 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-rules.test.ts +49 -0
- package/test/tool-input-preview.test.ts +0 -244
- package/test/tool-preview-formatter.test.ts +385 -0
|
@@ -160,8 +160,8 @@ describe("describePathGate", () => {
|
|
|
160
160
|
getSessionRuleset,
|
|
161
161
|
) as GateDescriptor;
|
|
162
162
|
expect(result.sessionApproval).toBeDefined();
|
|
163
|
-
expect(result.sessionApproval).
|
|
164
|
-
expect(result.sessionApproval).
|
|
163
|
+
expect(result.sessionApproval?.surface).toBe("path");
|
|
164
|
+
expect(result.sessionApproval?.representativePattern).toBeDefined();
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
it("descriptor denialContext references the file path and tool name", () => {
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
GateRunnerDeps,
|
|
8
8
|
} from "#src/handlers/gates/descriptor";
|
|
9
9
|
import { runGateCheck } from "#src/handlers/gates/runner";
|
|
10
|
+
import { SessionApproval } from "#src/session-approval";
|
|
10
11
|
import type { PermissionCheckResult } from "#src/types";
|
|
11
12
|
|
|
12
13
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
@@ -61,7 +62,7 @@ function makeRunnerDeps(
|
|
|
61
62
|
return {
|
|
62
63
|
checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
|
|
63
64
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
64
|
-
|
|
65
|
+
recordSessionApproval: vi.fn(),
|
|
65
66
|
writeReviewLog: vi.fn(),
|
|
66
67
|
emitDecision: vi.fn(),
|
|
67
68
|
canConfirm: vi.fn().mockReturnValue(true),
|
|
@@ -167,7 +168,7 @@ describe("runGateCheck", () => {
|
|
|
167
168
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
168
169
|
});
|
|
169
170
|
const descriptor = makeDescriptor({
|
|
170
|
-
sessionApproval:
|
|
171
|
+
sessionApproval: SessionApproval.single("read", "*"),
|
|
171
172
|
});
|
|
172
173
|
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
173
174
|
expect(result).toEqual({ action: "allow" });
|
|
@@ -176,33 +177,27 @@ describe("runGateCheck", () => {
|
|
|
176
177
|
resolution: "user_approved_for_session",
|
|
177
178
|
}),
|
|
178
179
|
);
|
|
179
|
-
expect(deps.
|
|
180
|
+
expect(deps.recordSessionApproval).toHaveBeenCalledWith(
|
|
181
|
+
SessionApproval.single("read", "*"),
|
|
182
|
+
);
|
|
180
183
|
});
|
|
181
184
|
|
|
182
|
-
it("calls
|
|
185
|
+
it("calls recordSessionApproval once with the full SessionApproval when sessionApproval has multiple patterns", async () => {
|
|
183
186
|
const deps = makeRunnerDeps({
|
|
184
187
|
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
185
188
|
promptPermission: vi
|
|
186
189
|
.fn()
|
|
187
190
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
188
191
|
});
|
|
189
|
-
const
|
|
190
|
-
sessionApproval: {
|
|
191
|
-
surface: "external_directory",
|
|
192
|
-
patterns: ["/outside/a/*", "/outside/b/*"],
|
|
193
|
-
},
|
|
194
|
-
});
|
|
195
|
-
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
196
|
-
expect(result).toEqual({ action: "allow" });
|
|
197
|
-
expect(deps.approveSessionRule).toHaveBeenCalledTimes(2);
|
|
198
|
-
expect(deps.approveSessionRule).toHaveBeenCalledWith(
|
|
199
|
-
"external_directory",
|
|
192
|
+
const approval = SessionApproval.multiple("external_directory", [
|
|
200
193
|
"/outside/a/*",
|
|
201
|
-
);
|
|
202
|
-
expect(deps.approveSessionRule).toHaveBeenCalledWith(
|
|
203
|
-
"external_directory",
|
|
204
194
|
"/outside/b/*",
|
|
205
|
-
);
|
|
195
|
+
]);
|
|
196
|
+
const descriptor = makeDescriptor({ sessionApproval: approval });
|
|
197
|
+
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
198
|
+
expect(result).toEqual({ action: "allow" });
|
|
199
|
+
expect(deps.recordSessionApproval).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(deps.recordSessionApproval).toHaveBeenCalledWith(approval);
|
|
206
201
|
});
|
|
207
202
|
|
|
208
203
|
it("returns block and emits user_denied when ask + user denies", async () => {
|
|
@@ -317,7 +312,7 @@ describe("runGateCheck", () => {
|
|
|
317
312
|
);
|
|
318
313
|
});
|
|
319
314
|
|
|
320
|
-
it("does not call
|
|
315
|
+
it("does not call recordSessionApproval when user approves once (no sessionApproval)", async () => {
|
|
321
316
|
const deps = makeRunnerDeps({
|
|
322
317
|
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
323
318
|
promptPermission: vi
|
|
@@ -325,7 +320,7 @@ describe("runGateCheck", () => {
|
|
|
325
320
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
326
321
|
});
|
|
327
322
|
await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
328
|
-
expect(deps.
|
|
323
|
+
expect(deps.recordSessionApproval).not.toHaveBeenCalled();
|
|
329
324
|
});
|
|
330
325
|
|
|
331
326
|
it("uses preCheck result directly instead of calling checkPermission", async () => {
|
|
@@ -348,7 +343,7 @@ describe("runGateCheck", () => {
|
|
|
348
343
|
);
|
|
349
344
|
});
|
|
350
345
|
|
|
351
|
-
it("does not call
|
|
346
|
+
it("does not call recordSessionApproval when user approves for session but no sessionApproval on descriptor", async () => {
|
|
352
347
|
const deps = makeRunnerDeps({
|
|
353
348
|
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
354
349
|
promptPermission: vi
|
|
@@ -357,7 +352,7 @@ describe("runGateCheck", () => {
|
|
|
357
352
|
});
|
|
358
353
|
// No sessionApproval on descriptor
|
|
359
354
|
await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
360
|
-
expect(deps.
|
|
355
|
+
expect(deps.recordSessionApproval).not.toHaveBeenCalled();
|
|
361
356
|
});
|
|
362
357
|
|
|
363
358
|
describe("denialContext formatting", () => {
|
|
@@ -2,10 +2,24 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { describeToolGate } from "#src/handlers/gates/tool";
|
|
4
4
|
import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
5
|
+
import {
|
|
6
|
+
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
7
|
+
TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
8
|
+
TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
9
|
+
} from "#src/tool-input-preview";
|
|
10
|
+
import { ToolPreviewFormatter } from "#src/tool-preview-formatter";
|
|
5
11
|
import type { PermissionCheckResult } from "#src/types";
|
|
6
12
|
|
|
7
13
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
8
14
|
|
|
15
|
+
function makeFormatter(): ToolPreviewFormatter {
|
|
16
|
+
return new ToolPreviewFormatter({
|
|
17
|
+
toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
18
|
+
toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
19
|
+
toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
10
24
|
return {
|
|
11
25
|
toolName: "read",
|
|
@@ -38,6 +52,7 @@ describe("describeToolGate", () => {
|
|
|
38
52
|
const desc = describeToolGate(
|
|
39
53
|
makeTcc({ toolName: "read" }),
|
|
40
54
|
makeCheckResult("ask"),
|
|
55
|
+
makeFormatter(),
|
|
41
56
|
);
|
|
42
57
|
expect(desc.surface).toBe("read");
|
|
43
58
|
expect(desc.decision.surface).toBe("read");
|
|
@@ -47,6 +62,7 @@ describe("describeToolGate", () => {
|
|
|
47
62
|
const desc = describeToolGate(
|
|
48
63
|
makeTcc({ toolName: "write" }),
|
|
49
64
|
makeCheckResult("ask"),
|
|
65
|
+
makeFormatter(),
|
|
50
66
|
);
|
|
51
67
|
expect(desc.decision.value).toBe("write");
|
|
52
68
|
});
|
|
@@ -59,6 +75,7 @@ describe("describeToolGate", () => {
|
|
|
59
75
|
const desc = describeToolGate(
|
|
60
76
|
makeTcc({ toolName: "bash", input: { command: "git status" } }),
|
|
61
77
|
check,
|
|
78
|
+
makeFormatter(),
|
|
62
79
|
);
|
|
63
80
|
expect(desc.surface).toBe("bash");
|
|
64
81
|
expect(desc.decision.surface).toBe("bash");
|
|
@@ -73,6 +90,7 @@ describe("describeToolGate", () => {
|
|
|
73
90
|
const desc = describeToolGate(
|
|
74
91
|
makeTcc({ toolName: "mcp", input: { tool: "server:tool" } }),
|
|
75
92
|
check,
|
|
93
|
+
makeFormatter(),
|
|
76
94
|
);
|
|
77
95
|
expect(desc.surface).toBe("mcp");
|
|
78
96
|
expect(desc.decision.surface).toBe("mcp");
|
|
@@ -81,7 +99,7 @@ describe("describeToolGate", () => {
|
|
|
81
99
|
|
|
82
100
|
it("populates denialContext with kind 'tool' and check result", () => {
|
|
83
101
|
const check = makeCheckResult("deny", { toolName: "read" });
|
|
84
|
-
const desc = describeToolGate(makeTcc(), check);
|
|
102
|
+
const desc = describeToolGate(makeTcc(), check, makeFormatter());
|
|
85
103
|
expect(desc.denialContext).toEqual({
|
|
86
104
|
kind: "tool",
|
|
87
105
|
check,
|
|
@@ -92,7 +110,11 @@ describe("describeToolGate", () => {
|
|
|
92
110
|
|
|
93
111
|
it("populates denialContext with agent name when provided", () => {
|
|
94
112
|
const check = makeCheckResult("ask", { toolName: "read" });
|
|
95
|
-
const desc = describeToolGate(
|
|
113
|
+
const desc = describeToolGate(
|
|
114
|
+
makeTcc({ agentName: "my-agent" }),
|
|
115
|
+
check,
|
|
116
|
+
makeFormatter(),
|
|
117
|
+
);
|
|
96
118
|
expect(desc.denialContext.agentName).toBe("my-agent");
|
|
97
119
|
});
|
|
98
120
|
|
|
@@ -101,6 +123,7 @@ describe("describeToolGate", () => {
|
|
|
101
123
|
const desc = describeToolGate(
|
|
102
124
|
makeTcc({ toolName: "bash", input: { command: "ls" } }),
|
|
103
125
|
check,
|
|
126
|
+
makeFormatter(),
|
|
104
127
|
);
|
|
105
128
|
expect(desc.denialContext).toMatchObject({
|
|
106
129
|
kind: "tool",
|
|
@@ -116,10 +139,11 @@ describe("describeToolGate", () => {
|
|
|
116
139
|
const desc = describeToolGate(
|
|
117
140
|
makeTcc({ toolName: "bash", input: { command: "git status" } }),
|
|
118
141
|
check,
|
|
142
|
+
makeFormatter(),
|
|
119
143
|
);
|
|
120
144
|
expect(desc.sessionApproval).toBeDefined();
|
|
121
|
-
expect(desc.sessionApproval
|
|
122
|
-
expect(desc.sessionApproval
|
|
145
|
+
expect(desc.sessionApproval?.surface).toBe("bash");
|
|
146
|
+
expect(desc.sessionApproval?.representativePattern).toBeDefined();
|
|
123
147
|
});
|
|
124
148
|
|
|
125
149
|
it("populates promptDetails with correct fields", () => {
|
|
@@ -127,6 +151,7 @@ describe("describeToolGate", () => {
|
|
|
127
151
|
const desc = describeToolGate(
|
|
128
152
|
makeTcc({ toolName: "read", agentName: "my-agent", toolCallId: "tc-42" }),
|
|
129
153
|
check,
|
|
154
|
+
makeFormatter(),
|
|
130
155
|
);
|
|
131
156
|
expect(desc.promptDetails).toMatchObject({
|
|
132
157
|
source: "tool_call",
|
|
@@ -143,6 +168,7 @@ describe("describeToolGate", () => {
|
|
|
143
168
|
const desc = describeToolGate(
|
|
144
169
|
makeTcc({ toolName: "bash", input: { command: "ls" } }),
|
|
145
170
|
check,
|
|
171
|
+
makeFormatter(),
|
|
146
172
|
);
|
|
147
173
|
expect(desc.logContext).toMatchObject({
|
|
148
174
|
source: "tool_call",
|
|
@@ -155,6 +181,7 @@ describe("describeToolGate", () => {
|
|
|
155
181
|
const desc = describeToolGate(
|
|
156
182
|
makeTcc({ toolName: "edit", input: { path: "/a.ts" } }),
|
|
157
183
|
makeCheckResult("ask", { toolName: "edit" }),
|
|
184
|
+
makeFormatter(),
|
|
158
185
|
);
|
|
159
186
|
expect(desc.surface).toBe("edit");
|
|
160
187
|
expect(desc.input).toEqual({ path: "/a.ts" });
|
|
@@ -55,7 +55,7 @@ function makeSession(
|
|
|
55
55
|
}),
|
|
56
56
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
57
57
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
58
|
-
|
|
58
|
+
recordSessionApproval: vi.fn(),
|
|
59
59
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
60
60
|
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
61
61
|
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
@@ -43,7 +43,7 @@ function makeSession(
|
|
|
43
43
|
checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
|
|
44
44
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
45
45
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
46
|
-
|
|
46
|
+
recordSessionApproval: vi.fn(),
|
|
47
47
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
48
48
|
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
49
49
|
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { describe, expect, it, vi } from "vitest";
|
|
7
|
-
|
|
7
|
+
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
8
8
|
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
9
9
|
import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
10
10
|
import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
|
|
@@ -77,12 +77,13 @@ function makeSession(
|
|
|
77
77
|
checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
|
|
78
78
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
79
79
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
80
|
-
|
|
80
|
+
recordSessionApproval: vi.fn(),
|
|
81
81
|
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
82
82
|
getInfrastructureDirs: vi
|
|
83
83
|
.fn()
|
|
84
84
|
.mockReturnValue(["/test/agent", "/test/agent/git"]),
|
|
85
85
|
getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
86
|
+
config: DEFAULT_EXTENSION_CONFIG,
|
|
86
87
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
87
88
|
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
88
89
|
...overrides,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
|
|
3
|
+
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
4
4
|
import {
|
|
5
5
|
getEventInput,
|
|
6
6
|
PermissionGateHandler,
|
|
@@ -68,12 +68,13 @@ function makeSession(
|
|
|
68
68
|
checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
|
|
69
69
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
70
70
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
71
|
-
|
|
71
|
+
recordSessionApproval: vi.fn(),
|
|
72
72
|
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
73
73
|
getInfrastructureDirs: vi
|
|
74
74
|
.fn()
|
|
75
75
|
.mockReturnValue(["/test/agent", "/test/agent/git"]),
|
|
76
76
|
getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
77
|
+
config: DEFAULT_EXTENSION_CONFIG,
|
|
77
78
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
78
79
|
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
79
80
|
...overrides,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type RequestedToolValidation,
|
|
5
|
+
validateRequestedTool,
|
|
6
|
+
} from "#src/handlers/permission-gate-handler";
|
|
7
|
+
|
|
8
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function makeTools(names: string[]): { name: string }[] {
|
|
11
|
+
return names.map((name) => ({ name }));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TOOLS = makeTools(["read", "bash", "edit"]);
|
|
15
|
+
|
|
16
|
+
// ── validateRequestedTool ──────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
describe("validateRequestedTool", () => {
|
|
19
|
+
describe("missing / unresolvable tool name", () => {
|
|
20
|
+
it("blocks when event has no name field", () => {
|
|
21
|
+
const result = validateRequestedTool({ type: "tool_call" }, TOOLS);
|
|
22
|
+
expect(result.status).toBe("block");
|
|
23
|
+
expect(
|
|
24
|
+
(result as Extract<RequestedToolValidation, { status: "block" }>)
|
|
25
|
+
.reason,
|
|
26
|
+
).toBeTruthy();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("blocks when name field is an empty string", () => {
|
|
30
|
+
const result = validateRequestedTool({ name: "" }, TOOLS);
|
|
31
|
+
expect(result.status).toBe("block");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("blocks when name field is null", () => {
|
|
35
|
+
const result = validateRequestedTool({ name: null }, TOOLS);
|
|
36
|
+
expect(result.status).toBe("block");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("blocks when event is a primitive", () => {
|
|
40
|
+
const result = validateRequestedTool("not-an-object", TOOLS);
|
|
41
|
+
expect(result.status).toBe("block");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("unregistered tool", () => {
|
|
46
|
+
it("blocks when the tool name is not in the registered list", () => {
|
|
47
|
+
const result = validateRequestedTool({ name: "unknown-tool" }, TOOLS);
|
|
48
|
+
expect(result.status).toBe("block");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("includes available tool names in the block reason", () => {
|
|
52
|
+
const result = validateRequestedTool({ name: "unknown-tool" }, TOOLS);
|
|
53
|
+
expect(result.status).toBe("block");
|
|
54
|
+
const { reason } = result as Extract<
|
|
55
|
+
RequestedToolValidation,
|
|
56
|
+
{ status: "block" }
|
|
57
|
+
>;
|
|
58
|
+
expect(reason).toContain("read");
|
|
59
|
+
expect(reason).toContain("bash");
|
|
60
|
+
expect(reason).toContain("edit");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("blocks with empty available list when no tools are registered", () => {
|
|
64
|
+
const result = validateRequestedTool({ name: "anything" }, []);
|
|
65
|
+
expect(result.status).toBe("block");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("registered tool (ok path)", () => {
|
|
70
|
+
it("returns ok with the raw tool name for a known tool", () => {
|
|
71
|
+
const result = validateRequestedTool({ name: "read" }, TOOLS);
|
|
72
|
+
expect(result).toEqual({ status: "ok", toolName: "read" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns the raw name as it appeared in the event (not normalised)", () => {
|
|
76
|
+
// If an alias mechanism were to normalise "Read" → "read",
|
|
77
|
+
// validateRequestedTool still returns the raw value from the event.
|
|
78
|
+
// Without aliases the raw name and registered name are the same; this
|
|
79
|
+
// asserts the contract that toolName comes from the event, not from the
|
|
80
|
+
// registration lookup's normalizedToolName field.
|
|
81
|
+
const result = validateRequestedTool({ name: "bash" }, TOOLS);
|
|
82
|
+
expect(result).toEqual({ status: "ok", toolName: "bash" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("resolves tool name via the `arguments` field naming convention", () => {
|
|
86
|
+
// getToolNameFromValue reads `.name` then falls back to other fields;
|
|
87
|
+
// a plain `{ name: "edit" }` event is sufficient here.
|
|
88
|
+
const result = validateRequestedTool({ name: "edit" }, TOOLS);
|
|
89
|
+
expect(result).toEqual({ status: "ok", toolName: "edit" });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
// Mock tool-input-preview collaborator before importing the module under test.
|
|
4
|
-
vi.mock("../src/tool-input-preview.js", () => ({
|
|
5
|
-
formatToolInputForPrompt: vi.fn(() => "mocked preview"),
|
|
6
|
-
}));
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
7
2
|
|
|
8
3
|
import {
|
|
9
4
|
formatAskPrompt,
|
|
@@ -13,18 +8,21 @@ import {
|
|
|
13
8
|
formatUnknownToolReason,
|
|
14
9
|
} from "#src/permission-prompts";
|
|
15
10
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
16
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
13
|
+
TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
14
|
+
TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
15
|
+
} from "#src/tool-input-preview";
|
|
16
|
+
import { ToolPreviewFormatter } from "#src/tool-preview-formatter";
|
|
17
17
|
import type { PermissionCheckResult } from "#src/types";
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
vi.restoreAllMocks();
|
|
27
|
-
});
|
|
19
|
+
function makeFormatter(): ToolPreviewFormatter {
|
|
20
|
+
return new ToolPreviewFormatter({
|
|
21
|
+
toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
22
|
+
toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
23
|
+
toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
28
26
|
|
|
29
27
|
function toolResult(
|
|
30
28
|
toolName: string,
|
|
@@ -104,66 +102,96 @@ describe("formatUnknownToolReason", () => {
|
|
|
104
102
|
|
|
105
103
|
describe("formatAskPrompt", () => {
|
|
106
104
|
test("uses 'Current agent' when no agent name given", () => {
|
|
107
|
-
const result = formatAskPrompt(
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
const result = formatAskPrompt(
|
|
106
|
+
toolResult("read"),
|
|
107
|
+
undefined,
|
|
108
|
+
{ path: "/src" },
|
|
109
|
+
makeFormatter(),
|
|
110
|
+
);
|
|
110
111
|
expect(result).toContain("Current agent");
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
test("uses agent name when provided", () => {
|
|
114
|
-
const result = formatAskPrompt(
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
const result = formatAskPrompt(
|
|
116
|
+
toolResult("read"),
|
|
117
|
+
"my-agent",
|
|
118
|
+
{ path: "/src" },
|
|
119
|
+
makeFormatter(),
|
|
120
|
+
);
|
|
117
121
|
expect(result).toContain("Agent 'my-agent'");
|
|
118
122
|
});
|
|
119
123
|
|
|
120
|
-
test("formats bash prompt with command and
|
|
124
|
+
test("formats bash prompt with command and does not use formatter", () => {
|
|
121
125
|
const result = formatAskPrompt(
|
|
122
126
|
toolResult("bash", { command: "git status" }),
|
|
127
|
+
undefined,
|
|
128
|
+
undefined,
|
|
129
|
+
makeFormatter(),
|
|
123
130
|
);
|
|
124
131
|
expect(result).toContain("git status");
|
|
125
132
|
expect(result).toContain("Allow this command?");
|
|
126
|
-
expect(mockedFormatToolInput).not.toHaveBeenCalled();
|
|
127
133
|
});
|
|
128
134
|
|
|
129
135
|
test("formats bash prompt with matched pattern", () => {
|
|
130
136
|
const result = formatAskPrompt(
|
|
131
137
|
toolResult("bash", { command: "git push", matchedPattern: "git *" }),
|
|
138
|
+
undefined,
|
|
139
|
+
undefined,
|
|
140
|
+
makeFormatter(),
|
|
132
141
|
);
|
|
133
142
|
expect(result).toContain("matched 'git *'");
|
|
134
143
|
});
|
|
135
144
|
|
|
136
145
|
test("formats MCP prompt with target", () => {
|
|
137
|
-
const result = formatAskPrompt(
|
|
146
|
+
const result = formatAskPrompt(
|
|
147
|
+
mcpResult("server:query"),
|
|
148
|
+
undefined,
|
|
149
|
+
undefined,
|
|
150
|
+
makeFormatter(),
|
|
151
|
+
);
|
|
138
152
|
expect(result).toContain("server:query");
|
|
139
153
|
expect(result).toContain("Allow this call?");
|
|
140
|
-
expect(mockedFormatToolInput).not.toHaveBeenCalled();
|
|
141
154
|
});
|
|
142
155
|
|
|
143
156
|
test("formats MCP prompt with matched pattern", () => {
|
|
144
157
|
const result = formatAskPrompt(
|
|
145
158
|
mcpResult("server:query", { matchedPattern: "server:*" }),
|
|
159
|
+
undefined,
|
|
160
|
+
undefined,
|
|
161
|
+
makeFormatter(),
|
|
146
162
|
);
|
|
147
163
|
expect(result).toContain("matched 'server:*'");
|
|
148
164
|
});
|
|
149
165
|
|
|
150
|
-
test("
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
expect(result).toContain("for '/src/foo.ts'");
|
|
166
|
+
test("includes real input preview for non-bash non-mcp tools", () => {
|
|
167
|
+
const result = formatAskPrompt(
|
|
168
|
+
toolResult("read"),
|
|
169
|
+
undefined,
|
|
170
|
+
{ path: "/src/foo.ts" },
|
|
171
|
+
makeFormatter(),
|
|
172
|
+
);
|
|
173
|
+
expect(result).toContain("path '/src/foo.ts'");
|
|
159
174
|
expect(result).toContain("Allow this call?");
|
|
160
175
|
});
|
|
161
176
|
|
|
162
|
-
test("omits input suffix when
|
|
163
|
-
|
|
164
|
-
|
|
177
|
+
test("omits input suffix when formatter returns empty string for input", () => {
|
|
178
|
+
const result = formatAskPrompt(
|
|
179
|
+
toolResult("task"),
|
|
180
|
+
undefined,
|
|
181
|
+
{},
|
|
182
|
+
makeFormatter(),
|
|
183
|
+
);
|
|
184
|
+
expect(result).toContain("task");
|
|
185
|
+
expect(result).not.toContain("undefined");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("omits input suffix when no formatter provided", () => {
|
|
189
|
+
const result = formatAskPrompt(toolResult("task"), undefined, {
|
|
190
|
+
path: "/src",
|
|
191
|
+
});
|
|
165
192
|
expect(result).toContain("task");
|
|
166
193
|
expect(result).not.toContain("undefined");
|
|
194
|
+
expect(result).toContain("Allow this call?");
|
|
167
195
|
});
|
|
168
196
|
});
|
|
169
197
|
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
PermissionSession,
|
|
37
37
|
type PermissionSessionRuntimeDeps,
|
|
38
38
|
} from "#src/permission-session";
|
|
39
|
+
import { SessionApproval } from "#src/session-approval";
|
|
39
40
|
import type { SessionLogger } from "#src/session-logger";
|
|
40
41
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
41
42
|
|
|
@@ -219,9 +220,11 @@ describe("PermissionSession", () => {
|
|
|
219
220
|
expect(rules).toEqual([]);
|
|
220
221
|
});
|
|
221
222
|
|
|
222
|
-
it("delegates
|
|
223
|
+
it("delegates recordSessionApproval to internal SessionRules", () => {
|
|
223
224
|
const { session } = createSession();
|
|
224
|
-
session.
|
|
225
|
+
session.recordSessionApproval(
|
|
226
|
+
SessionApproval.single("bash", "/usr/bin/*"),
|
|
227
|
+
);
|
|
225
228
|
const rules = session.getSessionRuleset();
|
|
226
229
|
expect(rules).toHaveLength(1);
|
|
227
230
|
expect(rules[0]).toMatchObject({
|
|
@@ -325,7 +328,7 @@ describe("PermissionSession", () => {
|
|
|
325
328
|
describe("shutdown", () => {
|
|
326
329
|
it("clears session rules", () => {
|
|
327
330
|
const { session } = createSession();
|
|
328
|
-
session.
|
|
331
|
+
session.recordSessionApproval(SessionApproval.single("bash", "*"));
|
|
329
332
|
expect(session.getSessionRuleset()).toHaveLength(1);
|
|
330
333
|
|
|
331
334
|
session.shutdown();
|