@codyswann/lisa 2.291.0 → 2.293.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/all/copy-overwrite/scripts/lisa-work-item.mjs +64 -3
  2. package/all/merge/.claude/settings.json +1 -1
  3. package/cdk/merge/.claude/settings.json +1 -1
  4. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.js +16 -13
  6. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  7. package/dist/opencode/plugin-templates/parity-safety-net.sh +340 -35
  8. package/expo/merge/.claude/settings.json +1 -1
  9. package/harper-fabric/merge/.claude/settings.json +1 -1
  10. package/nestjs/merge/.claude/settings.json +1 -1
  11. package/package.json +1 -1
  12. package/phaser/merge/.claude/settings.json +1 -1
  13. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +1 -1
  16. package/plugins/lisa/.codex-plugin/skills/lisa-parity-safety-net-rules/SKILL.md +46 -5
  17. package/plugins/lisa/hooks/parity-safety-net.sh +340 -35
  18. package/plugins/lisa/skills/lisa-implement/SKILL.md +1 -1
  19. package/plugins/lisa/skills/lisa-parity-safety-net-rules/SKILL.md +46 -5
  20. package/plugins/lisa-agy/hooks/parity-safety-net.sh +340 -35
  21. package/plugins/lisa-agy/plugin.json +1 -1
  22. package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +1 -1
  23. package/plugins/lisa-agy/skills/lisa-parity-safety-net-rules/SKILL.md +46 -5
  24. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  27. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-copilot/hooks/parity-safety-net.sh +340 -35
  31. package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +1 -1
  32. package/plugins/lisa-copilot/skills/lisa-parity-safety-net-rules/SKILL.md +46 -5
  33. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cursor/hooks/parity-safety-net.sh +340 -35
  35. package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +1 -1
  36. package/plugins/lisa-cursor/skills/lisa-parity-safety-net-rules/SKILL.md +46 -5
  37. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo-agy/plugin.json +1 -1
  40. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  50. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  55. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  60. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails-agy/plugin.json +1 -1
  65. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  70. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  75. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/src/base/hooks/parity-safety-net.sh +340 -35
  78. package/plugins/src/base/skills/lisa-implement/SKILL.md +1 -1
  79. package/plugins/src/base/skills/lisa-parity-safety-net-rules/SKILL.md +46 -5
  80. package/rails/merge/.claude/settings.json +1 -1
  81. package/scripts/install-claude-plugins.sh +9 -6
  82. package/typescript/merge/.claude/settings.json +1 -1
@@ -1,17 +1,55 @@
1
1
  #!/usr/bin/env bash
2
2
  # PreToolUse hook for Bash: a safety net that blocks destructive shell commands
3
3
  # before they run. Lisa-native reimplementation of the upstream
4
- # `safety-net@cc-marketplace` plugin's PreToolUse Bash-guard (parity work, issue
5
- # #1059). It does NOT port upstream code — it re-expresses the behavior in Lisa's
6
- # hook conventions, modeled on block-no-verify.sh.
4
+ # `safety-net@cc-marketplace` plugin's PreToolUse Bash-guard (parity work,
5
+ # issues #1059 and #1960). It does NOT port upstream code — it re-expresses the
6
+ # behavior in Lisa's hook conventions, modeled on block-no-verify.sh.
7
7
  #
8
8
  # It reads the hook stdin JSON, inspects the proposed Bash command, and EXITS
9
9
  # NON-ZERO (2) to BLOCK when a known-destructive pattern matches:
10
- # - `rm -rf /` (recursive forced delete of a root / home / wildcard path)
11
- # - force-pushing a protected branch (main/master/production/release)
12
- # - `git reset --hard` while the working tree is dirty (would discard work)
13
- # - dropping or truncating a database/schema/table
14
- # Otherwise it exits 0 and the command proceeds.
10
+ # - `rm -rf` of a root / home / wildcard path (quote-aware boundaries, so
11
+ # `bash -c "rm -rf /"` and interpreter one-liners are caught too; the rm
12
+ # guards also match path-prefixed invocations like `/bin/rm` and `./rm`)
13
+ # - `rm -rf` of the cwd (`.`), a `..`-traversal path, a `~/`-anchored path,
14
+ # an absolute path outside the project (with a /tmp, /var/tmp, $TMPDIR
15
+ # allowance), a `$VAR` target other than $TMPDIR, or ANY recursive forced
16
+ # delete while cwd is $HOME
17
+ # - force-pushing a protected branch (main/master/production/prod/release) —
18
+ # feature-branch force-push stays allowed (sanctioned rebase workflow;
19
+ # deliberate divergence from upstream's any-branch block). Every git guard
20
+ # sees through leading git GLOBAL options (`-C <path>`, `-c <k>=<v>`,
21
+ # `--git-dir[=…]`, `--no-pager`, …), so `git -C /path <destructive>` cannot
22
+ # dodge the subcommand anchor
23
+ # - `git reset --hard` / `--merge` while the working tree is dirty. Deliberate
24
+ # divergence: upstream blocks unconditionally; Lisa allows clean-tree resets.
25
+ # Residual risk (documented, accepted): the dirty check runs in the hook's
26
+ # cwd at hook time, so a `cd elsewhere && git reset --hard` evades it.
27
+ # - `git rebase --abort`/`--quit` while the in-progress rebase holds
28
+ # human-made conflict resolutions (AUTO_MERGE discriminator; issue #1956).
29
+ # Clean or untouched rebase state stays abortable; the apply backend and a
30
+ # missing AUTO_MERGE ref fail closed. Deliberate divergence: upstream
31
+ # blocks `rebase --abort` unconditionally, which strands agents mid-rebase.
32
+ # - `git checkout` discards (`--`, `-f/--force`, `--pathspec-from-file`,
33
+ # bare `.` — the bare-`.` block exceeds upstream)
34
+ # - `git switch --discard-changes` / `-f/--force`
35
+ # - `git restore` touching the worktree (allowed only with `--staged` and
36
+ # without `--worktree`)
37
+ # - `git stash drop` / `git stash clear`
38
+ # - `git clean` with force and no dry-run (`-n`/`--dry-run` anywhere wins)
39
+ # - `git branch -D` (or `-d` + `-f` in any spelling)
40
+ # - `git tag -d`, `git reflog delete`, `git worktree remove --force`
41
+ # - `find ... -delete`, `find ... -exec rm -rf`, `xargs ... rm -rf`
42
+ # - disk destroyers: `dd of=/dev/...`, `mkfs ... /dev/...`, `shred`
43
+ # - dropping or truncating a database/schema/table (Lisa-only guard)
44
+ # Otherwise it exits 0 and the command proceeds. Malformed hook input fails
45
+ # CLOSED: any parse error exits 2 (a non-2 exit would be a non-blocking hook
46
+ # error in Claude Code, silently failing open).
47
+ #
48
+ # Known accepted false-positive class: this is a text scan, not a shell engine,
49
+ # so display commands quoting a destructive string (`echo "docs about rm -rf /"`)
50
+ # can match. Upstream exempts those via an engine-only DISPLAY_COMMANDS list a
51
+ # grep hook cannot replicate. Workaround: quote-break the string or use the
52
+ # gh-writer heredoc form, whose payload is stripped before the guards run.
15
53
  #
16
54
  # Operators extend the built-in rules with a project-local rule file — one
17
55
  # extended-regex (ERE) per line, blank lines and `#` comments ignored — managed
@@ -20,6 +58,13 @@
20
58
  # ${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt
21
59
  set -euo pipefail
22
60
 
61
+ # Fail CLOSED on any unexpected error (malformed JSON, missing jq, …): exit 2 so
62
+ # the tool call is denied instead of surfacing a non-blocking hook warning.
63
+ # Deliberately NOT `set -E`: errtrace would propagate this trap into command
64
+ # substitutions (e.g. the heredoc parser call, whose non-zero exit codes are
65
+ # meaningful protocol) and rewrite their status to 2 before it can be read.
66
+ trap 'printf "%s\n" "Blocked by safety-net: hook failed while parsing its input; denying fail-closed." >&2; exit 2' ERR
67
+
23
68
  input="$(cat)"
24
69
 
25
70
  tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
@@ -90,22 +135,154 @@ case "$command_str" in
90
135
  ;;
91
136
  esac
92
137
 
138
+ # matches / matches_cs run an ERE against the guarded command text. matches is
139
+ # case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
140
+ # for guards where flag case is meaningful (`git branch -d` vs `-D`).
141
+ matches() {
142
+ printf '%s' "$command_for_guards" | grep -Eiq -- "$1"
143
+ }
144
+ matches_cs() {
145
+ printf '%s' "$command_for_guards" | grep -Eq -- "$1"
146
+ }
147
+
148
+ # Normalize bash line-continuations (a trailing backslash + newline → space)
149
+ # before segmenting the command. Without this, "git push --force origin
150
+ # \<newline>main" splits into a segment matching --force but not `main`, letting a
151
+ # protected force-push slip past. Uses awk (POSIX) instead of a GNU-only
152
+ # `sed ':a;N;$!ba;…'`, which errors on BSD sed (macOS) and there silently no-ops.
153
+ normalized_command_str="$(printf '%s' "$command_for_guards" \
154
+ | awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
155
+
156
+ # Shared ERE fragments. GIT_TOKENS walks over intermediate argv tokens without
157
+ # crossing a statement separator, so a flag in a LATER statement can never be
158
+ # attributed to an earlier git subcommand.
159
+ readonly GIT_TOKENS='([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+'
160
+ # Git GLOBAL options (`-C <path>`, `-c <k>=<v>`, `--git-dir[=…]`, `--no-pager`,
161
+ # …) legally sit between `git` and its subcommand, so every subcommand guard
162
+ # consumes them — otherwise `git -C /path checkout -- f` dodges the anchor
163
+ # (issue #1960 security review F1). Shape: zero or more dash-led tokens, each
164
+ # optionally followed by ONE non-dash value token, which covers both the
165
+ # `--git-dir=/x` and `--git-dir /x` spellings without naming every option.
166
+ readonly GIT_GLOBAL_OPTS='(-[^;&|[:space:]]+([[:space:]]+[^-;&|[:space:]][^;&|[:space:]]*)?[[:space:]]+)*'
167
+ # Anchor for every git subcommand guard: word-bounded `git`, whitespace, then
168
+ # any run of global options. Callers append the subcommand name directly.
169
+ readonly GIT_CMD='(^|[^[:alnum:]_-])git[[:space:]]+'"$GIT_GLOBAL_OPTS"
170
+ # An rm invocation: bare `rm` or a path whose basename is exactly `rm`
171
+ # (`/bin/rm`, `./rm`) — issue #1960 security review F2. The optional prefix must
172
+ # END IN `/` immediately before `rm`, so charm/confirm/informant/rmdir never
173
+ # match. The preceding-char class still excludes `.`, `/`, and `-` to keep
174
+ # `--rm`-style flags and `foo.rm` names out.
175
+ readonly RM_CMD='(^|[^[:alnum:]_./-])([[:alnum:]_./-]*/)?rm'
176
+ # rm invoked with BOTH a recursive and a force flag — clustered (-rf/-fr, any
177
+ # extra letters) or split, in either order. The split gate pairs ANY recursive
178
+ # form (short cluster containing r, or --recursive) with ANY force form (short
179
+ # cluster containing f, or --force), so mixed spellings like `rm -r --force /`
180
+ # and `rm --recursive -f /` cannot slip between the short/short and long/long
181
+ # alternations (PR #1976 review).
182
+ readonly RM_RF_CLUSTER="$RM_CMD"'([[:space:]]+-[[:alnum:]-]+)*[[:space:]]+(-[[:alnum:]]*r[[:alnum:]]*f|-[[:alnum:]]*f[[:alnum:]]*r)([[:space:]]|$)'
183
+ readonly RM_RF_SPLIT="$RM_CMD"'(([[:space:]][^;&|]*)?[[:space:]](-[[:alnum:]]*r[[:alnum:]]*|--recursive)([[:space:]][^;&|]*)?[[:space:]](-[[:alnum:]]*f[[:alnum:]]*|--force)([[:space:]]|$)|([[:space:]][^;&|]*)?[[:space:]](-[[:alnum:]]*f[[:alnum:]]*|--force)([[:space:]][^;&|]*)?[[:space:]](-[[:alnum:]]*r[[:alnum:]]*|--recursive)([[:space:]]|$))'
184
+
93
185
  # 1. Recursive forced delete (`rm -rf`) of a filesystem root, home, or top-level
94
- # wildcard. Two gates ANDed: the command must invoke `rm` with BOTH a
186
+ # wildcard. Two gates ANDed: the statement must invoke `rm` with BOTH a
95
187
  # recursive and a force flag, AND name a catastrophic target. Splitting the
96
188
  # flag check from the target check keeps each regex legible and testable.
97
- if printf '%s' "$command_for_guards" \
98
- | grep -Eiq '(^|[^[:alnum:]_./-])rm([[:space:]]+-[[:alnum:]-]+)*[[:space:]]+(-[[:alnum:]]*r[[:alnum:]]*f|-[[:alnum:]]*f[[:alnum:]]*r)([[:space:]]|$)' \
99
- || printf '%s' "$command_for_guards" \
100
- | grep -Eiq '(^|[^[:alnum:]_./-])rm[[:space:]].*(-r\b.*[[:space:]]-f\b|-f\b.*[[:space:]]-r\b|--recursive\b.*--force\b|--force\b.*--recursive\b)'; then
101
- if printf '%s' "$command_for_guards" \
102
- | grep -Eq '([[:space:]]|=)(/|/\*|/\.\*?|~|~/\*?|\$HOME\b|\$\{HOME\}|\*)([[:space:]]|/?\*?$)'; then
189
+ # The target boundary classes include quote characters (issue #1960): without
190
+ # them, `bash -c "rm -rf /"` and interpreter one-liners like
191
+ # `python -c "… os.system('rm -rf /')"` slip through because the target is
192
+ # quote-adjacent instead of space-bounded.
193
+ # Both gates run PER STATEMENT inside the shared rm loop below (quality
194
+ # review S1): a harmless `/` in a LATER statement (`rm -rf build && cd /`)
195
+ # is never attributed to the rm in an earlier one.
196
+ qc="'\""
197
+ readonly RM_CATASTROPHIC_TARGET='([[:space:]='"$qc"'])(/|/\*|/\.\*?|~|~/\*?|\$HOME\b|\$\{HOME\}|\*)([[:space:]'"$qc"']|/?\*?['"$qc"']?$)'
198
+
199
+ # 1b. rm target hardening (issue #1960). For every statement that invokes rm
200
+ # with recursive+force flags, additionally block when:
201
+ # - cwd is $HOME (any target is one argument away from wiping home), or
202
+ # - the target is the cwd itself (`.` / `./`), or
203
+ # - the target traverses out via `..`, or
204
+ # - the target is home-anchored (`~/…` — the shell expands it to $HOME at
205
+ # execution time, so it is always outside the project), or
206
+ # - the target is an absolute path outside the project — with an allowance
207
+ # for /tmp, /var/tmp, and $TMPDIR — or
208
+ # - the target is a `$VAR` expansion other than the sanctioned $TMPDIR.
209
+ # Globbing is disabled around the token walk so a literal `*` in the command
210
+ # is never expanded against the hook's own cwd.
211
+ project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
212
+ tmp_dir_allow="${TMPDIR:-/tmp}"
213
+ tmp_dir_allow="${tmp_dir_allow%/}"
214
+ [ -n "$tmp_dir_allow" ] || tmp_dir_allow="/tmp"
215
+ while IFS= read -r rm_stmt; do
216
+ if ! printf '%s' "$rm_stmt" | grep -Eiq -- "$RM_RF_CLUSTER" \
217
+ && ! printf '%s' "$rm_stmt" | grep -Eiq -- "$RM_RF_SPLIT"; then
218
+ continue
219
+ fi
220
+ # Guard 1: catastrophic target named in the SAME statement as the rm -rf.
221
+ if printf '%s' "$rm_stmt" | grep -Eq -- "$RM_CATASTROPHIC_TARGET"; then
103
222
  block "recursive forced delete of a root, home, or wildcard path (rm -rf)"
104
223
  fi
105
- fi
224
+ # Physical-path comparison (pwd -P): on macOS $HOME or the cwd may arrive via
225
+ # a symlink (/var → /private/var), and a string compare would miss the match.
226
+ if [ -n "${HOME:-}" ] \
227
+ && [ "$(pwd -P)" = "$(cd -- "$HOME" 2>/dev/null && pwd -P)" ]; then
228
+ block "recursive forced delete while the working directory is \$HOME (cd into a project first)"
229
+ fi
230
+ set -f
231
+ seen_rm=0
232
+ for raw_token in $rm_stmt; do
233
+ token="${raw_token#\"}"
234
+ token="${token#\'}"
235
+ token="${token%\"}"
236
+ token="${token%\'}"
237
+ if [ "$seen_rm" -eq 0 ]; then
238
+ # Path-prefixed spellings (`/bin/rm`, `./rm`) are still rm (F2).
239
+ case "$token" in
240
+ rm | */rm) seen_rm=1 ;;
241
+ esac
242
+ continue
243
+ fi
244
+ case "$token" in
245
+ -*) continue ;;
246
+ . | ./)
247
+ set +f
248
+ block "recursive forced delete of the current directory (rm -rf .)"
249
+ ;;
250
+ .. | ../* | */.. | */../*)
251
+ set +f
252
+ block "recursive forced delete of a path outside the project (.. traversal)"
253
+ ;;
254
+ '~' | '~'/*)
255
+ set +f
256
+ block "recursive forced delete of a home-anchored path (~/…) outside the project"
257
+ ;;
258
+ /*)
259
+ case "$token" in
260
+ "$project_dir" | "$project_dir"/* | /tmp | /tmp/* | /var/tmp | /var/tmp/* | "$tmp_dir_allow" | "$tmp_dir_allow"/*) : ;;
261
+ *)
262
+ set +f
263
+ block "recursive forced delete of an absolute path outside the project (only the project, /tmp, /var/tmp, and \$TMPDIR are allowed)"
264
+ ;;
265
+ esac
266
+ ;;
267
+ *'$'*)
268
+ case "$token" in
269
+ '$TMPDIR' | '$TMPDIR'/* | '${TMPDIR}' | '${TMPDIR}'/*) : ;;
270
+ *)
271
+ set +f
272
+ block "recursive forced delete of a variable-expanded target (unset or mistyped variables can point anywhere; \$TMPDIR is the only sanctioned dynamic target)"
273
+ ;;
274
+ esac
275
+ ;;
276
+ esac
277
+ done
278
+ set +f
279
+ done < <(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
280
+ | grep -Ei -- "$RM_CMD"'([[:space:]]|$)' || true)
106
281
 
107
282
  # 2. Force-pushing a protected branch. `--force-with-lease` is the safe,
108
- # non-clobbering alternative and is intentionally NOT blocked.
283
+ # non-clobbering alternative and is intentionally NOT blocked. Deliberate
284
+ # divergence from upstream (which blocks force-push on ANY branch):
285
+ # feature-branch force-push is a sanctioned rebase-and-push agent workflow.
109
286
  #
110
287
  # The force flag AND the protected-branch name must appear in the SAME
111
288
  # `git push` statement. Checking them independently over the whole command is
@@ -116,14 +293,6 @@ fi
116
293
  # (`;`, `&&`, `||`, `|`, newlines), keep only the `git push` segments, and
117
294
  # inspect each in isolation — a real `git push --force origin main` still
118
295
  # matches, while a feature-branch push next to `[ -f ]`/`--base main` passes.
119
- # Normalize bash line-continuations (a trailing backslash + newline → space)
120
- # before segmenting the command. Without this, "git push --force origin
121
- # \<newline>main" splits into a segment matching --force but not `main`, letting a
122
- # protected force-push slip past. Uses awk (POSIX) instead of a GNU-only
123
- # `sed ':a;N;$!ba;…'`, which errors on BSD sed (macOS) and there silently no-ops.
124
- normalized_command_str="$(printf '%s' "$command_for_guards" \
125
- | awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
126
-
127
296
  while IFS= read -r push_stmt; do
128
297
  if printf '%s' "$push_stmt" \
129
298
  | grep -Eiq '(--force([[:space:]]|=|$)|[[:space:]]-f([[:space:]]|$))' \
@@ -133,25 +302,161 @@ while IFS= read -r push_stmt; do
133
302
  block "force-pushing a protected branch (use --force-with-lease, or push a feature branch)"
134
303
  fi
135
304
  done < <(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
136
- | grep -Ei '(^|[^[:alnum:]_-])git[[:space:]]+push\b')
305
+ | grep -Ei -- "${GIT_CMD}push\b" || true)
137
306
 
138
- # 3. `git reset --hard` while the working tree has uncommitted changes — this
139
- # silently discards them. Only blocks when the tree is actually dirty, so a
140
- # clean-tree reset (a legitimate workflow) still passes.
141
- if printf '%s' "$command_for_guards" | grep -Eiq '(^|[^[:alnum:]_-])git[[:space:]]+reset\b.*--hard\b'; then
307
+ # 3. `git reset --hard` / `git reset --merge` while the working tree has
308
+ # uncommitted changes — both silently discard them. Only blocks when the tree
309
+ # is actually dirty, so a clean-tree reset (a legitimate workflow) passes.
310
+ # Deliberate divergence from upstream's unconditional block; the accepted
311
+ # residual risk (dirty check runs at hook time in the hook's cwd) is
312
+ # documented in the header.
313
+ if matches "${GIT_CMD}"'reset\b.*--(hard|merge)\b'; then
142
314
  if git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
143
315
  && [ -n "$(git status --porcelain 2>/dev/null)" ]; then
144
- block "git reset --hard on a dirty working tree would discard uncommitted changes (stash or commit first)"
316
+ block "git reset --hard/--merge on a dirty working tree would discard uncommitted changes (stash or commit first)"
145
317
  fi
146
318
  fi
147
319
 
148
- # 4. Dropping or truncating a database / schema / table.
149
- if printf '%s' "$command_for_guards" \
150
- | grep -Eiq '\b(drop[[:space:]]+(database|schema|table)|truncate[[:space:]]+(table[[:space:]]+)?[[:alnum:]_."`]+)\b'; then
320
+ # 3b. `git rebase --abort` / `--quit` ONLY while human/agent conflict
321
+ # resolutions exist (issue #1956). `--abort` restores the pre-rebase branch
322
+ # and discards every resolution; `--quit` deletes the rebase bookkeeping
323
+ # (head-name, todo) while stranding a detached HEAD, making recovery
324
+ # ambiguous — both are treated the same, conditionally. A rebase state with
325
+ # nothing human-made in it (a clean-pick wedge, or a conflict stop nobody
326
+ # has touched) is agent-recoverable, so aborting it stays ALLOWED.
327
+ # Discriminator (empirical, git 2.53): diff the worktree/index against the
328
+ # AUTO_MERGE ref (the merge-ort recorded conflicted tree). Abort-safe ⇔
329
+ # worktree diff is quiet AND (the cached diff is quiet OR unmerged index
330
+ # entries exist — an untouched conflict stop has unmerged entries that make
331
+ # the cached diff non-quiet without any human edit). Fail CLOSED on the
332
+ # rebase-apply backend (no AUTO_MERGE contract) and on a missing AUTO_MERGE
333
+ # ref while rebase-merge state exists. Same accepted residual risk as
334
+ # guard 3: the probes run in the hook's cwd at hook time.
335
+ if matches "${GIT_CMD}"'rebase'"${GIT_TOKENS}"'--(abort|quit)([[:space:]]|$)'; then
336
+ rebase_apply_dir="$(git rev-parse --git-path rebase-apply 2>/dev/null || true)"
337
+ rebase_merge_dir="$(git rev-parse --git-path rebase-merge 2>/dev/null || true)"
338
+ if [ -n "$rebase_apply_dir" ] && [ -d "$rebase_apply_dir" ]; then
339
+ block "git rebase --abort/--quit on an apply-backend rebase cannot prove no conflict resolutions would be lost (fail closed; finish or continue the rebase instead)"
340
+ fi
341
+ if [ -n "$rebase_merge_dir" ] && [ -d "$rebase_merge_dir" ]; then
342
+ if ! git rev-parse -q --verify AUTO_MERGE >/dev/null 2>&1; then
343
+ block "git rebase --abort/--quit with an unresolvable AUTO_MERGE ref cannot prove no conflict resolutions would be lost (fail closed)"
344
+ fi
345
+ if ! git diff --quiet AUTO_MERGE 2>/dev/null; then
346
+ block "git rebase --abort/--quit would discard conflict resolutions in the working tree (finish resolving and run git rebase --continue instead)"
347
+ fi
348
+ unmerged_entries="$(git ls-files -u 2>/dev/null || true)"
349
+ if [ -z "$unmerged_entries" ] \
350
+ && ! git diff --cached --quiet AUTO_MERGE 2>/dev/null; then
351
+ block "git rebase --abort/--quit would discard staged conflict resolutions (finish resolving and run git rebase --continue instead)"
352
+ fi
353
+ fi
354
+ fi
355
+
356
+ # 4. `git checkout` worktree discards: the `--` pathspec form (with or without a
357
+ # ref), `-f/--force`, `--pathspec-from-file`, and bare `git checkout .`.
358
+ # Blocking bare `.` exceeds upstream (which allows any single positional) —
359
+ # issue #1960 names it explicitly: it discards the entire tree. Branch
360
+ # switches and creation (`-b`/`-B`) stay allowed.
361
+ readonly GIT_CHECKOUT="${GIT_CMD}checkout"
362
+ if matches "${GIT_CHECKOUT}${GIT_TOKENS}"'--([[:space:]]|$)' \
363
+ || matches "${GIT_CHECKOUT}${GIT_TOKENS}"'(-[[:alnum:]]*f[[:alnum:]]*|--force)([[:space:]]|=|$)' \
364
+ || matches "${GIT_CHECKOUT}"'[[:space:]][^;&|]*--pathspec-from-file' \
365
+ || matches "${GIT_CHECKOUT}"'[[:space:]]+\.(/)?([[:space:]]|$)'; then
366
+ block "git checkout discarding local changes (--, -f/--force, --pathspec-from-file, or bare .) — use git stash to preserve work first"
367
+ fi
368
+
369
+ # 5. `git switch` discards: `--discard-changes` and its `-f/--force` aliases.
370
+ if matches "${GIT_CMD}switch${GIT_TOKENS}"'(--discard-changes|--force|-f)([[:space:]]|=|$)'; then
371
+ block "git switch discarding local changes (--discard-changes/-f/--force)"
372
+ fi
373
+
374
+ # 6. `git restore` defaults to overwriting the WORKTREE — a silent discard. Only
375
+ # the pure staging-area form is safe: `--staged` present and `--worktree`
376
+ # absent. Two conditions, because an ERE has no lookahead: `--staged
377
+ # --worktree` still discards, so `--worktree` blocks regardless of `--staged`.
378
+ if matches "${GIT_CMD}"'restore([[:space:]]|$)'; then
379
+ if matches "${GIT_CMD}"'restore[^;&|]*--worktree' \
380
+ || ! matches "${GIT_CMD}"'restore[^;&|]*--staged'; then
381
+ block "git restore overwriting worktree files (only 'git restore --staged <path>' without --worktree is allowed)"
382
+ fi
383
+ fi
384
+
385
+ # 7. `git stash drop` / `git stash clear` destroy stashed work. push/pop/list/
386
+ # apply — the safe alternatives the reset guard recommends — stay allowed.
387
+ if matches "${GIT_CMD}"'stash[[:space:]]+(drop|clear)([[:space:]]|$)'; then
388
+ block "git stash drop/clear destroys stashed work"
389
+ fi
390
+
391
+ # 8. `git clean` with a force flag wipes untracked files. A dry-run flag
392
+ # (`-n`/`--dry-run`) ANYWHERE wins — git itself performs no deletion under
393
+ # dry-run, so `git clean -fdn` is a safe preview.
394
+ readonly GIT_CLEAN="${GIT_CMD}clean"
395
+ if matches "${GIT_CLEAN}${GIT_TOKENS}"'(-[[:alnum:]]*f[[:alnum:]]*|--force)([[:space:]]|=|$)' \
396
+ && ! matches "${GIT_CLEAN}${GIT_TOKENS}"'(-[[:alnum:]]*n[[:alnum:]]*|--dry-run)([[:space:]]|=|$)'; then
397
+ block "git clean --force deletes untracked files (preview with git clean -n first)"
398
+ fi
399
+
400
+ # 9. `git branch` force-delete loses unmerged commits: `-D`, `-d` combined with
401
+ # `-f` (clustered or split), or `--delete` + `--force`. Case-SENSITIVE so the
402
+ # safe `-d` (which refuses unmerged work) and rename `-m` stay allowed.
403
+ readonly GIT_BRANCH="${GIT_CMD}branch"
404
+ if matches_cs "${GIT_BRANCH}${GIT_TOKENS}"'-[[:alnum:]]*D[[:alnum:]]*([[:space:]]|$)' \
405
+ || { matches_cs "${GIT_BRANCH}${GIT_TOKENS}"'-[[:alnum:]]*d[[:alnum:]]*([[:space:]]|$)' \
406
+ && matches_cs "${GIT_BRANCH}${GIT_TOKENS}"'-[[:alnum:]]*f[[:alnum:]]*([[:space:]]|$)'; } \
407
+ || { matches "${GIT_BRANCH}"'[^;&|]*--delete' && matches "${GIT_BRANCH}"'[^;&|]*--force'; }; then
408
+ block "git branch force-delete (-D) loses unmerged commits (use -d, which refuses unmerged work)"
409
+ fi
410
+
411
+ # 10. Cheap destructive-ref guards: `git tag -d` (tags are shared refs),
412
+ # `git reflog delete` (erases recovery history), and
413
+ # `git worktree remove --force` (discards a dirty worktree). Tag deletion is
414
+ # case-SENSITIVE so annotated-tag `-a` and message `-m` flags never match.
415
+ if matches_cs "${GIT_CMD}tag${GIT_TOKENS}"'(-[[:alnum:]]*d[[:alnum:]]*([[:space:]]|$)|--delete([[:space:]]|=|$))'; then
416
+ block "git tag -d deletes a shared ref"
417
+ fi
418
+ if matches "${GIT_CMD}"'reflog[[:space:]]+delete([[:space:]]|$)'; then
419
+ block "git reflog delete erases recovery history"
420
+ fi
421
+ if matches "${GIT_CMD}"'worktree[[:space:]]+remove[^;&|]*(--force([[:space:]]|=|$)|[[:space:]]-[[:alnum:]]*f[[:alnum:]]*([[:space:]]|$))'; then
422
+ block "git worktree remove --force discards a dirty worktree (remove without --force, which refuses dirty trees)"
423
+ fi
424
+
425
+ # 11. Deletion through find/xargs, where the target never appears as a literal
426
+ # argument: `find … -delete`, `find … -exec rm -rf`, and `xargs … rm -rf`
427
+ # (targets arrive from dynamic stdin — unauditable). Plain `rm` (no
428
+ # recursive+force) on find/xargs output stays allowed for normal cleanups.
429
+ if matches '(^|[^[:alnum:]_./-])find[[:space:]][^;&|]*[[:space:]]-delete([[:space:]]|$)'; then
430
+ block "find -delete removes files tree-wide (use -print to preview, or an explicit rm on reviewed paths)"
431
+ fi
432
+ if matches '(^|[^[:alnum:]_./-])find[[:space:]][^;&|]*-exec[[:space:]]+rm[[:space:]]+(-[[:alnum:]]*r[[:alnum:]]*f|-[[:alnum:]]*f[[:alnum:]]*r)([[:space:]]|$)'; then
433
+ block "find -exec rm -rf performs a recursive forced delete on unreviewed paths"
434
+ fi
435
+ if matches '(^|[^[:alnum:]_./-])xargs[[:space:]]([^;&|]*[[:space:]])?rm[[:space:]]+(-[[:alnum:]]*r[[:alnum:]]*f|-[[:alnum:]]*f[[:alnum:]]*r)([[:space:]]|$)'; then
436
+ block "xargs rm -rf performs a recursive forced delete on dynamic stdin input"
437
+ fi
438
+
439
+ # 12. Disk destroyers, always on (exceeds upstream, which only scans these
440
+ # inside interpreter one-liners): writing to a raw /dev node via dd,
441
+ # formatting a device with mkfs, and shred's unrecoverable overwrite.
442
+ # dd/mkfs against regular files (imaging) stays allowed.
443
+ if matches '(^|[^[:alnum:]_./-])dd[[:space:]]+([^;&|]*[[:space:]])?of=/dev/'; then
444
+ block "dd writing to a raw device (of=/dev/…) destroys it"
445
+ fi
446
+ if matches '(^|[^[:alnum:]_./-])mkfs(\.[[:alnum:]]+)?[[:space:]][^;&|]*/dev/'; then
447
+ block "mkfs formatting a device erases it"
448
+ fi
449
+ if matches '(^|[^[:alnum:]_./-])shred([[:space:]]|$)'; then
450
+ block "shred overwrites files unrecoverably"
451
+ fi
452
+
453
+ # 13. Dropping or truncating a database / schema / table (Lisa-only guard —
454
+ # upstream has no SQL protection; keep).
455
+ if matches '\b(drop[[:space:]]+(database|schema|table)|truncate[[:space:]]+(table[[:space:]]+)?[[:alnum:]_."`]+)\b'; then
151
456
  block "destructive SQL (DROP/TRUNCATE) detected"
152
457
  fi
153
458
 
154
- # 5. Project-local custom rules. Each non-comment line is an ERE; a match blocks.
459
+ # 14. Project-local custom rules. Each non-comment line is an ERE; a match blocks.
155
460
  rules_file="${SAFETY_NET_RULES_FILE:-${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt}"
156
461
  if [ -f "$rules_file" ]; then
157
462
  while IFS= read -r rule || [ -n "$rule" ]; do
@@ -95,7 +95,7 @@ Using the general-purpose agent in Team Lead session, **determine the base branc
95
95
  - Already on a feature branch with an **open PR** → reuse it. If the PR's base ≠ the resolved base branch, surface the mismatch and re-target only with confirmation — the ticket's environment is the source of truth.
96
96
  - Already on a feature branch with **no open PR** → reuse it; its PR base will be the resolved base branch (do not ask the human — the environment determines it).
97
97
  - On an **environment / default branch** → check out a feature branch named for this plan (with the work-item ref prefix, per the linkage rules below) **from `origin/<base>`**.
98
- - **Rebase the feature branch onto `origin/<base>` and resolve any merge conflicts BEFORE starting work.** If the conflicts cannot be resolved cleanly and safely, create a fix task for the agent team (with the conflicting file list and current merge state) and resolve it before implementation begins — never start work on stale or conflicted code.
98
+ - **Sync the feature branch onto the latest `origin/<base>` and resolve any merge conflicts BEFORE starting work.** Both sync lanes are sanctioned in a bound worktree: **rebase** (`git rebase origin/<base>` — the commit hooks validate mid-rebase picks against the rebase head-name, so a work-item binding never wedges a rebase) and **merge** (`git merge origin/<base>` — push validation exempts commits already reachable from the remote default branch, while branch-authored commits stay strictly validated; the exemption needs the local `origin/HEAD` symref, so if a hand-added remote fails push validation on foreign commits, run `git remote set-head origin -a` first). Prefer rebase for a linear history; use merge when rewriting pushed history is undesirable. If a rebase goes wrong before anyone has resolved conflicts, `git rebase --abort` is a safe, allowed recovery; once conflict resolutions exist, the safety net blocks abort to protect them — finish resolving and `git rebase --continue` instead. If the conflicts cannot be resolved cleanly and safely, create a fix task for the agent team (with the conflicting file list and current merge state) and resolve it before implementation begins — never start work on stale or conflicted code.
99
99
  4. **The PR targets the resolved base branch** — carry it as `target_branch=<base>` into `lisa-git-submit-pr` (Verify flow). `git-submit-pr` already chooses a closing keyword when the base is the production/default branch and a non-closing reference for a non-terminal environment branch. For a bug fixed on a non-integration environment branch, the current flow is not done until the fix is merged and verified there, then forward cherry-picked down to the integration branch via a linked follow-up.
100
100
 
101
101
  Every Implement run now has a tracker work item. Preserve its canonical identifier for development linkage unconditionally:
@@ -53,11 +53,52 @@ RULES_FILE="${SAFETY_NET_RULES_FILE:-${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-
53
53
 
54
54
  ### Built-in guards (always on)
55
55
 
56
- 1. `rm -rf` of a filesystem root, `$HOME`/`~`, or a top-level wildcard.
57
- 2. Force-pushing a protected branch (`main`/`master`/`production`/`release`).
58
- `--force-with-lease` is intentionally allowed.
59
- 3. `git reset --hard` while the working tree is **dirty** (would discard work).
60
- 4. Destructive SQL — `DROP DATABASE/SCHEMA/TABLE`, `TRUNCATE TABLE`.
56
+ 1. `rm -rf` of a filesystem root, `$HOME`/`~`, or a top-level wildcard — with
57
+ quote-aware boundaries, so wrapper/interpreter forms like
58
+ `bash -c "rm -rf /"` and `python -c "… os.system('rm -rf /')"` match too.
59
+ Path-prefixed spellings (`/bin/rm`, `./rm`) count as `rm` for every rm
60
+ guard.
61
+ 2. `rm -rf` target hardening: the cwd itself (`.`/`./`), `..`-traversal paths,
62
+ home-anchored `~/…` paths, absolute paths outside the project (`/tmp`,
63
+ `/var/tmp`, and `$TMPDIR` are allowed), `$VAR` targets other than `$TMPDIR`,
64
+ and **any** recursive forced delete while the working directory is `$HOME`.
65
+ 3. Force-pushing a protected branch
66
+ (`main`/`master`/`production`/`prod`/`release`). `--force-with-lease` is
67
+ intentionally allowed, and so is force-pushing a feature branch (sanctioned
68
+ rebase workflow). Acceptable parity divergence: a refspec force-push
69
+ (`git push origin +main`) is not blocked — upstream 1.0.6 allows it too.
70
+ 4. `git reset --hard` / `git reset --merge` while the working tree is **dirty**
71
+ (would discard work). Clean-tree resets are intentionally allowed.
72
+ 5. `git rebase --abort` / `git rebase --quit` while the in-progress rebase
73
+ holds human-made conflict resolutions (they would be discarded). A clean or
74
+ untouched rebase state stays abortable — an agent-recoverable wedge; the
75
+ apply backend and a missing `AUTO_MERGE` ref fail closed. Deliberate
76
+ divergence: upstream blocks `rebase --abort` unconditionally.
77
+ 6. `git checkout` discards: the `--` pathspec form (with or without a ref),
78
+ `-f`/`--force`, `--pathspec-from-file`, and bare `git checkout .`.
79
+ Branch switching and `-b`/`-B` creation stay allowed.
80
+ 7. `git switch --discard-changes` / `-f`/`--force`.
81
+ 8. `git restore` touching the worktree — only `git restore --staged <path>`
82
+ without `--worktree` is allowed (unstaging is safe).
83
+ 9. `git stash drop` / `git stash clear` (push/pop/list/apply stay allowed).
84
+ 10. `git clean` with a force flag and no dry-run — `-n`/`--dry-run` anywhere
85
+ makes it a safe preview.
86
+ 11. `git branch -D` (or `-d` combined with `-f`, clustered or split);
87
+ safe `-d` and rename `-m` stay allowed.
88
+ 12. `git tag -d`, `git reflog delete`, `git worktree remove --force`.
89
+ 13. Deletion via `find … -delete`, `find … -exec rm -rf`, and `xargs … rm -rf`
90
+ (plain non-recursive `rm` on find/xargs output stays allowed).
91
+ 14. Disk destroyers: `dd of=/dev/…`, `mkfs … /dev/…`, `shred`.
92
+ 15. Destructive SQL — `DROP DATABASE/SCHEMA/TABLE`, `TRUNCATE TABLE`.
93
+
94
+ Every git guard sees through leading git **global options** (`-C <path>`,
95
+ `-c <k>=<v>`, `--git-dir[=…]`, `--no-pager`, …), so `git -C /path <destructive>`
96
+ is screened the same as `git <destructive>`.
97
+
98
+ Malformed hook input fails **closed** (exit 2 denies the command). A text-scan
99
+ hook cannot exempt display commands, so prose like
100
+ `echo "docs about rm -rf /"` can false-positive — quote-break the string or use
101
+ the gh-writer heredoc form (payload is stripped before the guards run).
61
102
 
62
103
  ## View the current rules
63
104