@bookedsolid/rea 0.33.0 → 0.35.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 +49 -0
- package/dist/hooks/_lib/path-normalize.d.ts +81 -0
- package/dist/hooks/_lib/path-normalize.js +171 -0
- package/dist/hooks/_lib/payload.js +1 -1
- package/dist/hooks/_lib/protected-paths.d.ts +0 -0
- package/dist/hooks/_lib/protected-paths.js +232 -0
- package/dist/hooks/_lib/segments.d.ts +102 -0
- package/dist/hooks/_lib/segments.js +290 -0
- package/dist/hooks/blocked-paths-bash-gate/index.d.ts +55 -0
- package/dist/hooks/blocked-paths-bash-gate/index.js +175 -0
- package/dist/hooks/blocked-paths-enforcer/index.d.ts +51 -0
- package/dist/hooks/blocked-paths-enforcer/index.js +287 -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/protected-paths-bash-gate/index.d.ts +47 -0
- package/dist/hooks/protected-paths-bash-gate/index.js +168 -0
- package/dist/hooks/secret-scanner/index.d.ts +143 -0
- package/dist/hooks/secret-scanner/index.js +404 -0
- package/dist/hooks/settings-protection/index.d.ts +74 -0
- package/dist/hooks/settings-protection/index.js +485 -0
- package/hooks/blocked-paths-bash-gate.sh +118 -116
- package/hooks/blocked-paths-enforcer.sh +152 -256
- package/hooks/dangerous-bash-interceptor.sh +168 -386
- package/hooks/local-review-gate.sh +523 -410
- package/hooks/protected-paths-bash-gate.sh +123 -210
- package/hooks/secret-scanner.sh +210 -200
- package/hooks/settings-protection.sh +171 -549
- package/package.json +1 -1
- package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
- package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
- package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
- package/templates/local-review-gate.dogfood-staged.sh +573 -0
- package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
- package/templates/secret-scanner.dogfood-staged.sh +240 -0
- package/templates/settings-protection.dogfood-staged.sh +204 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: blocked-paths-bash-gate.sh
|
|
3
|
+
# 0.35.0+ — Node-binary shim for `rea hook blocked-paths-bash-gate`.
|
|
4
|
+
#
|
|
5
|
+
# Pre-0.35.0 this was a thin bash shim over `rea hook scan-bash --mode
|
|
6
|
+
# blocked` (the parser-backed AST walker that closes 9 bypass classes
|
|
7
|
+
# from helix-023 + discord-ops Round 13 — see `src/hooks/bash-scanner/`).
|
|
8
|
+
# The full bash body is preserved at
|
|
9
|
+
# `__tests__/hooks/parity/baselines/blocked-paths-bash-gate.sh.pre-0.35.0`.
|
|
10
|
+
#
|
|
11
|
+
# This shim now resolves the CLI through the same 2-tier sandboxed
|
|
12
|
+
# resolver as the 0.32.0+ pilots and calls `rea hook blocked-paths-
|
|
13
|
+
# bash-gate` directly — eliminating the shim → CLI → scanner-module
|
|
14
|
+
# subprocess hop entirely.
|
|
15
|
+
#
|
|
16
|
+
# Behavioral contract is preserved byte-for-byte: exit 0 on allow,
|
|
17
|
+
# exit 2 on HALT / verdict block / malformed payload / sandbox fail.
|
|
18
|
+
#
|
|
19
|
+
# # CLI-resolution trust boundary
|
|
20
|
+
#
|
|
21
|
+
# Mirrors the 0.32.0 final shim shape. The resolved CLI MUST live
|
|
22
|
+
# INSIDE realpath(CLAUDE_PROJECT_DIR) AND have an ancestor
|
|
23
|
+
# `package.json` whose `name` is `@bookedsolid/rea`. Defends against
|
|
24
|
+
# symlink-out and tarball-replacement attacks on the resolved CLI.
|
|
25
|
+
#
|
|
26
|
+
# # Fail-closed posture
|
|
27
|
+
#
|
|
28
|
+
# blocked-paths-bash-gate is a Tier-1 security gate (PreToolUse Bash).
|
|
29
|
+
# The pre-0.35.0 bash body refused on uncertainty for every failure
|
|
30
|
+
# class. Early-exit branches (CLI missing, node missing, sandbox failed,
|
|
31
|
+
# version skew) fail closed AFTER the relevance pre-gate passes.
|
|
32
|
+
# Irrelevant Bash calls exit 0 regardless of CLI state.
|
|
33
|
+
#
|
|
34
|
+
# # Relevance pre-gate
|
|
35
|
+
#
|
|
36
|
+
# Same posture as 0.34.0 dangerous-bash + secret-scanner. When the CLI
|
|
37
|
+
# is missing, refuse only when the extracted command MENTIONS a path
|
|
38
|
+
# from `policy.blocked_paths`. Empty policy → no enforcement, exit 0.
|
|
39
|
+
# This unblocks the install path itself: `npx rea init`, pre-`pnpm build`
|
|
40
|
+
# checkouts can still run benign Bash like `ls`/`mkdir`/`pnpm install`.
|
|
41
|
+
|
|
42
|
+
set -uo pipefail
|
|
43
|
+
|
|
44
|
+
# 1. HALT check.
|
|
45
|
+
# shellcheck source=_lib/halt-check.sh
|
|
46
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
47
|
+
check_halt
|
|
48
|
+
REA_ROOT=$(rea_root)
|
|
49
|
+
|
|
50
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
51
|
+
|
|
52
|
+
# 2. Capture stdin once.
|
|
53
|
+
INPUT=$(cat)
|
|
54
|
+
|
|
55
|
+
# 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
|
|
56
|
+
REA_ARGV=()
|
|
57
|
+
RESOLVED_CLI_PATH=""
|
|
58
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
59
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
60
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
61
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
62
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
63
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# 3b. Relevance pre-gate. Only used when the CLI is missing.
|
|
67
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
68
|
+
CLI_MISSING_CMD=""
|
|
69
|
+
if command -v jq >/dev/null 2>&1; then
|
|
70
|
+
CLI_MISSING_CMD=$(printf '%s' "$INPUT" | jq -r '
|
|
71
|
+
(.tool_input.command // "") | tostring
|
|
72
|
+
' 2>/dev/null || true)
|
|
73
|
+
else
|
|
74
|
+
CLI_MISSING_CMD="$INPUT"
|
|
75
|
+
fi
|
|
76
|
+
if [ -z "$CLI_MISSING_CMD" ]; then
|
|
77
|
+
# Empty/non-Bash payload → pre-0.35.0 body would have exited 0.
|
|
78
|
+
exit 0
|
|
79
|
+
fi
|
|
80
|
+
# Empty policy.blocked_paths → no enforcement, exit 0.
|
|
81
|
+
POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
|
|
82
|
+
if [ ! -f "$POLICY_FILE" ]; then
|
|
83
|
+
exit 0
|
|
84
|
+
fi
|
|
85
|
+
# Substring scan: does the command mention any blocked_paths entry?
|
|
86
|
+
# Coarse — over-trigger is fine, under-trigger is the bypass we MUST
|
|
87
|
+
# avoid. Strip YAML quotes/comments via a minimal awk filter.
|
|
88
|
+
CLI_MISSING_RELEVANT=0
|
|
89
|
+
while IFS= read -r entry; do
|
|
90
|
+
[ -z "$entry" ] && continue
|
|
91
|
+
case "$CLI_MISSING_CMD" in
|
|
92
|
+
*"$entry"*) CLI_MISSING_RELEVANT=1; break ;;
|
|
93
|
+
esac
|
|
94
|
+
done < <(awk '
|
|
95
|
+
/^blocked_paths:/ { in_block=1; next }
|
|
96
|
+
in_block && /^[[:space:]]*-/ {
|
|
97
|
+
sub(/^[[:space:]]*-[[:space:]]*/, "")
|
|
98
|
+
gsub(/^["'\'']/, "")
|
|
99
|
+
gsub(/["'\'']$/, "")
|
|
100
|
+
print
|
|
101
|
+
next
|
|
102
|
+
}
|
|
103
|
+
in_block && /^[^[:space:]-]/ { in_block=0 }
|
|
104
|
+
' "$POLICY_FILE" 2>/dev/null)
|
|
105
|
+
if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
|
|
106
|
+
exit 0
|
|
107
|
+
fi
|
|
108
|
+
printf 'rea: blocked-paths-bash-gate cannot run — the rea CLI is not built.\n' >&2
|
|
109
|
+
printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
|
|
110
|
+
printf 'This shim fails closed because the pre-0.35.0 bash body enforced blocked_paths refusal without a CLI.\n' >&2
|
|
111
|
+
exit 2
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
# 4. Realpath sandbox check.
|
|
115
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
116
|
+
printf 'rea: blocked-paths-bash-gate cannot run — `node` is not on PATH.\n' >&2
|
|
117
|
+
printf 'Install Node 22+ (engines.node) to restore blocked_paths refusal.\n' >&2
|
|
118
|
+
exit 2
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
sandbox_check=$(node -e '
|
|
122
|
+
const fs = require("fs");
|
|
123
|
+
const path = require("path");
|
|
124
|
+
const cli = process.argv[1];
|
|
125
|
+
const projDir = process.argv[2];
|
|
126
|
+
let real, realProj;
|
|
127
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
128
|
+
process.stdout.write("bad:realpath"); process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
131
|
+
process.stdout.write("bad:realpath-proj"); process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
const sep = path.sep;
|
|
134
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
135
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
136
|
+
process.stdout.write("bad:cli-escapes-project"); process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
// Codex round-1 P1 fix: enforce dist/cli/index.js shape (see
|
|
139
|
+
// settings-protection.sh).
|
|
140
|
+
const expectedEnd = path.join("dist", "cli", "index.js");
|
|
141
|
+
if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
|
|
142
|
+
process.stdout.write("bad:cli-shape"); process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
145
|
+
let found = false;
|
|
146
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
147
|
+
const pj = path.join(cur, "package.json");
|
|
148
|
+
if (fs.existsSync(pj)) {
|
|
149
|
+
try {
|
|
150
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
151
|
+
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
152
|
+
} catch (e) { /* keep walking */ }
|
|
153
|
+
}
|
|
154
|
+
cur = path.dirname(cur);
|
|
155
|
+
}
|
|
156
|
+
if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
|
|
157
|
+
process.stdout.write("ok");
|
|
158
|
+
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
159
|
+
|
|
160
|
+
if [ "$sandbox_check" != "ok" ]; then
|
|
161
|
+
printf 'rea: blocked-paths-bash-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
|
|
162
|
+
exit 2
|
|
163
|
+
fi
|
|
164
|
+
|
|
165
|
+
# 5. Version-probe.
|
|
166
|
+
probe_out=$("${REA_ARGV[@]}" hook blocked-paths-bash-gate --help 2>&1)
|
|
167
|
+
probe_status=$?
|
|
168
|
+
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'blocked-paths-bash-gate'; then
|
|
169
|
+
printf 'rea: this shim requires the `rea hook blocked-paths-bash-gate` subcommand (introduced in 0.35.0).\n' >&2
|
|
170
|
+
printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
|
|
171
|
+
printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
|
|
172
|
+
exit 2
|
|
173
|
+
fi
|
|
174
|
+
|
|
175
|
+
# 6. Forward stdin (already captured up-front).
|
|
176
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook blocked-paths-bash-gate
|
|
177
|
+
exit $?
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: blocked-paths-enforcer.sh
|
|
3
|
+
# 0.35.0+ — Node-binary shim for `rea hook blocked-paths-enforcer`.
|
|
4
|
+
#
|
|
5
|
+
# Pre-0.35.0 the gate's full body lived here as bash (284 LOC). The
|
|
6
|
+
# full bash body is preserved at
|
|
7
|
+
# `__tests__/hooks/parity/baselines/blocked-paths-enforcer.sh.pre-0.35.0`.
|
|
8
|
+
#
|
|
9
|
+
# Migration moves the enforcement logic (path normalization, traversal
|
|
10
|
+
# reject, glob/prefix/exact matching, symlink resolution, agent-
|
|
11
|
+
# writable allow-list) into `src/hooks/blocked-paths-enforcer/index.ts`.
|
|
12
|
+
# This shim is the Claude Code dispatcher's view of the hook — it
|
|
13
|
+
# forwards stdin to the CLI and exits with whatever the CLI returns.
|
|
14
|
+
#
|
|
15
|
+
# Behavioral contract is preserved byte-for-byte: exit 0 on allow,
|
|
16
|
+
# exit 2 on HALT / blocked-paths match / malformed payload.
|
|
17
|
+
#
|
|
18
|
+
# # CLI-resolution trust boundary
|
|
19
|
+
#
|
|
20
|
+
# Mirrors the 0.32.0 final shim shape.
|
|
21
|
+
#
|
|
22
|
+
# # Fail-closed posture
|
|
23
|
+
#
|
|
24
|
+
# blocked-paths-enforcer is a Write/Edit/MultiEdit/NotebookEdit tier
|
|
25
|
+
# security gate. The pre-0.35.0 bash body refused on uncertainty.
|
|
26
|
+
# Early-exit branches fail closed AFTER the relevance pre-gate passes.
|
|
27
|
+
#
|
|
28
|
+
# # Relevance pre-gate
|
|
29
|
+
#
|
|
30
|
+
# Extract file_path / notebook_path from the payload, substring-scan
|
|
31
|
+
# against the policy's blocked_paths entries. When CLI is missing AND
|
|
32
|
+
# no policy.blocked_paths entry matches, exit 0. Empty/missing policy
|
|
33
|
+
# → no enforcement, exit 0.
|
|
34
|
+
|
|
35
|
+
set -uo pipefail
|
|
36
|
+
|
|
37
|
+
# 1. HALT check.
|
|
38
|
+
# shellcheck source=_lib/halt-check.sh
|
|
39
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
40
|
+
check_halt
|
|
41
|
+
REA_ROOT=$(rea_root)
|
|
42
|
+
|
|
43
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
44
|
+
|
|
45
|
+
# 2. Capture stdin once.
|
|
46
|
+
INPUT=$(cat)
|
|
47
|
+
|
|
48
|
+
# 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
|
|
49
|
+
REA_ARGV=()
|
|
50
|
+
RESOLVED_CLI_PATH=""
|
|
51
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
52
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
53
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
54
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
55
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
56
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# 3b. Relevance pre-gate. Only used when the CLI is missing.
|
|
60
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
61
|
+
CLI_MISSING_FILE_PATH=""
|
|
62
|
+
if command -v jq >/dev/null 2>&1; then
|
|
63
|
+
CLI_MISSING_FILE_PATH=$(printf '%s' "$INPUT" | jq -r '
|
|
64
|
+
(.tool_input.file_path // .tool_input.notebook_path // "") | tostring
|
|
65
|
+
' 2>/dev/null || true)
|
|
66
|
+
else
|
|
67
|
+
CLI_MISSING_FILE_PATH="$INPUT"
|
|
68
|
+
fi
|
|
69
|
+
if [ -z "$CLI_MISSING_FILE_PATH" ]; then
|
|
70
|
+
exit 0
|
|
71
|
+
fi
|
|
72
|
+
POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
|
|
73
|
+
if [ ! -f "$POLICY_FILE" ]; then
|
|
74
|
+
exit 0
|
|
75
|
+
fi
|
|
76
|
+
CLI_MISSING_RELEVANT=0
|
|
77
|
+
while IFS= read -r entry; do
|
|
78
|
+
[ -z "$entry" ] && continue
|
|
79
|
+
# Substring scan — for directory prefixes the entry ends with /
|
|
80
|
+
# and any file_path under it matches. Glob entries fall back to
|
|
81
|
+
# the same substring test (over-trigger is fine — the CLI does
|
|
82
|
+
# the precise evaluation when reachable).
|
|
83
|
+
base="$entry"
|
|
84
|
+
case "$base" in
|
|
85
|
+
*/) base="${base%/}" ;;
|
|
86
|
+
esac
|
|
87
|
+
# Strip glob wildcards for substring testing — `src/*.ts` becomes
|
|
88
|
+
# `src/` + `.ts`. The simplest safe form is to scan the literal
|
|
89
|
+
# part before the first `*`.
|
|
90
|
+
case "$base" in
|
|
91
|
+
*'*'*) base="${base%%\**}" ;;
|
|
92
|
+
esac
|
|
93
|
+
[ -z "$base" ] && continue
|
|
94
|
+
case "$CLI_MISSING_FILE_PATH" in
|
|
95
|
+
*"$base"*) CLI_MISSING_RELEVANT=1; break ;;
|
|
96
|
+
esac
|
|
97
|
+
done < <(awk '
|
|
98
|
+
/^blocked_paths:/ { in_block=1; next }
|
|
99
|
+
in_block && /^[[:space:]]*-/ {
|
|
100
|
+
sub(/^[[:space:]]*-[[:space:]]*/, "")
|
|
101
|
+
gsub(/^["'\'']/, "")
|
|
102
|
+
gsub(/["'\'']$/, "")
|
|
103
|
+
print
|
|
104
|
+
next
|
|
105
|
+
}
|
|
106
|
+
in_block && /^[^[:space:]-]/ { in_block=0 }
|
|
107
|
+
' "$POLICY_FILE" 2>/dev/null)
|
|
108
|
+
if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
|
|
109
|
+
exit 0
|
|
110
|
+
fi
|
|
111
|
+
printf 'rea: blocked-paths-enforcer cannot run — the rea CLI is not built.\n' >&2
|
|
112
|
+
printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
|
|
113
|
+
printf 'This shim fails closed because the pre-0.35.0 bash body enforced blocked_paths refusal without a CLI.\n' >&2
|
|
114
|
+
exit 2
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
# 4. Realpath sandbox check.
|
|
118
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
119
|
+
printf 'rea: blocked-paths-enforcer cannot run — `node` is not on PATH.\n' >&2
|
|
120
|
+
printf 'Install Node 22+ (engines.node) to restore blocked_paths refusal.\n' >&2
|
|
121
|
+
exit 2
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
sandbox_check=$(node -e '
|
|
125
|
+
const fs = require("fs");
|
|
126
|
+
const path = require("path");
|
|
127
|
+
const cli = process.argv[1];
|
|
128
|
+
const projDir = process.argv[2];
|
|
129
|
+
let real, realProj;
|
|
130
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
131
|
+
process.stdout.write("bad:realpath"); process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
134
|
+
process.stdout.write("bad:realpath-proj"); process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
const sep = path.sep;
|
|
137
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
138
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
139
|
+
process.stdout.write("bad:cli-escapes-project"); process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
// Codex round-1 P1 fix: enforce dist/cli/index.js shape (see
|
|
142
|
+
// settings-protection.sh).
|
|
143
|
+
const expectedEnd = path.join("dist", "cli", "index.js");
|
|
144
|
+
if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
|
|
145
|
+
process.stdout.write("bad:cli-shape"); process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
148
|
+
let found = false;
|
|
149
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
150
|
+
const pj = path.join(cur, "package.json");
|
|
151
|
+
if (fs.existsSync(pj)) {
|
|
152
|
+
try {
|
|
153
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
154
|
+
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
155
|
+
} catch (e) { /* keep walking */ }
|
|
156
|
+
}
|
|
157
|
+
cur = path.dirname(cur);
|
|
158
|
+
}
|
|
159
|
+
if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
|
|
160
|
+
process.stdout.write("ok");
|
|
161
|
+
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
162
|
+
|
|
163
|
+
if [ "$sandbox_check" != "ok" ]; then
|
|
164
|
+
printf 'rea: blocked-paths-enforcer FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
|
|
165
|
+
exit 2
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
# 5. Version-probe.
|
|
169
|
+
probe_out=$("${REA_ARGV[@]}" hook blocked-paths-enforcer --help 2>&1)
|
|
170
|
+
probe_status=$?
|
|
171
|
+
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'blocked-paths-enforcer'; then
|
|
172
|
+
printf 'rea: this shim requires the `rea hook blocked-paths-enforcer` subcommand (introduced in 0.35.0).\n' >&2
|
|
173
|
+
printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
|
|
174
|
+
printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
|
|
175
|
+
exit 2
|
|
176
|
+
fi
|
|
177
|
+
|
|
178
|
+
# 6. Forward stdin (already captured up-front).
|
|
179
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook blocked-paths-enforcer
|
|
180
|
+
exit $?
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: dangerous-bash-interceptor.sh
|
|
3
|
+
# 0.34.0+ — Node-binary shim for `rea hook dangerous-bash-interceptor`.
|
|
4
|
+
#
|
|
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.
|
|
11
|
+
#
|
|
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).
|
|
15
|
+
#
|
|
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.
|
|
55
|
+
|
|
56
|
+
set -uo pipefail
|
|
57
|
+
|
|
58
|
+
# 1. HALT check.
|
|
59
|
+
# shellcheck source=_lib/halt-check.sh
|
|
60
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
61
|
+
check_halt
|
|
62
|
+
REA_ROOT=$(rea_root)
|
|
63
|
+
|
|
64
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
65
|
+
|
|
66
|
+
# 2. Capture stdin once. The CLI consumes it via stdin pipe below.
|
|
67
|
+
INPUT=$(cat)
|
|
68
|
+
|
|
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)
|
|
93
|
+
else
|
|
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"
|
|
99
|
+
fi
|
|
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
|
|
106
|
+
fi
|
|
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
|
|
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
|
|
137
|
+
fi
|
|
138
|
+
|
|
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
|
|
144
|
+
fi
|
|
145
|
+
|
|
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
|
|
181
|
+
exit 2
|
|
182
|
+
fi
|
|
183
|
+
|
|
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
|
|
192
|
+
fi
|
|
193
|
+
|
|
194
|
+
# 6. Forward stdin (already captured up-front).
|
|
195
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook dangerous-bash-interceptor
|
|
196
|
+
exit $?
|