@codyswann/lisa 4.34.4 → 4.34.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +40 -1
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +436 -9
  3. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +636 -101
  4. package/all/copy-overwrite/scripts/lisa-test-node.mjs +44 -1
  5. package/dist/codex/scripts/block-no-verify.sh +608 -99
  6. package/dist/configs/vitest/scratch-global-setup.d.ts +29 -0
  7. package/dist/configs/vitest/scratch-global-setup.d.ts.map +1 -1
  8. package/dist/configs/vitest/scratch-global-setup.js +77 -12
  9. package/dist/configs/vitest/scratch-global-setup.js.map +1 -1
  10. package/dist/core/config-shadowing.d.ts +72 -0
  11. package/dist/core/config-shadowing.d.ts.map +1 -0
  12. package/dist/core/config-shadowing.js +152 -0
  13. package/dist/core/config-shadowing.js.map +1 -0
  14. package/dist/core/config.d.ts +16 -1
  15. package/dist/core/config.d.ts.map +1 -1
  16. package/dist/core/config.js +1 -0
  17. package/dist/core/config.js.map +1 -1
  18. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  19. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  20. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  21. package/dist/core/lisa.d.ts +21 -0
  22. package/dist/core/lisa.d.ts.map +1 -1
  23. package/dist/core/lisa.js +46 -0
  24. package/dist/core/lisa.js.map +1 -1
  25. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  26. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  27. package/dist/core/upstream-evidence-manifest.js +19 -11
  28. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  29. package/dist/opencode/plugin-templates/block-no-verify.sh +636 -101
  30. package/dist/strategies/copy-overwrite.d.ts +42 -0
  31. package/dist/strategies/copy-overwrite.d.ts.map +1 -1
  32. package/dist/strategies/copy-overwrite.js +69 -5
  33. package/dist/strategies/copy-overwrite.js.map +1 -1
  34. package/expo/copy-overwrite/scripts/check-lighthouse-details.mjs +138 -11
  35. package/expo/create-only/.github/workflows/deploy.yml +51 -4
  36. package/package.json +4 -4
  37. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa/hooks/block-managed-file-edits.sh +436 -9
  40. package/plugins/lisa/hooks/block-no-verify.agy.sh +608 -99
  41. package/plugins/lisa/hooks/block-no-verify.sh +636 -101
  42. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +608 -99
  43. package/plugins/lisa-agy/plugin.json +1 -1
  44. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  47. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +436 -9
  51. package/plugins/lisa-copilot/hooks/block-no-verify.sh +636 -101
  52. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +436 -9
  54. package/plugins/lisa-cursor/hooks/block-no-verify.sh +636 -101
  55. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-expo-agy/plugin.json +1 -1
  58. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  63. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  68. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  73. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  78. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-rails-agy/plugin.json +1 -1
  83. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  88. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  93. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/src/base/hooks/block-managed-file-edits.sh +436 -9
  96. package/plugins/src/base/hooks/block-no-verify.agy.sh +608 -99
  97. package/plugins/src/base/hooks/block-no-verify.sh +636 -101
  98. package/rails/create-only/.github/workflows/deploy.yml +45 -1
  99. package/scripts/two-channel-couplings.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.34.4",
3
+ "version": "4.34.6",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -53,8 +53,30 @@
53
53
  # Blocked signatures:
54
54
  # 1. Write / Edit / MultiEdit / NotebookEdit whose target resolves to a
55
55
  # copy-overwrite template in the installed Lisa package;
56
- # 2. Bash output redirection (`>`, `>>`, `>|`), `tee`, or `sed -i` aimed at
57
- # one. Reads never fire.
56
+ # 2. Bash output redirection (`>`, `>>`, `>|`), `tee`, or an in-place `sed`
57
+ # aimed at one — `-i`, `-i ''`, `-i.bak`, a cluster such as `-ni`, and the
58
+ # long `--in-place[=SUFFIX]`. Reads never fire;
59
+ # 3. any of those inside a script the command EXECUTES — `bash edit.sh`,
60
+ # `sh`, `source`, `.`, `bash < edit.sh`, and behind wrappers that carry an
61
+ # operand of their own (`nice -n 5`, `timeout 5`, `sudo`).
62
+ #
63
+ # ## What signature 3 deliberately does NOT do
64
+ #
65
+ # A path is followed ONLY when the command EXECUTES it. `grep -n x edit.sh`,
66
+ # `wc -l edit.sh`, `git diff edit.sh` and a test run over it name a file as
67
+ # DATA, and following those is the known-wrong fix this guard family has already
68
+ # made once: it refuses ordinary reads and attributes a file's capability to a
69
+ # command that merely mentions it. Command POSITION decides. Taint does not
70
+ # propagate any further either — a path a followed script merely NAMES is data
71
+ # one file further out, not a third hop.
72
+ #
73
+ # ## Parity gap, recorded rather than silently dropped
74
+ #
75
+ # This guard has no Antigravity, Codex or OpenCode port — only the Claude
76
+ # reference and the copies generated from it. Its siblings do. That predates
77
+ # this change and is not closed by it: opening three ports is its own work, and
78
+ # doing it here would triple the surface under review. Recorded so it reads as a
79
+ # known gap rather than an oversight.
58
80
  #
59
81
  # Exemptions (allowed):
60
82
  # - `LISA_ALLOW_MANAGED_FILE_WRITE` set — the operator's explicit override,
@@ -62,6 +84,13 @@
62
84
  # - paths under `node_modules/` or `dist/` — vendored copies, not the host's;
63
85
  # - the Lisa source repository itself, where these files are the originals and
64
86
  # editing them is the entire point.
87
+ #
88
+ # The line below lets `lisa apply` tell a downstream copy of this guard that is
89
+ # BEHIND from one that is AHEAD. Byte comparison cannot: both look like "differs
90
+ # from mine", and guessing "behind" is how a fork's stronger guard gets silently
91
+ # replaced by a weaker upstream one. Add a name here in the same commit that
92
+ # closes a vector.
93
+ # lisa-guard-capabilities: managed-path-resolution, lisaignore-precedence, generated-path-rebuild, redirect-target, tee-target, sed-in-place-all-spellings, executed-script-reach, source-builtin-reach, stdin-redirect-reach, wrapper-positional-operand
65
94
  set -euo pipefail
66
95
 
67
96
  input="$(cat)"
@@ -363,19 +392,417 @@ EOF
363
392
  Bash)
364
393
  command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"
365
394
  [ -n "$command_str" ] || exit 0
366
- # Write signatures only. A bare mention is a read and stays allowed — the
367
- # path has to be the target of a redirection, a tee, or an in-place sed.
368
- # `>>?\|?` covers `>`, `>>`, and the noclobber override `>|`.
395
+ # A missing interpreter must be announced rather than swallowed. This guard
396
+ # degrades to "allow" — a hook that cannot parse its input cannot tell a
397
+ # write from a read — but a guard that is silently absent reads exactly like
398
+ # a guard that is passing.
399
+ if ! command -v python3 >/dev/null 2>&1; then
400
+ printf 'block-managed-file-edits: python3 not found; Bash write protection is NOT active\n' >&2
401
+ exit 0
402
+ fi
403
+ # Why a tokenizer replaced the two-stage regex.
404
+ #
405
+ # The old extraction was `grep -Eo` for a redirect / tee / in-place sed,
406
+ # followed by a `sed -E` strip of the matched prefix. MEASURED against it:
407
+ # `tee <path>` and `tee -a <path>` extracted correctly, and EVERY `sed -i`
408
+ # spelling extracted NOTHING — `-i`, `-i ''` and `-i.bak` alike. The
409
+ # `grep` matched; the strip pattern `sed[[:space:]]+[^|;&]*-i[^|;&]*` is
410
+ # greedy, so it consumed the path along with the flags and left an empty
411
+ # token. The arm was DEAD, not merely missing macOS's two-token spelling.
412
+ #
413
+ # A regex cannot answer the question this guard actually asks — is this path
414
+ # WRITTEN or merely NAMED — because that depends on token position, which is
415
+ # what a tokenizer is for.
416
+ #
417
+ # The analyzer is read into a variable by `read -d ''` rather than run
418
+ # inside a `$(python3 - <<'PY' … PY)`. Bash has to find the closing paren of
419
+ # a command substitution by re-scanning its text, and a heredoc body nested
420
+ # there is scanned too: the lone backtick in `re.compile(r"[$`]")` below
421
+ # reads as an unterminated backtick substitution and the whole script fails
422
+ # to parse. Keeping the heredoc out of the substitution is the fix.
423
+ analyzer=''
424
+ IFS= read -r -d '' analyzer <<'PY' || true
425
+ import os
426
+ import re
427
+ import shlex
428
+
429
+ command = os.environ.get("MANAGED_EDIT_COMMAND", "")
430
+ project = os.environ.get("MANAGED_EDIT_PROJECT", "") or os.getcwd()
431
+
432
+ # Following a script costs a read on every intercepted command, so the walk is
433
+ # bounded three ways, and a file past the cap is skipped rather than half-read:
434
+ # a truncated scan reports a confident ALLOW about text it never saw.
435
+ FOLLOW_MAX_BYTES = 262144
436
+ FOLLOW_MAX_FILES = 8
437
+ FOLLOW_MAX_DEPTH = 3
438
+
439
+ # Spelled with chr() rather than backslash escapes on purpose. This source is
440
+ # embedded in a shell heredoc and copied verbatim through several generators
441
+ # into every shipped plugin; a backslash literal is the one thing in it most
442
+ # likely to be mangled in transit, and the mangling would be silent.
443
+ BACKSLASH = chr(92)
444
+ NEWLINE = chr(10)
445
+
446
+ STATEMENT_SEPARATORS = {";", "|", "||", "&", "&&", "(", ")", "&|"}
447
+ # `>`, `>>`, and the noclobber override `>|`.
448
+ REDIRECT_OUT = {">", ">>", ">|"}
449
+ REDIRECT_IN = "<"
450
+
451
+ SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
452
+ SOURCE_BUILTINS = {"source", "."}
453
+
454
+ # Wrappers that run what FOLLOWS them without changing what it is, mapped to
455
+ # (options whose value is a SEPARATE token, positional operands consumed).
456
+ # The positional count is why this is a table rather than a set: `timeout 5
457
+ # bash x` puts an operand between the wrapper and the interpreter, so a walk
458
+ # that steps over `-flags` only stops at `5` and never reaches `bash`.
459
+ FOLLOW_WRAPPERS = {
460
+ "builtin": (frozenset(), 0),
461
+ "command": (frozenset(), 0),
462
+ "exec": (frozenset({"-a"}), 0),
463
+ "nice": (frozenset({"-n", "--adjustment"}), 0),
464
+ "nohup": (frozenset(), 0),
465
+ "setsid": (frozenset(), 0),
466
+ "stdbuf": (frozenset({"-i", "-o", "-e"}), 0),
467
+ "sudo": (
468
+ frozenset(
469
+ {
470
+ "-C", "--close-from", "-g", "--group", "-h", "--host",
471
+ "-p", "--prompt", "-r", "--role", "-t", "--type",
472
+ "-U", "--other-user", "-u", "--user",
473
+ }
474
+ ),
475
+ 0,
476
+ ),
477
+ "time": (frozenset(), 0),
478
+ "timeout": (frozenset({"-k", "--kill-after", "-s", "--signal"}), 1),
479
+ }
480
+
481
+ # `-i`, `-i.bak`, a cluster such as `-ni`, and the long `--in-place[=SUFFIX]`.
482
+ IN_PLACE = re.compile(r"^(--in-place(=.*)?|-[a-zA-Z]*i.*)$")
483
+ # A value the SHELL computes; this hook runs before expansion and cannot know
484
+ # which file it will name.
485
+ COMPUTED_VALUE = re.compile(r"[$`]")
486
+
487
+
488
+ def normalise_lines(text):
489
+ """Spell line breaks as statement separators.
490
+
491
+ A newline ends a command exactly as `;` does, but shlex treats it as plain
492
+ whitespace — so two consecutive lines of a followed script would read as
493
+ one invocation and only the first program would be classified. A
494
+ backslash-newline is joined first, because there the newline is NOT a
495
+ boundary.
496
+
497
+ Args:
498
+ text: A command line or a script's contents.
499
+
500
+ Returns:
501
+ The same text with line breaks spelled as separators.
502
+ """
503
+ joined = text.replace(BACKSLASH + NEWLINE, " ")
504
+ return joined.replace(NEWLINE, " ; ")
505
+
506
+
507
+ def tokenize(text):
508
+ """Tokenize with shell operators kept as their own tokens.
509
+
510
+ Args:
511
+ text: A command line or a script's contents.
512
+
513
+ Returns:
514
+ The token list, or None when the text does not lex.
515
+ """
516
+ lexer = shlex.shlex(normalise_lines(text), posix=True, punctuation_chars=True)
517
+ lexer.whitespace_split = True
518
+ # `#` is not a comment introducer on a typed command line, and letting shlex
519
+ # treat it as one silently truncates the rest of the line.
520
+ lexer.commenters = ""
521
+ try:
522
+ return list(lexer)
523
+ except ValueError:
524
+ return None
525
+
526
+
527
+ def strip_comments(text):
528
+ """Drop `#` comments from a FOLLOWED SCRIPT, quote-aware.
529
+
530
+ Applied to file contents and never to a typed command. A comment cannot
531
+ execute, and Lisa's own managed templates discuss redirection and `sed -i`
532
+ against managed paths in their headers — classifying a followed file
533
+ without dropping its comments reads documentation as an edit.
534
+
535
+ Args:
536
+ text: A script's contents.
537
+
538
+ Returns:
539
+ The same text with unquoted comments removed.
540
+ """
541
+ output = []
542
+ for line in text.split(NEWLINE):
543
+ quote = ""
544
+ escaped = False
545
+ cut = None
546
+ for position, character in enumerate(line):
547
+ if escaped:
548
+ escaped = False
549
+ continue
550
+ if character == BACKSLASH and quote != "'":
551
+ escaped = True
552
+ continue
553
+ if quote:
554
+ if character == quote:
555
+ quote = ""
556
+ continue
557
+ if character in "'\"":
558
+ quote = character
559
+ continue
560
+ # A `#` opens a comment only at the start of a word, so `x#y` and
561
+ # `${x#y}` keep their hashes.
562
+ if character == "#" and (position == 0 or line[position - 1].isspace()):
563
+ cut = position
564
+ break
565
+ output.append(line if cut is None else line[:cut])
566
+ return NEWLINE.join(output)
567
+
568
+
569
+ def statements(tokens):
570
+ """Split a token list at statement separators, keeping redirects inline.
571
+
572
+ Redirect operators are deliberately NOT separators here: `>` and its target
573
+ have to stay in the same group, because the pair is the write signature.
574
+
575
+ Args:
576
+ tokens: The operator-aware token list.
577
+
578
+ Returns:
579
+ A list of statements.
580
+ """
581
+ grouped = []
582
+ current = []
583
+ for token in tokens:
584
+ if token in STATEMENT_SEPARATORS:
585
+ grouped.append(current)
586
+ current = []
587
+ continue
588
+ current.append(token)
589
+ grouped.append(current)
590
+ return grouped
591
+
592
+
593
+ def command_word(statement):
594
+ """The program a statement runs, and the tokens after it.
595
+
596
+ Steps over leading variable assignments and over wrappers that do not
597
+ change what runs. Reading a wrapper as the command is how a payload hides
598
+ from a guard that inspects only a statement's first word.
599
+
600
+ Args:
601
+ statement: One statement's tokens.
602
+
603
+ Returns:
604
+ A pair (program, args); (None, []) when no program is named.
605
+ """
606
+ index = 0
607
+ while index < len(statement):
608
+ token = statement[index]
609
+ if "=" in token and not token.startswith(("=", "-")):
610
+ index += 1
611
+ continue
612
+ program = token.rsplit("/", 1)[-1]
613
+ if program not in FOLLOW_WRAPPERS:
614
+ return (program, statement[index + 1 :])
615
+ separate, positional = FOLLOW_WRAPPERS[program]
616
+ index += 1
617
+ while index < len(statement) and statement[index].startswith("-"):
618
+ option = statement[index]
619
+ if option == "--":
620
+ index += 1
621
+ break
622
+ index += 2 if option in separate else 1
623
+ index += positional
624
+ return (None, [])
625
+
626
+
627
+ def shell_script_operand(args):
628
+ """The file a shell invocation runs, or None when it runs no script file.
629
+
630
+ Args:
631
+ args: The tokens following the shell's own name.
632
+
633
+ Returns:
634
+ The operand token, or None for an interactive shell or a `-c` string.
635
+ """
636
+ for token in args:
637
+ if token == "--":
638
+ continue
639
+ if token.startswith(("-", "+")):
640
+ # A single-dash cluster containing `c` carries a command string,
641
+ # not a script file.
642
+ if not token.startswith("--") and "c" in token[1:]:
643
+ return None
644
+ continue
645
+ return token
646
+ return None
647
+
648
+
649
+ def write_targets(statement):
650
+ """Paths this statement WRITES.
651
+
652
+ Reads never fire. A path has to be the target of an output redirection, or
653
+ an operand of `tee` or of an in-place `sed`. That is the whole distinction
654
+ this guard turns on: `grep -n x <managed>` names the same path and stays
655
+ allowed, and following a merely-named path is the known-wrong fix that
656
+ refuses ordinary reads elsewhere in this family.
657
+
658
+ Extraction is deliberately GENEROUS for `tee` and `sed`: every non-flag
659
+ operand is offered. It costs nothing, because the caller classifies each
660
+ candidate by resolving it against the installed package, and a `sed` script
661
+ expression such as `s/a/b/` resolves to no template. Being narrow here is
662
+ precisely what left the old `sed -i` arm dead in every spelling.
663
+
664
+ Args:
665
+ statement: One statement's tokens.
666
+
667
+ Returns:
668
+ Candidate paths this statement writes.
669
+ """
670
+ found = []
671
+ program, args = command_word(statement)
672
+ for index, token in enumerate(statement):
673
+ if token in REDIRECT_OUT and index + 1 < len(statement):
674
+ found.append(statement[index + 1])
675
+ if program == "tee":
676
+ found.extend(token for token in args if not token.startswith("-"))
677
+ elif program == "sed" and any(IN_PLACE.match(token) for token in args):
678
+ found.extend(token for token in args if not token.startswith("-"))
679
+ return found
680
+
681
+
682
+ def executed_script(statement):
683
+ """The path this statement EXECUTES, or None.
684
+
685
+ Only a COMMAND POSITION can execute something. A path anywhere else is an
686
+ argument, and an argument is data.
687
+
688
+ Args:
689
+ statement: One statement's tokens.
690
+
691
+ Returns:
692
+ The operand token naming a script that will run, or None.
693
+ """
694
+ program, args = command_word(statement)
695
+ if program is None:
696
+ return None
697
+ if program in SHELL_PROGRAMS:
698
+ for index, token in enumerate(statement):
699
+ # `bash < script.sh` runs that file as surely as `bash script.sh`.
700
+ if token == REDIRECT_IN and index + 1 < len(statement):
701
+ return statement[index + 1]
702
+ return shell_script_operand(args)
703
+ if program in SOURCE_BUILTINS:
704
+ return args[0] if args else None
705
+ return None
706
+
707
+
708
+ def resolve(token):
709
+ """An existing readable script a token names, or None.
710
+
711
+ A computed target (`bash "$SCRIPT"`) is NOT followed and NOT refused. This
712
+ guard's harm is a template that silently forks — recoverable, and reported
713
+ by `lisa doctor` on every run — so failing closed on every `bash "$SCRIPT"`
714
+ in every host project would cost far more than it buys. Its sibling
715
+ `block-no-verify` DOES fail closed on the identical shape, because an
716
+ unverified commit is not recoverable. The divergence is deliberate, and it
717
+ is recorded here so the next reader does not read it as an oversight.
718
+
719
+ Args:
720
+ token: The operand naming a script.
721
+
722
+ Returns:
723
+ A path, or None.
724
+ """
725
+ if COMPUTED_VALUE.search(token):
726
+ return None
727
+ text = token.strip().strip("'\"")
728
+ if not text or text == "-":
729
+ return None
730
+ if os.path.isabs(text):
731
+ candidates = [text]
732
+ else:
733
+ candidates = [os.path.join(project, text), os.path.join(os.getcwd(), text)]
734
+ for candidate in candidates:
735
+ try:
736
+ if not os.path.isfile(candidate):
737
+ continue
738
+ if os.path.getsize(candidate) > FOLLOW_MAX_BYTES:
739
+ continue
740
+ except OSError:
741
+ continue
742
+ return candidate
743
+ return None
744
+
745
+
746
+ def collect(text, depth, seen, out):
747
+ """Accumulate every write target in this text and in the scripts it runs.
748
+
749
+ Args:
750
+ text: A command line, or a followed script's contents.
751
+ depth: Number of scripts already followed.
752
+ seen: Real paths already inspected, so a cycle terminates.
753
+ out: Accumulator for candidate paths.
754
+
755
+ Returns:
756
+ Nothing; `out` is extended in place.
757
+ """
758
+ tokens = tokenize(text)
759
+ if tokens is None:
760
+ return
761
+ for statement in statements(tokens):
762
+ if not statement:
763
+ continue
764
+ out.extend(write_targets(statement))
765
+ operand = executed_script(statement)
766
+ if operand is None:
767
+ continue
768
+ if depth >= FOLLOW_MAX_DEPTH or len(seen) >= FOLLOW_MAX_FILES:
769
+ continue
770
+ path = resolve(operand)
771
+ if path is None:
772
+ continue
773
+ try:
774
+ key = os.path.realpath(path)
775
+ except OSError:
776
+ key = path
777
+ if key in seen:
778
+ continue
779
+ seen.add(key)
780
+ try:
781
+ with open(path, encoding="utf-8", errors="replace") as handle:
782
+ body = handle.read(FOLLOW_MAX_BYTES)
783
+ except OSError:
784
+ continue
785
+ collect(strip_comments(body), depth + 1, seen, out)
786
+
787
+
788
+ targets = []
789
+ collect(command, 0, set(), targets)
790
+ for target in targets:
791
+ cleaned = target.strip().strip("'\"")
792
+ if cleaned:
793
+ print(cleaned)
794
+ PY
795
+ write_targets="$(printf '%s' "$analyzer" |
796
+ MANAGED_EDIT_COMMAND="$command_str" \
797
+ MANAGED_EDIT_PROJECT="$project_root" \
798
+ python3 - 2>/dev/null || true)"
369
799
  while IFS= read -r token; do
370
800
  [ -n "$token" ] || continue
371
801
  if source_path="$(managed_source "$token")"; then
372
802
  refuse "$token" "$source_path" "$(relative_path "$token")"
373
803
  fi
374
804
  done <<EOF
375
- $(printf '%s' "$command_str" |
376
- grep -Eo ">>?\|?[[:space:]]*['\"]?[^ |;&'\"]+|tee([[:space:]]+-[a-zA-Z]+)*[[:space:]]+['\"]?[^ |;&'\"]+|sed[[:space:]]+[^|;&]*-i[^|;&]*[[:space:]]['\"]?[^ |;&'\"]+" 2>/dev/null |
377
- sed -E "s/^(>>?\|?|tee([[:space:]]+-[a-zA-Z]+)*|sed[[:space:]]+[^|;&]*-i[^|;&]*)[[:space:]]*//" |
378
- tr -d "\"'" || true)
805
+ $write_targets
379
806
  EOF
380
807
  ;;
381
808
  esac