@gotgenes/pi-permission-system 16.2.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 (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/access-intent/bash/command-enumeration.ts +79 -2
  5. package/src/access-intent/bash/parser.ts +2 -0
  6. package/src/access-intent/bash/program.ts +3 -0
  7. package/src/builtin-tool-input-formatters.ts +1 -1
  8. package/src/config-loader.ts +7 -7
  9. package/src/forwarded-permissions/permission-forwarder.ts +1 -1
  10. package/src/handlers/gates/bash-command.ts +15 -1
  11. package/src/handlers/gates/bash-external-directory.ts +1 -1
  12. package/src/handlers/gates/bash-path.ts +1 -1
  13. package/src/handlers/gates/skill-read.ts +1 -1
  14. package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -1
  15. package/src/handlers/permission-gate-handler.ts +1 -2
  16. package/src/handlers/tool-call-boundary.ts +1 -2
  17. package/src/input-normalizer.ts +1 -1
  18. package/src/mcp-targets.ts +1 -1
  19. package/src/normalize.ts +1 -1
  20. package/src/path-utils.ts +1 -1
  21. package/src/permission-manager.ts +1 -1
  22. package/src/permission-prompts.ts +1 -1
  23. package/src/policy-loader.ts +2 -2
  24. package/src/tool-input-prompt-formatters.ts +1 -1
  25. package/src/tool-preview-formatter.ts +1 -1
  26. package/src/tool-registry.ts +1 -1
  27. package/src/{common.ts → value-guards.ts} +0 -66
  28. package/src/yaml-frontmatter.ts +65 -0
  29. package/test/access-intent/bash/node-text.test.ts +1 -0
  30. package/test/access-intent/bash/program.test.ts +67 -0
  31. package/test/handlers/external-directory-integration.test.ts +40 -153
  32. package/test/handlers/external-directory-session-dedup.test.ts +38 -262
  33. package/test/handlers/gates/bash-command.test.ts +63 -0
  34. package/test/handlers/gates/bash-external-directory.test.ts +1 -1
  35. package/test/handlers/gates/bash-path.test.ts +1 -1
  36. package/test/helpers/external-directory-fixtures.ts +269 -0
  37. package/test/{common.test.ts → value-guards.test.ts} +2 -96
  38. package/test/yaml-frontmatter.test.ts +91 -0
@@ -435,6 +435,73 @@ describe("BashProgram", () => {
435
435
  expect((await BashProgram.parse("", cwd)).commands()).toEqual([]);
436
436
  expect((await BashProgram.parse(" ", cwd)).commands()).toEqual([]);
437
437
  });
438
+
439
+ it("strips a leading env-var assignment prefix", async () => {
440
+ const program = await BashProgram.parse(
441
+ "AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1",
442
+ cwd,
443
+ );
444
+ expect(program.commands()).toEqual([
445
+ { text: "aws ec2 terminate-instances --instance-ids i-1" },
446
+ ]);
447
+ });
448
+
449
+ it("strips multiple leading env-var assignments", async () => {
450
+ const program = await BashProgram.parse("A=1 B=2 aws s3 ls", cwd);
451
+ expect(program.commands()).toEqual([{ text: "aws s3 ls" }]);
452
+ });
453
+
454
+ it("strips the env-var prefix of each command in a chain", async () => {
455
+ const program = await BashProgram.parse(
456
+ "X=1 aws sts get-caller-identity && ls",
457
+ cwd,
458
+ );
459
+ expect(program.commands()).toEqual([
460
+ { text: "aws sts get-caller-identity" },
461
+ { text: "ls" },
462
+ ]);
463
+ });
464
+
465
+ it("keeps a pure assignment with no command unchanged", async () => {
466
+ const program = await BashProgram.parse("FOO=bar", cwd);
467
+ expect(program.commands()).toEqual([{ text: "FOO=bar" }]);
468
+ });
469
+
470
+ describe("opaque-payload wrappers", () => {
471
+ it.each([
472
+ ['bash -c "rm -rf /"', 'bash -c "rm -rf /"'],
473
+ ['sh -c "rm -rf /"', 'sh -c "rm -rf /"'],
474
+ ['dash -c "rm -rf /"', 'dash -c "rm -rf /"'],
475
+ ['zsh -c "rm -rf /"', 'zsh -c "rm -rf /"'],
476
+ ['ksh -c "rm -rf /"', 'ksh -c "rm -rf /"'],
477
+ ['eval "rm -rf /"', 'eval "rm -rf /"'],
478
+ ['/bin/bash -c "rm -rf /"', '/bin/bash -c "rm -rf /"'],
479
+ ['bash -ec "rm -rf /"', 'bash -ec "rm -rf /"'],
480
+ ])("flags %s as opaque", async (command, text) => {
481
+ const program = await BashProgram.parse(command, cwd);
482
+ expect(program.commands()).toEqual([{ text, opaque: true }]);
483
+ });
484
+
485
+ it("flags an env-prefixed wrapper as opaque after stripping the prefix", async () => {
486
+ const program = await BashProgram.parse(
487
+ 'AWS_PROFILE=prod bash -c "rm -rf /"',
488
+ cwd,
489
+ );
490
+ expect(program.commands()).toEqual([
491
+ { text: 'bash -c "rm -rf /"', opaque: true },
492
+ ]);
493
+ });
494
+
495
+ it.each([
496
+ "bash script.sh",
497
+ "bash",
498
+ "ls -la",
499
+ "grep -c foo file",
500
+ ])("does not flag %s as opaque", async (command) => {
501
+ const program = await BashProgram.parse(command, cwd);
502
+ expect(program.commands()).toEqual([{ text: command }]);
503
+ });
504
+ });
438
505
  });
439
506
 
440
507
  it("derives both slices from a single parse", async () => {
@@ -12,15 +12,25 @@
12
12
  import { describe, expect, it, vi } from "vitest";
13
13
 
14
14
  import { EXTENSION_TAG } from "#src/denial-messages";
15
- import type { GatePrompter } from "#src/gate-prompter";
16
15
  import { formatExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
17
16
  import type { PermissionCheckResult } from "#src/types";
18
-
17
+ import {
18
+ ALL_PATH_BEARING_TOOLS,
19
+ ALL_TOOLS,
20
+ blockReviewEntries,
21
+ EXT_DIR_CWD,
22
+ EXTERNAL_PATH,
23
+ findExtDirDecision,
24
+ makeApprovingPrompter,
25
+ makeDenyingPrompter,
26
+ makeExtDirCheck,
27
+ makeUnavailablePrompter,
28
+ OPTIONAL_PATH_TOOLS,
29
+ } from "#test/helpers/external-directory-fixtures";
19
30
  import {
20
31
  getDecisionEvents,
21
32
  makeCtx,
22
33
  makeHandler,
23
- makeSurfaceCheck,
24
34
  makeToolCallEvent,
25
35
  } from "#test/helpers/handler-fixtures";
26
36
 
@@ -31,42 +41,6 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
31
41
  return { ...original };
32
42
  });
33
43
 
34
- // ── Constants ──────────────────────────────────────────────────────────────
35
-
36
- const CWD = "/test/project";
37
- const EXTERNAL_PATH = "/outside/project/file.ts";
38
-
39
- /** All PATH_BEARING_TOOLS members. */
40
- const ALL_PATH_BEARING_TOOLS = ["read", "write", "edit", "find", "grep", "ls"];
41
-
42
- /** Tools where path is optional. */
43
- const OPTIONAL_PATH_TOOLS = ["find", "grep", "ls"];
44
-
45
- /** Full tool set used as the default registry in ext-dir tests. */
46
- const ALL_TOOLS = [...ALL_PATH_BEARING_TOOLS, "bash"];
47
-
48
- // ── Helpers ────────────────────────────────────────────────────────────────
49
-
50
- /**
51
- * Builds a `checkPermission` mock for external-directory integration tests.
52
- *
53
- * Routes `external_directory` to `externalDirectoryState`, `path` to allow
54
- * with `source: "special"` (so the cross-cutting path gate is transparent),
55
- * and every other surface to `toolState` (default: allow).
56
- */
57
- function makeExtDirCheck(
58
- externalDirectoryState: "allow" | "deny" | "ask",
59
- toolState: "allow" | "deny" | "ask" = "allow",
60
- ) {
61
- return makeSurfaceCheck(
62
- {
63
- external_directory: { state: externalDirectoryState },
64
- path: { state: "allow", source: "special" },
65
- },
66
- { state: toolState },
67
- );
68
- }
69
-
70
44
  // ── Regression guard: helper presence ──────────────────────────────────────
71
45
 
72
46
  describe("external_directory helper regression guard", () => {
@@ -95,7 +69,7 @@ describe("external_directory path scope", () => {
95
69
  tools: ALL_TOOLS,
96
70
  });
97
71
  const event = makeToolCallEvent("read", {
98
- input: { path: `${CWD}/src/index.ts` },
72
+ input: { path: `${EXT_DIR_CWD}/src/index.ts` },
99
73
  });
100
74
  const result = await handler.handleToolCall(event, makeCtx());
101
75
  // Should not be blocked — the external_directory gate is skipped,
@@ -179,11 +153,7 @@ describe("external_directory policy state — allow", () => {
179
153
  });
180
154
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
181
155
  await handler.handleToolCall(event, makeCtx());
182
- const decisions = getDecisionEvents(events);
183
- const extDirDecision = decisions.find(
184
- (d) => d.surface === "external_directory",
185
- );
186
- expect(extDirDecision).toMatchObject({
156
+ expect(findExtDirDecision(events)).toMatchObject({
187
157
  surface: "external_directory",
188
158
  result: "allow",
189
159
  resolution: "policy_allow",
@@ -197,11 +167,7 @@ describe("external_directory policy state — allow", () => {
197
167
  });
198
168
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
199
169
  await handler.handleToolCall(event, makeCtx());
200
- const reviewCalls = (logger.review as ReturnType<typeof vi.fn>).mock.calls;
201
- const blockEntries = reviewCalls.filter(
202
- ([eventName]: string[]) => eventName === "permission_request.blocked",
203
- );
204
- expect(blockEntries).toHaveLength(0);
170
+ expect(blockReviewEntries(logger)).toHaveLength(0);
205
171
  });
206
172
  });
207
173
 
@@ -220,12 +186,7 @@ describe("external_directory — allow external reads, gate external writes (#14
220
186
  it("prompts for write to external path when external_directory allows but write is ask", async () => {
221
187
  const { handler, prompter } = makeHandler({
222
188
  session: { checkPermission: makeExtDirCheck("allow", "ask") },
223
- prompter: {
224
- canConfirm: vi.fn().mockReturnValue(true),
225
- prompt: vi
226
- .fn<GatePrompter["prompt"]>()
227
- .mockResolvedValue({ approved: true, state: "approved" }),
228
- },
189
+ prompter: makeApprovingPrompter(),
229
190
  tools: ALL_TOOLS,
230
191
  });
231
192
  const event = makeToolCallEvent("write", {
@@ -259,11 +220,8 @@ describe("external_directory — allow external reads, gate external writes (#14
259
220
  });
260
221
  await handler.handleToolCall(event, makeCtx());
261
222
  const decisions = getDecisionEvents(events);
262
- const extDirDecision = decisions.find(
263
- (d) => d.surface === "external_directory",
264
- );
265
223
  const writeDecision = decisions.find((d) => d.surface === "write");
266
- expect(extDirDecision).toMatchObject({
224
+ expect(findExtDirDecision(events)).toMatchObject({
267
225
  surface: "external_directory",
268
226
  result: "allow",
269
227
  resolution: "policy_allow",
@@ -308,12 +266,9 @@ describe("external_directory policy state — deny", () => {
308
266
  });
309
267
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
310
268
  await handler.handleToolCall(event, makeCtx());
311
- const reviewCalls = (logger.review as ReturnType<typeof vi.fn>).mock.calls;
312
- const blockEntries = reviewCalls.filter(
313
- ([eventName]: string[]) => eventName === "permission_request.blocked",
314
- );
315
- expect(blockEntries.length).toBeGreaterThanOrEqual(1);
316
- expect(blockEntries[0][1]).toMatchObject({
269
+ const entries = blockReviewEntries(logger);
270
+ expect(entries.length).toBeGreaterThanOrEqual(1);
271
+ expect(entries[0][1]).toMatchObject({
317
272
  resolution: "policy_denied",
318
273
  });
319
274
  });
@@ -325,11 +280,7 @@ describe("external_directory policy state — deny", () => {
325
280
  });
326
281
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
327
282
  await handler.handleToolCall(event, makeCtx());
328
- const decisions = getDecisionEvents(events);
329
- const extDirDecision = decisions.find(
330
- (d) => d.surface === "external_directory",
331
- );
332
- expect(extDirDecision).toMatchObject({
283
+ expect(findExtDirDecision(events)).toMatchObject({
333
284
  surface: "external_directory",
334
285
  result: "deny",
335
286
  resolution: "policy_deny",
@@ -343,12 +294,7 @@ describe("external_directory policy state — ask", () => {
343
294
  it("does not block when user approves", async () => {
344
295
  const { handler } = makeHandler({
345
296
  session: { checkPermission: makeExtDirCheck("ask") },
346
- prompter: {
347
- canConfirm: vi.fn().mockReturnValue(true),
348
- prompt: vi
349
- .fn<GatePrompter["prompt"]>()
350
- .mockResolvedValue({ approved: true, state: "approved" }),
351
- },
297
+ prompter: makeApprovingPrompter(),
352
298
  tools: ALL_TOOLS,
353
299
  });
354
300
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
@@ -359,21 +305,12 @@ describe("external_directory policy state — ask", () => {
359
305
  it("emits user_approved decision when user approves", async () => {
360
306
  const { handler, events } = makeHandler({
361
307
  session: { checkPermission: makeExtDirCheck("ask") },
362
- prompter: {
363
- canConfirm: vi.fn().mockReturnValue(true),
364
- prompt: vi
365
- .fn<GatePrompter["prompt"]>()
366
- .mockResolvedValue({ approved: true, state: "approved" }),
367
- },
308
+ prompter: makeApprovingPrompter(),
368
309
  tools: ALL_TOOLS,
369
310
  });
370
311
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
371
312
  await handler.handleToolCall(event, makeCtx());
372
- const decisions = getDecisionEvents(events);
373
- const extDirDecision = decisions.find(
374
- (d) => d.surface === "external_directory",
375
- );
376
- expect(extDirDecision).toMatchObject({
313
+ expect(findExtDirDecision(events)).toMatchObject({
377
314
  surface: "external_directory",
378
315
  result: "allow",
379
316
  resolution: "user_approved",
@@ -383,12 +320,7 @@ describe("external_directory policy state — ask", () => {
383
320
  it("blocks when user denies", async () => {
384
321
  const { handler } = makeHandler({
385
322
  session: { checkPermission: makeExtDirCheck("ask") },
386
- prompter: {
387
- canConfirm: vi.fn().mockReturnValue(true),
388
- prompt: vi
389
- .fn<GatePrompter["prompt"]>()
390
- .mockResolvedValue({ approved: false, state: "denied" }),
391
- },
323
+ prompter: makeDenyingPrompter(),
392
324
  tools: ALL_TOOLS,
393
325
  });
394
326
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
@@ -399,21 +331,12 @@ describe("external_directory policy state — ask", () => {
399
331
  it("emits user_denied decision when user denies", async () => {
400
332
  const { handler, events } = makeHandler({
401
333
  session: { checkPermission: makeExtDirCheck("ask") },
402
- prompter: {
403
- canConfirm: vi.fn().mockReturnValue(true),
404
- prompt: vi
405
- .fn<GatePrompter["prompt"]>()
406
- .mockResolvedValue({ approved: false, state: "denied" }),
407
- },
334
+ prompter: makeDenyingPrompter(),
408
335
  tools: ALL_TOOLS,
409
336
  });
410
337
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
411
338
  await handler.handleToolCall(event, makeCtx());
412
- const decisions = getDecisionEvents(events);
413
- const extDirDecision = decisions.find(
414
- (d) => d.surface === "external_directory",
415
- );
416
- expect(extDirDecision).toMatchObject({
339
+ expect(findExtDirDecision(events)).toMatchObject({
417
340
  surface: "external_directory",
418
341
  result: "deny",
419
342
  resolution: "user_denied",
@@ -423,14 +346,7 @@ describe("external_directory policy state — ask", () => {
423
346
  it("block reason includes denialReason when user provides one", async () => {
424
347
  const { handler } = makeHandler({
425
348
  session: { checkPermission: makeExtDirCheck("ask") },
426
- prompter: {
427
- canConfirm: vi.fn().mockReturnValue(true),
428
- prompt: vi.fn<GatePrompter["prompt"]>().mockResolvedValue({
429
- approved: false,
430
- state: "denied",
431
- denialReason: "not needed",
432
- }),
433
- },
349
+ prompter: makeDenyingPrompter("not needed"),
434
350
  tools: ALL_TOOLS,
435
351
  });
436
352
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
@@ -442,10 +358,7 @@ describe("external_directory policy state — ask", () => {
442
358
  it("blocks with confirmation_unavailable when no UI is available", async () => {
443
359
  const { handler } = makeHandler({
444
360
  session: { checkPermission: makeExtDirCheck("ask") },
445
- prompter: {
446
- canConfirm: vi.fn().mockReturnValue(false),
447
- prompt: vi.fn<GatePrompter["prompt"]>(),
448
- },
361
+ prompter: makeUnavailablePrompter(),
449
362
  tools: ALL_TOOLS,
450
363
  });
451
364
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
@@ -462,20 +375,14 @@ describe("external_directory policy state — ask", () => {
462
375
  it("writes review-log entry with confirmation_unavailable when no UI", async () => {
463
376
  const { handler, logger } = makeHandler({
464
377
  session: { checkPermission: makeExtDirCheck("ask") },
465
- prompter: {
466
- canConfirm: vi.fn().mockReturnValue(false),
467
- prompt: vi.fn<GatePrompter["prompt"]>(),
468
- },
378
+ prompter: makeUnavailablePrompter(),
469
379
  tools: ALL_TOOLS,
470
380
  });
471
381
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
472
382
  await handler.handleToolCall(event, makeCtx({ hasUI: false }));
473
- const reviewCalls = (logger.review as ReturnType<typeof vi.fn>).mock.calls;
474
- const blockEntries = reviewCalls.filter(
475
- ([eventName]: string[]) => eventName === "permission_request.blocked",
476
- );
477
- expect(blockEntries.length).toBeGreaterThanOrEqual(1);
478
- expect(blockEntries[0][1]).toMatchObject({
383
+ const entries = blockReviewEntries(logger);
384
+ expect(entries.length).toBeGreaterThanOrEqual(1);
385
+ expect(entries[0][1]).toMatchObject({
479
386
  resolution: "confirmation_unavailable",
480
387
  });
481
388
  });
@@ -483,19 +390,12 @@ describe("external_directory policy state — ask", () => {
483
390
  it("emits confirmation_unavailable decision when no UI", async () => {
484
391
  const { handler, events } = makeHandler({
485
392
  session: { checkPermission: makeExtDirCheck("ask") },
486
- prompter: {
487
- canConfirm: vi.fn().mockReturnValue(false),
488
- prompt: vi.fn<GatePrompter["prompt"]>(),
489
- },
393
+ prompter: makeUnavailablePrompter(),
490
394
  tools: ALL_TOOLS,
491
395
  });
492
396
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
493
397
  await handler.handleToolCall(event, makeCtx({ hasUI: false }));
494
- const decisions = getDecisionEvents(events);
495
- const extDirDecision = decisions.find(
496
- (d) => d.surface === "external_directory",
497
- );
498
- expect(extDirDecision).toMatchObject({
398
+ expect(findExtDirDecision(events)).toMatchObject({
499
399
  surface: "external_directory",
500
400
  result: "deny",
501
401
  resolution: "confirmation_unavailable",
@@ -547,9 +447,7 @@ describe("external_directory per-agent override", () => {
547
447
  const result1 = await handler1.handleToolCall(event, makeCtx());
548
448
  expect(result1).toEqual({ action: "allow" });
549
449
 
550
- const decisions1 = getDecisionEvents(events1);
551
- const extDir1 = decisions1.find((d) => d.surface === "external_directory");
552
- expect(extDir1).toMatchObject({
450
+ expect(findExtDirDecision(events1)).toMatchObject({
553
451
  result: "allow",
554
452
  resolution: "policy_allow",
555
453
  agentName: "special-agent",
@@ -578,10 +476,7 @@ describe("external_directory decision event fields", () => {
578
476
  });
579
477
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
580
478
  await handler.handleToolCall(event, makeCtx());
581
- const decisions = getDecisionEvents(events);
582
- const extDirDecision = decisions.find(
583
- (d) => d.surface === "external_directory",
584
- );
479
+ const extDirDecision = findExtDirDecision(events);
585
480
  expect(extDirDecision).toBeDefined();
586
481
  expect(extDirDecision!.value).toBe(EXTERNAL_PATH);
587
482
  });
@@ -596,11 +491,7 @@ describe("external_directory decision event fields", () => {
596
491
  });
597
492
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
598
493
  await handler.handleToolCall(event, makeCtx());
599
- const decisions = getDecisionEvents(events);
600
- const extDirDecision = decisions.find(
601
- (d) => d.surface === "external_directory",
602
- );
603
- expect(extDirDecision).toMatchObject({
494
+ expect(findExtDirDecision(events)).toMatchObject({
604
495
  agentName: "my-agent",
605
496
  });
606
497
  });
@@ -612,11 +503,7 @@ describe("external_directory decision event fields", () => {
612
503
  });
613
504
  const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
614
505
  await handler.handleToolCall(event, makeCtx());
615
- const decisions = getDecisionEvents(events);
616
- const extDirDecision = decisions.find(
617
- (d) => d.surface === "external_directory",
618
- );
619
- expect(extDirDecision).toMatchObject({
506
+ expect(findExtDirDecision(events)).toMatchObject({
620
507
  agentName: null,
621
508
  });
622
509
  });