@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,582 +1,204 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: settings-protection.sh
3
- # Fires BEFORE every Write or Edit tool call.
4
- # Blocks modifications to critical configuration files that, if tampered with,
5
- # would disable the entire hook safety layer.
3
+ # 0.35.0+ Node-binary shim for `rea hook settings-protection`.
6
4
  #
7
- # Protected paths (security controls and hook infrastructure ONLY):
8
- # .claude/settings.json — hook configuration
9
- # .claude/settings.local.json local hook overrides
10
- # .claude/hooks/* — hook scripts themselves
11
- # .husky/* — git hook scripts
12
- # .rea/policy.yaml — autonomy/blocking policy
13
- # .rea/HALT — kill switch file
5
+ # Pre-0.35.0 this was the LARGEST hook in the repo at 582 LOC of bash:
6
+ # §5a `..` traversal reject, §5a-bis interior `/./` reject, §5b
7
+ # extension-surface allow-list (with final-component + intermediate-
8
+ # directory symlink refusal), §6 hard-protected pattern resolution
9
+ # (PROTECTED_PATTERNS sourced from `_lib/protected-paths.sh` with
10
+ # `protected_writes` override + `protected_paths_relax` subtractor),
11
+ # §6c intermediate-symlink resolution against the hard-protected list,
12
+ # §6b REA_HOOK_PATCH_SESSION unlock for .claude/hooks/ with hash-
13
+ # chained audit append (fail-closed). The full bash body is preserved
14
+ # at `__tests__/hooks/parity/baselines/settings-protection.sh.pre-0.35.0`.
14
15
  #
15
- # NOT protected (operational files agents may legitimately write):
16
- # .rea/review-cache.json — cache file, writable by CLI and agents
17
- # .rea/tasks.jsonl task store, managed by task MCP tools
16
+ # The migration moves every section into
17
+ # `src/hooks/settings-protection/index.ts`. This shim is the Claude Code
18
+ # dispatcher's view of the hook it forwards stdin to the CLI and
19
+ # exits with whatever the CLI returns.
18
20
  #
19
- # Exit codes:
20
- # 0 = allow (path not protected)
21
- # 2 = block (protected path modification attempt)
22
-
23
- set -uo pipefail
24
-
25
- # ── 1. Read ALL stdin immediately ─────────────────────────────────────────────
26
- INPUT=$(cat)
27
-
28
- # ── 2. 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
- # ── 3. HALT check ────────────────────────────────────────────────────────────
36
- # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
37
- # shellcheck source=_lib/halt-check.sh
38
- source "$(dirname "$0")/_lib/halt-check.sh"
39
- check_halt
40
- REA_ROOT=$(rea_root)
41
-
42
- # ── 4. Extract file path from payload ─────────────────────────────────────────
43
- FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
44
-
45
- if [[ -z "$FILE_PATH" ]]; then
46
- exit 0
47
- fi
48
-
49
- # ── 5. Normalize path for comparison ──────────────────────────────────────────
50
- # Convert to relative path from project root for consistent matching
51
- normalize_path() {
52
- local p="$1"
53
- local root="$REA_ROOT"
54
-
55
- # Strip project root prefix if present
56
- if [[ "$p" == "$root"/* ]]; then
57
- p="${p#$root/}"
58
- fi
59
-
60
- # URL decode common sequences. Include %5C (`\`) so Windows-style or
61
- # percent-encoded back-slash traversal (`..%5C`, `\..\`) normalizes to the
62
- # forward-slash form the §5a detector sees.
63
- p=$(printf '%s' "$p" \
64
- | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g; s/%5[Cc]/\\/g')
65
-
66
- # Translate any backslash separators to forward slashes. Keeps the traversal
67
- # check in §5a working for `.claude\hooks\..\settings.json`-style inputs.
68
- p=$(printf '%s' "$p" | tr '\\\\' '/')
69
-
70
- # Strip leading ./ components only. We intentionally do NOT strip interior
71
- # ./ sequences — that transformation corrupts `..` traversals (e.g. `.../`
72
- # collapsed to `../`, or `../` collapsed to `./`) and hides traversal from
73
- # the §5a detector.
74
- while [[ "$p" == ./* ]]; do
75
- p="${p#./}"
76
- done
77
-
78
- printf '%s' "$p"
79
- }
80
-
81
- # Strip C0/C1 control characters from a string to prevent terminal escape
82
- # injection when we echo protected paths back to the operator. Escape sequences
83
- # in file names could otherwise rewrite lines above the deny message.
84
- #
85
- # Byte ranges stripped:
86
- # \000-\037 — C0 controls (BEL, BS, HT, LF, CR, ESC, …)
87
- # \177 — DEL
88
- # \200-\237 — C1 controls (CSI 0x9B, OSC 0x9D, …). Many terminals still
89
- # interpret these as single-byte CSI introducers; without
90
- # stripping, a UTF-8 file name whose bytes fall in this range
91
- # could still drive the cursor on older emulators.
92
- sanitize_for_stderr() {
93
- printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177\200-\237'
94
- }
95
-
96
- NORMALIZED=$(normalize_path "$FILE_PATH")
97
- SAFE_FILE_PATH=$(sanitize_for_stderr "$FILE_PATH")
98
- SAFE_NORMALIZED=$(sanitize_for_stderr "$NORMALIZED")
99
-
100
- # ── 5a. Reject path traversal segments (Codex HIGH: Defect I bypass) ─────────
101
- # A path containing `..` segments can be used to bypass the protected-path
102
- # globs in §6 — e.g. `.claude/hooks/../settings.json` would pass the
103
- # `.claude/hooks/*` case-glob in the patch-session allowlist but actually
104
- # refers to `.claude/settings.json`. We refuse any path that contains a `..`
105
- # segment in either the raw input OR the normalized form. The request must
106
- # be reissued with a canonical path.
107
- #
108
- # For the raw-input check, translate backslashes first so a Windows-style
109
- # `.claude\hooks\..\settings.json` is rejected at the raw stage too (the
110
- # normalized form also catches it — this is defense in depth).
111
- RAW_PATH_SLASHED=$(printf '%s' "$FILE_PATH" | tr '\\\\' '/')
112
- raw_has_traversal=0
113
- case "/$RAW_PATH_SLASHED/" in
114
- */../*) raw_has_traversal=1 ;;
115
- esac
116
- norm_has_traversal=0
117
- case "/$NORMALIZED/" in
118
- */../*) norm_has_traversal=1 ;;
119
- esac
120
- if [[ "$raw_has_traversal" -eq 1 ]] || [[ "$norm_has_traversal" -eq 1 ]]; then
121
- {
122
- printf 'SETTINGS PROTECTION: path traversal rejected\n'
123
- printf '\n'
124
- printf ' File: %s\n' "$SAFE_FILE_PATH"
125
- printf " Rule: path contains a '..' segment; rewrite to a canonical\n"
126
- printf ' project-relative path without traversal.\n'
127
- } >&2
128
- exit 2
129
- fi
130
-
131
- # ── 5a-bis. Reject interior single-dot segments (0.29.0 helix-/./-class) ─────
132
- # Companion to the `..` guard above. The `normalize_path` helper deliberately
133
- # does NOT collapse interior `./` segments because doing so would corrupt
134
- # `..` traversals — but that leaves a parallel bypass class. A path like
135
- # `.husky/./pre-push` resolves on disk to `.husky/pre-push`, yet the literal/
136
- # prefix matchers in §6 compare against the un-collapsed `.husky/./pre-push`
137
- # string and miss the match.
21
+ # Behavioral contract is preserved byte-for-byte: exit 0 on allow,
22
+ # exit 2 on HALT / traversal-reject / interior-dot-reject / protected
23
+ # match / patch-session-mismatch / malformed payload.
138
24
  #
139
- # Conservative reading (per Jake 2026-05-12): treat any interior `./`
140
- # segment exactly like a `..` segment — refuse outright, force the caller
141
- # to send a canonical path. The corpus design pairs shell-scripting-specialist
142
- # with adversarial-test-specialist; the canonical attack shapes are:
25
+ # # CLI-resolution trust boundary
143
26
  #
144
- # .husky/./pre-push — single segment
145
- # .husky/././pre-push — repeated segments
146
- # .husky/.//pre-push — `./` immediately followed by another `/`
147
- # .claude/hooks/./_lib/halt-check.sh — inside a protected directory
148
- # %2E%2F — percent-encoded `./`, caught after URL-decode
149
- # .\.\pre-push — backslash variant, normalize_path → `./`
27
+ # Mirrors the 0.32.0 final shim shape.
150
28
  #
151
- # Only the NORMALIZED form is checked (not the raw form) because raw `./foo`
152
- # at start-of-string is a legitimate relative path; `normalize_path` already
153
- # strips leading `./` segments, so anything that survives into the normalized
154
- # form's `/./` shape is INTERIOR by construction.
155
- norm_has_dot_segment=0
156
- case "/$NORMALIZED/" in
157
- */./*) norm_has_dot_segment=1 ;;
158
- esac
159
- if [[ "$norm_has_dot_segment" -eq 1 ]]; then
160
- {
161
- printf 'SETTINGS PROTECTION: interior dot-segment rejected\n'
162
- printf '\n'
163
- printf ' File: %s\n' "$SAFE_FILE_PATH"
164
- printf " Rule: path contains an interior '/./' segment; rewrite to a\n"
165
- printf ' canonical project-relative path without dot segments.\n'
166
- } >&2
167
- exit 2
168
- fi
169
-
170
- # Compute lower-cased path early so the §5b allow-list (and §6/§6b matchers
171
- # below) all reference a single normalized variable.
172
- LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
173
-
174
- # ── 5b. Extension-surface allow-list ──────────────────────────────────────────
175
- # `.husky/commit-msg.d/*`, `.husky/pre-push.d/*`, and (0.32.0+)
176
- # `.husky/prepare-commit-msg.d/*` are the documented consumer
177
- # extension surface (Fix H / 0.13.0; Phase 3 / 0.32.0 for the
178
- # prepare-commit-msg lane). Consumers — and the agents that govern
179
- # those consumers — are expected to write here freely so they can
180
- # layer commitlint, lint-staged, branch-policy, act-CI, etc. without
181
- # losing rea coverage on `rea upgrade`.
182
- #
183
- # The §6 PROTECTED_PATTERNS list below has `.husky/` as a prefix block,
184
- # which (correctly) keeps `.husky/pre-push`, `.husky/commit-msg`, and
185
- # the `.husky/_/*` runtime stubs out of agent reach. But the same prefix
186
- # also caught `.husky/pre-push.d/00-act-ci` and `.husky/commit-msg.d/*`
187
- # until 0.13.2 — the very directories advertised as the extension
188
- # surface. This early allow-list closes that contract gap.
29
+ # # Fail-closed posture
189
30
  #
190
- # Anchored on the literal `.d/` segment (not `.d`) so `.husky/pre-push.d.bak/`
191
- # or `.husky/pre-push.dump` still hit the prefix block. Nested fragments
192
- # (e.g. `pre-push.d/sub/file`) are allowed so the surface composes naturally.
31
+ # settings-protection is THE gate protecting the entire governance layer
32
+ # from agent self-disable. Pre-0.35.0 the bash body enforced refusal
33
+ # without any compiled CLI; the Node-binary port preserves that — early-
34
+ # exit branches fail closed AFTER the relevance pre-gate passes.
193
35
  #
194
- # SECURITY: runs AFTER §5a (path-traversal reject), so a clever
195
- # `.husky/pre-push.d/../pre-push` cannot bypass §6's protection of the
196
- # package-managed body — §5a kills it before this matcher runs.
36
+ # # Relevance pre-gate
197
37
  #
198
- # SECURITY (defense-in-depth): symlinks INSIDE the .d/ surface are
199
- # refused both final-component AND intermediate-directory symlinks.
200
- # A fragment is a short shell script authored in place; consumers do
201
- # not need symlinks here. Without these checks the gate has two
202
- # bypass shapes:
38
+ # Substring scan over the extracted file_path / notebook_path for the
39
+ # protected-path markers (.claude/, .husky/, .rea/policy.yaml, .rea/HALT,
40
+ # the verdict cache paths, plus any policy.blocked_paths entry). When
41
+ # CLI is missing AND none of these substrings appear in the payload's
42
+ # file path, exit 0. The pre-0.35.0 bash body would have allowed.
203
43
  #
204
- # (a) Final-component symlink:
205
- # ln -s ../pre-push .husky/pre-push.d/00-evil; write 00-evil
206
- # — caught by `[ -L "$FILE_PATH" ]`.
207
- #
208
- # (b) Intermediate-directory symlink (helix Finding 2 / 0.15.0):
209
- # mkdir .husky/pre-push.d; ln -s ../ .husky/pre-push.d/linkdir
210
- # write .husky/pre-push.d/linkdir/pre-push
211
- # — `[ -L $FILE_PATH ]` only inspects the FINAL component, so a
212
- # not-yet-existing target whose parent contains a symlink resolves
213
- # to outside the surface (here: `.husky/pre-push`), letting the
214
- # attacker write through to the package-managed body.
215
- #
216
- # Resolve the realpath of the parent directory and require it to live
217
- # under the literal extension surface. Use a portable `cd ... && pwd -P`
218
- # subshell pattern (no Python or readlink -f dependency required).
219
- # Closes the path-string→symlink bypass completely.
220
- case "$LOWER_NORM" in
221
- .husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/prepare-commit-msg.d/*)
222
- if [ -L "$FILE_PATH" ]; then
223
- {
224
- printf 'SETTINGS PROTECTION: symlink in extension surface refused\n'
225
- printf '\n'
226
- printf ' File: %s\n' "$SAFE_FILE_PATH"
227
- printf ' Rule: .husky/{commit-msg,pre-push,prepare-commit-msg}.d/* must\n'
228
- printf ' be regular files (a symlink could resolve to a protected\n'
229
- printf ' package-managed body and bypass §6 protection).\n'
230
- } >&2
231
- exit 2
232
- fi
233
- # Resolve the parent directory's realpath. If any intermediate
234
- # component is a symlink whose target leaves the surface, the
235
- # resolved path no longer contains `/.husky/<surface>.d/` and we
236
- # refuse. The parent dir must already exist for this check; if it
237
- # doesn't, the write is creating the parent, in which case there
238
- # is no intermediate symlink to follow yet.
239
- parent_dir=$(dirname -- "$FILE_PATH")
240
- if [ -d "$parent_dir" ]; then
241
- resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
242
- if [ -n "$resolved_parent" ]; then
243
- # 0.20.1 helix-021 #3: directory-boundary on the case glob.
244
- # Pre-fix `*"/.husky/commit-msg.d"*` matched `.husky/commit-msg.d.bak/`
245
- # too (substring without trailing-slash anchor). A symlink
246
- # `.husky/pre-push.d/linkdir -> ../pre-push.d.bak` then resolved
247
- # to `.husky/pre-push.d.bak/...` and slipped through.
248
- # The trailing `/` on each pattern (and the explicit
249
- # exact-match arm) requires a real directory boundary.
250
- # 0.32.0 Phase 3: `.husky/prepare-commit-msg.d/` joins the
251
- # allow-list (mirrors commit-msg.d/pre-push.d patterns).
252
- case "$resolved_parent" in
253
- */.husky/commit-msg.d|*/.husky/commit-msg.d/*|*/.husky/pre-push.d|*/.husky/pre-push.d/*|*/.husky/prepare-commit-msg.d|*/.husky/prepare-commit-msg.d/*) : ;;
254
- *)
255
- {
256
- printf 'SETTINGS PROTECTION: extension path resolves outside surface\n'
257
- printf '\n'
258
- printf ' Logical: %s\n' "$SAFE_FILE_PATH"
259
- printf ' Resolved: %s\n' "$resolved_parent"
260
- printf ' Rule: an intermediate directory of the extension path is a\n'
261
- printf ' symlink whose target leaves .husky/{commit-msg,pre-push,prepare-commit-msg}.d/.\n'
262
- printf ' Refused to prevent symlinked-parent bypass of the\n'
263
- printf ' package-managed body protection.\n'
264
- } >&2
265
- exit 2
266
- ;;
267
- esac
268
- fi
269
- fi
270
- # Documented extension surface — agents can write here freely.
271
- exit 0
272
- ;;
273
- esac
274
-
275
- # ── 6. Protected path patterns ────────────────────────────────────────────────
276
- # §6 runs BEFORE the patch-session allowlist so hook-patch sessions cannot
277
- # reach .rea/policy.yaml, .rea/HALT, or .claude/settings.json via any glob
278
- # creativity.
44
+ # # Bootstrap safety
279
45
  #
280
- # 0.16.3 F7: list is sourced from `_lib/protected-paths.sh`, which honors
281
- # the `protected_paths_relax` policy key (kill-switch invariants always
282
- # stay protected see the lib for the always-protected subset).
283
- # shellcheck source=_lib/protected-paths.sh
284
- source "$(dirname "$0")/_lib/protected-paths.sh"
285
- # Trigger lazy load now so PROTECTED_PATTERNS reflects the relaxed list
286
- # from the start of this hook process.
287
- rea_path_is_protected "/__rea_force_load__" >/dev/null 2>&1 || true
288
- PROTECTED_PATTERNS=("${REA_PROTECTED_PATTERNS[@]}")
289
-
290
- # Patterns that are protected from general agent edits but can be unlocked by
291
- # REA_HOOK_PATCH_SESSION. Kept separate from the hard-protected list above so
292
- # the patch-session gate in §6b only applies to these directories.
293
- PATCH_SESSION_PATTERNS=(
294
- '.claude/hooks/'
295
- )
296
-
297
- # LOWER_NORM was computed in §5b above and is reused here.
298
-
299
- # Match $NORMALIZED against PROTECTED_PATTERNS (exact or prefix for patterns
300
- # ending in '/'). Sets $PROTECTED_MATCH to the matched pattern; exit 0 on hit.
301
- match_protected() {
302
- local pattern
303
- PROTECTED_MATCH=""
304
- for pattern in "${PROTECTED_PATTERNS[@]}"; do
305
- if [[ "$NORMALIZED" == "$pattern" ]]; then
306
- PROTECTED_MATCH="$pattern"
307
- return 0
308
- fi
309
- if [[ "$pattern" == */ ]] && [[ "$NORMALIZED" == "$pattern"* ]]; then
310
- PROTECTED_MATCH="$pattern"
311
- return 0
312
- fi
313
- done
314
- return 1
315
- }
46
+ # This shim is ITSELF protected by `settings-protection.sh`. The new
47
+ # shim must not block legitimate writes — the `bash -n` syntax check
48
+ # in the test:bash-syntax script catches parse errors BEFORE the
49
+ # install lands them. The relevance pre-gate keeps benign writes (like
50
+ # editing `src/foo.ts`) exiting 0 even when the CLI is missing.
316
51
 
317
- match_protected_ci() {
318
- local pattern lp
319
- PROTECTED_MATCH=""
320
- for pattern in "${PROTECTED_PATTERNS[@]}"; do
321
- lp=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
322
- if [[ "$LOWER_NORM" == "$lp" ]]; then
323
- PROTECTED_MATCH="$pattern"
324
- return 0
325
- fi
326
- if [[ "$lp" == */ ]] && [[ "$LOWER_NORM" == "$lp"* ]]; then
327
- PROTECTED_MATCH="$pattern"
328
- return 0
329
- fi
330
- done
331
- return 1
332
- }
52
+ set -uo pipefail
333
53
 
334
- match_patch_session() {
335
- local pattern
336
- PROTECTED_MATCH=""
337
- for pattern in "${PATCH_SESSION_PATTERNS[@]}"; do
338
- if [[ "$NORMALIZED" == "$pattern" ]]; then
339
- PROTECTED_MATCH="$pattern"
340
- return 0
341
- fi
342
- if [[ "$pattern" == */ ]] && [[ "$NORMALIZED" == "$pattern"* ]]; then
343
- PROTECTED_MATCH="$pattern"
344
- return 0
345
- fi
346
- done
347
- return 1
348
- }
54
+ # 1. HALT check.
55
+ # shellcheck source=_lib/halt-check.sh
56
+ source "$(dirname "$0")/_lib/halt-check.sh"
57
+ check_halt
58
+ REA_ROOT=$(rea_root)
349
59
 
350
- match_patch_session_ci() {
351
- local pattern lp
352
- PROTECTED_MATCH=""
353
- for pattern in "${PATCH_SESSION_PATTERNS[@]}"; do
354
- lp=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
355
- if [[ "$LOWER_NORM" == "$lp" ]]; then
356
- PROTECTED_MATCH="$pattern"
357
- return 0
358
- fi
359
- if [[ "$lp" == */ ]] && [[ "$LOWER_NORM" == "$lp"* ]]; then
360
- PROTECTED_MATCH="$pattern"
361
- return 0
362
- fi
363
- done
364
- return 1
365
- }
60
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
366
61
 
367
- if match_protected; then
368
- {
369
- printf 'SETTINGS PROTECTION: Modification blocked\n'
370
- printf '\n'
371
- printf ' File: %s\n' "$SAFE_FILE_PATH"
372
- printf ' Matched: %s\n' "$PROTECTED_MATCH"
373
- printf ' Rule: This file is protected from agent modification, including\n'
374
- printf ' sessions with REA_HOOK_PATCH_SESSION set.\n'
375
- } >&2
376
- exit 2
377
- fi
62
+ # 2. Capture stdin once.
63
+ INPUT=$(cat)
378
64
 
379
- if match_protected_ci; then
380
- {
381
- printf 'SETTINGS PROTECTION: Modification blocked (case-insensitive match)\n'
382
- printf '\n'
383
- printf ' File: %s\n' "$SAFE_FILE_PATH"
384
- printf ' Matched: %s\n' "$PROTECTED_MATCH"
385
- } >&2
386
- exit 2
65
+ # 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
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"
387
74
  fi
388
75
 
389
- # ── 6c. Intermediate-symlink resolution (0.16.0 fix H.1) ──────────────────────
390
- # Helix Finding 2 reborn against the hard-protected list. The §5b
391
- # extension-surface fix (0.13.2) resolved parent realpath for the
392
- # `.husky/{commit-msg,pre-push}.d/*` allowlist; §6 was never given the
393
- # same protection. Attack:
394
- #
395
- # mkdir innocuous_path
396
- # ln -s ../.husky innocuous_path/maybe # symlink resolves to .husky/
397
- # write innocuous_path/maybe/pre-push # writes through to .husky/pre-push
398
- #
399
- # §5a (`..` traversal) doesn't catch — the path string has no `..`.
400
- # §6 PROTECTED_PATTERNS sees `innocuous_path/maybe/pre-push` — doesn't
401
- # match `.husky/` prefix. The write succeeds and the package-managed
402
- # pre-push body is overwritten.
403
- #
404
- # Fix: when the parent directory of the target exists, resolve its
405
- # realpath via cd -P && pwd -P (same shape as §5b) and check whether
406
- # the resolved path falls inside any protected directory. Only resolve
407
- # when the parent already exists — a write that creates the parent has
408
- # nothing to follow.
409
- if [[ -e "$FILE_PATH" || -d "$(dirname -- "$FILE_PATH")" ]]; then
410
- parent_dir=$(dirname -- "$FILE_PATH")
411
- if [[ -d "$parent_dir" ]]; then
412
- resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
413
- if [[ -n "$resolved_parent" ]]; then
414
- # If the resolved parent is inside REA_ROOT, compute the project-
415
- # relative path and test it against the protected patterns.
416
- if [[ "$resolved_parent" == "$REA_ROOT"/* ]]; then
417
- relative_resolved="${resolved_parent#"$REA_ROOT"/}"
418
- # Walk every PROTECTED_PATTERN that's a directory prefix and
419
- # check whether the resolved parent falls inside it. Direct
420
- # filename matches against PROTECTED_PATTERNS for the resolved
421
- # final path (parent + basename).
422
- resolved_target="${relative_resolved}/$(basename -- "$FILE_PATH")"
423
- resolved_target_lc=$(printf '%s' "$resolved_target" | tr '[:upper:]' '[:lower:]')
424
- for pattern in "${PROTECTED_PATTERNS[@]}"; do
425
- pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
426
- if [[ "$resolved_target_lc" == "$pattern_lc" ]] || \
427
- { [[ "$pattern_lc" == */ ]] && [[ "$resolved_target_lc" == "$pattern_lc"* ]]; }; then
428
- {
429
- printf 'SETTINGS PROTECTION: intermediate-symlink resolution blocked\n'
430
- printf '\n'
431
- printf ' Logical: %s\n' "$SAFE_FILE_PATH"
432
- printf ' Resolved: %s\n' "$resolved_target"
433
- printf ' Matched: %s\n' "$pattern"
434
- printf ' Rule: an intermediate directory of the target path is a\n'
435
- printf ' symlink whose target falls inside a hard-protected\n'
436
- printf ' path. Refused to prevent symlinked-parent bypass.\n'
437
- } >&2
438
- exit 2
439
- fi
440
- done
441
- fi
442
- fi
76
+ # 3b. Relevance pre-gate. Only used when the CLI is missing.
77
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
78
+ CLI_MISSING_FILE_PATH=""
79
+ if command -v jq >/dev/null 2>&1; then
80
+ CLI_MISSING_FILE_PATH=$(printf '%s' "$INPUT" | jq -r '
81
+ (.tool_input.file_path // .tool_input.notebook_path // "") | tostring
82
+ ' 2>/dev/null || true)
83
+ else
84
+ CLI_MISSING_FILE_PATH="$INPUT"
443
85
  fi
444
- fi
445
-
446
- # ── 6b. Hook-patch session (Defect I / rea#76) ───────────────────────────────
447
- # When REA_HOOK_PATCH_SESSION is set to a non-empty reason, allow edits under
448
- # .claude/hooks/ and hooks/ for this session. The session boundary IS the
449
- # expiry — a new shell requires a fresh opt-in. Every allowed edit is audited
450
- # as hooks.patch.session so the bypass is never silent.
451
- #
452
- # SECURITY: runs AFTER §5a (traversal reject) and §6 (hard-protected denies),
453
- # so no glob creativity can reach policy/HALT/settings files from here.
454
- if [[ -n "${REA_HOOK_PATCH_SESSION:-}" ]]; then
455
- if match_patch_session; then
456
- SAFE_REASON=$(sanitize_for_stderr "${REA_HOOK_PATCH_SESSION}")
457
- # Audit record via the TypeScript chain so the hash chain stays intact.
458
- # If the append fails, block the edit silent failure would let an
459
- # attacker disable audit logging and then patch hooks unobserved.
460
- SHA_BEFORE=""
461
- if [[ -f "$FILE_PATH" ]]; then
462
- if command -v sha256sum >/dev/null 2>&1; then
463
- SHA_BEFORE=$(sha256sum "$FILE_PATH" 2>/dev/null | awk '{print $1}')
464
- elif command -v shasum >/dev/null 2>&1; then
465
- SHA_BEFORE=$(shasum -a 256 "$FILE_PATH" 2>/dev/null | awk '{print $1}')
466
- elif command -v openssl >/dev/null 2>&1; then
467
- SHA_BEFORE=$(openssl dgst -sha256 "$FILE_PATH" 2>/dev/null | awk '{print $NF}')
468
- fi
469
- fi
470
- ACTOR_NAME=$(git -C "$REA_ROOT" config user.name 2>/dev/null || printf 'unknown')
471
- ACTOR_EMAIL=$(git -C "$REA_ROOT" config user.email 2>/dev/null || printf 'unknown')
472
-
473
- AUDIT_PAYLOAD=$(
474
- cd "$REA_ROOT" 2>/dev/null || true
475
- REA_AUDIT_REASON="${REA_HOOK_PATCH_SESSION}" \
476
- REA_AUDIT_FILE="$NORMALIZED" \
477
- REA_AUDIT_SHA="$SHA_BEFORE" \
478
- REA_AUDIT_ACTOR_NAME="$ACTOR_NAME" \
479
- REA_AUDIT_ACTOR_EMAIL="$ACTOR_EMAIL" \
480
- REA_AUDIT_PID="$$" \
481
- REA_AUDIT_PPID="$PPID" \
482
- REA_AUDIT_SESSION="${CLAUDE_SESSION_ID:-external}" \
483
- REA_AUDIT_ROOT="$REA_ROOT" \
484
- node --input-type=module -e '
485
- const root = process.env.REA_AUDIT_ROOT;
486
- async function loadMod() {
487
- // Consumer path: `@bookedsolid/rea` resolvable via node_modules
488
- // (how `rea init`-installed consumers reach the published package)
489
- // or via package self-reference when the hook runs inside the rea
490
- // source repo itself.
491
- try {
492
- return await import("@bookedsolid/rea/audit");
493
- } catch (e1) {
494
- // Dev path: direct file import from the source repos dist/.
495
- try {
496
- return await import(root + "/dist/audit/append.js");
497
- } catch (e2) {
498
- process.stderr.write(
499
- "audit import failed: package=" + (e1 && e1.message ? e1.message : e1) +
500
- "; dist=" + (e2 && e2.message ? e2.message : e2) + "\n");
501
- process.exit(1);
502
- }
503
- }
86
+ if [ -z "$CLI_MISSING_FILE_PATH" ]; then
87
+ exit 0
88
+ fi
89
+ CLI_MISSING_RELEVANT=0
90
+ case "$CLI_MISSING_FILE_PATH" in
91
+ *".claude/settings"*) CLI_MISSING_RELEVANT=1 ;;
92
+ *".claude/hooks/"*) CLI_MISSING_RELEVANT=1 ;;
93
+ *".husky/"*) CLI_MISSING_RELEVANT=1 ;;
94
+ *".rea/policy.yaml"*) CLI_MISSING_RELEVANT=1 ;;
95
+ *".rea/HALT"*) CLI_MISSING_RELEVANT=1 ;;
96
+ *".rea/last-review"*) CLI_MISSING_RELEVANT=1 ;;
97
+ *".claude\\"*|*".husky\\"*|*".rea\\"*) CLI_MISSING_RELEVANT=1 ;;
98
+ *"..%2F"*|*"%2E%2E"*) CLI_MISSING_RELEVANT=1 ;;
99
+ esac
100
+ # Codex round-1 P2 fix: scan policy.protected_writes entries too so a
101
+ # consumer-defined protected path isn't silently allowed when the CLI
102
+ # is missing.
103
+ if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
104
+ POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
105
+ if [ -f "$POLICY_FILE" ]; then
106
+ while IFS= read -r entry; do
107
+ [ -z "$entry" ] && continue
108
+ base="$entry"
109
+ case "$base" in
110
+ */) base="${base%/}" ;;
111
+ esac
112
+ [ -z "$base" ] && continue
113
+ case "$CLI_MISSING_FILE_PATH" in
114
+ *"$base"*) CLI_MISSING_RELEVANT=1; break ;;
115
+ esac
116
+ done < <(awk '
117
+ /^protected_writes:/ { in_block=1; next }
118
+ in_block && /^[[:space:]]*-/ {
119
+ sub(/^[[:space:]]*-[[:space:]]*/, "")
120
+ gsub(/^["'\'']/, "")
121
+ gsub(/["'\'']$/, "")
122
+ print
123
+ next
504
124
  }
505
- (async () => {
506
- const mod = await loadMod();
507
- try {
508
- await mod.appendAuditRecord(root, {
509
- session_id: process.env.REA_AUDIT_SESSION,
510
- tool_name: "hooks.patch.session",
511
- server_name: "rea",
512
- tier: "write",
513
- status: "allowed",
514
- autonomy_level: "unknown",
515
- duration_ms: 0,
516
- metadata: {
517
- reason: process.env.REA_AUDIT_REASON,
518
- file: process.env.REA_AUDIT_FILE,
519
- sha_before: process.env.REA_AUDIT_SHA,
520
- actor: {
521
- name: process.env.REA_AUDIT_ACTOR_NAME,
522
- email: process.env.REA_AUDIT_ACTOR_EMAIL,
523
- },
524
- pid: Number(process.env.REA_AUDIT_PID),
525
- ppid: Number(process.env.REA_AUDIT_PPID),
526
- },
527
- });
528
- process.exit(0);
529
- } catch (e) {
530
- process.stderr.write("audit append failed: " + (e && e.message ? e.message : e) + "\n");
531
- process.exit(1);
532
- }
533
- })();
534
- ' 2>&1
535
- )
536
- AUDIT_EXIT=$?
537
- if [[ "$AUDIT_EXIT" -ne 0 ]]; then
538
- # Fail closed. We deliberately do NOT fall back to a raw `jq … >> audit`
539
- # write: that path skips prev_hash/hash computation and would silently
540
- # degrade the hash-chain integrity the rest of REA (and `rea audit verify`)
541
- # relies on. If the TypeScript chain is unavailable (no `dist/`, missing
542
- # Node, broken import), refuse the hook-patch edit and surface why. The
543
- # operator resolves by building the package (`pnpm build`) or running
544
- # against a published install that ships `dist/`.
545
- {
546
- printf 'SETTINGS PROTECTION: audit-append failed; refusing hook-patch edit\n'
547
- printf ' File: %s\n' "$SAFE_FILE_PATH"
548
- printf ' Rule: hash-chained audit is required; no raw-jq fallback.\n'
549
- printf ' Detail: %s\n' "$(sanitize_for_stderr "$AUDIT_PAYLOAD")"
550
- } >&2
551
- exit 2
125
+ in_block && /^[^[:space:]-]/ { in_block=0 }
126
+ ' "$POLICY_FILE" 2>/dev/null)
552
127
  fi
553
- printf 'REA_HOOK_PATCH_SESSION: allowing edit to %s (reason: %s)\n' \
554
- "$SAFE_NORMALIZED" "$SAFE_REASON" >&2
128
+ fi
129
+ if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
555
130
  exit 0
556
131
  fi
132
+ printf 'rea: settings-protection cannot run — the rea CLI is not built.\n' >&2
133
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
134
+ printf 'This shim fails closed because the pre-0.35.0 bash body enforced protected-path refusal without a CLI.\n' >&2
135
+ exit 2
136
+ fi
137
+
138
+ # 4. Realpath sandbox check.
139
+ if ! command -v node >/dev/null 2>&1; then
140
+ printf 'rea: settings-protection cannot run — `node` is not on PATH.\n' >&2
141
+ printf 'Install Node 22+ (engines.node) to restore protected-path refusal.\n' >&2
142
+ exit 2
557
143
  fi
558
144
 
559
- # ── 6c. Patch-session patterns are still blocked when env var is NOT set ─────
560
- if match_patch_session; then
561
- {
562
- printf 'SETTINGS PROTECTION: Modification blocked\n'
563
- printf '\n'
564
- printf ' File: %s\n' "$SAFE_FILE_PATH"
565
- printf ' Matched: %s\n' "$PROTECTED_MATCH"
566
- printf ' Rule: Files under this path are protected. To apply an upstream\n'
567
- printf ' hook finding, set REA_HOOK_PATCH_SESSION=<reason> and retry.\n'
568
- } >&2
145
+ sandbox_check=$(node -e '
146
+ const fs = require("fs");
147
+ const path = require("path");
148
+ const cli = process.argv[1];
149
+ const projDir = process.argv[2];
150
+ let real, realProj;
151
+ try { real = fs.realpathSync(cli); } catch (e) {
152
+ process.stdout.write("bad:realpath"); process.exit(1);
153
+ }
154
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
155
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
156
+ }
157
+ const sep = path.sep;
158
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
159
+ if (!(real === realProj || real.startsWith(projWithSep))) {
160
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
161
+ }
162
+ // Codex round-1 P1 fix: enforce dist/cli/index.js shape so a
163
+ // workspace attacker who repoints node_modules/@bookedsolid/rea or
164
+ // dist at an arbitrary in-project JS file cannot execute it as the
165
+ // trusted gate CLI. Pre-0.35.0 shims had this check; the 0.34.0
166
+ // round-8 template dropped it; restored here.
167
+ const expectedEnd = path.join("dist", "cli", "index.js");
168
+ if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
169
+ process.stdout.write("bad:cli-shape"); process.exit(1);
170
+ }
171
+ let cur = path.dirname(path.dirname(path.dirname(real)));
172
+ let found = false;
173
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
174
+ const pj = path.join(cur, "package.json");
175
+ if (fs.existsSync(pj)) {
176
+ try {
177
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
178
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
179
+ } catch (e) { /* keep walking */ }
180
+ }
181
+ cur = path.dirname(cur);
182
+ }
183
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
184
+ process.stdout.write("ok");
185
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
186
+
187
+ if [ "$sandbox_check" != "ok" ]; then
188
+ printf 'rea: settings-protection FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
569
189
  exit 2
570
190
  fi
571
191
 
572
- if match_patch_session_ci; then
573
- {
574
- printf 'SETTINGS PROTECTION: Modification blocked (case-insensitive match)\n'
575
- printf '\n'
576
- printf ' File: %s\n' "$SAFE_FILE_PATH"
577
- printf ' Matched: %s\n' "$PROTECTED_MATCH"
578
- } >&2
192
+ # 5. Version-probe.
193
+ probe_out=$("${REA_ARGV[@]}" hook settings-protection --help 2>&1)
194
+ probe_status=$?
195
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'settings-protection'; then
196
+ printf 'rea: this shim requires the `rea hook settings-protection` subcommand (introduced in 0.35.0).\n' >&2
197
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
198
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
579
199
  exit 2
580
200
  fi
581
201
 
582
- exit 0
202
+ # 6. Forward stdin (already captured up-front).
203
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook settings-protection
204
+ exit $?