@bookedsolid/rea 0.33.0 → 0.34.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.
- package/dist/cli/hook.js +21 -0
- package/dist/hooks/_lib/segments.d.ts +102 -0
- package/dist/hooks/_lib/segments.js +290 -0
- package/dist/hooks/dangerous-bash-interceptor/index.d.ts +103 -0
- package/dist/hooks/dangerous-bash-interceptor/index.js +669 -0
- package/dist/hooks/local-review-gate/index.d.ts +145 -0
- package/dist/hooks/local-review-gate/index.js +374 -0
- package/dist/hooks/secret-scanner/index.d.ts +143 -0
- package/dist/hooks/secret-scanner/index.js +404 -0
- package/hooks/dangerous-bash-interceptor.sh +168 -386
- package/hooks/local-review-gate.sh +523 -410
- package/hooks/secret-scanner.sh +210 -200
- package/package.json +1 -1
- package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
- package/templates/local-review-gate.dogfood-staged.sh +573 -0
- package/templates/secret-scanner.dogfood-staged.sh +240 -0
|
@@ -1,414 +1,196 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: dangerous-bash-interceptor.sh
|
|
3
|
-
#
|
|
4
|
-
# Detects destructive shell commands and blocks them (exit 2) or warns (exit 0).
|
|
3
|
+
# 0.34.0+ — Node-binary shim for `rea hook dangerous-bash-interceptor`.
|
|
5
4
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# Pre-0.34.0 the gate's full body lived here as bash (414 LOC, every
|
|
6
|
+
# refusal class H1-H17 + M1 plus their bypass-corpus regressions). The
|
|
7
|
+
# migration to the parser-backed Node binary moves all of that into
|
|
8
|
+
# `src/hooks/dangerous-bash-interceptor/index.ts`. This shim is the
|
|
9
|
+
# Claude Code dispatcher's view of the hook — it forwards stdin to
|
|
10
|
+
# the CLI and exits with whatever the CLI returns.
|
|
8
11
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
12
|
+
# Behavioral contract is preserved byte-for-byte: exit 0 on
|
|
13
|
+
# pass-through / MEDIUM-only advisory, exit 2 on HALT / HIGH rule
|
|
14
|
+
# match / malformed payload (fail-closed).
|
|
11
15
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
16
|
+
# # CLI-resolution trust boundary
|
|
17
|
+
#
|
|
18
|
+
# Mirrors the 0.32.0 final shim shape (round-8 of the codex iteration
|
|
19
|
+
# on the three Phase 1 pilots). The resolved CLI MUST live INSIDE
|
|
20
|
+
# realpath(CLAUDE_PROJECT_DIR) AND have an ancestor `package.json`
|
|
21
|
+
# whose `name` is `@bookedsolid/rea`. Defends against symlink-out and
|
|
22
|
+
# tarball-replacement attacks on the resolved CLI.
|
|
23
|
+
#
|
|
24
|
+
# # Fail-closed posture
|
|
25
|
+
#
|
|
26
|
+
# dangerous-bash-interceptor is the agent-runaway gate — the pre-0.34.0
|
|
27
|
+
# bash body refused destructive commands without any compiled CLI. The
|
|
28
|
+
# early-exit branches (CLI missing, node missing, sandbox failed,
|
|
29
|
+
# version skew) fail closed AFTER the relevance pre-gate passes.
|
|
30
|
+
# Irrelevant Bash calls exit 0 regardless of CLI state.
|
|
31
|
+
#
|
|
32
|
+
# # Relevance pre-gate
|
|
33
|
+
#
|
|
34
|
+
# 0.34.0 round-7 P1 fix: the pre-0.34.0 bash body refused destructive
|
|
35
|
+
# commands without any compiled CLI. The round-0 shim preserved that
|
|
36
|
+
# fail-closed-on-CLI-missing posture for ALL Bash, but that's stricter
|
|
37
|
+
# than the pre-0.34.0 body which only refused commands matching the
|
|
38
|
+
# destructive catalog. On a fresh / unbuilt install (`npx rea init`,
|
|
39
|
+
# pre-`pnpm build` checkout) the shim blocked benign Bash like `ls`,
|
|
40
|
+
# `mkdir`, `pnpm install` — defeating the install path itself.
|
|
41
|
+
#
|
|
42
|
+
# Fix: substring pre-gate over the EXTRACTED command (not raw payload —
|
|
43
|
+
# the local-review-gate round-2 lesson). When CLI is missing AND no
|
|
44
|
+
# destructive-keyword appears in the extracted command, exit 0 (the
|
|
45
|
+
# pre-0.34.0 bash body would have done the same — there's no rule to
|
|
46
|
+
# match). When CLI is missing AND a destructive-keyword DOES appear,
|
|
47
|
+
# preserve the original fail-closed posture (we'd rather refuse than
|
|
48
|
+
# silently allow a destructive command).
|
|
49
|
+
#
|
|
50
|
+
# The keyword list is coarse — it over-triggers (e.g. `git status` hits
|
|
51
|
+
# `git` substring) but that's fine: the CLI does the real evaluation
|
|
52
|
+
# and lets benign forms through. Over-trigger costs one node-spawn;
|
|
53
|
+
# under-trigger is the bypass we MUST avoid. Same posture as the
|
|
54
|
+
# 0.32.0 secret-scanner `gh issue create` substring fix.
|
|
15
55
|
|
|
16
56
|
set -uo pipefail
|
|
17
57
|
|
|
18
|
-
#
|
|
19
|
-
# `any_segment_matches "$CMD" PATTERN` which iterates segments split on
|
|
20
|
-
# &&/||/;/| and runs the pattern with `grep -qiE` against each
|
|
21
|
-
# prefix-stripped segment. Replaces full-command grep that
|
|
22
|
-
# false-positives on heredoc bodies and commit messages mentioning
|
|
23
|
-
# trigger words.
|
|
24
|
-
# shellcheck source=_lib/cmd-segments.sh
|
|
25
|
-
source "$(dirname "$0")/_lib/cmd-segments.sh"
|
|
26
|
-
|
|
27
|
-
# ── 1. Read ALL stdin immediately before doing anything else ──────────────────
|
|
28
|
-
INPUT=$(cat)
|
|
29
|
-
|
|
30
|
-
# ── 2. Dependency check ───────────────────────────────────────────────────────
|
|
31
|
-
if ! command -v jq >/dev/null 2>&1; then
|
|
32
|
-
printf 'REA ERROR: jq is required but not installed.\n' >&2
|
|
33
|
-
printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
|
|
34
|
-
exit 2
|
|
35
|
-
fi
|
|
36
|
-
|
|
37
|
-
# ── 3. HALT check ─────────────────────────────────────────────────────────────
|
|
38
|
-
# 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
|
|
58
|
+
# 1. HALT check.
|
|
39
59
|
# shellcheck source=_lib/halt-check.sh
|
|
40
60
|
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
41
61
|
check_halt
|
|
42
62
|
REA_ROOT=$(rea_root)
|
|
43
63
|
|
|
44
|
-
|
|
45
|
-
CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
64
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
46
65
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
fi
|
|
66
|
+
# 2. Capture stdin once. The CLI consumes it via stdin pipe below.
|
|
67
|
+
INPUT=$(cat)
|
|
50
68
|
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
# 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
|
|
70
|
+
REA_ARGV=()
|
|
71
|
+
RESOLVED_CLI_PATH=""
|
|
72
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
73
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
74
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
75
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
76
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
77
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
# 3b. Relevance pre-gate (round-7 P1). Only used when the CLI is
|
|
81
|
+
# missing — when present, every Bash call goes through the CLI.
|
|
82
|
+
# Extract the command string from the payload, then substring-scan
|
|
83
|
+
# it for destructive-catalog keywords. Mirrors the H1-H17 + M1
|
|
84
|
+
# rule heads.
|
|
85
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
86
|
+
CLI_MISSING_CMD=""
|
|
87
|
+
if command -v jq >/dev/null 2>&1; then
|
|
88
|
+
# Match the CLI's payload schema: tool_input.command. tostring so
|
|
89
|
+
# a non-string value (object/number) doesn't blow up jq.
|
|
90
|
+
CLI_MISSING_CMD=$(printf '%s' "$INPUT" | jq -r '
|
|
91
|
+
(.tool_input.command // "") | tostring
|
|
92
|
+
' 2>/dev/null || true)
|
|
57
93
|
else
|
|
58
|
-
|
|
94
|
+
# jq missing — fall back to scanning the raw payload. Over-trigger
|
|
95
|
+
# by design (the CLI is the source of truth; this is fail-closed
|
|
96
|
+
# only when keywords match). Substring scan still catches the
|
|
97
|
+
# destructive forms in JSON-string-encoded payloads.
|
|
98
|
+
CLI_MISSING_CMD="$INPUT"
|
|
59
99
|
fi
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
cleanup_violations() {
|
|
67
|
-
rm -f "$HIGH_FILE" "$MEDIUM_FILE"
|
|
68
|
-
}
|
|
69
|
-
trap cleanup_violations EXIT
|
|
70
|
-
|
|
71
|
-
add_high() {
|
|
72
|
-
local LABEL="$1"
|
|
73
|
-
local DETAIL="$2"
|
|
74
|
-
shift 2
|
|
75
|
-
printf 'HIGH|%s|%s\n' "$LABEL" "$DETAIL" >> "$HIGH_FILE"
|
|
76
|
-
for ALT in "$@"; do
|
|
77
|
-
printf 'ALT:%s\n' "$ALT" >> "$HIGH_FILE"
|
|
78
|
-
done
|
|
79
|
-
printf 'END_VIOLATION\n' >> "$HIGH_FILE"
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
add_medium() {
|
|
83
|
-
local LABEL="$1"
|
|
84
|
-
local DETAIL="$2"
|
|
85
|
-
shift 2
|
|
86
|
-
printf 'MEDIUM|%s|%s\n' "$LABEL" "$DETAIL" >> "$MEDIUM_FILE"
|
|
87
|
-
for ALT in "$@"; do
|
|
88
|
-
printf 'ALT:%s\n' "$ALT" >> "$MEDIUM_FILE"
|
|
89
|
-
done
|
|
90
|
-
printf 'END_VIOLATION\n' >> "$MEDIUM_FILE"
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
# ── 7. Per-segment evaluation helper ──────────────────────────────────────────
|
|
94
|
-
# (Migrated to `_lib/cmd-segments.sh::any_segment_matches` as of 0.15.0.
|
|
95
|
-
# The previous inline helper was defined here but never called — H3-H17
|
|
96
|
-
# all greped the WHOLE command, which false-positived on heredoc bodies
|
|
97
|
-
# and commit messages mentioning trigger words. Migration: every check
|
|
98
|
-
# now uses `any_segment_matches "$CMD" PATTERN` with the helper sourced
|
|
99
|
-
# at the top of this file.)
|
|
100
|
-
|
|
101
|
-
# ── 8. Smart exclusion flags ──────────────────────────────────────────────────
|
|
102
|
-
CMD_IS_REBASE_SAFE=0
|
|
103
|
-
if any_segment_starts_with "$CMD" 'git[[:space:]]+(rebase)[[:space:]].*(--abort|--continue)'; then
|
|
104
|
-
CMD_IS_REBASE_SAFE=1
|
|
105
|
-
fi
|
|
106
|
-
|
|
107
|
-
CMD_IS_CLEAN_DRY=0
|
|
108
|
-
if any_segment_starts_with "$CMD" 'git[[:space:]]+clean.*([ \t]-n|--dry-run)'; then
|
|
109
|
-
CMD_IS_CLEAN_DRY=1
|
|
110
|
-
fi
|
|
111
|
-
|
|
112
|
-
# ── 9. HIGH severity checks ────────────────────────────────────────────────────
|
|
113
|
-
|
|
114
|
-
# H1: git push --force or -f (per-segment — prevents --force-with-lease poisoning)
|
|
115
|
-
# A segment containing --force-with-lease is excluded; other segments are not.
|
|
116
|
-
# 0.15.0: also catches `git push origin +<branch>` (refspec-prefix force-push
|
|
117
|
-
# shorthand) which the previous version missed.
|
|
118
|
-
_h1_check() {
|
|
119
|
-
local _raw="$1" SEGMENT="$2"
|
|
120
|
-
[[ -z "$SEGMENT" ]] && return 0
|
|
121
|
-
# 0.15.0 codex P1 fix: anchor on `^git push`. Pre-fix the unanchored
|
|
122
|
-
# match meant `echo "git push --force is bad"` triggered H1 even
|
|
123
|
-
# though no actual push was happening (the segment after prefix-strip
|
|
124
|
-
# was `echo "..."`, not `git push`). Anchoring scopes detection to
|
|
125
|
-
# segments whose first token IS git push.
|
|
126
|
-
printf '%s' "$SEGMENT" | grep -qiE '^git[[:space:]]+push([[:space:]]|$)' || return 0
|
|
127
|
-
# Skip segments that use the safe --force-with-lease.
|
|
128
|
-
if printf '%s' "$SEGMENT" | grep -qiE -- '--force-with-lease'; then
|
|
129
|
-
return 0
|
|
130
|
-
fi
|
|
131
|
-
# 0.15.0 codex P1 fix: combined-flag forms (`-fu`, `-uf`, `-Fu`) and
|
|
132
|
-
# long-form `--force=value` were not caught by the previous
|
|
133
|
-
# `-f[[:space:]]` shape. The flag-cluster pattern `-[a-zA-Z]*f[a-zA-Z]*`
|
|
134
|
-
# (followed by space or EOS) mirrors how H11 handles rm flag clusters.
|
|
135
|
-
# The refspec-prefix `+` on a branch name is git's force-push shorthand.
|
|
136
|
-
if printf '%s' "$SEGMENT" | grep -qiE -- '--force([[:space:]]|=|$)' || \
|
|
137
|
-
printf '%s' "$SEGMENT" | grep -qiE -- '(^|[[:space:]])-[a-zA-Z]*f[a-zA-Z]*([[:space:]]|$)' || \
|
|
138
|
-
printf '%s' "$SEGMENT" | grep -qE -- '[[:space:]]\+[A-Za-z0-9_./-]'; then
|
|
139
|
-
add_high \
|
|
140
|
-
"git push --force — force push detected" \
|
|
141
|
-
"Force-pushing rewrites public history and breaks collaborators' local copies." \
|
|
142
|
-
"Alt: Use 'git push --force-with-lease' — blocks if upstream has new commits you haven't pulled."
|
|
143
|
-
fi
|
|
144
|
-
return 0
|
|
145
|
-
}
|
|
146
|
-
for_each_segment "$CMD" _h1_check
|
|
147
|
-
|
|
148
|
-
# H2: git rebase — advisory (MEDIUM)
|
|
149
|
-
if [[ $CMD_IS_REBASE_SAFE -eq 0 ]]; then
|
|
150
|
-
if any_segment_starts_with "$CMD" 'git[[:space:]]+rebase([[:space:]]|$)'; then
|
|
151
|
-
add_medium \
|
|
152
|
-
"git rebase — rewrites commit history (advisory)" \
|
|
153
|
-
"Rebase changes commit SHAs. Safe on local feature branches; dangerous on shared/published branches." \
|
|
154
|
-
"Alt: 'git merge origin/main' preserves history (creates merge commit)." \
|
|
155
|
-
" 'git rebase --abort' to cancel if in progress."
|
|
100
|
+
# If we couldn't extract a command, treat as relevant (fail closed).
|
|
101
|
+
CLI_MISSING_RELEVANT=0
|
|
102
|
+
if [ -z "$CLI_MISSING_CMD" ]; then
|
|
103
|
+
# Empty command (or non-Bash payload). The pre-0.34.0 bash body
|
|
104
|
+
# would have exited 0 here — no command, no rule match.
|
|
105
|
+
exit 0
|
|
156
106
|
fi
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
"git
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"
|
|
172
|
-
"
|
|
173
|
-
"
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
"git clean -f — removes untracked files" \
|
|
181
|
-
"Permanently deletes untracked files from the working tree. Cannot be undone via git." \
|
|
182
|
-
"Alt: 'git clean -n' (dry-run) to preview what would be deleted before committing."
|
|
107
|
+
# Substring scan. Keywords cover every rule head H1-H17 + M1. Coarse
|
|
108
|
+
# by design — we're a safety net, not the source of truth. The CLI
|
|
109
|
+
# does the precise per-rule evaluation when reachable.
|
|
110
|
+
case "$CLI_MISSING_CMD" in
|
|
111
|
+
*"git "*) CLI_MISSING_RELEVANT=1 ;;
|
|
112
|
+
*"git "*) CLI_MISSING_RELEVANT=1 ;; # tab after git
|
|
113
|
+
*"rm "*|*"rm "*) CLI_MISSING_RELEVANT=1 ;;
|
|
114
|
+
*"psql"*|*"pgcli"*) CLI_MISSING_RELEVANT=1 ;;
|
|
115
|
+
*"DROP "*|*"DROP "*) CLI_MISSING_RELEVANT=1 ;;
|
|
116
|
+
*"kill "*|*"kill "*|*"killall"*) CLI_MISSING_RELEVANT=1 ;;
|
|
117
|
+
*"HUSKY="*) CLI_MISSING_RELEVANT=1 ;;
|
|
118
|
+
*"curl"*|*"wget"*) CLI_MISSING_RELEVANT=1 ;;
|
|
119
|
+
*"REA_BYPASS"*) CLI_MISSING_RELEVANT=1 ;;
|
|
120
|
+
*"alias "*|*"function "*) CLI_MISSING_RELEVANT=1 ;;
|
|
121
|
+
*"core.hooksPath"*|*"core.hookspath"*) CLI_MISSING_RELEVANT=1 ;;
|
|
122
|
+
*"npm "*|*"pnpm "*|*"yarn "*) CLI_MISSING_RELEVANT=1 ;;
|
|
123
|
+
*"--no-verify"*|*"--force"*) CLI_MISSING_RELEVANT=1 ;;
|
|
124
|
+
esac
|
|
125
|
+
if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
|
|
126
|
+
# No destructive-keyword in the extracted command. The pre-0.34.0
|
|
127
|
+
# bash body would have allowed this — exit 0 to preserve install-
|
|
128
|
+
# path / unbuilt-checkout workflows.
|
|
129
|
+
exit 0
|
|
183
130
|
fi
|
|
131
|
+
# Keyword matched. Preserve fail-closed posture — the pre-0.34.0
|
|
132
|
+
# bash body would have evaluated this command and potentially refused.
|
|
133
|
+
printf 'rea: dangerous-bash-interceptor cannot run — the rea CLI is not built.\n' >&2
|
|
134
|
+
printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
|
|
135
|
+
printf 'This shim fails closed because the pre-0.34.0 bash body enforced destructive-command refusal without a CLI.\n' >&2
|
|
136
|
+
exit 2
|
|
184
137
|
fi
|
|
185
138
|
|
|
186
|
-
#
|
|
187
|
-
if
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
"Alt: Use your project's migration tool. Never run DROP via ad-hoc psql."
|
|
192
|
-
fi
|
|
193
|
-
|
|
194
|
-
# H7: kill -9 with pgrep subshell
|
|
195
|
-
if any_segment_starts_with "$CMD" 'kill[[:space:]]+-9[[:space:]]+(\$\(|`)'; then
|
|
196
|
-
add_high \
|
|
197
|
-
"kill -9 with pgrep subshell — aggressive process termination" \
|
|
198
|
-
"Sends SIGKILL to processes matched by name, which may kill unintended processes." \
|
|
199
|
-
"Alt: 'kill -15 <pid>' (SIGTERM) for graceful shutdown."
|
|
200
|
-
fi
|
|
201
|
-
|
|
202
|
-
# H8: killall -9
|
|
203
|
-
if any_segment_starts_with "$CMD" 'killall[[:space:]]+-9[[:space:]]+\S'; then
|
|
204
|
-
add_high \
|
|
205
|
-
"killall -9 — SIGKILL all matching processes" \
|
|
206
|
-
"Immediately terminates all processes with the given name without cleanup." \
|
|
207
|
-
"Alt: 'killall -15 <name>' (SIGTERM) allows graceful shutdown."
|
|
208
|
-
fi
|
|
209
|
-
|
|
210
|
-
# H9: git commit --no-verify
|
|
211
|
-
if any_segment_starts_with "$CMD" 'git[[:space:]]+commit.*--no-verify'; then
|
|
212
|
-
add_high \
|
|
213
|
-
"git commit --no-verify — skipping pre-commit hooks" \
|
|
214
|
-
"Bypasses all pre-commit safety gates including secret scanning and linting." \
|
|
215
|
-
"Alt: Fix the underlying hook failure rather than bypassing it."
|
|
216
|
-
fi
|
|
217
|
-
|
|
218
|
-
# H10: HUSKY=0 bypass — suppresses all git hooks without --no-verify
|
|
219
|
-
if any_segment_raw_matches "$CMD" '^HUSKY=0[[:space:]]+git[[:space:]]+(commit|push|tag)'; then
|
|
220
|
-
add_high \
|
|
221
|
-
"HUSKY=0 — bypasses all husky git hooks" \
|
|
222
|
-
"Setting HUSKY=0 disables pre-commit, commit-msg, and pre-push safety gates without --no-verify." \
|
|
223
|
-
"Alt: Fix the underlying hook failure rather than suppressing all hooks."
|
|
224
|
-
fi
|
|
225
|
-
|
|
226
|
-
# H11: rm -rf with broad targets
|
|
227
|
-
# Covers combined flags (rm -rf, rm -fr), split flags (rm -r -f), and long flags (rm --recursive --force)
|
|
228
|
-
# 0.15.0 fix: anchored each target on word boundary (whitespace-or-EOS).
|
|
229
|
-
# The previous form had a bare `\.` which matched `rm -rf .git/foo`
|
|
230
|
-
# (legitimate `.git/`-tree cleanup). Each token now requires either
|
|
231
|
-
# end-of-string or whitespace after — so `.` alone matches `rm -rf .`
|
|
232
|
-
# (the cwd, dangerous) but NOT `rm -rf .git/foo`.
|
|
233
|
-
BROAD_TARGETS='(\/|~\/|\.\/\*|\*|\.|src|dist|build|node_modules)([[:space:]]|$)'
|
|
234
|
-
if any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[[:space:]]+${BROAD_TARGETS}" || \
|
|
235
|
-
any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*f[a-zA-Z]*r[[:space:]]+${BROAD_TARGETS}" || \
|
|
236
|
-
any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*r[[:space:]]+-[a-zA-Z]*f[[:space:]]+${BROAD_TARGETS}" || \
|
|
237
|
-
any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*f[[:space:]]+-[a-zA-Z]*r[[:space:]]+${BROAD_TARGETS}" || \
|
|
238
|
-
any_segment_starts_with "$CMD" "rm[[:space:]]+--recursive[[:space:]]+--force[[:space:]]+${BROAD_TARGETS}" || \
|
|
239
|
-
any_segment_starts_with "$CMD" "rm[[:space:]]+--force[[:space:]]+--recursive[[:space:]]+${BROAD_TARGETS}"; then
|
|
240
|
-
add_high \
|
|
241
|
-
"rm -rf with broad target — mass file deletion" \
|
|
242
|
-
"Permanently deletes files and directories. Cannot be undone." \
|
|
243
|
-
"Alt: Move to a temp location first, or use 'rm -ri' for interactive deletion."
|
|
244
|
-
fi
|
|
245
|
-
|
|
246
|
-
# H12: curl/wget piped directly to shell (supply chain attack vector).
|
|
247
|
-
# 0.16.1 helix-016 P1 fix: this check requires BOTH the curl/wget call
|
|
248
|
-
# AND the `| sh` to appear in the same shell pipeline. Pipe-RCE is
|
|
249
|
-
# fundamentally a multi-segment property — splitting on `|` would
|
|
250
|
-
# decompose `curl https://x | sh` into two unrelated segments — so the
|
|
251
|
-
# detection must run against the un-split command.
|
|
252
|
-
#
|
|
253
|
-
# 0.16.3 helix-016.1 #2 sibling fix: pre-fix the check ran against the
|
|
254
|
-
# raw `$CMD`, which false-positived on `git commit -m "...curl|sh..."`
|
|
255
|
-
# (literal pipe inside the commit-message body). The fix is to scan
|
|
256
|
-
# the QUOTE-MASKED form of the command — same un-split shape, but
|
|
257
|
-
# in-quote pipes are replaced with a sentinel that the regex doesn't
|
|
258
|
-
# match. Real curl-pipe-shell still matches because the pipe between
|
|
259
|
-
# `curl https://x` and `sh` is outside any quote span.
|
|
260
|
-
# 0.17.0 helix-017 #1 fix: also scan inner payloads of nested-shell
|
|
261
|
-
# wrappers (`zsh -c "curl https://x | sh"`). The unwrap helper emits
|
|
262
|
-
# the original command + each inner payload as separate lines; we
|
|
263
|
-
# quote-mask each line independently and grep. If ANY emitted line
|
|
264
|
-
# contains a real curl-pipe-shell, fire H12.
|
|
265
|
-
H12_HIT=0
|
|
266
|
-
while IFS= read -r _h12_line; do
|
|
267
|
-
[ -z "$_h12_line" ] && continue
|
|
268
|
-
_h12_masked=$(quote_masked_cmd "$_h12_line")
|
|
269
|
-
if printf '%s' "$_h12_masked" | grep -qiE '(curl|wget)[^|]*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh|zsh|fish)'; then
|
|
270
|
-
H12_HIT=1
|
|
271
|
-
break
|
|
272
|
-
fi
|
|
273
|
-
done < <(_rea_unwrap_nested_shells "$CMD")
|
|
274
|
-
if [ "$H12_HIT" = "1" ]; then
|
|
275
|
-
add_high \
|
|
276
|
-
"curl/wget piped to shell — remote code execution" \
|
|
277
|
-
"Executing remote scripts without inspection is a major supply chain risk." \
|
|
278
|
-
"Alt: Download first, inspect the script, then execute: curl -o script.sh URL && cat script.sh && bash script.sh"
|
|
279
|
-
fi
|
|
280
|
-
|
|
281
|
-
# H13: git push --no-verify — bypasses pre-push hooks
|
|
282
|
-
if any_segment_starts_with "$CMD" 'git[[:space:]]+push.*--no-verify'; then
|
|
283
|
-
add_high \
|
|
284
|
-
"git push --no-verify — skipping pre-push hooks" \
|
|
285
|
-
"Bypasses all pre-push safety gates including CI checks." \
|
|
286
|
-
"Alt: Fix the underlying hook failure rather than bypassing it."
|
|
287
|
-
fi
|
|
288
|
-
|
|
289
|
-
# H14: git -c core.hooksPath= — redirects or disables hook execution
|
|
290
|
-
if any_segment_starts_with "$CMD" 'git[[:space:]]+-c[[:space:]]+core\.hookspath'; then
|
|
291
|
-
add_high \
|
|
292
|
-
"git -c core.hooksPath — overriding hooks directory" \
|
|
293
|
-
"Redirecting the hooks path can disable all safety hooks." \
|
|
294
|
-
"Alt: Fix the underlying hook issue. Do not bypass the hooks directory."
|
|
295
|
-
fi
|
|
296
|
-
|
|
297
|
-
# H15: REA_BYPASS env var — attempted escape hatch
|
|
298
|
-
if any_segment_raw_matches "$CMD" '^REA_BYPASS[[:space:]]*='; then
|
|
299
|
-
add_high \
|
|
300
|
-
"REA_BYPASS env var — unauthorized bypass attempt" \
|
|
301
|
-
"Setting REA_BYPASS is not a supported escape mechanism and indicates a bypass attempt." \
|
|
302
|
-
"Alt: If you need to override a gate, request human escalation."
|
|
303
|
-
fi
|
|
304
|
-
|
|
305
|
-
# H16: alias/function definitions containing bypass strings
|
|
306
|
-
if any_segment_raw_matches "$CMD" '^(alias|function)[[:space:]]+[a-zA-Z_]+.*(--(no-verify|force)|HUSKY=0|core\.hookspath)'; then
|
|
307
|
-
add_high \
|
|
308
|
-
"Alias/function definition with bypass — circumventing safety gates" \
|
|
309
|
-
"Defining aliases or functions that embed bypass flags defeats safety hooks." \
|
|
310
|
-
"Alt: Do not wrap bypass patterns in aliases or functions."
|
|
311
|
-
fi
|
|
312
|
-
|
|
313
|
-
# H17: context_protection — block commands that should be delegated to subagents.
|
|
314
|
-
# Reads context_protection.delegate_to_subagent from .rea/policy.yaml.
|
|
315
|
-
# These commands produce excessive output that exhausts coordinator context windows.
|
|
316
|
-
#
|
|
317
|
-
# 0.16.0 fix J.2: replaced the inline YAML parser (40+ lines reimplementing
|
|
318
|
-
# block-sequence walking) with `policy_list` from `_lib/policy-read.sh`.
|
|
319
|
-
# Same parser shape as every other rea hook now reads policy via the shared
|
|
320
|
-
# helper; drift between hooks is structurally impossible.
|
|
321
|
-
# shellcheck source=_lib/policy-read.sh
|
|
322
|
-
source "$(dirname "$0")/_lib/policy-read.sh"
|
|
323
|
-
|
|
324
|
-
DELEGATE_PATTERNS=()
|
|
325
|
-
while IFS= read -r pattern; do
|
|
326
|
-
[[ -z "$pattern" ]] && continue
|
|
327
|
-
DELEGATE_PATTERNS+=("$pattern")
|
|
328
|
-
done < <(policy_list "delegate_to_subagent")
|
|
329
|
-
|
|
330
|
-
# 0.16.3 discord-ops Round 9 #3 fix: anchor the match on segment-start
|
|
331
|
-
# instead of unanchored substring search. The patterns from
|
|
332
|
-
# `policy_list "delegate_to_subagent"` are command prefixes
|
|
333
|
-
# (`pnpm run build`, `pnpm test`, `pnpm run lint`); a substring search
|
|
334
|
-
# fired on commit messages and prose mentioning those prefixes
|
|
335
|
-
# (`git commit -m "doc: when to delegate pnpm test to subagent"`).
|
|
336
|
-
# `any_segment_starts_with` regexes against the prefix-stripped form of
|
|
337
|
-
# each segment, so patterns now only match when the command segment
|
|
338
|
-
# actually invokes the named tool.
|
|
339
|
-
#
|
|
340
|
-
# `grep -qF` was fixed-string; `any_segment_starts_with` runs grep -E.
|
|
341
|
-
# The patterns from policy.yaml are literal prefixes — escape ERE
|
|
342
|
-
# metacharacters before passing them through.
|
|
343
|
-
_escape_ere() {
|
|
344
|
-
printf '%s' "$1" | sed 's/[][\\.*^$(){}+?|]/\\&/g'
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
for pattern in "${DELEGATE_PATTERNS[@]+"${DELEGATE_PATTERNS[@]}"}"; do
|
|
348
|
-
pattern_re=$(_escape_ere "$pattern")
|
|
349
|
-
if any_segment_starts_with "$CMD" "${pattern_re}([[:space:]]|$)"; then
|
|
350
|
-
add_high \
|
|
351
|
-
"Context protection — command must run in a subagent" \
|
|
352
|
-
"This command produces excessive output that will exhaust the coordinator context window. Delegate it to a subagent instead of running it directly." \
|
|
353
|
-
"Alt: Use the Agent tool to delegate: Agent(subagent_type: 'qa-engineer-automation', prompt: 'Run $pattern and report pass/fail summary only.')" \
|
|
354
|
-
"Alt: The context_protection policy in .rea/policy.yaml lists commands that must be delegated."
|
|
355
|
-
break
|
|
356
|
-
fi
|
|
357
|
-
done
|
|
358
|
-
|
|
359
|
-
# ── 10. MEDIUM severity checks ────────────────────────────────────────────────
|
|
360
|
-
|
|
361
|
-
# M1: npm install --force
|
|
362
|
-
if any_segment_matches "$CMD" 'npm[[:space:]]+(install|i)[[:space:]].*--force'; then
|
|
363
|
-
add_medium \
|
|
364
|
-
"npm install --force — bypasses dependency resolution" \
|
|
365
|
-
"--force skips conflict checks and can install incompatible package versions." \
|
|
366
|
-
"Alt: Resolve the dependency conflict explicitly. Use --legacy-peer-deps if needed."
|
|
139
|
+
# 4. Realpath sandbox check.
|
|
140
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
141
|
+
printf 'rea: dangerous-bash-interceptor cannot run — `node` is not on PATH.\n' >&2
|
|
142
|
+
printf 'Install Node 22+ (engines.node) to restore destructive-command refusal.\n' >&2
|
|
143
|
+
exit 2
|
|
367
144
|
fi
|
|
368
145
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
146
|
+
sandbox_check=$(node -e '
|
|
147
|
+
const fs = require("fs");
|
|
148
|
+
const path = require("path");
|
|
149
|
+
const cli = process.argv[1];
|
|
150
|
+
const projDir = process.argv[2];
|
|
151
|
+
let real, realProj;
|
|
152
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
153
|
+
process.stdout.write("bad:realpath"); process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
156
|
+
process.stdout.write("bad:realpath-proj"); process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const sep = path.sep;
|
|
159
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
160
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
161
|
+
process.stdout.write("bad:cli-escapes-project"); process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
164
|
+
let found = false;
|
|
165
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
166
|
+
const pj = path.join(cur, "package.json");
|
|
167
|
+
if (fs.existsSync(pj)) {
|
|
168
|
+
try {
|
|
169
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
170
|
+
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
171
|
+
} catch (e) { /* keep walking */ }
|
|
172
|
+
}
|
|
173
|
+
cur = path.dirname(cur);
|
|
174
|
+
}
|
|
175
|
+
if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
|
|
176
|
+
process.stdout.write("ok");
|
|
177
|
+
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
178
|
+
|
|
179
|
+
if [ "$sandbox_check" != "ok" ]; then
|
|
180
|
+
printf 'rea: dangerous-bash-interceptor FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
|
|
402
181
|
exit 2
|
|
403
182
|
fi
|
|
404
183
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
184
|
+
# 5. Version-probe.
|
|
185
|
+
probe_out=$("${REA_ARGV[@]}" hook dangerous-bash-interceptor --help 2>&1)
|
|
186
|
+
probe_status=$?
|
|
187
|
+
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'dangerous-bash-interceptor'; then
|
|
188
|
+
printf 'rea: this shim requires the `rea hook dangerous-bash-interceptor` subcommand (introduced in 0.34.0).\n' >&2
|
|
189
|
+
printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
|
|
190
|
+
printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
|
|
191
|
+
exit 2
|
|
412
192
|
fi
|
|
413
193
|
|
|
414
|
-
|
|
194
|
+
# 6. Forward stdin (already captured up-front).
|
|
195
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook dangerous-bash-interceptor
|
|
196
|
+
exit $?
|