@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.
@@ -1,414 +1,196 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: dangerous-bash-interceptor.sh
3
- # Fires BEFORE every Bash tool call.
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
- # Compatible with: interactive sessions + headless Docker (no TTY required).
7
- # All diagnostic output goes to stderr only.
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
- # Content extraction:
10
- # Bash tool tool_input.command
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
- # Exit codes:
13
- # 0 = safe or advisory-only — allow the command to run
14
- # 2 = HIGH severity danger detected block the command with feedback
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
- # Source shared shell-segment splitter (0.15.0). Provides
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
- # ── 4. Parse tool_input.command from the hook payload ─────────────────────────
45
- CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
64
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
46
65
 
47
- if [[ -z "$CMD" ]]; then
48
- exit 0
49
- fi
66
+ # 2. Capture stdin once. The CLI consumes it via stdin pipe below.
67
+ INPUT=$(cat)
50
68
 
51
- # ── 5. Helper: truncate command for display ────────────────────────────────────
52
- truncate_cmd() {
53
- local STR="$1"
54
- local MAX=200
55
- if [[ ${#STR} -gt $MAX ]]; then
56
- printf '%s' "${STR:0:$MAX}..."
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
- printf '%s' "$STR"
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
- # ── 6. Violation accumulators ──────────────────────────────────────────────────
63
- HIGH_FILE=$(mktemp "${TMPDIR:-/tmp}/rea-bash-high-XXXXXX")
64
- MEDIUM_FILE=$(mktemp "${TMPDIR:-/tmp}/rea-bash-medium-XXXXXX")
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
- fi
158
-
159
- # H3: git checkout -- .
160
- if any_segment_starts_with "$CMD" 'git[[:space:]]+checkout[[:space:]]+--[[:space:]]+\.'; then
161
- add_high \
162
- "git checkout -- . discards all uncommitted changes" \
163
- "Overwrites working tree changes with HEAD. Uncommitted work is lost permanently." \
164
- "Alt: 'git stash' to temporarily shelve changes, 'git restore <file>' for individual files."
165
- fi
166
-
167
- # H4: git restore . (any form — with or without --staged flag)
168
- if any_segment_starts_with "$CMD" 'git[[:space:]]+restore[[:space:]].*[[:space:]]\.([[:space:]]|$)' || \
169
- any_segment_starts_with "$CMD" 'git[[:space:]]+restore[[:space:]]+\.[[:space:]]*$'; then
170
- add_high \
171
- "git restore . discards all uncommitted changes" \
172
- "Restores every tracked file to HEAD, permanently discarding all working tree modifications." \
173
- "Alt: 'git stash' to save changes temporarily, or restore individual files: 'git restore <file>'."
174
- fi
175
-
176
- # H5: git clean -f
177
- if [[ $CMD_IS_CLEAN_DRY -eq 0 ]]; then
178
- if any_segment_starts_with "$CMD" 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'; then
179
- add_high \
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
- # H6: DROP TABLE or DROP DATABASE in psql
187
- if any_segment_matches "$CMD" '(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)'; then
188
- add_high \
189
- "DROP TABLE/DATABASE via psql destructive DDL" \
190
- "Running destructive DDL directly in psql bypasses migration pipeline safety checks." \
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
- # ── 11. Evaluate and report ───────────────────────────────────────────────────
370
-
371
- TRUNCATED_CMD=$(truncate_cmd "$CMD")
372
-
373
- print_violations() {
374
- local VF="$1"
375
- local NOTE_LABEL="$2"
376
- while IFS= read -r LINE; do
377
- case "$LINE" in
378
- HIGH\|*|MEDIUM\|*)
379
- local SEV LABEL DETAIL
380
- SEV=$(printf '%s' "$LINE" | cut -d'|' -f1)
381
- LABEL=$(printf '%s' "$LINE" | cut -d'|' -f2)
382
- DETAIL=$(printf '%s' "$LINE" | cut -d'|' -f3)
383
- printf ' %s: %s\n' "$SEV" "$LABEL"
384
- printf ' %s: %s\n' "$NOTE_LABEL" "$DETAIL"
385
- ;;
386
- ALT:*)
387
- printf ' %s\n' "${LINE#ALT:}"
388
- ;;
389
- END_VIOLATION)
390
- printf '\n'
391
- ;;
392
- esac
393
- done < "$VF"
394
- }
395
-
396
- if [[ -s "$HIGH_FILE" ]]; then
397
- {
398
- printf 'BASH INTERCEPTED: Dangerous command blocked\n'
399
- print_violations "$HIGH_FILE" "Reason"
400
- printf ' BLOCKED COMMAND: %s\n' "$TRUNCATED_CMD"
401
- } >&2
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
- if [[ -s "$MEDIUM_FILE" ]]; then
406
- {
407
- printf 'BASH ADVISORY: Potentially risky command (not blocked)\n'
408
- print_violations "$MEDIUM_FILE" "Note"
409
- printf ' COMMAND: %s\n' "$TRUNCATED_CMD"
410
- } >&2
411
- exit 0
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
- exit 0
194
+ # 6. Forward stdin (already captured up-front).
195
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook dangerous-bash-interceptor
196
+ exit $?