@hasna/hooks 0.4.1 → 0.6.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 (35) hide show
  1. package/README.md +96 -12
  2. package/bin/index.js +2089 -470
  3. package/bin/serve.js +5248 -0
  4. package/dist/cf/provision.d.ts +24 -0
  5. package/dist/config.d.ts +18 -0
  6. package/dist/db/legacy-import.d.ts +1 -1
  7. package/dist/db/migrations/004_hooks_table.d.ts +9 -0
  8. package/dist/db/pg-migrations.d.ts +1 -1
  9. package/dist/db/storage-sync.d.ts +26 -6
  10. package/dist/index.d.ts +18 -2
  11. package/dist/index.js +5341 -286
  12. package/dist/lib/custom-install.d.ts +19 -0
  13. package/dist/lib/manifest.d.ts +70 -0
  14. package/dist/lib/resolve.d.ts +19 -0
  15. package/dist/lib/run.d.ts +37 -0
  16. package/dist/lib/store.d.ts +69 -0
  17. package/dist/lib/sync.d.ts +35 -0
  18. package/dist/serve.d.ts +36 -0
  19. package/dist/storage.d.ts +2 -2
  20. package/dist/storage.js +133 -42
  21. package/hooks/codewith-native-common.test.ts +1521 -3
  22. package/hooks/codewith-native-common.ts +1627 -37
  23. package/hooks/hook-scanoutput/README.md +151 -0
  24. package/hooks/hook-scanoutput/package.json +12 -0
  25. package/hooks/hook-scanoutput/src/hook.test.ts +217 -0
  26. package/hooks/hook-scanoutput/src/hook.ts +319 -0
  27. package/hooks/mention-context/README.md +109 -0
  28. package/hooks/mention-context/package.json +9 -0
  29. package/hooks/mention-context/src/hasna-mention-context.py +1218 -0
  30. package/hooks/mention-context/src/hasna-mention-warm.py +521 -0
  31. package/hooks/mention-context/src/hook.test.ts +68 -0
  32. package/hooks/mention-context/src/test_run_capture.py +345 -0
  33. package/hooks/pre-bash/README.md +72 -2
  34. package/hooks/worktree-guard/README.md +9 -2
  35. package/package.json +9 -5
@@ -1,8 +1,19 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { join } from "path";
3
3
  import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "fs";
4
- import { tmpdir } from "os";
5
- import { claimCommand, getAgentName, gitCommandInfo, gitRemoteHostSlug, managedWorktreeInfo } from "./codewith-native-common";
4
+ import { homedir, tmpdir } from "os";
5
+ import {
6
+ claimCommand,
7
+ classifyDangerousOperation,
8
+ emptyExpansionCollapse,
9
+ globComponentMatches,
10
+ defaultWorktreesRoot,
11
+ getAgentName,
12
+ gitCommandInfo,
13
+ gitRemoteHostSlug,
14
+ managedWorktreeInfo,
15
+ SYSTEM_PROTECTED_ROOTS,
16
+ } from "./codewith-native-common";
6
17
 
7
18
  describe("codewith native common helpers", () => {
8
19
  test("gitCommandInfo detects global option commit/push forms and target cwd", () => {
@@ -411,3 +422,1510 @@ describe("codewith native common helpers", () => {
411
422
  }
412
423
  });
413
424
  });
425
+
426
+ /**
427
+ * Regression suite for the 2026-07-24 station02 data-destruction incident.
428
+ *
429
+ * A subagent composed `bash -c 'rm -rf "$(bun pm cache)"/* ; ... bun add -g ...'` and ssh'd
430
+ * it to station02. `bun pm cache` exits 1 with an empty stdout when no package.json is found
431
+ * walking up from cwd, so the substitution collapsed and the command ran as `rm -rf /*`. It
432
+ * ran unprivileged for ~5 minutes, freed ~700 GB, and destroyed the org repo checkouts;
433
+ * hasna/cloud's source is permanently gone.
434
+ *
435
+ * Every case below is a command STRING handed to the classifier. Nothing here executes any
436
+ * rm, at any scope, ever.
437
+ */
438
+ describe("destructive shell guard - rm -rf /* incident regression", () => {
439
+ /**
440
+ * Root for throwaway git fixtures.
441
+ *
442
+ * Deliberately NOT the real $HOME - a crashed run would strand repos in the operator's home
443
+ * directory. Also deliberately not shared with any `pushd` target used below: a fixture
444
+ * under the same tree as the pushd target once made the popd test pass for the wrong
445
+ * reason, because `<target>/*` covered the fixture whether or not popd was handled.
446
+ */
447
+ const fixtureRoot = mkdtempSync(join(tmpdir(), "hooks-fixtures-"));
448
+ afterAll(() => {
449
+ try { rmSync(fixtureRoot, { recursive: true, force: true }); } catch {}
450
+ });
451
+ // The incident machine's HOME. Pinned as an explicit fixture so the three mandated
452
+ // regression commands can appear verbatim rather than reconstructed from the runner's env.
453
+ const INCIDENT_HOME = "/home/hasna";
454
+
455
+ let scratchCwd: string;
456
+ let savedHome: string | undefined;
457
+
458
+ beforeAll(() => {
459
+ // A cwd outside any git repo and outside every protected root, so a verdict is
460
+ // attributable to the command under test rather than to where the suite happens to run.
461
+ scratchCwd = mkdtempSync(join(tmpdir(), "hooks-destructive-"));
462
+ savedHome = process.env.HOME;
463
+ });
464
+
465
+ afterAll(() => {
466
+ if (savedHome === undefined) delete process.env.HOME;
467
+ else process.env.HOME = savedHome;
468
+ try { rmSync(scratchCwd, { recursive: true, force: true }); } catch {}
469
+ });
470
+
471
+ async function classify(command: string, options: { home?: string; cwd?: string } = {}) {
472
+ process.env.HOME = options.home ?? INCIDENT_HOME;
473
+ return classifyDangerousOperation({
474
+ hook_event_name: "PreToolUse",
475
+ tool_name: "Bash",
476
+ cwd: options.cwd ?? scratchCwd,
477
+ tool_input: { command },
478
+ });
479
+ }
480
+
481
+ async function expectBlocked(command: string, options: { home?: string; cwd?: string } = {}) {
482
+ const result = await classify(command, options);
483
+ if (!result.block) throw new Error(`expected BLOCK, got continue for: ${command}`);
484
+ return result;
485
+ }
486
+
487
+ async function expectAllowed(command: string, options: { home?: string; cwd?: string } = {}) {
488
+ const result = await classify(command, options);
489
+ if (result.block) throw new Error(`expected continue, got BLOCK (${result.reason}) for: ${command}`);
490
+ return result;
491
+ }
492
+
493
+ // ---------------------------------------------------------------------------------------
494
+ // The three fixtures mandated by the incident remediation, verbatim.
495
+ // ---------------------------------------------------------------------------------------
496
+
497
+ test("control: rm -rf /home/hasna/.hasna still blocks (guard is wired up at all)", async () => {
498
+ const result = await expectBlocked("rm -rf /home/hasna/.hasna");
499
+ expect(result.protectedLabel).toBe("Hasna state root ~/.hasna");
500
+ expect(result.operation).toBe("rm -rf");
501
+ });
502
+
503
+ test("rm -rf /* blocks (was `continue` before this change)", async () => {
504
+ const result = await expectBlocked("rm -rf /*");
505
+ expect(result.protectedLabel).toBe("filesystem root /");
506
+ expect(result.reason).toContain("rm -rf");
507
+ });
508
+
509
+ test('rm -rf "$(bun pm cache)"/* blocks (was `continue` before this change)', async () => {
510
+ const result = await expectBlocked('rm -rf "$(bun pm cache)"/*');
511
+ expect(result.targetPath).toBe("/*");
512
+ expect(result.reason).toContain("collapses to /*");
513
+ // A refusal that does not tell the agent what to do instead just gets retried.
514
+ expect(result.reason).toContain("Safe alternative");
515
+ });
516
+
517
+ test("the realized incident command blocks", async () => {
518
+ await expectBlocked(
519
+ `bash -c 'rm -rf "$(bun pm cache)"/* ; bun add -g @hasna/connectors@1.3.45'`
520
+ );
521
+ });
522
+
523
+ // ---------------------------------------------------------------------------------------
524
+ // Rule 1 - system and filesystem roots.
525
+ // ---------------------------------------------------------------------------------------
526
+
527
+ test("recursive delete of the filesystem root blocks in bare and glob form", async () => {
528
+ for (const command of ["rm -rf /", "rm -rf /*", "rm -rf /.*", "rm -rf /**"]) {
529
+ const result = await expectBlocked(command);
530
+ expect(result.protectedLabel).toBe("filesystem root /");
531
+ }
532
+ });
533
+
534
+ test("recursive delete of every declared system root blocks, bare and wholesale-glob", async () => {
535
+ for (const root of SYSTEM_PROTECTED_ROOTS) {
536
+ if (root === "/") continue;
537
+ // The label is asserted, not just the verdict: /home (and /root, /Users on machines
538
+ // whose HOME sits under them) is also covered by the ~/.hasna tree rule, so a
539
+ // verdict-only assertion would still pass with that entry deleted from the list.
540
+ const bare = await expectBlocked(`rm -rf ${root}`);
541
+ expect(bare.protectedLabel).toBe(`system root ${root}`);
542
+ const glob = await expectBlocked(`rm -rf ${root}/*`);
543
+ expect(glob.protectedLabel).toBe(`system root ${root}`);
544
+ }
545
+ });
546
+
547
+ test("targeted deletes under a system root stay allowed", async () => {
548
+ await expectAllowed("rm -rf /usr/local/lib/my-abandoned-build");
549
+ await expectAllowed("rm -rf /var/log/my-app/old");
550
+ await expectAllowed("rm -rf /opt/my-app/cache/*");
551
+ // /tmp is deliberately not a protected root: scratch cleanup there is routine.
552
+ await expectAllowed("rm -rf /tmp/scratch-1234");
553
+ await expectAllowed("rm -rf /tmp/*");
554
+ });
555
+
556
+ test("a wholesale glob blocks where a narrower glob over the same directory does not", async () => {
557
+ const home = mkdtempSync(join(tmpdir(), "hooks-glob-home-"));
558
+ try {
559
+ // ~/.hasna lives under `home`, so `home/*` destroys it but `home/proj*` cannot.
560
+ await expectBlocked(`rm -rf ${home}/*`, { home });
561
+ await expectAllowed(`rm -rf ${home}/proj*`, { home });
562
+ await expectAllowed(`rm -rf ${home}/.cache`, { home });
563
+ } finally {
564
+ try { rmSync(home, { recursive: true, force: true }); } catch {}
565
+ }
566
+ });
567
+
568
+ // ---------------------------------------------------------------------------------------
569
+ // Rule 2 - expansions that can collapse to empty.
570
+ // ---------------------------------------------------------------------------------------
571
+
572
+ test("any expansion immediately followed by / blocks, whatever the expansion is", async () => {
573
+ const commands = [
574
+ 'rm -rf "$(bun pm cache)"/*',
575
+ "rm -rf $(bun pm cache)/*",
576
+ "rm -rf `bun pm cache`/*",
577
+ 'rm -rf "$(some-command-nobody-has-written-yet --json)"/*',
578
+ 'rm -rf "${BUN_CACHE}"/*',
579
+ 'rm -rf "$BUN_CACHE"/*',
580
+ 'rm -rf "$1"/*',
581
+ 'rm -rf "$(dirname "$(which bun)")"/*',
582
+ 'rm -rf "$CACHE"/',
583
+ ];
584
+ for (const command of commands) {
585
+ const result = await expectBlocked(command);
586
+ expect(result.reason).toContain("collapses to");
587
+ }
588
+ });
589
+
590
+ test('rm -rf "$HOME"/* blocks on the expanded path, before the collapse rule is needed', async () => {
591
+ // Two independent mechanisms cover this one: $HOME expands to a real home whose wholesale
592
+ // glob destroys ~/.hasna, and an empty $HOME would collapse the target to /*.
593
+ const result = await expectBlocked('rm -rf "$HOME"/*');
594
+ expect(result.protectedLabel).toBe("Hasna state root ~/.hasna");
595
+ expect(emptyExpansionCollapse("$HOME/*")).toBe("/*");
596
+ });
597
+
598
+ test("stderr redirection does not launder the shape - it discards the diagnostic, not the path", async () => {
599
+ await expectBlocked('rm -rf "$(bun pm cache 2>/dev/null)"/*');
600
+ });
601
+
602
+ test("an expansion collapsing onto a system root blocks even mid-path", async () => {
603
+ await expectBlocked('rm -rf "$SYSROOT"/usr');
604
+ await expectBlocked('rm -rf /opt/"$APP"/*');
605
+ });
606
+
607
+ test("rsync --delete and find -delete get the same collapse check as rm", async () => {
608
+ await expectBlocked('rsync -a --delete empty/ "$(build-dir)"/');
609
+ await expectBlocked('find "$(build-dir)"/ -delete');
610
+ });
611
+
612
+ /**
613
+ * Deliberate non-block. `rm -rf "$(cmd)"` with an empty expansion becomes `rm -rf ""`,
614
+ * which POSIX rm rejects ("cannot remove ''") with a non-zero exit and no deletion. The
615
+ * entire catastrophic class is the trailing separator, which turns the empty string into
616
+ * `/`. Blocking the bare form would break routine `rm -rf "$tmpdir"` cleanup for no gain.
617
+ */
618
+ test("the bare expansion form without a trailing separator stays allowed, by decision", async () => {
619
+ await expectAllowed('rm -rf "$(mktemp -d)"');
620
+ await expectAllowed('rm -rf "$BUILD_DIR"');
621
+ expect(emptyExpansionCollapse('$(mktemp -d)')).toBeNull();
622
+ });
623
+
624
+ test("an expansion collapsing to a non-protected absolute path stays allowed", async () => {
625
+ await expectAllowed('rm -rf "$BUILD_DIR"/dist');
626
+ await expectAllowed('rm -rf "$HOME"/.cache/my-app');
627
+ expect(emptyExpansionCollapse('"$BUILD_DIR"/dist'.replaceAll('"', ""))).toBe("/dist");
628
+ });
629
+
630
+ // ---------------------------------------------------------------------------------------
631
+ // Evasion: the guard must not be defeated by spelling.
632
+ // ---------------------------------------------------------------------------------------
633
+
634
+ test("recursive-flag spellings, privilege prefixes and chaining do not evade the guard", async () => {
635
+ const commands = [
636
+ "rm -fr /*",
637
+ "rm -R -f /*",
638
+ "rm -Rf /*",
639
+ "rm --recursive --force /*",
640
+ "rm --force --recursive /*",
641
+ "rm -r /*",
642
+ "sudo rm -rf /*",
643
+ "doas rm -rf /*",
644
+ "FOO=bar BAZ=qux rm -rf /*",
645
+ "/bin/rm -rf /*",
646
+ "/usr/bin/rm -rf /*",
647
+ "rm -rf -- /*",
648
+ "true && rm -rf /*",
649
+ "false; rm -rf /*",
650
+ "echo starting\nrm -rf /*",
651
+ "cd / && rm -rf *",
652
+ "nice -n 19 rm -rf /*",
653
+ "env FOO=1 rm -rf /*",
654
+ ];
655
+ for (const command of commands) await expectBlocked(command);
656
+ });
657
+
658
+ // ---------------------------------------------------------------------------------------
659
+ // Wrappers. The realized incident was inside `bash -c` inside `ssh`, so this is required.
660
+ // ---------------------------------------------------------------------------------------
661
+
662
+ test("interpreter wrappers are unwrapped and scanned", async () => {
663
+ const commands = [
664
+ `bash -c 'rm -rf /*'`,
665
+ `sh -c "rm -rf /*"`,
666
+ `zsh -c 'rm -rf /*'`,
667
+ `/bin/bash -c 'rm -rf /*'`,
668
+ `bash -lc 'rm -rf /*'`,
669
+ `bash -euxc 'rm -rf /*'`,
670
+ `sudo bash -c 'rm -rf /*'`,
671
+ `timeout 150 bash -c 'rm -rf "$(bun pm cache)"/*'`,
672
+ `bash -c 'cd /tmp && rm -rf /*'`,
673
+ ];
674
+ for (const command of commands) await expectBlocked(command);
675
+ });
676
+
677
+ test("ssh remote commands are unwrapped and scanned, including nested interpreters", async () => {
678
+ const commands = [
679
+ `ssh station02 'rm -rf /*'`,
680
+ `ssh -o BatchMode=yes -p 22 station02 'rm -rf /etc'`,
681
+ `ssh -i /home/hasna/.ssh/id_ed25519 hasna@station02 'rm -rf /usr/*'`,
682
+ `ssh station02 bash -c 'rm -rf /*'`,
683
+ `timeout 150 ssh station02 'rm -rf "$(bun pm cache)"/* ; bun add -g @hasna/connectors@1.3.45'`,
684
+ ];
685
+ for (const command of commands) {
686
+ const result = await expectBlocked(command);
687
+ expect(result.reason).toContain("on a remote host");
688
+ }
689
+ });
690
+
691
+ test("an unquoted ssh remote command still blocks, scanned as if it were local", async () => {
692
+ // `ssh host rm -rf /*` puts a bare `rm` token in the outer command, so the ordinary local
693
+ // scan catches it first. The verdict is the same; only the wording omits the remote host.
694
+ await expectBlocked(`ssh station02 rm -rf /*`);
695
+ });
696
+
697
+ test("a relative target on a remote host is not resolved against the local cwd", async () => {
698
+ // The remote `.` is not this machine's cwd, so guessing would be a false positive.
699
+ await expectAllowed(`ssh station02 'rm -rf dist'`);
700
+ await expectAllowed(`ssh station02 'rm -rf .'`);
701
+ await expectAllowed(`ssh station02 'ls -la /'`);
702
+ });
703
+
704
+ // ---------------------------------------------------------------------------------------
705
+ // False positives. A guard that blocks routine cleanup gets disabled, which is how this
706
+ // class of incident recurs.
707
+ // ---------------------------------------------------------------------------------------
708
+
709
+ test("routine cleanup stays allowed", async () => {
710
+ const commands = [
711
+ "rm -rf dist",
712
+ "rm -rf ./node_modules",
713
+ "rm -rf dist .turbo build",
714
+ "rm -rf ./dist/*",
715
+ "rm -f /home/hasna/some-file.txt",
716
+ "ls -la /",
717
+ "du -sh /*",
718
+ "git status",
719
+ "find . -name '*.log' -print",
720
+ "bun pm cache",
721
+ ];
722
+ for (const command of commands) await expectAllowed(command);
723
+ });
724
+
725
+ test("an unterminated substitution falls back to the plain tokenizer instead of swallowing the rest", async () => {
726
+ await expectBlocked("echo $( ; rm -rf /*");
727
+ await expectBlocked("echo ` ; rm -rf /*");
728
+ });
729
+
730
+ // ---------------------------------------------------------------------------------------
731
+ // Adversarial pass. Each of these got a root wipe past an earlier draft of this guard.
732
+ // ---------------------------------------------------------------------------------------
733
+
734
+ test("quoting tricks that still resolve to the filesystem root are blocked", async () => {
735
+ for (const command of [
736
+ `rm -rf "/"*`,
737
+ `rm -rf /"*"`,
738
+ `rm -rf ''/*`,
739
+ `rm -rf /./*`,
740
+ `rm -rf /*/`,
741
+ `rm -rf //*`,
742
+ `rm -rf /home/hasna/../..`,
743
+ `rm -rf ~/../*`,
744
+ `rm -rf "$HOME/.."/*`,
745
+ `rm -rf "$(bun pm cache)"/../*`,
746
+ `rm -rf "$(bun pm cache)"//*`,
747
+ ]) {
748
+ await expectBlocked(command);
749
+ }
750
+ });
751
+
752
+ test("cd moves the guard with it", async () => {
753
+ await expectBlocked("cd / ; rm -rf *");
754
+ await expectBlocked("cd /usr && rm -rf *");
755
+ await expectBlocked(`sh -c 'cd / && rm -rf *'`);
756
+ await expectBlocked(`ssh station02 "cd / && rm -rf *"`);
757
+ // The incident shape moved one command to the left: an empty substitution leaves cd at /.
758
+ await expectBlocked(`cd "$(bun pm cache)"/ && rm -rf ./*`);
759
+ await expectBlocked(`bash -c 'cd "$(bun pm cache)"/ && rm -rf ./*'`);
760
+ });
761
+
762
+ test("cd into a resolved directory then clearing it stays allowed", async () => {
763
+ // No trailing separator, so an empty substitution leaves cwd untouched rather than at /.
764
+ await expectAllowed(`bash -c 'cd "$(bun pm cache)" && rm -rf ./*'`);
765
+ await expectAllowed(`cd "$HOME/.cache/my-app" && rm -rf ./*`);
766
+ await expectAllowed("cd /tmp && rm -rf my-scratch-dir");
767
+ });
768
+
769
+ test("eval and user-switch wrappers are unwrapped", async () => {
770
+ await expectBlocked(`eval 'rm -rf /*'`);
771
+ await expectBlocked(`su -c 'rm -rf /*'`);
772
+ await expectBlocked(`su root -c 'rm -rf /*'`);
773
+ await expectBlocked(`runuser -u hasna -c 'rm -rf /*'`);
774
+ });
775
+
776
+ test("a for-loop over a root glob is blocked even though the delete target is just $d", async () => {
777
+ await expectBlocked(`for d in /*; do rm -rf "$d"; done`);
778
+ await expectBlocked(`for p in /usr/* /etc/*; do rm -rf "$p"; done`);
779
+ // The binding must actually be a root glob; ordinary loops stay allowed.
780
+ await expectAllowed(`for d in ./build/*; do rm -rf "$d"; done`);
781
+ });
782
+
783
+ test('${VAR:?} is honoured as the non-empty assertion it is', async () => {
784
+ // POSIX `:?` aborts on unset *or* empty, so this cannot collapse. Blocking the idiom the
785
+ // guard's own message recommends would teach agents to drop it.
786
+ await expectAllowed('rm -rf "${CACHE:?cache path required}"/*');
787
+ await expectAllowed('rm -rf "${BUN_CACHE:?}"/*');
788
+ expect(emptyExpansionCollapse("${BUN_CACHE:?}/*")).toBeNull();
789
+ // `${VAR?}` without the colon permits an empty value, which is the whole hazard.
790
+ expect(emptyExpansionCollapse("${BUN_CACHE?}/*")).toBe("/*");
791
+ });
792
+
793
+ test("privilege and scheduling prefixes do not hide the delete", async () => {
794
+ for (const command of [
795
+ String.raw`\rm -rf /*`,
796
+ `'rm' -rf /*`,
797
+ "command rm -rf /*",
798
+ "nohup rm -rf /* &",
799
+ "setsid rm -rf /*",
800
+ ]) {
801
+ await expectBlocked(command);
802
+ }
803
+ });
804
+
805
+ test("non-rm destructive tools targeting the root are blocked", async () => {
806
+ await expectBlocked("find / -delete");
807
+ await expectBlocked("find / -exec rm -rf {} \\;");
808
+ await expectBlocked("rsync -a --delete /var/empty/ /");
809
+ });
810
+
811
+ // -------------------------------------------------------------------------------------
812
+ // Adversarial review round 2. Each of these got a full wipe past the first draft of this
813
+ // change, and the first two were REGRESSIONS it introduced against the previous release.
814
+ // -------------------------------------------------------------------------------------
815
+
816
+ test("a delete inside a command substitution is still a delete", async () => {
817
+ // Regression guard. Making $( ) atomic for the collapse rule removed the accidental
818
+ // coverage the old segment splitter gave, so `echo $(rm -rf /)` became invisible: the
819
+ // delete runs and only its OUTPUT is discarded.
820
+ for (const command of [
821
+ "echo $(rm -rf /home/hasna/.hasna)",
822
+ "x=$(rm -rf /home/hasna/.hasna)",
823
+ "$(rm -rf /home/hasna/.hasna)",
824
+ "$(rm -rf /*)",
825
+ "x=$(rm -rf /*)",
826
+ 'echo "$(cd / && rm -rf *)"',
827
+ "echo `rm -rf /*`",
828
+ 'files=$(rm -rf "$(bun pm cache)"/*)',
829
+ ]) {
830
+ await expectBlocked(command);
831
+ }
832
+ });
833
+
834
+ test("a glob anywhere in the path counts, not only in the last component", async () => {
835
+ // `rm -rf /*/*` destroys /usr/*, /etc/*, /home/* - the incident's outcome, respelled.
836
+ for (const command of [
837
+ "rm -rf /*/*",
838
+ "rm -rf /*/*/*",
839
+ "rm -rf /**/*",
840
+ "rm -rf /home/*/*",
841
+ "rm -rf ~/*/*",
842
+ "rm -rf /home/*/.hasna",
843
+ "rm -rf /*/bin",
844
+ "find /*/x -delete",
845
+ ]) {
846
+ await expectBlocked(command);
847
+ }
848
+ });
849
+
850
+ test("a bounded glob earlier in the path still does not over-block", async () => {
851
+ const home = mkdtempSync(join(tmpdir(), "hooks-midglob-"));
852
+ try {
853
+ await expectAllowed(`rm -rf ${home}/proj*/dist`, { home });
854
+ await expectAllowed("rm -rf /var/log/*.gz", { home });
855
+ await expectAllowed("rm -rf /etc/nginx/sites-enabled/*", { home });
856
+ await expectAllowed("rm -rf /var/lib/docker/*", { home });
857
+ } finally {
858
+ try { rmSync(home, { recursive: true, force: true }); } catch {}
859
+ }
860
+ });
861
+
862
+ test("brace alternations are expanded", async () => {
863
+ await expectBlocked("rm -rf /{bin,boot,etc,home,lib,opt,root,srv,usr,var}");
864
+ await expectBlocked("rm -rf /{,usr,etc}");
865
+ await expectBlocked("rm -rf ~/{.hasna,Downloads}");
866
+ await expectAllowed("rm -rf ./{dist,build}");
867
+ });
868
+
869
+ test("exceeding the brace cap falls back conservatively rather than becoming a bypass", async () => {
870
+ // Abandoning expansion by returning the raw token was itself a hole: the unexpanded
871
+ // token resolved to a literal path matching no protected root.
872
+ const many = Array.from({ length: 70 }, (_, i) => `a${i}`).join(",");
873
+ await expectBlocked(`rm -rf /{${many},etc}`);
874
+ await expectBlocked(`rm -rf ~/{${many}}`);
875
+ // Ordinary alternations under the cap keep expanding normally.
876
+ await expectAllowed("rm -rf ./{dist,build}");
877
+ await expectAllowed("rm -rf /tmp/{a,b,c}");
878
+ });
879
+
880
+ test("a command too nested to analyse is refused, not waved through", async () => {
881
+ // Padding with dummy wrappers pushed the real delete past MAX_SHELL_LAYERS and it
882
+ // returned continue. Every cap in this file must fail closed: "too complex to analyse"
883
+ // must never mean "allowed".
884
+ const pad = (n: number) => Array.from({ length: n }, (_, i) => `sh -c 'a${i}'`).join(" ; ");
885
+ await expectBlocked(`${pad(32)} ; sh -c 'rm -rf /*'`);
886
+ await expectBlocked(`${pad(40)} ; sh -c 'rm -rf /*'`);
887
+ // Deep wrapper nesting, built programmatically so the escaping is not hand-maintained.
888
+ const nest = (inner: string, depth: number) => {
889
+ let out = inner;
890
+ for (let i = 0; i < depth; i += 1) out = `sh -c ${JSON.stringify(out)}`;
891
+ return out;
892
+ };
893
+ for (const depth of [2, 3, 4, 5]) await expectBlocked(nest("rm -rf /*", depth));
894
+ // Refusal is scoped to commands that actually contain a destructive verb.
895
+ await expectAllowed(pad(40));
896
+ await expectAllowed(nest("rm -rf dist", 2));
897
+ });
898
+
899
+ // -------------------------------------------------------------------------------------
900
+ // Adversarial review round 3. A fixed corpus, kept together deliberately: every entry here
901
+ // was a live escape or false positive at some commit ON THIS BRANCH, several of them
902
+ // introduced by the fix for the entry above it. Replay the whole block, never a subset.
903
+ // -------------------------------------------------------------------------------------
904
+
905
+ test("a cd inside a subshell still guards the rest of that subshell", async () => {
906
+ // Regression from the first isolation fix: `isolated` was read as "this cd never
907
+ // applies", but it only means "does not escape to the parent". `(cd X && rm -rf *)` is
908
+ // THE idiom for cd-without-moving-my-shell, and it was completely unguarded.
909
+ for (const command of [
910
+ "(cd / && rm -rf *)",
911
+ "(cd /usr && rm -rf *)",
912
+ "(cd /home/hasna && rm -rf .hasna)",
913
+ `(cd "$(bun pm cache)"/ && rm -rf ./*)`,
914
+ `bash -c '(cd / && rm -rf *)'`,
915
+ "echo x | (cd / && rm -rf *)",
916
+ ]) {
917
+ await expectBlocked(command);
918
+ }
919
+ // ...while a cd that genuinely cannot reach the delete is still ignored.
920
+ await expectBlocked("cd /var/tmp | cat; rm -rf *", { cwd: "/home/hasna/.hasna/projects" });
921
+ });
922
+
923
+ test("cd flag forms and brace groups are followed", async () => {
924
+ for (const command of [
925
+ "cd -P / && rm -rf *",
926
+ "cd -L / && rm -rf *",
927
+ "cd -- / && rm -rf *",
928
+ "{ cd /; }; rm -rf *",
929
+ "pushd / && rm -rf *",
930
+ ]) {
931
+ await expectBlocked(command);
932
+ }
933
+ });
934
+
935
+ test("a non-empty assignment only counts where the shell would actually apply it", async () => {
936
+ for (const command of [
937
+ // bash expands $X BEFORE applying a prefix assignment, so $X is still empty.
938
+ `X=/tmp/build rm -rf "$X"/*`,
939
+ `X=/tmp/build; X=$(bun pm cache); rm -rf "$X"/*`,
940
+ `rm -rf "$X"/* ; X=/tmp/build`,
941
+ `X=/tmp/build; X=; rm -rf "$X"/*`,
942
+ `(X=/tmp/build); rm -rf "$X"/*`,
943
+ `X=/tmp/build | cat; rm -rf "$X"/*`,
944
+ `PWD=$(bun pm cache); rm -rf "$PWD"/*`,
945
+ `PWD=; rm -rf "$PWD"/*`,
946
+ // The default IS the worst case, so it is substituted verbatim rather than assumed safe.
947
+ `rm -rf "\${A:-/}"/*`,
948
+ ]) {
949
+ await expectBlocked(command);
950
+ }
951
+ // The genuine guarantees still hold.
952
+ await expectAllowed(`X=/tmp/build; rm -rf "$X"/*`);
953
+ await expectAllowed(`rm -rf "\${BUILD_DIR:-/tmp/build}"/*`);
954
+ });
955
+
956
+ test("a delete nested inside a parameter expansion is still a delete", async () => {
957
+ await expectBlocked(`echo "\${x:-$(rm -rf /*)}"`);
958
+ await expectBlocked("echo \"\${x:-`rm -rf /*`}\"");
959
+ });
960
+
961
+ test("brace fallback covers absolute alternatives, and ${} does not abandon expansion", async () => {
962
+ const many = Array.from({ length: 70 }, (_, i) => `a${i}`).join(",");
963
+ // An absolute alternative is not a child of the brace-free prefix.
964
+ await expectBlocked(`rm -rf {/etc,${many}}`);
965
+ await expectBlocked(`rm -rf {/,${many}}`);
966
+ // A ${...} before the group is not a brace group; treating it as one abandoned the token.
967
+ await expectBlocked(`rm -rf "\${HOME}"/{,.hasna}`);
968
+ await expectBlocked(`rm -rf "\${HOME}"/{bin,.hasna}`);
969
+ });
970
+
971
+ test("an escaped character inside a substitution is data, not structure", async () => {
972
+ // Same root cause as the quoted-paren bug: quotes were fixed, backslashes were not.
973
+ await expectBlocked(`rm -rf "$(echo \\')"/*`);
974
+ await expectBlocked(`rm -rf "$(grep -c \\( f)"/*`);
975
+ });
976
+
977
+ test("a catch-all in the first component sweeps the filesystem root", async () => {
978
+ for (const command of ["rm -rf /*/bin", "rm -rf /*/*", "find /*/x -delete"]) {
979
+ await expectBlocked(command);
980
+ }
981
+ });
982
+
983
+ test("everyday monorepo and ops cleanup is not blocked", async () => {
984
+ // A guard that blocks `rm -rf */node_modules` at a monorepo root gets switched off, and
985
+ // then it protects nothing at all. Matching on "the first glob's parent" caused exactly
986
+ // that: the trailing literal bounds the delete, so the repo root is never destroyed.
987
+ const repo = mkdtempSync(join(tmpdir(), "hooks-monorepo-"));
988
+ try {
989
+ for (const command of [
990
+ "rm -rf */node_modules",
991
+ "rm -rf */dist",
992
+ "rm -rf ./*/dist",
993
+ "rm -rf **/dist",
994
+ "rm -rf */*.log",
995
+ ]) {
996
+ await expectAllowed(command, { cwd: repo });
997
+ }
998
+ for (const command of [
999
+ "rm -rf /opt/*/logs",
1000
+ "rm -rf /var/*/tmp",
1001
+ "rm -rf /home/*/tmp",
1002
+ "rm -rf /srv/*/cache",
1003
+ "rm -rf ~/workspace/*/node_modules",
1004
+ ]) {
1005
+ await expectAllowed(command);
1006
+ }
1007
+ } finally {
1008
+ try { rmSync(repo, { recursive: true, force: true }); } catch {}
1009
+ }
1010
+ });
1011
+
1012
+ test("ordinary commands never reach the unanalysable-command refusal", async () => {
1013
+ // Every $( ) counts as a layer, so a low cap made 40 substitutions in one ops line trip a
1014
+ // hard block on `rm -rf dist`.
1015
+ const substitutions = Array.from({ length: 40 }, (_, i) => `echo $(echo ${i})`).join(" ; ");
1016
+ await expectAllowed(`${substitutions} ; rm -rf dist`);
1017
+ await expectAllowed(`${substitutions} ; rm -rf /tmp/scratch`);
1018
+ await expectAllowed(`${substitutions} ; find . -name '*.log' -delete`);
1019
+ await expectAllowed(`bash -c "sh -c 'sh -c \\"sh -c \\\\"rm -rf dist\\\\"\\"'"`);
1020
+ });
1021
+
1022
+ test("deeply nested parameter expansion cannot stall the hook into failing open", async () => {
1023
+ // 40k-deep `${A:-…}` overflowed the stack; the hook caught it and answered
1024
+ // {"continue":true}, so the `rm -rf /*` in the same command was never classified.
1025
+ const command = `rm -rf /* "${"${A:-".repeat(2000)}x${"}".repeat(2000)}"`;
1026
+ const started = performance.now();
1027
+ const result = await classify(command);
1028
+ expect(result.block).toBe(true);
1029
+ expect(performance.now() - started).toBeLessThan(2000);
1030
+ });
1031
+
1032
+ // -------------------------------------------------------------------------------------
1033
+ // Adversarial review round 4. Three of these were regressions introduced by round 3's own
1034
+ // fixes. Kept as one block and replayed whole - subsetting is how the last four rounds of
1035
+ // regressions got through.
1036
+ // -------------------------------------------------------------------------------------
1037
+
1038
+ test("bracket-class globs are compiled, not escaped", async () => {
1039
+ // `[e]tc` was escaped to the literal \[e\]tc, which no directory can equal, so the
1040
+ // component was classified as a glob and then matched as an impossible literal. One
1041
+ // character defeated every rule, every tool and every wrapper.
1042
+ for (const command of [
1043
+ "rm -rf /[e]tc",
1044
+ "rm -rf /[!x]*",
1045
+ "rm -rf /[^x]*",
1046
+ "rm -rf /[a-z]*",
1047
+ "rm -rf /home/[h]asna",
1048
+ "rm -rf /?tc",
1049
+ "find /[e]tc -delete",
1050
+ "rsync -a --delete src/ /[e]tc",
1051
+ "ssh station02 rm -rf /[e]tc",
1052
+ `bash -c "rm -rf /[!x]*"`,
1053
+ `rm -rf "$(bun pm cache)"/[a-z]*`,
1054
+ "rm -rf /home/hasna/.[h]asna",
1055
+ "rm -rf /home/hasna/.h*/repos",
1056
+ ]) {
1057
+ await expectBlocked(command);
1058
+ }
1059
+ // A bounded class deeper than any protected root stays allowed.
1060
+ await expectAllowed("rm -rf /var/log/[0-9]*.gz");
1061
+ });
1062
+
1063
+ test("a sibling subshell does not inherit the previous subshell's cd", async () => {
1064
+ // Regression from the per-depth frame stack: two siblings are both depth 1, so the second
1065
+ // inherited the first's `cd`. bash discards it, so the guard was aimed at an
1066
+ // attacker-chosen directory while the delete hit the real cwd.
1067
+ const repo = mkdtempSync(join(tmpdir(), "hooks-sibling-"));
1068
+ const elsewhere = mkdtempSync(join(tmpdir(), "hooks-elsewhere-"));
1069
+ try {
1070
+ mkdirSync(join(repo, "src"), { recursive: true });
1071
+ Bun.spawnSync(["git", "init", "-q", repo]);
1072
+ for (const command of [
1073
+ `(cd ${elsewhere}); (rm -rf *)`,
1074
+ `(cd ${elsewhere}) && (rm -rf *)`,
1075
+ `(cd ${elsewhere}); (cd src); (rm -rf *)`,
1076
+ `echo hi | (cd ${elsewhere}); (rm -rf *)`,
1077
+ `(cd ${elsewhere}); (rm -rf .)`,
1078
+ `(cd ${elsewhere}); (git clean -xfd)`,
1079
+ `(cd ${elsewhere}); (find . -delete)`,
1080
+ `true; (cd ${elsewhere}); (rm -rf *)`,
1081
+ ]) {
1082
+ await expectBlocked(command, { cwd: repo });
1083
+ }
1084
+ // The same-subshell case must keep working.
1085
+ await expectBlocked("(cd / && rm -rf *)", { cwd: repo });
1086
+ } finally {
1087
+ for (const dir of [repo, elsewhere]) {
1088
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
1089
+ }
1090
+ }
1091
+ });
1092
+
1093
+ test("a delete nested past the parameter-expansion cap is refused, not dropped", async () => {
1094
+ // The hardcoded depth-4 cap abandoned the scan without setting `truncated`, so the
1095
+ // refusal path never fired and the delete was never classified at all.
1096
+ for (const depth of [1, 4, 5, 8, 40]) {
1097
+ await expectBlocked(`echo ${"${x:-".repeat(depth)}$(rm -rf /*)${"}".repeat(depth)}`);
1098
+ }
1099
+ });
1100
+
1101
+ test("$PWD stands for the directory the guard is already tracking", async () => {
1102
+ const repo = mkdtempSync(join(tmpdir(), "hooks-pwd-"));
1103
+ try {
1104
+ Bun.spawnSync(["git", "init", "-q", repo]);
1105
+ for (const command of [`rm -rf "$PWD"/*`, `rm -rf "$(pwd)"/*`, "rm -rf ${PWD}/*"]) {
1106
+ await expectBlocked(command, { cwd: repo });
1107
+ }
1108
+ await expectBlocked(`cd / && rm -rf "$PWD"/*`);
1109
+ } finally {
1110
+ try { rmSync(repo, { recursive: true, force: true }); } catch {}
1111
+ }
1112
+ });
1113
+
1114
+ test("unset withdraws a non-empty guarantee", async () => {
1115
+ await expectBlocked(`X=/tmp; unset X; rm -rf "$X"/*`);
1116
+ await expectBlocked(`X=/tmp; unset X; rm -rf "$X"/etc`);
1117
+ await expectAllowed(`X=/tmp/build; rm -rf "$X"/*`);
1118
+ });
1119
+
1120
+ test("segment-count padding cannot stall the hook into failing open", async () => {
1121
+ // The per-segment assignment scan re-split the whole command on every chunk - O(n²).
1122
+ // 36 KB of `:; ` padding took 25.6s against a 20s timeout, and a timed-out hook fails
1123
+ // open, so padding alone unguarded the delete. Varies SEGMENT COUNT, which the earlier
1124
+ // "deeply nested expansion" test never did.
1125
+ const command = `${":; ".repeat(12000)}rm -rf /*`;
1126
+ const started = performance.now();
1127
+ const result = await classify(command);
1128
+ expect(result.block).toBe(true);
1129
+ expect(performance.now() - started).toBeLessThan(3000);
1130
+ });
1131
+
1132
+ // -------------------------------------------------------------------------------------
1133
+ // Adversarial review round 5.
1134
+ // -------------------------------------------------------------------------------------
1135
+
1136
+ test("bracket expressions whose terminator is not the first ] still match", async () => {
1137
+ // The compiler stopped at the first `]`, so `[[:lower:]]` compiled to a pattern requiring
1138
+ // a literal `]` and matched nothing - a silent under-match, which fails OPEN. Verified
1139
+ // against bash: /home/hasna/.hasn[[:lower:]] expands to /home/hasna/.hasna.
1140
+ for (const command of [
1141
+ "rm -rf /home/hasna/.hasn[[:lower:]]",
1142
+ "rm -rf /home/hasna/.[[:lower:]]asna",
1143
+ "rm -rf /home/hasna/.[[:lower:]]asna/repos",
1144
+ "rm -rf /home/hasna/.hasn[a\\]]",
1145
+ "rm -rf /[[:lower:]]tc",
1146
+ "rm -rf /[[:alpha:]][[:alpha:]][[:alpha:]]",
1147
+ `ssh station02 "rm -rf /home/hasna/.hasn[[:lower:]]"`,
1148
+ `bash -c "rm -rf /home/hasna/.hasn[[:lower:]]"`,
1149
+ "find /home/hasna/.hasn[[:lower:]] -delete",
1150
+ ]) {
1151
+ await expectBlocked(command);
1152
+ }
1153
+ });
1154
+
1155
+ test("popd returns the guard to where the shell returns", async () => {
1156
+ // `pushd` was handled and `popd` was not, so the pushd target stayed as the tracked cwd
1157
+ // for the rest of the command. Blocked on d8c0e8a; allowed from f6def3c until now.
1158
+ // Deliberately NOT under the pushd target: a repo inside /tmp made this pass for the
1159
+ // wrong reason, because `/tmp/*` covers the repo root whether or not popd is handled.
1160
+ const repo = mkdtempSync(join(fixtureRoot, "hooks-popd-"));
1161
+ try {
1162
+ Bun.spawnSync(["git", "init", "-q", repo]);
1163
+ for (const command of [
1164
+ "pushd /var/tmp >/dev/null; popd >/dev/null; rm -rf *",
1165
+ "pushd /var/tmp; popd; git clean -xfd",
1166
+ "pushd /var/tmp; popd; find . -delete",
1167
+ ]) {
1168
+ await expectBlocked(command, { cwd: repo });
1169
+ }
1170
+ // pushd without popd still moves it.
1171
+ await expectAllowed("pushd /var/tmp; rm -rf scratch-dir", { cwd: repo });
1172
+ } finally {
1173
+ try { rmSync(repo, { recursive: true, force: true }); } catch {}
1174
+ }
1175
+ });
1176
+
1177
+ test("any construct that can rebind a name withdraws the non-empty guarantee", async () => {
1178
+ for (const rebind of [
1179
+ "export X=$(bun pm cache)",
1180
+ "declare X=$(cmd)",
1181
+ "readonly X=$(cmd)",
1182
+ "local X=$(cmd)",
1183
+ "typeset X=$(cmd)",
1184
+ "read X < /dev/null",
1185
+ "eval 'X='",
1186
+ 'for X in ""; do :; done',
1187
+ ]) {
1188
+ await expectBlocked(`X=/tmp/build; ${rebind}; rm -rf "$X"/*`);
1189
+ }
1190
+ // A literal, non-empty binding through the same builtins is still a guarantee.
1191
+ await expectAllowed(`export X=/tmp/build; rm -rf "$X"/*`);
1192
+ await expectAllowed(`declare X=/tmp/build; rm -rf "$X"/*`);
1193
+ });
1194
+
1195
+ test("glob matching is linear, so a long protected root cannot stall the hook", async () => {
1196
+ // `*` compiled to `[^/]*` backtracked exponentially against a ~200-char root component:
1197
+ // over 45s against a 20s timeout, and a timed-out hook fails open.
1198
+ const base = mkdtempSync(join(tmpdir(), "hooks-redos-"));
1199
+ const repo = join(base, "ab".repeat(100));
1200
+ mkdirSync(repo, { recursive: true });
1201
+ try {
1202
+ Bun.spawnSync(["git", "init", "-q", repo]);
1203
+ const started = performance.now();
1204
+ await classify(`rm -rf ${base}/${"*b".repeat(15)}c`, { cwd: repo });
1205
+ expect(performance.now() - started).toBeLessThan(2000);
1206
+ } finally {
1207
+ try { rmSync(base, { recursive: true, force: true }); } catch {}
1208
+ }
1209
+ });
1210
+
1211
+ test("the narrow-glob sweep fires only on unanchored patterns", async () => {
1212
+ // Previously untested: deleting the whole rule left the suite green. It is also the
1213
+ // rule most able to over-block, so both directions are asserted here.
1214
+ const repo = mkdtempSync(join(tmpdir(), "hooks-sweep-"));
1215
+ try {
1216
+ Bun.spawnSync(["git", "init", "-q", repo]);
1217
+ // Unanchored - nothing literal survives, so it takes essentially the whole root.
1218
+ for (const command of ["rm -rf [a-z]*", "rm -rf [!x]*", "rm -rf ?*", "rm -rf *"]) {
1219
+ await expectBlocked(command, { cwd: repo });
1220
+ }
1221
+ // Anchored by literal text - cannot take the root, and blocking these got the guard
1222
+ // switched off last round.
1223
+ for (const command of [
1224
+ "rm -rf *.log",
1225
+ "rm -rf *.tsbuildinfo",
1226
+ "rm -rf .turbo* .next* .cache*",
1227
+ "rm -rf tmp-*",
1228
+ "rm -rf test-output-*",
1229
+ "rm -rf dist-*.zip",
1230
+ "rm -rf report-2026-*",
1231
+ "rm -rf ./*.tgz",
1232
+ "rm -rf out?/",
1233
+ "rm -rf .venv*",
1234
+ "rm -rf build-cache-*",
1235
+ "rm -rf snapshot-[0-9]*",
1236
+ "rm -rf [s]rc",
1237
+ ]) {
1238
+ await expectAllowed(command, { cwd: repo });
1239
+ }
1240
+ } finally {
1241
+ try { rmSync(repo, { recursive: true, force: true }); } catch {}
1242
+ }
1243
+ });
1244
+
1245
+ // -------------------------------------------------------------------------------------
1246
+ // Adversarial review round 6. Bracket handling produced a fresh under-match in every one
1247
+ // of the three rounds that touched it, so the design changed rather than the edge cases:
1248
+ // anything not modelled exactly now MATCHES. Over-blocking a rare construct is survivable;
1249
+ // under-matching a protected root is not.
1250
+ // -------------------------------------------------------------------------------------
1251
+
1252
+ test("unparseable bracket constructs match rather than vanish", async () => {
1253
+ // Each of these names a real path in bash - verified with echo - while the previous
1254
+ // matcher held a pattern that could match nothing at all.
1255
+ for (const command of [
1256
+ "rm -rf /[e[:]tc",
1257
+ "rm -rf /[e[=]tc",
1258
+ "rm -rf /h[o[:]me",
1259
+ "rm -rf /[u[:]sr",
1260
+ "rm -rf /[v[:]ar/*",
1261
+ "rm -rf /r[o[:]ot",
1262
+ "rm -rf ~/.h[a[:]sna",
1263
+ "rm -rf ~/.h[a[:]sna/repos",
1264
+ `rm -rf "$(bun pm cache)"/[e[:]tc`,
1265
+ // A negated UNKNOWN class: the fail-safe "unknown matches" was then inverted by the
1266
+ // negation into "matches nothing", turning the safeguard into the bypass.
1267
+ "rm -rf /[![:foo:]]tc",
1268
+ "rm -rf /[!x[:foo:]]tc",
1269
+ "rm -rf /[^[:foo:]]tc",
1270
+ "rm -rf /h[![:foo:]]me",
1271
+ // An escaped `]` mid-class dropped every member after it.
1272
+ "rm -rf /[a\\]e]tc",
1273
+ "rm -rf /[a\\]e]tc/*",
1274
+ "rm -rf /home/hasna/.hasn[b\\]a]",
1275
+ ]) {
1276
+ await expectBlocked(command);
1277
+ }
1278
+ });
1279
+
1280
+ test("bracket flooding cannot stall the hook into failing open", async () => {
1281
+ // Re-scanning to end-of-pattern from every `[` was quadratic: 20k brackets took 22s
1282
+ // against the 20s timeout, on a command that also contained a real `rm -rf /*`.
1283
+ //
1284
+ // Two shapes, because the first one alone did not actually exercise the quadratic: a
1285
+ // flooded target under `/` is answered by the covers check before the anchoring scan
1286
+ // ever runs, so that test passed with the quadratic still in place. The second shape -
1287
+ // flood as the last component of a repo-root child - is the one that reaches it.
1288
+ const repo = mkdtempSync(join(fixtureRoot, "hooks-flood-"));
1289
+ try {
1290
+ Bun.spawnSync(["git", "init", "-q", repo]);
1291
+ // Timing is the point here. The verdict differs per shape and both are correct:
1292
+ // a bracket run with NO `]` is a literal filename to bash (`printf %s "[[[["` prints
1293
+ // `[[[[`, and an unterminated `[` is not a glob), so deleting it is an ordinary
1294
+ // targeted delete; the flood next to a real `/*` is a root wipe and blocks.
1295
+ for (const [command, cwd] of [
1296
+ [`rm -rf /${"[".repeat(20000)} /*`, undefined],
1297
+ [`rm -rf ${"[".repeat(20000)}`, repo],
1298
+ [`rm -rf /${"[".repeat(20000)}]`, undefined],
1299
+ ] as Array<[string, string | undefined]>) {
1300
+ const started = performance.now();
1301
+ await classify(command, cwd ? { cwd } : {});
1302
+ expect(performance.now() - started).toBeLessThan(3000);
1303
+ }
1304
+ // The flood must not disarm the delete beside it.
1305
+ expect((await classify(`rm -rf /${"[".repeat(20000)} /*`)).block).toBe(true);
1306
+ // ...and a literal bracket filename is a targeted delete, not a sweep.
1307
+ expect((await classify(`rm -rf ${"[".repeat(20000)}`, { cwd: repo })).block).toBe(false);
1308
+ } finally {
1309
+ try { rmSync(repo, { recursive: true, force: true }); } catch {}
1310
+ }
1311
+ });
1312
+
1313
+ test("pushd -n records a directory without moving the shell", async () => {
1314
+ const repo = mkdtempSync(join(fixtureRoot, "hooks-pushdn-"));
1315
+ try {
1316
+ Bun.spawnSync(["git", "init", "-q", repo]);
1317
+ await expectBlocked("pushd -n /var/tmp; rm -rf *", { cwd: repo });
1318
+ await expectBlocked("pushd -n /var/tmp && rm -rf *", { cwd: repo });
1319
+ } finally {
1320
+ try { rmSync(repo, { recursive: true, force: true }); } catch {}
1321
+ }
1322
+ });
1323
+
1324
+ test("a subshell cannot pop the parent shell's directory stack", async () => {
1325
+ // The dirStack was copied per frame; sharing the array would let a subshell's popd
1326
+ // rewrite the parent's stack. Previously no test distinguished the two.
1327
+ const repo = mkdtempSync(join(fixtureRoot, "hooks-dirstack-"));
1328
+ try {
1329
+ Bun.spawnSync(["git", "init", "-q", repo]);
1330
+ await expectBlocked("pushd /var/tmp; (pushd /var); popd; rm -rf *", { cwd: repo });
1331
+ } finally {
1332
+ try { rmSync(repo, { recursive: true, force: true }); } catch {}
1333
+ }
1334
+ });
1335
+
1336
+ test("dot-anchored and punctuation-only globs are sweeps, not anchored patterns", async () => {
1337
+ const repo = mkdtempSync(join(fixtureRoot, "hooks-dotglob-"));
1338
+ try {
1339
+ Bun.spawnSync(["git", "init", "-q", repo]);
1340
+ for (const command of ["rm -rf .??*", "rm -rf .?*", "rm -rf .[a-z]*", "rm -rf *.*"]) {
1341
+ await expectBlocked(command, { cwd: repo });
1342
+ }
1343
+ // Real literal text still anchors, including after a dot.
1344
+ for (const command of ["rm -rf .turbo*", "rm -rf .venv*", "rm -rf *.log"]) {
1345
+ await expectAllowed(command, { cwd: repo });
1346
+ }
1347
+ } finally {
1348
+ try { rmSync(repo, { recursive: true, force: true }); } catch {}
1349
+ }
1350
+ });
1351
+
1352
+ test("every shape that can rebind a name withdraws the guarantee", async () => {
1353
+ for (const rebind of [
1354
+ "IFS= read -r D",
1355
+ "while read D; do :; done",
1356
+ "printf -v D ''",
1357
+ "source ./x.sh",
1358
+ ". ./x.sh",
1359
+ 'trap "D=" EXIT',
1360
+ "builtin read D",
1361
+ "declare -n D=E",
1362
+ "getopts o D",
1363
+ "coproc read D",
1364
+ ]) {
1365
+ await expectBlocked(`D=/tmp/b; ${rebind}; rm -rf "$D"/*`);
1366
+ }
1367
+ // `export X` with no value does NOT change X, so it must not withdraw anything.
1368
+ await expectAllowed(`X=/tmp/build; export X; rm -rf "$X"/*`);
1369
+ });
1370
+
1371
+ // -------------------------------------------------------------------------------------
1372
+ // Adversarial review round 7. The first block asserts the MATCHER directly: five of round
1373
+ // 6's fifteen bracket assertions passed with the glob matcher entirely removed, because a
1374
+ // bare glob under `/` is caught by the unanchored-root rule regardless. Adding one literal
1375
+ // character after the bracket stops that rule firing - which is exactly how these shipped.
1376
+ // -------------------------------------------------------------------------------------
1377
+
1378
+ test("a class terminator belonging to a later bracket is not taken as this one's", async () => {
1379
+ // `[u[:]` searched to end-of-component for `:]` and found the one inside the FOLLOWING
1380
+ // bracket, swallowing its own `]`. Verified in bash: /[u[:][[:alpha:]]r expands to /usr.
1381
+ // 158 commands onto live system roots were allowed by that one unbounded search.
1382
+ for (const command of [
1383
+ "rm -rf /[u[:][[:alpha:]]r",
1384
+ "rm -rf /[e[:][[:alpha:]]c",
1385
+ "rm -rf /[h[:][[:alpha:]]me",
1386
+ "rm -rf /[u[:][[:alpha:]]r/*",
1387
+ "rm -rf /b[i[:][[:alpha:]]",
1388
+ "rm -rf /[v[:][[:alpha:]]r",
1389
+ ]) {
1390
+ await expectBlocked(command);
1391
+ }
1392
+ });
1393
+
1394
+ test("a mention of an assignment is not an execution of it", async () => {
1395
+ // The all-token rebinding scan also GRANTED certification at any position, so a comment
1396
+ // naming the variable certified it as non-empty - the realized incident shape, in the
1397
+ // likeliest form an agent writes it.
1398
+ await expectBlocked('# export CACHE=/tmp/bun-cache\nrm -rf "$CACHE"/*');
1399
+ await expectBlocked('echo export X=/tmp/build; rm -rf "$X"/*');
1400
+ await expectBlocked('grep export CACHE=/tmp/x notes.txt; rm -rf "$CACHE"/*');
1401
+ // A trailing comment in the same segment. Covered by the same command-position rule -
1402
+ // `export` is not token 0 here either - and asserted so the shape is pinned.
1403
+ await expectBlocked('rm -rf "$CACHE"/* # export CACHE=/tmp/x');
1404
+ // A real assignment in command position still certifies.
1405
+ await expectAllowed('export X=/tmp/build; rm -rf "$X"/*');
1406
+ await expectAllowed('X=/tmp/build; rm -rf "$X"/*');
1407
+ });
1408
+
1409
+ test("an assignment inside a compound-command body withdraws the guarantee", async () => {
1410
+ for (const rebind of [
1411
+ "{ X=; }",
1412
+ "f() { X=; }; f",
1413
+ "{ unset X; }",
1414
+ "if true; then X=; fi",
1415
+ "while :; do X=; break; done",
1416
+ "until false; do X=; break; done",
1417
+ ]) {
1418
+ await expectBlocked(`X=/tmp/build; ${rebind}; rm -rf "$X"/*`);
1419
+ }
1420
+ await expectBlocked('X=/tmp/build; { X=; rm -rf "$X"/*; }');
1421
+ // Distinguishing case for the function-body rule: `f() { … }` is split on the parens, so
1422
+ // the keyword form is the one that actually needs it.
1423
+ await expectBlocked('X=/tmp/build; function f { X=; }; f; rm -rf "$X"/*');
1424
+ });
1425
+
1426
+ test("builtin-token padding cannot stall the hook into failing open", async () => {
1427
+ // The all-token scan allocated a slice per token: 20k `export A=1 ` took 20.9s against
1428
+ // the 20s timeout. Fourth time a bound in this file reopened that same fail-open.
1429
+ for (const padding of ["export A=1 ".repeat(20000), "read ".repeat(40000)]) {
1430
+ const started = performance.now();
1431
+ const result = await classify(`${padding}; rm -rf /*`);
1432
+ expect(result.block).toBe(true);
1433
+ // The single-pass implementation runs this in ~100ms. A 3s budget was loose enough
1434
+ // that re-introducing per-token slicing still passed, so the margin is tightened to
1435
+ // what the fix actually achieves.
1436
+ expect(performance.now() - started).toBeLessThan(1000);
1437
+ }
1438
+ });
1439
+
1440
+ test("a literal bracket in a filename is not a sweep", async () => {
1441
+ // Fail-closed matching must stop where bash stops globbing: `[` with no `]` is an
1442
+ // ordinary character, and these were re-broken twice on this branch.
1443
+ const repo = mkdtempSync(join(fixtureRoot, "hooks-litbracket-"));
1444
+ try {
1445
+ Bun.spawnSync(["git", "init", "-q", repo]);
1446
+ await expectAllowed("rm -rf 'weird[dir'", { cwd: repo });
1447
+ await expectAllowed("rm -rf 'backup[2026'", { cwd: repo });
1448
+ // Distinguishing case for the matcher itself, not the anchoring scan: without the
1449
+ // literal-bracket rule the unterminated `[` makes the component match ANYTHING, so
1450
+ // this reads as `/etc`. bash leaves it literal - `shopt -s nullglob; a=( /etc[x )`
1451
+ // still yields one element.
1452
+ // The bracket must be REACHED by the matcher for this to distinguish anything:
1453
+ // `etc[x` runs out of name before the `[`, so it is rejected on length alone.
1454
+ // `et[c` evaluates the bracket against `c`. bash leaves it literal - verified with
1455
+ // `shopt -s nullglob; a=( /et[c )` yielding one element.
1456
+ await expectAllowed("rm -rf /et[c");
1457
+ await expectAllowed("rm -rf /ho[me");
1458
+ await expectAllowed("rm -rf /etc[x");
1459
+ } finally {
1460
+ try { rmSync(repo, { recursive: true, force: true }); } catch {}
1461
+ }
1462
+ });
1463
+
1464
+ test("opaque builtins and for-bindings are recognised at any token position", async () => {
1465
+ // Both fixes were real and had ZERO coverage: every case that looked like it tested
1466
+ // all-token scanning was satisfied by the name-binding branch or by tokens[0].
1467
+ await expectBlocked(`X=/tmp/build; env eval 'X='; rm -rf "$X"/*`);
1468
+ await expectBlocked(`X=/tmp/build; command source ./x.sh; rm -rf "$X"/*`);
1469
+ await expectBlocked(`X=/tmp/build; do for X in ""; do :; done; rm -rf "$X"/*`);
1470
+ });
1471
+
1472
+ // -------------------------------------------------------------------------------------
1473
+ // Adversarial review round 8. The bracket BOUNDARY was the defect class that survived
1474
+ // seven rounds: contents failed closed, but every disagreement with bash about where a
1475
+ // bracket ENDS became a silent under-match. Round 6 searched too far, round 7 stopped too
1476
+ // early and made it net worse (220 -> 380 live escapes). The guard now refuses to compute
1477
+ // a boundary it cannot pin down.
1478
+ // -------------------------------------------------------------------------------------
1479
+
1480
+ test("a component with a POSIX class, equivalence or collating symbol matches anything", async () => {
1481
+ // Each expands onto a live protected root in real bash; each was allowed at HEAD.
1482
+ for (const command of [
1483
+ "rm -rf /[![=o=]]]*",
1484
+ "rm -rf /[[:]:]v]*",
1485
+ "rm -rf /*[[:[=c=]]",
1486
+ "rm -rf /[b[.][:]*n",
1487
+ "rm -rf /[h[.[::]]*",
1488
+ "rm -rf /*[[:]:]c]",
1489
+ "rm -rf /[p[.].]]*",
1490
+ "rm -rf /*[s[.].]]",
1491
+ "rm -rf /*[[=a=]]r]",
1492
+ "rm -rf /[b[.[::]]*",
1493
+ // Round 6 and 7 shapes, kept so neither direction can regress.
1494
+ "rm -rf /[e[:]tc",
1495
+ "rm -rf /[u[:][[:alpha:]]r",
1496
+ ]) {
1497
+ await expectBlocked(command);
1498
+ }
1499
+ });
1500
+
1501
+ test("the matcher itself matches every component bash matches", () => {
1502
+ // Asserted on the MATCHER, not on an end-to-end verdict. Round 7 found that 5 of 15
1503
+ // end-to-end bracket assertions passed with the matcher entirely removed, because the
1504
+ // unanchored-glob-at-the-root rule catches them regardless - which is exactly how the
1505
+ // boundary escapes shipped twice. Every pair below was confirmed against real bash with
1506
+ // `[[ $name == $pattern ]]`.
1507
+ for (const [pattern, name] of [
1508
+ ["[![=o=]]]*", "bin"],
1509
+ ["[[:]:]v]*", "var"],
1510
+ ["*[[:[=c=]]", "etc"],
1511
+ ["[b[.][:]*n", "bin"],
1512
+ ["[h[.[::]]*", "home"],
1513
+ ["*[[:]:]c]", "etc"],
1514
+ ["[p[.].]]*", "proc"],
1515
+ // bash matches `sys` here, not `usr` - the original pair was asserted as verified and
1516
+ // was not. It passed only because the blunt rule matches every literal, which is this
1517
+ // branch's signature failure: a green assertion whose stated cause is not its cause.
1518
+ ["*[s[.].]]", "sys"],
1519
+ ["*[[=a=]]r]", "var"],
1520
+ ["[e[:]tc", "etc"],
1521
+ ["[u[:][[:alpha:]]r", "usr"],
1522
+ ["[[:lower:]]tc", "etc"],
1523
+ ["[![:foo:]]tc", "etc"],
1524
+ ] as Array<[string, string]>) {
1525
+ expect(globComponentMatches(pattern, name), `${pattern} must match ${name}`).toBe(true);
1526
+ }
1527
+
1528
+ // The pairs above are answered by the unpinnable-boundary rule, which matches ANY name -
1529
+ // so the literal in each is inert and a wrong one cannot be detected. Asserted explicitly
1530
+ // rather than left implied, because the previous version of this test claimed to verify
1531
+ // the literals and could not.
1532
+ for (const pattern of ["*[s[.].]]", "[[:lower:]]tc", "[![:foo:]]tc"]) {
1533
+ expect(globComponentMatches(pattern, "ZZqq9Xw"), `${pattern} is unpinnable`).toBe(true);
1534
+ }
1535
+
1536
+ // Patterns WITHOUT an unpinnable construct exercise the real matcher, so their literals
1537
+ // are load-bearing. Every pair confirmed against bash `[[ $name == $pattern ]]`.
1538
+ for (const [pattern, name, want] of [
1539
+ ["[a-z]tc", "etc", true],
1540
+ ["[a-z]tc", "9tc", false],
1541
+ ["[!a-z]tc", "9tc", true],
1542
+ ["[!a-z]tc", "etc", false],
1543
+ ["u[s]r", "usr", true],
1544
+ ["u[s]r", "uxr", false],
1545
+ ["h[o-p]me", "home", true],
1546
+ ["h[a-c]me", "home", false],
1547
+ ["*b[i]n", "bin", true],
1548
+ ["*b[i]n", "ban", false],
1549
+ ["v[a]r", "var", true],
1550
+ ["v[a]r", "vor", false],
1551
+ ] as Array<[string, string, boolean]>) {
1552
+ expect(globComponentMatches(pattern, name), `${pattern} vs ${name}`).toBe(want);
1553
+ }
1554
+
1555
+ // ...and does not match what bash does not: a literal bracket is not a pattern.
1556
+ expect(globComponentMatches("et[c", "etc")).toBe(false);
1557
+ expect(globComponentMatches("etc[x", "etc")).toBe(false);
1558
+ expect(globComponentMatches("backup[2026", "backup")).toBe(false);
1559
+ // Ordinary globs keep working.
1560
+ expect(globComponentMatches("*.log", "app.log")).toBe(true);
1561
+ expect(globComponentMatches("*.log", "app.txt")).toBe(false);
1562
+ expect(globComponentMatches("[a-z]*", "etc")).toBe(true);
1563
+ expect(globComponentMatches("[!a-z]*", "etc")).toBe(false);
1564
+ });
1565
+
1566
+ test("an assignment in a branch that may not run does not certify", async () => {
1567
+ // Stripping a compound keyword is right for withdrawal - the branch might run - but it
1568
+ // must not grant certification, which asserts the value IS set. `then X=/tmp/build`
1569
+ // certified X and let `rm -rf "$X"/*` through as the realized incident shape.
1570
+ for (const command of [
1571
+ 'if [ -d /nonexistent ]; then CACHE=/tmp/c; fi; rm -rf "$CACHE"/*',
1572
+ 'if false; then X=/tmp/build; fi; rm -rf "$X"/*',
1573
+ 'while false; do X=/tmp/build; done; rm -rf "$X"/*',
1574
+ 'for i in ""; do X=/tmp/build; done; rm -rf "$X"/*',
1575
+ ]) {
1576
+ await expectBlocked(command);
1577
+ }
1578
+ // The same must hold through a value-binding builtin, which is a separate code path.
1579
+ await expectBlocked('if false; then export X=/tmp/build; fi; rm -rf "$X"/*');
1580
+ await expectBlocked('while false; do declare X=/tmp/build; done; rm -rf "$X"/*');
1581
+ // Unconditional assignment still certifies.
1582
+ await expectAllowed('X=/tmp/build; rm -rf "$X"/*');
1583
+ await expectAllowed('export X=/tmp/build; rm -rf "$X"/*');
1584
+ });
1585
+
1586
+ test("distinct-name padding cannot stall the hook into failing open", async () => {
1587
+ // The certified-name set was copied per segment - O(segments x names). 30k distinct names
1588
+ // took 24.4s against the 20s timeout. The walker now advances one set in place and hands
1589
+ // it out only for segments that actually delete something.
1590
+ const command = `${Array.from({ length: 30000 }, (_, i) => `A${i}=1`).join("; ")}; rm -rf /*`;
1591
+ const started = performance.now();
1592
+ const result = await classify(command);
1593
+ expect(result.block).toBe(true);
1594
+ expect(performance.now() - started).toBeLessThan(2000);
1595
+ });
1596
+
1597
+ test("~ and $HOME resolve through the same home as the protected roots", async () => {
1598
+ // `~` went through homedir() while $HOME and every rule root used process.env.HOME, so
1599
+ // wherever they differ - containers, `sudo -u`, CI - the target and the rule were built
1600
+ // from different directories and `rm -rf ~/.hasna` missed its own rule. It also made this
1601
+ // suite pass only on a machine whose HOME is literally /home/hasna.
1602
+ const elsewhere = mkdtempSync(join(fixtureRoot, "otherhome-"));
1603
+ await expectBlocked("rm -rf ~/.hasna", { home: elsewhere });
1604
+ await expectBlocked('rm -rf "$HOME"/.hasna', { home: elsewhere });
1605
+ await expectBlocked("rm -rf ~/.hasna/repos", { home: elsewhere });
1606
+ });
1607
+
1608
+ test("value-binding builtins and for-bindings are recognised away from position 0", async () => {
1609
+ // Both mechanisms were real and had zero coverage across 72 assertions.
1610
+ await expectBlocked('X=/tmp/build; IFS= export X=$(cmd); rm -rf "$X"/*');
1611
+ await expectBlocked('X=/tmp/build; LC_ALL=C read X; rm -rf "$X"/*');
1612
+ await expectBlocked('X=/tmp/build; time for X in ""; do :; done; rm -rf "$X"/*');
1613
+ });
1614
+
1615
+ // -------------------------------------------------------------------------------------
1616
+ // Adversarial review round 9. The bracket class is confirmed closed (0 under-matches in
1617
+ // 3.3M matcher cells against real bash). These cover what round 8's OTHER fix missed.
1618
+ // -------------------------------------------------------------------------------------
1619
+
1620
+ test("conditionality is a property of context, not of the first token", async () => {
1621
+ // Deriving it from "a compound keyword was stripped from token 0" closed ~5% of the
1622
+ // class: one inserted statement, or `&&`/`||`/`case`, restored certification. 297 of
1623
+ // those shapes were bash-proven `rm -rf /*`.
1624
+ for (const guard of [
1625
+ "if false; then A=1; X=/tmp/build; fi",
1626
+ "if false; then :; X=/tmp/build; fi",
1627
+ "false && X=/tmp/build",
1628
+ "true || X=/tmp/build",
1629
+ "[ -d /nonexistent ] && X=/tmp/build",
1630
+ "test -d /nonexistent && X=/tmp/build",
1631
+ "case x in y) X=/tmp/build;; esac",
1632
+ "until true; do A=1; X=/tmp/build; done",
1633
+ "grep -q zzz /dev/null && X=/tmp/build",
1634
+ ]) {
1635
+ await expectBlocked(`${guard}; rm -rf "$X"/*`);
1636
+ }
1637
+ await expectBlocked('if [ -d /nonexistent ]; then CACHE=/tmp/c; fi; rm -rf "$CACHE"/*');
1638
+
1639
+ // A brace group is NOT conditional - bash runs it in the current shell.
1640
+ await expectAllowed('X=/tmp/build; { export X=/tmp/build; }; rm -rf "$X"/*');
1641
+ await expectAllowed('X=/tmp/build; rm -rf "$X"/*');
1642
+ });
1643
+
1644
+ test("any unanchored first component sweeps the filesystem root, not just a literal *", async () => {
1645
+ // `/?*/bin` and `/[a-z]*/bin` expand to /usr/bin exactly as `/*/bin` does; the sweep rule
1646
+ // tested for a literal `*` where the design says an unpinnable component is unbounded.
1647
+ for (const command of [
1648
+ "rm -rf /*/bin",
1649
+ "rm -rf /?*/bin",
1650
+ "rm -rf /[a-z]*/bin",
1651
+ "rm -rf /[[:alpha:]]*/bin",
1652
+ "rm -rf /[[:lower:]]*/lib",
1653
+ ]) {
1654
+ await expectBlocked(command);
1655
+ }
1656
+ // Anchored first components are still ordinary targeted deletes.
1657
+ await expectAllowed("rm -rf /opt/*/logs");
1658
+ await expectAllowed("rm -rf /var/*/tmp");
1659
+ // `/tmp*/x` moved to BLOCK in round 10: at the filesystem root a single literal character
1660
+ // is not an anchor. Asserted in the round-10 sweep test.
1661
+ });
1662
+
1663
+ test("a long relative cd chain cannot stall the hook, and an absolute cd still lands", async () => {
1664
+ // Resolving an ever-growing path per `cd` was quadratic: 70k took 19.6s against the 20s
1665
+ // timeout. Capping at PATH_MAX fixed that but skipped a later absolute `cd`, which was
1666
+ // itself a fail-open - the guard stayed on the long path and `rm -rf *` was allowed.
1667
+ const flood = Array.from({ length: 70000 }, (_, i) => `cd d${i}`).join("; ");
1668
+ for (const command of [
1669
+ `${flood}; rm -rf /*`,
1670
+ `${flood}; cd /; rm -rf *`,
1671
+ `${flood}; cd ~; rm -rf .hasna`,
1672
+ ]) {
1673
+ const started = performance.now();
1674
+ const result = await classify(command);
1675
+ expect(result.block).toBe(true);
1676
+ expect(performance.now() - started).toBeLessThan(3000);
1677
+ }
1678
+ // Three iterations, each gated at 3000ms above, so this test's own assertions permit up to
1679
+ // 9000ms and still pass. The runner budget therefore has to sit strictly ABOVE 9000 or the
1680
+ // runner kills a run the assertions consider passing, and a genuine regression reads as an
1681
+ // infrastructure timeout rather than as the assertion's message — the same failure the
1682
+ // oversized-command test below was given a budget for. Building the 70k-element flood sits
1683
+ // outside the timed regions and is unmeasured by them. Measured 3.5-4.1s in total alone at
1684
+ // load ~15; 20000 is over twice what its own assertions already allow.
1685
+ }, 20000);
1686
+
1687
+ test("an unpinnable target says why it was refused", async () => {
1688
+ // Reporting only "targets /" for `rm -rf /var[.]log` is wrong and gets a guard switched
1689
+ // off; the operator needs to know it was refused as unanalysable.
1690
+ const result = await classify("rm -rf /var[.]log");
1691
+ expect(result.block).toBe(true);
1692
+ expect(result.reason).toContain("POSIX character class");
1693
+ });
1694
+
1695
+ test("the worktrees root resolves through the same home as everything else", async () => {
1696
+ // Same divergence as `~`, one function away: defaultWorktreesRoot used homedir() while
1697
+ // every rule root used process.env.HOME.
1698
+ const previous = process.env.HASNA_REPOS_WORKTREES_ROOT;
1699
+ delete process.env.HASNA_REPOS_WORKTREES_ROOT;
1700
+ const home = mkdtempSync(join(fixtureRoot, "wthome-"));
1701
+ try {
1702
+ process.env.HOME = home;
1703
+ expect(defaultWorktreesRoot()).toBe(join(home, ".hasna", "repos", "worktrees"));
1704
+ } finally {
1705
+ if (previous === undefined) delete process.env.HASNA_REPOS_WORKTREES_ROOT;
1706
+ else process.env.HASNA_REPOS_WORKTREES_ROOT = previous;
1707
+ process.env.HOME = INCIDENT_HOME;
1708
+ }
1709
+ });
1710
+
1711
+ // -------------------------------------------------------------------------------------
1712
+ // Adversarial review round 10.
1713
+ // -------------------------------------------------------------------------------------
1714
+
1715
+ test("a cd that shrinks the path is never skipped by the length cap", async () => {
1716
+ // The PATH_MAX cap skipped ALL relative operands once crossed, including `..`, which
1717
+ // shrinks. After one crossing the model froze while bash walked back to `/`, so
1718
+ // `rm -rf *` was allowed - a fail-open introduced by the cap itself.
1719
+ const repo = mkdtempSync(join(fixtureRoot, "unwind-"));
1720
+ Bun.spawnSync(["git", "init", "-q", repo]);
1721
+ // TWO operations only, so the cd BUDGET is nowhere near spent and this isolates the
1722
+ // length cap. One descent past PATH_MAX, then one `..` back: if `..` is skipped as
1723
+ // "relative", the guard stays deep while bash is back at the repo root.
1724
+ const deep = `cd ${"a".repeat(4200)}`;
1725
+ await expectBlocked(`${deep}; cd ..; rm -rf *`, { cwd: repo });
1726
+ // `.` is the repo root itself; `.git` is a child and is correctly not a protected root.
1727
+ await expectBlocked(`${deep}; cd ..; rm -rf .`, { cwd: repo });
1728
+
1729
+ // And the same over a long chain, where the budget is spent as well.
1730
+ const down = Array.from({ length: 2000 }, (_, i) => `cd d${i}`).join("; ");
1731
+ const up = Array.from({ length: 2100 }, () => "cd ..").join("; ");
1732
+ await expectBlocked(`${down}; ${up}; rm -rf *`, { cwd: repo });
1733
+ await expectBlocked(`${down}; ${up}; rm -rf home`, { cwd: repo });
1734
+ });
1735
+
1736
+ test("a keyword only counts in command position, and for/done stay balanced", async () => {
1737
+ for (const command of [
1738
+ // `for` opens because `done` closes; omitting it let any loop zero the counter.
1739
+ 'if [ -d /nonexistent ]; then for f in *.log; do echo "$f"; done; X=/tmp/build; fi; rm -rf "$X"/*',
1740
+ 'for i in $NOPE; do A=1; X=/tmp/build; done; rm -rf "$X"/*',
1741
+ // A literal `done`/`fi` WORD is not a closer.
1742
+ 'if false; then echo done; X=/tmp/build; fi; rm -rf "$X"/*',
1743
+ 'if false; then echo "fi"; X=/tmp/build; fi; rm -rf "$X"/*',
1744
+ 'if false; then touch done; X=/tmp/build; fi; rm -rf "$X"/*',
1745
+ 'if [ -d /opt/nonexistent-cache ]; then echo done; CACHE=/opt/nonexistent-cache; fi; rm -rf "$CACHE"/*',
1746
+ 'if false; then echo x # fi\nX=/tmp/build; fi; rm -rf "$X"/*',
1747
+ // `&&`/`||` govern the whole right-hand side, brace group included.
1748
+ 'false && { A=1; X=/tmp/build; }; rm -rf "$X"/*',
1749
+ 'true || { A=1; X=/tmp/build; }; rm -rf "$X"/*',
1750
+ 'case x in y) echo fi;;& z) X=/tmp/build;; esac; rm -rf "$X"/*',
1751
+ ]) {
1752
+ await expectBlocked(command);
1753
+ }
1754
+ });
1755
+
1756
+ test("a keyword-shaped word does not poison the rest of the command", async () => {
1757
+ // `elif` was counted as an opener although `fi` closes the chain once, and any token
1758
+ // spelled like a keyword set conditional - both refused certification for ordinary work.
1759
+ for (const command of [
1760
+ 'if a; then :; elif b; then :; fi; CACHE=/tmp/c; rm -rf "$CACHE"/*',
1761
+ 'echo "if"; CACHE=/tmp/c; rm -rf "$CACHE"/*',
1762
+ 'touch if; CACHE=/tmp/c; rm -rf "$CACHE"/*',
1763
+ 'echo select; CACHE=/tmp/c; rm -rf "$CACHE"/*',
1764
+ 'X=/tmp/build; { export X=/tmp/build; }; rm -rf "$X"/*',
1765
+ ]) {
1766
+ await expectAllowed(command);
1767
+ }
1768
+ });
1769
+
1770
+ test("any glob in the first component sweeps the filesystem root", async () => {
1771
+ // `/*r*/lib` reached 11 of 25 top-level directories on the reference machine; one literal
1772
+ // character is not an anchor at that depth.
1773
+ for (const command of ["rm -rf /*r*/lib", "rm -rf /*s*/bin", "rm -rf /s*/bin", "rm -rf /tmp*/x"]) {
1774
+ await expectBlocked(command);
1775
+ }
1776
+ // Deeper globs are still ordinary targeted deletes.
1777
+ await expectAllowed("rm -rf /opt/*/logs");
1778
+ await expectAllowed("rm -rf /var/*/tmp");
1779
+ });
1780
+
1781
+ test("a command too large to tokenize is decided, not left to the timeout", async () => {
1782
+ // 70k repetitions of `cd /<4KB>` is a 280 MB string; no per-rule bound helps because the
1783
+ // cost is reading the input, and a timed-out hook fails open.
1784
+ const oversized = Array.from({ length: 70000 }, () => `cd /${"a".repeat(3990)}`).join("; ");
1785
+ const started = performance.now();
1786
+ expect((await classify(`${oversized}; rm -rf /*`)).block).toBe(true);
1787
+ expect((await classify(oversized.replace(/cd /g, "echo "))).block).toBe(false);
1788
+ expect(performance.now() - started).toBeLessThan(15000);
1789
+ // The 15000ms assertion above is this test's real gate, so the runner budget must sit
1790
+ // strictly ABOVE it: at bun's 5000ms default the runner kills the test at 5s and the
1791
+ // assertion never gets to speak, which reads as an infrastructure timeout rather than
1792
+ // as the regression it is. Measured cost is ~10.0-11.0s on a contended 20-core box
1793
+ // (load ~30); 30000 is double the assertion and ~3x the observed need, so a slow run
1794
+ // still fails on the assertion's message and only a true hang reaches this backstop.
1795
+ }, 30000);
1796
+
1797
+ // -------------------------------------------------------------------------------------
1798
+ // Adversarial review round 11. All three bounds added the round before turned into root
1799
+ // wipes, each with its own improvised fallback. They now share ONE fail-closed answer:
1800
+ // a bound that stops modelling the command marks the analysis degraded, and a degraded
1801
+ // analysis carrying a recursive delete is refused.
1802
+ // -------------------------------------------------------------------------------------
1803
+
1804
+ test("a cd operand too long to model fails closed", async () => {
1805
+ // The operand-length cap dropped the cd entirely, so a single `cd` of 4200 slashes
1806
+ // followed by `rm -rf *` was allowed - bash lands at `/`, where `*` is 27 entries.
1807
+ // cwd is deliberately an ORDINARY directory, not a repo root: if the over-long cd is
1808
+ // skipped the guard stays here, and here is not protected, so the verdict depends only on
1809
+ // the cap failing closed. A repo-root cwd blocked either way and proved nothing.
1810
+ const plain = mkdtempSync(join(fixtureRoot, "longcd-"));
1811
+ for (const command of [
1812
+ `cd ${"/".repeat(4200)}; rm -rf *`,
1813
+ `cd ${"../".repeat(1400)}; rm -rf *`,
1814
+ `cd /${"./".repeat(2100)}; rm -rf *`,
1815
+ `cd ${"../".repeat(1400)}; rm -rf .*`,
1816
+ ]) {
1817
+ await expectBlocked(command, { cwd: plain });
1818
+ }
1819
+ });
1820
+
1821
+ test("an exhausted cd budget fails closed for named targets too", async () => {
1822
+ // Adding `/` to the candidate set only caught targets that resolve to a protected root
1823
+ // FROM `/`. A named relative target resolving against the real, unmodelled cwd is the
1824
+ // same hole: this one destroys the entire Hasna home.
1825
+ const dots = Array.from({ length: 2000 }, () => "cd .").join("; ");
1826
+ await expectBlocked(`cd /home/hasna; ${dots}; cd ..; rm -rf hasna`);
1827
+ });
1828
+
1829
+ test("an oversized command recognises a recursive delete in any flag position", async () => {
1830
+ // The gate was anchored to the token right after `rm`, so seven ordinary spellings sailed
1831
+ // past unanalysed once padded beyond the threshold.
1832
+ // Must exceed MAX_ANALYSABLE_COMMAND_LENGTH, or the command is analysed normally and the
1833
+ // oversized gate - the thing under test - is never consulted.
1834
+ const pad = `; # ${"x".repeat(33_000_000)}`;
1835
+ for (const command of [
1836
+ "rm -f -r /*",
1837
+ "rm -f -R /*",
1838
+ "rm -v -f -r /*",
1839
+ "rm -f --recursive /*",
1840
+ "/bin/rm -f -r /*",
1841
+ "rm --one-file-system -rf /*",
1842
+ "rm /home/hasna/.hasna -rf",
1843
+ ]) {
1844
+ await expectBlocked(`${command}${pad}`);
1845
+ }
1846
+ // ...and does not fire on a non-recursive rm, which is what `--force` used to trip.
1847
+ await expectAllowed(`rm --force /tmp/x${pad}`);
1848
+ });
1849
+
1850
+ test("a pipeline in a compound head does not swallow its opener", async () => {
1851
+ // The isolation early-return ran BEFORE keyword accounting, so `if … | …; then` lost the
1852
+ // `if` while its `fi` still decremented, and the assignment after it certified. This is
1853
+ // the realized incident shape.
1854
+ await expectBlocked('if ls /opt | grep -q node; then echo yes; CACHE=/var/cache/app; fi; rm -rf "$CACHE"/*');
1855
+ await expectBlocked('echo hi | while read -r l; do echo $l; X=/tmp/build; done; rm -rf "$X"/*');
1856
+ });
1857
+
1858
+ test("a cd reached only through && or || does not move the guard", async () => {
1859
+ // `cd /home/hasna; false && cd /tmp; rm -rf *` left the model in /tmp while bash stayed
1860
+ // in the home directory. Allowed at every commit back to 2fe8426.
1861
+ for (const command of [
1862
+ "cd /home/hasna; false && cd /tmp; rm -rf *",
1863
+ "cd /home/hasna; true || cd /tmp; rm -rf *",
1864
+ "cd /etc; false && cd /tmp; rm -rf *",
1865
+ ]) {
1866
+ await expectBlocked(command);
1867
+ }
1868
+ });
1869
+
1870
+ test("a quoted paren inside a substitution does not disable the collapse rule", async () => {
1871
+ // The unterminated-substitution fallback turned an ordinary awk field separator into a
1872
+ // bypass, because the quoted "(" was counted as structure.
1873
+ await expectBlocked(`rm -rf "$(awk -F'(' '{print $2}' conf.txt)"/*`);
1874
+ await expectBlocked(`rm -rf "$(echo ")")"/*`);
1875
+ });
1876
+
1877
+ test("combinatorial brace input cannot stall the hook into failing open", async () => {
1878
+ // Brace expansion is combinatorial: 26 groups is 2^26 paths. A version that capped only
1879
+ // the finished list took 19.75s, past this hook's 20s timeout - and a timed-out hook
1880
+ // fails open, so a long enough brace string would switch the guard off and then delete.
1881
+ const command = `rm -rf ${Array.from({ length: 26 }, (_, i) => `/{a${i},b${i}}`).join("")}`;
1882
+ const started = performance.now();
1883
+ await classify(command);
1884
+ expect(performance.now() - started).toBeLessThan(2000);
1885
+ });
1886
+
1887
+ test("cd in a subshell or pipeline does not move the guard, and cd - comes back", async () => {
1888
+ // Regression guard: tracking `cd` naively made these WEAKER than before the change,
1889
+ // because the tracker followed a `cd` that the real shell confines to a child process.
1890
+ const cwd = "/home/hasna/.hasna/projects/workspaces";
1891
+ for (const command of [
1892
+ "cd /var/tmp && ls && cd - && rm -rf *",
1893
+ "(cd /var/tmp && ls); rm -rf *",
1894
+ "cd /var/tmp | cat; rm -rf *",
1895
+ "cd /var/tmp; cd $OLDPWD; rm -rf *",
1896
+ "for f in a b; do (cd /var/tmp); done; rm -rf *",
1897
+ ]) {
1898
+ await expectBlocked(command, { cwd });
1899
+ }
1900
+ await expectAllowed("cd /var/tmp && rm -rf scratch", { cwd });
1901
+ });
1902
+
1903
+ test("shell options taking a value do not hide the -c script", async () => {
1904
+ for (const command of [
1905
+ "bash -o errexit -c 'rm -rf /*'",
1906
+ "bash -o pipefail -c 'rm -rf /*'",
1907
+ "sh -o errexit -c 'rm -rf /*'",
1908
+ "bash --rcfile /dev/null -c 'rm -rf /*'",
1909
+ ]) {
1910
+ await expectBlocked(command);
1911
+ }
1912
+ });
1913
+
1914
+ test("expansion nesting has no depth limit", async () => {
1915
+ await expectBlocked('rm -rf "$(dirname "$(dirname "$(bun pm cache)")")"/*');
1916
+ await expectBlocked('rm -rf "${A:-${B}}"/*');
1917
+ expect(emptyExpansionCollapse("${A:-${B}}/*")).toBe("/*");
1918
+ });
1919
+
1920
+ test("expansions the shell cannot return empty are not treated as collapsible", async () => {
1921
+ // These block routine cleanup if mishandled, and a guard that blocks routine work
1922
+ // gets switched off - which is how this class of incident recurs.
1923
+ await expectAllowed('rm -rf "$(pwd)"/*');
1924
+ await expectAllowed('rm -rf "$PWD"/*');
1925
+ await expectAllowed('rm -rf "${BUILD_DIR:-/tmp/build}"/*');
1926
+ await expectAllowed('BUILD=/tmp/build && rm -rf "$BUILD"/*');
1927
+ // ...but only where the guarantee is real.
1928
+ await expectBlocked('rm -rf "${BUILD_DIR-/tmp/build}"/*');
1929
+ await expectBlocked('BUILD=$(some-command) && rm -rf "$BUILD"/*');
1930
+ });
1931
+ });