@gotgenes/pi-permission-system 8.0.0 → 8.1.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 +13 -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/tool.ts +4 -2
- package/src/handlers/permission-gate-handler.ts +106 -138
- package/src/permission-prompts.ts +5 -2
- 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 +2 -0
- package/test/handlers/external-directory-session-dedup.test.ts +2 -0
- package/test/handlers/gates/tool.test.ts +29 -2
- package/test/handlers/tool-call-events.test.ts +2 -1
- package/test/handlers/tool-call.test.ts +2 -1
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/permission-prompts.test.ts +66 -38
- package/test/tool-input-preview.test.ts +0 -244
- package/test/tool-preview-formatter.test.ts +385 -0
|
@@ -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
|
|
|
@@ -10,22 +10,15 @@ import {
|
|
|
10
10
|
countTextLines,
|
|
11
11
|
formatCount,
|
|
12
12
|
formatEditInputForPrompt,
|
|
13
|
-
formatGenericToolInputForLog,
|
|
14
13
|
formatReadInputForPrompt,
|
|
15
|
-
formatSearchInputForPrompt,
|
|
16
|
-
formatToolInputForPrompt,
|
|
17
14
|
formatWriteInputForPrompt,
|
|
18
|
-
getPermissionLogContext,
|
|
19
15
|
getPromptPath,
|
|
20
|
-
getToolInputPreviewForLog,
|
|
21
|
-
sanitizeInlineText,
|
|
22
16
|
serializeToolInputPreview,
|
|
23
17
|
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
24
18
|
TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
25
19
|
TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
26
20
|
truncateInlineText,
|
|
27
21
|
} from "#src/tool-input-preview";
|
|
28
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
29
22
|
|
|
30
23
|
const mockedStringify = vi.mocked(safeJsonStringify);
|
|
31
24
|
|
|
@@ -73,29 +66,6 @@ describe("truncateInlineText", () => {
|
|
|
73
66
|
});
|
|
74
67
|
});
|
|
75
68
|
|
|
76
|
-
describe("sanitizeInlineText", () => {
|
|
77
|
-
test("collapses whitespace and trims", () => {
|
|
78
|
-
expect(sanitizeInlineText(" hello world ")).toBe("hello world");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("returns 'empty text' for blank string", () => {
|
|
82
|
-
expect(sanitizeInlineText("")).toBe("empty text");
|
|
83
|
-
expect(sanitizeInlineText(" ")).toBe("empty text");
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("truncates to default TOOL_TEXT_SUMMARY_MAX_LENGTH", () => {
|
|
87
|
-
const long = "x".repeat(100);
|
|
88
|
-
const result = sanitizeInlineText(long);
|
|
89
|
-
expect(result.length).toBeLessThanOrEqual(TOOL_TEXT_SUMMARY_MAX_LENGTH + 1); // +1 for ellipsis char
|
|
90
|
-
expect(result).toContain("…");
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("respects custom maxLength", () => {
|
|
94
|
-
const result = sanitizeInlineText("hello world", 5);
|
|
95
|
-
expect(result).toBe("hello…");
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
69
|
describe("countTextLines", () => {
|
|
100
70
|
test("returns 0 for empty string", () => {
|
|
101
71
|
expect(countTextLines("")).toBe(0);
|
|
@@ -239,33 +209,6 @@ describe("formatReadInputForPrompt", () => {
|
|
|
239
209
|
});
|
|
240
210
|
});
|
|
241
211
|
|
|
242
|
-
describe("formatSearchInputForPrompt", () => {
|
|
243
|
-
test("includes pattern and path", () => {
|
|
244
|
-
const result = formatSearchInputForPrompt("grep", {
|
|
245
|
-
pattern: "TODO",
|
|
246
|
-
path: "/src",
|
|
247
|
-
});
|
|
248
|
-
expect(result).toContain("pattern 'TODO'");
|
|
249
|
-
expect(result).toContain("path '/src'");
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
test("includes glob when present", () => {
|
|
253
|
-
const result = formatSearchInputForPrompt("find", { glob: "*.ts" });
|
|
254
|
-
expect(result).toContain("glob '*.ts'");
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test("uses 'current working directory' for find/grep/ls without path", () => {
|
|
258
|
-
for (const toolName of ["find", "grep", "ls"]) {
|
|
259
|
-
const result = formatSearchInputForPrompt(toolName, {});
|
|
260
|
-
expect(result).toContain("current working directory");
|
|
261
|
-
}
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
test("returns empty string for other tools with no input", () => {
|
|
265
|
-
expect(formatSearchInputForPrompt("other", {})).toBe("");
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
|
|
269
212
|
describe("serializeToolInputPreview", () => {
|
|
270
213
|
test("delegates serialization to safeJsonStringify", () => {
|
|
271
214
|
mockedStringify.mockReturnValue('{"key":"value"}');
|
|
@@ -295,190 +238,3 @@ describe("serializeToolInputPreview", () => {
|
|
|
295
238
|
expect(result).toBe('{ "key": "val" }');
|
|
296
239
|
});
|
|
297
240
|
});
|
|
298
|
-
|
|
299
|
-
describe("formatToolInputForPrompt", () => {
|
|
300
|
-
test("dispatches 'edit' to formatEditInputForPrompt", () => {
|
|
301
|
-
mockedStringify.mockReturnValue(undefined);
|
|
302
|
-
const result = formatToolInputForPrompt("edit", {
|
|
303
|
-
path: "/foo.ts",
|
|
304
|
-
edits: [],
|
|
305
|
-
});
|
|
306
|
-
expect(result).toContain("for '/foo.ts'");
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
test("dispatches 'write' to formatWriteInputForPrompt", () => {
|
|
310
|
-
const result = formatToolInputForPrompt("write", {
|
|
311
|
-
path: "/out.ts",
|
|
312
|
-
content: "hi",
|
|
313
|
-
});
|
|
314
|
-
expect(result).toContain("for '/out.ts'");
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test("dispatches 'read' to formatReadInputForPrompt", () => {
|
|
318
|
-
const result = formatToolInputForPrompt("read", { path: "/src/x.ts" });
|
|
319
|
-
expect(result).toContain("path '/src/x.ts'");
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
test("dispatches 'find'/'grep'/'ls' to formatSearchInputForPrompt", () => {
|
|
323
|
-
for (const tool of ["find", "grep", "ls"]) {
|
|
324
|
-
const result = formatToolInputForPrompt(tool, {});
|
|
325
|
-
expect(result).toContain("current working directory");
|
|
326
|
-
}
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test("falls back to JSON preview for unknown tools", () => {
|
|
330
|
-
mockedStringify.mockReturnValue('{"x":1}');
|
|
331
|
-
const result = formatToolInputForPrompt("unknown", { x: 1 });
|
|
332
|
-
expect(result).toContain('{"x":1}');
|
|
333
|
-
});
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
describe("formatGenericToolInputForLog", () => {
|
|
337
|
-
test("returns undefined when serialization yields empty string", () => {
|
|
338
|
-
mockedStringify.mockReturnValue(undefined);
|
|
339
|
-
expect(formatGenericToolInputForLog({})).toBeUndefined();
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
test("returns prefixed input preview", () => {
|
|
343
|
-
mockedStringify.mockReturnValue('{"k":"v"}');
|
|
344
|
-
expect(formatGenericToolInputForLog({ k: "v" })).toBe('input {"k":"v"}');
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
test("truncates to TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH", () => {
|
|
348
|
-
const longJson = `{"k":"${"x".repeat(2000)}"}`;
|
|
349
|
-
mockedStringify.mockReturnValue(longJson);
|
|
350
|
-
const result = formatGenericToolInputForLog({});
|
|
351
|
-
expect(result).toBeDefined();
|
|
352
|
-
// result is "input " + truncated, so total > TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH by "input ".length
|
|
353
|
-
const preview = result!.slice("input ".length);
|
|
354
|
-
expect(preview.length).toBeLessThanOrEqual(
|
|
355
|
-
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH + 1,
|
|
356
|
-
);
|
|
357
|
-
});
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
describe("getToolInputPreviewForLog", () => {
|
|
361
|
-
const pathBearingTools = new Set(["read", "write", "edit"]);
|
|
362
|
-
|
|
363
|
-
test("returns undefined for bash tool", () => {
|
|
364
|
-
const result: PermissionCheckResult = {
|
|
365
|
-
toolName: "bash",
|
|
366
|
-
state: "allow",
|
|
367
|
-
source: "tool",
|
|
368
|
-
origin: "builtin",
|
|
369
|
-
};
|
|
370
|
-
expect(
|
|
371
|
-
getToolInputPreviewForLog(result, { command: "ls" }, pathBearingTools),
|
|
372
|
-
).toBeUndefined();
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
test("returns undefined for mcp tool", () => {
|
|
376
|
-
const result: PermissionCheckResult = {
|
|
377
|
-
toolName: "mcp",
|
|
378
|
-
state: "allow",
|
|
379
|
-
source: "tool",
|
|
380
|
-
origin: "builtin",
|
|
381
|
-
};
|
|
382
|
-
expect(
|
|
383
|
-
getToolInputPreviewForLog(result, {}, pathBearingTools),
|
|
384
|
-
).toBeUndefined();
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
test("returns undefined for mcp source", () => {
|
|
388
|
-
const result: PermissionCheckResult = {
|
|
389
|
-
toolName: "some-server:some-tool",
|
|
390
|
-
state: "allow",
|
|
391
|
-
source: "mcp",
|
|
392
|
-
origin: "builtin",
|
|
393
|
-
};
|
|
394
|
-
expect(
|
|
395
|
-
getToolInputPreviewForLog(result, {}, pathBearingTools),
|
|
396
|
-
).toBeUndefined();
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
test("returns path-based preview for path-bearing tools", () => {
|
|
400
|
-
const result: PermissionCheckResult = {
|
|
401
|
-
toolName: "read",
|
|
402
|
-
state: "allow",
|
|
403
|
-
source: "tool",
|
|
404
|
-
origin: "builtin",
|
|
405
|
-
};
|
|
406
|
-
const preview = getToolInputPreviewForLog(
|
|
407
|
-
result,
|
|
408
|
-
{ path: "/src/foo.ts" },
|
|
409
|
-
pathBearingTools,
|
|
410
|
-
);
|
|
411
|
-
expect(preview).toContain("/src/foo.ts");
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
test("returns generic JSON preview for non-path-bearing tools", () => {
|
|
415
|
-
mockedStringify.mockReturnValue('{"n":1}');
|
|
416
|
-
const result: PermissionCheckResult = {
|
|
417
|
-
toolName: "task",
|
|
418
|
-
state: "allow",
|
|
419
|
-
source: "tool",
|
|
420
|
-
origin: "builtin",
|
|
421
|
-
};
|
|
422
|
-
const preview = getToolInputPreviewForLog(
|
|
423
|
-
result,
|
|
424
|
-
{ n: 1 },
|
|
425
|
-
pathBearingTools,
|
|
426
|
-
);
|
|
427
|
-
expect(preview).toContain('{"n":1}');
|
|
428
|
-
});
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
describe("getPermissionLogContext", () => {
|
|
432
|
-
const pathBearingTools = new Set(["read", "write", "edit"]);
|
|
433
|
-
|
|
434
|
-
test("returns command, target, and toolInputPreview", () => {
|
|
435
|
-
const result: PermissionCheckResult = {
|
|
436
|
-
toolName: "bash",
|
|
437
|
-
state: "allow",
|
|
438
|
-
source: "tool",
|
|
439
|
-
origin: "builtin",
|
|
440
|
-
command: "ls -la",
|
|
441
|
-
};
|
|
442
|
-
const ctx = getPermissionLogContext(result, {}, pathBearingTools);
|
|
443
|
-
expect(ctx.command).toBe("ls -la");
|
|
444
|
-
expect(ctx.target).toBeUndefined();
|
|
445
|
-
expect(ctx.toolInputPreview).toBeUndefined();
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
test("includes toolInputPreview for non-bash path-bearing tools", () => {
|
|
449
|
-
const result: PermissionCheckResult = {
|
|
450
|
-
toolName: "read",
|
|
451
|
-
state: "allow",
|
|
452
|
-
source: "tool",
|
|
453
|
-
origin: "builtin",
|
|
454
|
-
};
|
|
455
|
-
const ctx = getPermissionLogContext(
|
|
456
|
-
result,
|
|
457
|
-
{ path: "/foo.ts" },
|
|
458
|
-
pathBearingTools,
|
|
459
|
-
);
|
|
460
|
-
expect(ctx.toolInputPreview).toContain("/foo.ts");
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
test("includes origin from check result when present", () => {
|
|
464
|
-
const result: PermissionCheckResult = {
|
|
465
|
-
toolName: "read",
|
|
466
|
-
state: "allow",
|
|
467
|
-
source: "tool",
|
|
468
|
-
origin: "project",
|
|
469
|
-
};
|
|
470
|
-
const ctx = getPermissionLogContext(result, {}, pathBearingTools);
|
|
471
|
-
expect(ctx.origin).toBe("project");
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
test("origin is 'builtin' when check result has builtin origin", () => {
|
|
475
|
-
const result: PermissionCheckResult = {
|
|
476
|
-
toolName: "read",
|
|
477
|
-
state: "allow",
|
|
478
|
-
source: "tool",
|
|
479
|
-
origin: "builtin",
|
|
480
|
-
};
|
|
481
|
-
const ctx = getPermissionLogContext(result, {}, pathBearingTools);
|
|
482
|
-
expect(ctx.origin).toBe("builtin");
|
|
483
|
-
});
|
|
484
|
-
});
|