@codyswann/lisa 4.10.13 → 4.10.15

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 (87) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +4 -1
  2. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +233 -60
  3. package/dist/codex/scripts/block-no-verify.sh +4 -1
  4. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  5. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  6. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +12 -9
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/dist/opencode/plugin-templates/block-no-verify.sh +4 -1
  11. package/dist/opencode/plugin-templates/parity-safety-net.sh +233 -60
  12. package/package.json +1 -1
  13. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/hooks/block-no-verify.agy.sh +4 -1
  16. package/plugins/lisa/hooks/block-no-verify.sh +4 -1
  17. package/plugins/lisa/hooks/parity-safety-net.sh +233 -60
  18. package/plugins/lisa/rules/eager/falsifiable-checks.md +1 -0
  19. package/plugins/lisa/rules/reference/base-rules.md +1 -1
  20. package/plugins/lisa/rules/reference/falsifiable-checks.md +14 -0
  21. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +4 -1
  22. package/plugins/lisa-agy/hooks/parity-safety-net.sh +233 -60
  23. package/plugins/lisa-agy/plugin.json +1 -1
  24. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  27. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-copilot/hooks/block-no-verify.sh +4 -1
  31. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +233 -60
  32. package/plugins/lisa-copilot/rules/eager/falsifiable-checks.md +1 -0
  33. package/plugins/lisa-copilot/rules/reference/base-rules.md +1 -1
  34. package/plugins/lisa-copilot/rules/reference/falsifiable-checks.md +14 -0
  35. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-cursor/hooks/block-no-verify.sh +4 -1
  37. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +233 -60
  38. package/plugins/lisa-cursor/rules/base-rules-reference.mdc +1 -1
  39. package/plugins/lisa-cursor/rules/falsifiable-checks-reference.mdc +14 -0
  40. package/plugins/lisa-cursor/rules/falsifiable-checks.mdc +1 -0
  41. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-expo-agy/plugin.json +1 -1
  44. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  49. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  54. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  59. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  64. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-rails-agy/plugin.json +1 -1
  69. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  74. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  79. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/src/base/hooks/block-no-verify.agy.sh +4 -1
  82. package/plugins/src/base/hooks/block-no-verify.sh +4 -1
  83. package/plugins/src/base/hooks/parity-safety-net.sh +233 -60
  84. package/plugins/src/base/rules/eager/falsifiable-checks.md +1 -0
  85. package/plugins/src/base/rules/reference/base-rules.md +1 -1
  86. package/plugins/src/base/rules/reference/falsifiable-checks.md +14 -0
  87. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +176 -1
@@ -127,6 +127,66 @@ EOF
127
127
  exit 2
128
128
  }
129
129
 
130
+ # grep answers three ways, not two: 0 it matched, 1 it did not match, and >=2 "I
131
+ # could not do this" — it was never exec'd, the pattern was rejected, the input
132
+ # could not be read. Every guard in this file used to reach its verdict through
133
+ # a bare `| grep -q` in a condition, where >=2 is indistinguishable from 1. So
134
+ # "the scan could not run" was read as "this command is safe", and the safety
135
+ # net stopped guarding without ever saying so.
136
+ #
137
+ # Measured rather than feared (CodySwannGT/lisa#3054). With a `grep` that exits
138
+ # 2 first on PATH, this hook ALLOWED `rm -rf /`, `git push --force origin main`,
139
+ # `dd of=/dev/disk0` and `git branch -D main` — every built-in guard, silently,
140
+ # exit 0. And the condition that gets there is live rather than theoretical: on
141
+ # the machine this was found on, 7,679 orphaned processes were outstanding
142
+ # against a `kern.maxprocperuid` of 10,666, putting a fork() that returns EAGAIN
143
+ # a few hundred spawns away.
144
+ #
145
+ # The reasoning is ALREADY in this file, at the bottom, for the project custom
146
+ # rules: a malformed ERE used to silently disable a rule the project believed it
147
+ # had, and that was fixed by reading grep's STATUS instead of its truthiness. It
148
+ # was never extended to the built-in guards. This is that extension.
149
+ #
150
+ # Fails CLOSED, which is this hook's stated contract everywhere else, and NAMES
151
+ # the environment failure — a guard that reports "safe" when it means "I could
152
+ # not tell" is the exact shape #3054 exists to remove.
153
+ #
154
+ # $1 = grep flags (-Ei / -E), $2 = text to scan, $3 = pattern.
155
+ scan() {
156
+ local flags="$1" text="$2" pattern="$3" status=0
157
+ printf '%s' "$text" | grep "$flags" -q -- "$pattern" || status=$?
158
+ case "$status" in
159
+ 0) return 0 ;;
160
+ 1) return 1 ;;
161
+ *) scan_failed "$status" ;;
162
+ esac
163
+ }
164
+
165
+ # Deny, and say what actually happened. Deliberately NOT block(): block() tells
166
+ # the reader their command matched a destructive pattern, which is a claim about
167
+ # the COMMAND. This is a claim about the MACHINE, and printing the first when
168
+ # the second is true is precisely what sends an operator to re-run instead of to
169
+ # look — the reporting failure #3054 is about.
170
+ scan_failed() {
171
+ cat >&2 <<EOF
172
+ Blocked by safety-net: a guard's pattern scan FAILED TO RUN (grep exited $1); denying fail-closed.
173
+
174
+ This is an ENVIRONMENT failure, not a property of your command. The guard could
175
+ not be evaluated at all, so the safety net denied rather than guessed — a scan
176
+ that cannot answer must never read as "safe".
177
+
178
+ The usual cause is a machine at its per-user process limit, where fork() starts
179
+ returning EAGAIN. Compare:
180
+
181
+ ps -u \$(id -u) | wc -l
182
+ sysctl -n kern.maxprocperuid # Linux: ulimit -u
183
+
184
+ Re-running is reasonable. If it repeats, the machine cannot fork and no number
185
+ of re-runs will change that.
186
+ EOF
187
+ exit 2
188
+ }
189
+
130
190
  # Heredoc payloads are data only for a deliberately narrow set of GitHub CLI
131
191
  # write commands. A companion parser proves that shape before removing payload
132
192
  # text from the destructive-command scans below. Unknown executable heredocs
@@ -142,8 +202,8 @@ EOF
142
202
  # this file, after the heredoc dispatch runs) so `git -C <path> commit` and
143
203
  # `git -c k=v commit` spellings are still recognized.
144
204
  block_heredoc() {
145
- if printf '%s' "$command_str" \
146
- | grep -Eq -- '(^|[^[:alnum:]_-])git[[:space:]]+(-[^;&|[:space:]]+([[:space:]]+[^-;&|[:space:]][^;&|[:space:]]*)?[[:space:]]+)*commit([^[:alnum:]_-]|$)'; then
205
+ if scan -E "$command_str" \
206
+ '(^|[^[:alnum:]_-])git[[:space:]]+(-[^;&|[:space:]]+([[:space:]]+[^-;&|[:space:]][^;&|[:space:]]*)?[[:space:]]+)*commit([^[:alnum:]_-]|$)'; then
147
207
  block "$1
148
208
  Heredoc commit invocations are blocked (the payload is executable shell).
149
209
  Fix: write the commit message to a file and run \`git commit -F <file>\`.
@@ -208,10 +268,10 @@ normalized_command_str="$(printf '%s' "$command_for_guards" \
208
268
  # not whitespace to grep -E, so the anchor failed to span the break. Defined
209
269
  # after the normalization above so the value exists when these are first called.
210
270
  matches() {
211
- printf '%s' "$normalized_command_str" | grep -Eiq -- "$1"
271
+ scan -Ei "$normalized_command_str" "$1"
212
272
  }
213
273
  matches_cs() {
214
- printf '%s' "$normalized_command_str" | grep -Eq -- "$1"
274
+ scan -E "$normalized_command_str" "$1"
215
275
  }
216
276
 
217
277
  # Shared ERE fragments. GIT_TOKENS walks over intermediate argv tokens without
@@ -384,7 +444,8 @@ rm_scan_scope() {
384
444
  printf '%s' "$stmt"
385
445
  return
386
446
  fi
387
- local i=0 ch q="" esc=0 outside="" cur_run="" first_rm_run="" found=0
447
+ local i=0 ch q="" esc=0 outside="" cur_run="" rm_runs="" found=0
448
+ local run_leading=0
388
449
  while [ "$i" -lt "$n" ]; do
389
450
  ch="${stmt:i:1}"
390
451
  i=$((i + 1))
@@ -399,16 +460,19 @@ rm_scan_scope() {
399
460
  fi
400
461
  if [ -n "$q" ]; then
401
462
  if [ "$ch" = "$q" ]; then
402
- if [ "$found" -eq 0 ]; then
403
- case "$cur_run" in
404
- *[Rr][Mm]*)
405
- if printf '%s' "$cur_run" | grep -Eiq -- "$RM_CMD"'([[:space:]]|$)'; then
406
- found=1
407
- first_rm_run="$cur_run"
463
+ case "$cur_run" in
464
+ *[Rr][Mm]*)
465
+ if scan -Ei "$cur_run" "$RM_CMD"'([[:space:]]|$)'; then
466
+ found=$((found + 1))
467
+ if [ -n "$rm_runs" ]; then rm_runs="$rm_runs"$'\n'; fi
468
+ rm_runs="$rm_runs$cur_run"
469
+ if scan -Ei "$cur_run" \
470
+ '^[[:space:]]*(\\|\$\(|`|\(|<\(|>\()*[[:space:]]*([[:alnum:]_./-]*/)?rm([[:space:]]|$)'; then
471
+ run_leading=1
408
472
  fi
409
- ;;
410
- esac
411
- fi
473
+ fi
474
+ ;;
475
+ esac
412
476
  q=""
413
477
  cur_run=""
414
478
  else
@@ -427,7 +491,7 @@ rm_scan_scope() {
427
491
  # An unterminated quote is text the scan cannot place: treat it as unquoted so
428
492
  # the fallback below hands back the whole statement.
429
493
  [ -z "$q" ] || outside="$outside$cur_run"
430
- if printf '%s' "$outside" | grep -Eiq -- "$RM_CMD"'([[:space:]]|$)'; then
494
+ if scan -Ei "$outside" "$RM_CMD"'([[:space:]]|$)'; then
431
495
  printf '%s' "$stmt"
432
496
  return
433
497
  fi
@@ -435,36 +499,118 @@ rm_scan_scope() {
435
499
  printf '%s' "$stmt"
436
500
  return
437
501
  fi
438
- # `rm` first in its run (modulo whitespace and substitution/alias openers) is
439
- # an invocation, not prose.
440
- if printf '%s' "$first_rm_run" \
441
- | grep -Eiq -- '^[[:space:]]*(\\|\$\(|`|\(|<\(|>\()*[[:space:]]*([[:alnum:]_./-]*/)?rm([[:space:]]|$)'; then
502
+ # `rm` first in ANY run (modulo whitespace and substitution/alias openers) is
503
+ # an invocation, not prose. Inspect every run before deciding: returning on
504
+ # the first prose run used to hide a real delete in a later quoted run.
505
+ if [ "$run_leading" -eq 1 ]; then
442
506
  printf '%s' "$stmt"
443
507
  return
444
508
  fi
445
- printf '%s' "$first_rm_run"
509
+ printf '%s' "$rm_runs"
446
510
  }
447
511
  # Look up the ONE unambiguous assignment of NAME inside the command being
448
512
  # classified — issue #3106 arm B, the variable half.
449
513
  #
450
- # Prints the recorded right-hand side, or fails when the name is never assigned
451
- # here or is assigned more than one distinct value. Ambiguity must fail: the
452
- # last write wins at run time and a text scan cannot order writes against the
453
- # delete, so `V=/etc; rm -rf "$V"; V=/tmp/x` must not resolve to the harmless
454
- # one. Only assignments literally present in this command are ever trusted;
455
- # nothing is expanded from the hook's own environment.
514
+ # Prints the recorded right-hand side, or fails when the name is not assigned
515
+ # before the first rm token or is assigned more than one distinct value there.
516
+ # A later write cannot affect an earlier delete, and a quoted assignment-like
517
+ # argument can be prose, so neither is considered. Only assignments literally
518
+ # present in this command are ever trusted; nothing is expanded from the hook's
519
+ # own environment.
520
+ rm_assignment_stmt=""
456
521
  rm_assignment_value() {
457
- local name="$1" hits="" count=""
522
+ local name="$1"
458
523
  case "$name" in
459
524
  "" | *[!A-Za-z0-9_]*) return 1 ;;
460
525
  esac
461
- hits="$(printf '%s\n' "$normalized_command_str" | tr ';&|' '\n' \
462
- | grep -oE "(^|[[:space:]])${name}=[^[:space:]]*" \
463
- | sed -E "s/^.*${name}=//")"
464
- [ -n "$hits" ] || return 1
465
- count="$(printf '%s\n' "$hits" | sort -u | grep -c '' | tr -d '[:space:]')"
466
- [ "$count" = "1" ] || return 1
467
- printf '%s' "$(printf '%s\n' "$hits" | head -n 1)"
526
+ # Only assignments that occur before this statement's rm token can influence
527
+ # its delete safely. A later assignment is too late at runtime, and an
528
+ # assignment whose NAME begins inside a quoted argument can be prose. The
529
+ # small lexer below preserves quoted right-hand sides such as V="/tmp/x"
530
+ # while excluding an argument such as echo "V=/tmp/x". Any ambiguity fails
531
+ # closed.
532
+ RM_ASSIGNMENT_NAME="$name" RM_ASSIGNMENT_COMMAND="$normalized_command_str" \
533
+ RM_ASSIGNMENT_STATEMENT="$rm_assignment_stmt" \
534
+ python3 - <<'PY'
535
+ import os
536
+ import re
537
+ import sys
538
+
539
+ name = os.environ.get("RM_ASSIGNMENT_NAME", "")
540
+ command = os.environ.get("RM_ASSIGNMENT_COMMAND", "")
541
+ statement = os.environ.get("RM_ASSIGNMENT_STATEMENT", "")
542
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
543
+ sys.exit(1)
544
+
545
+ rm_pattern = re.compile(
546
+ r"(?i)(?<![A-Za-z0-9_./-])(?:[A-Za-z0-9_./-]*/)?rm(?=$|[\s;&|()'\"`])",
547
+ )
548
+ rm_token = rm_pattern.search(statement)
549
+ if rm_token is None or not statement or command.count(statement) != 1:
550
+ sys.exit(1)
551
+ statement_start = command.find(statement)
552
+ if statement_start < 0:
553
+ sys.exit(1)
554
+ prefix = command[: statement_start + rm_token.start()]
555
+
556
+ tokens = []
557
+ buffer = []
558
+ quote = None
559
+ escaped = False
560
+ started = False
561
+ started_unquoted = False
562
+
563
+ def flush():
564
+ global buffer, started, started_unquoted
565
+ if started:
566
+ tokens.append(("".join(buffer), started_unquoted))
567
+ buffer = []
568
+ started = False
569
+ started_unquoted = False
570
+
571
+ for char in prefix:
572
+ if escaped:
573
+ buffer.append(char)
574
+ escaped = False
575
+ continue
576
+ if quote is not None:
577
+ if char == quote:
578
+ quote = None
579
+ elif char == "\\" and quote == '"':
580
+ escaped = True
581
+ else:
582
+ buffer.append(char)
583
+ continue
584
+ if char in "'\"":
585
+ if not started:
586
+ started = True
587
+ started_unquoted = False
588
+ quote = char
589
+ elif char == "\\":
590
+ if not started:
591
+ started = True
592
+ started_unquoted = True
593
+ escaped = True
594
+ elif char.isspace() or char in ";&|()":
595
+ flush()
596
+ else:
597
+ if not started:
598
+ started = True
599
+ started_unquoted = True
600
+ buffer.append(char)
601
+ flush()
602
+
603
+ needle = f"{name}="
604
+ hits = [token[len(needle):] for token, outside in tokens
605
+ if outside and token.startswith(needle)]
606
+ distinct = set(hits)
607
+ if len(distinct) != 1:
608
+ sys.exit(1)
609
+ value = hits[0]
610
+ if not value:
611
+ sys.exit(1)
612
+ sys.stdout.write(value)
613
+ PY
468
614
  }
469
615
  # Rewrite a leading `$NAME` / `${NAME}` in a deletion target to the value the
470
616
  # command itself assigns it, so the target is classified as if the agent had
@@ -570,13 +716,29 @@ classify_rm_target() {
570
716
  esac
571
717
  return 0
572
718
  }
719
+ # Segmented in the PARENT shell rather than in a process substitution, and the
720
+ # `|| true` that used to sit on the feeder is gone. Both together were the same
721
+ # three-way read as a bare `grep -q` condition (see scan() above), just wearing
722
+ # a loop: a grep that could not run produced no lines, the body never executed,
723
+ # and the guard was silently skipped with the command ALLOWED. `exit` inside a
724
+ # process substitution cannot deny anything either — it ends that subshell, not
725
+ # this hook.
726
+ rm_segments_status=0
727
+ rm_segments="$(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
728
+ | grep -Ei -- "$RM_CMD"'([[:space:]]|$)')" || rm_segments_status=$?
729
+ case "$rm_segments_status" in
730
+ 0 | 1) ;;
731
+ *) scan_failed "$rm_segments_status" ;;
732
+ esac
573
733
  while IFS= read -r rm_stmt; do
574
- if ! printf '%s' "$rm_stmt" | grep -Eiq -- "$RM_RF_CLUSTER" \
575
- && ! printf '%s' "$rm_stmt" | grep -Eiq -- "$RM_RF_SPLIT"; then
734
+ # A here-string over empty input still yields one empty line.
735
+ [ -n "$rm_stmt" ] || continue
736
+ if ! scan -Ei "$rm_stmt" "$RM_RF_CLUSTER" \
737
+ && ! scan -Ei "$rm_stmt" "$RM_RF_SPLIT"; then
576
738
  continue
577
739
  fi
578
740
  # Guard 1: catastrophic target named in the SAME statement as the rm -rf.
579
- if printf '%s' "$rm_stmt" | grep -Eq -- "$RM_CATASTROPHIC_TARGET"; then
741
+ if scan -E "$rm_stmt" "$RM_CATASTROPHIC_TARGET"; then
580
742
  block "recursive forced delete of a root, home, or wildcard path (rm -rf)"
581
743
  fi
582
744
  # Physical-path comparison (pwd -P): on macOS $HOME or the cwd may arrive via
@@ -588,23 +750,25 @@ while IFS= read -r rm_stmt; do
588
750
  # Scope the walk to the quoting region that actually contains the rm (#3106
589
751
  # arm A). Unquoted and run-leading invocations get the whole statement back,
590
752
  # so this is a no-op for every real delete.
753
+ rm_assignment_stmt="$rm_stmt"
591
754
  rm_walk_text="$(rm_scan_scope "$rm_stmt")"
592
- set -f
593
- seen_rm=0
594
- for raw_token in $rm_walk_text; do
595
- token="$(strip_subst_wrappers "$raw_token")"
596
- if [ "$seen_rm" -eq 0 ]; then
597
- # Path-prefixed spellings (`/bin/rm`, `./rm`) are still rm (F2).
598
- case "$token" in
599
- rm | */rm) seen_rm=1 ;;
600
- esac
601
- continue
602
- fi
603
- classify_rm_target "$token"
604
- done
605
- set +f
606
- done < <(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
607
- | grep -Ei -- "$RM_CMD"'([[:space:]]|$)' || true)
755
+ while IFS= read -r rm_scope; do
756
+ set -f
757
+ seen_rm=0
758
+ for raw_token in $rm_scope; do
759
+ token="$(strip_subst_wrappers "$raw_token")"
760
+ if [ "$seen_rm" -eq 0 ]; then
761
+ # Path-prefixed spellings (`/bin/rm`, `./rm`) are still rm (F2).
762
+ case "$token" in
763
+ rm | */rm) seen_rm=1 ;;
764
+ esac
765
+ continue
766
+ fi
767
+ classify_rm_target "$token"
768
+ done
769
+ set +f
770
+ done <<< "$rm_walk_text"
771
+ done <<<"$rm_segments"
608
772
 
609
773
  # 2. Force-pushing a protected branch. `--force-with-lease` is the safe,
610
774
  # non-clobbering alternative and is intentionally NOT blocked. Deliberate
@@ -620,16 +784,25 @@ done < <(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
620
784
  # (`;`, `&&`, `||`, `|`, newlines), keep only the `git push` segments, and
621
785
  # inspect each in isolation — a real `git push --force origin main` still
622
786
  # matches, while a feature-branch push next to `[ -f ]`/`--base main` passes.
787
+ # Same reason as the rm segmentation above: a feeder that swallows grep's error
788
+ # skips the guard entirely and allows the command.
789
+ push_segments_status=0
790
+ push_segments="$(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
791
+ | grep -Ei -- "${GIT_CMD}push\b")" || push_segments_status=$?
792
+ case "$push_segments_status" in
793
+ 0 | 1) ;;
794
+ *) scan_failed "$push_segments_status" ;;
795
+ esac
623
796
  while IFS= read -r push_stmt; do
624
- if printf '%s' "$push_stmt" \
625
- | grep -Eiq '(--force([[:space:]]|=|$)|[[:space:]]-f([[:space:]]|$))' \
626
- && ! printf '%s' "$push_stmt" | grep -Eiq -- '--force-with-lease' \
627
- && printf '%s' "$push_stmt" \
628
- | grep -Eiq '(^|[^[:alnum:]_/-])(main|master|production|prod|release)([^[:alnum:]_/-]|$)'; then
797
+ [ -n "$push_stmt" ] || continue
798
+ if scan -Ei "$push_stmt" \
799
+ '(--force([[:space:]]|=|$)|[[:space:]]-f([[:space:]]|$))' \
800
+ && ! scan -Ei "$push_stmt" '--force-with-lease' \
801
+ && scan -Ei "$push_stmt" \
802
+ '(^|[^[:alnum:]_/-])(main|master|production|prod|release)([^[:alnum:]_/-]|$)'; then
629
803
  block "force-pushing a protected branch (use --force-with-lease, or push a feature branch)"
630
804
  fi
631
- done < <(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
632
- | grep -Ei -- "${GIT_CMD}push\b" || true)
805
+ done <<<"$push_segments"
633
806
 
634
807
  # 3. `git reset --hard` / `git reset --merge` while the working tree has
635
808
  # uncommitted changes — both silently discard them. Only blocks when the tree
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.10.13",
3
+ "version": "4.10.15",
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.10.13",
3
+ "version": "4.10.15",
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.10.13",
3
+ "version": "4.10.15",
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.10.13",
3
+ "version": "4.10.15",
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.10.13",
3
+ "version": "4.10.15",
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.10.13",
3
+ "version": "4.10.15",
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.10.13",
3
+ "version": "4.10.15",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -109,7 +109,10 @@ def strip_heredocs(text: str) -> str:
109
109
  words = shlex.split(raw, posix=True)
110
110
  except ValueError:
111
111
  return None
112
- return words[0] if len(words) == 1 and words[0] else None
112
+ # An explicitly quoted empty word is a valid heredoc delimiter. Bash
113
+ # terminates that heredoc on the next empty line, so preserve "" as a
114
+ # marker instead of treating it as a parse failure.
115
+ return words[0] if len(words) == 1 else None
113
116
 
114
117
  index = 0
115
118
  while index < len(lines):