@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.
- package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +4 -1
- package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +233 -60
- package/dist/codex/scripts/block-no-verify.sh +4 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +8 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +12 -9
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/block-no-verify.sh +4 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +233 -60
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/block-no-verify.agy.sh +4 -1
- package/plugins/lisa/hooks/block-no-verify.sh +4 -1
- package/plugins/lisa/hooks/parity-safety-net.sh +233 -60
- package/plugins/lisa/rules/eager/falsifiable-checks.md +1 -0
- package/plugins/lisa/rules/reference/base-rules.md +1 -1
- package/plugins/lisa/rules/reference/falsifiable-checks.md +14 -0
- package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +4 -1
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +233 -60
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/block-no-verify.sh +4 -1
- package/plugins/lisa-copilot/hooks/parity-safety-net.sh +233 -60
- package/plugins/lisa-copilot/rules/eager/falsifiable-checks.md +1 -0
- package/plugins/lisa-copilot/rules/reference/base-rules.md +1 -1
- package/plugins/lisa-copilot/rules/reference/falsifiable-checks.md +14 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-no-verify.sh +4 -1
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +233 -60
- package/plugins/lisa-cursor/rules/base-rules-reference.mdc +1 -1
- package/plugins/lisa-cursor/rules/falsifiable-checks-reference.mdc +14 -0
- package/plugins/lisa-cursor/rules/falsifiable-checks.mdc +1 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/hooks/block-no-verify.agy.sh +4 -1
- package/plugins/src/base/hooks/block-no-verify.sh +4 -1
- package/plugins/src/base/hooks/parity-safety-net.sh +233 -60
- package/plugins/src/base/rules/eager/falsifiable-checks.md +1 -0
- package/plugins/src/base/rules/reference/base-rules.md +1 -1
- package/plugins/src/base/rules/reference/falsifiable-checks.md +14 -0
- 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
|
|
146
|
-
|
|
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
|
-
|
|
271
|
+
scan -Ei "$normalized_command_str" "$1"
|
|
212
272
|
}
|
|
213
273
|
matches_cs() {
|
|
214
|
-
|
|
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=""
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
|
|
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
|
|
439
|
-
# an invocation, not prose.
|
|
440
|
-
|
|
441
|
-
|
|
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' "$
|
|
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
|
|
451
|
-
#
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
-
#
|
|
455
|
-
#
|
|
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"
|
|
522
|
+
local name="$1"
|
|
458
523
|
case "$name" in
|
|
459
524
|
"" | *[!A-Za-z0-9_]*) return 1 ;;
|
|
460
525
|
esac
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
575
|
-
|
|
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
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
&&
|
|
628
|
-
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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):
|