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