@codyswann/lisa 4.28.2 → 4.28.3
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/dist/codex/scripts/block-no-verify.sh +302 -97
- 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 +2 -1
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +3 -3
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/block-no-verify.agy.sh +302 -97
- package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +302 -97
- 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-cursor/.claude-plugin/plugin.json +1 -1
- 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-no-verify.agy.sh +302 -97
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
# avoids false positives from issue bodies, heredocs, and commit-message prose
|
|
9
9
|
# while still catching quoted real argv values such as
|
|
10
10
|
# `git -c "core.hooksPath=/dev/null"`.
|
|
11
|
+
#
|
|
12
|
+
# Capability names below are the canonical guard's, kept identical on every agent.
|
|
13
|
+
# lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization
|
|
11
14
|
set -euo pipefail
|
|
12
15
|
|
|
13
16
|
input="$(cat 2>/dev/null || true)"
|
|
@@ -76,10 +79,42 @@ except ValueError:
|
|
|
76
79
|
|
|
77
80
|
normalized_tokens = [token.strip("();|&") for token in tokens]
|
|
78
81
|
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
82
|
+
# The only relocations that keep a repo's own hooks in play. `.husky` is the
|
|
83
|
+
# husky convention this fleet runs; `.githooks` is the common hand-rolled one.
|
|
84
|
+
# Anything else — including "" and /dev/null, which are simply the two most
|
|
85
|
+
# obvious members of the blocked set rather than special cases — is refused.
|
|
86
|
+
PERMITTED_HOOKS_PATHS = {".husky", ".githooks"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_permitted_hooks_path(value):
|
|
90
|
+
"""Whether a core.hooksPath value relocates hooks rather than disabling them.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
value: The raw core.hooksPath value as it appeared on the command line.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True if the path is an established in-repo hooks directory.
|
|
97
|
+
"""
|
|
98
|
+
cleaned = value.strip().strip("'\"")
|
|
99
|
+
if cleaned.startswith("./"):
|
|
100
|
+
cleaned = cleaned[2:]
|
|
101
|
+
return cleaned.rstrip("/") in PERMITTED_HOOKS_PATHS
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Git resolves any UNAMBIGUOUS abbreviation of a long option, so `--no-verify`
|
|
105
|
+
# is only the longest of the spellings that disable verification: `git commit
|
|
106
|
+
# --no-veri` skips hooks exactly as completely. An equality check therefore
|
|
107
|
+
# enforced the guard against the one spelling nobody in a hurry types.
|
|
108
|
+
#
|
|
109
|
+
# Matched as "a prefix of --no-verify" rather than by listing abbreviations,
|
|
110
|
+
# because the set of accepted abbreviations is a property of git's parser and
|
|
111
|
+
# changes with the surrounding options. `--no-verbose` is NOT caught, and must
|
|
112
|
+
# not be: it diverges from `--no-verify` at the character after `--no-ver`, so
|
|
113
|
+
# it fails the prefix test.
|
|
114
|
+
#
|
|
115
|
+
# The floor is `--no-v`. Shorter is refused anyway — bare `--no-` is not a flag
|
|
116
|
+
# — and blocking an abbreviation git would reject as ambiguous costs nothing,
|
|
117
|
+
# while missing one git accepts costs the whole guard.
|
|
83
118
|
NO_VERIFY = "--no-verify"
|
|
84
119
|
NO_VERIFY_MIN_PREFIX = len("--no-v")
|
|
85
120
|
|
|
@@ -99,89 +134,113 @@ def disables_verification(token):
|
|
|
99
134
|
and NO_VERIFY.startswith(token)
|
|
100
135
|
)
|
|
101
136
|
|
|
102
|
-
# The only relocations that keep a repo's own hooks in play. Anything else —
|
|
103
|
-
# including "" and /dev/null, which are simply the two most obvious members of
|
|
104
|
-
# the blocked set rather than special cases — is refused.
|
|
105
|
-
PERMITTED_HOOKS_PATHS = {".husky", ".githooks"}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def is_permitted_hooks_path(value):
|
|
109
|
-
"""Whether a core.hooksPath value relocates hooks rather than disabling them.
|
|
110
|
-
|
|
111
|
-
Args:
|
|
112
|
-
value: The raw core.hooksPath value as it appeared on the command line.
|
|
113
|
-
|
|
114
|
-
Returns:
|
|
115
|
-
True if the path is an established in-repo hooks directory.
|
|
116
|
-
"""
|
|
117
|
-
cleaned = value.strip().strip("'\"")
|
|
118
|
-
if cleaned.startswith("./"):
|
|
119
|
-
cleaned = cleaned[2:]
|
|
120
|
-
return cleaned.rstrip("/") in PERMITTED_HOOKS_PATHS
|
|
121
|
-
|
|
122
137
|
|
|
123
138
|
# `git commit -n` is the short spelling of --no-verify and skips pre-commit and
|
|
124
|
-
# commit-msg identically
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
# `git commit` reads it as a hook
|
|
128
|
-
#
|
|
129
|
-
# rationale for why a tokenizer can make this distinction and a grep could not.
|
|
139
|
+
# commit-msg identically. Matching it needs one thing the `--no-verify` scan
|
|
140
|
+
# above does not: SCOPE. `-n` means --dry-run to `git push`, --no-stat to `git
|
|
141
|
+
# merge`, and a line count to head/tail/sort/grep, so a token-anywhere match
|
|
142
|
+
# would refuse ordinary commands all day. Only `git commit` reads it as a hook
|
|
143
|
+
# bypass, so only that invocation's argv is scanned.
|
|
130
144
|
#
|
|
131
|
-
#
|
|
132
|
-
# `shlex.split`
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
145
|
+
# The tokenizer below is a SECOND pass, deliberately not shared with the one the
|
|
146
|
+
# checks above use. `shlex.split` leaves `|` and `;` glued to their neighbours,
|
|
147
|
+
# which those checks paper over by stripping the punctuation off each token —
|
|
148
|
+
# fine when the question is "does this token look like a bypass anywhere in the
|
|
149
|
+
# line", fatal when the question is "where does this command end", because
|
|
150
|
+
# `git commit -m x && grep -n foo` would then read as one long git invocation
|
|
151
|
+
# and refuse the grep. `punctuation_chars=True` emits the operators as their own
|
|
152
|
+
# tokens, so the invocation's boundary is a token the scan can stop at.
|
|
136
153
|
COMMAND_SEPARATORS = {
|
|
137
154
|
";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
|
|
138
155
|
}
|
|
139
156
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
#
|
|
157
|
+
SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
|
|
158
|
+
MAX_SHELL_NESTING = 8
|
|
159
|
+
|
|
160
|
+
# GNU env's long options, listed in FULL so an abbreviation can be resolved
|
|
161
|
+
# against them. getopt_long accepts any prefix that names exactly one option,
|
|
162
|
+
# and no other env option begins with `s` — so `env --s`, `env --sp`, and
|
|
163
|
+
# `env --split` are `env --split-string` just as completely as the full
|
|
164
|
+
# spelling is. Matching only the full spelling is bypassed by deleting
|
|
165
|
+
# characters, which is why the set below exists instead of a literal list of
|
|
166
|
+
# the two spellings the guard used to know.
|
|
144
167
|
ENV_LONG_OPTIONS = {
|
|
145
168
|
"--argv0", "--block-signals", "--chdir", "--debug", "--default-signal",
|
|
146
169
|
"--help", "--ignore-environment", "--ignore-signal",
|
|
147
170
|
"--list-signal-handling", "--null", "--split-string", "--unset",
|
|
148
171
|
"--version",
|
|
149
172
|
}
|
|
150
|
-
# Of those, the ones whose value is a SEPARATE token
|
|
151
|
-
#
|
|
173
|
+
# Of those, the ones whose value is a SEPARATE token, so the operand is never
|
|
174
|
+
# mistaken for the command (`env -u bash -c ...` consumes `bash` as the
|
|
175
|
+
# variable name). The signal options take an OPTIONAL value, which GNU reads
|
|
176
|
+
# only when it is attached with `=`, so they never consume a following token.
|
|
152
177
|
ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
|
|
178
|
+
|
|
179
|
+
# env's single-letter options, by whether the rest of a cluster is more
|
|
180
|
+
# options or the option's value.
|
|
153
181
|
ENV_SHORT_NO_VALUE = frozenset("i0v")
|
|
154
182
|
ENV_SHORT_TAKES_VALUE = frozenset("uCaS")
|
|
155
183
|
|
|
156
|
-
# Builtins and wrappers that run whatever FOLLOWS them without changing what
|
|
157
|
-
# is
|
|
184
|
+
# Builtins and wrappers that run whatever FOLLOWS them without changing what
|
|
185
|
+
# it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
|
|
186
|
+
# and `env -- env -S ...` runs exactly the inner `env`. Reading the wrapper as
|
|
187
|
+
# the command is how a payload hides from a guard that inspects only the first
|
|
188
|
+
# word of an invocation.
|
|
158
189
|
COMMAND_PREFIX_WRAPPERS = {"command", "exec", "builtin", "nohup", "setsid"}
|
|
190
|
+
# The only option of those that consumes a separate following token
|
|
191
|
+
# (`exec -a NAME env -S ...`).
|
|
159
192
|
COMMAND_PREFIX_SEPARATE_VALUE = {"-a"}
|
|
160
193
|
|
|
161
|
-
#
|
|
162
|
-
#
|
|
194
|
+
# Shell options whose following token is an operand, not another option. The
|
|
195
|
+
# generic recursive scanner must step over these before looking for `-c`.
|
|
196
|
+
SHELL_OPTIONS_SEPARATE_VALUE = {
|
|
197
|
+
"-o", "+o", "-O", "+O", "--rcfile", "--init-file", "--emulate",
|
|
198
|
+
}
|
|
199
|
+
SHELL_OPTIONS_INLINE_VALUE = ("--rcfile=", "--init-file=", "--emulate=")
|
|
200
|
+
|
|
201
|
+
# Boolean long options of the recognized shells. Listed only for PRECISION:
|
|
202
|
+
# a long option is never the command-string flag either way, but naming the
|
|
203
|
+
# ordinary ones keeps `bash --norc script.sh` from being treated as a possible
|
|
204
|
+
# value-taking option whose operand must be scanned past. zsh and ksh accept a
|
|
205
|
+
# long option per shell setting, so this set is deliberately not exhaustive —
|
|
206
|
+
# the scanner stays correct on the ones it does not name.
|
|
207
|
+
SHELL_LONG_OPTIONS_NO_VALUE = {
|
|
208
|
+
"--debugger", "--dump-po-strings", "--dump-strings", "--emacs",
|
|
209
|
+
"--globalrcs", "--help", "--interactive", "--login", "--monitor",
|
|
210
|
+
"--no-globalrcs", "--no-rcs", "--noediting", "--noprofile", "--norc",
|
|
211
|
+
"--norcs", "--posix", "--pretty-print", "--privileged", "--protected",
|
|
212
|
+
"--rcs", "--restricted", "--verbose", "--version", "--vi", "--wordexp",
|
|
213
|
+
"--xtrace",
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# git's own options that take a SEPARATE value token, which therefore must be
|
|
217
|
+
# skipped when looking for the subcommand: `git -c core.hooksPath=x commit` and
|
|
218
|
+
# `git -C /repo commit` both reach `commit`.
|
|
163
219
|
GIT_GLOBAL_SEPARATE_VALUE = {
|
|
164
220
|
"-c", "-C", "--config-env", "--git-dir", "--work-tree",
|
|
165
221
|
"--namespace", "--exec-path", "--super-prefix",
|
|
166
222
|
}
|
|
167
223
|
|
|
168
|
-
# `git commit` long options whose value can be a separate token
|
|
169
|
-
# VALUE is never
|
|
170
|
-
# Only genuinely value-taking options belong here: a
|
|
171
|
-
# would swallow the
|
|
224
|
+
# `git commit` long options whose value can be a separate token. Listed so the
|
|
225
|
+
# VALUE is never mistaken for a flag — `git commit --author "A -n B" -m x`
|
|
226
|
+
# must stay allowed. Only genuinely value-taking options belong here: adding a
|
|
227
|
+
# boolean one by mistake would swallow the token after it, and `git commit
|
|
228
|
+
# --amend -n` would go unnoticed.
|
|
172
229
|
COMMIT_LONG_SEPARATE_VALUE = {
|
|
173
230
|
"--message", "--file", "--author", "--date", "--template", "--cleanup",
|
|
174
231
|
"--reuse-message", "--reedit-message", "--fixup", "--squash",
|
|
175
232
|
"--pathspec-from-file", "--trailer",
|
|
176
233
|
}
|
|
177
234
|
|
|
178
|
-
# Short
|
|
179
|
-
#
|
|
180
|
-
# the message "n" and not a
|
|
235
|
+
# Short `git commit` options that REQUIRE a value: `-m`/`-F`/`-c`/`-C`/`-t`.
|
|
236
|
+
# In a cluster the value is whatever follows them in the same token, or the next
|
|
237
|
+
# token when nothing does — which is why `-mn` is the message "n" and not a
|
|
238
|
+
# bypass, and `-nm msg` IS one.
|
|
181
239
|
COMMIT_SHORT_REQUIRED_VALUE = set("mFcCt")
|
|
182
240
|
|
|
183
|
-
# Short options taking an OPTIONAL value, which git
|
|
184
|
-
# `-uno` is --untracked-files=no,
|
|
241
|
+
# Short options taking an OPTIONAL value, which git only ever reads attached:
|
|
242
|
+
# `-uno` is --untracked-files=no, `-Skeyid` is --gpg-sign=keyid. The cluster
|
|
243
|
+
# ends at them either way, so `-un` is untracked-files "n" rather than a bypass.
|
|
185
244
|
COMMIT_SHORT_OPTIONAL_VALUE = set("uS")
|
|
186
245
|
|
|
187
246
|
|
|
@@ -221,8 +280,8 @@ def shell_tokens(text):
|
|
|
221
280
|
line_boundaries_as_separators(text), posix=True, punctuation_chars=True
|
|
222
281
|
)
|
|
223
282
|
lexer.whitespace_split = True
|
|
224
|
-
# shlex treats `#` as a comment introducer and would
|
|
225
|
-
# `shlex.split` disables it, and so must this.
|
|
283
|
+
# shlex treats `#` as a comment introducer by default and would silently
|
|
284
|
+
# truncate the rest of the line; `shlex.split` disables it, and so must this.
|
|
226
285
|
lexer.commenters = ""
|
|
227
286
|
return list(lexer)
|
|
228
287
|
|
|
@@ -234,13 +293,17 @@ def cluster_skips_verification(cluster):
|
|
|
234
293
|
cluster: A single token beginning with one `-`, e.g. `-nm` or `-mn`.
|
|
235
294
|
|
|
236
295
|
Returns:
|
|
237
|
-
A pair (bypasses, consumes_next_token).
|
|
296
|
+
A pair (bypasses, consumes_next_token). `bypasses` is True when a real
|
|
297
|
+
`-n` option is present; `consumes_next_token` is True when the cluster
|
|
298
|
+
ends in a value-taking option whose value is the following token.
|
|
238
299
|
"""
|
|
239
300
|
body = cluster[1:]
|
|
240
301
|
for offset, letter in enumerate(body):
|
|
241
302
|
if letter == "n":
|
|
242
303
|
return (True, False)
|
|
243
304
|
if letter in COMMIT_SHORT_REQUIRED_VALUE:
|
|
305
|
+
# Everything after this letter is the value. It is a separate token
|
|
306
|
+
# only when nothing is attached.
|
|
244
307
|
return (False, offset == len(body) - 1)
|
|
245
308
|
if letter in COMMIT_SHORT_OPTIONAL_VALUE:
|
|
246
309
|
return (False, False)
|
|
@@ -260,12 +323,15 @@ def commit_bypasses_verification(argv):
|
|
|
260
323
|
while index < len(argv):
|
|
261
324
|
token = argv[index]
|
|
262
325
|
index += 1
|
|
263
|
-
# `--` ends the options;
|
|
326
|
+
# `--` ends the options; everything after it is a pathspec, and a file
|
|
327
|
+
# legitimately named `-n` is not a bypass.
|
|
264
328
|
if token in COMMAND_SEPARATORS or token == "--":
|
|
265
329
|
return False
|
|
266
330
|
if token.startswith("--"):
|
|
267
331
|
if token in COMMIT_LONG_SEPARATE_VALUE:
|
|
268
332
|
index += 1
|
|
333
|
+
elif disables_verification(token):
|
|
334
|
+
return True
|
|
269
335
|
continue
|
|
270
336
|
# A bare `-` is git's stdin placeholder, not an option cluster.
|
|
271
337
|
if not token.startswith("-") or token == "-":
|
|
@@ -300,10 +366,14 @@ def subcommand_after_git(tokens, start):
|
|
|
300
366
|
|
|
301
367
|
|
|
302
368
|
def resolve_env_long_option(name):
|
|
303
|
-
"""Resolve one env long option, abbreviations included.
|
|
369
|
+
"""Resolve one GNU env long option, abbreviations included.
|
|
304
370
|
|
|
305
|
-
|
|
306
|
-
|
|
371
|
+
Args:
|
|
372
|
+
name: A token beginning with `--`, with any `=value` already removed.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
The full option spelling; "" when the abbreviation names more than one
|
|
376
|
+
option, which real env refuses; None when nothing matches at all.
|
|
307
377
|
"""
|
|
308
378
|
if not name.startswith("--") or name == "--":
|
|
309
379
|
return None
|
|
@@ -316,17 +386,24 @@ def resolve_env_long_option(name):
|
|
|
316
386
|
|
|
317
387
|
|
|
318
388
|
def env_option_kind(token):
|
|
319
|
-
"""Classify one token in an `env` option position.
|
|
389
|
+
"""Classify one token sitting in an `env` option position.
|
|
320
390
|
|
|
321
|
-
Returns
|
|
322
|
-
|
|
323
|
-
|
|
391
|
+
Returns:
|
|
392
|
+
"split-string" when env will reparse an argv value as shell words,
|
|
393
|
+
"separate-value" when the NEXT token is this option's operand,
|
|
394
|
+
"no-value" when the token stands alone, "ambiguous" when env accepts
|
|
395
|
+
the token but this parser cannot say which option it is, or None when
|
|
396
|
+
the token is not an option.
|
|
324
397
|
"""
|
|
325
398
|
if not token.startswith("-") or token in {"-", "--"}:
|
|
326
399
|
return None
|
|
327
400
|
if token.startswith("--"):
|
|
328
|
-
|
|
401
|
+
name = token.split("=", 1)[0]
|
|
402
|
+
resolved = resolve_env_long_option(name)
|
|
329
403
|
if not resolved:
|
|
404
|
+
# Either unknown to this parser or an abbreviation naming several
|
|
405
|
+
# options. Neither can be reasoned about, so say so and let the
|
|
406
|
+
# caller fail closed rather than read past it.
|
|
330
407
|
return "ambiguous"
|
|
331
408
|
if resolved == "--split-string":
|
|
332
409
|
return "split-string"
|
|
@@ -339,8 +416,8 @@ def env_option_kind(token):
|
|
|
339
416
|
if letter == "S":
|
|
340
417
|
return "split-string"
|
|
341
418
|
if letter in ENV_SHORT_TAKES_VALUE:
|
|
342
|
-
# The rest of
|
|
343
|
-
# otherwise the value is the following token.
|
|
419
|
+
# The rest of this cluster is the option's value when there is
|
|
420
|
+
# one; otherwise the value is the following token.
|
|
344
421
|
return "no-value" if position <= len(token) - 1 else "separate-value"
|
|
345
422
|
if letter not in ENV_SHORT_NO_VALUE:
|
|
346
423
|
return "ambiguous"
|
|
@@ -348,17 +425,24 @@ def env_option_kind(token):
|
|
|
348
425
|
|
|
349
426
|
|
|
350
427
|
def strip_command_prefix(prefix):
|
|
351
|
-
"""Drop tokens that precede a command without changing what it is.
|
|
428
|
+
"""Drop the tokens that precede a command without changing what it is.
|
|
429
|
+
|
|
430
|
+
Variable assignments, the `command`/`exec`/`builtin` builtins, and nested
|
|
431
|
+
`env` wrappers all leave the following word in command position. Reading
|
|
432
|
+
any of them as the command itself is what let `command env -S ...` and
|
|
433
|
+
`env -- env -S ...` slip past the split-string check: the prefix failed
|
|
434
|
+
the "assignments only" test, so the wrapped `env` was never classified.
|
|
352
435
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
...` past the split-string check: the prefix failed the assignments-only
|
|
357
|
-
test, so the wrapped `env` was never classified.
|
|
436
|
+
Args:
|
|
437
|
+
prefix: The tokens between the last command separator and the
|
|
438
|
+
candidate program.
|
|
358
439
|
|
|
359
|
-
Returns
|
|
360
|
-
|
|
361
|
-
|
|
440
|
+
Returns:
|
|
441
|
+
A pair (status, remainder). "ok" carries the tokens still standing
|
|
442
|
+
between the separator and the candidate — empty means the candidate is
|
|
443
|
+
in command position. "operand" means the candidate is a wrapper
|
|
444
|
+
option's value rather than a command. "ambiguous" means a wrapper
|
|
445
|
+
option could not be classified and the caller must fail closed.
|
|
362
446
|
"""
|
|
363
447
|
cursor = 0
|
|
364
448
|
while cursor < len(prefix):
|
|
@@ -393,6 +477,8 @@ def strip_command_prefix(prefix):
|
|
|
393
477
|
if kind == "ambiguous":
|
|
394
478
|
return ("ambiguous", [])
|
|
395
479
|
if kind == "split-string":
|
|
480
|
+
# Everything after `-S` is env's own opaque payload, so the
|
|
481
|
+
# candidate is not a command at all.
|
|
396
482
|
return ("operand", [])
|
|
397
483
|
if kind == "separate-value":
|
|
398
484
|
if cursor + 1 >= len(prefix):
|
|
@@ -405,8 +491,33 @@ def strip_command_prefix(prefix):
|
|
|
405
491
|
return ("ok", prefix[cursor:])
|
|
406
492
|
|
|
407
493
|
|
|
494
|
+
def shell_starts_command(tokens, index):
|
|
495
|
+
"""Whether a recognized shell token occupies command position."""
|
|
496
|
+
start = index - 1
|
|
497
|
+
while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
|
|
498
|
+
start -= 1
|
|
499
|
+
status, remainder = strip_command_prefix(tokens[start + 1:index])
|
|
500
|
+
if status == "ambiguous":
|
|
501
|
+
# A prefix option this parser cannot classify may still be launching
|
|
502
|
+
# the shell. Fail closed rather than trusting the wrapper.
|
|
503
|
+
return True
|
|
504
|
+
return status == "ok" and not remainder
|
|
505
|
+
|
|
506
|
+
|
|
408
507
|
def env_uses_split_string(tokens, index):
|
|
409
|
-
"""Whether a command-position env invocation
|
|
508
|
+
"""Whether a command-position env invocation uses split-string parsing.
|
|
509
|
+
|
|
510
|
+
`env -S` reparses one opaque argv value as shell-like words. Inspecting the
|
|
511
|
+
outer command cannot prove what executable or nested shell that second
|
|
512
|
+
parse will produce, so the verification guard refuses the ambiguous form.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
tokens: The full operator-aware token list.
|
|
516
|
+
index: Index of the candidate env executable.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
True when env will reparse a split-string payload.
|
|
520
|
+
"""
|
|
410
521
|
start = index - 1
|
|
411
522
|
while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
|
|
412
523
|
start -= 1
|
|
@@ -420,9 +531,11 @@ def env_uses_split_string(tokens, index):
|
|
|
420
531
|
if token in COMMAND_SEPARATORS or token == "--":
|
|
421
532
|
return False
|
|
422
533
|
kind = env_option_kind(token)
|
|
423
|
-
if kind
|
|
424
|
-
|
|
425
|
-
|
|
534
|
+
if kind == "split-string":
|
|
535
|
+
return True
|
|
536
|
+
if kind == "ambiguous":
|
|
537
|
+
# An option env accepts but this parser cannot name makes the rest
|
|
538
|
+
# of the invocation unreadable; refuse instead of reading past it.
|
|
426
539
|
return True
|
|
427
540
|
if kind is None:
|
|
428
541
|
if "=" in token and not token.startswith(("=", "-")):
|
|
@@ -433,11 +546,55 @@ def env_uses_split_string(tokens, index):
|
|
|
433
546
|
return False
|
|
434
547
|
|
|
435
548
|
|
|
436
|
-
def
|
|
549
|
+
def nested_shell_payload(tokens, index):
|
|
550
|
+
"""Return a shell `-c` payload, or True when option parsing is ambiguous."""
|
|
551
|
+
cursor = index + 1
|
|
552
|
+
opaque_option_seen = False
|
|
553
|
+
while cursor < len(tokens):
|
|
554
|
+
option = tokens[cursor]
|
|
555
|
+
if option in COMMAND_SEPARATORS or option == "--":
|
|
556
|
+
return None
|
|
557
|
+
if not option.startswith(("-", "+")):
|
|
558
|
+
# Ordinarily the script operand, which ends option parsing. After
|
|
559
|
+
# a long option this parser cannot name it may instead be that
|
|
560
|
+
# option's value (`zsh --emulate ksh -c ...`), so keep scanning
|
|
561
|
+
# rather than concluding there is no command string.
|
|
562
|
+
if not opaque_option_seen:
|
|
563
|
+
return None
|
|
564
|
+
cursor += 1
|
|
565
|
+
continue
|
|
566
|
+
if option in SHELL_OPTIONS_SEPARATE_VALUE:
|
|
567
|
+
if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
|
|
568
|
+
return True
|
|
569
|
+
cursor += 2
|
|
570
|
+
continue
|
|
571
|
+
if option.startswith(SHELL_OPTIONS_INLINE_VALUE):
|
|
572
|
+
cursor += 1
|
|
573
|
+
continue
|
|
574
|
+
# A LONG option is never the command-string flag, and testing it for a
|
|
575
|
+
# bare `c` was the bug: `--norc` and `--restricted` merely CONTAIN one,
|
|
576
|
+
# so the real `-c` that followed was consumed as their payload and the
|
|
577
|
+
# nested `git commit --no-verify` was never classified at all. The
|
|
578
|
+
# command string lives only in a single-dash cluster (`-c`, `-lc`).
|
|
579
|
+
if option.startswith("--"):
|
|
580
|
+
if option not in SHELL_LONG_OPTIONS_NO_VALUE:
|
|
581
|
+
opaque_option_seen = True
|
|
582
|
+
cursor += 1
|
|
583
|
+
continue
|
|
584
|
+
if "c" in option[1:]:
|
|
585
|
+
if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
|
|
586
|
+
return True
|
|
587
|
+
return tokens[cursor + 1]
|
|
588
|
+
cursor += 1
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def git_commit_skips_verification(text, depth=0):
|
|
437
593
|
"""Whether the command runs `git commit` with the short `-n` bypass.
|
|
438
594
|
|
|
439
595
|
Args:
|
|
440
596
|
text: The command line, heredoc payloads already stripped.
|
|
597
|
+
depth: Number of recognized shell payloads already traversed.
|
|
441
598
|
|
|
442
599
|
Returns:
|
|
443
600
|
True if any `git commit` invocation on the line skips verification.
|
|
@@ -459,6 +616,21 @@ def git_commit_skips_verification(text):
|
|
|
459
616
|
found = subcommand_after_git(scoped_tokens, index + 1)
|
|
460
617
|
if found and found[0] == "commit" and commit_bypasses_verification(found[1]):
|
|
461
618
|
return True
|
|
619
|
+
if depth >= MAX_SHELL_NESTING:
|
|
620
|
+
return True
|
|
621
|
+
for index, token in enumerate(scoped_tokens):
|
|
622
|
+
program = token.rsplit("/", 1)[-1]
|
|
623
|
+
if program not in SHELL_PROGRAMS or not shell_starts_command(
|
|
624
|
+
scoped_tokens, index
|
|
625
|
+
):
|
|
626
|
+
continue
|
|
627
|
+
payload = nested_shell_payload(scoped_tokens, index)
|
|
628
|
+
if payload is True:
|
|
629
|
+
return True
|
|
630
|
+
if isinstance(payload, str) and git_commit_skips_verification(
|
|
631
|
+
payload, depth + 1
|
|
632
|
+
):
|
|
633
|
+
return True
|
|
462
634
|
return False
|
|
463
635
|
|
|
464
636
|
|
|
@@ -470,11 +642,19 @@ for i, token in enumerate(normalized_tokens):
|
|
|
470
642
|
sys.exit(1)
|
|
471
643
|
if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
|
|
472
644
|
sys.exit(1)
|
|
473
|
-
# Allowlist the destinations, do not denylist the disabling ones
|
|
474
|
-
#
|
|
475
|
-
#
|
|
476
|
-
#
|
|
477
|
-
#
|
|
645
|
+
# Allowlist the destinations, do not denylist the disabling ones.
|
|
646
|
+
#
|
|
647
|
+
# This used to block only "" and /dev/null. But hooks are disabled just as
|
|
648
|
+
# completely by pointing hooksPath at any directory that happens to contain
|
|
649
|
+
# none — `-c core.hooksPath=/tmp/empty` bypassed every hook while reading as
|
|
650
|
+
# an ordinary path — so a denylist of "obviously disabling" values can
|
|
651
|
+
# always be stepped around by naming a third thing. The set of paths that
|
|
652
|
+
# DISABLE hooks is unbounded; the set that legitimately relocates them is
|
|
653
|
+
# tiny and known, so the allowlist is the only side that can be enumerated.
|
|
654
|
+
#
|
|
655
|
+
# Matched case-insensitively because git config variable names are:
|
|
656
|
+
# `CORE.HOOKSPATH=/x` and `core.hookspath=/x` are the same setting to git,
|
|
657
|
+
# so a case-sensitive check is bypassed by holding down shift.
|
|
478
658
|
lowered = token.lower()
|
|
479
659
|
if lowered.startswith("core.hookspath="):
|
|
480
660
|
if not is_permitted_hooks_path(token.split("=", 1)[1]):
|
|
@@ -482,30 +662,55 @@ for i, token in enumerate(normalized_tokens):
|
|
|
482
662
|
if lowered == "core.hookspath" and i + 1 < len(normalized_tokens):
|
|
483
663
|
if not is_permitted_hooks_path(normalized_tokens[i + 1]):
|
|
484
664
|
sys.exit(1)
|
|
485
|
-
#
|
|
486
|
-
#
|
|
665
|
+
# `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
|
|
666
|
+
# the value out of the named environment variable. The path therefore is
|
|
667
|
+
# not in the command at all, so there is nothing to allowlist against —
|
|
668
|
+
# SOMEVAR can hold anything by the time git reads it. Any core.hooksPath
|
|
669
|
+
# routed through --config-env is refused outright.
|
|
487
670
|
if lowered.startswith("--config-env="):
|
|
488
671
|
spec = token.split("=", 1)[1]
|
|
489
672
|
if spec.split("=", 1)[0].strip().lower() == "core.hookspath":
|
|
490
673
|
sys.exit(1)
|
|
491
|
-
# git
|
|
492
|
-
# only the `=` spelling
|
|
493
|
-
#
|
|
494
|
-
#
|
|
495
|
-
#
|
|
674
|
+
# git accepts `--config-env <name>=<envvar>` as TWO tokens as well as one,
|
|
675
|
+
# and guarding only the `=` spelling was worse than missing the separate
|
|
676
|
+
# form outright: the trailing `core.hooksPath=.husky` then fell through to
|
|
677
|
+
# the allowlist above, which reads `.husky` as a PATH and permits it. But
|
|
678
|
+
# here it is an ENVIRONMENT VARIABLE NAME, and `env '.husky=/dev/null' git
|
|
679
|
+
# --config-env core.hooksPath=.husky` really does resolve hooksPath to
|
|
680
|
+
# /dev/null. The allowlist was being used as the bypass.
|
|
681
|
+
#
|
|
682
|
+
# Checked at the `--config-env` token, which the loop reaches first, so the
|
|
683
|
+
# refusal happens before the value token can be mistaken for a path.
|
|
496
684
|
if lowered == "--config-env" and i + 1 < len(normalized_tokens):
|
|
497
685
|
spec = normalized_tokens[i + 1]
|
|
498
686
|
if spec.split("=", 1)[0].strip().strip("'\"").lower() == "core.hookspath":
|
|
499
687
|
sys.exit(1)
|
|
500
688
|
# `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath
|
|
501
|
-
# GIT_CONFIG_VALUE_0=/dev/null git
|
|
502
|
-
#
|
|
503
|
-
#
|
|
689
|
+
# GIT_CONFIG_VALUE_0=/dev/null git commit` sets command-scope config the
|
|
690
|
+
# same way `-c core.hooksPath=...` does — env-var-style assignments ahead of
|
|
691
|
+
# the invocation instead of a flag — so it disables every hook just as
|
|
692
|
+
# completely while matching none of the token shapes above. Upstream missed
|
|
693
|
+
# this until a downstream fork hardened its own copy against it, which is
|
|
694
|
+
# the one direction a guard must never be caught in.
|
|
695
|
+
#
|
|
696
|
+
# The index is matched as `\d+` rather than pinned to 0: git accepts any
|
|
697
|
+
# index below GIT_CONFIG_COUNT, so a single-index check is evaded by typing
|
|
698
|
+
# a 1. Refused outright, like --config-env=, because the path lives in a
|
|
699
|
+
# separate GIT_CONFIG_VALUE_<n> token that can be exported earlier,
|
|
700
|
+
# reordered, or left out entirely — there is nothing here to allowlist
|
|
701
|
+
# against.
|
|
504
702
|
key_match = re.match(r"git_config_key_\d+=(.*)$", lowered, re.DOTALL)
|
|
505
703
|
if key_match and key_match.group(1).strip().strip("'\"") == "core.hookspath":
|
|
506
704
|
sys.exit(1)
|
|
705
|
+
# `git -c` propagates command-scope config through GIT_CONFIG_PARAMETERS.
|
|
706
|
+
# Parsing the value as Git's shell-quoted parameter list distinguishes the
|
|
707
|
+
# key from an unrelated value that merely contains the same text.
|
|
507
708
|
if re.match(r"git_config_parameters\+?=", lowered):
|
|
508
709
|
parameters = token.split("=", 1)[1]
|
|
710
|
+
# The guard sees the command before the shell expands assignments. A
|
|
711
|
+
# variable or command substitution can therefore hide hooksPath from
|
|
712
|
+
# this parser and reveal it only to Git. Refuse unresolved values; the
|
|
713
|
+
# guard cannot safely prove what configuration they will become.
|
|
509
714
|
if "$" in parameters or "`" in parameters:
|
|
510
715
|
sys.exit(1)
|
|
511
716
|
try:
|
|
@@ -20,9 +20,9 @@ export const NIGHTLY_E2E_GUARD_BEHAVIOR_CERTIFICATES = Object.freeze({
|
|
|
20
20
|
}),
|
|
21
21
|
"312e8962e21be2f3c6c6c0235b0c6159b8dc39425508b4965c7df87fb1333380": Object.freeze({
|
|
22
22
|
contractVersion: "1.9.0",
|
|
23
|
-
packageVersions: Object.freeze(["4.28.
|
|
23
|
+
packageVersions: Object.freeze(["4.28.3"]),
|
|
24
24
|
provenances: Object.freeze([
|
|
25
|
-
"workspace package @codyswann/lisa@4.28.
|
|
25
|
+
"workspace package @codyswann/lisa@4.28.3 (typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs)",
|
|
26
26
|
]),
|
|
27
27
|
}),
|
|
28
28
|
"898ee0247806c7aa6e98328662d80bcff22ff067999298865462a58daef8bb22": Object.freeze({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upstream-evidence-manifest.d.ts","sourceRoot":"","sources":["../../src/core/upstream-evidence-manifest.ts"],"names":[],"mappings":"AACA,iFAAiF;AACjF,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAs+EpE,CAAC;AAEL,4EAA4E;AAC5E,eAAO,MAAM,yBAAyB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"upstream-evidence-manifest.d.ts","sourceRoot":"","sources":["../../src/core/upstream-evidence-manifest.ts"],"names":[],"mappings":"AACA,iFAAiF;AACjF,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAs+EpE,CAAC;AAEL,4EAA4E;AAC5E,eAAO,MAAM,yBAAyB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAg+PjE,CAAC;AAEL,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAk6I/D,CAAC"}
|
|
@@ -357,7 +357,7 @@ export const UPSTREAM_EVIDENCE_MANIFEST = Object.freeze({
|
|
|
357
357
|
"plugins/src/base/hooks/block-instruction-file-edits.agy.sh": "aa249cae53caeb3e0fb6d6af114e2756084f45a2896332fb063a9f20e4902125",
|
|
358
358
|
"plugins/src/base/hooks/block-instruction-file-edits.sh": "d47314b66d6ce85f77d6e058f861eede4462b2b0a33d54e82ff1c931167ec3f7",
|
|
359
359
|
"plugins/src/base/hooks/block-managed-file-edits.sh": "9b566bd1ff465147955300ce66b75c866db461a04dc67866c22bb928c757a616",
|
|
360
|
-
"plugins/src/base/hooks/block-no-verify.agy.sh": "
|
|
360
|
+
"plugins/src/base/hooks/block-no-verify.agy.sh": "96bb101825a4f20f49dcb2b8cd87af055e7a05c33b4b614b957e34811294ddad",
|
|
361
361
|
"plugins/src/base/hooks/block-no-verify.sh": "a091c68c8ce67d250adb89600a0875d63c8a57a48f179a5eb5e527cc394dc128",
|
|
362
362
|
"plugins/src/base/hooks/block-shell-json-parsing.agy.sh": "dc688efe382e7b8fe6f6c88bb0fde851527128cae778599559f23a93f1c9ec86",
|
|
363
363
|
"plugins/src/base/hooks/block-shell-json-parsing.sh": "90601e31603c440d8f19d213d63bfef8d18596c60f88cb6e28c93d12453c5c0c",
|
|
@@ -8398,6 +8398,7 @@ export const UPSTREAM_SURFACE_MANIFEST = Object.freeze({
|
|
|
8398
8398
|
"tests/unit/hooks/block-managed-file-edits.test.ts": true,
|
|
8399
8399
|
"tests/unit/hooks/block-no-verify-command-config.test.ts": true,
|
|
8400
8400
|
"tests/unit/hooks/block-no-verify-missing-jq.test.ts": true,
|
|
8401
|
+
"tests/unit/hooks/block-no-verify-nested-shell-parity.test.ts": true,
|
|
8401
8402
|
"tests/unit/hooks/block-no-verify-short-flag.test.ts": true,
|
|
8402
8403
|
"tests/unit/hooks/block-no-verify.test.ts": true,
|
|
8403
8404
|
"tests/unit/hooks/block-shell-json-parsing-missing-deps.test.ts": true,
|