@bookedsolid/rea 0.36.0 → 0.38.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/hooks/_lib/policy-reader.sh +948 -0
- package/hooks/_lib/shim-runtime.sh +405 -0
- package/hooks/architecture-review-gate.sh +11 -103
- package/hooks/attribution-advisory.sh +43 -155
- package/hooks/blocked-paths-bash-gate.sh +35 -149
- package/hooks/blocked-paths-enforcer.sh +35 -140
- package/hooks/changeset-security-gate.sh +26 -119
- package/hooks/dangerous-bash-interceptor.sh +46 -170
- package/hooks/delegation-advisory.sh +26 -144
- package/hooks/delegation-capture.sh +33 -139
- package/hooks/dependency-audit-gate.sh +29 -121
- package/hooks/env-file-protection.sh +30 -141
- package/hooks/local-review-gate.sh +191 -396
- package/hooks/pr-issue-link-gate.sh +16 -118
- package/hooks/protected-paths-bash-gate.sh +57 -160
- package/hooks/secret-scanner.sh +90 -213
- package/hooks/security-disclosure-gate.sh +32 -155
- package/hooks/settings-protection.sh +56 -179
- package/package.json +1 -1
- package/templates/_lib_policy-reader.dogfood-staged.sh +948 -0
- package/templates/_lib_shim-runtime.dogfood-staged.sh +405 -0
- package/templates/architecture-review-gate.dogfood-staged.sh +11 -103
- package/templates/attribution-advisory.dogfood-staged.sh +43 -155
- package/templates/blocked-paths-bash-gate.dogfood-staged.sh +35 -149
- package/templates/blocked-paths-enforcer.dogfood-staged.sh +35 -140
- package/templates/changeset-security-gate.dogfood-staged.sh +26 -119
- package/templates/dangerous-bash-interceptor.dogfood-staged.sh +46 -170
- package/templates/delegation-advisory.dogfood-staged.sh +44 -0
- package/templates/delegation-capture.dogfood-staged.sh +52 -0
- package/templates/dependency-audit-gate.dogfood-staged.sh +29 -121
- package/templates/env-file-protection.dogfood-staged.sh +30 -141
- package/templates/local-review-gate.dogfood-staged.sh +191 -396
- package/templates/pr-issue-link-gate.dogfood-staged.sh +16 -118
- package/templates/protected-paths-bash-gate.dogfood-staged.sh +57 -160
- package/templates/secret-scanner.dogfood-staged.sh +90 -213
- package/templates/security-disclosure-gate.dogfood-staged.sh +32 -155
- package/templates/settings-protection.dogfood-staged.sh +56 -179
|
@@ -1,158 +1,52 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: delegation-capture.sh
|
|
3
3
|
# 0.29.0+ — delegation-telemetry MVP.
|
|
4
|
+
# 0.38.0+ — migrated to `_lib/shim-runtime.sh` (shared runtime).
|
|
4
5
|
#
|
|
5
|
-
# Fires BEFORE every `Agent` or `Skill` tool call.
|
|
6
|
-
#
|
|
7
|
-
#
|
|
6
|
+
# Fires BEFORE every `Agent` or `Skill` tool call. Pipes the payload to
|
|
7
|
+
# `rea hook delegation-signal --detach`, BACKGROUNDED so the hook
|
|
8
|
+
# returns instantly even when the CLI's startup takes a few ms. The
|
|
9
|
+
# signal is OBSERVATIONAL — never gates tool dispatch.
|
|
8
10
|
#
|
|
9
|
-
#
|
|
10
|
-
# latency budget is ~50ms even when the audit chain is under
|
|
11
|
-
# cross-process contention, because the audit append runs in the
|
|
12
|
-
# background (via `&`) and the CLI subcommand itself only validates
|
|
13
|
-
# the payload before forking the writer.
|
|
11
|
+
# Matcher: `Agent|Skill` (NOT `Task|Skill`).
|
|
14
12
|
#
|
|
15
|
-
#
|
|
16
|
-
# are the unrelated todo-list tools and MUST NOT match).
|
|
13
|
+
# # CLI subcommand differs from SHIM_NAME
|
|
17
14
|
#
|
|
18
|
-
#
|
|
15
|
+
# The forward target is `rea hook delegation-signal`, not `rea hook
|
|
16
|
+
# delegation-capture` — the hook name and the CLI subcommand differ.
|
|
17
|
+
# We use shim_forward to invoke the correct subcommand.
|
|
19
18
|
#
|
|
20
|
-
#
|
|
21
|
-
# binary via `$REA_ROOT/node_modules/.bin/rea` then PATH-walked
|
|
22
|
-
# `command -v rea`. Either path was attacker-influenced in a consumer
|
|
23
|
-
# repo with a forged `node_modules/.bin/rea` symlink or a
|
|
24
|
-
# PATH-prepended fake `rea` binary — giving attacker-controlled code
|
|
25
|
-
# execution on every Agent/Skill dispatch.
|
|
19
|
+
# # No version probe
|
|
26
20
|
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# published artifact)
|
|
31
|
-
# 2. dist/cli/index.js under CLAUDE_PROJECT_DIR (the rea repo's own
|
|
32
|
-
# dogfood install)
|
|
33
|
-
#
|
|
34
|
-
# A realpath sandbox check ensures the resolved CLI lives INSIDE
|
|
35
|
-
# realpath(CLAUDE_PROJECT_DIR) — catches symlink-out attacks.
|
|
36
|
-
#
|
|
37
|
-
# Exit codes:
|
|
38
|
-
# 0 — always (under normal operation). Failure to write the audit
|
|
39
|
-
# signal must NEVER block Claude Code's tool dispatch. Stderr
|
|
40
|
-
# breadcrumbs surface diagnostic info to the operator. HALT
|
|
41
|
-
# still exits 2 because the kill-switch contract must hold.
|
|
42
|
-
# 2 — HALT active.
|
|
21
|
+
# SHIM_SKIP_VERSION_PROBE=1: the pre-port body had no probe; a stale
|
|
22
|
+
# CLI drops the signal silently rather than emit a probe-skew banner
|
|
23
|
+
# on every Agent/Skill dispatch.
|
|
43
24
|
|
|
44
25
|
set -uo pipefail
|
|
45
26
|
|
|
46
|
-
# 1. HALT check. Even though this hook is observational, refusing to
|
|
47
|
-
# emit signals while frozen matches the rest of the hook tree and
|
|
48
|
-
# keeps the kill-switch contract uniform.
|
|
49
27
|
# shellcheck source=_lib/halt-check.sh
|
|
50
28
|
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
51
29
|
check_halt
|
|
52
30
|
REA_ROOT=$(rea_root)
|
|
53
31
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
70
|
-
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
71
|
-
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
72
|
-
# rea repo dogfood: the project IS @bookedsolid/rea.
|
|
73
|
-
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
74
|
-
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
75
|
-
fi
|
|
76
|
-
|
|
77
|
-
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
78
|
-
# No rea CLI in scope — drop the signal silently. This is the
|
|
79
|
-
# expected state during bootstrap (consumer ran `rea init` but
|
|
80
|
-
# hasn't installed the npm package yet) or in non-rea repos. A
|
|
81
|
-
# noisy stderr warning here would fire on every Agent/Skill
|
|
82
|
-
# dispatch and drown legitimate signals.
|
|
83
|
-
exit 0
|
|
84
|
-
fi
|
|
85
|
-
|
|
86
|
-
# 3. Realpath sandbox check — mirrors protected-paths-bash-gate.sh §6.
|
|
87
|
-
# The resolved CLI MUST live inside realpath(CLAUDE_PROJECT_DIR)
|
|
88
|
-
# AND have an ancestor package.json declaring `@bookedsolid/rea`
|
|
89
|
-
# as its `name`. Catches symlink-out attacks where an attacker
|
|
90
|
-
# writes node_modules/@bookedsolid/rea → /tmp/forged-tree.
|
|
91
|
-
if ! command -v node >/dev/null 2>&1; then
|
|
92
|
-
# Node not on PATH — we can't verify the CLI shape. Fail safe by
|
|
93
|
-
# dropping the signal (observability is not a security claim; the
|
|
94
|
-
# rest of the Bash gate suite refuses on this path).
|
|
32
|
+
SHIM_NAME="delegation-capture"
|
|
33
|
+
SHIM_INTRODUCED_IN="0.29.0"
|
|
34
|
+
SHIM_FAIL_OPEN=1
|
|
35
|
+
SHIM_SKIP_VERSION_PROBE=1
|
|
36
|
+
SHIM_REFUSAL_NOUN="the delegation telemetry signal"
|
|
37
|
+
|
|
38
|
+
shim_forward() {
|
|
39
|
+
# Pipe to `rea hook delegation-signal --detach`. `--detach` tells the
|
|
40
|
+
# CLI to suppress stderr; the whole pipeline is backgrounded with
|
|
41
|
+
# `& disown` so the shell hook returns instantly.
|
|
42
|
+
{
|
|
43
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook delegation-signal --detach \
|
|
44
|
+
>/dev/null 2>&1 &
|
|
45
|
+
disown 2>/dev/null || true
|
|
46
|
+
} 2>/dev/null
|
|
95
47
|
exit 0
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
sandbox_check=$(node -e '
|
|
99
|
-
const fs = require("fs");
|
|
100
|
-
const path = require("path");
|
|
101
|
-
const cli = process.argv[1];
|
|
102
|
-
const projDir = process.argv[2];
|
|
103
|
-
let real, realProj;
|
|
104
|
-
try { real = fs.realpathSync(cli); } catch (e) {
|
|
105
|
-
process.stdout.write("bad:realpath");
|
|
106
|
-
process.exit(1);
|
|
107
|
-
}
|
|
108
|
-
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
109
|
-
process.stdout.write("bad:realpath-proj");
|
|
110
|
-
process.exit(1);
|
|
111
|
-
}
|
|
112
|
-
const sep = path.sep;
|
|
113
|
-
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
114
|
-
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
115
|
-
process.stdout.write("bad:cli-escapes-project");
|
|
116
|
-
process.exit(1);
|
|
117
|
-
}
|
|
118
|
-
// Walk up looking for package.json with the protected name.
|
|
119
|
-
let cur = path.dirname(path.dirname(path.dirname(real))); // pkg root
|
|
120
|
-
let found = false;
|
|
121
|
-
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
122
|
-
const pj = path.join(cur, "package.json");
|
|
123
|
-
if (fs.existsSync(pj)) {
|
|
124
|
-
try {
|
|
125
|
-
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
126
|
-
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
127
|
-
} catch (e) { /* keep walking */ }
|
|
128
|
-
}
|
|
129
|
-
cur = path.dirname(cur);
|
|
130
|
-
}
|
|
131
|
-
if (!found) {
|
|
132
|
-
process.stdout.write("bad:no-rea-pkg-json");
|
|
133
|
-
process.exit(1);
|
|
134
|
-
}
|
|
135
|
-
process.stdout.write("ok");
|
|
136
|
-
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
137
|
-
|
|
138
|
-
if [ "$sandbox_check" != "ok" ]; then
|
|
139
|
-
# CLI failed the sandbox check — silent drop. The forensic
|
|
140
|
-
# breadcrumb in stderr is intentional but trimmed so this doesn't
|
|
141
|
-
# become spammy on every dispatch.
|
|
142
|
-
printf 'rea: delegation-capture skipped (sandbox check: %s)\n' "$sandbox_check" >&2
|
|
143
|
-
exit 0
|
|
144
|
-
fi
|
|
145
|
-
|
|
146
|
-
# 4. Read stdin and pipe to the CLI. `--detach` tells the CLI to
|
|
147
|
-
# suppress stderr output (no parent shell is listening); we ALSO
|
|
148
|
-
# background the whole pipeline with `&` and `disown` so the
|
|
149
|
-
# shell hook returns instantly even if the CLI's own startup
|
|
150
|
-
# takes a few ms.
|
|
151
|
-
INPUT=$(cat)
|
|
152
|
-
{
|
|
153
|
-
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook delegation-signal --detach \
|
|
154
|
-
>/dev/null 2>&1 &
|
|
155
|
-
disown 2>/dev/null || true
|
|
156
|
-
} 2>/dev/null
|
|
48
|
+
}
|
|
157
49
|
|
|
158
|
-
|
|
50
|
+
# shellcheck source=_lib/shim-runtime.sh
|
|
51
|
+
source "$(dirname "$0")/_lib/shim-runtime.sh"
|
|
52
|
+
shim_run
|
|
@@ -1,138 +1,46 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: dependency-audit-gate.sh
|
|
3
3
|
# 0.33.0+ — Node-binary shim for `rea hook dependency-audit-gate`.
|
|
4
|
+
# 0.38.0+ — migrated to `_lib/shim-runtime.sh` (shared runtime).
|
|
4
5
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# `npm view` probe
|
|
8
|
-
#
|
|
6
|
+
# Blocking-tier: refuses install commands when any requested package
|
|
7
|
+
# isn't published on the registry (pre-port behavior). The full
|
|
8
|
+
# segment splitter + per-package `npm view` probe is in
|
|
9
|
+
# `src/hooks/dependency-audit-gate/index.ts`.
|
|
9
10
|
#
|
|
10
|
-
#
|
|
11
|
-
# pass-through / all-packages-verified, exit 2 on HALT / any package
|
|
12
|
-
# missing / malformed payload.
|
|
11
|
+
# # Relevance pre-gate
|
|
13
12
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# # Fail-closed posture
|
|
20
|
-
#
|
|
21
|
-
# dependency-audit-gate is BLOCKING-tier — the pre-0.33.0 bash body
|
|
22
|
-
# refused on missing packages. Early-exit branches (CLI missing,
|
|
23
|
-
# node missing, sandbox failed, version skew) fail closed AFTER the
|
|
24
|
-
# relevance pre-gate passes.
|
|
13
|
+
# 2026-05-15 codex round-2 P2 fix: scan `tool_input.command` ONLY,
|
|
14
|
+
# not the raw JSON payload. Pre-fix `git commit -m "docs: run pnpm
|
|
15
|
+
# install foo"` triggered fail-closed on fresh checkout (the regex hit
|
|
16
|
+
# the substring inside the commit-message ARG). The jq-less fallback
|
|
17
|
+
# preserves the pre-0.33.0 over-trigger shape.
|
|
25
18
|
|
|
26
19
|
set -uo pipefail
|
|
27
20
|
|
|
28
|
-
# 1. HALT check.
|
|
29
21
|
# shellcheck source=_lib/halt-check.sh
|
|
30
22
|
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
31
23
|
check_halt
|
|
32
24
|
REA_ROOT=$(rea_root)
|
|
33
25
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
# must match that posture.
|
|
46
|
-
#
|
|
47
|
-
# `jq`-less fallback preserves the pre-0.33.0 over-trigger shape.
|
|
48
|
-
INPUT=$(cat)
|
|
49
|
-
RELEVANT=0
|
|
50
|
-
PROBE=""
|
|
51
|
-
if command -v jq >/dev/null 2>&1; then
|
|
52
|
-
PROBE=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
|
53
|
-
if printf '%s' "$PROBE" | grep -qE '(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]'; then
|
|
54
|
-
RELEVANT=1
|
|
26
|
+
SHIM_NAME="dependency-audit-gate"
|
|
27
|
+
SHIM_INTRODUCED_IN="0.33.0"
|
|
28
|
+
SHIM_FAIL_OPEN=0
|
|
29
|
+
SHIM_REFUSAL_NOUN="dependency-audit refusal"
|
|
30
|
+
|
|
31
|
+
shim_is_relevant() {
|
|
32
|
+
local probe
|
|
33
|
+
if command -v jq >/dev/null 2>&1; then
|
|
34
|
+
probe=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
|
35
|
+
else
|
|
36
|
+
probe="$INPUT"
|
|
55
37
|
fi
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
RELEVANT=1
|
|
38
|
+
if printf '%s' "$probe" | grep -qE '(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]'; then
|
|
39
|
+
return 0
|
|
59
40
|
fi
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
exit 0
|
|
63
|
-
fi
|
|
64
|
-
|
|
65
|
-
# 3. Resolve the rea CLI.
|
|
66
|
-
REA_ARGV=()
|
|
67
|
-
RESOLVED_CLI_PATH=""
|
|
68
|
-
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
69
|
-
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
70
|
-
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
71
|
-
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
72
|
-
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
73
|
-
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
74
|
-
fi
|
|
75
|
-
|
|
76
|
-
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
77
|
-
printf 'rea: dependency-audit-gate cannot run — the rea CLI is not built.\n' >&2
|
|
78
|
-
printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
|
|
79
|
-
exit 2
|
|
80
|
-
fi
|
|
81
|
-
|
|
82
|
-
# 4. Realpath sandbox check.
|
|
83
|
-
if ! command -v node >/dev/null 2>&1; then
|
|
84
|
-
printf 'rea: dependency-audit-gate cannot run — `node` is not on PATH.\n' >&2
|
|
85
|
-
exit 2
|
|
86
|
-
fi
|
|
87
|
-
|
|
88
|
-
sandbox_check=$(node -e '
|
|
89
|
-
const fs = require("fs");
|
|
90
|
-
const path = require("path");
|
|
91
|
-
const cli = process.argv[1];
|
|
92
|
-
const projDir = process.argv[2];
|
|
93
|
-
let real, realProj;
|
|
94
|
-
try { real = fs.realpathSync(cli); } catch (e) {
|
|
95
|
-
process.stdout.write("bad:realpath"); process.exit(1);
|
|
96
|
-
}
|
|
97
|
-
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
98
|
-
process.stdout.write("bad:realpath-proj"); process.exit(1);
|
|
99
|
-
}
|
|
100
|
-
const sep = path.sep;
|
|
101
|
-
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
102
|
-
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
103
|
-
process.stdout.write("bad:cli-escapes-project"); process.exit(1);
|
|
104
|
-
}
|
|
105
|
-
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
106
|
-
let found = false;
|
|
107
|
-
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
108
|
-
const pj = path.join(cur, "package.json");
|
|
109
|
-
if (fs.existsSync(pj)) {
|
|
110
|
-
try {
|
|
111
|
-
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
112
|
-
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
113
|
-
} catch (e) { /* keep walking */ }
|
|
114
|
-
}
|
|
115
|
-
cur = path.dirname(cur);
|
|
116
|
-
}
|
|
117
|
-
if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
|
|
118
|
-
process.stdout.write("ok");
|
|
119
|
-
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
120
|
-
|
|
121
|
-
if [ "$sandbox_check" != "ok" ]; then
|
|
122
|
-
printf 'rea: dependency-audit-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
|
|
123
|
-
exit 2
|
|
124
|
-
fi
|
|
125
|
-
|
|
126
|
-
# 5. Version-probe.
|
|
127
|
-
probe_out=$("${REA_ARGV[@]}" hook dependency-audit-gate --help 2>&1)
|
|
128
|
-
probe_status=$?
|
|
129
|
-
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'dependency-audit-gate'; then
|
|
130
|
-
printf 'rea: this shim requires the `rea hook dependency-audit-gate` subcommand (introduced in 0.33.0).\n' >&2
|
|
131
|
-
printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
|
|
132
|
-
printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
|
|
133
|
-
exit 2
|
|
134
|
-
fi
|
|
41
|
+
return 1
|
|
42
|
+
}
|
|
135
43
|
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
|
|
44
|
+
# shellcheck source=_lib/shim-runtime.sh
|
|
45
|
+
source "$(dirname "$0")/_lib/shim-runtime.sh"
|
|
46
|
+
shim_run
|
|
@@ -1,157 +1,46 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: env-file-protection.sh
|
|
3
3
|
# 0.33.0+ — Node-binary shim for `rea hook env-file-protection`.
|
|
4
|
+
# 0.38.0+ — migrated to `_lib/shim-runtime.sh` (shared runtime).
|
|
4
5
|
#
|
|
5
|
-
#
|
|
6
|
-
# segment splitter +
|
|
7
|
-
#
|
|
8
|
-
# moves all of that into `src/hooks/env-file-protection/index.ts`. This
|
|
9
|
-
# shim is the Claude Code dispatcher's view of the hook — it forwards
|
|
10
|
-
# stdin to the CLI and exits with whatever the CLI returns.
|
|
6
|
+
# Blocking-tier: refuses Bash commands that source/cp/cat .env. Full
|
|
7
|
+
# segment splitter + utility-vs-.env co-occurrence logic in
|
|
8
|
+
# `src/hooks/env-file-protection/index.ts`.
|
|
11
9
|
#
|
|
12
|
-
#
|
|
13
|
-
# pass-through / no-match, exit 2 on HALT / .env access detected /
|
|
14
|
-
# malformed payload (fail-closed).
|
|
10
|
+
# # Relevance pre-gate
|
|
15
11
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# on
|
|
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
|
-
# env-file-protection is a BLOCKING-tier gate — the pre-0.33.0 bash
|
|
27
|
-
# body refused on .env access without a compiled CLI. The early-exit
|
|
28
|
-
# branches (CLI missing, node missing, sandbox failed, version skew)
|
|
29
|
-
# fail closed AFTER the relevance pre-gate passes. Irrelevant Bash
|
|
30
|
-
# calls exit 0 regardless of CLI state.
|
|
12
|
+
# 2026-05-15 codex round-2 P2 fix: scan `tool_input.command` ONLY, not
|
|
13
|
+
# the raw JSON payload — otherwise `git commit -m "stop reading .env"`
|
|
14
|
+
# (where `.env` appears inside the commit message ARG) hits fail-closed
|
|
15
|
+
# on a fresh checkout.
|
|
31
16
|
|
|
32
17
|
set -uo pipefail
|
|
33
18
|
|
|
34
|
-
# 1. HALT check.
|
|
35
19
|
# shellcheck source=_lib/halt-check.sh
|
|
36
20
|
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
37
21
|
check_halt
|
|
38
22
|
REA_ROOT=$(rea_root)
|
|
39
23
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# by 5 other hooks; trust assumption is consistent). When `jq` is
|
|
57
|
-
# not installed, fall back to scanning the raw payload — the cost
|
|
58
|
-
# is the same over-trigger the bash original had, NOT a new
|
|
59
|
-
# regression. When `jq` IS installed (the common case), the
|
|
60
|
-
# pre-gate is field-scoped.
|
|
61
|
-
INPUT=$(cat)
|
|
62
|
-
RELEVANT=0
|
|
63
|
-
PROBE=""
|
|
64
|
-
if command -v jq >/dev/null 2>&1; then
|
|
65
|
-
PROBE=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
|
66
|
-
if printf '%s' "$PROBE" | grep -qE '\.env'; then
|
|
67
|
-
RELEVANT=1
|
|
68
|
-
fi
|
|
69
|
-
else
|
|
70
|
-
# jq-less fallback — match the pre-0.33.0 over-trigger posture.
|
|
71
|
-
if printf '%s' "$INPUT" | grep -qE '\.env'; then
|
|
72
|
-
RELEVANT=1
|
|
24
|
+
SHIM_NAME="env-file-protection"
|
|
25
|
+
SHIM_INTRODUCED_IN="0.33.0"
|
|
26
|
+
SHIM_FAIL_OPEN=0
|
|
27
|
+
SHIM_REFUSAL_NOUN=".env protection"
|
|
28
|
+
|
|
29
|
+
shim_is_relevant() {
|
|
30
|
+
local probe
|
|
31
|
+
if command -v jq >/dev/null 2>&1; then
|
|
32
|
+
probe=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
|
33
|
+
if printf '%s' "$probe" | grep -qE '\.env'; then
|
|
34
|
+
return 0
|
|
35
|
+
fi
|
|
36
|
+
else
|
|
37
|
+
if printf '%s' "$INPUT" | grep -qE '\.env'; then
|
|
38
|
+
return 0
|
|
39
|
+
fi
|
|
73
40
|
fi
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
exit 0
|
|
77
|
-
fi
|
|
78
|
-
|
|
79
|
-
# 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
|
|
80
|
-
REA_ARGV=()
|
|
81
|
-
RESOLVED_CLI_PATH=""
|
|
82
|
-
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
83
|
-
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
84
|
-
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
85
|
-
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
86
|
-
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
87
|
-
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
88
|
-
fi
|
|
89
|
-
|
|
90
|
-
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
91
|
-
# Blocking-tier: fail closed. The pre-0.33.0 bash body enforced
|
|
92
|
-
# .env protection without a CLI. Refuse and tell the operator how
|
|
93
|
-
# to restore protection.
|
|
94
|
-
printf 'rea: env-file-protection cannot run — the rea CLI is not built.\n' >&2
|
|
95
|
-
printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
|
|
96
|
-
printf 'This shim fails closed because the pre-0.33.0 bash body enforced .env protection without a CLI.\n' >&2
|
|
97
|
-
exit 2
|
|
98
|
-
fi
|
|
99
|
-
|
|
100
|
-
# 4. Realpath sandbox check.
|
|
101
|
-
if ! command -v node >/dev/null 2>&1; then
|
|
102
|
-
printf 'rea: env-file-protection cannot run — `node` is not on PATH.\n' >&2
|
|
103
|
-
printf 'Install Node 22+ (engines.node) to restore .env protection.\n' >&2
|
|
104
|
-
exit 2
|
|
105
|
-
fi
|
|
106
|
-
|
|
107
|
-
sandbox_check=$(node -e '
|
|
108
|
-
const fs = require("fs");
|
|
109
|
-
const path = require("path");
|
|
110
|
-
const cli = process.argv[1];
|
|
111
|
-
const projDir = process.argv[2];
|
|
112
|
-
let real, realProj;
|
|
113
|
-
try { real = fs.realpathSync(cli); } catch (e) {
|
|
114
|
-
process.stdout.write("bad:realpath"); process.exit(1);
|
|
115
|
-
}
|
|
116
|
-
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
117
|
-
process.stdout.write("bad:realpath-proj"); process.exit(1);
|
|
118
|
-
}
|
|
119
|
-
const sep = path.sep;
|
|
120
|
-
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
121
|
-
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
122
|
-
process.stdout.write("bad:cli-escapes-project"); process.exit(1);
|
|
123
|
-
}
|
|
124
|
-
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
125
|
-
let found = false;
|
|
126
|
-
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
127
|
-
const pj = path.join(cur, "package.json");
|
|
128
|
-
if (fs.existsSync(pj)) {
|
|
129
|
-
try {
|
|
130
|
-
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
131
|
-
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
132
|
-
} catch (e) { /* keep walking */ }
|
|
133
|
-
}
|
|
134
|
-
cur = path.dirname(cur);
|
|
135
|
-
}
|
|
136
|
-
if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
|
|
137
|
-
process.stdout.write("ok");
|
|
138
|
-
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
139
|
-
|
|
140
|
-
if [ "$sandbox_check" != "ok" ]; then
|
|
141
|
-
printf 'rea: env-file-protection FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
|
|
142
|
-
exit 2
|
|
143
|
-
fi
|
|
144
|
-
|
|
145
|
-
# 5. Version-probe.
|
|
146
|
-
probe_out=$("${REA_ARGV[@]}" hook env-file-protection --help 2>&1)
|
|
147
|
-
probe_status=$?
|
|
148
|
-
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'env-file-protection'; then
|
|
149
|
-
printf 'rea: this shim requires the `rea hook env-file-protection` subcommand (introduced in 0.33.0).\n' >&2
|
|
150
|
-
printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
|
|
151
|
-
printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
|
|
152
|
-
exit 2
|
|
153
|
-
fi
|
|
41
|
+
return 1
|
|
42
|
+
}
|
|
154
43
|
|
|
155
|
-
#
|
|
156
|
-
|
|
157
|
-
|
|
44
|
+
# shellcheck source=_lib/shim-runtime.sh
|
|
45
|
+
source "$(dirname "$0")/_lib/shim-runtime.sh"
|
|
46
|
+
shim_run
|