@gotgenes/pi-permission-system 10.5.0 → 10.5.2

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 (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +1 -1
  3. package/src/handlers/before-agent-start.ts +11 -6
  4. package/src/handlers/lifecycle.ts +7 -4
  5. package/src/handlers/permission-gate-handler.ts +3 -3
  6. package/src/index.ts +8 -3
  7. package/src/input-normalizer.ts +20 -8
  8. package/src/path-utils.ts +1 -10
  9. package/src/permission-resolver.ts +0 -3
  10. package/src/permission-session.ts +8 -52
  11. package/src/session-rules.ts +3 -2
  12. package/src/skill-prompt-sanitizer.ts +1 -1
  13. package/test/before-agent-start-cache.test.ts +89 -0
  14. package/test/handlers/before-agent-start.test.ts +56 -86
  15. package/test/handlers/external-directory-session-dedup.test.ts +175 -159
  16. package/test/handlers/gates/bash-path.test.ts +57 -0
  17. package/test/handlers/gates/path.test.ts +58 -0
  18. package/test/handlers/input.test.ts +5 -4
  19. package/test/handlers/lifecycle.test.ts +79 -85
  20. package/test/handlers/tool-call.test.ts +106 -2
  21. package/test/helpers/handler-fixtures.ts +99 -102
  22. package/test/helpers/manager-harness.ts +61 -0
  23. package/test/helpers/session-fixtures.ts +192 -0
  24. package/test/input-normalizer.test.ts +77 -1
  25. package/test/logging.test.ts +51 -0
  26. package/test/path-utils.test.ts +10 -0
  27. package/test/permission-forwarding.test.ts +73 -0
  28. package/test/permission-manager-unified.test.ts +1577 -3
  29. package/test/permission-resolver.test.ts +3 -1
  30. package/test/permission-session.test.ts +14 -198
  31. package/test/session-rules.test.ts +13 -5
  32. package/test/skill-prompt-sanitizer.test.ts +130 -0
  33. package/test/status.test.ts +10 -0
  34. package/test/system-prompt-sanitizer.test.ts +68 -0
  35. package/test/tool-registry.test.ts +42 -0
  36. package/test/yolo-mode.test.ts +78 -0
  37. package/src/agent-prep-session.ts +0 -28
  38. package/src/gate-handler-session.ts +0 -13
  39. package/src/session-lifecycle-session.ts +0 -24
  40. package/test/permission-system.test.ts +0 -2785
@@ -216,3 +216,60 @@ describe("describeBashPathGate", () => {
216
216
  expect(desc.decision.value).toBe(".env");
217
217
  });
218
218
  });
219
+
220
+ // Home-relative path characterization (#350) ──────────────────────────────
221
+ //
222
+ // The parser extracts ~/... tokens from bash commands; the resolver receives
223
+ // the raw token and normalizeInput handles expansion. These tests verify the
224
+ // gate correctly dispatches ~/... tokens through the deny/ask path.
225
+
226
+ describe("describeBashPathGate — home-relative paths", () => {
227
+ it("extracts ~/... token and builds descriptor on deny", async () => {
228
+ // node:os is mocked: homedir() returns "/mock/home".
229
+ // cat ~/.ssh/config → token "~/.ssh/config" extracted.
230
+ const resolver = makePathDispatchResolver(
231
+ {
232
+ "~/.ssh/config": makeCheckResult({
233
+ state: "deny",
234
+ matchedPattern: "~/.ssh/*",
235
+ }),
236
+ },
237
+ makeCheckResult({ state: "allow" }),
238
+ );
239
+ const result = (await describeGate(
240
+ makeTcc({ input: { command: "cat ~/.ssh/config" } }),
241
+ resolver,
242
+ )) as GateDescriptor;
243
+
244
+ expect(isGateDescriptor(result)).toBe(true);
245
+ expect(result.preCheck?.state).toBe("deny");
246
+ expect(result.denialContext).toMatchObject({
247
+ kind: "bash_path",
248
+ command: "cat ~/.ssh/config",
249
+ pathValue: "~/.ssh/config",
250
+ });
251
+ });
252
+
253
+ it("extracts $HOME/... token and builds descriptor on deny", async () => {
254
+ const resolver = makePathDispatchResolver(
255
+ {
256
+ "$HOME/.ssh/config": makeCheckResult({
257
+ state: "deny",
258
+ matchedPattern: "$HOME/.ssh/*",
259
+ }),
260
+ },
261
+ makeCheckResult({ state: "allow" }),
262
+ );
263
+ const result = (await describeGate(
264
+ makeTcc({ input: { command: "cat $HOME/.ssh/config" } }),
265
+ resolver,
266
+ )) as GateDescriptor;
267
+
268
+ expect(isGateDescriptor(result)).toBe(true);
269
+ expect(result.preCheck?.state).toBe("deny");
270
+ expect(result.denialContext).toMatchObject({
271
+ kind: "bash_path",
272
+ pathValue: "$HOME/.ssh/config",
273
+ });
274
+ });
275
+ });
@@ -148,3 +148,61 @@ describe("describePathGate", () => {
148
148
  );
149
149
  });
150
150
  });
151
+
152
+ // Home-relative path characterization (#350) ──────────────────────────────
153
+ //
154
+ // The gate passes the raw path to the resolver; home expansion is handled
155
+ // downstream by normalizeInput. These tests lock in that the gate works
156
+ // correctly when the tool input contains a ~/... or $HOME/... path.
157
+
158
+ describe("describePathGate — home-relative paths", () => {
159
+ it("passes raw ~/... path to resolver and builds descriptor on deny", () => {
160
+ const resolver = makeResolver(
161
+ makeCheckResult({ state: "deny", matchedPattern: "~/.ssh/*" }),
162
+ );
163
+ const result = describePathGate(
164
+ makeTcc({ input: { path: "~/.ssh/config" } }),
165
+ resolver,
166
+ ) as GateDescriptor;
167
+
168
+ expect(isGateDescriptor(result)).toBe(true);
169
+ expect(result.preCheck?.state).toBe("deny");
170
+ // Raw path preserved in denial context for display.
171
+ expect(result.denialContext).toMatchObject({
172
+ kind: "path",
173
+ toolName: "read",
174
+ pathValue: "~/.ssh/config",
175
+ });
176
+ expect(resolver.resolve).toHaveBeenCalledWith(
177
+ "path",
178
+ { path: "~/.ssh/config" },
179
+ undefined,
180
+ );
181
+ });
182
+
183
+ it("passes raw $HOME/... path to resolver and builds descriptor on deny", () => {
184
+ const resolver = makeResolver(
185
+ makeCheckResult({ state: "deny", matchedPattern: "$HOME/.ssh/*" }),
186
+ );
187
+ const result = describePathGate(
188
+ makeTcc({ input: { path: "$HOME/.ssh/config" } }),
189
+ resolver,
190
+ ) as GateDescriptor;
191
+
192
+ expect(isGateDescriptor(result)).toBe(true);
193
+ expect(result.preCheck?.state).toBe("deny");
194
+ expect(result.denialContext).toMatchObject({
195
+ kind: "path",
196
+ pathValue: "$HOME/.ssh/config",
197
+ });
198
+ });
199
+
200
+ it("returns null when home-relative path resolves to allow", () => {
201
+ const resolver = makeResolver(makeCheckResult({ state: "allow" }));
202
+ const result = describePathGate(
203
+ makeTcc({ input: { path: "~/.ssh/config" } }),
204
+ resolver,
205
+ );
206
+ expect(result).toBeNull();
207
+ });
208
+ });
@@ -49,9 +49,10 @@ describe("extractSkillNameFromInput", () => {
49
49
  describe("handleInput", () => {
50
50
  it("activates session with ctx", async () => {
51
51
  const ctx = makeCtx();
52
- const { handler, session } = makeHandler();
52
+ const { handler, forwarding } = makeHandler();
53
53
  await handler.handleInput(makeInputEvent("hello"), ctx);
54
- expect(session.activate).toHaveBeenCalledWith(ctx);
54
+ // session.activate(ctx) calls forwarding.start(ctx) on the real session
55
+ expect(forwarding.start).toHaveBeenCalledWith(ctx);
55
56
  });
56
57
 
57
58
  it("returns continue for non-skill input", async () => {
@@ -64,9 +65,9 @@ describe("handleInput", () => {
64
65
  });
65
66
 
66
67
  it("does not check permissions for non-skill input", async () => {
67
- const { handler, session } = makeHandler();
68
+ const { handler, permissionManager } = makeHandler();
68
69
  await handler.handleInput(makeInputEvent("just a message"), makeCtx());
69
- expect(session.checkPermission).not.toHaveBeenCalled();
70
+ expect(permissionManager.checkPermission).not.toHaveBeenCalled();
70
71
  });
71
72
 
72
73
  it("returns continue when skill is allowed", async () => {
@@ -2,9 +2,12 @@ import { describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import { SessionLifecycleHandler } from "#src/handlers/lifecycle";
4
4
  import type { ServiceLifecycle } from "#src/service-lifecycle";
5
- import type { SessionLifecycleSession } from "#src/session-lifecycle-session";
6
5
 
7
6
  import { makeCtx } from "#test/helpers/handler-fixtures";
7
+ import {
8
+ makeRealResolver,
9
+ makeRealSession,
10
+ } from "#test/helpers/session-fixtures";
8
11
 
9
12
  // ── status stub ────────────────────────────────────────────────────────────
10
13
  vi.mock("../../src/status", () => ({
@@ -15,55 +18,40 @@ vi.mock("../../src/status", () => ({
15
18
 
16
19
  // ── helpers ────────────────────────────────────────────────────────────────
17
20
 
18
- function makeSession(
19
- overrides: Partial<SessionLifecycleSession> = {},
20
- ): SessionLifecycleSession {
21
- return {
22
- logger: overrides.logger ?? {
23
- debug: vi.fn<SessionLifecycleSession["logger"]["debug"]>(),
24
- review: vi.fn<SessionLifecycleSession["logger"]["review"]>(),
25
- warn: vi.fn<SessionLifecycleSession["logger"]["warn"]>(),
26
- },
27
- refreshConfig:
28
- overrides.refreshConfig ??
29
- vi.fn<SessionLifecycleSession["refreshConfig"]>(),
30
- resetForNewSession:
31
- overrides.resetForNewSession ??
32
- vi.fn<SessionLifecycleSession["resetForNewSession"]>(),
33
- logResolvedConfigPaths:
34
- overrides.logResolvedConfigPaths ??
35
- vi.fn<SessionLifecycleSession["logResolvedConfigPaths"]>(),
36
- resolveAgentName:
37
- overrides.resolveAgentName ??
38
- vi
39
- .fn<SessionLifecycleSession["resolveAgentName"]>()
40
- .mockReturnValue(null),
41
- getConfigIssues:
42
- overrides.getConfigIssues ??
43
- vi.fn<SessionLifecycleSession["getConfigIssues"]>().mockReturnValue([]),
44
- reload: overrides.reload ?? vi.fn<SessionLifecycleSession["reload"]>(),
45
- getRuntimeContext:
46
- overrides.getRuntimeContext ??
47
- vi
48
- .fn<SessionLifecycleSession["getRuntimeContext"]>()
49
- .mockReturnValue(null),
50
- shutdown:
51
- overrides.shutdown ?? vi.fn<SessionLifecycleSession["shutdown"]>(),
52
- };
53
- }
54
-
55
- function makeHandler(overrides?: Partial<SessionLifecycleSession>): {
56
- handler: SessionLifecycleHandler;
57
- session: SessionLifecycleSession;
58
- serviceLifecycle: ServiceLifecycle;
59
- } {
60
- const session = makeSession(overrides);
21
+ function makeSetup(opts?: { configIssues?: string[] }) {
22
+ const {
23
+ session,
24
+ permissionManager,
25
+ sessionRules,
26
+ logger,
27
+ forwarding,
28
+ configStore,
29
+ } = makeRealSession();
30
+ const { resolver } = makeRealResolver(permissionManager, sessionRules);
31
+ if (opts?.configIssues) {
32
+ vi.mocked(permissionManager.getConfigIssues).mockReturnValue(
33
+ opts.configIssues,
34
+ );
35
+ }
61
36
  const serviceLifecycle: ServiceLifecycle = {
62
37
  activate: vi.fn<ServiceLifecycle["activate"]>(),
63
38
  teardown: vi.fn<ServiceLifecycle["teardown"]>(),
64
39
  };
65
- const handler = new SessionLifecycleHandler(session, serviceLifecycle);
66
- return { handler, session, serviceLifecycle };
40
+ const handler = new SessionLifecycleHandler(
41
+ session,
42
+ resolver,
43
+ serviceLifecycle,
44
+ );
45
+ return {
46
+ handler,
47
+ session,
48
+ resolver,
49
+ permissionManager,
50
+ logger,
51
+ forwarding,
52
+ configStore,
53
+ serviceLifecycle,
54
+ };
67
55
  }
68
56
 
69
57
  // ── handleSessionStart ─────────────────────────────────────────────────────
@@ -71,51 +59,53 @@ function makeHandler(overrides?: Partial<SessionLifecycleSession>): {
71
59
  describe("handleSessionStart", () => {
72
60
  it("refreshes config with ctx", async () => {
73
61
  const ctx = makeCtx();
74
- const { handler, session } = makeHandler();
62
+ const { handler, configStore } = makeSetup();
75
63
  await handler.handleSessionStart({ reason: "startup" }, ctx);
76
- expect(session.refreshConfig).toHaveBeenCalledWith(ctx);
64
+ expect(configStore.refresh).toHaveBeenCalledWith(ctx);
77
65
  });
78
66
 
79
67
  it("calls resetForNewSession with ctx", async () => {
80
68
  const ctx = makeCtx();
81
- const { handler, session } = makeHandler();
69
+ const { handler, session } = makeSetup();
70
+ const spy = vi.spyOn(session, "resetForNewSession");
82
71
  await handler.handleSessionStart({ reason: "startup" }, ctx);
83
- expect(session.resetForNewSession).toHaveBeenCalledWith(ctx);
72
+ expect(spy).toHaveBeenCalledWith(ctx);
84
73
  });
85
74
 
86
75
  it("logs resolved config paths", async () => {
87
- const { handler, session } = makeHandler();
76
+ const { handler, configStore } = makeSetup();
88
77
  await handler.handleSessionStart({ reason: "startup" }, makeCtx());
89
- expect(session.logResolvedConfigPaths).toHaveBeenCalledOnce();
78
+ expect(configStore.logResolvedPaths).toHaveBeenCalledOnce();
90
79
  });
91
80
 
92
81
  it("resolves agent name from ctx", async () => {
93
82
  const ctx = makeCtx();
94
- const { handler, session } = makeHandler();
83
+ const { handler, session } = makeSetup();
84
+ const spy = vi.spyOn(session, "resolveAgentName");
95
85
  await handler.handleSessionStart({ reason: "startup" }, ctx);
96
- expect(session.resolveAgentName).toHaveBeenCalledWith(ctx);
86
+ expect(spy).toHaveBeenCalledWith(ctx);
97
87
  });
98
88
 
99
89
  it("notifies each policy issue", async () => {
100
- const { handler, session } = makeHandler({
101
- getConfigIssues: vi.fn().mockReturnValue(["issue A", "issue B"]),
90
+ const { handler, logger } = makeSetup({
91
+ configIssues: ["issue A", "issue B"],
102
92
  });
103
93
  await handler.handleSessionStart({ reason: "startup" }, makeCtx());
104
- expect(session.logger.warn).toHaveBeenCalledWith("issue A");
105
- expect(session.logger.warn).toHaveBeenCalledWith("issue B");
94
+ expect(logger.warn).toHaveBeenCalledWith("issue A");
95
+ expect(logger.warn).toHaveBeenCalledWith("issue B");
106
96
  });
107
97
 
108
98
  it("does not warn when there are no policy issues", async () => {
109
- const { handler, session } = makeHandler();
99
+ const { handler, logger } = makeSetup();
110
100
  await handler.handleSessionStart({ reason: "startup" }, makeCtx());
111
- expect(session.logger.warn).not.toHaveBeenCalled();
101
+ expect(logger.warn).not.toHaveBeenCalled();
112
102
  });
113
103
 
114
104
  it("writes lifecycle.reload debug log when reason is reload", async () => {
115
105
  const ctx = makeCtx({ cwd: "/proj" });
116
- const { handler, session } = makeHandler();
106
+ const { handler, logger } = makeSetup();
117
107
  await handler.handleSessionStart({ reason: "reload" }, ctx);
118
- expect(session.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
108
+ expect(logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
119
109
  triggeredBy: "session_start",
120
110
  reason: "reload",
121
111
  cwd: "/proj",
@@ -123,23 +113,26 @@ describe("handleSessionStart", () => {
123
113
  });
124
114
 
125
115
  it("does not write lifecycle.reload debug log for non-reload reasons", async () => {
126
- const { handler, session } = makeHandler();
116
+ const { handler, logger } = makeSetup();
127
117
  await handler.handleSessionStart({ reason: "startup" }, makeCtx());
128
- expect(session.logger.debug).not.toHaveBeenCalled();
118
+ expect(logger.debug).not.toHaveBeenCalled();
129
119
  });
130
120
 
131
121
  it("activates the service for the session with ctx", async () => {
132
122
  const ctx = makeCtx();
133
- const { handler, serviceLifecycle } = makeHandler();
123
+ const { handler, serviceLifecycle } = makeSetup();
134
124
  await handler.handleSessionStart({ reason: "startup" }, ctx);
135
125
  expect(serviceLifecycle.activate).toHaveBeenCalledWith(ctx);
136
126
  });
137
127
 
138
128
  it("calls refreshConfig before resetForNewSession", async () => {
139
129
  const callOrder: string[] = [];
140
- const { handler } = makeHandler({
141
- refreshConfig: vi.fn(() => callOrder.push("refreshConfig")),
142
- resetForNewSession: vi.fn(() => callOrder.push("resetForNewSession")),
130
+ const { handler, session, configStore } = makeSetup();
131
+ vi.spyOn(configStore, "refresh").mockImplementation(() => {
132
+ callOrder.push("refreshConfig");
133
+ });
134
+ vi.spyOn(session, "resetForNewSession").mockImplementation(() => {
135
+ callOrder.push("resetForNewSession");
143
136
  });
144
137
  await handler.handleSessionStart({ reason: "startup" }, makeCtx());
145
138
  expect(callOrder).toEqual(["refreshConfig", "resetForNewSession"]);
@@ -150,24 +143,25 @@ describe("handleSessionStart", () => {
150
143
 
151
144
  describe("handleResourcesDiscover", () => {
152
145
  it("does nothing when reason is not reload", async () => {
153
- const { handler, session } = makeHandler();
146
+ const { handler, session } = makeSetup();
147
+ const spy = vi.spyOn(session, "reload");
154
148
  await handler.handleResourcesDiscover({ reason: "startup" });
155
- expect(session.reload).not.toHaveBeenCalled();
149
+ expect(spy).not.toHaveBeenCalled();
156
150
  });
157
151
 
158
152
  it("calls reload on the session on reload", async () => {
159
- const { handler, session } = makeHandler();
153
+ const { handler, session } = makeSetup();
154
+ const spy = vi.spyOn(session, "reload");
160
155
  await handler.handleResourcesDiscover({ reason: "reload" });
161
- expect(session.reload).toHaveBeenCalledOnce();
156
+ expect(spy).toHaveBeenCalledOnce();
162
157
  });
163
158
 
164
159
  it("writes lifecycle.reload debug log on reload", async () => {
165
160
  const ctx = makeCtx({ cwd: "/proj" });
166
- const { handler, session } = makeHandler({
167
- getRuntimeContext: vi.fn().mockReturnValue(ctx),
168
- });
161
+ const { handler, session, logger } = makeSetup();
162
+ session.activate(ctx);
169
163
  await handler.handleResourcesDiscover({ reason: "reload" });
170
- expect(session.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
164
+ expect(logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
171
165
  triggeredBy: "resources_discover",
172
166
  reason: "reload",
173
167
  cwd: "/proj",
@@ -175,9 +169,9 @@ describe("handleResourcesDiscover", () => {
175
169
  });
176
170
 
177
171
  it("logs cwd as null when runtimeContext is null on reload", async () => {
178
- const { handler, session } = makeHandler();
172
+ const { handler, logger } = makeSetup();
179
173
  await handler.handleResourcesDiscover({ reason: "reload" });
180
- expect(session.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
174
+ expect(logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
181
175
  triggeredBy: "resources_discover",
182
176
  reason: "reload",
183
177
  cwd: null,
@@ -190,9 +184,8 @@ describe("handleResourcesDiscover", () => {
190
184
  describe("handleSessionShutdown", () => {
191
185
  it("clears UI status when runtime context is present", async () => {
192
186
  const ctx = makeCtx();
193
- const { handler } = makeHandler({
194
- getRuntimeContext: vi.fn().mockReturnValue(ctx),
195
- });
187
+ const { handler, session } = makeSetup();
188
+ session.activate(ctx);
196
189
  await handler.handleSessionShutdown();
197
190
  expect(ctx.ui.setStatus).toHaveBeenCalledWith(
198
191
  "permission-system",
@@ -201,18 +194,19 @@ describe("handleSessionShutdown", () => {
201
194
  });
202
195
 
203
196
  it("does not throw when runtime context is null", async () => {
204
- const { handler } = makeHandler();
197
+ const { handler } = makeSetup();
205
198
  await expect(handler.handleSessionShutdown()).resolves.not.toThrow();
206
199
  });
207
200
 
208
201
  it("calls shutdown on the session", async () => {
209
- const { handler, session } = makeHandler();
202
+ const { handler, session } = makeSetup();
203
+ const spy = vi.spyOn(session, "shutdown");
210
204
  await handler.handleSessionShutdown();
211
- expect(session.shutdown).toHaveBeenCalledOnce();
205
+ expect(spy).toHaveBeenCalledOnce();
212
206
  });
213
207
 
214
208
  it("calls serviceLifecycle.teardown", async () => {
215
- const { handler, serviceLifecycle } = makeHandler();
209
+ const { handler, serviceLifecycle } = makeSetup();
216
210
  await handler.handleSessionShutdown();
217
211
  expect(serviceLifecycle.teardown).toHaveBeenCalledOnce();
218
212
  });
@@ -49,9 +49,10 @@ describe("getEventInput", () => {
49
49
  describe("handleToolCall", () => {
50
50
  it("activates session with ctx", async () => {
51
51
  const ctx = makeCtx();
52
- const { handler, session } = makeHandler();
52
+ const { handler, forwarding } = makeHandler();
53
53
  await handler.handleToolCall(makeToolCallEvent("read"), ctx);
54
- expect(session.activate).toHaveBeenCalledWith(ctx);
54
+ // session.activate(ctx) calls forwarding.start(ctx) on the real session
55
+ expect(forwarding.start).toHaveBeenCalledWith(ctx);
55
56
  });
56
57
 
57
58
  it("blocks when tool name cannot be resolved", async () => {
@@ -289,3 +290,106 @@ describe("handleToolCall — bash command chain gate", () => {
289
290
  expect(result).toEqual({});
290
291
  });
291
292
  });
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Moved from permission-system.test.ts catch-all (#342)
296
+ // ---------------------------------------------------------------------------
297
+
298
+ describe("handleToolCall — bash external-directory policy states", () => {
299
+ it("allows bash command with only internal paths when external_directory is denied", async () => {
300
+ const { handler } = makeHandler({ tools: ["bash"] });
301
+ const event = makeToolCallEvent("bash", {
302
+ input: { command: "cat src/index.ts" },
303
+ });
304
+ const result = await handler.handleToolCall(event, makeCtx());
305
+ expect(result).toEqual({});
306
+ });
307
+
308
+ it("blocks bash command with external path when external_directory is ask and no UI", async () => {
309
+ const { handler } = makeHandler({
310
+ session: {
311
+ checkPermission: makeSurfaceCheck({
312
+ external_directory: { state: "ask", source: "special" },
313
+ }),
314
+ },
315
+ tools: ["bash"],
316
+ prompter: {
317
+ canConfirm: vi.fn().mockReturnValue(false),
318
+ prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
319
+ },
320
+ });
321
+ const event = makeToolCallEvent("bash", {
322
+ input: { command: "cat /etc/hosts" },
323
+ });
324
+ const result = await handler.handleToolCall(
325
+ event,
326
+ makeCtx({ hasUI: false }),
327
+ );
328
+ expect(result).toMatchObject({ block: true });
329
+ expect(String((result as { reason?: unknown }).reason)).toMatch(
330
+ /no interactive UI/i,
331
+ );
332
+ });
333
+
334
+ it("allows bash command with external path when external_directory is allow", async () => {
335
+ const { handler } = makeHandler({
336
+ session: {
337
+ checkPermission: makeSurfaceCheck({
338
+ external_directory: { state: "allow", source: "special" },
339
+ }),
340
+ },
341
+ tools: ["bash"],
342
+ });
343
+ const event = makeToolCallEvent("bash", {
344
+ input: { command: "cat /etc/hosts" },
345
+ });
346
+ const result = await handler.handleToolCall(event, makeCtx());
347
+ expect(result).toEqual({});
348
+ });
349
+
350
+ it("applies bash pattern deny after external_directory allow", async () => {
351
+ const { handler } = makeHandler({
352
+ session: {
353
+ checkPermission: makeSurfaceCheck(
354
+ {
355
+ external_directory: { state: "allow", source: "special" },
356
+ bash: { state: "deny", source: "bash" },
357
+ },
358
+ { state: "allow" },
359
+ ),
360
+ },
361
+ tools: ["bash"],
362
+ });
363
+ const event = makeToolCallEvent("bash", {
364
+ input: { command: "cat /etc/hosts" },
365
+ });
366
+ const result = await handler.handleToolCall(event, makeCtx());
367
+ expect(result).toMatchObject({ block: true });
368
+ });
369
+ });
370
+
371
+ describe("handleToolCall — generic ask prompt content", () => {
372
+ it("ask prompt includes serialized tool input for informed approval", async () => {
373
+ const { handler, prompter } = makeHandler({
374
+ session: {
375
+ checkPermission: makeSurfaceCheck({
376
+ weather_lookup: { state: "ask" },
377
+ }),
378
+ },
379
+ tools: ["weather_lookup"],
380
+ prompter: {
381
+ canConfirm: vi.fn().mockReturnValue(true),
382
+ prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
383
+ },
384
+ });
385
+ const event = makeToolCallEvent("weather_lookup", {
386
+ input: { city: "Chicago", units: "metric" },
387
+ });
388
+ await handler.handleToolCall(event, makeCtx());
389
+ expect(vi.mocked(prompter.prompt)).toHaveBeenCalledTimes(1);
390
+ const promptDetails = vi.mocked(prompter.prompt).mock.calls[0][0];
391
+ expect(promptDetails.message).toMatch(
392
+ /\{"city":"Chicago","units":"metric"\}/,
393
+ );
394
+ });
395
+ });