@codyswann/lisa 4.34.5 → 4.34.6

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 (97) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +436 -9
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +636 -101
  3. package/all/copy-overwrite/scripts/lisa-test-node.mjs +44 -1
  4. package/dist/codex/scripts/block-no-verify.sh +608 -99
  5. package/dist/configs/vitest/scratch-global-setup.d.ts +29 -0
  6. package/dist/configs/vitest/scratch-global-setup.d.ts.map +1 -1
  7. package/dist/configs/vitest/scratch-global-setup.js +77 -12
  8. package/dist/configs/vitest/scratch-global-setup.js.map +1 -1
  9. package/dist/core/config-shadowing.d.ts +72 -0
  10. package/dist/core/config-shadowing.d.ts.map +1 -0
  11. package/dist/core/config-shadowing.js +152 -0
  12. package/dist/core/config-shadowing.js.map +1 -0
  13. package/dist/core/config.d.ts +16 -1
  14. package/dist/core/config.d.ts.map +1 -1
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/config.js.map +1 -1
  17. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  18. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  19. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  20. package/dist/core/lisa.d.ts +21 -0
  21. package/dist/core/lisa.d.ts.map +1 -1
  22. package/dist/core/lisa.js +46 -0
  23. package/dist/core/lisa.js.map +1 -1
  24. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  25. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  26. package/dist/core/upstream-evidence-manifest.js +17 -9
  27. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  28. package/dist/opencode/plugin-templates/block-no-verify.sh +636 -101
  29. package/dist/strategies/copy-overwrite.d.ts +42 -0
  30. package/dist/strategies/copy-overwrite.d.ts.map +1 -1
  31. package/dist/strategies/copy-overwrite.js +69 -5
  32. package/dist/strategies/copy-overwrite.js.map +1 -1
  33. package/expo/copy-overwrite/scripts/check-lighthouse-details.mjs +138 -11
  34. package/expo/create-only/.github/workflows/deploy.yml +51 -4
  35. package/package.json +4 -4
  36. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa/hooks/block-managed-file-edits.sh +436 -9
  39. package/plugins/lisa/hooks/block-no-verify.agy.sh +608 -99
  40. package/plugins/lisa/hooks/block-no-verify.sh +636 -101
  41. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +608 -99
  42. package/plugins/lisa-agy/plugin.json +1 -1
  43. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  46. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +436 -9
  50. package/plugins/lisa-copilot/hooks/block-no-verify.sh +636 -101
  51. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +436 -9
  53. package/plugins/lisa-cursor/hooks/block-no-verify.sh +636 -101
  54. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-expo-agy/plugin.json +1 -1
  57. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  62. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  67. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  72. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  76. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  77. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  81. package/plugins/lisa-rails-agy/plugin.json +1 -1
  82. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  86. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  87. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  91. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  92. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  94. package/plugins/src/base/hooks/block-managed-file-edits.sh +436 -9
  95. package/plugins/src/base/hooks/block-no-verify.agy.sh +608 -99
  96. package/plugins/src/base/hooks/block-no-verify.sh +636 -101
  97. package/rails/create-only/.github/workflows/deploy.yml +45 -1
@@ -10,7 +10,7 @@
10
10
  # `git -c "core.hooksPath=/dev/null"`.
11
11
  #
12
12
  # Capability names below are the canonical guard's, kept identical on every agent.
13
- # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization
13
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand, dispatcher-exec-position
14
14
  set -euo pipefail
15
15
 
16
16
  input="$(cat 2>/dev/null || true)"
@@ -72,13 +72,6 @@ def strip_heredocs(text: str) -> str:
72
72
  return "\n".join(output)
73
73
 
74
74
 
75
- try:
76
- tokens = shlex.split(strip_heredocs(command), posix=True)
77
- except ValueError:
78
- sys.exit(0)
79
-
80
- normalized_tokens = [token.strip("();|&") for token in tokens]
81
-
82
75
  # The only relocations that keep a repo's own hooks in play. `.husky` is the
83
76
  # husky convention this fleet runs; `.githooks` is the common hand-rolled one.
84
77
  # Anything else — including "" and /dev/null, which are simply the two most
@@ -589,8 +582,430 @@ def nested_shell_payload(tokens, index):
589
582
  return None
590
583
 
591
584
 
592
- def git_commit_skips_verification(text, depth=0):
593
- """Whether the command runs `git commit` with the short `-n` bypass.
585
+ # ── Reach: the contents of a script this command EXECUTES ──────────────────
586
+ #
587
+ # The guard used to inspect argv and nothing else, so `bash nv.sh` showed it two
588
+ # tokens, `bash` and a path, while the script it was about to run carried
589
+ # `git commit --no-verify` and skipped pre-commit exactly as completely as the
590
+ # inline spelling. Measured before this change: inline BLOCK, the same line one
591
+ # file away ALLOW — on bash, sh, zsh, `source`, `.`, an absolute interpreter
592
+ # path, and behind every wrapper.
593
+ #
594
+ # THE RULE IS TAKEN FROM parity-safety-net.sh RATHER THAN INVENTED HERE: a path
595
+ # is followed ONLY when the command EXECUTES it. `grep -n x nv.sh`,
596
+ # `git commit -F msg.txt` and `gh pr create --body-file body.md` name a file as
597
+ # DATA, and following those is the known-wrong fix that guard names by number —
598
+ # it refuses ordinary reads and attributes a file's capability to a command that
599
+ # merely mentions it. So COMMAND POSITION decides, never the presence of a path.
600
+ #
601
+ # Non-shell interpreters (`python3 x.py`, `node x.mjs`) are deliberately NOT
602
+ # followed. These matchers are git and shell syntax; running them over a Python
603
+ # or JS file buys mis-attribution, not coverage.
604
+ #
605
+ # Reach was not safe to add until the bare-token match was narrowed to a git
606
+ # argv — see `git_argv_disables_verification`. With the old unscoped matcher
607
+ # this guard would have refused `bash` on its own source, on every husky hook,
608
+ # and on much of this repository, all of which carry the literal token in prose.
609
+ FOLLOW_MAX_BYTES = 262144
610
+ FOLLOW_MAX_FILES = 8
611
+ FOLLOW_MAX_DEPTH = 3
612
+
613
+ # The `.` builtin and its `source` alias run the named file in the CURRENT
614
+ # shell, which is execution by any definition.
615
+ SOURCE_BUILTINS = {"source", "."}
616
+
617
+ # Wrappers that run what FOLLOWS them without changing what it is, mapped to
618
+ # (options whose value is a SEPARATE token, positional operands consumed).
619
+ #
620
+ # The second field is why this table exists rather than a bare set. `timeout 5
621
+ # bash x` puts a POSITIONAL between the wrapper and the interpreter, so a walk
622
+ # that steps over `-flags` only stops at `5` and never reaches `bash` — the
623
+ # shape that walked past three guards in this family before it was named.
624
+ FOLLOW_WRAPPERS = {
625
+ "builtin": (frozenset(), 0),
626
+ "command": (frozenset(), 0),
627
+ "exec": (frozenset({"-a"}), 0),
628
+ "nice": (frozenset({"-n", "--adjustment"}), 0),
629
+ "nohup": (frozenset(), 0),
630
+ "setsid": (frozenset(), 0),
631
+ "stdbuf": (frozenset({"-i", "-o", "-e"}), 0),
632
+ "sudo": (
633
+ frozenset(
634
+ {
635
+ "-C", "--close-from", "-g", "--group", "-h", "--host",
636
+ "-p", "--prompt", "-r", "--role", "-t", "--type",
637
+ "-U", "--other-user", "-u", "--user",
638
+ }
639
+ ),
640
+ 0,
641
+ ),
642
+ "time": (frozenset(), 0),
643
+ "timeout": (frozenset({"-k", "--kill-after", "-s", "--signal"}), 1),
644
+ }
645
+
646
+ # Dispatchers that BUILD a command out of their own arguments, putting an
647
+ # interpreter at a command position the text does not spell as one. Scoped to
648
+ # exactly one token — the word after `find -exec`/`-execdir`, and `xargs`'s
649
+ # first operand — because latching "an interpreter name anywhere" refuses
650
+ # `find . -name bash`, where `bash` is a filename PATTERN and nothing runs.
651
+ FIND_EXEC_FLAGS = {"-exec", "-execdir"}
652
+
653
+ # A value the SHELL computes. This guard sees the command before expansion, so
654
+ # it cannot prove which file such a token will name.
655
+ COMPUTED_VALUE = re.compile(r"[$`]")
656
+
657
+
658
+ def strip_shell_comments(text):
659
+ """Remove `#` comments from a file's contents, quote-aware.
660
+
661
+ Applied to FILE CONTENTS and never to a typed command, because the two are
662
+ genuinely different problems. On a typed command `#` handling is a
663
+ bash-versus-shlex divergence a guard can be attacked through, which is why
664
+ `shell_tokens` sets `commenters = ""`. Inside a file the question is not
665
+ adversarial and the answer is unambiguous: a comment cannot execute.
666
+
667
+ It is also what makes reach usable at all. This repository's guards, its
668
+ husky hooks and its own refusal messages discuss `git commit -n` and
669
+ `--no-verify` in prose, and a comment line reading "`git commit -n` skips
670
+ pre-commit" tokenizes into exactly the argv of a real bypass. Following a
671
+ file without dropping its comments refuses this guard's own source.
672
+
673
+ Args:
674
+ text: A file's contents.
675
+
676
+ Returns:
677
+ The same text with unquoted comments removed.
678
+ """
679
+ output = []
680
+ for line in text.splitlines():
681
+ quote = ""
682
+ escaped = False
683
+ cut = None
684
+ for position, character in enumerate(line):
685
+ if escaped:
686
+ escaped = False
687
+ continue
688
+ if character == "\\" and quote != "'":
689
+ escaped = True
690
+ continue
691
+ if quote:
692
+ if character == quote:
693
+ quote = ""
694
+ continue
695
+ if character in "'\"":
696
+ quote = character
697
+ continue
698
+ # A `#` only opens a comment at the start of a word, so `x#y` and
699
+ # `${x#y}` keep their hashes.
700
+ if character == "#" and (position == 0 or line[position - 1].isspace()):
701
+ cut = position
702
+ break
703
+ output.append(line if cut is None else line[:cut])
704
+ return "\n".join(output)
705
+
706
+
707
+ def statement_slices(tokens):
708
+ """Split an operator-aware token list into statements.
709
+
710
+ Args:
711
+ tokens: The full operator-aware token list.
712
+
713
+ Returns:
714
+ Pairs of (separator that PRECEDED the statement, its tokens). The
715
+ separator is carried because `<` is both a statement boundary and the
716
+ link between `bash` and the script it reads — `bash < nv.sh` runs that
717
+ file as surely as `bash nv.sh` does.
718
+ """
719
+ statements = []
720
+ current = []
721
+ separator = ""
722
+ for token in tokens:
723
+ if token in COMMAND_SEPARATORS:
724
+ statements.append((separator, current))
725
+ current = []
726
+ separator = token
727
+ continue
728
+ current.append(token)
729
+ statements.append((separator, current))
730
+ return statements
731
+
732
+
733
+ def command_word(statement):
734
+ """The program a statement runs, and the tokens after it.
735
+
736
+ Steps over leading variable assignments and over wrappers that do not
737
+ change what runs. Reading a wrapper as the command is how a payload hides
738
+ from a guard that inspects only a statement's first word.
739
+
740
+ Args:
741
+ statement: One statement's tokens.
742
+
743
+ Returns:
744
+ A triple (program, args, opaque). `opaque` is True when a wrapper's
745
+ option grammar could not be read, which the caller must treat as an
746
+ execution it cannot follow rather than as an ordinary command.
747
+ """
748
+ index = 0
749
+ while index < len(statement):
750
+ token = statement[index]
751
+ if "=" in token and not token.startswith(("=", "-")):
752
+ index += 1
753
+ continue
754
+ program = token.rsplit("/", 1)[-1]
755
+ if program == "env":
756
+ index += 1
757
+ while index < len(statement):
758
+ option = statement[index]
759
+ if option == "--":
760
+ index += 1
761
+ break
762
+ kind = env_option_kind(option)
763
+ if kind is None:
764
+ if "=" in option and not option.startswith(("=", "-")):
765
+ index += 1
766
+ continue
767
+ break
768
+ if kind in {"ambiguous", "split-string"}:
769
+ # `env -S` reparses an opaque payload as shell words, and an
770
+ # unnameable option makes the rest unreadable. Neither can be
771
+ # resolved to a program.
772
+ return (None, [], True)
773
+ index += 2 if kind == "separate-value" else 1
774
+ continue
775
+ if program in FOLLOW_WRAPPERS:
776
+ separate, positional = FOLLOW_WRAPPERS[program]
777
+ index += 1
778
+ while index < len(statement) and statement[index].startswith("-"):
779
+ option = statement[index]
780
+ if option == "--":
781
+ index += 1
782
+ break
783
+ index += 2 if option in separate else 1
784
+ index += positional
785
+ continue
786
+ return (program, statement[index + 1 :], False)
787
+ return (None, [], False)
788
+
789
+
790
+ def shell_script_operand(args):
791
+ """The file a shell invocation runs, when it runs one.
792
+
793
+ Args:
794
+ args: The tokens following the shell's own name.
795
+
796
+ Returns:
797
+ The operand token, or None when the invocation runs no script file —
798
+ an interactive shell, or a `-c` command string, which
799
+ `nested_shell_payload` already owns.
800
+ """
801
+ index = 0
802
+ opaque_option_seen = False
803
+ while index < len(args):
804
+ token = args[index]
805
+ if token == "--":
806
+ index += 1
807
+ break
808
+ if not token.startswith(("-", "+")):
809
+ if not opaque_option_seen:
810
+ break
811
+ index += 1
812
+ opaque_option_seen = False
813
+ continue
814
+ if token in SHELL_OPTIONS_SEPARATE_VALUE:
815
+ index += 2
816
+ continue
817
+ if token.startswith(SHELL_OPTIONS_INLINE_VALUE):
818
+ index += 1
819
+ continue
820
+ if token.startswith("--"):
821
+ if token not in SHELL_LONG_OPTIONS_NO_VALUE:
822
+ opaque_option_seen = True
823
+ index += 1
824
+ continue
825
+ if "c" in token[1:]:
826
+ return None
827
+ index += 1
828
+ return args[index] if index < len(args) else None
829
+
830
+
831
+ def dispatched_commands(program, args):
832
+ """Commands a dispatcher builds out of its own arguments.
833
+
834
+ `find … -exec bash {} \\;` and `find … | xargs bash` put an interpreter at
835
+ a command position the text does not spell as one. The exception is scoped
836
+ to a `find`/`xargs` statement and to exactly ONE token, because latching an
837
+ interpreter NAME anywhere refuses `find . -name bash` and
838
+ `xargs grep bash`, where the word is a pattern and nothing is executed.
839
+
840
+ Args:
841
+ program: The dispatcher's name.
842
+ args: Its arguments.
843
+
844
+ Returns:
845
+ Pairs of (program, args) the dispatcher will run.
846
+ """
847
+ built = []
848
+ if program == "find":
849
+ for index, token in enumerate(args):
850
+ if token in FIND_EXEC_FLAGS and index + 1 < len(args):
851
+ built.append((args[index + 1].rsplit("/", 1)[-1], args[index + 2 :]))
852
+ return built
853
+ if program != "xargs":
854
+ return built
855
+ index = 0
856
+ while index < len(args):
857
+ token = args[index]
858
+ if token == "--":
859
+ index += 1
860
+ break
861
+ if not token.startswith("-"):
862
+ break
863
+ # `-I`, `-n`, `-P`, `-s`, `-a`, `-d`, `-E`, `-L` take a value; the rest
864
+ # do not. Over-consuming here only loses the dispatcher, which degrades
865
+ # to the fail-open this file's header already accepts.
866
+ index += 2 if token in {"-I", "-n", "-P", "-s", "-a", "-d", "-E", "-L"} else 1
867
+ if index < len(args):
868
+ built.append((args[index].rsplit("/", 1)[-1], args[index + 1 :]))
869
+ return built
870
+
871
+
872
+ def resolve_script(token):
873
+ """Resolve a token naming a script to a readable file.
874
+
875
+ Args:
876
+ token: The operand an interpreter will run.
877
+
878
+ Returns:
879
+ A pair (path, reason). Exactly one is set. `reason` names why the
880
+ execution could not be followed, which the caller fails CLOSED on:
881
+ silence about a command that is definitely running something the guard
882
+ cannot read is the fail-open this change exists to close.
883
+ """
884
+ if COMPUTED_VALUE.search(token):
885
+ return (None, "a computed path the guard cannot resolve before the shell does")
886
+ text = token.strip().strip("'\"")
887
+ # `bash -` and a bare `-` read the script from stdin; there is no file.
888
+ if not text or text == "-":
889
+ return (None, None)
890
+ candidates = [text]
891
+ if not os.path.isabs(text):
892
+ project = os.environ.get("CLAUDE_PROJECT_DIR", "")
893
+ candidates.append(os.path.join(os.getcwd(), text))
894
+ if project:
895
+ candidates.append(os.path.join(project, text))
896
+ for candidate in candidates:
897
+ try:
898
+ if not os.path.isfile(candidate):
899
+ continue
900
+ if os.path.getsize(candidate) > FOLLOW_MAX_BYTES:
901
+ return (None, "a script larger than the inspection cap")
902
+ except OSError:
903
+ continue
904
+ return (candidate, None)
905
+ return (None, "a script that does not exist or cannot be read")
906
+
907
+
908
+ def executed_scripts(tokens):
909
+ """The files a command line will RUN, and the executions it cannot follow.
910
+
911
+ Args:
912
+ tokens: The full operator-aware token list.
913
+
914
+ Returns:
915
+ A pair (paths, reasons). `reasons` is non-empty when a command position
916
+ clearly runs a script the guard cannot read.
917
+ """
918
+ paths = []
919
+ reasons = []
920
+ previous_shell_awaiting_stdin = False
921
+ for separator, statement in statement_slices(tokens):
922
+ if separator == "<" and previous_shell_awaiting_stdin and statement:
923
+ # `bash < nv.sh`: the redirect target is the script.
924
+ path, reason = resolve_script(statement[0])
925
+ if reason:
926
+ reasons.append((reason, statement[0]))
927
+ elif path:
928
+ paths.append(path)
929
+ previous_shell_awaiting_stdin = False
930
+ if not statement:
931
+ continue
932
+ program, args, opaque = command_word(statement)
933
+ if opaque:
934
+ reasons.append(("an invocation whose wrapper options cannot be read", ""))
935
+ continue
936
+ if program is None:
937
+ continue
938
+ for name, arguments in [(program, args)] + dispatched_commands(program, args):
939
+ if name in SOURCE_BUILTINS:
940
+ operand = arguments[0] if arguments else None
941
+ elif name in SHELL_PROGRAMS:
942
+ operand = shell_script_operand(arguments)
943
+ if operand is None and not arguments:
944
+ previous_shell_awaiting_stdin = True
945
+ else:
946
+ continue
947
+ if operand is None:
948
+ continue
949
+ path, reason = resolve_script(operand)
950
+ if reason:
951
+ reasons.append((reason, operand))
952
+ elif path:
953
+ paths.append(path)
954
+ return paths, reasons
955
+
956
+
957
+ def read_script(path):
958
+ """The text of a script the command runs, comments removed.
959
+
960
+ Args:
961
+ path: A path from `resolve_script`.
962
+
963
+ Returns:
964
+ The contents, or an empty string when unreadable.
965
+ """
966
+ try:
967
+ with open(path, encoding="utf-8", errors="replace") as handle:
968
+ return strip_shell_comments(handle.read(FOLLOW_MAX_BYTES))
969
+ except OSError:
970
+ return ""
971
+
972
+
973
+ def git_argv_disables_verification(argv):
974
+ """Whether a git invocation's argv carries `--no-verify` or an abbreviation.
975
+
976
+ SCOPED to a git invocation, which is the change that made file reach usable.
977
+ The token match used to fire anywhere on the line, so `echo --no-verify` and
978
+ `grep -rl -- "--no-verify" scripts/` were both refused — ordinary commands
979
+ that run no git at all. That was survivable while the guard read only argv.
980
+ It is not once the guard reads FILE CONTENTS, because this file, every husky
981
+ hook, and much of this repository carry the literal token in prose and in
982
+ refusal text; an unscoped matcher plus reach refuses the repository's own
983
+ operations on the first command.
984
+
985
+ Deliberately NOT narrowed to `commit`. `git push --no-verify` skips pre-push
986
+ and `git am` / `git merge` skip their own hooks, so every subcommand's argv
987
+ is scanned. Only the SHORT `-n` needs the commit scope, because `-n` is
988
+ --dry-run to push and --no-stat to merge — see
989
+ `commit_bypasses_verification`.
990
+
991
+ Args:
992
+ argv: Tokens following the `git` token, to the end of the line.
993
+
994
+ Returns:
995
+ True if git would read one of them as --no-verify.
996
+ """
997
+ for token in argv:
998
+ # `--` ends the options; a pathspec literally named `--no-verify` is a
999
+ # file, not a bypass.
1000
+ if token in COMMAND_SEPARATORS or token == "--":
1001
+ return False
1002
+ if disables_verification(token):
1003
+ return True
1004
+ return False
1005
+
1006
+
1007
+ def git_skips_verification(text, depth=0):
1008
+ """Whether the command runs a git invocation that skips verification.
594
1009
 
595
1010
  Args:
596
1011
  text: The command line, heredoc payloads already stripped.
@@ -613,6 +1028,11 @@ def git_commit_skips_verification(text, depth=0):
613
1028
  # (`HUSKY=1 git commit -n`) simply sits in an earlier token.
614
1029
  if token != "git" and not token.endswith("/git"):
615
1030
  continue
1031
+ # The LONG flag, scoped to this invocation's argv rather than matched
1032
+ # anywhere on the line. This replaces the unscoped token match that used
1033
+ # to live in the flat loop below.
1034
+ if git_argv_disables_verification(scoped_tokens[index + 1 :]):
1035
+ return True
616
1036
  found = subcommand_after_git(scoped_tokens, index + 1)
617
1037
  if found and found[0] == "commit" and commit_bypasses_verification(found[1]):
618
1038
  return True
@@ -627,101 +1047,190 @@ def git_commit_skips_verification(text, depth=0):
627
1047
  payload = nested_shell_payload(scoped_tokens, index)
628
1048
  if payload is True:
629
1049
  return True
630
- if isinstance(payload, str) and git_commit_skips_verification(
631
- payload, depth + 1
632
- ):
1050
+ if isinstance(payload, str) and git_skips_verification(payload, depth + 1):
633
1051
  return True
634
1052
  return False
635
1053
 
636
1054
 
637
- if git_commit_skips_verification(strip_heredocs(command)):
638
- sys.exit(1)
1055
+ def token_bypass(tokens):
1056
+ """Whether any token disables hooks by environment or by git config.
1057
+
1058
+ These stay UNSCOPED, unlike the `--no-verify` match that moved into
1059
+ `git_argv_disables_verification`. The difference is what the token means
1060
+ where it appears: `HUSKY=0` and `core.hooksPath=` are assignments that
1061
+ configure whatever git runs next, so they legitimately sit before the
1062
+ invocation and often on a different line of a script. `--no-verify` is an
1063
+ option, and an option belongs to an argv.
639
1064
 
640
- for i, token in enumerate(normalized_tokens):
641
- if disables_verification(token):
642
- sys.exit(1)
643
- if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
644
- sys.exit(1)
645
- # Allowlist the destinations, do not denylist the disabling ones.
646
- #
647
- # This used to block only "" and /dev/null. But hooks are disabled just as
648
- # completely by pointing hooksPath at any directory that happens to contain
649
- # none — `-c core.hooksPath=/tmp/empty` bypassed every hook while reading as
650
- # an ordinary path — so a denylist of "obviously disabling" values can
651
- # always be stepped around by naming a third thing. The set of paths that
652
- # DISABLE hooks is unbounded; the set that legitimately relocates them is
653
- # tiny and known, so the allowlist is the only side that can be enumerated.
654
- #
655
- # Matched case-insensitively because git config variable names are:
656
- # `CORE.HOOKSPATH=/x` and `core.hookspath=/x` are the same setting to git,
657
- # so a case-sensitive check is bypassed by holding down shift.
658
- lowered = token.lower()
659
- if lowered.startswith("core.hookspath="):
660
- if not is_permitted_hooks_path(token.split("=", 1)[1]):
661
- sys.exit(1)
662
- if lowered == "core.hookspath" and i + 1 < len(normalized_tokens):
663
- if not is_permitted_hooks_path(normalized_tokens[i + 1]):
664
- sys.exit(1)
665
- # `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
666
- # the value out of the named environment variable. The path therefore is
667
- # not in the command at all, so there is nothing to allowlist against —
668
- # SOMEVAR can hold anything by the time git reads it. Any core.hooksPath
669
- # routed through --config-env is refused outright.
670
- if lowered.startswith("--config-env="):
671
- spec = token.split("=", 1)[1]
672
- if spec.split("=", 1)[0].strip().lower() == "core.hookspath":
673
- sys.exit(1)
674
- # git accepts `--config-env <name>=<envvar>` as TWO tokens as well as one,
675
- # and guarding only the `=` spelling was worse than missing the separate
676
- # form outright: the trailing `core.hooksPath=.husky` then fell through to
677
- # the allowlist above, which reads `.husky` as a PATH and permits it. But
678
- # here it is an ENVIRONMENT VARIABLE NAME, and `env '.husky=/dev/null' git
679
- # --config-env core.hooksPath=.husky` really does resolve hooksPath to
680
- # /dev/null. The allowlist was being used as the bypass.
681
- #
682
- # Checked at the `--config-env` token, which the loop reaches first, so the
683
- # refusal happens before the value token can be mistaken for a path.
684
- if lowered == "--config-env" and i + 1 < len(normalized_tokens):
685
- spec = normalized_tokens[i + 1]
686
- if spec.split("=", 1)[0].strip().strip("'\"").lower() == "core.hookspath":
687
- sys.exit(1)
688
- # `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath
689
- # GIT_CONFIG_VALUE_0=/dev/null git commit` sets command-scope config the
690
- # same way `-c core.hooksPath=...` does — env-var-style assignments ahead of
691
- # the invocation instead of a flag — so it disables every hook just as
692
- # completely while matching none of the token shapes above. Upstream missed
693
- # this until a downstream fork hardened its own copy against it, which is
694
- # the one direction a guard must never be caught in.
695
- #
696
- # The index is matched as `\d+` rather than pinned to 0: git accepts any
697
- # index below GIT_CONFIG_COUNT, so a single-index check is evaded by typing
698
- # a 1. Refused outright, like --config-env=, because the path lives in a
699
- # separate GIT_CONFIG_VALUE_<n> token that can be exported earlier,
700
- # reordered, or left out entirely — there is nothing here to allowlist
701
- # against.
702
- key_match = re.match(r"git_config_key_\d+=(.*)$", lowered, re.DOTALL)
703
- if key_match and key_match.group(1).strip().strip("'\"") == "core.hookspath":
704
- sys.exit(1)
705
- # `git -c` propagates command-scope config through GIT_CONFIG_PARAMETERS.
706
- # Parsing the value as Git's shell-quoted parameter list distinguishes the
707
- # key from an unrelated value that merely contains the same text.
708
- if re.match(r"git_config_parameters\+?=", lowered):
709
- parameters = token.split("=", 1)[1]
710
- # The guard sees the command before the shell expands assignments. A
711
- # variable or command substitution can therefore hide hooksPath from
712
- # this parser and reveal it only to Git. Refuse unresolved values; the
713
- # guard cannot safely prove what configuration they will become.
714
- if "$" in parameters or "`" in parameters:
715
- sys.exit(1)
1065
+ Args:
1066
+ tokens: Punctuation-stripped tokens of one command or script.
1067
+
1068
+ Returns:
1069
+ True when a token disables verification.
1070
+ """
1071
+ for i, token in enumerate(tokens):
1072
+ if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
1073
+ return True
1074
+ # Allowlist the destinations, do not denylist the disabling ones.
1075
+ #
1076
+ # This used to block only "" and /dev/null. But hooks are disabled just
1077
+ # as completely by pointing hooksPath at any directory that happens to
1078
+ # contain none — `-c core.hooksPath=/tmp/empty` bypassed every hook while
1079
+ # reading as an ordinary path — so a denylist of "obviously disabling"
1080
+ # values can always be stepped around by naming a third thing. The set of
1081
+ # paths that DISABLE hooks is unbounded; the set that legitimately
1082
+ # relocates them is tiny and known, so the allowlist is the only side
1083
+ # that can be enumerated.
1084
+ #
1085
+ # Matched case-insensitively because git config variable names are:
1086
+ # `CORE.HOOKSPATH=/x` and `core.hookspath=/x` are the same setting to
1087
+ # git, so a case-sensitive check is bypassed by holding down shift.
1088
+ lowered = token.lower()
1089
+ if lowered.startswith("core.hookspath="):
1090
+ if not is_permitted_hooks_path(token.split("=", 1)[1]):
1091
+ return True
1092
+ if lowered == "core.hookspath" and i + 1 < len(tokens):
1093
+ if not is_permitted_hooks_path(tokens[i + 1]):
1094
+ return True
1095
+ # `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
1096
+ # the value out of the named environment variable. The path therefore is
1097
+ # not in the command at all, so there is nothing to allowlist against —
1098
+ # SOMEVAR can hold anything by the time git reads it. Any core.hooksPath
1099
+ # routed through --config-env is refused outright.
1100
+ if lowered.startswith("--config-env="):
1101
+ spec = token.split("=", 1)[1]
1102
+ if spec.split("=", 1)[0].strip().lower() == "core.hookspath":
1103
+ return True
1104
+ # git accepts `--config-env <name>=<envvar>` as TWO tokens as well as
1105
+ # one, and guarding only the `=` spelling was worse than missing the
1106
+ # separate form outright: the trailing `core.hooksPath=.husky` then fell
1107
+ # through to the allowlist above, which reads `.husky` as a PATH and
1108
+ # permits it. But here it is an ENVIRONMENT VARIABLE NAME, and
1109
+ # `env '.husky=/dev/null' git --config-env core.hooksPath=.husky` really
1110
+ # does resolve hooksPath to /dev/null. The allowlist was being used as
1111
+ # the bypass.
1112
+ #
1113
+ # Checked at the `--config-env` token, which the loop reaches first, so
1114
+ # the refusal happens before the value token can be mistaken for a path.
1115
+ if lowered == "--config-env" and i + 1 < len(tokens):
1116
+ spec = tokens[i + 1]
1117
+ if spec.split("=", 1)[0].strip().strip("'\"").lower() == "core.hookspath":
1118
+ return True
1119
+ # `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath
1120
+ # GIT_CONFIG_VALUE_0=/dev/null git commit` sets command-scope config the
1121
+ # same way `-c core.hooksPath=...` does — env-var-style assignments ahead
1122
+ # of the invocation instead of a flag — so it disables every hook just as
1123
+ # completely while matching none of the token shapes above. Upstream
1124
+ # missed this until a downstream fork hardened its own copy against it,
1125
+ # which is the one direction a guard must never be caught in.
1126
+ #
1127
+ # The index is matched as `\d+` rather than pinned to 0: git accepts any
1128
+ # index below GIT_CONFIG_COUNT, so a single-index check is evaded by
1129
+ # typing a 1. Refused outright, like --config-env=, because the path
1130
+ # lives in a separate GIT_CONFIG_VALUE_<n> token that can be exported
1131
+ # earlier, reordered, or left out entirely — there is nothing here to
1132
+ # allowlist against.
1133
+ key_match = re.match(r"git_config_key_\d+=(.*)$", lowered, re.DOTALL)
1134
+ if key_match and key_match.group(1).strip().strip("'\"") == "core.hookspath":
1135
+ return True
1136
+ # `git -c` propagates command-scope config through GIT_CONFIG_PARAMETERS.
1137
+ # Parsing the value as Git's shell-quoted parameter list distinguishes
1138
+ # the key from an unrelated value that merely contains the same text.
1139
+ if re.match(r"git_config_parameters\+?=", lowered):
1140
+ parameters = token.split("=", 1)[1]
1141
+ # The guard sees the command before the shell expands assignments. A
1142
+ # variable or command substitution can therefore hide hooksPath from
1143
+ # this parser and reveal it only to Git. Refuse unresolved values;
1144
+ # the guard cannot safely prove what configuration they will become.
1145
+ if "$" in parameters or "`" in parameters:
1146
+ return True
1147
+ try:
1148
+ configured = shlex.split(parameters, posix=True)
1149
+ except ValueError:
1150
+ configured = []
1151
+ if any(
1152
+ parameter.split("=", 1)[0].strip().lower() == "core.hookspath"
1153
+ for parameter in configured
1154
+ ):
1155
+ return True
1156
+ return False
1157
+
1158
+
1159
+ def flat_tokens(text):
1160
+ """Punctuation-stripped tokens, or None when the text does not lex.
1161
+
1162
+ Args:
1163
+ text: A command line or a script's contents.
1164
+
1165
+ Returns:
1166
+ The token list, or None.
1167
+ """
1168
+ try:
1169
+ return [token.strip("();|&") for token in shlex.split(text, posix=True)]
1170
+ except ValueError:
1171
+ return None
1172
+
1173
+
1174
+ def verdict(text, depth=0, followed=None):
1175
+ """Whether this text, or a script it runs, bypasses verification.
1176
+
1177
+ Args:
1178
+ text: A command line or a followed script's contents.
1179
+ depth: Number of scripts already followed.
1180
+ followed: Real paths already inspected, so a cycle terminates.
1181
+
1182
+ Returns:
1183
+ True when the command must be refused.
1184
+ """
1185
+ if git_skips_verification(text):
1186
+ return True
1187
+ tokens = flat_tokens(text)
1188
+ if tokens is not None and token_bypass(tokens):
1189
+ return True
1190
+ try:
1191
+ scoped_tokens = shell_tokens(text)
1192
+ except ValueError:
1193
+ # Text this parser cannot lex is left to the checks above, which is the
1194
+ # same accepted fail-open the header records for an unlexable command.
1195
+ return False
1196
+ paths, reasons = executed_scripts(scoped_tokens)
1197
+ if reasons:
1198
+ reason, operand = reasons[0]
1199
+ print(
1200
+ f"block-no-verify: refusing an execution it cannot inspect — {reason}"
1201
+ + (f" ({operand})" if operand else ""),
1202
+ file=sys.stderr,
1203
+ )
1204
+ return True
1205
+ if followed is None:
1206
+ followed = set()
1207
+ for path in paths:
716
1208
  try:
717
- configured = shlex.split(parameters, posix=True)
718
- except ValueError:
719
- configured = []
720
- if any(
721
- parameter.split("=", 1)[0].strip().lower() == "core.hookspath"
722
- for parameter in configured
723
- ):
724
- sys.exit(1)
1209
+ key = os.path.realpath(path)
1210
+ except OSError:
1211
+ key = path
1212
+ if key in followed:
1213
+ continue
1214
+ if len(followed) >= FOLLOW_MAX_FILES or depth >= FOLLOW_MAX_DEPTH:
1215
+ print(
1216
+ "block-no-verify: refusing at the script-following cap rather "
1217
+ f"than skipping past it ({path})",
1218
+ file=sys.stderr,
1219
+ )
1220
+ return True
1221
+ followed.add(key)
1222
+ if verdict(read_script(path), depth + 1, followed):
1223
+ print(
1224
+ f"block-no-verify: {path} — a script this command runs — "
1225
+ "bypasses git's verification hooks.",
1226
+ file=sys.stderr,
1227
+ )
1228
+ return True
1229
+ return False
1230
+
1231
+
1232
+ if verdict(strip_heredocs(command)):
1233
+ sys.exit(1)
725
1234
 
726
1235
  sys.exit(0)
727
1236
  PY