@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
@@ -21,7 +21,11 @@
21
21
  # 9. `env --split-string` in EVERY abbreviation GNU accepts (`--s` upward,
22
22
  # with or without an attached `=value`);
23
23
  # 10. those forms behind a wrapper that does not change what runs —
24
- # `command`, `exec`, `builtin`, `nohup`, `setsid`, and nested `env`.
24
+ # `command`, `exec`, `builtin`, `nohup`, `setsid`, and nested `env`, and
25
+ # wrappers with an operand of their own (`nice -n 5`, `timeout 5`, `sudo`);
26
+ # 11. any of the above inside a script the command EXECUTES — `bash nv.sh`,
27
+ # `source`, `.`, `bash < nv.sh`, and an interpreter a `find -exec` or
28
+ # `xargs` puts at a command position.
25
29
  #
26
30
  # The line below is what lets `lisa apply` tell a downstream copy of this guard
27
31
  # that is BEHIND from one that is AHEAD. Byte comparison cannot: both look like
@@ -32,12 +36,34 @@
32
36
  # Add a name here in the same commit that closes a vector. A hardening that
33
37
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
34
38
  # review time instead of a named capability.
35
- # 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
39
+ # 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
36
40
  #
37
41
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
38
42
  # commit-message prose while still catching quoted real argv values such as
39
43
  # `git -c "core.hooksPath=/dev/null"`.
40
44
  #
45
+ # ## The long flag is scoped to a git argv, and that is a WEAKENING
46
+ #
47
+ # `--no-verify` used to match as a bare token anywhere on the line. Measured
48
+ # against that matcher: `echo --no-verify` BLOCK, `grep -rl -- "--no-verify"
49
+ # scripts/` BLOCK. Both are correct to ALLOW — neither runs git — and both are
50
+ # now allowed. They are listed here rather than left to be rediscovered, because
51
+ # giving up a refusal is exactly the change that deserves to be written down.
52
+ #
53
+ # It was given up because it had to be. Vector 11 above reads FILE CONTENTS, and
54
+ # this file, every husky hook, and much of this repository carry the literal
55
+ # token in prose and in refusal text. An unscoped matcher plus reach refuses
56
+ # `bash` on this guard's own source and on the repository's own operations, on
57
+ # the first command. Narrowing was the prerequisite, not a cleanup.
58
+ #
59
+ # Scoped to a git INVOCATION, not to `git commit`: `git push --no-verify` skips
60
+ # pre-push and `git am` / `git merge` skip their own hooks. Only the short `-n`
61
+ # needs the commit scope. See `git_argv_disables_verification`.
62
+ #
63
+ # `HUSKY=0` and `core.hooksPath=` stay unscoped, deliberately — they are
64
+ # assignments that configure whatever git runs next, so they legitimately sit
65
+ # before the invocation or on an earlier line. See `token_bypass`.
66
+ #
41
67
  # The short `-n` form used to be excluded here, and the stated reason was that
42
68
  # "grep cannot distinguish a real -n option from -n in commit-message prose or
43
69
  # an unrelated piped command". That was true of the matcher it was written for.
@@ -56,7 +82,7 @@
56
82
  # concern is answered by SCOPE rather than by giving up: `-n` is `--dry-run` for
57
83
  # `push`, `--no-stat` for `merge`, and a line count for head/tail/sort/grep, so
58
84
  # the match is confined to the argv of a `git commit` invocation and nothing
59
- # else. See `git_commit_skips_verification` below.
85
+ # else. See `git_skips_verification` below.
60
86
  set -euo pipefail
61
87
 
62
88
  input="$(cat)"
@@ -143,13 +169,6 @@ def strip_heredocs(text: str) -> str:
143
169
  return "\n".join(output)
144
170
 
145
171
 
146
- try:
147
- tokens = shlex.split(strip_heredocs(command), posix=True)
148
- except ValueError:
149
- sys.exit(0)
150
-
151
- normalized_tokens = [token.strip("();|&") for token in tokens]
152
-
153
172
  # The only relocations that keep a repo's own hooks in play. `.husky` is the
154
173
  # husky convention this fleet runs; `.githooks` is the common hand-rolled one.
155
174
  # Anything else — including "" and /dev/null, which are simply the two most
@@ -660,8 +679,430 @@ def nested_shell_payload(tokens, index):
660
679
  return None
661
680
 
662
681
 
663
- def git_commit_skips_verification(text, depth=0):
664
- """Whether the command runs `git commit` with the short `-n` bypass.
682
+ # ── Reach: the contents of a script this command EXECUTES ──────────────────
683
+ #
684
+ # The guard used to inspect argv and nothing else, so `bash nv.sh` showed it two
685
+ # tokens, `bash` and a path, while the script it was about to run carried
686
+ # `git commit --no-verify` and skipped pre-commit exactly as completely as the
687
+ # inline spelling. Measured before this change: inline BLOCK, the same line one
688
+ # file away ALLOW — on bash, sh, zsh, `source`, `.`, an absolute interpreter
689
+ # path, and behind every wrapper.
690
+ #
691
+ # THE RULE IS TAKEN FROM parity-safety-net.sh RATHER THAN INVENTED HERE: a path
692
+ # is followed ONLY when the command EXECUTES it. `grep -n x nv.sh`,
693
+ # `git commit -F msg.txt` and `gh pr create --body-file body.md` name a file as
694
+ # DATA, and following those is the known-wrong fix that guard names by number —
695
+ # it refuses ordinary reads and attributes a file's capability to a command that
696
+ # merely mentions it. So COMMAND POSITION decides, never the presence of a path.
697
+ #
698
+ # Non-shell interpreters (`python3 x.py`, `node x.mjs`) are deliberately NOT
699
+ # followed. These matchers are git and shell syntax; running them over a Python
700
+ # or JS file buys mis-attribution, not coverage.
701
+ #
702
+ # Reach was not safe to add until the bare-token match was narrowed to a git
703
+ # argv — see `git_argv_disables_verification`. With the old unscoped matcher
704
+ # this guard would have refused `bash` on its own source, on every husky hook,
705
+ # and on much of this repository, all of which carry the literal token in prose.
706
+ FOLLOW_MAX_BYTES = 262144
707
+ FOLLOW_MAX_FILES = 8
708
+ FOLLOW_MAX_DEPTH = 3
709
+
710
+ # The `.` builtin and its `source` alias run the named file in the CURRENT
711
+ # shell, which is execution by any definition.
712
+ SOURCE_BUILTINS = {"source", "."}
713
+
714
+ # Wrappers that run what FOLLOWS them without changing what it is, mapped to
715
+ # (options whose value is a SEPARATE token, positional operands consumed).
716
+ #
717
+ # The second field is why this table exists rather than a bare set. `timeout 5
718
+ # bash x` puts a POSITIONAL between the wrapper and the interpreter, so a walk
719
+ # that steps over `-flags` only stops at `5` and never reaches `bash` — the
720
+ # shape that walked past three guards in this family before it was named.
721
+ FOLLOW_WRAPPERS = {
722
+ "builtin": (frozenset(), 0),
723
+ "command": (frozenset(), 0),
724
+ "exec": (frozenset({"-a"}), 0),
725
+ "nice": (frozenset({"-n", "--adjustment"}), 0),
726
+ "nohup": (frozenset(), 0),
727
+ "setsid": (frozenset(), 0),
728
+ "stdbuf": (frozenset({"-i", "-o", "-e"}), 0),
729
+ "sudo": (
730
+ frozenset(
731
+ {
732
+ "-C", "--close-from", "-g", "--group", "-h", "--host",
733
+ "-p", "--prompt", "-r", "--role", "-t", "--type",
734
+ "-U", "--other-user", "-u", "--user",
735
+ }
736
+ ),
737
+ 0,
738
+ ),
739
+ "time": (frozenset(), 0),
740
+ "timeout": (frozenset({"-k", "--kill-after", "-s", "--signal"}), 1),
741
+ }
742
+
743
+ # Dispatchers that BUILD a command out of their own arguments, putting an
744
+ # interpreter at a command position the text does not spell as one. Scoped to
745
+ # exactly one token — the word after `find -exec`/`-execdir`, and `xargs`'s
746
+ # first operand — because latching "an interpreter name anywhere" refuses
747
+ # `find . -name bash`, where `bash` is a filename PATTERN and nothing runs.
748
+ FIND_EXEC_FLAGS = {"-exec", "-execdir"}
749
+
750
+ # A value the SHELL computes. This guard sees the command before expansion, so
751
+ # it cannot prove which file such a token will name.
752
+ COMPUTED_VALUE = re.compile(r"[$`]")
753
+
754
+
755
+ def strip_shell_comments(text):
756
+ """Remove `#` comments from a file's contents, quote-aware.
757
+
758
+ Applied to FILE CONTENTS and never to a typed command, because the two are
759
+ genuinely different problems. On a typed command `#` handling is a
760
+ bash-versus-shlex divergence a guard can be attacked through, which is why
761
+ `shell_tokens` sets `commenters = ""`. Inside a file the question is not
762
+ adversarial and the answer is unambiguous: a comment cannot execute.
763
+
764
+ It is also what makes reach usable at all. This repository's guards, its
765
+ husky hooks and its own refusal messages discuss `git commit -n` and
766
+ `--no-verify` in prose, and a comment line reading "`git commit -n` skips
767
+ pre-commit" tokenizes into exactly the argv of a real bypass. Following a
768
+ file without dropping its comments refuses this guard's own source.
769
+
770
+ Args:
771
+ text: A file's contents.
772
+
773
+ Returns:
774
+ The same text with unquoted comments removed.
775
+ """
776
+ output = []
777
+ for line in text.splitlines():
778
+ quote = ""
779
+ escaped = False
780
+ cut = None
781
+ for position, character in enumerate(line):
782
+ if escaped:
783
+ escaped = False
784
+ continue
785
+ if character == "\\" and quote != "'":
786
+ escaped = True
787
+ continue
788
+ if quote:
789
+ if character == quote:
790
+ quote = ""
791
+ continue
792
+ if character in "'\"":
793
+ quote = character
794
+ continue
795
+ # A `#` only opens a comment at the start of a word, so `x#y` and
796
+ # `${x#y}` keep their hashes.
797
+ if character == "#" and (position == 0 or line[position - 1].isspace()):
798
+ cut = position
799
+ break
800
+ output.append(line if cut is None else line[:cut])
801
+ return "\n".join(output)
802
+
803
+
804
+ def statement_slices(tokens):
805
+ """Split an operator-aware token list into statements.
806
+
807
+ Args:
808
+ tokens: The full operator-aware token list.
809
+
810
+ Returns:
811
+ Pairs of (separator that PRECEDED the statement, its tokens). The
812
+ separator is carried because `<` is both a statement boundary and the
813
+ link between `bash` and the script it reads — `bash < nv.sh` runs that
814
+ file as surely as `bash nv.sh` does.
815
+ """
816
+ statements = []
817
+ current = []
818
+ separator = ""
819
+ for token in tokens:
820
+ if token in COMMAND_SEPARATORS:
821
+ statements.append((separator, current))
822
+ current = []
823
+ separator = token
824
+ continue
825
+ current.append(token)
826
+ statements.append((separator, current))
827
+ return statements
828
+
829
+
830
+ def command_word(statement):
831
+ """The program a statement runs, and the tokens after it.
832
+
833
+ Steps over leading variable assignments and over wrappers that do not
834
+ change what runs. Reading a wrapper as the command is how a payload hides
835
+ from a guard that inspects only a statement's first word.
836
+
837
+ Args:
838
+ statement: One statement's tokens.
839
+
840
+ Returns:
841
+ A triple (program, args, opaque). `opaque` is True when a wrapper's
842
+ option grammar could not be read, which the caller must treat as an
843
+ execution it cannot follow rather than as an ordinary command.
844
+ """
845
+ index = 0
846
+ while index < len(statement):
847
+ token = statement[index]
848
+ if "=" in token and not token.startswith(("=", "-")):
849
+ index += 1
850
+ continue
851
+ program = token.rsplit("/", 1)[-1]
852
+ if program == "env":
853
+ index += 1
854
+ while index < len(statement):
855
+ option = statement[index]
856
+ if option == "--":
857
+ index += 1
858
+ break
859
+ kind = env_option_kind(option)
860
+ if kind is None:
861
+ if "=" in option and not option.startswith(("=", "-")):
862
+ index += 1
863
+ continue
864
+ break
865
+ if kind in {"ambiguous", "split-string"}:
866
+ # `env -S` reparses an opaque payload as shell words, and an
867
+ # unnameable option makes the rest unreadable. Neither can be
868
+ # resolved to a program.
869
+ return (None, [], True)
870
+ index += 2 if kind == "separate-value" else 1
871
+ continue
872
+ if program in FOLLOW_WRAPPERS:
873
+ separate, positional = FOLLOW_WRAPPERS[program]
874
+ index += 1
875
+ while index < len(statement) and statement[index].startswith("-"):
876
+ option = statement[index]
877
+ if option == "--":
878
+ index += 1
879
+ break
880
+ index += 2 if option in separate else 1
881
+ index += positional
882
+ continue
883
+ return (program, statement[index + 1 :], False)
884
+ return (None, [], False)
885
+
886
+
887
+ def shell_script_operand(args):
888
+ """The file a shell invocation runs, when it runs one.
889
+
890
+ Args:
891
+ args: The tokens following the shell's own name.
892
+
893
+ Returns:
894
+ The operand token, or None when the invocation runs no script file —
895
+ an interactive shell, or a `-c` command string, which
896
+ `nested_shell_payload` already owns.
897
+ """
898
+ index = 0
899
+ opaque_option_seen = False
900
+ while index < len(args):
901
+ token = args[index]
902
+ if token == "--":
903
+ index += 1
904
+ break
905
+ if not token.startswith(("-", "+")):
906
+ if not opaque_option_seen:
907
+ break
908
+ index += 1
909
+ opaque_option_seen = False
910
+ continue
911
+ if token in SHELL_OPTIONS_SEPARATE_VALUE:
912
+ index += 2
913
+ continue
914
+ if token.startswith(SHELL_OPTIONS_INLINE_VALUE):
915
+ index += 1
916
+ continue
917
+ if token.startswith("--"):
918
+ if token not in SHELL_LONG_OPTIONS_NO_VALUE:
919
+ opaque_option_seen = True
920
+ index += 1
921
+ continue
922
+ if "c" in token[1:]:
923
+ return None
924
+ index += 1
925
+ return args[index] if index < len(args) else None
926
+
927
+
928
+ def dispatched_commands(program, args):
929
+ """Commands a dispatcher builds out of its own arguments.
930
+
931
+ `find … -exec bash {} \\;` and `find … | xargs bash` put an interpreter at
932
+ a command position the text does not spell as one. The exception is scoped
933
+ to a `find`/`xargs` statement and to exactly ONE token, because latching an
934
+ interpreter NAME anywhere refuses `find . -name bash` and
935
+ `xargs grep bash`, where the word is a pattern and nothing is executed.
936
+
937
+ Args:
938
+ program: The dispatcher's name.
939
+ args: Its arguments.
940
+
941
+ Returns:
942
+ Pairs of (program, args) the dispatcher will run.
943
+ """
944
+ built = []
945
+ if program == "find":
946
+ for index, token in enumerate(args):
947
+ if token in FIND_EXEC_FLAGS and index + 1 < len(args):
948
+ built.append((args[index + 1].rsplit("/", 1)[-1], args[index + 2 :]))
949
+ return built
950
+ if program != "xargs":
951
+ return built
952
+ index = 0
953
+ while index < len(args):
954
+ token = args[index]
955
+ if token == "--":
956
+ index += 1
957
+ break
958
+ if not token.startswith("-"):
959
+ break
960
+ # `-I`, `-n`, `-P`, `-s`, `-a`, `-d`, `-E`, `-L` take a value; the rest
961
+ # do not. Over-consuming here only loses the dispatcher, which degrades
962
+ # to the fail-open this file's header already accepts.
963
+ index += 2 if token in {"-I", "-n", "-P", "-s", "-a", "-d", "-E", "-L"} else 1
964
+ if index < len(args):
965
+ built.append((args[index].rsplit("/", 1)[-1], args[index + 1 :]))
966
+ return built
967
+
968
+
969
+ def resolve_script(token):
970
+ """Resolve a token naming a script to a readable file.
971
+
972
+ Args:
973
+ token: The operand an interpreter will run.
974
+
975
+ Returns:
976
+ A pair (path, reason). Exactly one is set. `reason` names why the
977
+ execution could not be followed, which the caller fails CLOSED on:
978
+ silence about a command that is definitely running something the guard
979
+ cannot read is the fail-open this change exists to close.
980
+ """
981
+ if COMPUTED_VALUE.search(token):
982
+ return (None, "a computed path the guard cannot resolve before the shell does")
983
+ text = token.strip().strip("'\"")
984
+ # `bash -` and a bare `-` read the script from stdin; there is no file.
985
+ if not text or text == "-":
986
+ return (None, None)
987
+ candidates = [text]
988
+ if not os.path.isabs(text):
989
+ project = os.environ.get("CLAUDE_PROJECT_DIR", "")
990
+ candidates.append(os.path.join(os.getcwd(), text))
991
+ if project:
992
+ candidates.append(os.path.join(project, text))
993
+ for candidate in candidates:
994
+ try:
995
+ if not os.path.isfile(candidate):
996
+ continue
997
+ if os.path.getsize(candidate) > FOLLOW_MAX_BYTES:
998
+ return (None, "a script larger than the inspection cap")
999
+ except OSError:
1000
+ continue
1001
+ return (candidate, None)
1002
+ return (None, "a script that does not exist or cannot be read")
1003
+
1004
+
1005
+ def executed_scripts(tokens):
1006
+ """The files a command line will RUN, and the executions it cannot follow.
1007
+
1008
+ Args:
1009
+ tokens: The full operator-aware token list.
1010
+
1011
+ Returns:
1012
+ A pair (paths, reasons). `reasons` is non-empty when a command position
1013
+ clearly runs a script the guard cannot read.
1014
+ """
1015
+ paths = []
1016
+ reasons = []
1017
+ previous_shell_awaiting_stdin = False
1018
+ for separator, statement in statement_slices(tokens):
1019
+ if separator == "<" and previous_shell_awaiting_stdin and statement:
1020
+ # `bash < nv.sh`: the redirect target is the script.
1021
+ path, reason = resolve_script(statement[0])
1022
+ if reason:
1023
+ reasons.append((reason, statement[0]))
1024
+ elif path:
1025
+ paths.append(path)
1026
+ previous_shell_awaiting_stdin = False
1027
+ if not statement:
1028
+ continue
1029
+ program, args, opaque = command_word(statement)
1030
+ if opaque:
1031
+ reasons.append(("an invocation whose wrapper options cannot be read", ""))
1032
+ continue
1033
+ if program is None:
1034
+ continue
1035
+ for name, arguments in [(program, args)] + dispatched_commands(program, args):
1036
+ if name in SOURCE_BUILTINS:
1037
+ operand = arguments[0] if arguments else None
1038
+ elif name in SHELL_PROGRAMS:
1039
+ operand = shell_script_operand(arguments)
1040
+ if operand is None and not arguments:
1041
+ previous_shell_awaiting_stdin = True
1042
+ else:
1043
+ continue
1044
+ if operand is None:
1045
+ continue
1046
+ path, reason = resolve_script(operand)
1047
+ if reason:
1048
+ reasons.append((reason, operand))
1049
+ elif path:
1050
+ paths.append(path)
1051
+ return paths, reasons
1052
+
1053
+
1054
+ def read_script(path):
1055
+ """The text of a script the command runs, comments removed.
1056
+
1057
+ Args:
1058
+ path: A path from `resolve_script`.
1059
+
1060
+ Returns:
1061
+ The contents, or an empty string when unreadable.
1062
+ """
1063
+ try:
1064
+ with open(path, encoding="utf-8", errors="replace") as handle:
1065
+ return strip_shell_comments(handle.read(FOLLOW_MAX_BYTES))
1066
+ except OSError:
1067
+ return ""
1068
+
1069
+
1070
+ def git_argv_disables_verification(argv):
1071
+ """Whether a git invocation's argv carries `--no-verify` or an abbreviation.
1072
+
1073
+ SCOPED to a git invocation, which is the change that made file reach usable.
1074
+ The token match used to fire anywhere on the line, so `echo --no-verify` and
1075
+ `grep -rl -- "--no-verify" scripts/` were both refused — ordinary commands
1076
+ that run no git at all. That was survivable while the guard read only argv.
1077
+ It is not once the guard reads FILE CONTENTS, because this file, every husky
1078
+ hook, and much of this repository carry the literal token in prose and in
1079
+ refusal text; an unscoped matcher plus reach refuses the repository's own
1080
+ operations on the first command.
1081
+
1082
+ Deliberately NOT narrowed to `commit`. `git push --no-verify` skips pre-push
1083
+ and `git am` / `git merge` skip their own hooks, so every subcommand's argv
1084
+ is scanned. Only the SHORT `-n` needs the commit scope, because `-n` is
1085
+ --dry-run to push and --no-stat to merge — see
1086
+ `commit_bypasses_verification`.
1087
+
1088
+ Args:
1089
+ argv: Tokens following the `git` token, to the end of the line.
1090
+
1091
+ Returns:
1092
+ True if git would read one of them as --no-verify.
1093
+ """
1094
+ for token in argv:
1095
+ # `--` ends the options; a pathspec literally named `--no-verify` is a
1096
+ # file, not a bypass.
1097
+ if token in COMMAND_SEPARATORS or token == "--":
1098
+ return False
1099
+ if disables_verification(token):
1100
+ return True
1101
+ return False
1102
+
1103
+
1104
+ def git_skips_verification(text, depth=0):
1105
+ """Whether the command runs a git invocation that skips verification.
665
1106
 
666
1107
  Args:
667
1108
  text: The command line, heredoc payloads already stripped.
@@ -684,6 +1125,11 @@ def git_commit_skips_verification(text, depth=0):
684
1125
  # (`HUSKY=1 git commit -n`) simply sits in an earlier token.
685
1126
  if token != "git" and not token.endswith("/git"):
686
1127
  continue
1128
+ # The LONG flag, scoped to this invocation's argv rather than matched
1129
+ # anywhere on the line. This replaces the unscoped token match that used
1130
+ # to live in the flat loop below.
1131
+ if git_argv_disables_verification(scoped_tokens[index + 1 :]):
1132
+ return True
687
1133
  found = subcommand_after_git(scoped_tokens, index + 1)
688
1134
  if found and found[0] == "commit" and commit_bypasses_verification(found[1]):
689
1135
  return True
@@ -698,101 +1144,190 @@ def git_commit_skips_verification(text, depth=0):
698
1144
  payload = nested_shell_payload(scoped_tokens, index)
699
1145
  if payload is True:
700
1146
  return True
701
- if isinstance(payload, str) and git_commit_skips_verification(
702
- payload, depth + 1
703
- ):
1147
+ if isinstance(payload, str) and git_skips_verification(payload, depth + 1):
704
1148
  return True
705
1149
  return False
706
1150
 
707
1151
 
708
- if git_commit_skips_verification(strip_heredocs(command)):
709
- sys.exit(1)
1152
+ def token_bypass(tokens):
1153
+ """Whether any token disables hooks by environment or by git config.
710
1154
 
711
- for i, token in enumerate(normalized_tokens):
712
- if disables_verification(token):
713
- sys.exit(1)
714
- if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
715
- sys.exit(1)
716
- # Allowlist the destinations, do not denylist the disabling ones.
717
- #
718
- # This used to block only "" and /dev/null. But hooks are disabled just as
719
- # completely by pointing hooksPath at any directory that happens to contain
720
- # none — `-c core.hooksPath=/tmp/empty` bypassed every hook while reading as
721
- # an ordinary path — so a denylist of "obviously disabling" values can
722
- # always be stepped around by naming a third thing. The set of paths that
723
- # DISABLE hooks is unbounded; the set that legitimately relocates them is
724
- # tiny and known, so the allowlist is the only side that can be enumerated.
725
- #
726
- # Matched case-insensitively because git config variable names are:
727
- # `CORE.HOOKSPATH=/x` and `core.hookspath=/x` are the same setting to git,
728
- # so a case-sensitive check is bypassed by holding down shift.
729
- lowered = token.lower()
730
- if lowered.startswith("core.hookspath="):
731
- if not is_permitted_hooks_path(token.split("=", 1)[1]):
732
- sys.exit(1)
733
- if lowered == "core.hookspath" and i + 1 < len(normalized_tokens):
734
- if not is_permitted_hooks_path(normalized_tokens[i + 1]):
735
- sys.exit(1)
736
- # `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
737
- # the value out of the named environment variable. The path therefore is
738
- # not in the command at all, so there is nothing to allowlist against —
739
- # SOMEVAR can hold anything by the time git reads it. Any core.hooksPath
740
- # routed through --config-env is refused outright.
741
- if lowered.startswith("--config-env="):
742
- spec = token.split("=", 1)[1]
743
- if spec.split("=", 1)[0].strip().lower() == "core.hookspath":
744
- sys.exit(1)
745
- # git accepts `--config-env <name>=<envvar>` as TWO tokens as well as one,
746
- # and guarding only the `=` spelling was worse than missing the separate
747
- # form outright: the trailing `core.hooksPath=.husky` then fell through to
748
- # the allowlist above, which reads `.husky` as a PATH and permits it. But
749
- # here it is an ENVIRONMENT VARIABLE NAME, and `env '.husky=/dev/null' git
750
- # --config-env core.hooksPath=.husky` really does resolve hooksPath to
751
- # /dev/null. The allowlist was being used as the bypass.
752
- #
753
- # Checked at the `--config-env` token, which the loop reaches first, so the
754
- # refusal happens before the value token can be mistaken for a path.
755
- if lowered == "--config-env" and i + 1 < len(normalized_tokens):
756
- spec = normalized_tokens[i + 1]
757
- if spec.split("=", 1)[0].strip().strip("'\"").lower() == "core.hookspath":
758
- sys.exit(1)
759
- # `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath
760
- # GIT_CONFIG_VALUE_0=/dev/null git commit` sets command-scope config the
761
- # same way `-c core.hooksPath=...` does — env-var-style assignments ahead of
762
- # the invocation instead of a flag — so it disables every hook just as
763
- # completely while matching none of the token shapes above. Upstream missed
764
- # this until a downstream fork hardened its own copy against it, which is
765
- # the one direction a guard must never be caught in.
766
- #
767
- # The index is matched as `\d+` rather than pinned to 0: git accepts any
768
- # index below GIT_CONFIG_COUNT, so a single-index check is evaded by typing
769
- # a 1. Refused outright, like --config-env=, because the path lives in a
770
- # separate GIT_CONFIG_VALUE_<n> token that can be exported earlier,
771
- # reordered, or left out entirely — there is nothing here to allowlist
772
- # against.
773
- key_match = re.match(r"git_config_key_\d+=(.*)$", lowered, re.DOTALL)
774
- if key_match and key_match.group(1).strip().strip("'\"") == "core.hookspath":
775
- sys.exit(1)
776
- # `git -c` propagates command-scope config through GIT_CONFIG_PARAMETERS.
777
- # Parsing the value as Git's shell-quoted parameter list distinguishes the
778
- # key from an unrelated value that merely contains the same text.
779
- if re.match(r"git_config_parameters\+?=", lowered):
780
- parameters = token.split("=", 1)[1]
781
- # The guard sees the command before the shell expands assignments. A
782
- # variable or command substitution can therefore hide hooksPath from
783
- # this parser and reveal it only to Git. Refuse unresolved values; the
784
- # guard cannot safely prove what configuration they will become.
785
- if "$" in parameters or "`" in parameters:
786
- sys.exit(1)
1155
+ These stay UNSCOPED, unlike the `--no-verify` match that moved into
1156
+ `git_argv_disables_verification`. The difference is what the token means
1157
+ where it appears: `HUSKY=0` and `core.hooksPath=` are assignments that
1158
+ configure whatever git runs next, so they legitimately sit before the
1159
+ invocation and often on a different line of a script. `--no-verify` is an
1160
+ option, and an option belongs to an argv.
1161
+
1162
+ Args:
1163
+ tokens: Punctuation-stripped tokens of one command or script.
1164
+
1165
+ Returns:
1166
+ True when a token disables verification.
1167
+ """
1168
+ for i, token in enumerate(tokens):
1169
+ if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
1170
+ return True
1171
+ # Allowlist the destinations, do not denylist the disabling ones.
1172
+ #
1173
+ # This used to block only "" and /dev/null. But hooks are disabled just
1174
+ # as completely by pointing hooksPath at any directory that happens to
1175
+ # contain none — `-c core.hooksPath=/tmp/empty` bypassed every hook while
1176
+ # reading as an ordinary path — so a denylist of "obviously disabling"
1177
+ # values can always be stepped around by naming a third thing. The set of
1178
+ # paths that DISABLE hooks is unbounded; the set that legitimately
1179
+ # relocates them is tiny and known, so the allowlist is the only side
1180
+ # that can be enumerated.
1181
+ #
1182
+ # Matched case-insensitively because git config variable names are:
1183
+ # `CORE.HOOKSPATH=/x` and `core.hookspath=/x` are the same setting to
1184
+ # git, so a case-sensitive check is bypassed by holding down shift.
1185
+ lowered = token.lower()
1186
+ if lowered.startswith("core.hookspath="):
1187
+ if not is_permitted_hooks_path(token.split("=", 1)[1]):
1188
+ return True
1189
+ if lowered == "core.hookspath" and i + 1 < len(tokens):
1190
+ if not is_permitted_hooks_path(tokens[i + 1]):
1191
+ return True
1192
+ # `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
1193
+ # the value out of the named environment variable. The path therefore is
1194
+ # not in the command at all, so there is nothing to allowlist against —
1195
+ # SOMEVAR can hold anything by the time git reads it. Any core.hooksPath
1196
+ # routed through --config-env is refused outright.
1197
+ if lowered.startswith("--config-env="):
1198
+ spec = token.split("=", 1)[1]
1199
+ if spec.split("=", 1)[0].strip().lower() == "core.hookspath":
1200
+ return True
1201
+ # git accepts `--config-env <name>=<envvar>` as TWO tokens as well as
1202
+ # one, and guarding only the `=` spelling was worse than missing the
1203
+ # separate form outright: the trailing `core.hooksPath=.husky` then fell
1204
+ # through to the allowlist above, which reads `.husky` as a PATH and
1205
+ # permits it. But here it is an ENVIRONMENT VARIABLE NAME, and
1206
+ # `env '.husky=/dev/null' git --config-env core.hooksPath=.husky` really
1207
+ # does resolve hooksPath to /dev/null. The allowlist was being used as
1208
+ # the bypass.
1209
+ #
1210
+ # Checked at the `--config-env` token, which the loop reaches first, so
1211
+ # the refusal happens before the value token can be mistaken for a path.
1212
+ if lowered == "--config-env" and i + 1 < len(tokens):
1213
+ spec = tokens[i + 1]
1214
+ if spec.split("=", 1)[0].strip().strip("'\"").lower() == "core.hookspath":
1215
+ return True
1216
+ # `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath
1217
+ # GIT_CONFIG_VALUE_0=/dev/null git commit` sets command-scope config the
1218
+ # same way `-c core.hooksPath=...` does — env-var-style assignments ahead
1219
+ # of the invocation instead of a flag — so it disables every hook just as
1220
+ # completely while matching none of the token shapes above. Upstream
1221
+ # missed this until a downstream fork hardened its own copy against it,
1222
+ # which is the one direction a guard must never be caught in.
1223
+ #
1224
+ # The index is matched as `\d+` rather than pinned to 0: git accepts any
1225
+ # index below GIT_CONFIG_COUNT, so a single-index check is evaded by
1226
+ # typing a 1. Refused outright, like --config-env=, because the path
1227
+ # lives in a separate GIT_CONFIG_VALUE_<n> token that can be exported
1228
+ # earlier, reordered, or left out entirely — there is nothing here to
1229
+ # allowlist against.
1230
+ key_match = re.match(r"git_config_key_\d+=(.*)$", lowered, re.DOTALL)
1231
+ if key_match and key_match.group(1).strip().strip("'\"") == "core.hookspath":
1232
+ return True
1233
+ # `git -c` propagates command-scope config through GIT_CONFIG_PARAMETERS.
1234
+ # Parsing the value as Git's shell-quoted parameter list distinguishes
1235
+ # the key from an unrelated value that merely contains the same text.
1236
+ if re.match(r"git_config_parameters\+?=", lowered):
1237
+ parameters = token.split("=", 1)[1]
1238
+ # The guard sees the command before the shell expands assignments. A
1239
+ # variable or command substitution can therefore hide hooksPath from
1240
+ # this parser and reveal it only to Git. Refuse unresolved values;
1241
+ # the guard cannot safely prove what configuration they will become.
1242
+ if "$" in parameters or "`" in parameters:
1243
+ return True
1244
+ try:
1245
+ configured = shlex.split(parameters, posix=True)
1246
+ except ValueError:
1247
+ configured = []
1248
+ if any(
1249
+ parameter.split("=", 1)[0].strip().lower() == "core.hookspath"
1250
+ for parameter in configured
1251
+ ):
1252
+ return True
1253
+ return False
1254
+
1255
+
1256
+ def flat_tokens(text):
1257
+ """Punctuation-stripped tokens, or None when the text does not lex.
1258
+
1259
+ Args:
1260
+ text: A command line or a script's contents.
1261
+
1262
+ Returns:
1263
+ The token list, or None.
1264
+ """
1265
+ try:
1266
+ return [token.strip("();|&") for token in shlex.split(text, posix=True)]
1267
+ except ValueError:
1268
+ return None
1269
+
1270
+
1271
+ def verdict(text, depth=0, followed=None):
1272
+ """Whether this text, or a script it runs, bypasses verification.
1273
+
1274
+ Args:
1275
+ text: A command line or a followed script's contents.
1276
+ depth: Number of scripts already followed.
1277
+ followed: Real paths already inspected, so a cycle terminates.
1278
+
1279
+ Returns:
1280
+ True when the command must be refused.
1281
+ """
1282
+ if git_skips_verification(text):
1283
+ return True
1284
+ tokens = flat_tokens(text)
1285
+ if tokens is not None and token_bypass(tokens):
1286
+ return True
1287
+ try:
1288
+ scoped_tokens = shell_tokens(text)
1289
+ except ValueError:
1290
+ # Text this parser cannot lex is left to the checks above, which is the
1291
+ # same accepted fail-open the header records for an unlexable command.
1292
+ return False
1293
+ paths, reasons = executed_scripts(scoped_tokens)
1294
+ if reasons:
1295
+ reason, operand = reasons[0]
1296
+ print(
1297
+ f"block-no-verify: refusing an execution it cannot inspect — {reason}"
1298
+ + (f" ({operand})" if operand else ""),
1299
+ file=sys.stderr,
1300
+ )
1301
+ return True
1302
+ if followed is None:
1303
+ followed = set()
1304
+ for path in paths:
787
1305
  try:
788
- configured = shlex.split(parameters, posix=True)
789
- except ValueError:
790
- configured = []
791
- if any(
792
- parameter.split("=", 1)[0].strip().lower() == "core.hookspath"
793
- for parameter in configured
794
- ):
795
- sys.exit(1)
1306
+ key = os.path.realpath(path)
1307
+ except OSError:
1308
+ key = path
1309
+ if key in followed:
1310
+ continue
1311
+ if len(followed) >= FOLLOW_MAX_FILES or depth >= FOLLOW_MAX_DEPTH:
1312
+ print(
1313
+ "block-no-verify: refusing at the script-following cap rather "
1314
+ f"than skipping past it ({path})",
1315
+ file=sys.stderr,
1316
+ )
1317
+ return True
1318
+ followed.add(key)
1319
+ if verdict(read_script(path), depth + 1, followed):
1320
+ print(
1321
+ f"block-no-verify: {path} — a script this command runs — "
1322
+ "bypasses git's verification hooks.",
1323
+ file=sys.stderr,
1324
+ )
1325
+ return True
1326
+ return False
1327
+
1328
+
1329
+ if verdict(strip_heredocs(command)):
1330
+ sys.exit(1)
796
1331
 
797
1332
  sys.exit(0)
798
1333
  PY