@codyswann/lisa 2.324.0 → 2.324.1

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 (73) hide show
  1. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +71 -0
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +102 -0
  3. package/all/copy-overwrite/scripts/lisa-hooks/block-shell-json-parsing.sh +220 -0
  4. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +562 -0
  5. package/all/merge/.claude/settings.json +19 -4
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +15 -6
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/dist/standards/git-state.d.ts.map +1 -1
  10. package/dist/standards/git-state.js +10 -1
  11. package/dist/standards/git-state.js.map +1 -1
  12. package/package.json +1 -1
  13. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/rules/eager/tracked-work.md +1 -1
  16. package/plugins/lisa/rules/reference/tracked-work.md +1 -1
  17. package/plugins/lisa-agy/plugin.json +1 -1
  18. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  21. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-copilot/rules/eager/tracked-work.md +1 -1
  25. package/plugins/lisa-copilot/rules/reference/tracked-work.md +1 -1
  26. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cursor/rules/tracked-work-reference.mdc +1 -1
  28. package/plugins/lisa-cursor/rules/tracked-work.mdc +1 -1
  29. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo-agy/plugin.json +1 -1
  32. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  52. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails-agy/plugin.json +1 -1
  57. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  62. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  67. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/src/base/rules/eager/tracked-work.md +1 -1
  70. package/plugins/src/base/rules/reference/tracked-work.md +1 -1
  71. package/scripts/build-plugins.sh +32 -0
  72. package/scripts/generate-upstream-evidence-manifest.mjs +50 -11
  73. package/scripts/lisa-enforcement-fallback.sh +16 -2
@@ -0,0 +1,562 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook for Bash: a safety net that blocks destructive shell commands
3
+ # before they run. Lisa-native reimplementation of the upstream
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
+ #
8
+ # It reads the hook stdin JSON, inspects the proposed Bash command, and EXITS
9
+ # NON-ZERO (2) to BLOCK when a known-destructive pattern matches:
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. The same text-scan limit means the rm guard treats
52
+ # a substitution-wrapped catastrophic delete as verdict-neutral (issue #1982): an
53
+ # executable `echo "$(rm -rf /)"` is blocked, but so are inert twins the shell
54
+ # would never expand — a single-quoted `echo '$(rm -rf /)'` or an escaped
55
+ # `echo "\$(rm -rf /)"` — because the scan has no quote-context awareness. That
56
+ # over-block stays inside this accepted class. Workaround: quote-break the string
57
+ # or use the gh-writer heredoc form, whose payload is stripped before the guards
58
+ # run — spelled with a QUOTE-PAIR delimiter (`<<'EOF'`). That exact spelling is
59
+ # what the SAFE path recognises; `<<"EOF"` and `<<\EOF` do not reach it, and
60
+ # since #1993 a `gh … <<\EOF` writer fails closed instead (see parse_marker).
61
+ #
62
+ # Known accepted BYPASS class (the symmetric error direction, issue #1993): the
63
+ # heredoc classifier in parity-safety-net-heredoc.py re-implements bash's
64
+ # here-doc lexing in Python, and any divergence from real bash is a shape it
65
+ # mis-classifies. Five rounds on #1958 and three more on #1993 (R5a swallowed
66
+ # terminator, R5b body-apostrophe quote-state poisoning, R5c backslash-quoted
67
+ # delimiter) established that enumerating those divergences is an open-ended
68
+ # arms race, so the class is CLOSED as accepted rather than chased further. This
69
+ # is survivable because of where a mis-classification lands: ONLY the narrow
70
+ # gh-writer SAFE path strips payload text, and every other classifier exit hands
71
+ # the RAW command to the guards below. A missed here-doc therefore degrades to
72
+ # the content guards; it does not bypass them. Since #1982 taught those guards to
73
+ # see through substitution wrappers, every destructive class this net exists to
74
+ # stop is blocked on the raw text regardless of how the here-doc was read. The
75
+ # net catches ACCIDENTAL catastrophic commands; it is not a wall against an
76
+ # adversary who knows bash's lexer, and it was never the only thing standing
77
+ # between an agent and arbitrary execution.
78
+ #
79
+ # Operators extend the built-in rules with a project-local rule file — one
80
+ # extended-regex (ERE) per line, blank lines and `#` comments ignored — managed
81
+ # by the parity-safety-net-rules skill. Default location (overridable via
82
+ # SAFETY_NET_RULES_FILE):
83
+ # ${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt
84
+ set -euo pipefail
85
+
86
+ # Fail CLOSED on any unexpected error (malformed JSON, missing jq, …): exit 2 so
87
+ # the tool call is denied instead of surfacing a non-blocking hook warning.
88
+ # Deliberately NOT `set -E`: errtrace would propagate this trap into command
89
+ # substitutions (e.g. the heredoc parser call, whose non-zero exit codes are
90
+ # meaningful protocol) and rewrite their status to 2 before it can be read.
91
+ trap 'printf "%s\n" "Blocked by safety-net: hook failed while parsing its input; denying fail-closed." >&2; exit 2' ERR
92
+
93
+ input="$(cat)"
94
+
95
+ tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
96
+ if [ "$tool_name" != "Bash" ]; then
97
+ exit 0
98
+ fi
99
+
100
+ command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"
101
+ if [ -z "$command_str" ]; then
102
+ exit 0
103
+ fi
104
+
105
+ # block() prints the reason to stderr (surfaced to the model) and exits 2 so the
106
+ # Bash tool call is denied. $1 = human-readable reason for the block.
107
+ block() {
108
+ cat >&2 <<EOF
109
+ Blocked by safety-net: $1
110
+
111
+ This command matched a destructive-operation guard. If it is genuinely safe and
112
+ intentional, ask the user to confirm, then run it manually outside the agent, or
113
+ narrow the command so it no longer matches the guard.
114
+ EOF
115
+ exit 2
116
+ }
117
+
118
+ # Heredoc payloads are data only for a deliberately narrow set of GitHub CLI
119
+ # write commands. A companion parser proves that shape before removing payload
120
+ # text from the destructive-command scans below. Unknown executable heredocs
121
+ # remain visible to every built-in and custom rule. Ambiguous or malformed
122
+ # heredocs fail closed instead of guessing which text the shell would execute.
123
+ #
124
+ # block_heredoc() teaches the remediation the moment the wall is hit: a bare
125
+ # denial strands the agent with no path forward (gardener #1789). The remedy
126
+ # depends on the command shape (issue #1958): `git commit -m "$(cat <<EOF …)"`
127
+ # attempts get the commit -F text; every other heredoc denial gets the
128
+ # file-based execution guidance instead — the commit text is misleading there.
129
+ # The git-commit detection inlines the GIT_GLOBAL_OPTS shape (defined later in
130
+ # this file, after the heredoc dispatch runs) so `git -C <path> commit` and
131
+ # `git -c k=v commit` spellings are still recognized.
132
+ block_heredoc() {
133
+ if printf '%s' "$command_str" \
134
+ | grep -Eq -- '(^|[^[:alnum:]_-])git[[:space:]]+(-[^;&|[:space:]]+([[:space:]]+[^-;&|[:space:]][^;&|[:space:]]*)?[[:space:]]+)*commit([^[:alnum:]_-]|$)'; then
135
+ block "$1
136
+ Heredoc commit invocations are blocked (the payload is executable shell).
137
+ Fix: write the commit message to a file and run \`git commit -F <file>\`.
138
+ Every commit must also carry a Co-authored-by trailer for a supported agent
139
+ (Claude/Codex/OpenCode) — the commit-msg hook enforces this."
140
+ fi
141
+ block "$1
142
+ Heredoc payloads are blocked here (the payload is executable shell).
143
+ Fix: write the payload to a file with the Write tool, then execute that file
144
+ directly (for example \`python3 <file>\` or \`bash <file>\`)."
145
+ }
146
+
147
+ command_for_guards="$command_str"
148
+ case "$command_str" in
149
+ *'<<'*)
150
+ hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
151
+ heredoc_parser="$hook_dir/parity-safety-net-heredoc.py"
152
+ if ! command -v python3 >/dev/null 2>&1 || [ ! -r "$heredoc_parser" ]; then
153
+ block_heredoc "cannot safely classify heredoc command because its parser runtime is unavailable"
154
+ fi
155
+ if ! printf '%s\n' "$command_str" | /bin/bash -n >/dev/null 2>&1; then
156
+ block_heredoc "malformed heredoc command failed shell syntax validation"
157
+ fi
158
+
159
+ parser_status=0
160
+ if parser_output="$(printf '%s' "$command_str" | python3 "$heredoc_parser" 2>/dev/null)"; then
161
+ parser_status=0
162
+ else
163
+ parser_status=$?
164
+ fi
165
+
166
+ case "$parser_status" in
167
+ 0) command_for_guards="$parser_output" ;;
168
+ 10) command_for_guards="$command_str" ;;
169
+ 20) block_heredoc "malformed or ambiguous heredoc command cannot be safely classified" ;;
170
+ *) block_heredoc "heredoc parser failed; command was denied fail-closed" ;;
171
+ esac
172
+ ;;
173
+ esac
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
+ # Normalize bash line-continuations (a trailing backslash + newline → space)
186
+ # before segmenting the command. Without this, "git push --force origin
187
+ # \<newline>main" splits into a segment matching --force but not `main`, letting a
188
+ # protected force-push slip past. Uses awk (POSIX) instead of a GNU-only
189
+ # `sed ':a;N;$!ba;…'`, which errors on BSD sed (macOS) and there silently no-ops.
190
+ normalized_command_str="$(printf '%s' "$command_for_guards" \
191
+ | awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
192
+
193
+ # Shared ERE fragments. GIT_TOKENS walks over intermediate argv tokens without
194
+ # crossing a statement separator, so a flag in a LATER statement can never be
195
+ # attributed to an earlier git subcommand.
196
+ readonly GIT_TOKENS='([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+'
197
+ # Git GLOBAL options (`-C <path>`, `-c <k>=<v>`, `--git-dir[=…]`, `--no-pager`,
198
+ # …) legally sit between `git` and its subcommand, so every subcommand guard
199
+ # consumes them — otherwise `git -C /path checkout -- f` dodges the anchor
200
+ # (issue #1960 security review F1). Shape: zero or more dash-led tokens, each
201
+ # optionally followed by ONE non-dash value token, which covers both the
202
+ # `--git-dir=/x` and `--git-dir /x` spellings without naming every option.
203
+ readonly GIT_GLOBAL_OPTS='(-[^;&|[:space:]]+([[:space:]]+[^-;&|[:space:]][^;&|[:space:]]*)?[[:space:]]+)*'
204
+ # Anchor for every git subcommand guard: word-bounded `git`, whitespace, then
205
+ # any run of global options. Callers append the subcommand name directly.
206
+ readonly GIT_CMD='(^|[^[:alnum:]_-])git[[:space:]]+'"$GIT_GLOBAL_OPTS"
207
+ # An rm invocation: bare `rm` or a path whose basename is exactly `rm`
208
+ # (`/bin/rm`, `./rm`) — issue #1960 security review F2. The optional prefix must
209
+ # END IN `/` immediately before `rm`, so charm/confirm/informant/rmdir never
210
+ # match. The preceding-char class still excludes `.`, `/`, and `-` to keep
211
+ # `--rm`-style flags and `foo.rm` names out.
212
+ readonly RM_CMD='(^|[^[:alnum:]_./-])([[:alnum:]_./-]*/)?rm'
213
+ # rm invoked with BOTH a recursive and a force flag — clustered (-rf/-fr, any
214
+ # extra letters) or split, in either order. The split gate pairs ANY recursive
215
+ # form (short cluster containing r, or --recursive) with ANY force form (short
216
+ # cluster containing f, or --force), so mixed spellings like `rm -r --force /`
217
+ # and `rm --recursive -f /` cannot slip between the short/short and long/long
218
+ # alternations (PR #1976 review).
219
+ readonly RM_RF_CLUSTER="$RM_CMD"'([[:space:]]+-[[:alnum:]-]+)*[[:space:]]+(-[[:alnum:]]*r[[:alnum:]]*f|-[[:alnum:]]*f[[:alnum:]]*r)([[:space:]]|$)'
220
+ 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:]]|$))'
221
+
222
+ # 1. Recursive forced delete (`rm -rf`) of a filesystem root, home, or top-level
223
+ # wildcard. Two gates ANDed: the statement must invoke `rm` with BOTH a
224
+ # recursive and a force flag, AND name a catastrophic target. Splitting the
225
+ # flag check from the target check keeps each regex legible and testable.
226
+ # The target boundary classes include quote characters (issue #1960): without
227
+ # them, `bash -c "rm -rf /"` and interpreter one-liners like
228
+ # `python -c "… os.system('rm -rf /')"` slip through because the target is
229
+ # quote-adjacent instead of space-bounded.
230
+ # Both gates run PER STATEMENT inside the shared rm loop below (quality
231
+ # review S1): a harmless `/` in a LATER statement (`rm -rf build && cd /`)
232
+ # is never attributed to the rm in an earlier one.
233
+ qc="'\""
234
+ # Path/home targets also accept the command-substitution closers `)` and backtick
235
+ # as a trailing boundary (issue #1982): in `echo "$(rm -rf /)"` the target `/` is
236
+ # followed by `)`, not a space or quote, so without them an executable
237
+ # substitution-wrapped `rm -rf <root>` slips past this gate. Leading boundary is
238
+ # unchanged — the target is always space-separated from the recursive+force flag.
239
+ tc="'\"\`)"
240
+ # The bare-`*` wildcard deliberately KEEPS the narrow (space/quote/end) boundary:
241
+ # admitting `)` there would misread a shell `case … in *)` default arm as a
242
+ # catastrophic `rm -rf *` (T4b F1). A genuinely substitution-wrapped bare `*`
243
+ # (`echo "$(rm -rf *)"`) is instead caught by the token-walk (guard 1b) below.
244
+ readonly RM_CATASTROPHIC_TARGET='([[:space:]='"$qc"'])((/|/\*|/\.\*?|~|~/\*?|\$HOME\b|\$\{HOME\})([[:space:]'"$tc"']|/?\*?['"$tc"']?$)|\*([[:space:]'"$qc"']|/?\*?['"$qc"']?$))'
245
+
246
+ # 1b. rm target hardening (issue #1960). For every statement that invokes rm
247
+ # with recursive+force flags, additionally block when:
248
+ # - cwd is $HOME (any target is one argument away from wiping home), or
249
+ # - the target is the cwd itself (`.` / `./`), or
250
+ # - the target traverses out via `..`, or
251
+ # - the target is home-anchored (`~/…` — the shell expands it to $HOME at
252
+ # execution time, so it is always outside the project), or
253
+ # - the target is an absolute path outside the project — with an allowance
254
+ # for /tmp, /var/tmp, and $TMPDIR — or
255
+ # - the target is a `$VAR` expansion other than the sanctioned $TMPDIR.
256
+ # Globbing is disabled around the token walk so a literal `*` in the command
257
+ # is never expanded against the hook's own cwd.
258
+ project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
259
+ tmp_dir_allow="${TMPDIR:-/tmp}"
260
+ tmp_dir_allow="${tmp_dir_allow%/}"
261
+ [ -n "$tmp_dir_allow" ] || tmp_dir_allow="/tmp"
262
+ # Peel command-substitution wrappers off a token so an rm nested in `$(…)`,
263
+ # backticks, `<(…)`/`>(…)`, a `"…"`/`'…'` quote, or a leading `\` alias-bypass
264
+ # (`\rm`) is recognized and its target classified as if the token were unwrapped
265
+ # (issue #1982) — substitution-wrapping is verdict-neutral for the rm guard.
266
+ # Leading wrappers (\ $( ` ( <( >( and quotes) and trailing closers () ` and
267
+ # quotes) are stripped in runs, so `"$(\rm` → `rm` and `/etc)"` → `/etc`. `$((`
268
+ # is ARITHMETIC, never command substitution: the LEADING peel stops at it (its
269
+ # case is ordered first), so the inner expression is never exposed as a command
270
+ # or a target. The trailing loop may still trim a closing `))`, but that is
271
+ # harmless — an arithmetic token like `$((10/2))` still keeps its `$` for the
272
+ # variable-target guard and can never be mis-normalized into an rm or a `/`-target.
273
+ strip_subst_wrappers() {
274
+ local t="$1" prev=""
275
+ while [ "$t" != "$prev" ]; do
276
+ prev="$t"
277
+ case "$t" in
278
+ '$(('*) break ;;
279
+ '\'*) t="${t#\\}" ;;
280
+ '$('*) t="${t#'$('}" ;;
281
+ '<('*) t="${t#'<('}" ;;
282
+ '>('*) t="${t#'>('}" ;;
283
+ '"'*) t="${t#'"'}" ;;
284
+ "'"*) t="${t#\'}" ;;
285
+ '`'*) t="${t#'`'}" ;;
286
+ '('*) t="${t#(}" ;;
287
+ esac
288
+ done
289
+ prev=""
290
+ while [ "$t" != "$prev" ]; do
291
+ prev="$t"
292
+ case "$t" in
293
+ *')') t="${t%)}" ;;
294
+ *'`') t="${t%'`'}" ;;
295
+ *'"') t="${t%'"'}" ;;
296
+ *"'") t="${t%\'}" ;;
297
+ esac
298
+ done
299
+ printf '%s' "$t"
300
+ }
301
+ while IFS= read -r rm_stmt; do
302
+ if ! printf '%s' "$rm_stmt" | grep -Eiq -- "$RM_RF_CLUSTER" \
303
+ && ! printf '%s' "$rm_stmt" | grep -Eiq -- "$RM_RF_SPLIT"; then
304
+ continue
305
+ fi
306
+ # Guard 1: catastrophic target named in the SAME statement as the rm -rf.
307
+ if printf '%s' "$rm_stmt" | grep -Eq -- "$RM_CATASTROPHIC_TARGET"; then
308
+ block "recursive forced delete of a root, home, or wildcard path (rm -rf)"
309
+ fi
310
+ # Physical-path comparison (pwd -P): on macOS $HOME or the cwd may arrive via
311
+ # a symlink (/var → /private/var), and a string compare would miss the match.
312
+ if [ -n "${HOME:-}" ] \
313
+ && [ "$(pwd -P)" = "$(cd -- "$HOME" 2>/dev/null && pwd -P)" ]; then
314
+ block "recursive forced delete while the working directory is \$HOME (cd into a project first)"
315
+ fi
316
+ set -f
317
+ seen_rm=0
318
+ for raw_token in $rm_stmt; do
319
+ token="$(strip_subst_wrappers "$raw_token")"
320
+ if [ "$seen_rm" -eq 0 ]; then
321
+ # Path-prefixed spellings (`/bin/rm`, `./rm`) are still rm (F2).
322
+ case "$token" in
323
+ rm | */rm) seen_rm=1 ;;
324
+ esac
325
+ continue
326
+ fi
327
+ case "$token" in
328
+ -*) continue ;;
329
+ . | ./)
330
+ set +f
331
+ block "recursive forced delete of the current directory (rm -rf .)"
332
+ ;;
333
+ .. | ../* | */.. | */../*)
334
+ set +f
335
+ block "recursive forced delete of a path outside the project (.. traversal)"
336
+ ;;
337
+ '~' | '~'/*)
338
+ set +f
339
+ block "recursive forced delete of a home-anchored path (~/…) outside the project"
340
+ ;;
341
+ /*)
342
+ case "$token" in
343
+ "$project_dir" | "$project_dir"/* | /tmp | /tmp/* | /var/tmp | /var/tmp/* | "$tmp_dir_allow" | "$tmp_dir_allow"/*) : ;;
344
+ *)
345
+ set +f
346
+ block "recursive forced delete of an absolute path outside the project (only the project, /tmp, /var/tmp, and \$TMPDIR are allowed)"
347
+ ;;
348
+ esac
349
+ ;;
350
+ '*')
351
+ # Bare top-level wildcard expands to every entry in the cwd. Guard 1a no
352
+ # longer bounds `*)` (T4b F1), so a substitution-wrapped `rm -rf *` is
353
+ # caught here after the trailing-closer strip peels `*)"` down to `*`.
354
+ set +f
355
+ block "recursive forced delete of a top-level wildcard (rm -rf *)"
356
+ ;;
357
+ *'$'*)
358
+ case "$token" in
359
+ '$TMPDIR' | '$TMPDIR'/* | '${TMPDIR}' | '${TMPDIR}'/*) : ;;
360
+ *)
361
+ set +f
362
+ block "recursive forced delete of a variable-expanded target (unset or mistyped variables can point anywhere; \$TMPDIR is the only sanctioned dynamic target)"
363
+ ;;
364
+ esac
365
+ ;;
366
+ esac
367
+ done
368
+ set +f
369
+ done < <(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
370
+ | grep -Ei -- "$RM_CMD"'([[:space:]]|$)' || true)
371
+
372
+ # 2. Force-pushing a protected branch. `--force-with-lease` is the safe,
373
+ # non-clobbering alternative and is intentionally NOT blocked. Deliberate
374
+ # divergence from upstream (which blocks force-push on ANY branch):
375
+ # feature-branch force-push is a sanctioned rebase-and-push agent workflow.
376
+ #
377
+ # The force flag AND the protected-branch name must appear in the SAME
378
+ # `git push` statement. Checking them independently over the whole command is
379
+ # a false-positive magnet: an unrelated `-f` (a `[ -f file ]` test, `rm -f`,
380
+ # `grep -f`, `tail -f`) plus an unrelated protected name (`--base main`,
381
+ # `origin/main`, `git fetch origin main`) alongside any feature-branch
382
+ # `git push` would wrongly block. So split the command into statements
383
+ # (`;`, `&&`, `||`, `|`, newlines), keep only the `git push` segments, and
384
+ # inspect each in isolation — a real `git push --force origin main` still
385
+ # matches, while a feature-branch push next to `[ -f ]`/`--base main` passes.
386
+ while IFS= read -r push_stmt; do
387
+ if printf '%s' "$push_stmt" \
388
+ | grep -Eiq '(--force([[:space:]]|=|$)|[[:space:]]-f([[:space:]]|$))' \
389
+ && ! printf '%s' "$push_stmt" | grep -Eiq -- '--force-with-lease' \
390
+ && printf '%s' "$push_stmt" \
391
+ | grep -Eiq '(^|[^[:alnum:]_/-])(main|master|production|prod|release)([^[:alnum:]_/-]|$)'; then
392
+ block "force-pushing a protected branch (use --force-with-lease, or push a feature branch)"
393
+ fi
394
+ done < <(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
395
+ | grep -Ei -- "${GIT_CMD}push\b" || true)
396
+
397
+ # 3. `git reset --hard` / `git reset --merge` while the working tree has
398
+ # uncommitted changes — both silently discard them. Only blocks when the tree
399
+ # is actually dirty, so a clean-tree reset (a legitimate workflow) passes.
400
+ # Deliberate divergence from upstream's unconditional block; the accepted
401
+ # residual risk (dirty check runs at hook time in the hook's cwd) is
402
+ # documented in the header.
403
+ if matches "${GIT_CMD}"'reset\b.*--(hard|merge)\b'; then
404
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
405
+ && [ -n "$(git status --porcelain 2>/dev/null)" ]; then
406
+ block "git reset --hard/--merge on a dirty working tree would discard uncommitted changes (stash or commit first)"
407
+ fi
408
+ fi
409
+
410
+ # 3b. `git rebase --abort` / `--quit` ONLY while human/agent conflict
411
+ # resolutions exist (issue #1956). `--abort` restores the pre-rebase branch
412
+ # and discards every resolution; `--quit` deletes the rebase bookkeeping
413
+ # (head-name, todo) while stranding a detached HEAD, making recovery
414
+ # ambiguous — both are treated the same, conditionally. A rebase state with
415
+ # nothing human-made in it (a clean-pick wedge, or a conflict stop nobody
416
+ # has touched) is agent-recoverable, so aborting it stays ALLOWED.
417
+ # Discriminator (empirical, git 2.53): diff the worktree/index against the
418
+ # AUTO_MERGE ref (the merge-ort recorded conflicted tree). Abort-safe ⇔
419
+ # worktree diff is quiet AND (the cached diff is quiet OR unmerged index
420
+ # entries exist — an untouched conflict stop has unmerged entries that make
421
+ # the cached diff non-quiet without any human edit). Fail CLOSED on the
422
+ # rebase-apply backend (no AUTO_MERGE contract) and on a missing AUTO_MERGE
423
+ # ref while rebase-merge state exists. Same accepted residual risk as
424
+ # guard 3: the probes run in the hook's cwd at hook time.
425
+ if matches "${GIT_CMD}"'rebase'"${GIT_TOKENS}"'--(abort|quit)([[:space:]]|$)'; then
426
+ rebase_apply_dir="$(git rev-parse --git-path rebase-apply 2>/dev/null || true)"
427
+ rebase_merge_dir="$(git rev-parse --git-path rebase-merge 2>/dev/null || true)"
428
+ if [ -n "$rebase_apply_dir" ] && [ -d "$rebase_apply_dir" ]; then
429
+ 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)"
430
+ fi
431
+ if [ -n "$rebase_merge_dir" ] && [ -d "$rebase_merge_dir" ]; then
432
+ if ! git rev-parse -q --verify AUTO_MERGE >/dev/null 2>&1; then
433
+ block "git rebase --abort/--quit with an unresolvable AUTO_MERGE ref cannot prove no conflict resolutions would be lost (fail closed)"
434
+ fi
435
+ if ! git diff --quiet AUTO_MERGE 2>/dev/null; then
436
+ block "git rebase --abort/--quit would discard conflict resolutions in the working tree (finish resolving and run git rebase --continue instead)"
437
+ fi
438
+ unmerged_entries="$(git ls-files -u 2>/dev/null || true)"
439
+ if [ -z "$unmerged_entries" ] \
440
+ && ! git diff --cached --quiet AUTO_MERGE 2>/dev/null; then
441
+ block "git rebase --abort/--quit would discard staged conflict resolutions (finish resolving and run git rebase --continue instead)"
442
+ fi
443
+ fi
444
+ fi
445
+
446
+ # 4. `git checkout` worktree discards: the `--` pathspec form (with or without a
447
+ # ref), `-f/--force`, `--pathspec-from-file`, and bare `git checkout .`.
448
+ # Blocking bare `.` exceeds upstream (which allows any single positional) —
449
+ # issue #1960 names it explicitly: it discards the entire tree. Branch
450
+ # switches and creation (`-b`/`-B`) stay allowed.
451
+ readonly GIT_CHECKOUT="${GIT_CMD}checkout"
452
+ if matches "${GIT_CHECKOUT}${GIT_TOKENS}"'--([[:space:]]|$)' \
453
+ || matches "${GIT_CHECKOUT}${GIT_TOKENS}"'(-[[:alnum:]]*f[[:alnum:]]*|--force)([[:space:]]|=|$)' \
454
+ || matches "${GIT_CHECKOUT}"'[[:space:]][^;&|]*--pathspec-from-file' \
455
+ || matches "${GIT_CHECKOUT}"'[[:space:]]+\.(/)?([[:space:]]|$)'; then
456
+ block "git checkout discarding local changes (--, -f/--force, --pathspec-from-file, or bare .) — use git stash to preserve work first"
457
+ fi
458
+
459
+ # 5. `git switch` discards: `--discard-changes` and its `-f/--force` aliases.
460
+ if matches "${GIT_CMD}switch${GIT_TOKENS}"'(--discard-changes|--force|-f)([[:space:]]|=|$)'; then
461
+ block "git switch discarding local changes (--discard-changes/-f/--force)"
462
+ fi
463
+
464
+ # 6. `git restore` defaults to overwriting the WORKTREE — a silent discard. Only
465
+ # the pure staging-area form is safe: `--staged` present and `--worktree`
466
+ # absent. Two conditions, because an ERE has no lookahead: `--staged
467
+ # --worktree` still discards, so `--worktree` blocks regardless of `--staged`.
468
+ if matches "${GIT_CMD}"'restore([[:space:]]|$)'; then
469
+ if matches "${GIT_CMD}"'restore[^;&|]*--worktree' \
470
+ || ! matches "${GIT_CMD}"'restore[^;&|]*--staged'; then
471
+ block "git restore overwriting worktree files (only 'git restore --staged <path>' without --worktree is allowed)"
472
+ fi
473
+ fi
474
+
475
+ # 7. `git stash drop` / `git stash clear` destroy stashed work. push/pop/list/
476
+ # apply — the safe alternatives the reset guard recommends — stay allowed.
477
+ if matches "${GIT_CMD}"'stash[[:space:]]+(drop|clear)([[:space:]]|$)'; then
478
+ block "git stash drop/clear destroys stashed work"
479
+ fi
480
+
481
+ # 8. `git clean` with a force flag wipes untracked files. A dry-run flag
482
+ # (`-n`/`--dry-run`) ANYWHERE wins — git itself performs no deletion under
483
+ # dry-run, so `git clean -fdn` is a safe preview.
484
+ readonly GIT_CLEAN="${GIT_CMD}clean"
485
+ if matches "${GIT_CLEAN}${GIT_TOKENS}"'(-[[:alnum:]]*f[[:alnum:]]*|--force)([[:space:]]|=|$)' \
486
+ && ! matches "${GIT_CLEAN}${GIT_TOKENS}"'(-[[:alnum:]]*n[[:alnum:]]*|--dry-run)([[:space:]]|=|$)'; then
487
+ block "git clean --force deletes untracked files (preview with git clean -n first)"
488
+ fi
489
+
490
+ # 9. `git branch` force-delete loses unmerged commits: `-D`, `-d` combined with
491
+ # `-f` (clustered or split), or `--delete` + `--force`. Case-SENSITIVE so the
492
+ # safe `-d` (which refuses unmerged work) and rename `-m` stay allowed.
493
+ readonly GIT_BRANCH="${GIT_CMD}branch"
494
+ if matches_cs "${GIT_BRANCH}${GIT_TOKENS}"'-[[:alnum:]]*D[[:alnum:]]*([[:space:]]|$)' \
495
+ || { matches_cs "${GIT_BRANCH}${GIT_TOKENS}"'-[[:alnum:]]*d[[:alnum:]]*([[:space:]]|$)' \
496
+ && matches_cs "${GIT_BRANCH}${GIT_TOKENS}"'-[[:alnum:]]*f[[:alnum:]]*([[:space:]]|$)'; } \
497
+ || { matches "${GIT_BRANCH}"'[^;&|]*--delete' && matches "${GIT_BRANCH}"'[^;&|]*--force'; }; then
498
+ block "git branch force-delete (-D) loses unmerged commits (use -d, which refuses unmerged work)"
499
+ fi
500
+
501
+ # 10. Cheap destructive-ref guards: `git tag -d` (tags are shared refs),
502
+ # `git reflog delete` (erases recovery history), and
503
+ # `git worktree remove --force` (discards a dirty worktree). Tag deletion is
504
+ # case-SENSITIVE so annotated-tag `-a` and message `-m` flags never match.
505
+ if matches_cs "${GIT_CMD}tag${GIT_TOKENS}"'(-[[:alnum:]]*d[[:alnum:]]*([[:space:]]|$)|--delete([[:space:]]|=|$))'; then
506
+ block "git tag -d deletes a shared ref"
507
+ fi
508
+ if matches "${GIT_CMD}"'reflog[[:space:]]+delete([[:space:]]|$)'; then
509
+ block "git reflog delete erases recovery history"
510
+ fi
511
+ if matches "${GIT_CMD}"'worktree[[:space:]]+remove[^;&|]*(--force([[:space:]]|=|$)|[[:space:]]-[[:alnum:]]*f[[:alnum:]]*([[:space:]]|$))'; then
512
+ block "git worktree remove --force discards a dirty worktree (remove without --force, which refuses dirty trees)"
513
+ fi
514
+
515
+ # 11. Deletion through find/xargs, where the target never appears as a literal
516
+ # argument: `find … -delete`, `find … -exec rm -rf`, and `xargs … rm -rf`
517
+ # (targets arrive from dynamic stdin — unauditable). Plain `rm` (no
518
+ # recursive+force) on find/xargs output stays allowed for normal cleanups.
519
+ if matches '(^|[^[:alnum:]_./-])find[[:space:]][^;&|]*[[:space:]]-delete([[:space:]]|$)'; then
520
+ block "find -delete removes files tree-wide (use -print to preview, or an explicit rm on reviewed paths)"
521
+ fi
522
+ if matches '(^|[^[:alnum:]_./-])find[[:space:]][^;&|]*-exec[[:space:]]+rm[[:space:]]+(-[[:alnum:]]*r[[:alnum:]]*f|-[[:alnum:]]*f[[:alnum:]]*r)([[:space:]]|$)'; then
523
+ block "find -exec rm -rf performs a recursive forced delete on unreviewed paths"
524
+ fi
525
+ if matches '(^|[^[:alnum:]_./-])xargs[[:space:]]([^;&|]*[[:space:]])?rm[[:space:]]+(-[[:alnum:]]*r[[:alnum:]]*f|-[[:alnum:]]*f[[:alnum:]]*r)([[:space:]]|$)'; then
526
+ block "xargs rm -rf performs a recursive forced delete on dynamic stdin input"
527
+ fi
528
+
529
+ # 12. Disk destroyers, always on (exceeds upstream, which only scans these
530
+ # inside interpreter one-liners): writing to a raw /dev node via dd,
531
+ # formatting a device with mkfs, and shred's unrecoverable overwrite.
532
+ # dd/mkfs against regular files (imaging) stays allowed.
533
+ if matches '(^|[^[:alnum:]_./-])dd[[:space:]]+([^;&|]*[[:space:]])?of=/dev/'; then
534
+ block "dd writing to a raw device (of=/dev/…) destroys it"
535
+ fi
536
+ if matches '(^|[^[:alnum:]_./-])mkfs(\.[[:alnum:]]+)?[[:space:]][^;&|]*/dev/'; then
537
+ block "mkfs formatting a device erases it"
538
+ fi
539
+ if matches '(^|[^[:alnum:]_./-])shred([[:space:]]|$)'; then
540
+ block "shred overwrites files unrecoverably"
541
+ fi
542
+
543
+ # 13. Dropping or truncating a database / schema / table (Lisa-only guard —
544
+ # upstream has no SQL protection; keep).
545
+ if matches '\b(drop[[:space:]]+(database|schema|table)|truncate[[:space:]]+(table[[:space:]]+)?[[:alnum:]_."`]+)\b'; then
546
+ block "destructive SQL (DROP/TRUNCATE) detected"
547
+ fi
548
+
549
+ # 14. Project-local custom rules. Each non-comment line is an ERE; a match blocks.
550
+ rules_file="${SAFETY_NET_RULES_FILE:-${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt}"
551
+ if [ -f "$rules_file" ]; then
552
+ while IFS= read -r rule || [ -n "$rule" ]; do
553
+ case "$rule" in
554
+ '' | '#'*) continue ;;
555
+ esac
556
+ if printf '%s' "$command_for_guards" | grep -Eiq -- "$rule"; then
557
+ block "matched a project custom safety rule (${rules_file##*/}): $rule"
558
+ fi
559
+ done <"$rules_file"
560
+ fi
561
+
562
+ exit 0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "attribution": {
3
- "commit": "🤖 Generated with Claude Code\n\nCo-Authored-By: Claude",
4
- "pr": "🤖 Generated with Claude Code"
3
+ "commit": "\ud83e\udd16 Generated with Claude Code\n\nCo-Authored-By: Claude",
4
+ "pr": "\ud83e\udd16 Generated with Claude Code"
5
5
  },
6
6
  "enabledPlugins": {
7
7
  "lisa@lisa": true,
@@ -33,7 +33,22 @@
33
33
  "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
34
34
  },
35
35
  "permissions": {
36
- "deny": ["Read(./.entire/metadata/**)"]
36
+ "deny": [
37
+ "Read(./.entire/metadata/**)"
38
+ ]
37
39
  },
38
- "plansDirectory": "./plans"
40
+ "plansDirectory": "./plans",
41
+ "hooks": {
42
+ "PreToolUse": [
43
+ {
44
+ "matcher": "Bash",
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "command": "bash \"$CLAUDE_PROJECT_DIR\"/scripts/lisa-enforcement-fallback.sh"
49
+ }
50
+ ]
51
+ }
52
+ ]
53
+ }
39
54
  }
@@ -1 +1 @@
1
- {"version":3,"file":"upstream-evidence-manifest.d.ts","sourceRoot":"","sources":["../../src/core/upstream-evidence-manifest.ts"],"names":[],"mappings":"AACA,iFAAiF;AACjF,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAknEpE,CAAC;AAEL,4EAA4E;AAC5E,eAAO,MAAM,yBAAyB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CA4jNjE,CAAC;AAEL,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAk6I/D,CAAC"}
1
+ {"version":3,"file":"upstream-evidence-manifest.d.ts","sourceRoot":"","sources":["../../src/core/upstream-evidence-manifest.ts"],"names":[],"mappings":"AACA,iFAAiF;AACjF,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA0nEpE,CAAC;AAEL,4EAA4E;AAC5E,eAAO,MAAM,yBAAyB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAikNjE,CAAC;AAEL,sFAAsF;AACtF,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAk6I/D,CAAC"}