@bookedsolid/rea 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,284 +1,180 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: blocked-paths-enforcer.sh
3
- # Fires BEFORE every Write or Edit tool call.
4
- # Reads blocked_paths from .rea/policy.yaml and blocks matching writes.
3
+ # 0.35.0+ Node-binary shim for `rea hook blocked-paths-enforcer`.
5
4
  #
6
- # This enforces the policy layer at the hook level even if an agent ignores
7
- # the CLAUDE.md rules or skips the orchestrator, the hook will catch it.
5
+ # Pre-0.35.0 the gate's full body lived here as bash (284 LOC). The
6
+ # full bash body is preserved at
7
+ # `__tests__/hooks/parity/baselines/blocked-paths-enforcer.sh.pre-0.35.0`.
8
8
  #
9
- # Exit codes:
10
- # 0 = allow (path not blocked)
11
- # 2 = block (path matches a blocked_paths entry)
9
+ # Migration moves the enforcement logic (path normalization, traversal
10
+ # reject, glob/prefix/exact matching, symlink resolution, agent-
11
+ # writable allow-list) into `src/hooks/blocked-paths-enforcer/index.ts`.
12
+ # This shim is the Claude Code dispatcher's view of the hook — it
13
+ # forwards stdin to the CLI and exits with whatever the CLI returns.
14
+ #
15
+ # Behavioral contract is preserved byte-for-byte: exit 0 on allow,
16
+ # exit 2 on HALT / blocked-paths match / malformed payload.
17
+ #
18
+ # # CLI-resolution trust boundary
19
+ #
20
+ # Mirrors the 0.32.0 final shim shape.
21
+ #
22
+ # # Fail-closed posture
23
+ #
24
+ # blocked-paths-enforcer is a Write/Edit/MultiEdit/NotebookEdit tier
25
+ # security gate. The pre-0.35.0 bash body refused on uncertainty.
26
+ # Early-exit branches fail closed AFTER the relevance pre-gate passes.
27
+ #
28
+ # # Relevance pre-gate
29
+ #
30
+ # Extract file_path / notebook_path from the payload, substring-scan
31
+ # against the policy's blocked_paths entries. When CLI is missing AND
32
+ # no policy.blocked_paths entry matches, exit 0. Empty/missing policy
33
+ # → no enforcement, exit 0.
12
34
 
13
35
  set -uo pipefail
14
36
 
15
- # ── 1. Read ALL stdin immediately ─────────────────────────────────────────────
16
- INPUT=$(cat)
17
-
18
- # ── 2. Dependency check ──────────────────────────────────────────────────────
19
- if ! command -v jq >/dev/null 2>&1; then
20
- printf 'REA ERROR: jq is required but not installed.\n' >&2
21
- printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
22
- exit 2
23
- fi
24
-
25
- # ── 3. HALT check ────────────────────────────────────────────────────────────
26
- # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
37
+ # 1. HALT check.
27
38
  # shellcheck source=_lib/halt-check.sh
28
39
  source "$(dirname "$0")/_lib/halt-check.sh"
29
40
  check_halt
30
41
  REA_ROOT=$(rea_root)
31
42
 
32
- # ── 4. Extract file path from payload ─────────────────────────────────────────
33
- FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
34
-
35
- if [[ -z "$FILE_PATH" ]]; then
36
- exit 0
37
- fi
43
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
38
44
 
39
- # ── 5. Load blocked_paths from policy ─────────────────────────────────────────
40
- POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
45
+ # 2. Capture stdin once.
46
+ INPUT=$(cat)
41
47
 
42
- if [[ ! -f "$POLICY_FILE" ]]; then
43
- exit 0
48
+ # 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
49
+ REA_ARGV=()
50
+ RESOLVED_CLI_PATH=""
51
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
52
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
53
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
54
+ elif [ -f "$proj/dist/cli/index.js" ]; then
55
+ REA_ARGV=(node "$proj/dist/cli/index.js")
56
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
44
57
  fi
45
58
 
46
- # Parse blocked_paths using grep + sed (avoid yaml parser dependency)
47
- # Handles both inline array [] and block sequence - "..." formats
48
- BLOCKED_PATHS=()
49
- IN_BLOCK=0
50
- while IFS= read -r line; do
51
- # Check if we're entering blocked_paths section
52
- if printf '%s' "$line" | grep -qE '^blocked_paths:'; then
53
- # Check for inline empty array
54
- if printf '%s' "$line" | grep -qE 'blocked_paths:[[:space:]]*\[\]'; then
55
- break
56
- fi
57
- # Check for inline array with values
58
- if printf '%s' "$line" | grep -qE 'blocked_paths:[[:space:]]*\['; then
59
- # Extract inline array items
60
- items=$(printf '%s' "$line" | sed 's/.*\[//; s/\].*//; s/,/ /g')
61
- for item in $items; do
62
- cleaned=$(printf '%s' "$item" | sed "s/^[[:space:]]*[\"']//; s/[\"'][[:space:]]*$//")
63
- if [[ -n "$cleaned" ]]; then
64
- BLOCKED_PATHS+=("$cleaned")
65
- fi
66
- done
67
- break
68
- fi
69
- IN_BLOCK=1
70
- continue
59
+ # 3b. Relevance pre-gate. Only used when the CLI is missing.
60
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
61
+ CLI_MISSING_FILE_PATH=""
62
+ if command -v jq >/dev/null 2>&1; then
63
+ CLI_MISSING_FILE_PATH=$(printf '%s' "$INPUT" | jq -r '
64
+ (.tool_input.file_path // .tool_input.notebook_path // "") | tostring
65
+ ' 2>/dev/null || true)
66
+ else
67
+ CLI_MISSING_FILE_PATH="$INPUT"
71
68
  fi
72
-
73
- if [[ $IN_BLOCK -eq 1 ]]; then
74
- # Block sequence items start with " - "
75
- if printf '%s' "$line" | grep -qE '^[[:space:]]+-'; then
76
- cleaned=$(printf '%s' "$line" | sed 's/^[[:space:]]*-[[:space:]]*//; s/^"//; s/"$//; s/^'"'"'//; s/'"'"'$//')
77
- if [[ -n "$cleaned" ]]; then
78
- BLOCKED_PATHS+=("$cleaned")
79
- fi
80
- else
81
- # Non-indented line means we've left the block
82
- break
83
- fi
69
+ if [ -z "$CLI_MISSING_FILE_PATH" ]; then
70
+ exit 0
84
71
  fi
85
- done < "$POLICY_FILE"
86
-
87
- if [[ ${#BLOCKED_PATHS[@]} -eq 0 ]]; then
88
- exit 0
72
+ POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
73
+ if [ ! -f "$POLICY_FILE" ]; then
74
+ exit 0
75
+ fi
76
+ CLI_MISSING_RELEVANT=0
77
+ while IFS= read -r entry; do
78
+ [ -z "$entry" ] && continue
79
+ # Substring scan — for directory prefixes the entry ends with /
80
+ # and any file_path under it matches. Glob entries fall back to
81
+ # the same substring test (over-trigger is fine — the CLI does
82
+ # the precise evaluation when reachable).
83
+ base="$entry"
84
+ case "$base" in
85
+ */) base="${base%/}" ;;
86
+ esac
87
+ # Strip glob wildcards for substring testing — `src/*.ts` becomes
88
+ # `src/` + `.ts`. The simplest safe form is to scan the literal
89
+ # part before the first `*`.
90
+ case "$base" in
91
+ *'*'*) base="${base%%\**}" ;;
92
+ esac
93
+ [ -z "$base" ] && continue
94
+ case "$CLI_MISSING_FILE_PATH" in
95
+ *"$base"*) CLI_MISSING_RELEVANT=1; break ;;
96
+ esac
97
+ done < <(awk '
98
+ /^blocked_paths:/ { in_block=1; next }
99
+ in_block && /^[[:space:]]*-/ {
100
+ sub(/^[[:space:]]*-[[:space:]]*/, "")
101
+ gsub(/^["'\'']/, "")
102
+ gsub(/["'\'']$/, "")
103
+ print
104
+ next
105
+ }
106
+ in_block && /^[^[:space:]-]/ { in_block=0 }
107
+ ' "$POLICY_FILE" 2>/dev/null)
108
+ if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
109
+ exit 0
110
+ fi
111
+ printf 'rea: blocked-paths-enforcer cannot run — the rea CLI is not built.\n' >&2
112
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
113
+ printf 'This shim fails closed because the pre-0.35.0 bash body enforced blocked_paths refusal without a CLI.\n' >&2
114
+ exit 2
89
115
  fi
90
116
 
91
- # ── 6. Agent-writable allowlist ───────────────────────────────────────────────
92
- # These paths under .rea/ must always be writable by agents regardless of
93
- # what blocked_paths says. Blocking the whole .rea/ directory in policy
94
- # is a common default, but tasks.jsonl is the PM data store — agents must
95
- # write there. Settings-protection.sh guards the sensitive files explicitly.
96
- AGENT_WRITABLE=(
97
- '.rea/tasks.jsonl'
98
- '.rea/audit/'
99
- )
100
-
101
- # 0.16.0: normalize_path migrated to shared `_lib/path-normalize.sh`.
102
- # Both this hook AND settings-protection.sh consume the same helper
103
- # so URL-decoding / backslash-translation / `./`-stripping cannot
104
- # drift between them again.
105
- # shellcheck source=_lib/path-normalize.sh
106
- source "$(dirname "$0")/_lib/path-normalize.sh"
107
-
108
- NORMALIZED=$(normalize_path "$FILE_PATH")
109
-
110
- # ── 5a. Path-traversal rejection (0.14.0 iron-gate fix) ───────────────────────
111
- # Reject any path containing a `..` segment BEFORE the literal-match below.
112
- # Without this, `foo/../CODEOWNERS` would get past `normalize_path()` (which
113
- # only strips leading project root + URL-decodes) and the literal-match
114
- # loop would compare `foo/../CODEOWNERS` against the literal `CODEOWNERS`
115
- # entry — which doesn't match, so the policy lets the write through. The
116
- # downstream Write/Edit tool then resolves the traversal and writes to
117
- # `CODEOWNERS` anyway, defeating the gate.
118
- #
119
- # Mirrors settings-protection.sh §5a (which has had this guard since
120
- # 0.10.x). Both pre- and post-decode forms are checked because
121
- # normalize_path() URL-decodes earlier and an attacker could split the
122
- # traversal across encodings (`%2E%2E/`, `..%2F`, etc.).
123
- raw_has_traversal=0
124
- norm_has_traversal=0
125
- case "/$FILE_PATH/" in
126
- */../*) raw_has_traversal=1 ;;
127
- esac
128
- case "/$NORMALIZED/" in
129
- */../*) norm_has_traversal=1 ;;
130
- esac
131
- # Also catch URL-encoded traversal in case some tool routes raw-encoded
132
- # paths through here (e.g. file:// inputs). normalize_path()'s decoder
133
- # only handles a fixed set; an unrecognized encoding would slip past.
134
- case "$FILE_PATH" in
135
- *%2[Ee]%2[Ee]*|*%2[Ee].*|*.%2[Ee]*) raw_has_traversal=1 ;;
136
- esac
137
- if [[ "$raw_has_traversal" -eq 1 ]] || [[ "$norm_has_traversal" -eq 1 ]]; then
138
- {
139
- printf 'BLOCKED PATH: path traversal rejected\n'
140
- printf '\n'
141
- printf ' File: %s\n' "$FILE_PATH"
142
- printf " Rule: path contains a '..' segment; rewrite to a canonical\n"
143
- printf ' project-relative path without traversal.\n'
144
- } >&2
117
+ # 4. Realpath sandbox check.
118
+ if ! command -v node >/dev/null 2>&1; then
119
+ printf 'rea: blocked-paths-enforcer cannot run `node` is not on PATH.\n' >&2
120
+ printf 'Install Node 22+ (engines.node) to restore blocked_paths refusal.\n' >&2
145
121
  exit 2
146
122
  fi
147
123
 
148
- # ── 5a-bis. Reject interior single-dot segments (0.29.0 helix-/./-class) ─────
149
- # Parallel to the `..` guard above. `normalize_path` does NOT collapse
150
- # interior `./` segments — that would corrupt `..` traversals — which leaves
151
- # a bypass class. A blocked entry of `.env` does not match `foo/./.env`
152
- # (the literal-comparison loop is byte-for-byte), so an attacker who can
153
- # influence the file_path string can dodge the policy entry.
154
- #
155
- # The conservative closure (per Jake 2026-05-12): treat any interior `/./`
156
- # segment exactly like `..`. The NORMALIZED form is the safe surface for
157
- # the check `normalize_path` already stripped leading `./` segments, so
158
- # any `/./` that survives is interior by construction. A raw-form check
159
- # would false-positive on benign `./foo` paths (codex round 1 P2: a path
160
- # like `%2E%2Fsrc/foo.ts` decodes to `./src/foo.ts` which is the same
161
- # leading-`./` allowed shape the comment at the top of `normalize_path`
162
- # documents guarding against it on the raw form would block legit
163
- # writes under `src/` and friends).
164
- #
165
- # URL-encoded companion: `.%2F` / `%2E/` / `%2E%2F` decode to `./` via
166
- # `normalize_path` (which knows `%2E` → `.` and `%2F` → `/`). After
167
- # URL-decode + leading-`./` strip, any encoded INTERIOR form hits the
168
- # normalized `*/./* ` check. No raw-form encoded guard is needed — the
169
- # normalize_path path already covers every encoded shape the helper
170
- # decodes, and shapes it doesn't decode wouldn't resolve to an interior
171
- # `./` segment on disk either.
172
- norm_has_dot_segment=0
173
- case "/$NORMALIZED/" in
174
- */./*) norm_has_dot_segment=1 ;;
175
- esac
176
- if [[ "$norm_has_dot_segment" -eq 1 ]]; then
177
- {
178
- printf 'BLOCKED PATH: interior dot-segment rejected\n'
179
- printf '\n'
180
- printf ' File: %s\n' "$FILE_PATH"
181
- printf " Rule: path contains an interior '/./' segment; rewrite to a\n"
182
- printf ' canonical project-relative path without dot segments.\n'
183
- } >&2
124
+ sandbox_check=$(node -e '
125
+ const fs = require("fs");
126
+ const path = require("path");
127
+ const cli = process.argv[1];
128
+ const projDir = process.argv[2];
129
+ let real, realProj;
130
+ try { real = fs.realpathSync(cli); } catch (e) {
131
+ process.stdout.write("bad:realpath"); process.exit(1);
132
+ }
133
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
134
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
135
+ }
136
+ const sep = path.sep;
137
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
138
+ if (!(real === realProj || real.startsWith(projWithSep))) {
139
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
140
+ }
141
+ // Codex round-1 P1 fix: enforce dist/cli/index.js shape (see
142
+ // settings-protection.sh).
143
+ const expectedEnd = path.join("dist", "cli", "index.js");
144
+ if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
145
+ process.stdout.write("bad:cli-shape"); process.exit(1);
146
+ }
147
+ let cur = path.dirname(path.dirname(path.dirname(real)));
148
+ let found = false;
149
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
150
+ const pj = path.join(cur, "package.json");
151
+ if (fs.existsSync(pj)) {
152
+ try {
153
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
154
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
155
+ } catch (e) { /* keep walking */ }
156
+ }
157
+ cur = path.dirname(cur);
158
+ }
159
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
160
+ process.stdout.write("ok");
161
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
162
+
163
+ if [ "$sandbox_check" != "ok" ]; then
164
+ printf 'rea: blocked-paths-enforcer FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
184
165
  exit 2
185
166
  fi
186
167
 
187
- for writable in "${AGENT_WRITABLE[@]}"; do
188
- if [[ "$NORMALIZED" == "$writable" ]] || [[ "$NORMALIZED" == "$writable"* && "$writable" == */ ]]; then
189
- exit 0
190
- fi
191
- done
192
-
193
- # ── 7. Match against blocked_paths ───────────────────────────────────────────
194
- LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
195
-
196
- for blocked in "${BLOCKED_PATHS[@]}"; do
197
- LOWER_BLOCKED=$(printf '%s' "$blocked" | tr '[:upper:]' '[:lower:]')
198
-
199
- # Directory match (blocked path ends with /)
200
- if [[ "$LOWER_BLOCKED" == */ ]]; then
201
- if [[ "$LOWER_NORM" == "$LOWER_BLOCKED"* ]] || [[ "$LOWER_NORM" == "${LOWER_BLOCKED%/}" ]]; then
202
- {
203
- printf 'BLOCKED PATH: Write denied by policy\n'
204
- printf '\n'
205
- printf ' File: %s\n' "$FILE_PATH"
206
- printf ' Blocked by: %s\n' "$blocked"
207
- printf ' Source: .rea/policy.yaml → blocked_paths\n'
208
- printf '\n'
209
- printf ' This path is protected by policy. To modify it, a human must\n'
210
- printf ' either update blocked_paths in policy.yaml or edit the file directly.\n'
211
- } >&2
212
- exit 2
213
- fi
214
- continue
215
- fi
216
-
217
- # Glob pattern match (contains *)
218
- if [[ "$blocked" == *'*'* ]]; then
219
- # Convert glob to regex: . → \., * → .*
220
- regex=$(printf '%s' "$LOWER_BLOCKED" | sed 's/\./\\./g; s/\*/.*/g')
221
- if printf '%s' "$LOWER_NORM" | grep -qE "^${regex}$"; then
222
- {
223
- printf 'BLOCKED PATH: Write denied by policy\n'
224
- printf '\n'
225
- printf ' File: %s\n' "$FILE_PATH"
226
- printf ' Blocked by: %s (glob pattern)\n' "$blocked"
227
- printf ' Source: .rea/policy.yaml → blocked_paths\n'
228
- } >&2
229
- exit 2
230
- fi
231
- continue
232
- fi
233
-
234
- # Exact match
235
- if [[ "$LOWER_NORM" == "$LOWER_BLOCKED" ]]; then
236
- {
237
- printf 'BLOCKED PATH: Write denied by policy\n'
238
- printf '\n'
239
- printf ' File: %s\n' "$FILE_PATH"
240
- printf ' Blocked by: %s\n' "$blocked"
241
- printf ' Source: .rea/policy.yaml → blocked_paths\n'
242
- } >&2
243
- exit 2
244
- fi
245
- done
246
-
247
- # ── 0.16.0 fix H.2: intermediate-symlink resolution ──────────────────────────
248
- # Same shape as Helix Finding 2 against blocked_paths policy entries.
249
- # If `secrets/` is in blocked_paths and an attacker creates
250
- # `pretty/ -> ../secrets/`, then writes `pretty/foo`, the literal-match
251
- # loop above sees `pretty/foo` (no match) and exits 0 — the downstream
252
- # Write tool follows the symlink and lands the body in `secrets/foo`.
253
- # Mirrors settings-protection.sh §6c.
254
- if [[ -e "$FILE_PATH" || -d "$(dirname -- "$FILE_PATH")" ]]; then
255
- parent_dir=$(dirname -- "$FILE_PATH")
256
- if [[ -d "$parent_dir" ]]; then
257
- resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
258
- if [[ -n "$resolved_parent" && "$resolved_parent" == "$REA_ROOT"/* ]]; then
259
- relative_resolved="${resolved_parent#"$REA_ROOT"/}"
260
- resolved_target="${relative_resolved}/$(basename -- "$FILE_PATH")"
261
- resolved_target_lc=$(printf '%s' "$resolved_target" | tr '[:upper:]' '[:lower:]')
262
- for blocked in "${BLOCKED_PATHS[@]}"; do
263
- blocked_lc=$(printf '%s' "$blocked" | tr '[:upper:]' '[:lower:]')
264
- if [[ "$resolved_target_lc" == "$blocked_lc" ]] || \
265
- { [[ "$blocked_lc" == */ ]] && [[ "$resolved_target_lc" == "$blocked_lc"* ]]; }; then
266
- {
267
- printf 'BLOCKED PATH: intermediate-symlink resolution blocked\n'
268
- printf '\n'
269
- printf ' Logical: %s\n' "$FILE_PATH"
270
- printf ' Resolved: %s\n' "$resolved_target"
271
- printf ' Blocked by: %s\n' "$blocked"
272
- printf ' Source: .rea/policy.yaml → blocked_paths\n'
273
- printf '\n'
274
- printf ' Rule: an intermediate directory of the path is a symlink\n'
275
- printf ' whose target falls inside a blocked policy entry.\n'
276
- } >&2
277
- exit 2
278
- fi
279
- done
280
- fi
281
- fi
168
+ # 5. Version-probe.
169
+ probe_out=$("${REA_ARGV[@]}" hook blocked-paths-enforcer --help 2>&1)
170
+ probe_status=$?
171
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'blocked-paths-enforcer'; then
172
+ printf 'rea: this shim requires the `rea hook blocked-paths-enforcer` subcommand (introduced in 0.35.0).\n' >&2
173
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
174
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
175
+ exit 2
282
176
  fi
283
177
 
284
- exit 0
178
+ # 6. Forward stdin (already captured up-front).
179
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook blocked-paths-enforcer
180
+ exit $?