@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.
- package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +71 -0
- package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +102 -0
- package/all/copy-overwrite/scripts/lisa-hooks/block-shell-json-parsing.sh +220 -0
- package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +562 -0
- package/all/merge/.claude/settings.json +19 -4
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +15 -6
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/standards/git-state.d.ts.map +1 -1
- package/dist/standards/git-state.js +10 -1
- package/dist/standards/git-state.js.map +1 -1
- 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/rules/eager/tracked-work.md +1 -1
- package/plugins/lisa/rules/reference/tracked-work.md +1 -1
- 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/rules/eager/tracked-work.md +1 -1
- package/plugins/lisa-copilot/rules/reference/tracked-work.md +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/tracked-work-reference.mdc +1 -1
- package/plugins/lisa-cursor/rules/tracked-work.mdc +1 -1
- 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/rules/eager/tracked-work.md +1 -1
- package/plugins/src/base/rules/reference/tracked-work.md +1 -1
- package/scripts/build-plugins.sh +32 -0
- package/scripts/generate-upstream-evidence-manifest.mjs +50 -11
- 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": "
|
|
4
|
-
"pr": "
|
|
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": [
|
|
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,
|
|
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"}
|