@codyswann/lisa 4.24.2 → 4.25.0

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 (79) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +218 -82
  2. package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +85 -12
  3. package/all/copy-overwrite/scripts/lisa-work-item.mjs +74 -15
  4. package/dist/cli/ui-config-write-persistence.d.ts +12 -2
  5. package/dist/cli/ui-config-write-persistence.d.ts.map +1 -1
  6. package/dist/cli/ui-config-write-persistence.js +7 -3
  7. package/dist/cli/ui-config-write-persistence.js.map +1 -1
  8. package/dist/cli/ui-config-write.d.ts +1 -1
  9. package/dist/cli/ui-config-write.d.ts.map +1 -1
  10. package/dist/cli/ui-config-write.js +62 -6
  11. package/dist/cli/ui-config-write.js.map +1 -1
  12. package/dist/codex/scripts/block-no-verify.sh +134 -37
  13. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  14. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  15. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  16. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  17. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  18. package/dist/core/upstream-evidence-manifest.js +7 -6
  19. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  20. package/dist/opencode/plugin-templates/block-no-verify.sh +218 -82
  21. package/package.json +3 -3
  22. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa/hooks/block-no-verify.agy.sh +134 -37
  25. package/plugins/lisa/hooks/block-no-verify.sh +218 -82
  26. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +134 -37
  27. package/plugins/lisa-agy/plugin.json +1 -1
  28. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  31. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-copilot/hooks/block-no-verify.sh +218 -82
  35. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-cursor/hooks/block-no-verify.sh +218 -82
  37. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo-agy/plugin.json +1 -1
  40. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  50. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  55. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  60. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails-agy/plugin.json +1 -1
  65. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  70. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  75. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/src/base/hooks/block-no-verify.agy.sh +134 -37
  78. package/plugins/src/base/hooks/block-no-verify.sh +218 -82
  79. package/ui/README.md +30 -2
@@ -12,7 +12,13 @@
12
12
  # 5. GIT_CONFIG_KEY_<n>=core.hooksPath env-var-style command-scope config;
13
13
  # 6. GIT_CONFIG_PARAMETERS carrying core.hooksPath command-scope config;
14
14
  # 7. the short `-n`, as a real argv token of a `git commit` — bare, or bundled
15
- # into a short-option cluster such as `-nm "msg"`.
15
+ # into a short-option cluster such as `-nm "msg"`;
16
+ # 8. a nested shell carrying any of the above as its `-c` command string,
17
+ # including behind long shell options such as `bash --norc -c`;
18
+ # 9. `env --split-string` in EVERY abbreviation GNU accepts (`--s` upward,
19
+ # with or without an attached `=value`);
20
+ # 10. those forms behind a wrapper that does not change what runs —
21
+ # `command`, `exec`, `builtin`, `nohup`, `setsid`, and nested `env`.
16
22
  #
17
23
  # The line below is what lets `lisa apply` tell a downstream copy of this guard
18
24
  # that is BEHIND from one that is AHEAD. Byte comparison cannot: both look like
@@ -23,7 +29,7 @@
23
29
  # Add a name here in the same commit that closes a vector. A hardening that
24
30
  # forgets to is invisible to refresh, and shows up as an unexplained diff at
25
31
  # review time instead of a named capability.
26
- # 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
32
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization
27
33
  #
28
34
  # Shell-token matching avoids false positives from issue bodies, heredocs, and
29
35
  # commit-message prose while still catching quoted real argv values such as
@@ -219,26 +225,61 @@ COMMAND_SEPARATORS = {
219
225
  SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
220
226
  MAX_SHELL_NESTING = 8
221
227
 
222
- # Prefix options accepted by `env` before the command name. Value-taking
223
- # options are separate because their operand must never be mistaken for the
224
- # command (`env -u bash -c ...` consumes `bash` as the variable name).
225
- ENV_OPTIONS_NO_VALUE = {
226
- "-i", "--ignore-environment", "-0", "--null", "-v", "--debug",
228
+ # GNU env's long options, listed in FULL so an abbreviation can be resolved
229
+ # against them. getopt_long accepts any prefix that names exactly one option,
230
+ # and no other env option begins with `s` — so `env --s`, `env --sp`, and
231
+ # `env --split` are `env --split-string` just as completely as the full
232
+ # spelling is. Matching only the full spelling is bypassed by deleting
233
+ # characters, which is why the set below exists instead of a literal list of
234
+ # the two spellings the guard used to know.
235
+ ENV_LONG_OPTIONS = {
236
+ "--argv0", "--block-signals", "--chdir", "--debug", "--default-signal",
237
+ "--help", "--ignore-environment", "--ignore-signal",
238
+ "--list-signal-handling", "--null", "--split-string", "--unset",
239
+ "--version",
227
240
  }
228
- ENV_OPTIONS_SEPARATE_VALUE = {
229
- "-u", "--unset", "-C", "--chdir", "-S", "--split-string",
230
- "-a", "--argv0",
231
- }
232
- ENV_OPTIONS_INLINE_VALUE = (
233
- "--unset=", "--chdir=", "--split-string=", "--argv0=",
234
- )
241
+ # Of those, the ones whose value is a SEPARATE token, so the operand is never
242
+ # mistaken for the command (`env -u bash -c ...` consumes `bash` as the
243
+ # variable name). The signal options take an OPTIONAL value, which GNU reads
244
+ # only when it is attached with `=`, so they never consume a following token.
245
+ ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
246
+
247
+ # env's single-letter options, by whether the rest of a cluster is more
248
+ # options or the option's value.
249
+ ENV_SHORT_NO_VALUE = frozenset("i0v")
250
+ ENV_SHORT_TAKES_VALUE = frozenset("uCaS")
251
+
252
+ # Builtins and wrappers that run whatever FOLLOWS them without changing what
253
+ # it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
254
+ # and `env -- env -S ...` runs exactly the inner `env`. Reading the wrapper as
255
+ # the command is how a payload hides from a guard that inspects only the first
256
+ # word of an invocation.
257
+ COMMAND_PREFIX_WRAPPERS = {"command", "exec", "builtin", "nohup", "setsid"}
258
+ # The only option of those that consumes a separate following token
259
+ # (`exec -a NAME env -S ...`).
260
+ COMMAND_PREFIX_SEPARATE_VALUE = {"-a"}
235
261
 
236
262
  # Shell options whose following token is an operand, not another option. The
237
263
  # generic recursive scanner must step over these before looking for `-c`.
238
264
  SHELL_OPTIONS_SEPARATE_VALUE = {
239
- "-o", "+o", "-O", "+O", "--rcfile", "--init-file",
265
+ "-o", "+o", "-O", "+O", "--rcfile", "--init-file", "--emulate",
266
+ }
267
+ SHELL_OPTIONS_INLINE_VALUE = ("--rcfile=", "--init-file=", "--emulate=")
268
+
269
+ # Boolean long options of the recognized shells. Listed only for PRECISION:
270
+ # a long option is never the command-string flag either way, but naming the
271
+ # ordinary ones keeps `bash --norc script.sh` from being treated as a possible
272
+ # value-taking option whose operand must be scanned past. zsh and ksh accept a
273
+ # long option per shell setting, so this set is deliberately not exhaustive —
274
+ # the scanner stays correct on the ones it does not name.
275
+ SHELL_LONG_OPTIONS_NO_VALUE = {
276
+ "--debugger", "--dump-po-strings", "--dump-strings", "--emacs",
277
+ "--globalrcs", "--help", "--interactive", "--login", "--monitor",
278
+ "--no-globalrcs", "--no-rcs", "--noediting", "--noprofile", "--norc",
279
+ "--norcs", "--posix", "--pretty-print", "--privileged", "--protected",
280
+ "--rcs", "--restricted", "--verbose", "--version", "--vi", "--wordexp",
281
+ "--xtrace",
240
282
  }
241
- SHELL_OPTIONS_INLINE_VALUE = ("--rcfile=", "--init-file=")
242
283
 
243
284
  # git's own options that take a SEPARATE value token, which therefore must be
244
285
  # skipped when looking for the subcommand: `git -c core.hooksPath=x commit` and
@@ -392,56 +433,143 @@ def subcommand_after_git(tokens, start):
392
433
  return None
393
434
 
394
435
 
395
- def shell_starts_command(tokens, index):
396
- """Whether a recognized shell token occupies command position."""
397
- start = index - 1
398
- while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
399
- start -= 1
400
- prefix = tokens[start + 1:index]
401
- if not prefix:
402
- return True
403
- if prefix[0].rsplit("/", 1)[-1] != "env":
404
- return all("=" in token and not token.startswith("=") for token in prefix)
436
+ def resolve_env_long_option(name):
437
+ """Resolve one GNU env long option, abbreviations included.
405
438
 
406
- cursor = 1
439
+ Args:
440
+ name: A token beginning with `--`, with any `=value` already removed.
441
+
442
+ Returns:
443
+ The full option spelling; "" when the abbreviation names more than one
444
+ option, which real env refuses; None when nothing matches at all.
445
+ """
446
+ if not name.startswith("--") or name == "--":
447
+ return None
448
+ if name in ENV_LONG_OPTIONS:
449
+ return name
450
+ matches = [option for option in ENV_LONG_OPTIONS if option.startswith(name)]
451
+ if len(matches) == 1:
452
+ return matches[0]
453
+ return "" if matches else None
454
+
455
+
456
+ def env_option_kind(token):
457
+ """Classify one token sitting in an `env` option position.
458
+
459
+ Returns:
460
+ "split-string" when env will reparse an argv value as shell words,
461
+ "separate-value" when the NEXT token is this option's operand,
462
+ "no-value" when the token stands alone, "ambiguous" when env accepts
463
+ the token but this parser cannot say which option it is, or None when
464
+ the token is not an option.
465
+ """
466
+ if not token.startswith("-") or token in {"-", "--"}:
467
+ return None
468
+ if token.startswith("--"):
469
+ name = token.split("=", 1)[0]
470
+ resolved = resolve_env_long_option(name)
471
+ if not resolved:
472
+ # Either unknown to this parser or an abbreviation naming several
473
+ # options. Neither can be reasoned about, so say so and let the
474
+ # caller fail closed rather than read past it.
475
+ return "ambiguous"
476
+ if resolved == "--split-string":
477
+ return "split-string"
478
+ if "=" in token:
479
+ return "no-value"
480
+ return (
481
+ "separate-value" if resolved in ENV_LONG_SEPARATE_VALUE else "no-value"
482
+ )
483
+ for position, letter in enumerate(token[1:], start=2):
484
+ if letter == "S":
485
+ return "split-string"
486
+ if letter in ENV_SHORT_TAKES_VALUE:
487
+ # The rest of this cluster is the option's value when there is
488
+ # one; otherwise the value is the following token.
489
+ return "no-value" if position <= len(token) - 1 else "separate-value"
490
+ if letter not in ENV_SHORT_NO_VALUE:
491
+ return "ambiguous"
492
+ return "no-value"
493
+
494
+
495
+ def strip_command_prefix(prefix):
496
+ """Drop the tokens that precede a command without changing what it is.
497
+
498
+ Variable assignments, the `command`/`exec`/`builtin` builtins, and nested
499
+ `env` wrappers all leave the following word in command position. Reading
500
+ any of them as the command itself is what let `command env -S ...` and
501
+ `env -- env -S ...` slip past the split-string check: the prefix failed
502
+ the "assignments only" test, so the wrapped `env` was never classified.
503
+
504
+ Args:
505
+ prefix: The tokens between the last command separator and the
506
+ candidate program.
507
+
508
+ Returns:
509
+ A pair (status, remainder). "ok" carries the tokens still standing
510
+ between the separator and the candidate — empty means the candidate is
511
+ in command position. "operand" means the candidate is a wrapper
512
+ option's value rather than a command. "ambiguous" means a wrapper
513
+ option could not be classified and the caller must fail closed.
514
+ """
515
+ cursor = 0
407
516
  while cursor < len(prefix):
408
517
  token = prefix[cursor]
409
- if token == "--":
410
- return cursor == len(prefix) - 1
411
- if "=" in token and not token.startswith("=") and not token.startswith("-"):
518
+ if "=" in token and not token.startswith(("=", "-")):
412
519
  cursor += 1
413
520
  continue
414
- if token in ENV_OPTIONS_NO_VALUE or token.startswith(ENV_OPTIONS_INLINE_VALUE):
521
+ program = token.rsplit("/", 1)[-1]
522
+ if program in COMMAND_PREFIX_WRAPPERS:
415
523
  cursor += 1
524
+ while cursor < len(prefix) and prefix[cursor].startswith("-"):
525
+ if prefix[cursor] == "--":
526
+ cursor += 1
527
+ break
528
+ if prefix[cursor] in COMMAND_PREFIX_SEPARATE_VALUE:
529
+ if cursor + 1 >= len(prefix):
530
+ return ("operand", [])
531
+ cursor += 2
532
+ continue
533
+ cursor += 1
416
534
  continue
417
- if token in ENV_OPTIONS_SEPARATE_VALUE:
418
- # If the option has no operand inside the prefix, the shell token
419
- # itself is that operand and therefore is not command position.
420
- if cursor + 1 >= len(prefix):
421
- return False
422
- cursor += 2
535
+ if program == "env":
536
+ cursor += 1
537
+ while cursor < len(prefix):
538
+ option = prefix[cursor]
539
+ if option == "--":
540
+ cursor += 1
541
+ break
542
+ kind = env_option_kind(option)
543
+ if kind is None:
544
+ break
545
+ if kind == "ambiguous":
546
+ return ("ambiguous", [])
547
+ if kind == "split-string":
548
+ # Everything after `-S` is env's own opaque payload, so the
549
+ # candidate is not a command at all.
550
+ return ("operand", [])
551
+ if kind == "separate-value":
552
+ if cursor + 1 >= len(prefix):
553
+ return ("operand", [])
554
+ cursor += 2
555
+ continue
556
+ cursor += 1
423
557
  continue
424
- if token.startswith("-"):
425
- # An unknown env option makes command-position parsing ambiguous.
426
- # Fail closed for a recognized shell rather than trusting it.
427
- return True
428
- return False
429
- return True
558
+ break
559
+ return ("ok", prefix[cursor:])
430
560
 
431
561
 
432
- def env_short_option_uses_split_string(token):
433
- """Whether one GNU env short-option cluster enables split-string."""
434
- if not token.startswith("-") or token.startswith("--") or token == "-":
435
- return False
436
- for letter in token[1:]:
437
- if letter == "S":
438
- return True
439
- if letter in {"u", "C", "a"}:
440
- # The rest of this token is the option's value, not more options.
441
- return False
442
- if letter not in {"i", "0", "v"}:
443
- return False
444
- return False
562
+ def shell_starts_command(tokens, index):
563
+ """Whether a recognized shell token occupies command position."""
564
+ start = index - 1
565
+ while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
566
+ start -= 1
567
+ status, remainder = strip_command_prefix(tokens[start + 1:index])
568
+ if status == "ambiguous":
569
+ # A prefix option this parser cannot classify may still be launching
570
+ # the shell. Fail closed rather than trusting the wrapper.
571
+ return True
572
+ return status == "ok" and not remainder
445
573
 
446
574
 
447
575
  def env_uses_split_string(tokens, index):
@@ -461,8 +589,8 @@ def env_uses_split_string(tokens, index):
461
589
  start = index - 1
462
590
  while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
463
591
  start -= 1
464
- prefix = tokens[start + 1:index]
465
- if not all("=" in token and not token.startswith("=") for token in prefix):
592
+ status, remainder = strip_command_prefix(tokens[start + 1:index])
593
+ if status != "ok" or remainder:
466
594
  return False
467
595
 
468
596
  cursor = index + 1
@@ -470,40 +598,39 @@ def env_uses_split_string(tokens, index):
470
598
  token = tokens[cursor]
471
599
  if token in COMMAND_SEPARATORS or token == "--":
472
600
  return False
473
- if (
474
- token in {"-S", "--split-string"}
475
- or token.startswith("--split-string=")
476
- or env_short_option_uses_split_string(token)
477
- ):
601
+ kind = env_option_kind(token)
602
+ if kind == "split-string":
478
603
  return True
479
- if "=" in token and not token.startswith(("=", "-")):
480
- cursor += 1
481
- continue
482
- if token in ENV_OPTIONS_NO_VALUE or token.startswith(
483
- tuple(
484
- option
485
- for option in ENV_OPTIONS_INLINE_VALUE
486
- if option != "--split-string="
487
- )
488
- ):
489
- cursor += 1
490
- continue
491
- if token in ENV_OPTIONS_SEPARATE_VALUE:
492
- cursor += 2
493
- continue
494
- return False
604
+ if kind == "ambiguous":
605
+ # An option env accepts but this parser cannot name makes the rest
606
+ # of the invocation unreadable; refuse instead of reading past it.
607
+ return True
608
+ if kind is None:
609
+ if "=" in token and not token.startswith(("=", "-")):
610
+ cursor += 1
611
+ continue
612
+ return False
613
+ cursor += 2 if kind == "separate-value" else 1
495
614
  return False
496
615
 
497
616
 
498
617
  def nested_shell_payload(tokens, index):
499
618
  """Return a shell `-c` payload, or True when option parsing is ambiguous."""
500
619
  cursor = index + 1
620
+ opaque_option_seen = False
501
621
  while cursor < len(tokens):
502
622
  option = tokens[cursor]
503
623
  if option in COMMAND_SEPARATORS or option == "--":
504
624
  return None
505
625
  if not option.startswith(("-", "+")):
506
- return None
626
+ # Ordinarily the script operand, which ends option parsing. After
627
+ # a long option this parser cannot name it may instead be that
628
+ # option's value (`zsh --emulate ksh -c ...`), so keep scanning
629
+ # rather than concluding there is no command string.
630
+ if not opaque_option_seen:
631
+ return None
632
+ cursor += 1
633
+ continue
507
634
  if option in SHELL_OPTIONS_SEPARATE_VALUE:
508
635
  if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
509
636
  return True
@@ -512,8 +639,17 @@ def nested_shell_payload(tokens, index):
512
639
  if option.startswith(SHELL_OPTIONS_INLINE_VALUE):
513
640
  cursor += 1
514
641
  continue
515
- body = option[1:]
516
- if "c" in body:
642
+ # A LONG option is never the command-string flag, and testing it for a
643
+ # bare `c` was the bug: `--norc` and `--restricted` merely CONTAIN one,
644
+ # so the real `-c` that followed was consumed as their payload and the
645
+ # nested `git commit --no-verify` was never classified at all. The
646
+ # command string lives only in a single-dash cluster (`-c`, `-lc`).
647
+ if option.startswith("--"):
648
+ if option not in SHELL_LONG_OPTIONS_NO_VALUE:
649
+ opaque_option_seen = True
650
+ cursor += 1
651
+ continue
652
+ if "c" in option[1:]:
517
653
  if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
518
654
  return True
519
655
  return tokens[cursor + 1]
@@ -161,13 +161,26 @@ COMMAND_SEPARATORS = {
161
161
  ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
162
162
  }
163
163
 
164
- ENV_OPTIONS_NO_VALUE = {
165
- "-i", "--ignore-environment", "-0", "--null", "-v", "--debug",
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.
168
+ ENV_LONG_OPTIONS = {
169
+ "--argv0", "--block-signals", "--chdir", "--debug", "--default-signal",
170
+ "--help", "--ignore-environment", "--ignore-signal",
171
+ "--list-signal-handling", "--null", "--split-string", "--unset",
172
+ "--version",
166
173
  }
167
- ENV_OPTIONS_SEPARATE_VALUE = {
168
- "-u", "--unset", "-C", "--chdir", "-a", "--argv0",
169
- }
170
- ENV_OPTIONS_INLINE_VALUE = ("--unset=", "--chdir=", "--argv0=")
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 `=`.
176
+ ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
177
+ ENV_SHORT_NO_VALUE = frozenset("i0v")
178
+ ENV_SHORT_TAKES_VALUE = frozenset("uCaS")
179
+
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.
182
+ COMMAND_PREFIX_WRAPPERS = {"command", "exec", "builtin", "nohup", "setsid"}
183
+ COMMAND_PREFIX_SEPARATE_VALUE = {"-a"}
171
184
 
172
185
  # git's own options taking a SEPARATE value token, skipped when looking for the
173
186
  # subcommand so `git -c core.hooksPath=x commit` still reaches `commit`.
@@ -310,19 +323,110 @@ def subcommand_after_git(tokens, start):
310
323
  return None
311
324
 
312
325
 
313
- def env_short_option_uses_split_string(token):
314
- """Whether one GNU env short-option cluster enables split-string."""
315
- if not token.startswith("-") or token.startswith("--") or token == "-":
316
- return False
317
- for letter in token[1:]:
326
+ def resolve_env_long_option(name):
327
+ """Resolve one env long option, abbreviations included.
328
+
329
+ Returns the full spelling, "" when the abbreviation names several options
330
+ (real env refuses those), or None when nothing matches.
331
+ """
332
+ if not name.startswith("--") or name == "--":
333
+ return None
334
+ if name in ENV_LONG_OPTIONS:
335
+ return name
336
+ matches = [option for option in ENV_LONG_OPTIONS if option.startswith(name)]
337
+ if len(matches) == 1:
338
+ return matches[0]
339
+ return "" if matches else None
340
+
341
+
342
+ def env_option_kind(token):
343
+ """Classify one token in an `env` option position.
344
+
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.
348
+ """
349
+ if not token.startswith("-") or token in {"-", "--"}:
350
+ return None
351
+ if token.startswith("--"):
352
+ resolved = resolve_env_long_option(token.split("=", 1)[0])
353
+ if not resolved:
354
+ return "ambiguous"
355
+ if resolved == "--split-string":
356
+ return "split-string"
357
+ if "=" in token:
358
+ return "no-value"
359
+ return (
360
+ "separate-value" if resolved in ENV_LONG_SEPARATE_VALUE else "no-value"
361
+ )
362
+ for position, letter in enumerate(token[1:], start=2):
318
363
  if letter == "S":
319
- return True
320
- if letter in {"u", "C", "a"}:
321
- # The rest of this token is the option's value, not more options.
322
- return False
323
- if letter not in {"i", "0", "v"}:
324
- return False
325
- return False
364
+ return "split-string"
365
+ 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.
368
+ return "no-value" if position <= len(token) - 1 else "separate-value"
369
+ if letter not in ENV_SHORT_NO_VALUE:
370
+ return "ambiguous"
371
+ return "no-value"
372
+
373
+
374
+ def strip_command_prefix(prefix):
375
+ """Drop tokens that precede a command without changing what it is.
376
+
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.
382
+
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.
386
+ """
387
+ cursor = 0
388
+ while cursor < len(prefix):
389
+ token = prefix[cursor]
390
+ if "=" in token and not token.startswith(("=", "-")):
391
+ cursor += 1
392
+ continue
393
+ program = token.rsplit("/", 1)[-1]
394
+ if program in COMMAND_PREFIX_WRAPPERS:
395
+ cursor += 1
396
+ while cursor < len(prefix) and prefix[cursor].startswith("-"):
397
+ if prefix[cursor] == "--":
398
+ cursor += 1
399
+ break
400
+ if prefix[cursor] in COMMAND_PREFIX_SEPARATE_VALUE:
401
+ if cursor + 1 >= len(prefix):
402
+ return ("operand", [])
403
+ cursor += 2
404
+ continue
405
+ cursor += 1
406
+ continue
407
+ if program == "env":
408
+ cursor += 1
409
+ while cursor < len(prefix):
410
+ option = prefix[cursor]
411
+ if option == "--":
412
+ cursor += 1
413
+ break
414
+ kind = env_option_kind(option)
415
+ if kind is None:
416
+ break
417
+ if kind == "ambiguous":
418
+ return ("ambiguous", [])
419
+ if kind == "split-string":
420
+ return ("operand", [])
421
+ if kind == "separate-value":
422
+ if cursor + 1 >= len(prefix):
423
+ return ("operand", [])
424
+ cursor += 2
425
+ continue
426
+ cursor += 1
427
+ continue
428
+ break
429
+ return ("ok", prefix[cursor:])
326
430
 
327
431
 
328
432
  def env_uses_split_string(tokens, index):
@@ -330,8 +434,8 @@ def env_uses_split_string(tokens, index):
330
434
  start = index - 1
331
435
  while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
332
436
  start -= 1
333
- prefix = tokens[start + 1:index]
334
- if not all("=" in token and not token.startswith("=") for token in prefix):
437
+ status, remainder = strip_command_prefix(tokens[start + 1:index])
438
+ if status != "ok" or remainder:
335
439
  return False
336
440
 
337
441
  cursor = index + 1
@@ -339,24 +443,17 @@ def env_uses_split_string(tokens, index):
339
443
  token = tokens[cursor]
340
444
  if token in COMMAND_SEPARATORS or token == "--":
341
445
  return False
342
- if (
343
- token in {"-S", "--split-string"}
344
- or token.startswith("--split-string=")
345
- or env_short_option_uses_split_string(token)
346
- ):
446
+ 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.
347
450
  return True
348
- if "=" in token and not token.startswith(("=", "-")):
349
- cursor += 1
350
- continue
351
- if token in ENV_OPTIONS_NO_VALUE or token.startswith(
352
- ENV_OPTIONS_INLINE_VALUE
353
- ):
354
- cursor += 1
355
- continue
356
- if token in ENV_OPTIONS_SEPARATE_VALUE:
357
- cursor += 2
358
- continue
359
- return False
451
+ if kind is None:
452
+ if "=" in token and not token.startswith(("=", "-")):
453
+ cursor += 1
454
+ continue
455
+ return False
456
+ cursor += 2 if kind == "separate-value" else 1
360
457
  return False
361
458
 
362
459
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.24.2",
3
+ "version": "4.25.0",
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.24.2",
3
+ "version": "4.25.0",
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.24.2",
3
+ "version": "4.25.0",
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.24.2",
3
+ "version": "4.25.0",
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.24.2",
3
+ "version": "4.25.0",
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.24.2",
3
+ "version": "4.25.0",
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",
3
- "version": "4.24.2",
3
+ "version": "4.25.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"