@codyswann/lisa 2.342.0 → 2.342.2
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-floor-collisions.mjs +38 -6
- package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +44 -13
- package/all/create-only/scripts/remote-agent-aws-setup.sh +8 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +8 -7
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +44 -13
- 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 +44 -13
- package/plugins/lisa/hooks/threshold-ratchet.mjs +39 -5
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +44 -13
- 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 +44 -13
- package/plugins/lisa-copilot/hooks/threshold-ratchet.mjs +39 -5
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +44 -13
- package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +39 -5
- 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 +44 -13
- package/plugins/src/base/hooks/threshold-ratchet.mjs +39 -5
- package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +39 -5
- package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +39 -5
|
@@ -172,16 +172,6 @@ case "$command_str" in
|
|
|
172
172
|
;;
|
|
173
173
|
esac
|
|
174
174
|
|
|
175
|
-
# matches / matches_cs run an ERE against the guarded command text. matches is
|
|
176
|
-
# case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
|
|
177
|
-
# for guards where flag case is meaningful (`git branch -d` vs `-D`).
|
|
178
|
-
matches() {
|
|
179
|
-
printf '%s' "$command_for_guards" | grep -Eiq -- "$1"
|
|
180
|
-
}
|
|
181
|
-
matches_cs() {
|
|
182
|
-
printf '%s' "$command_for_guards" | grep -Eq -- "$1"
|
|
183
|
-
}
|
|
184
|
-
|
|
185
175
|
# Normalize bash line-continuations (a trailing backslash + newline → space)
|
|
186
176
|
# before segmenting the command. Without this, "git push --force origin
|
|
187
177
|
# \<newline>main" splits into a segment matching --force but not `main`, letting a
|
|
@@ -190,6 +180,28 @@ matches_cs() {
|
|
|
190
180
|
normalized_command_str="$(printf '%s' "$command_for_guards" \
|
|
191
181
|
| awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
|
|
192
182
|
|
|
183
|
+
# matches / matches_cs run an ERE against the guarded command text. matches is
|
|
184
|
+
# case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
|
|
185
|
+
# for guards where flag case is meaningful (`git branch -d` vs `-D`).
|
|
186
|
+
#
|
|
187
|
+
# Both read the NORMALIZED text. They used to read $command_for_guards, so only
|
|
188
|
+
# the two segment-walking guards (rm, git push) were protected against line
|
|
189
|
+
# continuations and every guard expressed through these helpers could be evaded
|
|
190
|
+
# by breaking the command across lines:
|
|
191
|
+
#
|
|
192
|
+
# git reset --hard \
|
|
193
|
+
# origin/main
|
|
194
|
+
#
|
|
195
|
+
# The patterns match on intermediate whitespace, and an embedded "\<newline>" is
|
|
196
|
+
# not whitespace to grep -E, so the anchor failed to span the break. Defined
|
|
197
|
+
# after the normalization above so the value exists when these are first called.
|
|
198
|
+
matches() {
|
|
199
|
+
printf '%s' "$normalized_command_str" | grep -Eiq -- "$1"
|
|
200
|
+
}
|
|
201
|
+
matches_cs() {
|
|
202
|
+
printf '%s' "$normalized_command_str" | grep -Eq -- "$1"
|
|
203
|
+
}
|
|
204
|
+
|
|
193
205
|
# Shared ERE fragments. GIT_TOKENS walks over intermediate argv tokens without
|
|
194
206
|
# crossing a statement separator, so a flag in a LATER statement can never be
|
|
195
207
|
# attributed to an earlier git subcommand.
|
|
@@ -553,9 +565,28 @@ if [ -f "$rules_file" ]; then
|
|
|
553
565
|
case "$rule" in
|
|
554
566
|
'' | '#'*) continue ;;
|
|
555
567
|
esac
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
568
|
+
# Normalized text, for the same reason every built-in guard uses it: a
|
|
569
|
+
# project rule must not be evadable by breaking the command across lines.
|
|
570
|
+
#
|
|
571
|
+
# `|| rule_status=$?` rather than a bare pipeline: this script runs under
|
|
572
|
+
# `set -e`, and a no-match grep exits 1, which would end the hook right
|
|
573
|
+
# here — before the remaining rules ran, and with an exit status that is
|
|
574
|
+
# not a refusal. The `||` puts the pipeline in a condition context, where
|
|
575
|
+
# a non-zero status is expected rather than fatal.
|
|
576
|
+
rule_status=0
|
|
577
|
+
printf '%s' "$normalized_command_str" | grep -Eiq -- "$rule" || rule_status=$?
|
|
578
|
+
case "$rule_status" in
|
|
579
|
+
0) block "matched a project custom safety rule (${rules_file##*/}): $rule" ;;
|
|
580
|
+
1) ;; # no match
|
|
581
|
+
# grep exits 2 on a malformed ERE. Treated as no-match before, so a typo
|
|
582
|
+
# in a project's rules file silently disabled that rule — a guard the
|
|
583
|
+
# project believed it had. Say so instead; still non-fatal, because one
|
|
584
|
+
# bad line must not take the other rules down with it.
|
|
585
|
+
*)
|
|
586
|
+
printf 'parity-safety-net: invalid regex in %s, rule NOT enforced: %s\n' \
|
|
587
|
+
"$rules_file" "$rule" >&2
|
|
588
|
+
;;
|
|
589
|
+
esac
|
|
559
590
|
done <"$rules_file"
|
|
560
591
|
fi
|
|
561
592
|
|
package/package.json
CHANGED
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
}
|
|
121
121
|
},
|
|
122
122
|
"name": "@codyswann/lisa",
|
|
123
|
-
"version": "2.342.
|
|
123
|
+
"version": "2.342.2",
|
|
124
124
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
125
125
|
"main": "dist/index.js",
|
|
126
126
|
"exports": {
|
|
@@ -172,16 +172,6 @@ case "$command_str" in
|
|
|
172
172
|
;;
|
|
173
173
|
esac
|
|
174
174
|
|
|
175
|
-
# matches / matches_cs run an ERE against the guarded command text. matches is
|
|
176
|
-
# case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
|
|
177
|
-
# for guards where flag case is meaningful (`git branch -d` vs `-D`).
|
|
178
|
-
matches() {
|
|
179
|
-
printf '%s' "$command_for_guards" | grep -Eiq -- "$1"
|
|
180
|
-
}
|
|
181
|
-
matches_cs() {
|
|
182
|
-
printf '%s' "$command_for_guards" | grep -Eq -- "$1"
|
|
183
|
-
}
|
|
184
|
-
|
|
185
175
|
# Normalize bash line-continuations (a trailing backslash + newline → space)
|
|
186
176
|
# before segmenting the command. Without this, "git push --force origin
|
|
187
177
|
# \<newline>main" splits into a segment matching --force but not `main`, letting a
|
|
@@ -190,6 +180,28 @@ matches_cs() {
|
|
|
190
180
|
normalized_command_str="$(printf '%s' "$command_for_guards" \
|
|
191
181
|
| awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
|
|
192
182
|
|
|
183
|
+
# matches / matches_cs run an ERE against the guarded command text. matches is
|
|
184
|
+
# case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
|
|
185
|
+
# for guards where flag case is meaningful (`git branch -d` vs `-D`).
|
|
186
|
+
#
|
|
187
|
+
# Both read the NORMALIZED text. They used to read $command_for_guards, so only
|
|
188
|
+
# the two segment-walking guards (rm, git push) were protected against line
|
|
189
|
+
# continuations and every guard expressed through these helpers could be evaded
|
|
190
|
+
# by breaking the command across lines:
|
|
191
|
+
#
|
|
192
|
+
# git reset --hard \
|
|
193
|
+
# origin/main
|
|
194
|
+
#
|
|
195
|
+
# The patterns match on intermediate whitespace, and an embedded "\<newline>" is
|
|
196
|
+
# not whitespace to grep -E, so the anchor failed to span the break. Defined
|
|
197
|
+
# after the normalization above so the value exists when these are first called.
|
|
198
|
+
matches() {
|
|
199
|
+
printf '%s' "$normalized_command_str" | grep -Eiq -- "$1"
|
|
200
|
+
}
|
|
201
|
+
matches_cs() {
|
|
202
|
+
printf '%s' "$normalized_command_str" | grep -Eq -- "$1"
|
|
203
|
+
}
|
|
204
|
+
|
|
193
205
|
# Shared ERE fragments. GIT_TOKENS walks over intermediate argv tokens without
|
|
194
206
|
# crossing a statement separator, so a flag in a LATER statement can never be
|
|
195
207
|
# attributed to an earlier git subcommand.
|
|
@@ -553,9 +565,28 @@ if [ -f "$rules_file" ]; then
|
|
|
553
565
|
case "$rule" in
|
|
554
566
|
'' | '#'*) continue ;;
|
|
555
567
|
esac
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
568
|
+
# Normalized text, for the same reason every built-in guard uses it: a
|
|
569
|
+
# project rule must not be evadable by breaking the command across lines.
|
|
570
|
+
#
|
|
571
|
+
# `|| rule_status=$?` rather than a bare pipeline: this script runs under
|
|
572
|
+
# `set -e`, and a no-match grep exits 1, which would end the hook right
|
|
573
|
+
# here — before the remaining rules ran, and with an exit status that is
|
|
574
|
+
# not a refusal. The `||` puts the pipeline in a condition context, where
|
|
575
|
+
# a non-zero status is expected rather than fatal.
|
|
576
|
+
rule_status=0
|
|
577
|
+
printf '%s' "$normalized_command_str" | grep -Eiq -- "$rule" || rule_status=$?
|
|
578
|
+
case "$rule_status" in
|
|
579
|
+
0) block "matched a project custom safety rule (${rules_file##*/}): $rule" ;;
|
|
580
|
+
1) ;; # no match
|
|
581
|
+
# grep exits 2 on a malformed ERE. Treated as no-match before, so a typo
|
|
582
|
+
# in a project's rules file silently disabled that rule — a guard the
|
|
583
|
+
# project believed it had. Say so instead; still non-fatal, because one
|
|
584
|
+
# bad line must not take the other rules down with it.
|
|
585
|
+
*)
|
|
586
|
+
printf 'parity-safety-net: invalid regex in %s, rule NOT enforced: %s\n' \
|
|
587
|
+
"$rules_file" "$rule" >&2
|
|
588
|
+
;;
|
|
589
|
+
esac
|
|
559
590
|
done <"$rules_file"
|
|
560
591
|
fi
|
|
561
592
|
|
|
@@ -56,6 +56,15 @@ const GIT = GIT_LOCATIONS.find(candidate => fs.existsSync(candidate)) ?? "git";
|
|
|
56
56
|
|
|
57
57
|
/** Git flag shared by every changed-file listing. */
|
|
58
58
|
const NAME_ONLY = "--name-only";
|
|
59
|
+
/**
|
|
60
|
+
* Suppress rename detection when discovering changed files.
|
|
61
|
+
*
|
|
62
|
+
* With it on, `git diff --name-only` reports a rename as the destination path
|
|
63
|
+
* only. Moving a watched threshold file to an unwatched path therefore lists
|
|
64
|
+
* one name the ratchet does not watch, and the loosening at the old path is
|
|
65
|
+
* never compared. Both sides have to be visible for the gate to mean anything.
|
|
66
|
+
*/
|
|
67
|
+
const NO_RENAMES = "--no-renames";
|
|
59
68
|
|
|
60
69
|
/**
|
|
61
70
|
* Run git, returning stdout or null on any failure.
|
|
@@ -87,7 +96,7 @@ function git(args, cwd) {
|
|
|
87
96
|
*/
|
|
88
97
|
function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
89
98
|
if (mode === "staged") {
|
|
90
|
-
const diff = git(["diff", "--cached", NAME_ONLY], root);
|
|
99
|
+
const diff = git(["diff", "--cached", NAME_ONLY, NO_RENAMES], root);
|
|
91
100
|
if (diff === null) return null;
|
|
92
101
|
return {
|
|
93
102
|
files: diff.split("\n").filter(Boolean),
|
|
@@ -99,7 +108,7 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
|
99
108
|
if (!baseRef) return null;
|
|
100
109
|
const mergeBase = git(["merge-base", baseRef, "HEAD"], root)?.trim();
|
|
101
110
|
if (!mergeBase) return null;
|
|
102
|
-
const diff = git(["diff", NAME_ONLY, mergeBase, "HEAD"], root);
|
|
111
|
+
const diff = git(["diff", NAME_ONLY, NO_RENAMES, mergeBase, "HEAD"], root);
|
|
103
112
|
if (diff === null) return null;
|
|
104
113
|
return {
|
|
105
114
|
files: diff.split("\n").filter(Boolean),
|
|
@@ -107,7 +116,7 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
|
107
116
|
readCurrent: f => git(["show", `HEAD:${f}`], root),
|
|
108
117
|
};
|
|
109
118
|
}
|
|
110
|
-
const diff = git(["diff", NAME_ONLY, "HEAD"], root);
|
|
119
|
+
const diff = git(["diff", NAME_ONLY, NO_RENAMES, "HEAD"], root);
|
|
111
120
|
if (diff === null) return null;
|
|
112
121
|
return {
|
|
113
122
|
files: onlyFiles ?? diff.split("\n").filter(Boolean),
|
|
@@ -122,6 +131,31 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
|
122
131
|
};
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Decide the exit code when the ratchet cannot determine what changed.
|
|
136
|
+
*
|
|
137
|
+
* `staged` and `base` are enforcement gates — pre-commit and CI. A gate that
|
|
138
|
+
* cannot see the change set has not found the change set clean, and returning 0
|
|
139
|
+
* reports exactly that. It fails closed, loudly.
|
|
140
|
+
*
|
|
141
|
+
* `hook` is advisory feedback to an agent mid-edit, fires on every tool call,
|
|
142
|
+
* and blocks nothing downstream. Failing it closed on a transient git hiccup
|
|
143
|
+
* would turn a hint into an outage, so it stays permissive — but still says so.
|
|
144
|
+
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
145
|
+
* @param {string} reason What could not be determined
|
|
146
|
+
* @returns {number} Process exit code
|
|
147
|
+
*/
|
|
148
|
+
function undeterminable(mode, reason) {
|
|
149
|
+
process.stderr.write(
|
|
150
|
+
`threshold-ratchet: ${reason}; the ratchet could not run in ${mode} mode\n`
|
|
151
|
+
);
|
|
152
|
+
if (mode === "hook") return 0;
|
|
153
|
+
process.stderr.write(
|
|
154
|
+
"threshold-ratchet: failing closed — an enforcement gate that cannot read the change set has not verified it\n"
|
|
155
|
+
);
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
125
159
|
/**
|
|
126
160
|
* Run the ratchet for a mode, print the report, and return the exit code.
|
|
127
161
|
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
@@ -131,9 +165,9 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
|
131
165
|
*/
|
|
132
166
|
function run(mode, baseRef, onlyFiles) {
|
|
133
167
|
const root = git(["rev-parse", "--show-toplevel"])?.trim();
|
|
134
|
-
if (!root) return
|
|
168
|
+
if (!root) return undeterminable(mode, "not a git repository");
|
|
135
169
|
const plan = resolvePlan(mode, root, baseRef, onlyFiles);
|
|
136
|
-
if (!plan) return
|
|
170
|
+
if (!plan) return undeterminable(mode, "could not resolve the changed files");
|
|
137
171
|
|
|
138
172
|
const watched = plan.files.filter(f => familyFor(f));
|
|
139
173
|
if (watched.length === 0) return 0;
|
|
@@ -172,16 +172,6 @@ case "$command_str" in
|
|
|
172
172
|
;;
|
|
173
173
|
esac
|
|
174
174
|
|
|
175
|
-
# matches / matches_cs run an ERE against the guarded command text. matches is
|
|
176
|
-
# case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
|
|
177
|
-
# for guards where flag case is meaningful (`git branch -d` vs `-D`).
|
|
178
|
-
matches() {
|
|
179
|
-
printf '%s' "$command_for_guards" | grep -Eiq -- "$1"
|
|
180
|
-
}
|
|
181
|
-
matches_cs() {
|
|
182
|
-
printf '%s' "$command_for_guards" | grep -Eq -- "$1"
|
|
183
|
-
}
|
|
184
|
-
|
|
185
175
|
# Normalize bash line-continuations (a trailing backslash + newline → space)
|
|
186
176
|
# before segmenting the command. Without this, "git push --force origin
|
|
187
177
|
# \<newline>main" splits into a segment matching --force but not `main`, letting a
|
|
@@ -190,6 +180,28 @@ matches_cs() {
|
|
|
190
180
|
normalized_command_str="$(printf '%s' "$command_for_guards" \
|
|
191
181
|
| awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
|
|
192
182
|
|
|
183
|
+
# matches / matches_cs run an ERE against the guarded command text. matches is
|
|
184
|
+
# case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
|
|
185
|
+
# for guards where flag case is meaningful (`git branch -d` vs `-D`).
|
|
186
|
+
#
|
|
187
|
+
# Both read the NORMALIZED text. They used to read $command_for_guards, so only
|
|
188
|
+
# the two segment-walking guards (rm, git push) were protected against line
|
|
189
|
+
# continuations and every guard expressed through these helpers could be evaded
|
|
190
|
+
# by breaking the command across lines:
|
|
191
|
+
#
|
|
192
|
+
# git reset --hard \
|
|
193
|
+
# origin/main
|
|
194
|
+
#
|
|
195
|
+
# The patterns match on intermediate whitespace, and an embedded "\<newline>" is
|
|
196
|
+
# not whitespace to grep -E, so the anchor failed to span the break. Defined
|
|
197
|
+
# after the normalization above so the value exists when these are first called.
|
|
198
|
+
matches() {
|
|
199
|
+
printf '%s' "$normalized_command_str" | grep -Eiq -- "$1"
|
|
200
|
+
}
|
|
201
|
+
matches_cs() {
|
|
202
|
+
printf '%s' "$normalized_command_str" | grep -Eq -- "$1"
|
|
203
|
+
}
|
|
204
|
+
|
|
193
205
|
# Shared ERE fragments. GIT_TOKENS walks over intermediate argv tokens without
|
|
194
206
|
# crossing a statement separator, so a flag in a LATER statement can never be
|
|
195
207
|
# attributed to an earlier git subcommand.
|
|
@@ -553,9 +565,28 @@ if [ -f "$rules_file" ]; then
|
|
|
553
565
|
case "$rule" in
|
|
554
566
|
'' | '#'*) continue ;;
|
|
555
567
|
esac
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
568
|
+
# Normalized text, for the same reason every built-in guard uses it: a
|
|
569
|
+
# project rule must not be evadable by breaking the command across lines.
|
|
570
|
+
#
|
|
571
|
+
# `|| rule_status=$?` rather than a bare pipeline: this script runs under
|
|
572
|
+
# `set -e`, and a no-match grep exits 1, which would end the hook right
|
|
573
|
+
# here — before the remaining rules ran, and with an exit status that is
|
|
574
|
+
# not a refusal. The `||` puts the pipeline in a condition context, where
|
|
575
|
+
# a non-zero status is expected rather than fatal.
|
|
576
|
+
rule_status=0
|
|
577
|
+
printf '%s' "$normalized_command_str" | grep -Eiq -- "$rule" || rule_status=$?
|
|
578
|
+
case "$rule_status" in
|
|
579
|
+
0) block "matched a project custom safety rule (${rules_file##*/}): $rule" ;;
|
|
580
|
+
1) ;; # no match
|
|
581
|
+
# grep exits 2 on a malformed ERE. Treated as no-match before, so a typo
|
|
582
|
+
# in a project's rules file silently disabled that rule — a guard the
|
|
583
|
+
# project believed it had. Say so instead; still non-fatal, because one
|
|
584
|
+
# bad line must not take the other rules down with it.
|
|
585
|
+
*)
|
|
586
|
+
printf 'parity-safety-net: invalid regex in %s, rule NOT enforced: %s\n' \
|
|
587
|
+
"$rules_file" "$rule" >&2
|
|
588
|
+
;;
|
|
589
|
+
esac
|
|
559
590
|
done <"$rules_file"
|
|
560
591
|
fi
|
|
561
592
|
|
|
@@ -172,16 +172,6 @@ case "$command_str" in
|
|
|
172
172
|
;;
|
|
173
173
|
esac
|
|
174
174
|
|
|
175
|
-
# matches / matches_cs run an ERE against the guarded command text. matches is
|
|
176
|
-
# case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
|
|
177
|
-
# for guards where flag case is meaningful (`git branch -d` vs `-D`).
|
|
178
|
-
matches() {
|
|
179
|
-
printf '%s' "$command_for_guards" | grep -Eiq -- "$1"
|
|
180
|
-
}
|
|
181
|
-
matches_cs() {
|
|
182
|
-
printf '%s' "$command_for_guards" | grep -Eq -- "$1"
|
|
183
|
-
}
|
|
184
|
-
|
|
185
175
|
# Normalize bash line-continuations (a trailing backslash + newline → space)
|
|
186
176
|
# before segmenting the command. Without this, "git push --force origin
|
|
187
177
|
# \<newline>main" splits into a segment matching --force but not `main`, letting a
|
|
@@ -190,6 +180,28 @@ matches_cs() {
|
|
|
190
180
|
normalized_command_str="$(printf '%s' "$command_for_guards" \
|
|
191
181
|
| awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
|
|
192
182
|
|
|
183
|
+
# matches / matches_cs run an ERE against the guarded command text. matches is
|
|
184
|
+
# case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
|
|
185
|
+
# for guards where flag case is meaningful (`git branch -d` vs `-D`).
|
|
186
|
+
#
|
|
187
|
+
# Both read the NORMALIZED text. They used to read $command_for_guards, so only
|
|
188
|
+
# the two segment-walking guards (rm, git push) were protected against line
|
|
189
|
+
# continuations and every guard expressed through these helpers could be evaded
|
|
190
|
+
# by breaking the command across lines:
|
|
191
|
+
#
|
|
192
|
+
# git reset --hard \
|
|
193
|
+
# origin/main
|
|
194
|
+
#
|
|
195
|
+
# The patterns match on intermediate whitespace, and an embedded "\<newline>" is
|
|
196
|
+
# not whitespace to grep -E, so the anchor failed to span the break. Defined
|
|
197
|
+
# after the normalization above so the value exists when these are first called.
|
|
198
|
+
matches() {
|
|
199
|
+
printf '%s' "$normalized_command_str" | grep -Eiq -- "$1"
|
|
200
|
+
}
|
|
201
|
+
matches_cs() {
|
|
202
|
+
printf '%s' "$normalized_command_str" | grep -Eq -- "$1"
|
|
203
|
+
}
|
|
204
|
+
|
|
193
205
|
# Shared ERE fragments. GIT_TOKENS walks over intermediate argv tokens without
|
|
194
206
|
# crossing a statement separator, so a flag in a LATER statement can never be
|
|
195
207
|
# attributed to an earlier git subcommand.
|
|
@@ -553,9 +565,28 @@ if [ -f "$rules_file" ]; then
|
|
|
553
565
|
case "$rule" in
|
|
554
566
|
'' | '#'*) continue ;;
|
|
555
567
|
esac
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
568
|
+
# Normalized text, for the same reason every built-in guard uses it: a
|
|
569
|
+
# project rule must not be evadable by breaking the command across lines.
|
|
570
|
+
#
|
|
571
|
+
# `|| rule_status=$?` rather than a bare pipeline: this script runs under
|
|
572
|
+
# `set -e`, and a no-match grep exits 1, which would end the hook right
|
|
573
|
+
# here — before the remaining rules ran, and with an exit status that is
|
|
574
|
+
# not a refusal. The `||` puts the pipeline in a condition context, where
|
|
575
|
+
# a non-zero status is expected rather than fatal.
|
|
576
|
+
rule_status=0
|
|
577
|
+
printf '%s' "$normalized_command_str" | grep -Eiq -- "$rule" || rule_status=$?
|
|
578
|
+
case "$rule_status" in
|
|
579
|
+
0) block "matched a project custom safety rule (${rules_file##*/}): $rule" ;;
|
|
580
|
+
1) ;; # no match
|
|
581
|
+
# grep exits 2 on a malformed ERE. Treated as no-match before, so a typo
|
|
582
|
+
# in a project's rules file silently disabled that rule — a guard the
|
|
583
|
+
# project believed it had. Say so instead; still non-fatal, because one
|
|
584
|
+
# bad line must not take the other rules down with it.
|
|
585
|
+
*)
|
|
586
|
+
printf 'parity-safety-net: invalid regex in %s, rule NOT enforced: %s\n' \
|
|
587
|
+
"$rules_file" "$rule" >&2
|
|
588
|
+
;;
|
|
589
|
+
esac
|
|
559
590
|
done <"$rules_file"
|
|
560
591
|
fi
|
|
561
592
|
|
|
@@ -56,6 +56,15 @@ const GIT = GIT_LOCATIONS.find(candidate => fs.existsSync(candidate)) ?? "git";
|
|
|
56
56
|
|
|
57
57
|
/** Git flag shared by every changed-file listing. */
|
|
58
58
|
const NAME_ONLY = "--name-only";
|
|
59
|
+
/**
|
|
60
|
+
* Suppress rename detection when discovering changed files.
|
|
61
|
+
*
|
|
62
|
+
* With it on, `git diff --name-only` reports a rename as the destination path
|
|
63
|
+
* only. Moving a watched threshold file to an unwatched path therefore lists
|
|
64
|
+
* one name the ratchet does not watch, and the loosening at the old path is
|
|
65
|
+
* never compared. Both sides have to be visible for the gate to mean anything.
|
|
66
|
+
*/
|
|
67
|
+
const NO_RENAMES = "--no-renames";
|
|
59
68
|
|
|
60
69
|
/**
|
|
61
70
|
* Run git, returning stdout or null on any failure.
|
|
@@ -87,7 +96,7 @@ function git(args, cwd) {
|
|
|
87
96
|
*/
|
|
88
97
|
function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
89
98
|
if (mode === "staged") {
|
|
90
|
-
const diff = git(["diff", "--cached", NAME_ONLY], root);
|
|
99
|
+
const diff = git(["diff", "--cached", NAME_ONLY, NO_RENAMES], root);
|
|
91
100
|
if (diff === null) return null;
|
|
92
101
|
return {
|
|
93
102
|
files: diff.split("\n").filter(Boolean),
|
|
@@ -99,7 +108,7 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
|
99
108
|
if (!baseRef) return null;
|
|
100
109
|
const mergeBase = git(["merge-base", baseRef, "HEAD"], root)?.trim();
|
|
101
110
|
if (!mergeBase) return null;
|
|
102
|
-
const diff = git(["diff", NAME_ONLY, mergeBase, "HEAD"], root);
|
|
111
|
+
const diff = git(["diff", NAME_ONLY, NO_RENAMES, mergeBase, "HEAD"], root);
|
|
103
112
|
if (diff === null) return null;
|
|
104
113
|
return {
|
|
105
114
|
files: diff.split("\n").filter(Boolean),
|
|
@@ -107,7 +116,7 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
|
107
116
|
readCurrent: f => git(["show", `HEAD:${f}`], root),
|
|
108
117
|
};
|
|
109
118
|
}
|
|
110
|
-
const diff = git(["diff", NAME_ONLY, "HEAD"], root);
|
|
119
|
+
const diff = git(["diff", NAME_ONLY, NO_RENAMES, "HEAD"], root);
|
|
111
120
|
if (diff === null) return null;
|
|
112
121
|
return {
|
|
113
122
|
files: onlyFiles ?? diff.split("\n").filter(Boolean),
|
|
@@ -122,6 +131,31 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
|
122
131
|
};
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Decide the exit code when the ratchet cannot determine what changed.
|
|
136
|
+
*
|
|
137
|
+
* `staged` and `base` are enforcement gates — pre-commit and CI. A gate that
|
|
138
|
+
* cannot see the change set has not found the change set clean, and returning 0
|
|
139
|
+
* reports exactly that. It fails closed, loudly.
|
|
140
|
+
*
|
|
141
|
+
* `hook` is advisory feedback to an agent mid-edit, fires on every tool call,
|
|
142
|
+
* and blocks nothing downstream. Failing it closed on a transient git hiccup
|
|
143
|
+
* would turn a hint into an outage, so it stays permissive — but still says so.
|
|
144
|
+
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
145
|
+
* @param {string} reason What could not be determined
|
|
146
|
+
* @returns {number} Process exit code
|
|
147
|
+
*/
|
|
148
|
+
function undeterminable(mode, reason) {
|
|
149
|
+
process.stderr.write(
|
|
150
|
+
`threshold-ratchet: ${reason}; the ratchet could not run in ${mode} mode\n`
|
|
151
|
+
);
|
|
152
|
+
if (mode === "hook") return 0;
|
|
153
|
+
process.stderr.write(
|
|
154
|
+
"threshold-ratchet: failing closed — an enforcement gate that cannot read the change set has not verified it\n"
|
|
155
|
+
);
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
125
159
|
/**
|
|
126
160
|
* Run the ratchet for a mode, print the report, and return the exit code.
|
|
127
161
|
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
@@ -131,9 +165,9 @@ function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
|
131
165
|
*/
|
|
132
166
|
function run(mode, baseRef, onlyFiles) {
|
|
133
167
|
const root = git(["rev-parse", "--show-toplevel"])?.trim();
|
|
134
|
-
if (!root) return
|
|
168
|
+
if (!root) return undeterminable(mode, "not a git repository");
|
|
135
169
|
const plan = resolvePlan(mode, root, baseRef, onlyFiles);
|
|
136
|
-
if (!plan) return
|
|
170
|
+
if (!plan) return undeterminable(mode, "could not resolve the changed files");
|
|
137
171
|
|
|
138
172
|
const watched = plan.files.filter(f => familyFor(f));
|
|
139
173
|
if (watched.length === 0) return 0;
|
|
@@ -172,16 +172,6 @@ case "$command_str" in
|
|
|
172
172
|
;;
|
|
173
173
|
esac
|
|
174
174
|
|
|
175
|
-
# matches / matches_cs run an ERE against the guarded command text. matches is
|
|
176
|
-
# case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
|
|
177
|
-
# for guards where flag case is meaningful (`git branch -d` vs `-D`).
|
|
178
|
-
matches() {
|
|
179
|
-
printf '%s' "$command_for_guards" | grep -Eiq -- "$1"
|
|
180
|
-
}
|
|
181
|
-
matches_cs() {
|
|
182
|
-
printf '%s' "$command_for_guards" | grep -Eq -- "$1"
|
|
183
|
-
}
|
|
184
|
-
|
|
185
175
|
# Normalize bash line-continuations (a trailing backslash + newline → space)
|
|
186
176
|
# before segmenting the command. Without this, "git push --force origin
|
|
187
177
|
# \<newline>main" splits into a segment matching --force but not `main`, letting a
|
|
@@ -190,6 +180,28 @@ matches_cs() {
|
|
|
190
180
|
normalized_command_str="$(printf '%s' "$command_for_guards" \
|
|
191
181
|
| awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
|
|
192
182
|
|
|
183
|
+
# matches / matches_cs run an ERE against the guarded command text. matches is
|
|
184
|
+
# case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
|
|
185
|
+
# for guards where flag case is meaningful (`git branch -d` vs `-D`).
|
|
186
|
+
#
|
|
187
|
+
# Both read the NORMALIZED text. They used to read $command_for_guards, so only
|
|
188
|
+
# the two segment-walking guards (rm, git push) were protected against line
|
|
189
|
+
# continuations and every guard expressed through these helpers could be evaded
|
|
190
|
+
# by breaking the command across lines:
|
|
191
|
+
#
|
|
192
|
+
# git reset --hard \
|
|
193
|
+
# origin/main
|
|
194
|
+
#
|
|
195
|
+
# The patterns match on intermediate whitespace, and an embedded "\<newline>" is
|
|
196
|
+
# not whitespace to grep -E, so the anchor failed to span the break. Defined
|
|
197
|
+
# after the normalization above so the value exists when these are first called.
|
|
198
|
+
matches() {
|
|
199
|
+
printf '%s' "$normalized_command_str" | grep -Eiq -- "$1"
|
|
200
|
+
}
|
|
201
|
+
matches_cs() {
|
|
202
|
+
printf '%s' "$normalized_command_str" | grep -Eq -- "$1"
|
|
203
|
+
}
|
|
204
|
+
|
|
193
205
|
# Shared ERE fragments. GIT_TOKENS walks over intermediate argv tokens without
|
|
194
206
|
# crossing a statement separator, so a flag in a LATER statement can never be
|
|
195
207
|
# attributed to an earlier git subcommand.
|
|
@@ -553,9 +565,28 @@ if [ -f "$rules_file" ]; then
|
|
|
553
565
|
case "$rule" in
|
|
554
566
|
'' | '#'*) continue ;;
|
|
555
567
|
esac
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
568
|
+
# Normalized text, for the same reason every built-in guard uses it: a
|
|
569
|
+
# project rule must not be evadable by breaking the command across lines.
|
|
570
|
+
#
|
|
571
|
+
# `|| rule_status=$?` rather than a bare pipeline: this script runs under
|
|
572
|
+
# `set -e`, and a no-match grep exits 1, which would end the hook right
|
|
573
|
+
# here — before the remaining rules ran, and with an exit status that is
|
|
574
|
+
# not a refusal. The `||` puts the pipeline in a condition context, where
|
|
575
|
+
# a non-zero status is expected rather than fatal.
|
|
576
|
+
rule_status=0
|
|
577
|
+
printf '%s' "$normalized_command_str" | grep -Eiq -- "$rule" || rule_status=$?
|
|
578
|
+
case "$rule_status" in
|
|
579
|
+
0) block "matched a project custom safety rule (${rules_file##*/}): $rule" ;;
|
|
580
|
+
1) ;; # no match
|
|
581
|
+
# grep exits 2 on a malformed ERE. Treated as no-match before, so a typo
|
|
582
|
+
# in a project's rules file silently disabled that rule — a guard the
|
|
583
|
+
# project believed it had. Say so instead; still non-fatal, because one
|
|
584
|
+
# bad line must not take the other rules down with it.
|
|
585
|
+
*)
|
|
586
|
+
printf 'parity-safety-net: invalid regex in %s, rule NOT enforced: %s\n' \
|
|
587
|
+
"$rules_file" "$rule" >&2
|
|
588
|
+
;;
|
|
589
|
+
esac
|
|
559
590
|
done <"$rules_file"
|
|
560
591
|
fi
|
|
561
592
|
|