@codyswann/lisa 4.10.13 → 4.10.14
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/parity-safety-net.sh +104 -20
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +4 -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 +7 -5
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +104 -20
- 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/parity-safety-net.sh +104 -20
- package/plugins/lisa/rules/eager/falsifiable-checks.md +1 -0
- package/plugins/lisa/rules/reference/falsifiable-checks.md +14 -0
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +104 -20
- 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/parity-safety-net.sh +104 -20
- package/plugins/lisa-copilot/rules/eager/falsifiable-checks.md +1 -0
- 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/parity-safety-net.sh +104 -20
- 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/parity-safety-net.sh +104 -20
- package/plugins/src/base/rules/eager/falsifiable-checks.md +1 -0
- 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
|
|
@@ -402,7 +462,7 @@ rm_scan_scope() {
|
|
|
402
462
|
if [ "$found" -eq 0 ]; then
|
|
403
463
|
case "$cur_run" in
|
|
404
464
|
*[Rr][Mm]*)
|
|
405
|
-
if
|
|
465
|
+
if scan -Ei "$cur_run" "$RM_CMD"'([[:space:]]|$)'; then
|
|
406
466
|
found=1
|
|
407
467
|
first_rm_run="$cur_run"
|
|
408
468
|
fi
|
|
@@ -427,7 +487,7 @@ rm_scan_scope() {
|
|
|
427
487
|
# An unterminated quote is text the scan cannot place: treat it as unquoted so
|
|
428
488
|
# the fallback below hands back the whole statement.
|
|
429
489
|
[ -z "$q" ] || outside="$outside$cur_run"
|
|
430
|
-
if
|
|
490
|
+
if scan -Ei "$outside" "$RM_CMD"'([[:space:]]|$)'; then
|
|
431
491
|
printf '%s' "$stmt"
|
|
432
492
|
return
|
|
433
493
|
fi
|
|
@@ -437,8 +497,8 @@ rm_scan_scope() {
|
|
|
437
497
|
fi
|
|
438
498
|
# `rm` first in its run (modulo whitespace and substitution/alias openers) is
|
|
439
499
|
# an invocation, not prose.
|
|
440
|
-
if
|
|
441
|
-
|
|
500
|
+
if scan -Ei "$first_rm_run" \
|
|
501
|
+
'^[[:space:]]*(\\|\$\(|`|\(|<\(|>\()*[[:space:]]*([[:alnum:]_./-]*/)?rm([[:space:]]|$)'; then
|
|
442
502
|
printf '%s' "$stmt"
|
|
443
503
|
return
|
|
444
504
|
fi
|
|
@@ -570,13 +630,29 @@ classify_rm_target() {
|
|
|
570
630
|
esac
|
|
571
631
|
return 0
|
|
572
632
|
}
|
|
633
|
+
# Segmented in the PARENT shell rather than in a process substitution, and the
|
|
634
|
+
# `|| true` that used to sit on the feeder is gone. Both together were the same
|
|
635
|
+
# three-way read as a bare `grep -q` condition (see scan() above), just wearing
|
|
636
|
+
# a loop: a grep that could not run produced no lines, the body never executed,
|
|
637
|
+
# and the guard was silently skipped with the command ALLOWED. `exit` inside a
|
|
638
|
+
# process substitution cannot deny anything either — it ends that subshell, not
|
|
639
|
+
# this hook.
|
|
640
|
+
rm_segments_status=0
|
|
641
|
+
rm_segments="$(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
|
|
642
|
+
| grep -Ei -- "$RM_CMD"'([[:space:]]|$)')" || rm_segments_status=$?
|
|
643
|
+
case "$rm_segments_status" in
|
|
644
|
+
0 | 1) ;;
|
|
645
|
+
*) scan_failed "$rm_segments_status" ;;
|
|
646
|
+
esac
|
|
573
647
|
while IFS= read -r rm_stmt; do
|
|
574
|
-
|
|
575
|
-
|
|
648
|
+
# A here-string over empty input still yields one empty line.
|
|
649
|
+
[ -n "$rm_stmt" ] || continue
|
|
650
|
+
if ! scan -Ei "$rm_stmt" "$RM_RF_CLUSTER" \
|
|
651
|
+
&& ! scan -Ei "$rm_stmt" "$RM_RF_SPLIT"; then
|
|
576
652
|
continue
|
|
577
653
|
fi
|
|
578
654
|
# Guard 1: catastrophic target named in the SAME statement as the rm -rf.
|
|
579
|
-
if
|
|
655
|
+
if scan -E "$rm_stmt" "$RM_CATASTROPHIC_TARGET"; then
|
|
580
656
|
block "recursive forced delete of a root, home, or wildcard path (rm -rf)"
|
|
581
657
|
fi
|
|
582
658
|
# Physical-path comparison (pwd -P): on macOS $HOME or the cwd may arrive via
|
|
@@ -603,8 +679,7 @@ while IFS= read -r rm_stmt; do
|
|
|
603
679
|
classify_rm_target "$token"
|
|
604
680
|
done
|
|
605
681
|
set +f
|
|
606
|
-
done
|
|
607
|
-
| grep -Ei -- "$RM_CMD"'([[:space:]]|$)' || true)
|
|
682
|
+
done <<<"$rm_segments"
|
|
608
683
|
|
|
609
684
|
# 2. Force-pushing a protected branch. `--force-with-lease` is the safe,
|
|
610
685
|
# non-clobbering alternative and is intentionally NOT blocked. Deliberate
|
|
@@ -620,16 +695,25 @@ done < <(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
|
|
|
620
695
|
# (`;`, `&&`, `||`, `|`, newlines), keep only the `git push` segments, and
|
|
621
696
|
# inspect each in isolation — a real `git push --force origin main` still
|
|
622
697
|
# matches, while a feature-branch push next to `[ -f ]`/`--base main` passes.
|
|
698
|
+
# Same reason as the rm segmentation above: a feeder that swallows grep's error
|
|
699
|
+
# skips the guard entirely and allows the command.
|
|
700
|
+
push_segments_status=0
|
|
701
|
+
push_segments="$(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
|
|
702
|
+
| grep -Ei -- "${GIT_CMD}push\b")" || push_segments_status=$?
|
|
703
|
+
case "$push_segments_status" in
|
|
704
|
+
0 | 1) ;;
|
|
705
|
+
*) scan_failed "$push_segments_status" ;;
|
|
706
|
+
esac
|
|
623
707
|
while IFS= read -r push_stmt; do
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
&&
|
|
628
|
-
|
|
708
|
+
[ -n "$push_stmt" ] || continue
|
|
709
|
+
if scan -Ei "$push_stmt" \
|
|
710
|
+
'(--force([[:space:]]|=|$)|[[:space:]]-f([[:space:]]|$))' \
|
|
711
|
+
&& ! scan -Ei "$push_stmt" '--force-with-lease' \
|
|
712
|
+
&& scan -Ei "$push_stmt" \
|
|
713
|
+
'(^|[^[:alnum:]_/-])(main|master|production|prod|release)([^[:alnum:]_/-]|$)'; then
|
|
629
714
|
block "force-pushing a protected branch (use --force-with-lease, or push a feature branch)"
|
|
630
715
|
fi
|
|
631
|
-
done
|
|
632
|
-
| grep -Ei -- "${GIT_CMD}push\b" || true)
|
|
716
|
+
done <<<"$push_segments"
|
|
633
717
|
|
|
634
718
|
# 3. `git reset --hard` / `git reset --merge` while the working tree has
|
|
635
719
|
# uncommitted changes — both silently discard them. Only blocks when the tree
|
|
@@ -34,6 +34,7 @@ Each has been observed in real runs; each reported success while asserting nothi
|
|
|
34
34
|
This form is preferred over the alternatives because it is the only one that both survives `sh` and keeps the full output, which is what you need the moment the status says the gate failed. `set -o pipefail` is correct where it exists but is **not POSIX `sh`** — dash has no such option, and this repository runs hooks under `sh`. `${PIPESTATUS[0]}` is **bash-only** (zsh spells it `$pipestatus[1]`, 1-indexed) and is overwritten by the very next command, so it must be read immediately. Never report a gate as green from a status read through `| tail`, `| head`, or `| tee`.
|
|
35
35
|
- **Falsify before reporting.** No clean result is reportable until the check has been shown to fail on a deliberate break. **"Mentally reverting" does not count** — reasoning that the assertion *would* fail is precisely the step that lets a non-functional guard ship, because the author already believes it is load-bearing. Run the break.
|
|
36
36
|
- **Mutation-prove the guard, then read the cardinality.** Neuter the protection **in production code**, run the **whole suite**, count the failures and **read their names**, then revert. **Zero** ⇒ the guard is inert (one pinning a single field shipped with all 50 tests green while the regression walked through). **Many, unrelated** ⇒ over-broad; its next failure will not name the cause. **Exactly one, or several all named for the same regression** ⇒ load-bearing and correctly scoped. A **zero is robust to contention** — load only ever adds failures — so only the non-zero counts need a quiet machine.
|
|
37
|
+
- **The automated mutation gate is language-scoped; the manual probe above is not.** Stryker instruments the JS/TS family and nothing else — there is **no shell parser**, so a guard whose logic lives in `.sh` yields no mutants under **any** `mutate` list. Adding one does not measure it: it aborts the whole run (`No parser registered for .sh!` — measured, not inferred) and takes every other guard's score with it. So a mutation gate that stays grey or green on a shell-guard change **measured nothing about that guard**, and reading it as coverage is the fifth failure mode above wearing a config file. Shell guards are covered by **driving tests only**: execute the script against a payload table and assert the blocked/allowed verdict, with a control on **both** sides — a refusal case *and* an allowed case. A shell guard nothing executes has no bite evidence at all, whatever the gate printed.
|
|
37
38
|
- **Never scope the probe by filename, and never infer absent coverage from a grep.** Suites split at the line cap, so a protection's tests often sit in a sibling file: a filename-scoped probe reported cardinality 0 on a live security fix that a whole-suite run showed failing 4 tests, every one named for it.
|
|
38
39
|
- **Assert that no copy is wrong, not that some copy is right.** An existential assertion over duplicated content is satisfied by the correct copy and cannot see the broken one — rewriting one suite's assertions to universal negatives took the same mutation from **0 to 18** failures. Enumerate the property (*every* marker uses an em-dash), never the one known-bad spelling.
|
|
39
40
|
- **Reconstruct a red state; never narrate one you did not run.** Evidence describing a failing or dirty state must come from a run you actually observed — an evidence file once claimed "working tree clean" while listing two untracked scripts in the same file. To observe a state that no longer exists, check that commit out in a **throwaway detached-HEAD worktree** and run it there. Writing down what the output would have said is fabrication, however confident the reasoning.
|
|
@@ -166,6 +166,20 @@ The obvious objection to any cardinality measured on a shared machine — *"your
|
|
|
166
166
|
|
|
167
167
|
Practical consequence: an inert-guard finding is safe to report from a busy machine, while the "exactly one, or several all named alike" reading is only trustworthy once you have separated your mutation's failures from the load-flake population — which is what reading the *names* is for.
|
|
168
168
|
|
|
169
|
+
### The automated gate stops at the language boundary
|
|
170
|
+
|
|
171
|
+
Everything above is a *manual* probe, and it is language-agnostic: neuter a shell guard, run the suite, read the names, and the yardstick works exactly as it does for TypeScript.
|
|
172
|
+
|
|
173
|
+
The **automated** mutation gate is not language-agnostic, and the difference is easy to miss because both end in a green result. Stryker's instrumenter is per-language — the JS/TS family, plus HTML and Svelte, which delegate to it. There is **no shell parser**. So:
|
|
174
|
+
|
|
175
|
+
- A guard whose logic lives in `.sh` produces **no mutants under any `mutate` list**. It is outside the gate by construction, not by a selection decision, and widening the patterns cannot bring it in.
|
|
176
|
+
- Adding a `.sh` path to `mutate` does not measure it and does not quietly no-op. Stryker throws out of instrumentation — `Unable to parse …. No parser registered for .sh!` — and the run aborts before a single mutant is tried, so **one such entry destroys the score of every other guard in the list.** Measured, not inferred.
|
|
177
|
+
- Therefore a mutation gate that reports nothing-to-mutate, or reports green, on a change whose only guard edits were shell **measured nothing about those guards**. The absence of red is a property of the toolchain, not of the tests.
|
|
178
|
+
|
|
179
|
+
What does measure a shell guard is a **driving test**: execute the script as a subprocess against a payload table and assert the verdict — blocked or allowed — with a control on **both** sides. A refusal-only suite cannot see a guard that refuses everything; an allow-only suite cannot see one that refuses nothing. Tests that merely `bash -n` the script, grep its source, or assert it exists are *source-shape* checks and are not evidence of bite.
|
|
180
|
+
|
|
181
|
+
A shell guard with no test that executes it has **no bite evidence at all**, whatever any gate printed.
|
|
182
|
+
|
|
169
183
|
### Two reasons a real protection reports zero
|
|
170
184
|
|
|
171
185
|
A cardinality of zero has two distinct innocent causes, and they need different fixes. Rule out both before concluding a guard is inert:
|
|
@@ -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
|
|
@@ -402,7 +462,7 @@ rm_scan_scope() {
|
|
|
402
462
|
if [ "$found" -eq 0 ]; then
|
|
403
463
|
case "$cur_run" in
|
|
404
464
|
*[Rr][Mm]*)
|
|
405
|
-
if
|
|
465
|
+
if scan -Ei "$cur_run" "$RM_CMD"'([[:space:]]|$)'; then
|
|
406
466
|
found=1
|
|
407
467
|
first_rm_run="$cur_run"
|
|
408
468
|
fi
|
|
@@ -427,7 +487,7 @@ rm_scan_scope() {
|
|
|
427
487
|
# An unterminated quote is text the scan cannot place: treat it as unquoted so
|
|
428
488
|
# the fallback below hands back the whole statement.
|
|
429
489
|
[ -z "$q" ] || outside="$outside$cur_run"
|
|
430
|
-
if
|
|
490
|
+
if scan -Ei "$outside" "$RM_CMD"'([[:space:]]|$)'; then
|
|
431
491
|
printf '%s' "$stmt"
|
|
432
492
|
return
|
|
433
493
|
fi
|
|
@@ -437,8 +497,8 @@ rm_scan_scope() {
|
|
|
437
497
|
fi
|
|
438
498
|
# `rm` first in its run (modulo whitespace and substitution/alias openers) is
|
|
439
499
|
# an invocation, not prose.
|
|
440
|
-
if
|
|
441
|
-
|
|
500
|
+
if scan -Ei "$first_rm_run" \
|
|
501
|
+
'^[[:space:]]*(\\|\$\(|`|\(|<\(|>\()*[[:space:]]*([[:alnum:]_./-]*/)?rm([[:space:]]|$)'; then
|
|
442
502
|
printf '%s' "$stmt"
|
|
443
503
|
return
|
|
444
504
|
fi
|
|
@@ -570,13 +630,29 @@ classify_rm_target() {
|
|
|
570
630
|
esac
|
|
571
631
|
return 0
|
|
572
632
|
}
|
|
633
|
+
# Segmented in the PARENT shell rather than in a process substitution, and the
|
|
634
|
+
# `|| true` that used to sit on the feeder is gone. Both together were the same
|
|
635
|
+
# three-way read as a bare `grep -q` condition (see scan() above), just wearing
|
|
636
|
+
# a loop: a grep that could not run produced no lines, the body never executed,
|
|
637
|
+
# and the guard was silently skipped with the command ALLOWED. `exit` inside a
|
|
638
|
+
# process substitution cannot deny anything either — it ends that subshell, not
|
|
639
|
+
# this hook.
|
|
640
|
+
rm_segments_status=0
|
|
641
|
+
rm_segments="$(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
|
|
642
|
+
| grep -Ei -- "$RM_CMD"'([[:space:]]|$)')" || rm_segments_status=$?
|
|
643
|
+
case "$rm_segments_status" in
|
|
644
|
+
0 | 1) ;;
|
|
645
|
+
*) scan_failed "$rm_segments_status" ;;
|
|
646
|
+
esac
|
|
573
647
|
while IFS= read -r rm_stmt; do
|
|
574
|
-
|
|
575
|
-
|
|
648
|
+
# A here-string over empty input still yields one empty line.
|
|
649
|
+
[ -n "$rm_stmt" ] || continue
|
|
650
|
+
if ! scan -Ei "$rm_stmt" "$RM_RF_CLUSTER" \
|
|
651
|
+
&& ! scan -Ei "$rm_stmt" "$RM_RF_SPLIT"; then
|
|
576
652
|
continue
|
|
577
653
|
fi
|
|
578
654
|
# Guard 1: catastrophic target named in the SAME statement as the rm -rf.
|
|
579
|
-
if
|
|
655
|
+
if scan -E "$rm_stmt" "$RM_CATASTROPHIC_TARGET"; then
|
|
580
656
|
block "recursive forced delete of a root, home, or wildcard path (rm -rf)"
|
|
581
657
|
fi
|
|
582
658
|
# Physical-path comparison (pwd -P): on macOS $HOME or the cwd may arrive via
|
|
@@ -603,8 +679,7 @@ while IFS= read -r rm_stmt; do
|
|
|
603
679
|
classify_rm_target "$token"
|
|
604
680
|
done
|
|
605
681
|
set +f
|
|
606
|
-
done
|
|
607
|
-
| grep -Ei -- "$RM_CMD"'([[:space:]]|$)' || true)
|
|
682
|
+
done <<<"$rm_segments"
|
|
608
683
|
|
|
609
684
|
# 2. Force-pushing a protected branch. `--force-with-lease` is the safe,
|
|
610
685
|
# non-clobbering alternative and is intentionally NOT blocked. Deliberate
|
|
@@ -620,16 +695,25 @@ done < <(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
|
|
|
620
695
|
# (`;`, `&&`, `||`, `|`, newlines), keep only the `git push` segments, and
|
|
621
696
|
# inspect each in isolation — a real `git push --force origin main` still
|
|
622
697
|
# matches, while a feature-branch push next to `[ -f ]`/`--base main` passes.
|
|
698
|
+
# Same reason as the rm segmentation above: a feeder that swallows grep's error
|
|
699
|
+
# skips the guard entirely and allows the command.
|
|
700
|
+
push_segments_status=0
|
|
701
|
+
push_segments="$(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
|
|
702
|
+
| grep -Ei -- "${GIT_CMD}push\b")" || push_segments_status=$?
|
|
703
|
+
case "$push_segments_status" in
|
|
704
|
+
0 | 1) ;;
|
|
705
|
+
*) scan_failed "$push_segments_status" ;;
|
|
706
|
+
esac
|
|
623
707
|
while IFS= read -r push_stmt; do
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
&&
|
|
628
|
-
|
|
708
|
+
[ -n "$push_stmt" ] || continue
|
|
709
|
+
if scan -Ei "$push_stmt" \
|
|
710
|
+
'(--force([[:space:]]|=|$)|[[:space:]]-f([[:space:]]|$))' \
|
|
711
|
+
&& ! scan -Ei "$push_stmt" '--force-with-lease' \
|
|
712
|
+
&& scan -Ei "$push_stmt" \
|
|
713
|
+
'(^|[^[:alnum:]_/-])(main|master|production|prod|release)([^[:alnum:]_/-]|$)'; then
|
|
629
714
|
block "force-pushing a protected branch (use --force-with-lease, or push a feature branch)"
|
|
630
715
|
fi
|
|
631
|
-
done
|
|
632
|
-
| grep -Ei -- "${GIT_CMD}push\b" || true)
|
|
716
|
+
done <<<"$push_segments"
|
|
633
717
|
|
|
634
718
|
# 3. `git reset --hard` / `git reset --merge` while the working tree has
|
|
635
719
|
# uncommitted changes — both silently discard them. Only blocks when the tree
|
|
@@ -171,6 +171,20 @@ The obvious objection to any cardinality measured on a shared machine — *"your
|
|
|
171
171
|
|
|
172
172
|
Practical consequence: an inert-guard finding is safe to report from a busy machine, while the "exactly one, or several all named alike" reading is only trustworthy once you have separated your mutation's failures from the load-flake population — which is what reading the *names* is for.
|
|
173
173
|
|
|
174
|
+
### The automated gate stops at the language boundary
|
|
175
|
+
|
|
176
|
+
Everything above is a *manual* probe, and it is language-agnostic: neuter a shell guard, run the suite, read the names, and the yardstick works exactly as it does for TypeScript.
|
|
177
|
+
|
|
178
|
+
The **automated** mutation gate is not language-agnostic, and the difference is easy to miss because both end in a green result. Stryker's instrumenter is per-language — the JS/TS family, plus HTML and Svelte, which delegate to it. There is **no shell parser**. So:
|
|
179
|
+
|
|
180
|
+
- A guard whose logic lives in `.sh` produces **no mutants under any `mutate` list**. It is outside the gate by construction, not by a selection decision, and widening the patterns cannot bring it in.
|
|
181
|
+
- Adding a `.sh` path to `mutate` does not measure it and does not quietly no-op. Stryker throws out of instrumentation — `Unable to parse …. No parser registered for .sh!` — and the run aborts before a single mutant is tried, so **one such entry destroys the score of every other guard in the list.** Measured, not inferred.
|
|
182
|
+
- Therefore a mutation gate that reports nothing-to-mutate, or reports green, on a change whose only guard edits were shell **measured nothing about those guards**. The absence of red is a property of the toolchain, not of the tests.
|
|
183
|
+
|
|
184
|
+
What does measure a shell guard is a **driving test**: execute the script as a subprocess against a payload table and assert the verdict — blocked or allowed — with a control on **both** sides. A refusal-only suite cannot see a guard that refuses everything; an allow-only suite cannot see one that refuses nothing. Tests that merely `bash -n` the script, grep its source, or assert it exists are *source-shape* checks and are not evidence of bite.
|
|
185
|
+
|
|
186
|
+
A shell guard with no test that executes it has **no bite evidence at all**, whatever any gate printed.
|
|
187
|
+
|
|
174
188
|
### Two reasons a real protection reports zero
|
|
175
189
|
|
|
176
190
|
A cardinality of zero has two distinct innocent causes, and they need different fixes. Rule out both before concluding a guard is inert:
|
|
@@ -39,6 +39,7 @@ Each has been observed in real runs; each reported success while asserting nothi
|
|
|
39
39
|
This form is preferred over the alternatives because it is the only one that both survives `sh` and keeps the full output, which is what you need the moment the status says the gate failed. `set -o pipefail` is correct where it exists but is **not POSIX `sh`** — dash has no such option, and this repository runs hooks under `sh`. `${PIPESTATUS[0]}` is **bash-only** (zsh spells it `$pipestatus[1]`, 1-indexed) and is overwritten by the very next command, so it must be read immediately. Never report a gate as green from a status read through `| tail`, `| head`, or `| tee`.
|
|
40
40
|
- **Falsify before reporting.** No clean result is reportable until the check has been shown to fail on a deliberate break. **"Mentally reverting" does not count** — reasoning that the assertion *would* fail is precisely the step that lets a non-functional guard ship, because the author already believes it is load-bearing. Run the break.
|
|
41
41
|
- **Mutation-prove the guard, then read the cardinality.** Neuter the protection **in production code**, run the **whole suite**, count the failures and **read their names**, then revert. **Zero** ⇒ the guard is inert (one pinning a single field shipped with all 50 tests green while the regression walked through). **Many, unrelated** ⇒ over-broad; its next failure will not name the cause. **Exactly one, or several all named for the same regression** ⇒ load-bearing and correctly scoped. A **zero is robust to contention** — load only ever adds failures — so only the non-zero counts need a quiet machine.
|
|
42
|
+
- **The automated mutation gate is language-scoped; the manual probe above is not.** Stryker instruments the JS/TS family and nothing else — there is **no shell parser**, so a guard whose logic lives in `.sh` yields no mutants under **any** `mutate` list. Adding one does not measure it: it aborts the whole run (`No parser registered for .sh!` — measured, not inferred) and takes every other guard's score with it. So a mutation gate that stays grey or green on a shell-guard change **measured nothing about that guard**, and reading it as coverage is the fifth failure mode above wearing a config file. Shell guards are covered by **driving tests only**: execute the script against a payload table and assert the blocked/allowed verdict, with a control on **both** sides — a refusal case *and* an allowed case. A shell guard nothing executes has no bite evidence at all, whatever the gate printed.
|
|
42
43
|
- **Never scope the probe by filename, and never infer absent coverage from a grep.** Suites split at the line cap, so a protection's tests often sit in a sibling file: a filename-scoped probe reported cardinality 0 on a live security fix that a whole-suite run showed failing 4 tests, every one named for it.
|
|
43
44
|
- **Assert that no copy is wrong, not that some copy is right.** An existential assertion over duplicated content is satisfied by the correct copy and cannot see the broken one — rewriting one suite's assertions to universal negatives took the same mutation from **0 to 18** failures. Enumerate the property (*every* marker uses an em-dash), never the one known-bad spelling.
|
|
44
45
|
- **Reconstruct a red state; never narrate one you did not run.** Evidence describing a failing or dirty state must come from a run you actually observed — an evidence file once claimed "working tree clean" while listing two untracked scripts in the same file. To observe a state that no longer exists, check that commit out in a **throwaway detached-HEAD worktree** and run it there. Writing down what the output would have said is fabrication, however confident the reasoning.
|