@bookedsolid/rea 0.32.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.
Files changed (34) hide show
  1. package/dist/cli/hook.js +49 -0
  2. package/dist/hooks/_lib/payload.d.ts +38 -0
  3. package/dist/hooks/_lib/payload.js +79 -0
  4. package/dist/hooks/_lib/segments.d.ts +127 -0
  5. package/dist/hooks/_lib/segments.js +628 -16
  6. package/dist/hooks/architecture-review-gate/index.d.ts +58 -0
  7. package/dist/hooks/architecture-review-gate/index.js +250 -0
  8. package/dist/hooks/changeset-security-gate/index.d.ts +71 -0
  9. package/dist/hooks/changeset-security-gate/index.js +330 -0
  10. package/dist/hooks/dangerous-bash-interceptor/index.d.ts +103 -0
  11. package/dist/hooks/dangerous-bash-interceptor/index.js +669 -0
  12. package/dist/hooks/dependency-audit-gate/index.d.ts +91 -0
  13. package/dist/hooks/dependency-audit-gate/index.js +294 -0
  14. package/dist/hooks/env-file-protection/index.d.ts +55 -0
  15. package/dist/hooks/env-file-protection/index.js +159 -0
  16. package/dist/hooks/local-review-gate/index.d.ts +145 -0
  17. package/dist/hooks/local-review-gate/index.js +374 -0
  18. package/dist/hooks/secret-scanner/index.d.ts +143 -0
  19. package/dist/hooks/secret-scanner/index.js +404 -0
  20. package/hooks/architecture-review-gate.sh +92 -77
  21. package/hooks/changeset-security-gate.sh +114 -149
  22. package/hooks/dangerous-bash-interceptor.sh +168 -386
  23. package/hooks/dependency-audit-gate.sh +115 -156
  24. package/hooks/env-file-protection.sh +130 -97
  25. package/hooks/local-review-gate.sh +523 -410
  26. package/hooks/secret-scanner.sh +210 -200
  27. package/package.json +1 -1
  28. package/templates/architecture-review-gate.dogfood-staged.sh +116 -0
  29. package/templates/changeset-security-gate.dogfood-staged.sh +137 -0
  30. package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
  31. package/templates/dependency-audit-gate.dogfood-staged.sh +138 -0
  32. package/templates/env-file-protection.dogfood-staged.sh +157 -0
  33. package/templates/local-review-gate.dogfood-staged.sh +573 -0
  34. package/templates/secret-scanner.dogfood-staged.sh +240 -0
@@ -0,0 +1,137 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: changeset-security-gate.sh
3
+ # 0.33.0+ — Node-binary shim for `rea hook changeset-security-gate`.
4
+ #
5
+ # Pre-0.33.0 the gate's full body lived here as bash (172 LOC, frontmatter
6
+ # validation + GHSA/CVE scan + MultiEdit-aware tool handling). The
7
+ # migration to the parser-backed Node binary moves all of that into
8
+ # `src/hooks/changeset-security-gate/index.ts`.
9
+ #
10
+ # Behavioral contract is preserved byte-for-byte: exit 0 on
11
+ # pass-through / non-changeset / valid frontmatter, exit 2 on HALT /
12
+ # disclosure leak / malformed frontmatter / malformed payload.
13
+ #
14
+ # # CLI-resolution trust boundary
15
+ #
16
+ # Realpath sandbox check + version probe. Same shape as the 0.32.0
17
+ # pilots.
18
+ #
19
+ # # Fail-closed posture
20
+ #
21
+ # changeset-security-gate is BLOCKING-tier — the pre-0.33.0 bash body
22
+ # refused on GHSA/CVE patterns and on malformed frontmatter. Early-exit
23
+ # branches fail closed AFTER the relevance pre-gate passes.
24
+
25
+ set -uo pipefail
26
+
27
+ # 1. HALT check.
28
+ # shellcheck source=_lib/halt-check.sh
29
+ source "$(dirname "$0")/_lib/halt-check.sh"
30
+ check_halt
31
+ REA_ROOT=$(rea_root)
32
+
33
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
34
+
35
+ # 2. Relevance pre-gate. This is a PreToolUse Write/Edit/MultiEdit/
36
+ # NotebookEdit matcher, so the payload always has a `tool_input.
37
+ # file_path` (or `notebook_path`).
38
+ #
39
+ # 2026-05-15 codex round-2 P2 fix: scan `tool_input.file_path` /
40
+ # `tool_input.notebook_path` ONLY, NOT the raw JSON payload. Pre-fix
41
+ # a Write to `README.md` whose body merely mentions `.changeset/`
42
+ # (e.g. "See .changeset/example.md") tripped the fail-closed branch
43
+ # when the CLI was unbuilt — the substring lived in the
44
+ # tool_input.content blob, not in the target path. The Node body
45
+ # correctly filters by file_path; the shim's pre-gate must match
46
+ # that posture.
47
+ INPUT=$(cat)
48
+ RELEVANT=0
49
+ PROBE=""
50
+ if command -v jq >/dev/null 2>&1; then
51
+ PROBE=$(printf '%s' "$INPUT" | jq -r '(.tool_input.file_path // .tool_input.notebook_path // "")' 2>/dev/null || true)
52
+ if printf '%s' "$PROBE" | grep -qE '\.changeset/'; then
53
+ RELEVANT=1
54
+ fi
55
+ else
56
+ if printf '%s' "$INPUT" | grep -qE '\.changeset/'; then
57
+ RELEVANT=1
58
+ fi
59
+ fi
60
+ if [ "$RELEVANT" -eq 0 ]; then
61
+ exit 0
62
+ fi
63
+
64
+ # 3. Resolve the rea CLI.
65
+ REA_ARGV=()
66
+ RESOLVED_CLI_PATH=""
67
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
68
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
69
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
70
+ elif [ -f "$proj/dist/cli/index.js" ]; then
71
+ REA_ARGV=(node "$proj/dist/cli/index.js")
72
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
73
+ fi
74
+
75
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
76
+ printf 'rea: changeset-security-gate cannot run — the rea CLI is not built.\n' >&2
77
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
78
+ exit 2
79
+ fi
80
+
81
+ # 4. Realpath sandbox check.
82
+ if ! command -v node >/dev/null 2>&1; then
83
+ printf 'rea: changeset-security-gate cannot run — `node` is not on PATH.\n' >&2
84
+ exit 2
85
+ fi
86
+
87
+ sandbox_check=$(node -e '
88
+ const fs = require("fs");
89
+ const path = require("path");
90
+ const cli = process.argv[1];
91
+ const projDir = process.argv[2];
92
+ let real, realProj;
93
+ try { real = fs.realpathSync(cli); } catch (e) {
94
+ process.stdout.write("bad:realpath"); process.exit(1);
95
+ }
96
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
97
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
98
+ }
99
+ const sep = path.sep;
100
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
101
+ if (!(real === realProj || real.startsWith(projWithSep))) {
102
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
103
+ }
104
+ let cur = path.dirname(path.dirname(path.dirname(real)));
105
+ let found = false;
106
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
107
+ const pj = path.join(cur, "package.json");
108
+ if (fs.existsSync(pj)) {
109
+ try {
110
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
111
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
112
+ } catch (e) { /* keep walking */ }
113
+ }
114
+ cur = path.dirname(cur);
115
+ }
116
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
117
+ process.stdout.write("ok");
118
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
119
+
120
+ if [ "$sandbox_check" != "ok" ]; then
121
+ printf 'rea: changeset-security-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
122
+ exit 2
123
+ fi
124
+
125
+ # 5. Version-probe.
126
+ probe_out=$("${REA_ARGV[@]}" hook changeset-security-gate --help 2>&1)
127
+ probe_status=$?
128
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'changeset-security-gate'; then
129
+ printf 'rea: this shim requires the `rea hook changeset-security-gate` subcommand (introduced in 0.33.0).\n' >&2
130
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
131
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
132
+ exit 2
133
+ fi
134
+
135
+ # 6. Forward stdin.
136
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook changeset-security-gate
137
+ 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 $?
@@ -0,0 +1,138 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: dependency-audit-gate.sh
3
+ # 0.33.0+ — Node-binary shim for `rea hook dependency-audit-gate`.
4
+ #
5
+ # Pre-0.33.0 the gate's full body lived here as bash (179 LOC, the
6
+ # segment splitter + install-pattern detection + per-package
7
+ # `npm view` probe). The migration to the parser-backed Node binary
8
+ # moves all of that into `src/hooks/dependency-audit-gate/index.ts`.
9
+ #
10
+ # Behavioral contract is preserved byte-for-byte: exit 0 on
11
+ # pass-through / all-packages-verified, exit 2 on HALT / any package
12
+ # missing / malformed payload.
13
+ #
14
+ # # CLI-resolution trust boundary
15
+ #
16
+ # Realpath sandbox check + version probe. Same shape as the 0.32.0
17
+ # pilots and the env-file-protection shim above.
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.
25
+
26
+ set -uo pipefail
27
+
28
+ # 1. HALT check.
29
+ # shellcheck source=_lib/halt-check.sh
30
+ source "$(dirname "$0")/_lib/halt-check.sh"
31
+ check_halt
32
+ REA_ROOT=$(rea_root)
33
+
34
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
35
+
36
+ # 2. Relevance pre-gate. Look for any install-pattern keyword.
37
+ #
38
+ # 2026-05-15 codex round-2 P2 fix: scan `tool_input.command` ONLY,
39
+ # not the raw JSON payload. Pre-fix `git commit -m "docs: run pnpm
40
+ # install foo before start"` triggered the fail-closed branch on a
41
+ # fresh checkout (the install-pattern regex hit the substring
42
+ # inside the commit-message ARG of the git command, not a real
43
+ # install invocation). The Node body's segment-anchored matcher
44
+ # correctly distinguishes between the two — the shim's pre-gate
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
55
+ fi
56
+ else
57
+ if printf '%s' "$INPUT" | grep -qE '(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]'; then
58
+ RELEVANT=1
59
+ fi
60
+ fi
61
+ if [ "$RELEVANT" -eq 0 ]; then
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
135
+
136
+ # 6. Forward stdin.
137
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook dependency-audit-gate
138
+ exit $?
@@ -0,0 +1,157 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: env-file-protection.sh
3
+ # 0.33.0+ — Node-binary shim for `rea hook env-file-protection`.
4
+ #
5
+ # Pre-0.33.0 the gate's full body lived here as bash (124 LOC, the
6
+ # segment splitter + `source`/`cp` anchor patterns + utility-vs-.env
7
+ # co-occurrence check). The migration to the parser-backed Node binary
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.
11
+ #
12
+ # Behavioral contract is preserved byte-for-byte: exit 0 on
13
+ # pass-through / no-match, exit 2 on HALT / .env access detected /
14
+ # 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
+ # 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.
31
+
32
+ set -uo pipefail
33
+
34
+ # 1. HALT check.
35
+ # shellcheck source=_lib/halt-check.sh
36
+ source "$(dirname "$0")/_lib/halt-check.sh"
37
+ check_halt
38
+ REA_ROOT=$(rea_root)
39
+
40
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
41
+
42
+ # 2. Relevance pre-gate. Capture stdin + check for `.env` substring
43
+ # BEFORE any CLI/sandbox/probe work so unrelated Bash calls
44
+ # (`ls`, `pnpm test`, `git status`, …) exit 0 even when the CLI
45
+ # is missing/stale/sandboxed-out.
46
+ #
47
+ # 2026-05-15 codex round-2 P2 fix: the substring scan MUST run
48
+ # against `tool_input.command` ONLY, not the raw JSON payload —
49
+ # otherwise a benign `git commit -m "stop reading .env"` (where
50
+ # `.env` appears inside the commit message ARG, NOT as a file
51
+ # target) would hit the fail-closed branch on a fresh checkout
52
+ # where the CLI is unbuilt. Pre-fix the raw scan saw the substring
53
+ # inside the payload's "command" string-quoted body and refused.
54
+ #
55
+ # Strategy: extract `tool_input.command` via `jq` (already required
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
73
+ fi
74
+ fi
75
+ if [ "$RELEVANT" -eq 0 ]; then
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
154
+
155
+ # 6. Forward stdin (already captured up-front for the relevance gate).
156
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook env-file-protection
157
+ exit $?