@bookedsolid/rea 0.34.0 → 0.36.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,273 +1,186 @@
1
- #!/usr/bin/env bash
1
+ #!/bin/bash
2
2
  # PreToolUse hook: protected-paths-bash-gate.sh
3
+ # 0.35.0+ — Node-binary shim for `rea hook protected-paths-bash-gate`.
3
4
  #
4
- # 0.23.0+ thin shim. Forwards stdin (Claude Code's tool_input JSON)
5
- # to `rea hook scan-bash --mode protected`, parses the verdict, exits
6
- # 0/2 accordingly.
5
+ # Pre-0.35.0 this was a thin bash shim over `rea hook scan-bash --mode
6
+ # protected` (the parser-backed AST walker that replaces the 536-line
7
+ # pre-0.23.0 regex pipeline). The full bash body is preserved at
8
+ # `__tests__/hooks/parity/baselines/protected-paths-bash-gate.sh.pre-0.35.0`.
7
9
  #
8
- # Pre-0.23.0 this hook was a 536-line bash regex pipeline. The rewrite
9
- # moved every detection rule into a parser-backed AST walker at
10
- # `src/hooks/bash-scanner/`. helix-023 and discord-ops Round 13 closed
11
- # 9 bypass classes that lived in the old segmenter; the new scanner
12
- # closes them definitionally — there is no segmenter to bypass.
10
+ # This shim now resolves the CLI through the same 2-tier sandboxed
11
+ # resolver as the 0.32.0+ pilots and calls `rea hook protected-paths-
12
+ # bash-gate` directly eliminating the shim → CLI → scanner-module
13
+ # subprocess hop entirely.
13
14
  #
14
- # Failure mode: if the rea CLI cannot be located in a SANDBOXED tier
15
- # (consumer's `node_modules/@bookedsolid/rea/dist/cli/index.js` or the
16
- # rea repo's own `dist/cli/index.js`), we REFUSE the command. NEVER
17
- # ALLOW on uncertainty. Operators need `@bookedsolid/rea` installed for
18
- # the gate to work; `rea doctor` flags missing CLI as a P0.
15
+ # Behavioral contract is preserved byte-for-byte: exit 0 on allow,
16
+ # exit 2 on HALT / verdict block / malformed payload / sandbox fail.
19
17
  #
20
- # Defense in depth: the shim does NOT trust the CLI exit code alone.
21
- # After capturing stdout we re-parse the verdict with `node -e` to
22
- # confirm:
23
- # 1. it is valid JSON,
24
- # 2. the top-level shape has `.verdict == "allow"|"block"`,
25
- # 3. the verdict matches the exit code (allow→0, block→2).
26
- # Any disagreement → exit 2.
18
+ # # CLI-resolution trust boundary
27
19
  #
28
- # Codex round 4 Finding 2: dropped tier 1 (PATH `command -v rea`) and
29
- # tier 2 (`node_modules/.bin/rea` symlink). Both are workspace-attacker
30
- # controllable: an attacker who can write a file at
31
- # `node_modules/.bin/rea` (or set PATH to a directory they own) can
32
- # stage a fake `rea` binary that exits 0 with `{"verdict":"allow"}` and
33
- # subvert the gate.
20
+ # Mirrors the 0.32.0 final shim shape.
34
21
  #
35
- # Trust-boundary HONESTY (codex round 5 F5): the shim's structural
36
- # defense is the realpath sandbox (round 4 #2 + round 5 F2). It defeats:
37
- # - PATH-attacker hijack via fake `rea` binary
38
- # - node_modules/.bin/rea symlink-bin hijack
39
- # - node_modules/@bookedsolid/rea -> /tmp/sym-attacker symlink-out
40
- # - intra-project hijack without a matching package.json
41
- # It does NOT defeat an attacker who writes a forged dist/cli/index.js
42
- # AND a matching package.json directly into node_modules/. At that level
43
- # the attacker has already compromised the package install pipeline (npm
44
- # registry, lockfile, dependency confusion) and any dependency the agent
45
- # imports is also forgeable — hook-tier defense is past. The trust
46
- # boundary is package-tier integrity (npm provenance + manifest
47
- # verification), not the bash gate. See THREAT_MODEL §8.3 + docs/
48
- # architecture/bash-scanner.md for the full rationale.
22
+ # # Fail-closed posture
49
23
  #
50
- # Tier defense: realpath the resolved CLI before exec. Two complementary
51
- # checks:
52
- # PRIMARY (codex round 5 F2): realpath(cli) MUST live INSIDE
53
- # realpath(CLAUDE_PROJECT_DIR). Catches symlink-out-of-project attacks
54
- # where the attacker writes `node_modules/@bookedsolid/rea` as a
55
- # symlink to a tree under `/tmp/sym-attacker` containing a forged
56
- # `package.json` with name `@bookedsolid/rea` and a forged
57
- # `dist/cli/index.js` that exits 0 with `{"verdict":"allow"}`. Pre-fix
58
- # the secondary check (package.json walk-up) was the ONLY guard, and
59
- # the attacker satisfies it by placing a forged package.json in their
60
- # own tree.
61
- # SECONDARY: walk up from the resolved CLI looking for an ancestor
62
- # `package.json` whose `name` is `@bookedsolid/rea`. This guards
63
- # against intra-project symlinks where the realpath stays inside
64
- # the project (e.g. accidentally pointing dist/ at node_modules/).
24
+ # protected-paths-bash-gate is a Tier-1 security gate. The pre-0.35.0
25
+ # bash body refused on uncertainty. Early-exit branches fail closed
26
+ # AFTER the relevance pre-gate passes. Irrelevant Bash calls exit 0
27
+ # regardless of CLI state.
65
28
  #
66
- # Codex round 2 R2-3 (preserved): REA_NODE_CLI env-var honoring REMOVED.
67
- # Test harnesses must set CLAUDE_PROJECT_DIR to a directory whose
68
- # `dist/cli/index.js` (or `node_modules/@bookedsolid/rea/...`) holds
69
- # the trusted CLI build. The shim NEVER reads REA_NODE_CLI.
29
+ # # Relevance pre-gate
70
30
  #
71
- # Exit codes:
72
- # 0 = allow (verdict.verdict == "allow")
73
- # 2 = block (verdict.verdict == "block", or any failure path)
31
+ # Substring scan over the extracted command for any of the protected-
32
+ # path markers: .claude/, .husky/, .rea/policy.yaml, .rea/HALT, the
33
+ # verdict cache paths. When the CLI is missing AND none of these
34
+ # substrings appear, exit 0 (the pre-0.35.0 bash body would have
35
+ # allowed). When the CLI is missing AND a marker DOES match, preserve
36
+ # fail-closed.
74
37
 
75
38
  set -uo pipefail
76
39
 
77
- proj="${CLAUDE_PROJECT_DIR:-$(pwd)}"
40
+ # 1. HALT check.
41
+ # shellcheck source=_lib/halt-check.sh
42
+ source "$(dirname "$0")/_lib/halt-check.sh"
43
+ check_halt
44
+ REA_ROOT=$(rea_root)
78
45
 
79
- # Resolve the rea CLI through a fixed 2-tier sandboxed order.
80
- # 1. node_modules/@bookedsolid/rea/dist/cli/index.js (consumer-side
81
- # direct dist execution the published artifact)
82
- # 2. dist/cli/index.js under CLAUDE_PROJECT_DIR (the rea repo's own
83
- # dogfood install, where `rea` is the package itself)
84
- #
85
- # We build an `argv` array rather than a string so paths containing
86
- # whitespace round-trip safely.
46
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
47
+
48
+ # 2. Capture stdin once.
49
+ INPUT=$(cat)
50
+
51
+ # 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
87
52
  REA_ARGV=()
88
53
  RESOLVED_CLI_PATH=""
89
54
  if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
90
55
  REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
91
56
  RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
92
57
  elif [ -f "$proj/dist/cli/index.js" ]; then
93
- # rea repo dogfood: the project IS @bookedsolid/rea.
94
58
  REA_ARGV=(node "$proj/dist/cli/index.js")
95
59
  RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
96
60
  fi
97
61
 
62
+ # 3b. Relevance pre-gate. Only used when the CLI is missing.
98
63
  if [ "${#REA_ARGV[@]}" -eq 0 ]; then
99
- printf 'rea: CLI not found at sandboxed tiers (node_modules/@bookedsolid/rea/dist or dist/).\n' >&2
100
- printf 'Install @bookedsolid/rea via npm/pnpm and run `rea doctor`.\n' >&2
101
- printf 'Refusing the Bash command on uncertainty.\n' >&2
64
+ CLI_MISSING_CMD=""
65
+ if command -v jq >/dev/null 2>&1; then
66
+ CLI_MISSING_CMD=$(printf '%s' "$INPUT" | jq -r '
67
+ (.tool_input.command // "") | tostring
68
+ ' 2>/dev/null || true)
69
+ else
70
+ CLI_MISSING_CMD="$INPUT"
71
+ fi
72
+ if [ -z "$CLI_MISSING_CMD" ]; then
73
+ exit 0
74
+ fi
75
+ CLI_MISSING_RELEVANT=0
76
+ case "$CLI_MISSING_CMD" in
77
+ *".claude/"*) CLI_MISSING_RELEVANT=1 ;;
78
+ *".husky/"*) CLI_MISSING_RELEVANT=1 ;;
79
+ *".rea/policy.yaml"*) CLI_MISSING_RELEVANT=1 ;;
80
+ *".rea/HALT"*) CLI_MISSING_RELEVANT=1 ;;
81
+ *".rea/last-review"*) CLI_MISSING_RELEVANT=1 ;;
82
+ *".claude\\"*|*".husky\\"*|*".rea\\"*) CLI_MISSING_RELEVANT=1 ;;
83
+ esac
84
+ # Codex round-1 P2 fix: scan policy.protected_writes entries too so a
85
+ # consumer-defined protected path isn't silently allowed when the CLI
86
+ # is missing. Read the policy via the same awk parser the consumer-
87
+ # facing relevance pre-gates use for blocked_paths.
88
+ if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
89
+ POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
90
+ if [ -f "$POLICY_FILE" ]; then
91
+ while IFS= read -r entry; do
92
+ [ -z "$entry" ] && continue
93
+ base="$entry"
94
+ case "$base" in
95
+ */) base="${base%/}" ;;
96
+ esac
97
+ [ -z "$base" ] && continue
98
+ case "$CLI_MISSING_CMD" in
99
+ *"$base"*) CLI_MISSING_RELEVANT=1; break ;;
100
+ esac
101
+ done < <(awk '
102
+ /^protected_writes:/ { in_block=1; next }
103
+ in_block && /^[[:space:]]*-/ {
104
+ sub(/^[[:space:]]*-[[:space:]]*/, "")
105
+ gsub(/^["'\'']/, "")
106
+ gsub(/["'\'']$/, "")
107
+ print
108
+ next
109
+ }
110
+ in_block && /^[^[:space:]-]/ { in_block=0 }
111
+ ' "$POLICY_FILE" 2>/dev/null)
112
+ fi
113
+ fi
114
+ if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
115
+ exit 0
116
+ fi
117
+ printf 'rea: protected-paths-bash-gate cannot run — the rea CLI is not built.\n' >&2
118
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
119
+ printf 'This shim fails closed because the pre-0.35.0 bash body enforced protected-path refusal without a CLI.\n' >&2
102
120
  exit 2
103
121
  fi
104
122
 
105
- # Codex round 4 Finding 2 tier defense: realpath the resolved CLI and
106
- # verify it lives inside the sandboxed dirs. Catches symlink games.
107
- # We require Node for the verifier anyway (below) — use it here too.
123
+ # 4. Realpath sandbox check.
108
124
  if ! command -v node >/dev/null 2>&1; then
109
- printf 'rea: node not on PATH (required to realpath verify scan-bash CLI). Refusing.\n' >&2
125
+ printf 'rea: protected-paths-bash-gate cannot run `node` is not on PATH.\n' >&2
126
+ printf 'Install Node 22+ (engines.node) to restore protected-path refusal.\n' >&2
110
127
  exit 2
111
128
  fi
129
+
112
130
  sandbox_check=$(node -e '
113
131
  const fs = require("fs");
114
132
  const path = require("path");
115
133
  const cli = process.argv[1];
116
134
  const projDir = process.argv[2];
117
- let real;
135
+ let real, realProj;
118
136
  try { real = fs.realpathSync(cli); } catch (e) {
119
- process.stdout.write("bad:realpath:" + (e && e.message ? e.message : String(e)));
120
- process.exit(1);
137
+ process.stdout.write("bad:realpath"); process.exit(1);
121
138
  }
122
- // Codex round 5 F2 (P0) — PRIMARY check: realpath(cli) must live
123
- // INSIDE realpath(CLAUDE_PROJECT_DIR). Pre-fix an attacker who
124
- // writes a symlink at node_modules/@bookedsolid/rea pointing to
125
- // /tmp/sym-attacker (containing a forged package.json + dist/cli/index.js
126
- // that exits 0 with verdict:"allow") completely defeated the
127
- // sandbox — the package.json walk-up was satisfied by the forged
128
- // file in the attacker tree. Containing realCli to realProj closes
129
- // every symlink-out attack class because the attacker no longer
130
- // controls a path inside the project tree.
131
- let realProj;
132
139
  try { realProj = fs.realpathSync(projDir); } catch (e) {
133
- process.stdout.write("bad:realpath-proj:" + (e && e.message ? e.message : String(e)));
134
- process.exit(1);
140
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
135
141
  }
136
- const projWithSep = realProj.endsWith(path.sep) ? realProj : realProj + path.sep;
142
+ const sep = path.sep;
143
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
137
144
  if (!(real === realProj || real.startsWith(projWithSep))) {
138
- process.stdout.write("bad:cli-escapes-project:" + real + ":proj=" + realProj);
139
- process.exit(1);
145
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
140
146
  }
141
- // Codex round 4 Finding 2 (now SECONDARY) — shape + ancestor pkg.json.
142
- //
143
- // Acceptance: the resolved CLI must end in `.../dist/cli/index.js`
144
- // and have an ancestor `package.json` whose `name` is `@bookedsolid/rea`.
145
- // This guards against intra-project hijack where an attacker writes
146
- // a symlink at node_modules/@bookedsolid/rea pointing to a sibling
147
- // tree INSIDE the project (e.g. ./scratch/) — the PRIMARY check
148
- // accepts it (still inside project root) but the package.json walk-up
149
- // refuses unless that tree contains the canonical package metadata.
147
+ // Codex round-1 P1 fix: enforce dist/cli/index.js shape (see
148
+ // settings-protection.sh).
150
149
  const expectedEnd = path.join("dist", "cli", "index.js");
151
150
  if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
152
- process.stdout.write("bad:cli-shape:" + real);
153
- process.exit(1);
151
+ process.stdout.write("bad:cli-shape"); process.exit(1);
154
152
  }
155
- // Walk up looking for package.json with the protected name.
156
- let cur = path.dirname(path.dirname(path.dirname(real))); // pkg root
153
+ let cur = path.dirname(path.dirname(path.dirname(real)));
157
154
  let found = false;
158
155
  for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
159
156
  const pj = path.join(cur, "package.json");
160
157
  if (fs.existsSync(pj)) {
161
158
  try {
162
159
  const data = JSON.parse(fs.readFileSync(pj, "utf8"));
163
- if (data && data.name === "@bookedsolid/rea") {
164
- found = true;
165
- break;
166
- }
167
- } catch (e) {
168
- // Continue walking up.
169
- }
160
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
161
+ } catch (e) { /* keep walking */ }
170
162
  }
171
163
  cur = path.dirname(cur);
172
164
  }
173
- if (!found) {
174
- process.stdout.write("bad:no-rea-pkg:" + real);
175
- process.exit(1);
176
- }
165
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
177
166
  process.stdout.write("ok");
178
- process.exit(0);
179
- ' "$RESOLVED_CLI_PATH" "$proj" 2>&1)
180
- sandbox_status=$?
181
- if [ "$sandbox_status" -ne 0 ] || [ "$sandbox_check" != "ok" ]; then
182
- printf 'rea: scan-bash CLI realpath escapes sandbox (%s). Refusing.\n' "$sandbox_check" >&2
167
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
168
+
169
+ if [ "$sandbox_check" != "ok" ]; then
170
+ printf 'rea: protected-paths-bash-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
183
171
  exit 2
184
172
  fi
185
173
 
186
- # 0.28.0 helix-027 (bash total-lockout postmortem) — version-probe per
187
- # shim. The 0.23.0+ scan-bash subcommand is required; if the resolved
188
- # CLI is older than 0.23.0 it will refuse with "unknown command" and the
189
- # shim's exit-code dispatch lands on the catch-all "exit 2" branch
190
- # WITHOUT explaining why. That was the symptom that locked Jake's
191
- # helix workspace out of every Bash tool until he ran `pnpm install`.
192
- #
193
- # The probe runs `rea hook scan-bash --help` once per shim invocation
194
- # (~30 LOC) and refuses with an actionable message if the subcommand
195
- # does not exist. Probe failure is fail-closed (exit 2) — same posture
196
- # the rest of the shim takes — but the message tells the operator
197
- # exactly what to do (`pnpm install`).
198
- probe_out=$("${REA_ARGV[@]}" hook scan-bash --help 2>&1)
174
+ # 5. Version-probe.
175
+ probe_out=$("${REA_ARGV[@]}" hook protected-paths-bash-gate --help 2>&1)
199
176
  probe_status=$?
200
- if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'scan-bash' -e '--mode'; then
201
- printf 'rea: this shim requires the `rea hook scan-bash` subcommand (introduced in 0.23.0).\n' >&2
177
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'protected-paths-bash-gate'; then
178
+ printf 'rea: this shim requires the `rea hook protected-paths-bash-gate` subcommand (introduced in 0.35.0).\n' >&2
202
179
  printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
203
- printf 'Run `pnpm install` (or `npm install`) to sync the CLI to the version this shim expects.\n' >&2
180
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
204
181
  exit 2
205
182
  fi
206
183
 
207
- # Capture stdin once and forward it to the CLI.
208
- payload=$(cat)
209
- if [ -z "$payload" ]; then
210
- exit 0
211
- fi
212
-
213
- # Run the scanner.
214
- verdict=$(printf '%s' "$payload" | "${REA_ARGV[@]}" hook scan-bash --mode protected)
215
- status=$?
216
-
217
- # Defense in depth — verify the verdict JSON matches the exit code.
218
- verifier='try {
219
- const raw = require("fs").readFileSync(0, "utf8");
220
- if (raw.trim().length === 0) { process.stdout.write("bad:empty"); process.exit(1); }
221
- const v = JSON.parse(raw);
222
- if (typeof v !== "object" || v === null || Array.isArray(v)) {
223
- process.stdout.write("bad:non-object"); process.exit(1);
224
- }
225
- if (v.verdict !== "allow" && v.verdict !== "block") {
226
- process.stdout.write("bad:verdict-shape:" + String(v.verdict)); process.exit(1);
227
- }
228
- process.stdout.write("ok:" + v.verdict); process.exit(0);
229
- } catch (e) {
230
- process.stdout.write("bad:" + (e && e.message ? e.message : String(e))); process.exit(1);
231
- }'
232
-
233
- verdict_check=$(printf '%s' "$verdict" | node -e "$verifier" 2>&1)
234
- verdict_check_status=$?
235
-
236
- case "$status" in
237
- 0)
238
- if [ "$verdict_check_status" -ne 0 ]; then
239
- printf 'rea: scan-bash exited 0 but verdict JSON is malformed (%s). Refusing on uncertainty.\n' "$verdict_check" >&2
240
- exit 2
241
- fi
242
- if [ "$verdict_check" != "ok:allow" ]; then
243
- printf 'rea: scan-bash exit 0 but verdict says %s. Refusing on uncertainty.\n' "$verdict_check" >&2
244
- exit 2
245
- fi
246
- exit 0
247
- ;;
248
- 2)
249
- # Block path — the CLI has already emitted the operator-facing
250
- # reason on stderr. We additionally verify the JSON shape so a
251
- # forged `/bin/true` (which would never reach here, but be defensive)
252
- # cannot bypass.
253
- if [ "$verdict_check_status" -ne 0 ]; then
254
- # Malformed stdout under exit 2 is unusual but harmless — the
255
- # block path is still honored.
256
- exit 2
257
- fi
258
- if [ "$verdict_check" != "ok:block" ]; then
259
- printf 'rea: scan-bash exit 2 but verdict says %s. Refusing on uncertainty.\n' "$verdict_check" >&2
260
- exit 2
261
- fi
262
- exit 2
263
- ;;
264
- *)
265
- # Unexpected exit code — treat as block on uncertainty. The CLI
266
- # writes its own diagnostic; we add an explicit refusal.
267
- printf 'rea: scan-bash exited %d (expected 0/2). Refusing on uncertainty.\n' "$status" >&2
268
- if [ -n "$verdict" ]; then
269
- printf 'rea: scan-bash stdout was: %s\n' "$verdict" >&2
270
- fi
271
- exit 2
272
- ;;
273
- esac
184
+ # 6. Forward stdin (already captured up-front).
185
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook protected-paths-bash-gate
186
+ exit $?