@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
@@ -26,6 +26,9 @@
26
26
  # on PreToolUse. jq parses the JSON envelope (per project rule: never grep/sed
27
27
  # JSON); the command string itself is matched with grep (it is a plain string,
28
28
  # not JSON). Malformed/empty stdin → allow (fail open, never crash the tool).
29
+ #
30
+ # Capability names below are the canonical guard's, kept identical on every agent.
31
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization
29
32
  set -uo pipefail
30
33
 
31
34
  allow() {
@@ -100,10 +103,42 @@ except ValueError:
100
103
 
101
104
  normalized_tokens = [token.strip("();|&") for token in tokens]
102
105
 
103
- # Git resolves any UNAMBIGUOUS abbreviation of a long option, so `--no-veri`
104
- # skips hooks exactly as completely as `--no-verify`. Matched as a prefix
105
- # rather than by listing abbreviations; `--no-verbose` diverges after
106
- # `--no-ver` and so is correctly not caught.
106
+ # The only relocations that keep a repo's own hooks in play. `.husky` is the
107
+ # husky convention this fleet runs; `.githooks` is the common hand-rolled one.
108
+ # Anything else — including "" and /dev/null, which are simply the two most
109
+ # obvious members of the blocked set rather than special cases — is refused.
110
+ PERMITTED_HOOKS_PATHS = {".husky", ".githooks"}
111
+
112
+
113
+ def is_permitted_hooks_path(value):
114
+ """Whether a core.hooksPath value relocates hooks rather than disabling them.
115
+
116
+ Args:
117
+ value: The raw core.hooksPath value as it appeared on the command line.
118
+
119
+ Returns:
120
+ True if the path is an established in-repo hooks directory.
121
+ """
122
+ cleaned = value.strip().strip("'\"")
123
+ if cleaned.startswith("./"):
124
+ cleaned = cleaned[2:]
125
+ return cleaned.rstrip("/") in PERMITTED_HOOKS_PATHS
126
+
127
+
128
+ # Git resolves any UNAMBIGUOUS abbreviation of a long option, so `--no-verify`
129
+ # is only the longest of the spellings that disable verification: `git commit
130
+ # --no-veri` skips hooks exactly as completely. An equality check therefore
131
+ # enforced the guard against the one spelling nobody in a hurry types.
132
+ #
133
+ # Matched as "a prefix of --no-verify" rather than by listing abbreviations,
134
+ # because the set of accepted abbreviations is a property of git's parser and
135
+ # changes with the surrounding options. `--no-verbose` is NOT caught, and must
136
+ # not be: it diverges from `--no-verify` at the character after `--no-ver`, so
137
+ # it fails the prefix test.
138
+ #
139
+ # The floor is `--no-v`. Shorter is refused anyway — bare `--no-` is not a flag
140
+ # — and blocking an abbreviation git would reject as ambiguous costs nothing,
141
+ # while missing one git accepts costs the whole guard.
107
142
  NO_VERIFY = "--no-verify"
108
143
  NO_VERIFY_MIN_PREFIX = len("--no-v")
109
144
 
@@ -123,89 +158,113 @@ def disables_verification(token):
123
158
  and NO_VERIFY.startswith(token)
124
159
  )
125
160
 
126
- # The only relocations that keep a repo's own hooks in play. Anything else —
127
- # including "" and /dev/null, which are simply the two most obvious members of
128
- # the blocked set rather than special cases — is refused.
129
- PERMITTED_HOOKS_PATHS = {".husky", ".githooks"}
130
-
131
-
132
- def is_permitted_hooks_path(value):
133
- """Whether a core.hooksPath value relocates hooks rather than disabling them.
134
-
135
- Args:
136
- value: The raw core.hooksPath value as it appeared on the command line.
137
-
138
- Returns:
139
- True if the path is an established in-repo hooks directory.
140
- """
141
- cleaned = value.strip().strip("'\"")
142
- if cleaned.startswith("./"):
143
- cleaned = cleaned[2:]
144
- return cleaned.rstrip("/") in PERMITTED_HOOKS_PATHS
145
-
146
161
 
147
162
  # `git commit -n` is the short spelling of --no-verify and skips pre-commit and
148
- # commit-msg identically; so does a cluster such as `-nm "msg"`. Matching it
149
- # needs SCOPE, which is why it was long excluded: `-n` means --dry-run to `git
150
- # push`, --no-stat to `git merge`, and a line count to head/tail/sort/grep. Only
151
- # `git commit` reads it as a hook bypass, so only that invocation's argv is
152
- # scanned. Parity with the Claude block-no-verify.sh, which carries the full
153
- # rationale for why a tokenizer can make this distinction and a grep could not.
163
+ # commit-msg identically. Matching it needs one thing the `--no-verify` scan
164
+ # above does not: SCOPE. `-n` means --dry-run to `git push`, --no-stat to `git
165
+ # merge`, and a line count to head/tail/sort/grep, so a token-anywhere match
166
+ # would refuse ordinary commands all day. Only `git commit` reads it as a hook
167
+ # bypass, so only that invocation's argv is scanned.
154
168
  #
155
- # A SECOND tokenizer pass, deliberately not shared with the checks above:
156
- # `shlex.split` glues `|` and `;` to their neighbours, so `git commit -m x &&
157
- # grep -n foo` would read as one long git invocation and refuse the grep.
158
- # `punctuation_chars=True` emits the operators as their own tokens, giving the
159
- # scan a boundary to stop at.
169
+ # The tokenizer below is a SECOND pass, deliberately not shared with the one the
170
+ # checks above use. `shlex.split` leaves `|` and `;` glued to their neighbours,
171
+ # which those checks paper over by stripping the punctuation off each token —
172
+ # fine when the question is "does this token look like a bypass anywhere in the
173
+ # line", fatal when the question is "where does this command end", because
174
+ # `git commit -m x && grep -n foo` would then read as one long git invocation
175
+ # and refuse the grep. `punctuation_chars=True` emits the operators as their own
176
+ # tokens, so the invocation's boundary is a token the scan can stop at.
160
177
  COMMAND_SEPARATORS = {
161
178
  ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
162
179
  }
163
180
 
164
- # GNU env's long options in FULL, so an ABBREVIATION can be resolved against
165
- # them: getopt_long accepts any prefix naming exactly one option, and nothing
166
- # but --split-string starts with `s`, so `env --s` splits a string just as
167
- # completely as `env --split-string` does.
181
+ SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
182
+ MAX_SHELL_NESTING = 8
183
+
184
+ # GNU env's long options, listed in FULL so an abbreviation can be resolved
185
+ # against them. getopt_long accepts any prefix that names exactly one option,
186
+ # and no other env option begins with `s` — so `env --s`, `env --sp`, and
187
+ # `env --split` are `env --split-string` just as completely as the full
188
+ # spelling is. Matching only the full spelling is bypassed by deleting
189
+ # characters, which is why the set below exists instead of a literal list of
190
+ # the two spellings the guard used to know.
168
191
  ENV_LONG_OPTIONS = {
169
192
  "--argv0", "--block-signals", "--chdir", "--debug", "--default-signal",
170
193
  "--help", "--ignore-environment", "--ignore-signal",
171
194
  "--list-signal-handling", "--null", "--split-string", "--unset",
172
195
  "--version",
173
196
  }
174
- # Of those, the ones whose value is a SEPARATE token. The signal options take
175
- # an OPTIONAL value, which GNU reads only when attached with `=`.
197
+ # Of those, the ones whose value is a SEPARATE token, so the operand is never
198
+ # mistaken for the command (`env -u bash -c ...` consumes `bash` as the
199
+ # variable name). The signal options take an OPTIONAL value, which GNU reads
200
+ # only when it is attached with `=`, so they never consume a following token.
176
201
  ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
202
+
203
+ # env's single-letter options, by whether the rest of a cluster is more
204
+ # options or the option's value.
177
205
  ENV_SHORT_NO_VALUE = frozenset("i0v")
178
206
  ENV_SHORT_TAKES_VALUE = frozenset("uCaS")
179
207
 
180
- # Builtins and wrappers that run whatever FOLLOWS them without changing what it
181
- # is, so `command env -S ...` runs exactly the `env` that `env -S ...` runs.
208
+ # Builtins and wrappers that run whatever FOLLOWS them without changing what
209
+ # it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
210
+ # and `env -- env -S ...` runs exactly the inner `env`. Reading the wrapper as
211
+ # the command is how a payload hides from a guard that inspects only the first
212
+ # word of an invocation.
182
213
  COMMAND_PREFIX_WRAPPERS = {"command", "exec", "builtin", "nohup", "setsid"}
214
+ # The only option of those that consumes a separate following token
215
+ # (`exec -a NAME env -S ...`).
183
216
  COMMAND_PREFIX_SEPARATE_VALUE = {"-a"}
184
217
 
185
- # git's own options taking a SEPARATE value token, skipped when looking for the
186
- # subcommand so `git -c core.hooksPath=x commit` still reaches `commit`.
218
+ # Shell options whose following token is an operand, not another option. The
219
+ # generic recursive scanner must step over these before looking for `-c`.
220
+ SHELL_OPTIONS_SEPARATE_VALUE = {
221
+ "-o", "+o", "-O", "+O", "--rcfile", "--init-file", "--emulate",
222
+ }
223
+ SHELL_OPTIONS_INLINE_VALUE = ("--rcfile=", "--init-file=", "--emulate=")
224
+
225
+ # Boolean long options of the recognized shells. Listed only for PRECISION:
226
+ # a long option is never the command-string flag either way, but naming the
227
+ # ordinary ones keeps `bash --norc script.sh` from being treated as a possible
228
+ # value-taking option whose operand must be scanned past. zsh and ksh accept a
229
+ # long option per shell setting, so this set is deliberately not exhaustive —
230
+ # the scanner stays correct on the ones it does not name.
231
+ SHELL_LONG_OPTIONS_NO_VALUE = {
232
+ "--debugger", "--dump-po-strings", "--dump-strings", "--emacs",
233
+ "--globalrcs", "--help", "--interactive", "--login", "--monitor",
234
+ "--no-globalrcs", "--no-rcs", "--noediting", "--noprofile", "--norc",
235
+ "--norcs", "--posix", "--pretty-print", "--privileged", "--protected",
236
+ "--rcs", "--restricted", "--verbose", "--version", "--vi", "--wordexp",
237
+ "--xtrace",
238
+ }
239
+
240
+ # git's own options that take a SEPARATE value token, which therefore must be
241
+ # skipped when looking for the subcommand: `git -c core.hooksPath=x commit` and
242
+ # `git -C /repo commit` both reach `commit`.
187
243
  GIT_GLOBAL_SEPARATE_VALUE = {
188
244
  "-c", "-C", "--config-env", "--git-dir", "--work-tree",
189
245
  "--namespace", "--exec-path", "--super-prefix",
190
246
  }
191
247
 
192
- # `git commit` long options whose value can be a separate token, listed so the
193
- # VALUE is never read as a flag (`git commit --author "A -n B" -m x` is fine).
194
- # Only genuinely value-taking options belong here: a boolean added by mistake
195
- # would swallow the next token and miss `git commit --amend -n`.
248
+ # `git commit` long options whose value can be a separate token. Listed so the
249
+ # VALUE is never mistaken for a flag — `git commit --author "A -n B" -m x`
250
+ # must stay allowed. Only genuinely value-taking options belong here: adding a
251
+ # boolean one by mistake would swallow the token after it, and `git commit
252
+ # --amend -n` would go unnoticed.
196
253
  COMMIT_LONG_SEPARATE_VALUE = {
197
254
  "--message", "--file", "--author", "--date", "--template", "--cleanup",
198
255
  "--reuse-message", "--reedit-message", "--fixup", "--squash",
199
256
  "--pathspec-from-file", "--trailer",
200
257
  }
201
258
 
202
- # Short options REQUIRING a value. In a cluster the value is whatever follows in
203
- # the same token, or the next token when nothing does — which is why `-mn` is
204
- # the message "n" and not a bypass, while `-nm msg` is one.
259
+ # Short `git commit` options that REQUIRE a value: `-m`/`-F`/`-c`/`-C`/`-t`.
260
+ # In a cluster the value is whatever follows them in the same token, or the next
261
+ # token when nothing does — which is why `-mn` is the message "n" and not a
262
+ # bypass, and `-nm msg` IS one.
205
263
  COMMIT_SHORT_REQUIRED_VALUE = set("mFcCt")
206
264
 
207
- # Short options taking an OPTIONAL value, which git reads only when attached:
208
- # `-uno` is --untracked-files=no, so `-un` is a mode and not a bypass.
265
+ # Short options taking an OPTIONAL value, which git only ever reads attached:
266
+ # `-uno` is --untracked-files=no, `-Skeyid` is --gpg-sign=keyid. The cluster
267
+ # ends at them either way, so `-un` is untracked-files "n" rather than a bypass.
209
268
  COMMIT_SHORT_OPTIONAL_VALUE = set("uS")
210
269
 
211
270
 
@@ -245,8 +304,8 @@ def shell_tokens(text):
245
304
  line_boundaries_as_separators(text), posix=True, punctuation_chars=True
246
305
  )
247
306
  lexer.whitespace_split = True
248
- # shlex treats `#` as a comment introducer and would truncate the line;
249
- # `shlex.split` disables it, and so must this.
307
+ # shlex treats `#` as a comment introducer by default and would silently
308
+ # truncate the rest of the line; `shlex.split` disables it, and so must this.
250
309
  lexer.commenters = ""
251
310
  return list(lexer)
252
311
 
@@ -258,13 +317,17 @@ def cluster_skips_verification(cluster):
258
317
  cluster: A single token beginning with one `-`, e.g. `-nm` or `-mn`.
259
318
 
260
319
  Returns:
261
- A pair (bypasses, consumes_next_token).
320
+ A pair (bypasses, consumes_next_token). `bypasses` is True when a real
321
+ `-n` option is present; `consumes_next_token` is True when the cluster
322
+ ends in a value-taking option whose value is the following token.
262
323
  """
263
324
  body = cluster[1:]
264
325
  for offset, letter in enumerate(body):
265
326
  if letter == "n":
266
327
  return (True, False)
267
328
  if letter in COMMIT_SHORT_REQUIRED_VALUE:
329
+ # Everything after this letter is the value. It is a separate token
330
+ # only when nothing is attached.
268
331
  return (False, offset == len(body) - 1)
269
332
  if letter in COMMIT_SHORT_OPTIONAL_VALUE:
270
333
  return (False, False)
@@ -284,12 +347,15 @@ def commit_bypasses_verification(argv):
284
347
  while index < len(argv):
285
348
  token = argv[index]
286
349
  index += 1
287
- # `--` ends the options; a file legitimately named `-n` is not a bypass.
350
+ # `--` ends the options; everything after it is a pathspec, and a file
351
+ # legitimately named `-n` is not a bypass.
288
352
  if token in COMMAND_SEPARATORS or token == "--":
289
353
  return False
290
354
  if token.startswith("--"):
291
355
  if token in COMMIT_LONG_SEPARATE_VALUE:
292
356
  index += 1
357
+ elif disables_verification(token):
358
+ return True
293
359
  continue
294
360
  # A bare `-` is git's stdin placeholder, not an option cluster.
295
361
  if not token.startswith("-") or token == "-":
@@ -324,10 +390,14 @@ def subcommand_after_git(tokens, start):
324
390
 
325
391
 
326
392
  def resolve_env_long_option(name):
327
- """Resolve one env long option, abbreviations included.
393
+ """Resolve one GNU env long option, abbreviations included.
328
394
 
329
- Returns the full spelling, "" when the abbreviation names several options
330
- (real env refuses those), or None when nothing matches.
395
+ Args:
396
+ name: A token beginning with `--`, with any `=value` already removed.
397
+
398
+ Returns:
399
+ The full option spelling; "" when the abbreviation names more than one
400
+ option, which real env refuses; None when nothing matches at all.
331
401
  """
332
402
  if not name.startswith("--") or name == "--":
333
403
  return None
@@ -340,17 +410,24 @@ def resolve_env_long_option(name):
340
410
 
341
411
 
342
412
  def env_option_kind(token):
343
- """Classify one token in an `env` option position.
413
+ """Classify one token sitting in an `env` option position.
344
414
 
345
- Returns "split-string", "separate-value", "no-value", "ambiguous" when env
346
- accepts the token but this parser cannot name it, or None when the token is
347
- not an option at all.
415
+ Returns:
416
+ "split-string" when env will reparse an argv value as shell words,
417
+ "separate-value" when the NEXT token is this option's operand,
418
+ "no-value" when the token stands alone, "ambiguous" when env accepts
419
+ the token but this parser cannot say which option it is, or None when
420
+ the token is not an option.
348
421
  """
349
422
  if not token.startswith("-") or token in {"-", "--"}:
350
423
  return None
351
424
  if token.startswith("--"):
352
- resolved = resolve_env_long_option(token.split("=", 1)[0])
425
+ name = token.split("=", 1)[0]
426
+ resolved = resolve_env_long_option(name)
353
427
  if not resolved:
428
+ # Either unknown to this parser or an abbreviation naming several
429
+ # options. Neither can be reasoned about, so say so and let the
430
+ # caller fail closed rather than read past it.
354
431
  return "ambiguous"
355
432
  if resolved == "--split-string":
356
433
  return "split-string"
@@ -363,8 +440,8 @@ def env_option_kind(token):
363
440
  if letter == "S":
364
441
  return "split-string"
365
442
  if letter in ENV_SHORT_TAKES_VALUE:
366
- # The rest of the cluster is the option's value when there is one;
367
- # otherwise the value is the following token.
443
+ # The rest of this cluster is the option's value when there is
444
+ # one; otherwise the value is the following token.
368
445
  return "no-value" if position <= len(token) - 1 else "separate-value"
369
446
  if letter not in ENV_SHORT_NO_VALUE:
370
447
  return "ambiguous"
@@ -372,17 +449,24 @@ def env_option_kind(token):
372
449
 
373
450
 
374
451
  def strip_command_prefix(prefix):
375
- """Drop tokens that precede a command without changing what it is.
452
+ """Drop the tokens that precede a command without changing what it is.
453
+
454
+ Variable assignments, the `command`/`exec`/`builtin` builtins, and nested
455
+ `env` wrappers all leave the following word in command position. Reading
456
+ any of them as the command itself is what let `command env -S ...` and
457
+ `env -- env -S ...` slip past the split-string check: the prefix failed
458
+ the "assignments only" test, so the wrapped `env` was never classified.
376
459
 
377
- Assignments, the `command`/`exec`/`builtin` builtins, and nested `env`
378
- wrappers all leave the following word in command position. Reading any of
379
- them as the command is what let `command env -S ...` and `env -- env -S
380
- ...` past the split-string check: the prefix failed the assignments-only
381
- test, so the wrapped `env` was never classified.
460
+ Args:
461
+ prefix: The tokens between the last command separator and the
462
+ candidate program.
382
463
 
383
- Returns a pair (status, remainder): "ok" with whatever still stands before
384
- the candidate, "operand" when the candidate is a wrapper option's value,
385
- or "ambiguous" when an option could not be classified.
464
+ Returns:
465
+ A pair (status, remainder). "ok" carries the tokens still standing
466
+ between the separator and the candidate — empty means the candidate is
467
+ in command position. "operand" means the candidate is a wrapper
468
+ option's value rather than a command. "ambiguous" means a wrapper
469
+ option could not be classified and the caller must fail closed.
386
470
  """
387
471
  cursor = 0
388
472
  while cursor < len(prefix):
@@ -417,6 +501,8 @@ def strip_command_prefix(prefix):
417
501
  if kind == "ambiguous":
418
502
  return ("ambiguous", [])
419
503
  if kind == "split-string":
504
+ # Everything after `-S` is env's own opaque payload, so the
505
+ # candidate is not a command at all.
420
506
  return ("operand", [])
421
507
  if kind == "separate-value":
422
508
  if cursor + 1 >= len(prefix):
@@ -429,8 +515,33 @@ def strip_command_prefix(prefix):
429
515
  return ("ok", prefix[cursor:])
430
516
 
431
517
 
518
+ def shell_starts_command(tokens, index):
519
+ """Whether a recognized shell token occupies command position."""
520
+ start = index - 1
521
+ while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
522
+ start -= 1
523
+ status, remainder = strip_command_prefix(tokens[start + 1:index])
524
+ if status == "ambiguous":
525
+ # A prefix option this parser cannot classify may still be launching
526
+ # the shell. Fail closed rather than trusting the wrapper.
527
+ return True
528
+ return status == "ok" and not remainder
529
+
530
+
432
531
  def env_uses_split_string(tokens, index):
433
- """Whether a command-position env invocation reparses an opaque payload."""
532
+ """Whether a command-position env invocation uses split-string parsing.
533
+
534
+ `env -S` reparses one opaque argv value as shell-like words. Inspecting the
535
+ outer command cannot prove what executable or nested shell that second
536
+ parse will produce, so the verification guard refuses the ambiguous form.
537
+
538
+ Args:
539
+ tokens: The full operator-aware token list.
540
+ index: Index of the candidate env executable.
541
+
542
+ Returns:
543
+ True when env will reparse a split-string payload.
544
+ """
434
545
  start = index - 1
435
546
  while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
436
547
  start -= 1
@@ -444,9 +555,11 @@ def env_uses_split_string(tokens, index):
444
555
  if token in COMMAND_SEPARATORS or token == "--":
445
556
  return False
446
557
  kind = env_option_kind(token)
447
- if kind in {"split-string", "ambiguous"}:
448
- # An option this parser cannot name makes the rest of the
449
- # invocation unreadable; refuse instead of reading past it.
558
+ if kind == "split-string":
559
+ return True
560
+ if kind == "ambiguous":
561
+ # An option env accepts but this parser cannot name makes the rest
562
+ # of the invocation unreadable; refuse instead of reading past it.
450
563
  return True
451
564
  if kind is None:
452
565
  if "=" in token and not token.startswith(("=", "-")):
@@ -457,11 +570,55 @@ def env_uses_split_string(tokens, index):
457
570
  return False
458
571
 
459
572
 
460
- def git_commit_skips_verification(text):
573
+ def nested_shell_payload(tokens, index):
574
+ """Return a shell `-c` payload, or True when option parsing is ambiguous."""
575
+ cursor = index + 1
576
+ opaque_option_seen = False
577
+ while cursor < len(tokens):
578
+ option = tokens[cursor]
579
+ if option in COMMAND_SEPARATORS or option == "--":
580
+ return None
581
+ if not option.startswith(("-", "+")):
582
+ # Ordinarily the script operand, which ends option parsing. After
583
+ # a long option this parser cannot name it may instead be that
584
+ # option's value (`zsh --emulate ksh -c ...`), so keep scanning
585
+ # rather than concluding there is no command string.
586
+ if not opaque_option_seen:
587
+ return None
588
+ cursor += 1
589
+ continue
590
+ if option in SHELL_OPTIONS_SEPARATE_VALUE:
591
+ if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
592
+ return True
593
+ cursor += 2
594
+ continue
595
+ if option.startswith(SHELL_OPTIONS_INLINE_VALUE):
596
+ cursor += 1
597
+ continue
598
+ # A LONG option is never the command-string flag, and testing it for a
599
+ # bare `c` was the bug: `--norc` and `--restricted` merely CONTAIN one,
600
+ # so the real `-c` that followed was consumed as their payload and the
601
+ # nested `git commit --no-verify` was never classified at all. The
602
+ # command string lives only in a single-dash cluster (`-c`, `-lc`).
603
+ if option.startswith("--"):
604
+ if option not in SHELL_LONG_OPTIONS_NO_VALUE:
605
+ opaque_option_seen = True
606
+ cursor += 1
607
+ continue
608
+ if "c" in option[1:]:
609
+ if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
610
+ return True
611
+ return tokens[cursor + 1]
612
+ cursor += 1
613
+ return None
614
+
615
+
616
+ def git_commit_skips_verification(text, depth=0):
461
617
  """Whether the command runs `git commit` with the short `-n` bypass.
462
618
 
463
619
  Args:
464
620
  text: The command line, heredoc payloads already stripped.
621
+ depth: Number of recognized shell payloads already traversed.
465
622
 
466
623
  Returns:
467
624
  True if any `git commit` invocation on the line skips verification.
@@ -483,6 +640,21 @@ def git_commit_skips_verification(text):
483
640
  found = subcommand_after_git(scoped_tokens, index + 1)
484
641
  if found and found[0] == "commit" and commit_bypasses_verification(found[1]):
485
642
  return True
643
+ if depth >= MAX_SHELL_NESTING:
644
+ return True
645
+ for index, token in enumerate(scoped_tokens):
646
+ program = token.rsplit("/", 1)[-1]
647
+ if program not in SHELL_PROGRAMS or not shell_starts_command(
648
+ scoped_tokens, index
649
+ ):
650
+ continue
651
+ payload = nested_shell_payload(scoped_tokens, index)
652
+ if payload is True:
653
+ return True
654
+ if isinstance(payload, str) and git_commit_skips_verification(
655
+ payload, depth + 1
656
+ ):
657
+ return True
486
658
  return False
487
659
 
488
660
 
@@ -494,11 +666,19 @@ for i, token in enumerate(normalized_tokens):
494
666
  sys.exit(1)
495
667
  if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
496
668
  sys.exit(1)
497
- # Allowlist the destinations, do not denylist the disabling ones: hooks are
498
- # disabled just as completely by any directory that happens to contain none
499
- # (`-c core.hooksPath=/tmp/empty`), so the set that DISABLES hooks is
500
- # unbounded while the set that legitimately relocates them is tiny. Matched
501
- # case-insensitively because git config names are.
669
+ # Allowlist the destinations, do not denylist the disabling ones.
670
+ #
671
+ # This used to block only "" and /dev/null. But hooks are disabled just as
672
+ # completely by pointing hooksPath at any directory that happens to contain
673
+ # none — `-c core.hooksPath=/tmp/empty` bypassed every hook while reading as
674
+ # an ordinary path — so a denylist of "obviously disabling" values can
675
+ # always be stepped around by naming a third thing. The set of paths that
676
+ # DISABLE hooks is unbounded; the set that legitimately relocates them is
677
+ # tiny and known, so the allowlist is the only side that can be enumerated.
678
+ #
679
+ # Matched case-insensitively because git config variable names are:
680
+ # `CORE.HOOKSPATH=/x` and `core.hookspath=/x` are the same setting to git,
681
+ # so a case-sensitive check is bypassed by holding down shift.
502
682
  lowered = token.lower()
503
683
  if lowered.startswith("core.hookspath="):
504
684
  if not is_permitted_hooks_path(token.split("=", 1)[1]):
@@ -506,30 +686,55 @@ for i, token in enumerate(normalized_tokens):
506
686
  if lowered == "core.hookspath" and i + 1 < len(normalized_tokens):
507
687
  if not is_permitted_hooks_path(normalized_tokens[i + 1]):
508
688
  sys.exit(1)
509
- # `--config-env=core.hooksPath=SOMEVAR` reads the path out of the named env
510
- # var, so it is not in the command at all and cannot be allowlisted.
689
+ # `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
690
+ # the value out of the named environment variable. The path therefore is
691
+ # not in the command at all, so there is nothing to allowlist against —
692
+ # SOMEVAR can hold anything by the time git reads it. Any core.hooksPath
693
+ # routed through --config-env is refused outright.
511
694
  if lowered.startswith("--config-env="):
512
695
  spec = token.split("=", 1)[1]
513
696
  if spec.split("=", 1)[0].strip().lower() == "core.hookspath":
514
697
  sys.exit(1)
515
- # git also accepts `--config-env <name>=<envvar>` as two tokens. Guarding
516
- # only the `=` spelling let the trailing `core.hooksPath=.husky` fall
517
- # through to the allowlist above, which reads `.husky` as a path — but here
518
- # it names an ENVIRONMENT VARIABLE, which can hold /dev/null. Checked at the
519
- # `--config-env` token, which the loop reaches first.
698
+ # git accepts `--config-env <name>=<envvar>` as TWO tokens as well as one,
699
+ # and guarding only the `=` spelling was worse than missing the separate
700
+ # form outright: the trailing `core.hooksPath=.husky` then fell through to
701
+ # the allowlist above, which reads `.husky` as a PATH and permits it. But
702
+ # here it is an ENVIRONMENT VARIABLE NAME, and `env '.husky=/dev/null' git
703
+ # --config-env core.hooksPath=.husky` really does resolve hooksPath to
704
+ # /dev/null. The allowlist was being used as the bypass.
705
+ #
706
+ # Checked at the `--config-env` token, which the loop reaches first, so the
707
+ # refusal happens before the value token can be mistaken for a path.
520
708
  if lowered == "--config-env" and i + 1 < len(normalized_tokens):
521
709
  spec = normalized_tokens[i + 1]
522
710
  if spec.split("=", 1)[0].strip().strip("'\"").lower() == "core.hookspath":
523
711
  sys.exit(1)
524
712
  # `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath
525
- # GIT_CONFIG_VALUE_0=/dev/null git ...` sets the same command-scope config
526
- # via env-var-style assignments. The index is arbitrary below
527
- # GIT_CONFIG_COUNT, so it is matched as `\d+` rather than pinned to 0.
713
+ # GIT_CONFIG_VALUE_0=/dev/null git commit` sets command-scope config the
714
+ # same way `-c core.hooksPath=...` does — env-var-style assignments ahead of
715
+ # the invocation instead of a flag — so it disables every hook just as
716
+ # completely while matching none of the token shapes above. Upstream missed
717
+ # this until a downstream fork hardened its own copy against it, which is
718
+ # the one direction a guard must never be caught in.
719
+ #
720
+ # The index is matched as `\d+` rather than pinned to 0: git accepts any
721
+ # index below GIT_CONFIG_COUNT, so a single-index check is evaded by typing
722
+ # a 1. Refused outright, like --config-env=, because the path lives in a
723
+ # separate GIT_CONFIG_VALUE_<n> token that can be exported earlier,
724
+ # reordered, or left out entirely — there is nothing here to allowlist
725
+ # against.
528
726
  key_match = re.match(r"git_config_key_\d+=(.*)$", lowered, re.DOTALL)
529
727
  if key_match and key_match.group(1).strip().strip("'\"") == "core.hookspath":
530
728
  sys.exit(1)
729
+ # `git -c` propagates command-scope config through GIT_CONFIG_PARAMETERS.
730
+ # Parsing the value as Git's shell-quoted parameter list distinguishes the
731
+ # key from an unrelated value that merely contains the same text.
531
732
  if re.match(r"git_config_parameters\+?=", lowered):
532
733
  parameters = token.split("=", 1)[1]
734
+ # The guard sees the command before the shell expands assignments. A
735
+ # variable or command substitution can therefore hide hooksPath from
736
+ # this parser and reveal it only to Git. Refuse unresolved values; the
737
+ # guard cannot safely prove what configuration they will become.
533
738
  if "$" in parameters or "`" in parameters:
534
739
  sys.exit(1)
535
740
  try:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.28.2",
3
+ "version": "4.28.3",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.28.2",
3
+ "version": "4.28.3",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.28.2",
3
+ "version": "4.28.3",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.28.2",
3
+ "version": "4.28.3",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.28.2",
3
+ "version": "4.28.3",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "4.28.2",
3
+ "version": "4.28.3",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"