@gotgenes/pi-permission-system 7.4.1 → 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.
@@ -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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
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 { formatToolInputForPrompt } from "#src/tool-input-preview";
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
- const mockedFormatToolInput = vi.mocked(formatToolInputForPrompt);
20
-
21
- beforeEach(() => {
22
- mockedFormatToolInput.mockReset();
23
- });
24
-
25
- afterEach(() => {
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(toolResult("read"), undefined, {
108
- path: "/src",
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(toolResult("read"), "my-agent", {
115
- path: "/src",
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 no tool-input-preview call", () => {
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(mcpResult("server:query"));
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("calls formatToolInputForPrompt for non-bash non-mcp tools", () => {
151
- mockedFormatToolInput.mockReturnValue("for '/src/foo.ts'");
152
- const result = formatAskPrompt(toolResult("read"), undefined, {
153
- path: "/src/foo.ts",
154
- });
155
- expect(mockedFormatToolInput).toHaveBeenCalledWith("read", {
156
- path: "/src/foo.ts",
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 formatToolInputForPrompt returns empty string", () => {
163
- mockedFormatToolInput.mockReturnValue("");
164
- const result = formatAskPrompt(toolResult("task"));
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
 
@@ -6,7 +6,6 @@ import {
6
6
  publishPermissionsService,
7
7
  unpublishPermissionsService,
8
8
  } from "#src/service";
9
- import { SubagentSessionRegistry } from "#src/subagent-registry";
10
9
  import type { PermissionCheckResult } from "#src/types";
11
10
 
12
11
  // ── helpers ────────────────────────────────────────────────────────────────
@@ -16,8 +15,6 @@ function makeService(
16
15
  ): PermissionsService {
17
16
  return {
18
17
  checkPermission: vi.fn(),
19
- registerSubagentSession: vi.fn(),
20
- unregisterSubagentSession: vi.fn(),
21
18
  getToolPermission: vi.fn(),
22
19
  ...overrides,
23
20
  };
@@ -130,61 +127,12 @@ describe("service adapter delegation", () => {
130
127
  );
131
128
  });
132
129
 
133
- it("registerSubagentSession delegates to the registry", () => {
134
- const registry = new SubagentSessionRegistry();
135
- const service: PermissionsService = {
136
- checkPermission: vi.fn(),
137
- registerSubagentSession(key, info) {
138
- registry.register(key, info);
139
- },
140
- unregisterSubagentSession(key) {
141
- registry.unregister(key);
142
- },
143
- getToolPermission: vi.fn((): "allow" => "allow"),
144
- };
145
-
146
- publishPermissionsService(service);
147
- getPermissionsService()!.registerSubagentSession("/sessions/task-1", {
148
- agentName: "Explore",
149
- parentSessionId: "parent-abc",
150
- });
151
-
152
- expect(registry.has("/sessions/task-1")).toBe(true);
153
- expect(registry.get("/sessions/task-1")).toEqual({
154
- agentName: "Explore",
155
- parentSessionId: "parent-abc",
156
- });
157
- });
158
-
159
- it("unregisterSubagentSession delegates to the registry", () => {
160
- const registry = new SubagentSessionRegistry();
161
- const service: PermissionsService = {
162
- checkPermission: vi.fn(),
163
- registerSubagentSession(key, info) {
164
- registry.register(key, info);
165
- },
166
- unregisterSubagentSession(key) {
167
- registry.unregister(key);
168
- },
169
- getToolPermission: vi.fn((): "allow" => "allow"),
170
- };
171
-
172
- publishPermissionsService(service);
173
- const svc = getPermissionsService()!;
174
- svc.registerSubagentSession("/sessions/task-1", { agentName: "Explore" });
175
- svc.unregisterSubagentSession("/sessions/task-1");
176
-
177
- expect(registry.has("/sessions/task-1")).toBe(false);
178
- });
179
-
180
130
  it("getToolPermission delegates to the permission manager", () => {
181
131
  const getToolPermissionFn = vi.fn(
182
132
  (_t: string, _a?: string): "deny" => "deny",
183
133
  );
184
134
  const service: PermissionsService = {
185
135
  checkPermission: vi.fn(),
186
- registerSubagentSession: vi.fn(),
187
- unregisterSubagentSession: vi.fn(),
188
136
  getToolPermission(toolName, agentName) {
189
137
  return getToolPermissionFn(toolName, agentName);
190
138
  },
@@ -206,8 +154,6 @@ describe("service adapter delegation", () => {
206
154
  );
207
155
  const service: PermissionsService = {
208
156
  checkPermission: vi.fn(),
209
- registerSubagentSession: vi.fn(),
210
- unregisterSubagentSession: vi.fn(),
211
157
  getToolPermission(toolName, agentName) {
212
158
  return getToolPermissionFn(toolName, agentName);
213
159
  },
@@ -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
- });