@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/config/config.example.json +3 -0
  3. package/package.json +1 -1
  4. package/schemas/permissions.schema.json +12 -0
  5. package/src/extension-config.ts +23 -0
  6. package/src/handlers/gates/bash-external-directory.ts +2 -4
  7. package/src/handlers/gates/bash-path.ts +2 -4
  8. package/src/handlers/gates/descriptor.ts +6 -6
  9. package/src/handlers/gates/external-directory.ts +2 -4
  10. package/src/handlers/gates/helpers.ts +30 -1
  11. package/src/handlers/gates/path.ts +2 -4
  12. package/src/handlers/gates/runner.ts +29 -56
  13. package/src/handlers/gates/tool.ts +9 -6
  14. package/src/handlers/permission-gate-handler.ts +110 -141
  15. package/src/permission-manager.ts +6 -49
  16. package/src/permission-prompts.ts +5 -2
  17. package/src/permission-session.ts +3 -2
  18. package/src/scope-merge.ts +72 -0
  19. package/src/session-approval.ts +43 -0
  20. package/src/session-rules.ts +13 -0
  21. package/src/tool-input-preview.ts +0 -116
  22. package/src/tool-preview-formatter.ts +188 -0
  23. package/test/extension-config.test.ts +93 -0
  24. package/test/handlers/external-directory-integration.test.ts +3 -1
  25. package/test/handlers/external-directory-session-dedup.test.ts +17 -12
  26. package/test/handlers/gates/bash-external-directory.test.ts +11 -9
  27. package/test/handlers/gates/external-directory.test.ts +2 -5
  28. package/test/handlers/gates/helpers.test.ts +81 -0
  29. package/test/handlers/gates/path.test.ts +2 -2
  30. package/test/handlers/gates/runner.test.ts +18 -23
  31. package/test/handlers/gates/tool.test.ts +31 -4
  32. package/test/handlers/input-events.test.ts +1 -1
  33. package/test/handlers/input.test.ts +1 -1
  34. package/test/handlers/tool-call-events.test.ts +3 -2
  35. package/test/handlers/tool-call.test.ts +3 -2
  36. package/test/handlers/validate-requested-tool.test.ts +92 -0
  37. package/test/permission-prompts.test.ts +66 -38
  38. package/test/permission-session.test.ts +6 -3
  39. package/test/scope-merge.test.ts +116 -0
  40. package/test/session-approval.test.ts +75 -0
  41. package/test/session-rules.test.ts +49 -0
  42. package/test/tool-input-preview.test.ts +0 -244
  43. 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).toHaveProperty("surface", "path");
164
- expect(result.sessionApproval).toHaveProperty("pattern");
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
- approveSessionRule: vi.fn(),
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: { surface: "read", pattern: "*" },
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.approveSessionRule).toHaveBeenCalledWith("read", "*");
180
+ expect(deps.recordSessionApproval).toHaveBeenCalledWith(
181
+ SessionApproval.single("read", "*"),
182
+ );
180
183
  });
181
184
 
182
- it("calls approveSessionRule once per pattern when sessionApproval has multiple patterns", async () => {
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 descriptor = makeDescriptor({
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 approveSessionRule when user approves once (no sessionApproval)", async () => {
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.approveSessionRule).not.toHaveBeenCalled();
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 approveSessionRule when user approves for session but no sessionApproval on descriptor", async () => {
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.approveSessionRule).not.toHaveBeenCalled();
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(makeTcc({ agentName: "my-agent" }), check);
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!).toHaveProperty("surface", "bash");
122
- expect(desc.sessionApproval!).toHaveProperty("pattern");
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
- approveSessionRule: vi.fn(),
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
- approveSessionRule: vi.fn(),
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
- approveSessionRule: vi.fn(),
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
- approveSessionRule: vi.fn(),
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 { 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
 
@@ -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 approveSessionRule to internal SessionRules", () => {
223
+ it("delegates recordSessionApproval to internal SessionRules", () => {
223
224
  const { session } = createSession();
224
- session.approveSessionRule("bash", "/usr/bin/*");
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.approveSessionRule("bash", "*");
331
+ session.recordSessionApproval(SessionApproval.single("bash", "*"));
329
332
  expect(session.getSessionRuleset()).toHaveLength(1);
330
333
 
331
334
  session.shutdown();