@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.
Files changed (59) hide show
  1. package/dist/codex/scripts/block-no-verify.sh +302 -97
  2. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  3. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  4. package/dist/core/upstream-evidence-manifest.js +2 -1
  5. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  6. package/package.json +3 -3
  7. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  8. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  9. package/plugins/lisa/hooks/block-no-verify.agy.sh +302 -97
  10. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +302 -97
  11. package/plugins/lisa-agy/plugin.json +1 -1
  12. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  15. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo-agy/plugin.json +1 -1
  22. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  42. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails-agy/plugin.json +1 -1
  47. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  52. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  57. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  59. 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
- # Git resolves any UNAMBIGUOUS abbreviation of a long option, so `--no-veri`
80
- # skips hooks exactly as completely as `--no-verify`. Matched as a prefix
81
- # rather than by listing abbreviations; `--no-verbose` diverges after
82
- # `--no-ver` and so is correctly not caught.
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; so does a cluster such as `-nm "msg"`. Matching it
125
- # needs SCOPE, which is why it was long excluded: `-n` means --dry-run to `git
126
- # push`, --no-stat to `git merge`, and a line count to head/tail/sort/grep. Only
127
- # `git commit` reads it as a hook bypass, so only that invocation's argv is
128
- # scanned. Parity with the Claude block-no-verify.sh, which carries the full
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
- # A SECOND tokenizer pass, deliberately not shared with the checks above:
132
- # `shlex.split` glues `|` and `;` to their neighbours, so `git commit -m x &&
133
- # grep -n foo` would read as one long git invocation and refuse the grep.
134
- # `punctuation_chars=True` emits the operators as their own tokens, giving the
135
- # scan a boundary to stop at.
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
- # GNU env's long options in FULL, so an ABBREVIATION can be resolved against
141
- # them: getopt_long accepts any prefix naming exactly one option, and nothing
142
- # but --split-string starts with `s`, so `env --s` splits a string just as
143
- # completely as `env --split-string` does.
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. The signal options take
151
- # an OPTIONAL value, which GNU reads only when attached with `=`.
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 it
157
- # is, so `command env -S ...` runs exactly the `env` that `env -S ...` runs.
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
- # git's own options taking a SEPARATE value token, skipped when looking for the
162
- # subcommand so `git -c core.hooksPath=x commit` still reaches `commit`.
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, listed so the
169
- # VALUE is never read as a flag (`git commit --author "A -n B" -m x` is fine).
170
- # Only genuinely value-taking options belong here: a boolean added by mistake
171
- # would swallow the next token and miss `git commit --amend -n`.
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 options REQUIRING a value. In a cluster the value is whatever follows in
179
- # the same token, or the next token when nothing does — which is why `-mn` is
180
- # the message "n" and not a bypass, while `-nm msg` is one.
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 reads only when attached:
184
- # `-uno` is --untracked-files=no, so `-un` is a mode and not a bypass.
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 truncate the line;
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; a file legitimately named `-n` is not a bypass.
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
- Returns the full spelling, "" when the abbreviation names several options
306
- (real env refuses those), or None when nothing matches.
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 "split-string", "separate-value", "no-value", "ambiguous" when env
322
- accepts the token but this parser cannot name it, or None when the token is
323
- not an option at all.
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
- resolved = resolve_env_long_option(token.split("=", 1)[0])
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 the cluster is the option's value when there is one;
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
- Assignments, the `command`/`exec`/`builtin` builtins, and nested `env`
354
- wrappers all leave the following word in command position. Reading any of
355
- them as the command is what let `command env -S ...` and `env -- env -S
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 a pair (status, remainder): "ok" with whatever still stands before
360
- the candidate, "operand" when the candidate is a wrapper option's value,
361
- or "ambiguous" when an option could not be classified.
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 reparses an opaque payload."""
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 in {"split-string", "ambiguous"}:
424
- # An option this parser cannot name makes the rest of the
425
- # invocation unreadable; refuse instead of reading past it.
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 git_commit_skips_verification(text):
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: hooks are
474
- # disabled just as completely by any directory that happens to contain none
475
- # (`-c core.hooksPath=/tmp/empty`), so the set that DISABLES hooks is
476
- # unbounded while the set that legitimately relocates them is tiny. Matched
477
- # case-insensitively because git config names are.
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
- # `--config-env=core.hooksPath=SOMEVAR` reads the path out of the named env
486
- # var, so it is not in the command at all and cannot be allowlisted.
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 also accepts `--config-env <name>=<envvar>` as two tokens. Guarding
492
- # only the `=` spelling let the trailing `core.hooksPath=.husky` fall
493
- # through to the allowlist above, which reads `.husky` as a path — but here
494
- # it names an ENVIRONMENT VARIABLE, which can hold /dev/null. Checked at the
495
- # `--config-env` token, which the loop reaches first.
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 ...` sets the same command-scope config
502
- # via env-var-style assignments. The index is arbitrary below
503
- # GIT_CONFIG_COUNT, so it is matched as `\d+` rather than pinned to 0.
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.2"]),
23
+ packageVersions: Object.freeze(["4.28.3"]),
24
24
  provenances: Object.freeze([
25
- "workspace package @codyswann/lisa@4.28.2 (typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs)",
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,CA+9PjE,CAAC;AAEL,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAk6I/D,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": "9edd3c57da0a6b8947f1d762615344578bdfd7ba5342327657558801195dc9cd",
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,