@gotgenes/pi-permission-system 5.0.0 → 5.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.1.0](https://github.com/gotgenes/pi-permission-system/compare/v5.0.0...v5.1.0) (2026-05-05)
9
+
10
+
11
+ ### Features
12
+
13
+ * command-aware path extraction for pattern-first commands ([#91](https://github.com/gotgenes/pi-permission-system/issues/91)) ([befca23](https://github.com/gotgenes/pi-permission-system/commit/befca2341e1b54d9ed7e6ff3c3d465776afcc50d))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * plan command-aware path extraction for sed/awk/grep/rg/sd ([#91](https://github.com/gotgenes/pi-permission-system/issues/91)) ([be88a6a](https://github.com/gotgenes/pi-permission-system/commit/be88a6ab66ab386ce5843b3dd12218fc7968ee15))
19
+ * **retro:** add retro notes for issue [#88](https://github.com/gotgenes/pi-permission-system/issues/88) ([453a8ba](https://github.com/gotgenes/pi-permission-system/commit/453a8ba69fb68f24200be7a604f4fac4738c0cfe))
20
+
8
21
  ## [5.0.0](https://github.com/gotgenes/pi-permission-system/compare/v4.9.0...v5.0.0) (2026-05-05)
9
22
 
10
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -352,17 +352,252 @@ function resolveNodeText(node: TSNode): string {
352
352
  }
353
353
  }
354
354
 
355
+ // ── Pattern-first command config ───────────────────────────────────────────
356
+
357
+ interface PatternCommandConfig {
358
+ /** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
359
+ readonly argConsumingFlags: ReadonlySet<string>;
360
+ /** Flags that consume the next argument as a file path */
361
+ readonly fileConsumingFlags: ReadonlySet<string>;
362
+ /**
363
+ * Number of leading positional arguments that are patterns/scripts, not paths.
364
+ * Default: 1 (covers sed, awk, grep, rg).
365
+ * sd uses 2 (FIND and REPLACE_WITH are both non-path positionals).
366
+ */
367
+ readonly patternPositionals?: number;
368
+ }
369
+
370
+ /**
371
+ * Commands whose first N positional arguments are inline patterns/scripts,
372
+ * not filesystem paths. The map stores per-command flag configuration so
373
+ * the walker can correctly identify which arguments are consumed by flags
374
+ * vs. which are positional.
375
+ */
376
+ const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
377
+ new Map([
378
+ [
379
+ "sed",
380
+ {
381
+ argConsumingFlags: new Set(["-e", "-i"]),
382
+ fileConsumingFlags: new Set(["-f"]),
383
+ },
384
+ ],
385
+ [
386
+ "awk",
387
+ {
388
+ argConsumingFlags: new Set(["-e", "-F", "-v"]),
389
+ fileConsumingFlags: new Set(["-f"]),
390
+ },
391
+ ],
392
+ [
393
+ "gawk",
394
+ {
395
+ argConsumingFlags: new Set(["-e", "-F", "-v"]),
396
+ fileConsumingFlags: new Set(["-f"]),
397
+ },
398
+ ],
399
+ [
400
+ "nawk",
401
+ {
402
+ argConsumingFlags: new Set(["-e", "-F", "-v"]),
403
+ fileConsumingFlags: new Set(["-f"]),
404
+ },
405
+ ],
406
+ [
407
+ "grep",
408
+ {
409
+ argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
410
+ fileConsumingFlags: new Set(["-f"]),
411
+ },
412
+ ],
413
+ [
414
+ "egrep",
415
+ {
416
+ argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
417
+ fileConsumingFlags: new Set(["-f"]),
418
+ },
419
+ ],
420
+ [
421
+ "fgrep",
422
+ {
423
+ argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
424
+ fileConsumingFlags: new Set(["-f"]),
425
+ },
426
+ ],
427
+ [
428
+ "rg",
429
+ {
430
+ argConsumingFlags: new Set([
431
+ "-e",
432
+ "-A",
433
+ "-B",
434
+ "-C",
435
+ "-m",
436
+ "-g",
437
+ "-t",
438
+ "-T",
439
+ "-j",
440
+ "-M",
441
+ "-r",
442
+ "-E",
443
+ ]),
444
+ fileConsumingFlags: new Set(["-f"]),
445
+ },
446
+ ],
447
+ [
448
+ "sd",
449
+ {
450
+ argConsumingFlags: new Set(["-n", "-f"]),
451
+ fileConsumingFlags: new Set([]),
452
+ patternPositionals: 2,
453
+ },
454
+ ],
455
+ ]);
456
+
457
+ /** Node types that represent argument values in the AST. */
458
+ const ARG_NODE_TYPES = new Set([
459
+ "word",
460
+ "concatenation",
461
+ "string",
462
+ "raw_string",
463
+ ]);
464
+
465
+ /**
466
+ * Extract the command name from a `command` node.
467
+ * Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
468
+ * if the command name cannot be determined (e.g. variable expansion).
469
+ */
470
+ function extractCommandName(node: TSNode): string | undefined {
471
+ for (let i = 0; i < node.childCount; i++) {
472
+ const child = node.child(i);
473
+ if (!child) continue;
474
+ if (child.type === "command_name") {
475
+ const text = resolveNodeText(child);
476
+ return text ? basename(text) : undefined;
477
+ }
478
+ }
479
+ return undefined;
480
+ }
481
+
482
+ /**
483
+ * Collect path-candidate tokens from a command known to have
484
+ * pattern/script arguments in leading positional slots.
485
+ *
486
+ * Uses position-based skipping: the first N positional arguments
487
+ * (where N = patternPositionals, default 1) are assumed to be
488
+ * inline patterns/scripts and are skipped. Remaining positional
489
+ * arguments are collected as path candidates.
490
+ *
491
+ * Flags listed in `argConsumingFlags` consume the next argument
492
+ * (skipped). Flags in `fileConsumingFlags` consume the next
493
+ * argument as a file path (collected). The flags `-e` and `-f`
494
+ * additionally signal that an explicit script was provided via
495
+ * flag, so no inline positional script is expected.
496
+ */
497
+ function collectPatternCommandTokens(
498
+ node: TSNode,
499
+ tokens: string[],
500
+ config: PatternCommandConfig,
501
+ ): void {
502
+ const patternPositionals = config.patternPositionals ?? 1;
503
+ let hasExplicitScript = false;
504
+ let positionalsSeen = 0;
505
+ let nextArgAction: "skip" | "extract" | null = null;
506
+ let pastEndOfFlags = false;
507
+
508
+ for (let i = 0; i < node.childCount; i++) {
509
+ const child = node.child(i);
510
+ if (!child) continue;
511
+
512
+ // Skip command_name and variable_assignment nodes.
513
+ if (child.type === "command_name" || child.type === "variable_assignment")
514
+ continue;
515
+
516
+ // Only process argument-like nodes; recurse into others
517
+ // (e.g. command_substitution) for nested commands.
518
+ if (!ARG_NODE_TYPES.has(child.type)) {
519
+ collectPathCandidateTokens(child, tokens);
520
+ continue;
521
+ }
522
+
523
+ const text = resolveNodeText(child);
524
+
525
+ // Handle consumed argument from previous flag.
526
+ if (nextArgAction === "skip") {
527
+ nextArgAction = null;
528
+ continue;
529
+ }
530
+ if (nextArgAction === "extract") {
531
+ tokens.push(text);
532
+ nextArgAction = null;
533
+ continue;
534
+ }
535
+
536
+ // Flag detection (only before "--" end-of-flags marker).
537
+ if (
538
+ !pastEndOfFlags &&
539
+ child.type === "word" &&
540
+ text.startsWith("-") &&
541
+ text.length > 1
542
+ ) {
543
+ if (text === "--") {
544
+ pastEndOfFlags = true;
545
+ continue;
546
+ }
547
+ if (config.argConsumingFlags.has(text)) {
548
+ nextArgAction = "skip";
549
+ if (text === "-e" || text === "-f") {
550
+ hasExplicitScript = true;
551
+ }
552
+ continue;
553
+ }
554
+ if (config.fileConsumingFlags.has(text)) {
555
+ nextArgAction = "extract";
556
+ hasExplicitScript = true;
557
+ continue;
558
+ }
559
+ // Regular flag — skip it.
560
+ continue;
561
+ }
562
+
563
+ // Positional argument.
564
+ if (!hasExplicitScript && positionalsSeen < patternPositionals) {
565
+ positionalsSeen++;
566
+ continue; // Skip: this is an inline pattern/script.
567
+ }
568
+
569
+ // File argument — collect as path candidate.
570
+ tokens.push(text);
571
+ }
572
+ }
573
+
355
574
  /**
356
575
  * Recursively visit the AST and collect resolved text of nodes that
357
576
  * represent command arguments or redirect destinations.
358
577
  *
359
578
  * Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
579
+ *
580
+ * For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
581
+ * argument skipping to avoid collecting inline patterns/scripts
582
+ * as path candidates. For all other commands, collects all
583
+ * arguments generically.
360
584
  */
361
585
  function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
362
586
  if (SKIP_SUBTREE_TYPES.has(node.type)) return;
363
587
 
364
- // Extract arguments from `command` nodes (skip the command name).
588
+ // Extract arguments from `command` nodes.
365
589
  if (node.type === "command") {
590
+ const commandName = extractCommandName(node);
591
+ const patternConfig = commandName
592
+ ? PATTERN_FIRST_COMMANDS.get(commandName)
593
+ : undefined;
594
+
595
+ if (patternConfig) {
596
+ collectPatternCommandTokens(node, tokens, patternConfig);
597
+ return;
598
+ }
599
+
600
+ // Generic extraction: collect all arguments (skip command name).
366
601
  let seenCommandName = false;
367
602
  for (let i = 0; i < node.childCount; i++) {
368
603
  const child = node.child(i);
@@ -377,24 +612,13 @@ function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
377
612
 
378
613
  // If there was no explicit command_name node, the first word-like
379
614
  // child is the command name itself — skip it.
380
- if (
381
- !seenCommandName &&
382
- (child.type === "word" ||
383
- child.type === "concatenation" ||
384
- child.type === "string" ||
385
- child.type === "raw_string")
386
- ) {
615
+ if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
387
616
  seenCommandName = true;
388
617
  continue;
389
618
  }
390
619
 
391
620
  // Argument nodes: resolve their text and collect.
392
- if (
393
- child.type === "word" ||
394
- child.type === "concatenation" ||
395
- child.type === "string" ||
396
- child.type === "raw_string"
397
- ) {
621
+ if (ARG_NODE_TYPES.has(child.type)) {
398
622
  tokens.push(resolveNodeText(child));
399
623
  continue;
400
624
  }
@@ -545,6 +545,233 @@ describe("extractExternalPathsFromBashCommand", () => {
545
545
  });
546
546
  });
547
547
 
548
+ describe("command-aware extraction", () => {
549
+ describe("sed", () => {
550
+ test("issue #91 reproducer: sed address pattern is not flagged", async () => {
551
+ const cmd = `sed -i '' '/source: "tool",/{/origin:/!s/source: "tool",/source: "tool",\n origin: "builtin",/;}' tests/tool-input-preview.test.ts`;
552
+ const result = await extractExternalPathsFromBashCommand(cmd, cwd);
553
+ expect(result).toHaveLength(0);
554
+ });
555
+
556
+ test("sed script is skipped but file argument is extracted", async () => {
557
+ const result = await extractExternalPathsFromBashCommand(
558
+ "sed 's/foo/bar/g' /etc/hosts",
559
+ cwd,
560
+ );
561
+ expect(result).toContain("/etc/hosts");
562
+ });
563
+
564
+ test("sed address pattern starting with / is skipped", async () => {
565
+ const result = await extractExternalPathsFromBashCommand(
566
+ "sed '/pattern/d' /etc/hosts",
567
+ cwd,
568
+ );
569
+ expect(result).toContain("/etc/hosts");
570
+ expect(result).toHaveLength(1);
571
+ });
572
+
573
+ test("sed with only in-CWD file returns empty", async () => {
574
+ const result = await extractExternalPathsFromBashCommand(
575
+ "sed 's/foo/bar/' src/index.ts",
576
+ cwd,
577
+ );
578
+ expect(result).toHaveLength(0);
579
+ });
580
+
581
+ test("sed -e: script consumed by flag, file extracted", async () => {
582
+ const result = await extractExternalPathsFromBashCommand(
583
+ "sed -e 's/foo/bar/' /etc/hosts",
584
+ cwd,
585
+ );
586
+ expect(result).toContain("/etc/hosts");
587
+ expect(result).toHaveLength(1);
588
+ });
589
+
590
+ test("sed -n: regular flag does not consume next arg", async () => {
591
+ const result = await extractExternalPathsFromBashCommand(
592
+ "sed -n '/pattern/p' /etc/hosts",
593
+ cwd,
594
+ );
595
+ expect(result).toContain("/etc/hosts");
596
+ expect(result).toHaveLength(1);
597
+ });
598
+
599
+ test("sed -f: script file is extracted as path", async () => {
600
+ const result = await extractExternalPathsFromBashCommand(
601
+ "sed -f /etc/sed-script.sed input.txt",
602
+ cwd,
603
+ );
604
+ expect(result).toContain("/etc/sed-script.sed");
605
+ expect(result).toHaveLength(1);
606
+ });
607
+
608
+ test("sed -i '': extension consumed, script skipped, file extracted", async () => {
609
+ const result = await extractExternalPathsFromBashCommand(
610
+ "sed -i '' 's/foo/bar/' /etc/hosts",
611
+ cwd,
612
+ );
613
+ expect(result).toContain("/etc/hosts");
614
+ expect(result).toHaveLength(1);
615
+ });
616
+ });
617
+
618
+ describe("grep", () => {
619
+ test("grep: pattern skipped, file extracted", async () => {
620
+ const result = await extractExternalPathsFromBashCommand(
621
+ "grep '/etc/' /var/log/syslog",
622
+ cwd,
623
+ );
624
+ expect(result).toContain("/var/log/syslog");
625
+ expect(result).toHaveLength(1);
626
+ });
627
+
628
+ test("grep -e: pattern consumed by flag, file extracted", async () => {
629
+ const result = await extractExternalPathsFromBashCommand(
630
+ "grep -e '/etc/' /var/log/syslog",
631
+ cwd,
632
+ );
633
+ expect(result).toContain("/var/log/syslog");
634
+ expect(result).toHaveLength(1);
635
+ });
636
+ });
637
+
638
+ describe("awk", () => {
639
+ test("awk: program skipped, file extracted", async () => {
640
+ const result = await extractExternalPathsFromBashCommand(
641
+ "awk '{print}' /etc/hosts",
642
+ cwd,
643
+ );
644
+ expect(result).toContain("/etc/hosts");
645
+ expect(result).toHaveLength(1);
646
+ });
647
+
648
+ test("awk -F: separator consumed, program skipped, file extracted", async () => {
649
+ const result = await extractExternalPathsFromBashCommand(
650
+ "awk -F: '{print $1}' /etc/passwd",
651
+ cwd,
652
+ );
653
+ expect(result).toContain("/etc/passwd");
654
+ expect(result).toHaveLength(1);
655
+ });
656
+ });
657
+
658
+ describe("rg", () => {
659
+ test("rg: pattern skipped, path extracted", async () => {
660
+ const result = await extractExternalPathsFromBashCommand(
661
+ "rg '/usr/local' /etc/profile.d/",
662
+ cwd,
663
+ );
664
+ expect(result).toContain("/etc/profile.d");
665
+ expect(result).toHaveLength(1);
666
+ });
667
+
668
+ test("rg -e: pattern consumed by flag, path extracted", async () => {
669
+ const result = await extractExternalPathsFromBashCommand(
670
+ "rg -e '/usr/local' /etc/profile.d/",
671
+ cwd,
672
+ );
673
+ expect(result).toContain("/etc/profile.d");
674
+ expect(result).toHaveLength(1);
675
+ });
676
+ });
677
+
678
+ describe("sd", () => {
679
+ test("sd: both pattern positionals skipped, file extracted", async () => {
680
+ const result = await extractExternalPathsFromBashCommand(
681
+ "sd '/usr/local/bin' '/opt/bin' /etc/profile",
682
+ cwd,
683
+ );
684
+ expect(result).toContain("/etc/profile");
685
+ expect(result).toHaveLength(1);
686
+ });
687
+
688
+ test("sd with only in-CWD file returns empty", async () => {
689
+ const result = await extractExternalPathsFromBashCommand(
690
+ "sd 'foo' 'bar' src/index.ts",
691
+ cwd,
692
+ );
693
+ expect(result).toHaveLength(0);
694
+ });
695
+ });
696
+
697
+ describe("unknown commands", () => {
698
+ test("unknown command: all args go through generic extraction", async () => {
699
+ const result = await extractExternalPathsFromBashCommand(
700
+ "some-tool /etc/hosts",
701
+ cwd,
702
+ );
703
+ expect(result).toContain("/etc/hosts");
704
+ });
705
+ });
706
+
707
+ describe("edge cases", () => {
708
+ test("full-path command invocation: /usr/bin/sed", async () => {
709
+ const result = await extractExternalPathsFromBashCommand(
710
+ "/usr/bin/sed 's/foo/bar/' /etc/hosts",
711
+ cwd,
712
+ );
713
+ expect(result).toContain("/etc/hosts");
714
+ expect(result).toHaveLength(1);
715
+ });
716
+
717
+ test("-- end-of-flags: all remaining args are positional files", async () => {
718
+ const result = await extractExternalPathsFromBashCommand(
719
+ "grep -- '/etc/' /var/log/syslog",
720
+ cwd,
721
+ );
722
+ // After --, '/etc/' is the pattern positional, /var/log/syslog is a file
723
+ expect(result).toContain("/var/log/syslog");
724
+ expect(result).toHaveLength(1);
725
+ });
726
+
727
+ test("redirect target still extracted for pattern-first command", async () => {
728
+ const result = await extractExternalPathsFromBashCommand(
729
+ "sed 's/foo/bar/' input.txt > /tmp/output.txt",
730
+ cwd,
731
+ );
732
+ expect(result).toContain("/tmp/output.txt");
733
+ });
734
+
735
+ test("pipeline: sed piped to cat with external path", async () => {
736
+ const result = await extractExternalPathsFromBashCommand(
737
+ "sed 's/foo/bar/' src/file.ts | cat /etc/hosts",
738
+ cwd,
739
+ );
740
+ expect(result).toContain("/etc/hosts");
741
+ expect(result).toHaveLength(1);
742
+ });
743
+
744
+ test("command substitution inside pattern-first command", async () => {
745
+ const result = await extractExternalPathsFromBashCommand(
746
+ "grep 'pattern' $(cat /etc/file-list)",
747
+ cwd,
748
+ );
749
+ // /etc/file-list is an argument to cat inside command substitution
750
+ expect(result).toContain("/etc/file-list");
751
+ });
752
+ });
753
+
754
+ describe("known limitations", () => {
755
+ test("sed -i without extension (GNU sed): /etc/hosts is missed (false negative)", async () => {
756
+ // GNU sed treats -i as a flag with no argument, so 's/foo/bar/' is
757
+ // the inline script and /etc/hosts is the input file. Our logic
758
+ // treats -i as arg-consuming (correct for BSD sed -i ''), so it
759
+ // consumes the script as the -i extension and /etc/hosts becomes
760
+ // the first positional — which is skipped as the inline script.
761
+ // This is a known false negative. The bash permission gate still
762
+ // applies, so external access is not silently allowed.
763
+ const result = await extractExternalPathsFromBashCommand(
764
+ "sed -i 's/foo/bar/' /etc/hosts",
765
+ cwd,
766
+ );
767
+ // Ideally this would detect /etc/hosts, but position tracking
768
+ // treats it as the inline script. Assert current behavior so
769
+ // a future fix can flip this expectation.
770
+ expect(result).toHaveLength(0);
771
+ });
772
+ });
773
+ });
774
+
548
775
  describe("regex patterns are not mistaken for paths", () => {
549
776
  test("grep -v with //.*pattern is not flagged", async () => {
550
777
  const result = await extractExternalPathsFromBashCommand(