@gotgenes/pi-permission-system 10.0.0 → 10.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  8. package/src/forwarding-manager.ts +3 -7
  9. package/src/gate-handler-session.ts +13 -0
  10. package/src/gate-prompter.ts +14 -0
  11. package/src/handlers/before-agent-start.ts +2 -3
  12. package/src/handlers/gates/bash-command.ts +4 -18
  13. package/src/handlers/gates/bash-external-directory.ts +3 -15
  14. package/src/handlers/gates/bash-path.ts +3 -16
  15. package/src/handlers/gates/descriptor.ts +0 -28
  16. package/src/handlers/gates/path.ts +3 -15
  17. package/src/handlers/gates/runner.ts +142 -105
  18. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  19. package/src/handlers/gates/skill-input.ts +44 -0
  20. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  21. package/src/handlers/lifecycle.ts +9 -9
  22. package/src/handlers/permission-gate-handler.ts +34 -238
  23. package/src/index.ts +49 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-prompter.ts +7 -58
  26. package/src/permission-resolver.ts +17 -0
  27. package/src/permission-session.ts +77 -9
  28. package/src/permissions-service.ts +53 -0
  29. package/src/service-lifecycle.ts +49 -0
  30. package/src/session-approval-recorder.ts +6 -0
  31. package/src/session-lifecycle-session.ts +24 -0
  32. package/src/tool-input-preview.ts +0 -62
  33. package/src/tool-input-prompt-formatters.ts +63 -0
  34. package/src/tool-preview-formatter.ts +6 -4
  35. package/test/decision-reporter.test.ts +112 -0
  36. package/test/denial-messages.test.ts +62 -0
  37. package/test/forwarding-manager.test.ts +26 -44
  38. package/test/handlers/before-agent-start.test.ts +45 -21
  39. package/test/handlers/external-directory-integration.test.ts +86 -22
  40. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  41. package/test/handlers/gates/bash-command.test.ts +49 -90
  42. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  43. package/test/handlers/gates/bash-path.test.ts +63 -148
  44. package/test/handlers/gates/path.test.ts +38 -105
  45. package/test/handlers/gates/runner.test.ts +150 -93
  46. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  47. package/test/handlers/gates/skill-input.test.ts +128 -0
  48. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  49. package/test/handlers/input.test.ts +1 -2
  50. package/test/handlers/lifecycle.test.ts +49 -33
  51. package/test/handlers/tool-call-events.test.ts +1 -1
  52. package/test/helpers/gate-fixtures.ts +147 -16
  53. package/test/helpers/handler-fixtures.ts +143 -27
  54. package/test/mcp-targets.test.ts +55 -0
  55. package/test/permission-forwarder.test.ts +295 -0
  56. package/test/permission-forwarding.test.ts +0 -282
  57. package/test/permission-prompter.test.ts +33 -44
  58. package/test/permission-session.test.ts +160 -27
  59. package/test/permissions-service.test.ts +151 -0
  60. package/test/runtime.test.ts +0 -4
  61. package/test/service-lifecycle.test.ts +162 -0
  62. package/test/tool-input-preview.test.ts +0 -111
  63. package/test/tool-input-prompt-formatters.test.ts +115 -0
  64. package/src/forwarded-permissions/polling.ts +0 -411
@@ -8,18 +8,25 @@
8
8
  * Regression guard: importing the four external-directory message helpers
9
9
  * ensures the test file fails to load if any helper is removed.
10
10
  */
11
+
12
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
13
  import { describe, expect, it, vi } from "vitest";
12
14
 
15
+ import { GateDecisionReporter } from "#src/decision-reporter";
13
16
  import { EXTENSION_TAG } from "#src/denial-messages";
14
17
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
15
18
  import { formatExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
19
+ import { GateRunner } from "#src/handlers/gates/runner";
20
+ import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
21
+ import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
16
22
  import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
17
- import type { PermissionSession } from "#src/permission-session";
23
+ import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
18
24
  import type { ToolRegistry } from "#src/tool-registry";
19
25
  import type { PermissionCheckResult, PermissionState } from "#src/types";
20
26
 
21
27
  import {
22
28
  getDecisionEvents,
29
+ type MockGateHandlerSession,
23
30
  makeCtx,
24
31
  makeEvents,
25
32
  makeToolCallEvent,
@@ -73,24 +80,71 @@ function makeCheckPermission(
73
80
  }
74
81
 
75
82
  function makeSession(
76
- overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
77
- ): PermissionSession {
78
- return {
79
- logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
80
- activate: vi.fn(),
81
- resolveAgentName: vi.fn().mockReturnValue(null),
82
- checkPermission: makeCheckPermission("deny"),
83
- getToolPermission: vi.fn().mockReturnValue("allow"),
84
- getSessionRuleset: vi.fn().mockReturnValue([]),
85
- recordSessionApproval: vi.fn(),
86
- getActiveSkillEntries: vi.fn().mockReturnValue([]),
87
- getInfrastructureDirs: vi.fn().mockReturnValue([]),
88
- getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
89
- config: DEFAULT_EXTENSION_CONFIG,
90
- canPrompt: vi.fn().mockReturnValue(true),
91
- prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
92
- ...overrides,
93
- } as unknown as PermissionSession;
83
+ overrides: Partial<MockGateHandlerSession> = {},
84
+ ): MockGateHandlerSession {
85
+ const session: MockGateHandlerSession = {
86
+ logger: overrides.logger ?? {
87
+ debug: vi.fn(),
88
+ review: vi.fn(),
89
+ warn: vi.fn(),
90
+ },
91
+ activate: overrides.activate ?? vi.fn<MockGateHandlerSession["activate"]>(),
92
+ resolveAgentName:
93
+ overrides.resolveAgentName ??
94
+ vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
95
+ checkPermission: overrides.checkPermission ?? makeCheckPermission("deny"),
96
+ getSessionRuleset:
97
+ overrides.getSessionRuleset ??
98
+ vi.fn<MockGateHandlerSession["getSessionRuleset"]>().mockReturnValue([]),
99
+ recordSessionApproval:
100
+ overrides.recordSessionApproval ??
101
+ vi.fn<MockGateHandlerSession["recordSessionApproval"]>(),
102
+ getActiveSkillEntries:
103
+ overrides.getActiveSkillEntries ??
104
+ vi
105
+ .fn<MockGateHandlerSession["getActiveSkillEntries"]>()
106
+ .mockReturnValue([]),
107
+ getInfrastructureReadDirs:
108
+ overrides.getInfrastructureReadDirs ??
109
+ vi
110
+ .fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
111
+ .mockReturnValue([]),
112
+ getToolPreviewLimits:
113
+ overrides.getToolPreviewLimits ??
114
+ vi
115
+ .fn<MockGateHandlerSession["getToolPreviewLimits"]>()
116
+ .mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
117
+ canPrompt:
118
+ overrides.canPrompt ??
119
+ vi.fn<MockGateHandlerSession["canPrompt"]>().mockReturnValue(true),
120
+ prompt:
121
+ overrides.prompt ??
122
+ vi
123
+ .fn<MockGateHandlerSession["prompt"]>()
124
+ .mockResolvedValue({ approved: true, state: "approved" }),
125
+ // Delegations — closures read `session` at call time so overrides win.
126
+ resolve:
127
+ overrides.resolve ??
128
+ vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
129
+ session.checkPermission(
130
+ surface,
131
+ input,
132
+ agentName,
133
+ session.getSessionRuleset(),
134
+ ),
135
+ ),
136
+ canConfirm:
137
+ overrides.canConfirm ??
138
+ vi.fn<MockGateHandlerSession["canConfirm"]>(() =>
139
+ session.canPrompt(undefined as unknown as ExtensionContext),
140
+ ),
141
+ promptPermission:
142
+ overrides.promptPermission ??
143
+ vi.fn<MockGateHandlerSession["promptPermission"]>((details) =>
144
+ session.prompt(undefined as unknown as ExtensionContext, details),
145
+ ),
146
+ };
147
+ return session;
94
148
  }
95
149
 
96
150
  /** All PATH_BEARING_TOOLS members. */
@@ -112,17 +166,27 @@ function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
112
166
  }
113
167
 
114
168
  function makeHandler(overrides?: {
115
- session?: Partial<Record<keyof PermissionSession, unknown>>;
169
+ session?: Partial<MockGateHandlerSession>;
116
170
  toolRegistry?: Partial<ToolRegistry>;
117
171
  }): {
118
172
  handler: PermissionGateHandler;
119
173
  events: ReturnType<typeof makeEvents>;
120
- session: PermissionSession;
174
+ session: MockGateHandlerSession;
121
175
  } {
122
176
  const session = makeSession(overrides?.session);
123
177
  const events = makeEvents();
124
178
  const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
125
- const handler = new PermissionGateHandler(session, events, toolRegistry);
179
+ const pipeline = new ToolCallGatePipeline(session);
180
+ const skillInputPipeline = new SkillInputGatePipeline(session);
181
+ const reporter = new GateDecisionReporter(session.logger, events);
182
+ const runner = new GateRunner(session, session, session, reporter);
183
+ const handler = new PermissionGateHandler(
184
+ session,
185
+ toolRegistry,
186
+ pipeline,
187
+ skillInputPipeline,
188
+ runner,
189
+ );
126
190
  return { handler, events, session };
127
191
  }
128
192
 
@@ -8,18 +8,29 @@
8
8
  * the real interaction between PermissionSession, SessionRules, and
9
9
  * PermissionManager.
10
10
  */
11
+
12
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
13
  import { describe, expect, it, vi } from "vitest";
12
14
 
15
+ import { GateDecisionReporter } from "#src/decision-reporter";
13
16
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
17
+ import { GateRunner } from "#src/handlers/gates/runner";
18
+ import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
19
+ import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
14
20
  import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
15
- import type { PermissionSession } from "#src/permission-session";
21
+ import type { PromptPermissionDetails } from "#src/permission-prompter";
16
22
  import type { Rule } from "#src/rule";
17
23
  import type { SessionApproval } from "#src/session-approval";
24
+ import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
18
25
  import type { ToolRegistry } from "#src/tool-registry";
19
26
  import type { PermissionCheckResult } from "#src/types";
20
27
  import { wildcardMatch } from "#src/wildcard-matcher";
21
28
 
22
- import { makeCtx, makeEvents } from "#test/helpers/handler-fixtures";
29
+ import {
30
+ type MockGateHandlerSession,
31
+ makeCtx,
32
+ makeEvents,
33
+ } from "#test/helpers/handler-fixtures";
23
34
 
24
35
  // ── SDK stub ───────────────────────────────────────────────────────────────
25
36
  vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
@@ -39,12 +50,12 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
39
50
  * "allow" by default.
40
51
  */
41
52
  function makeStatefulSession(
42
- overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
43
- ): PermissionSession {
53
+ overrides: Partial<MockGateHandlerSession> = {},
54
+ ): MockGateHandlerSession {
44
55
  const sessionRules: Rule[] = [];
45
56
 
46
57
  const checkPermission = vi
47
- .fn()
58
+ .fn<MockGateHandlerSession["checkPermission"]>()
48
59
  .mockImplementation(
49
60
  (
50
61
  surface: string,
@@ -97,7 +108,7 @@ function makeStatefulSession(
97
108
  );
98
109
 
99
110
  const recordSessionApproval = vi
100
- .fn()
111
+ .fn<MockGateHandlerSession["recordSessionApproval"]>()
101
112
  .mockImplementation((approval: SessionApproval) => {
102
113
  for (const pattern of approval.patterns) {
103
114
  sessionRules.push({
@@ -110,26 +121,86 @@ function makeStatefulSession(
110
121
  }
111
122
  });
112
123
 
113
- const getSessionRuleset = vi.fn().mockImplementation(() => [...sessionRules]);
124
+ const getSessionRuleset = vi
125
+ .fn<MockGateHandlerSession["getSessionRuleset"]>()
126
+ .mockImplementation(() => [...sessionRules]);
127
+
128
+ const session: MockGateHandlerSession = {
129
+ logger: overrides.logger ?? {
130
+ debug: vi.fn(),
131
+ review: vi.fn(),
132
+ warn: vi.fn(),
133
+ },
134
+ activate: overrides.activate ?? vi.fn<MockGateHandlerSession["activate"]>(),
135
+ resolveAgentName:
136
+ overrides.resolveAgentName ??
137
+ vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
138
+ checkPermission: overrides.checkPermission ?? checkPermission,
139
+ getSessionRuleset: overrides.getSessionRuleset ?? getSessionRuleset,
140
+ recordSessionApproval:
141
+ overrides.recordSessionApproval ?? recordSessionApproval,
142
+ getActiveSkillEntries:
143
+ overrides.getActiveSkillEntries ??
144
+ vi
145
+ .fn<MockGateHandlerSession["getActiveSkillEntries"]>()
146
+ .mockReturnValue([]),
147
+ getInfrastructureReadDirs:
148
+ overrides.getInfrastructureReadDirs ??
149
+ vi
150
+ .fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
151
+ .mockReturnValue([]),
152
+ getToolPreviewLimits:
153
+ overrides.getToolPreviewLimits ??
154
+ vi
155
+ .fn<MockGateHandlerSession["getToolPreviewLimits"]>()
156
+ .mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
157
+ canPrompt:
158
+ overrides.canPrompt ??
159
+ vi.fn<MockGateHandlerSession["canPrompt"]>().mockReturnValue(true),
160
+ prompt:
161
+ overrides.prompt ??
162
+ vi
163
+ .fn<MockGateHandlerSession["prompt"]>()
164
+ .mockResolvedValue({ approved: true, state: "approved_for_session" }),
165
+ // Delegations — closures read `session` at call time so overrides win.
166
+ resolve:
167
+ overrides.resolve ??
168
+ vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
169
+ session.checkPermission(
170
+ surface,
171
+ input,
172
+ agentName,
173
+ session.getSessionRuleset(),
174
+ ),
175
+ ),
176
+ canConfirm:
177
+ overrides.canConfirm ??
178
+ vi.fn<MockGateHandlerSession["canConfirm"]>(() =>
179
+ session.canPrompt(undefined as unknown as ExtensionContext),
180
+ ),
181
+ promptPermission:
182
+ overrides.promptPermission ??
183
+ vi.fn<MockGateHandlerSession["promptPermission"]>(
184
+ (details: PromptPermissionDetails) =>
185
+ session.prompt(undefined as unknown as ExtensionContext, details),
186
+ ),
187
+ };
188
+ return session;
189
+ }
114
190
 
115
- return {
116
- logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
117
- activate: vi.fn(),
118
- resolveAgentName: vi.fn().mockReturnValue(null),
119
- checkPermission,
120
- getToolPermission: vi.fn().mockReturnValue("allow"),
121
- getSessionRuleset,
122
- recordSessionApproval,
123
- getActiveSkillEntries: vi.fn().mockReturnValue([]),
124
- getInfrastructureDirs: vi.fn().mockReturnValue([]),
125
- getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
126
- config: DEFAULT_EXTENSION_CONFIG,
127
- canPrompt: vi.fn().mockReturnValue(true),
128
- prompt: vi
129
- .fn()
130
- .mockResolvedValue({ approved: true, state: "approved_for_session" }),
131
- ...overrides,
132
- } as unknown as PermissionSession;
191
+ function makeHandlerForSession(
192
+ session: MockGateHandlerSession,
193
+ ): PermissionGateHandler {
194
+ const events = makeEvents();
195
+ const reporter = new GateDecisionReporter(session.logger, events);
196
+ const runner = new GateRunner(session, session, session, reporter);
197
+ return new PermissionGateHandler(
198
+ session,
199
+ makeToolRegistry(),
200
+ new ToolCallGatePipeline(session),
201
+ new SkillInputGatePipeline(session),
202
+ runner,
203
+ );
133
204
  }
134
205
 
135
206
  function makeToolRegistry(): ToolRegistry {
@@ -152,11 +223,7 @@ describe("external-directory session dedup", () => {
152
223
  describe("path-bearing tools (read, write, edit)", () => {
153
224
  it("does not re-prompt for the same external path after session approval", async () => {
154
225
  const session = makeStatefulSession();
155
- const handler = new PermissionGateHandler(
156
- session,
157
- makeEvents(),
158
- makeToolRegistry(),
159
- );
226
+ const handler = makeHandlerForSession(session);
160
227
  const ctx = makeCtx();
161
228
  const externalPath = "/outside/project/data.txt";
162
229
 
@@ -185,11 +252,7 @@ describe("external-directory session dedup", () => {
185
252
 
186
253
  it("does not re-prompt for a different file in the same external directory", async () => {
187
254
  const session = makeStatefulSession();
188
- const handler = new PermissionGateHandler(
189
- session,
190
- makeEvents(),
191
- makeToolRegistry(),
192
- );
255
+ const handler = makeHandlerForSession(session);
193
256
  const ctx = makeCtx();
194
257
 
195
258
  // First call — prompt for /outside/project/a.txt
@@ -215,11 +278,7 @@ describe("external-directory session dedup", () => {
215
278
 
216
279
  it("does prompt for a file in a different external directory", async () => {
217
280
  const session = makeStatefulSession();
218
- const handler = new PermissionGateHandler(
219
- session,
220
- makeEvents(),
221
- makeToolRegistry(),
222
- );
281
+ const handler = makeHandlerForSession(session);
223
282
  const ctx = makeCtx();
224
283
 
225
284
  // First call — /outside/alpha/file.txt
@@ -249,11 +308,7 @@ describe("external-directory session dedup", () => {
249
308
  .fn()
250
309
  .mockResolvedValue({ approved: true, state: "approved" }),
251
310
  });
252
- const handler = new PermissionGateHandler(
253
- session,
254
- makeEvents(),
255
- makeToolRegistry(),
256
- );
311
+ const handler = makeHandlerForSession(session);
257
312
  const ctx = makeCtx();
258
313
  const externalPath = "/outside/project/data.txt";
259
314
 
@@ -282,11 +337,7 @@ describe("external-directory session dedup", () => {
282
337
  describe("bash commands with external paths", () => {
283
338
  it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
284
339
  const session = makeStatefulSession();
285
- const handler = new PermissionGateHandler(
286
- session,
287
- makeEvents(),
288
- makeToolRegistry(),
289
- );
340
+ const handler = makeHandlerForSession(session);
290
341
  const ctx = makeCtx();
291
342
 
292
343
  // First call — bash referencing /tmp/out.txt
@@ -314,11 +365,7 @@ describe("external-directory session dedup", () => {
314
365
 
315
366
  it("does not re-prompt for read after bash already approved the same directory", async () => {
316
367
  const session = makeStatefulSession();
317
- const handler = new PermissionGateHandler(
318
- session,
319
- makeEvents(),
320
- makeToolRegistry(),
321
- );
368
+ const handler = makeHandlerForSession(session);
322
369
  const ctx = makeCtx();
323
370
 
324
371
  // First call — bash writes to /tmp/out.txt
@@ -1,18 +1,11 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
4
- import type { Rule } from "#src/rule";
5
4
  import type { PermissionCheckResult } from "#src/types";
6
5
 
6
+ import { makeResolver } from "#test/helpers/gate-fixtures";
7
7
  import { makeCheckResult } from "#test/helpers/handler-fixtures";
8
8
 
9
- type CheckPermissionFn = (
10
- surface: string,
11
- input: unknown,
12
- agentName?: string,
13
- sessionRules?: Rule[],
14
- ) => PermissionCheckResult;
15
-
16
9
  /** Build a bash-surface check result for a single command unit. */
17
10
  function bashResult(
18
11
  state: PermissionCheckResult["state"],
@@ -24,44 +17,40 @@ function bashResult(
24
17
 
25
18
  describe("resolveBashCommandCheck", () => {
26
19
  it("passes a single command straight through", () => {
27
- const checkPermission = vi
28
- .fn<CheckPermissionFn>()
29
- .mockReturnValue(bashResult("allow", "npm install pkg", "npm *"));
20
+ const resolver = makeResolver(
21
+ bashResult("allow", "npm install pkg", "npm *"),
22
+ );
30
23
 
31
24
  const result = resolveBashCommandCheck(
32
25
  "npm install pkg",
33
26
  [{ text: "npm install pkg" }],
34
27
  undefined,
35
- [],
36
- checkPermission,
28
+ resolver,
37
29
  );
38
30
 
39
31
  expect(result.state).toBe("allow");
40
- expect(checkPermission).toHaveBeenCalledTimes(1);
41
- expect(checkPermission).toHaveBeenCalledWith(
32
+ expect(resolver.resolve).toHaveBeenCalledTimes(1);
33
+ expect(resolver.resolve).toHaveBeenCalledWith(
42
34
  "bash",
43
35
  { command: "npm install pkg" },
44
36
  undefined,
45
- [],
46
37
  );
47
38
  });
48
39
 
49
40
  it("denies the chain when any sub-command is denied, reporting that command's pattern", () => {
50
- const checkPermission = vi
51
- .fn<CheckPermissionFn>()
52
- .mockImplementation((_surface, input) => {
53
- const command = (input as { command: string }).command;
54
- return command.startsWith("npm")
55
- ? bashResult("deny", command, "npm *")
56
- : bashResult("allow", command, "cd *");
57
- });
41
+ const resolver = makeResolver();
42
+ resolver.resolve.mockImplementation((_surface, input) => {
43
+ const command = (input as { command: string }).command;
44
+ return command.startsWith("npm")
45
+ ? bashResult("deny", command, "npm *")
46
+ : bashResult("allow", command, "cd *");
47
+ });
58
48
 
59
49
  const result = resolveBashCommandCheck(
60
50
  "cd /p && npm install pkg",
61
51
  [{ text: "cd /p" }, { text: "npm install pkg" }],
62
52
  undefined,
63
- [],
64
- checkPermission,
53
+ resolver,
65
54
  );
66
55
 
67
56
  expect(result.state).toBe("deny");
@@ -70,21 +59,19 @@ describe("resolveBashCommandCheck", () => {
70
59
  });
71
60
 
72
61
  it("asks when a sub-command asks and none denies", () => {
73
- const checkPermission = vi
74
- .fn<CheckPermissionFn>()
75
- .mockImplementation((_surface, input) => {
76
- const command = (input as { command: string }).command;
77
- return command.startsWith("git")
78
- ? bashResult("ask", command, "git *")
79
- : bashResult("allow", command, "cd *");
80
- });
62
+ const resolver = makeResolver();
63
+ resolver.resolve.mockImplementation((_surface, input) => {
64
+ const command = (input as { command: string }).command;
65
+ return command.startsWith("git")
66
+ ? bashResult("ask", command, "git *")
67
+ : bashResult("allow", command, "cd *");
68
+ });
81
69
 
82
70
  const result = resolveBashCommandCheck(
83
71
  "cd /p && git push",
84
72
  [{ text: "cd /p" }, { text: "git push" }],
85
73
  undefined,
86
- [],
87
- checkPermission,
74
+ resolver,
88
75
  );
89
76
 
90
77
  expect(result.state).toBe("ask");
@@ -93,19 +80,17 @@ describe("resolveBashCommandCheck", () => {
93
80
  });
94
81
 
95
82
  it("returns the first allow result when every sub-command is allowed", () => {
96
- const checkPermission = vi
97
- .fn<CheckPermissionFn>()
98
- .mockImplementation((_surface, input) => {
99
- const command = (input as { command: string }).command;
100
- return bashResult("allow", command, `${command} *`);
101
- });
83
+ const resolver = makeResolver();
84
+ resolver.resolve.mockImplementation((_surface, input) => {
85
+ const command = (input as { command: string }).command;
86
+ return bashResult("allow", command, `${command} *`);
87
+ });
102
88
 
103
89
  const result = resolveBashCommandCheck(
104
90
  "a && b",
105
91
  [{ text: "a" }, { text: "b" }],
106
92
  undefined,
107
- [],
108
- checkPermission,
93
+ resolver,
109
94
  );
110
95
 
111
96
  expect(result.state).toBe("allow");
@@ -113,62 +98,40 @@ describe("resolveBashCommandCheck", () => {
113
98
  });
114
99
 
115
100
  it("falls back to the whole command when no top-level commands are found", () => {
116
- const checkPermission = vi
117
- .fn<CheckPermissionFn>()
118
- .mockReturnValue(bashResult("ask", "( rm x )", "*"));
101
+ const resolver = makeResolver(bashResult("ask", "( rm x )", "*"));
119
102
 
120
- const result = resolveBashCommandCheck(
121
- "( rm x )",
122
- [],
123
- undefined,
124
- [],
125
- checkPermission,
126
- );
103
+ const result = resolveBashCommandCheck("( rm x )", [], undefined, resolver);
127
104
 
128
105
  expect(result.state).toBe("ask");
129
106
  expect(result.commandContext).toBeUndefined();
130
- expect(checkPermission).toHaveBeenCalledTimes(1);
131
- expect(checkPermission).toHaveBeenCalledWith(
107
+ expect(resolver.resolve).toHaveBeenCalledTimes(1);
108
+ expect(resolver.resolve).toHaveBeenCalledWith(
132
109
  "bash",
133
110
  { command: "( rm x )" },
134
111
  undefined,
135
- [],
136
112
  );
137
113
  });
138
114
 
139
- it("forwards the agent name and session rules to each sub-command check", () => {
140
- const sessionRules: Rule[] = [
141
- { surface: "bash", pattern: "npm *", action: "allow", origin: "session" },
142
- ];
143
- const checkPermission = vi
144
- .fn<CheckPermissionFn>()
145
- .mockReturnValue(bashResult("allow", "npm i"));
146
-
147
- resolveBashCommandCheck(
148
- "npm i",
149
- [{ text: "npm i" }],
150
- "agent-x",
151
- sessionRules,
152
- checkPermission,
153
- );
115
+ it("forwards the agent name to each sub-command check", () => {
116
+ const resolver = makeResolver(bashResult("allow", "npm i"));
117
+
118
+ resolveBashCommandCheck("npm i", [{ text: "npm i" }], "agent-x", resolver);
154
119
 
155
- expect(checkPermission).toHaveBeenCalledWith(
120
+ expect(resolver.resolve).toHaveBeenCalledWith(
156
121
  "bash",
157
122
  { command: "npm i" },
158
123
  "agent-x",
159
- sessionRules,
160
124
  );
161
125
  });
162
126
 
163
127
  it("tags the winning result with the offending command's execution context", () => {
164
- const checkPermission = vi
165
- .fn<CheckPermissionFn>()
166
- .mockImplementation((_surface, input) => {
167
- const command = (input as { command: string }).command;
168
- return command.startsWith("rm")
169
- ? bashResult("deny", command, "rm *")
170
- : bashResult("allow", command, "echo *");
171
- });
128
+ const resolver = makeResolver();
129
+ resolver.resolve.mockImplementation((_surface, input) => {
130
+ const command = (input as { command: string }).command;
131
+ return command.startsWith("rm")
132
+ ? bashResult("deny", command, "rm *")
133
+ : bashResult("allow", command, "echo *");
134
+ });
172
135
 
173
136
  const result = resolveBashCommandCheck(
174
137
  "echo $(rm -rf foo)",
@@ -177,8 +140,7 @@ describe("resolveBashCommandCheck", () => {
177
140
  { text: "rm -rf foo", context: "command_substitution" },
178
141
  ],
179
142
  undefined,
180
- [],
181
- checkPermission,
143
+ resolver,
182
144
  );
183
145
 
184
146
  expect(result.state).toBe("deny");
@@ -187,16 +149,13 @@ describe("resolveBashCommandCheck", () => {
187
149
  });
188
150
 
189
151
  it("leaves commandContext unset when the winning command is top-level", () => {
190
- const checkPermission = vi
191
- .fn<CheckPermissionFn>()
192
- .mockReturnValue(bashResult("deny", "rm -rf foo", "rm *"));
152
+ const resolver = makeResolver(bashResult("deny", "rm -rf foo", "rm *"));
193
153
 
194
154
  const result = resolveBashCommandCheck(
195
155
  "rm -rf foo",
196
156
  [{ text: "rm -rf foo" }],
197
157
  undefined,
198
- [],
199
- checkPermission,
158
+ resolver,
200
159
  );
201
160
 
202
161
  expect(result.state).toBe("deny");