@bookedsolid/rea 0.32.0 → 0.33.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,179 +1,138 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: dependency-audit-gate.sh
3
- # Fires BEFORE every Bash tool call.
4
- # Detects package install commands (npm install, pnpm add, yarn add) and
5
- # verifies the package exists on the registry before allowing the install.
3
+ # 0.33.0+ Node-binary shim for `rea hook dependency-audit-gate`.
6
4
  #
7
- # Exit codes:
8
- # 0 = allow (not an install command, or package verified)
9
- # 2 = block (package not found on registry)
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.
10
25
 
11
26
  set -uo pipefail
12
27
 
13
- # ── 1. Read ALL stdin immediately ─────────────────────────────────────────────
14
- INPUT=$(cat)
15
-
16
- # ── 2. Dependency check ──────────────────────────────────────────────────────
17
- if ! command -v jq >/dev/null 2>&1; then
18
- printf 'REA ERROR: jq is required but not installed.\n' >&2
19
- printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
20
- exit 2
21
- fi
22
-
23
- # ── 3. HALT check ────────────────────────────────────────────────────────────
24
- # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
28
+ # 1. HALT check.
25
29
  # shellcheck source=_lib/halt-check.sh
26
30
  source "$(dirname "$0")/_lib/halt-check.sh"
27
31
  check_halt
28
32
  REA_ROOT=$(rea_root)
29
33
 
30
- # ── 4. Parse command ──────────────────────────────────────────────────────────
31
- CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
32
-
33
- if [[ -z "$CMD" ]]; then
34
- exit 0
35
- fi
36
-
37
- # ── 5. Detect package install commands ────────────────────────────────────────
38
- # Match: npm install <pkg>, npm i <pkg>, pnpm add <pkg>, yarn add <pkg>
39
- # Skip: npm install (no args), npm ci, npm install --save-dev (without new pkg)
34
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
40
35
 
41
- extract_packages() {
42
- local cmd="$1"
43
-
44
- # 0.15.0 fix: the previous parser ran `grep` against the entire bash
45
- # command string with no segment boundary anchor. A heredoc body or
46
- # commit-message containing `pnpm install` (e.g. inside
47
- # `git commit -m "$(cat <<EOF ... pnpm install ... EOF)"`) matched the
48
- # grep, the `.*` in the sed stripped up to that occurrence, and the rest
49
- # of the command (`chore:`, `&&`, `||`, etc.) was passed to
50
- # `npm view <token> name` and reported as missing packages. The hook
51
- # then refused to commit perfectly innocent code.
52
- #
53
- # Fix: split the command on shell command separators (`;`, `&&`, `||`,
54
- # `|`, newlines) and only run the install-detection on segments whose
55
- # FIRST non-whitespace token is one of the install commands. Heredoc
56
- # bodies inside `$()` substitutions are NOT split into separate segments
57
- # the entire `$(cat <<EOF ... EOF)` is one token attached to the
58
- # outer command — but they're never the FIRST token on a segment, so
59
- # the anchor rejects them.
60
-
61
- # 0.17.0 helix-017 #3: unwrap nested-shell wrappers (`bash -c 'PAYLOAD'`,
62
- # `sh -lc "PAYLOAD"`, etc.) before splitting so the inner install
63
- # command becomes a segment that anchors against the install-pattern
64
- # check below. Pre-fix `bash -lc 'npm install pkg'` produced a single
65
- # segment whose first token was `bash` — install-detection skipped.
66
- # 0.17.0 helix-019 #3: delegate splitting to the shared
67
- # `_rea_split_segments` so this gate inherits the full separator set
68
- # (including bare `&` background-process operator added in 0.16.1)
69
- # and the quote-mask that prevents over-fire from in-quote separators.
70
- # Pre-fix the local segmenter splat on `|||&&|;|` only, missing bare
71
- # `&` — `echo warmup & pnpm add lodash` stayed merged into one segment
72
- # and the install-pattern leading-token check skipped it entirely.
73
- local segments
74
- if [ -f "$(dirname "$0")/_lib/cmd-segments.sh" ]; then
75
- # shellcheck source=_lib/cmd-segments.sh
76
- source "$(dirname "$0")/_lib/cmd-segments.sh"
77
- segments=$(_rea_split_segments "$cmd")
78
- else
79
- # Fallback (lib unavailable): legacy local splitter preserved.
80
- segments=$(printf '%s\n' "$cmd" | sed -E 's/(\|\||\&\&|;|\||\&)/\n/g')
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
81
55
  fi
82
-
83
- while IFS= read -r segment; do
84
- # Trim leading whitespace.
85
- segment="${segment#"${segment%%[![:space:]]*}"}"
86
- # Anchor to start: only match when the install command is the FIRST
87
- # thing on the segment, optionally preceded by `sudo` / `exec` /
88
- # `time` / etc.
89
- #
90
- # 0.16.1 helix-016 P2 fix: also strip leading KEY=VALUE env-var
91
- # assignments. Pre-fix the prefix allow-list only permitted
92
- # sudo/exec/time, so `CI=1 pnpm add foo` and
93
- # `NODE_ENV=development npm install bar` bypassed the audit
94
- # entirely. POSIX shell allows any number of leading KEY=VALUE
95
- # assignments before the command word; we strip them the same
96
- # way the shell does.
97
- local stripped_segment
98
- stripped_segment=$(printf '%s' "$segment" | sed -E 's/^([[:space:]]*[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+)+//')
99
-
100
- if printf '%s' "$stripped_segment" | grep -qiE '^(sudo[[:space:]]+|exec[[:space:]]+|time[[:space:]]+)*(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]+'; then
101
- # Strip the leading prefix wrappers + install command, leaving args.
102
- local after_cmd
103
- after_cmd=$(printf '%s' "$stripped_segment" | sed -E 's/^(sudo[[:space:]]+|exec[[:space:]]+|time[[:space:]]+)*(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]+//')
104
-
105
- for token in $after_cmd; do
106
- if [[ "$token" == -* ]]; then continue; fi
107
- if [[ "$token" == ./* || "$token" == /* || "$token" == ../* ]]; then continue; fi
108
- if [[ -z "$token" ]]; then continue; fi
109
- # 0.16.1: tighten token classification (helix-016 sibling concern).
110
- # A "package name" is something that doesn't contain shell
111
- # metacharacters — `2>&1`, `$VAR`, etc. are never valid npm
112
- # package names. Skip any token containing `=`, `>`, `<`, `&`,
113
- # `|`, `;`, `$`, backtick, or quotes.
114
- if [[ "$token" == *=* || "$token" == *">"* || "$token" == *"<"* ||
115
- "$token" == *"&"* || "$token" == *"|"* || "$token" == *";"* ||
116
- "$token" == *'$'* || "$token" == *'`'* ||
117
- "$token" == *'"'* || "$token" == *"'"* ]]; then continue; fi
118
- # `npm view` can't validate `@workspace:*` / `link:` / `file:`
119
- # prefixes (workspace protocols). Skip them — they're never npm
120
- # registry packages.
121
- if [[ "$token" == workspace:* || "$token" == link:* || "$token" == file:* || "$token" == git+* ]]; then continue; fi
122
- local pkg_name
123
- pkg_name=$(printf '%s' "$token" | sed -E 's/@[^@/]+$//')
124
- if [[ -z "$pkg_name" ]]; then
125
- pkg_name="$token"
126
- fi
127
- printf '%s\n' "$pkg_name"
128
- done
129
- fi
130
- done <<< "$segments"
131
- }
132
-
133
- PACKAGES=$(extract_packages "$CMD")
134
-
135
- if [[ -z "$PACKAGES" ]]; then
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
136
62
  exit 0
137
63
  fi
138
64
 
139
- # ── 6. Verify packages exist on registry ──────────────────────────────────────
140
- FAILED=""
141
- CHECKED=0
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
142
75
 
143
- while IFS= read -r pkg; do
144
- [[ -z "$pkg" ]] && continue
145
- CHECKED=$((CHECKED + 1))
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
146
81
 
147
- # Cap at 5 packages per command to avoid slow hook
148
- if [[ $CHECKED -gt 5 ]]; then
149
- break
150
- fi
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
151
87
 
152
- # Use npm view to check if package exists
153
- # macOS doesn't have `timeout` by default, use a background process with kill
154
- if command -v timeout >/dev/null 2>&1; then
155
- if ! timeout 5 npm view "$pkg" name >/dev/null 2>&1; then
156
- FAILED="${FAILED} - ${pkg}\n"
157
- fi
158
- else
159
- # Fallback: run npm view without timeout (still fast for simple checks)
160
- if ! npm view "$pkg" name >/dev/null 2>&1; then
161
- FAILED="${FAILED} - ${pkg}\n"
162
- fi
163
- fi
164
- done <<< "$PACKAGES"
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
165
125
 
166
- if [[ -n "$FAILED" ]]; then
167
- {
168
- printf 'DEPENDENCY AUDIT: Package not found on npm registry\n'
169
- printf '\n'
170
- printf ' The following packages could not be verified:\n'
171
- printf '%b' "$FAILED"
172
- printf '\n'
173
- printf ' Rule: All packages must exist on the npm registry before installation.\n'
174
- printf ' Check: Is the package name spelled correctly? Does it exist on npmjs.com?\n'
175
- } >&2
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
176
133
  exit 2
177
134
  fi
178
135
 
179
- exit 0
136
+ # 6. Forward stdin.
137
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook dependency-audit-gate
138
+ exit $?
@@ -1,124 +1,157 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: env-file-protection.sh
3
- # Fires BEFORE every Bash tool call.
4
- # Blocks commands that read .env* / .envrc files via shell text utilities.
3
+ # 0.33.0+ Node-binary shim for `rea hook env-file-protection`.
5
4
  #
6
- # Rationale: .env files contain credentials. Reading them via Bash exposes
7
- # the values in command output, logs, and agent transcripts. Load credentials
8
- # in code only (process.env, os.environ, etc.) never via shell reads.
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.
9
11
  #
10
- # Trigger: command matches ALL of:
11
- # 1. Uses a text-reading utility (list below)
12
- # 2. References a .env* or .envrc filename
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).
13
15
  #
14
- # Exit codes:
15
- # 0 = allow
16
- # 2 = block (env file read detected)
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.
17
31
 
18
32
  set -uo pipefail
19
33
 
20
- # Source shared shell-segment splitter (0.15.0). Replaces full-command
21
- # grep that false-positives on commit messages mentioning `.env` (e.g.
22
- # `git commit -m "stop reading .env via cat"`).
23
- # shellcheck source=_lib/cmd-segments.sh
24
- source "$(dirname "$0")/_lib/cmd-segments.sh"
25
-
26
- INPUT=$(cat)
27
-
28
- # ── Dependency check ──────────────────────────────────────────────────────────
29
- if ! command -v jq >/dev/null 2>&1; then
30
- printf 'REA ERROR: jq is required but not installed.\n' >&2
31
- printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
32
- exit 2
33
- fi
34
-
35
- # ── HALT check ────────────────────────────────────────────────────────────────
36
- # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
34
+ # 1. HALT check.
37
35
  # shellcheck source=_lib/halt-check.sh
38
36
  source "$(dirname "$0")/_lib/halt-check.sh"
39
37
  check_halt
40
38
  REA_ROOT=$(rea_root)
41
39
 
42
- CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
40
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
43
41
 
44
- if [[ -z "$CMD" ]]; then
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
45
76
  exit 0
46
77
  fi
47
78
 
48
- truncate_cmd() {
49
- local STR="$1"
50
- local MAX=100
51
- if [[ ${#STR} -gt $MAX ]]; then
52
- printf '%s' "${STR:0:$MAX}..."
53
- else
54
- printf '%s' "$STR"
55
- fi
56
- }
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
57
89
 
58
- # Text-reading utilities (shell and common alternatives)
59
- # Defense-in-depth: this list catches the most common shell-based exfiltration
60
- # vectors. It is NOT exhaustive. Known gaps include:
61
- # - Docker volume mounts (docker run -v .env:/...) — separate concern
62
- # - Editor commands (vim, nano, code) not typically used by agents
63
- # - Redirects/process substitution (< .env) without a listed utility
64
- # - Network tools (curl file://, nc) low-risk in agent context
65
- # The goal is to block casual and accidental reads, not defeat a determined
66
- # adversary with shell access.
67
- PATTERN_UTILITY='(cat|head|tail|less|more|grep|sed|awk|bat|strings|printf|xargs|tee|jq|python3?[[:space:]]+-c|ruby[[:space:]]+-e)[[:space:]]'
68
- # Also catch: source/., cp (reads then writes elsewhere).
69
- #
70
- # 0.16.3 discord-ops Round 9 #4 fix: anchored on segment-start. Pre-fix
71
- # `any_segment_matches` matched anywhere in the segment, so
72
- # `git commit -m "fix: don't source .env files"` fired even though no
73
- # real source-of-.env was happening — the trigger words appeared inside
74
- # the quoted commit-message body. The patterns are command prefixes
75
- # (`source PATH`, `. PATH`, `cp X PATH`), so segment-start anchoring is
76
- # the correct shape.
77
- PATTERN_SOURCE='(source|\.)[[:space:]]+[^;|&]*\.env'
78
- PATTERN_CP_ENV='cp[[:space:]]+[^;|&]*\.env'
79
- # .env* files or .envrc (direnv)
80
- PATTERN_ENV_FILE='(\.env[a-zA-Z0-9._-]*|\.envrc)([[:space:]]|"|'"'"'|$)'
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
81
99
 
82
- # 0.16.2 helix-017 P2 #2: utility AND env-filename must co-occur within
83
- # the SAME shell segment. Pre-fix this set two independent booleans
84
- # (any segment with utility OR any segment with .env) and AND'd them,
85
- # which false-positived across multi-segment constructions like
86
- # `echo "log: cat is broken" ; touch foo.env` (utility in segment 1,
87
- # .env name in segment 2). Detection is fundamentally a same-segment
88
- # co-occurrence property.
89
- MATCHES_BOTH_SAME_SEGMENT=0
90
- if any_segment_matches_both "$CMD" "$PATTERN_UTILITY" "$PATTERN_ENV_FILE"; then
91
- MATCHES_BOTH_SAME_SEGMENT=1
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
92
105
  fi
93
106
 
94
- # Direct source/cp of .env files — always block (segment-start anchored
95
- # per discord-ops Round 9 #4).
96
- if any_segment_starts_with "$CMD" "$PATTERN_SOURCE" || \
97
- any_segment_starts_with "$CMD" "$PATTERN_CP_ENV"; then
98
- TRUNCATED_CMD=$(truncate_cmd "$CMD")
99
- {
100
- printf 'ENV FILE PROTECTION: Direct sourcing or copying of .env files is blocked.\n'
101
- printf '\n'
102
- printf ' Command: %s\n' "$TRUNCATED_CMD"
103
- printf '\n'
104
- printf ' Rule: Load credentials in code only — never via shell source or cp.\n'
105
- printf ' Use: process.env.VAR_NAME, os.environ["VAR_NAME"], etc.\n'
106
- } >&2
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
107
142
  exit 2
108
143
  fi
109
144
 
110
- if [[ $MATCHES_BOTH_SAME_SEGMENT -eq 1 ]]; then
111
- TRUNCATED_CMD=$(truncate_cmd "$CMD")
112
- {
113
- printf 'ENV FILE PROTECTION: Reading .env files via Bash is blocked.\n'
114
- printf '\n'
115
- printf ' Command: %s\n' "$TRUNCATED_CMD"
116
- printf '\n'
117
- printf ' Rule: Load credentials in code only, never via shell.\n'
118
- printf ' Use: process.env.VAR_NAME, os.environ["VAR_NAME"], etc.\n'
119
- printf ' .env files must not be read via shell utilities in agent sessions.\n'
120
- } >&2
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
121
152
  exit 2
122
153
  fi
123
154
 
124
- exit 0
155
+ # 6. Forward stdin (already captured up-front for the relevance gate).
156
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook env-file-protection
157
+ exit $?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.32.0",
3
+ "version": "0.33.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)",