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