@bookedsolid/rea 0.14.0 → 0.16.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.
@@ -17,6 +17,12 @@
17
17
 
18
18
  set -uo pipefail
19
19
 
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
+
20
26
  INPUT=$(cat)
21
27
 
22
28
  # ── Dependency check ──────────────────────────────────────────────────────────
@@ -27,13 +33,11 @@ if ! command -v jq >/dev/null 2>&1; then
27
33
  fi
28
34
 
29
35
  # ── HALT check ────────────────────────────────────────────────────────────────
30
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
31
- HALT_FILE="${REA_ROOT}/.rea/HALT"
32
- if [ -f "$HALT_FILE" ]; then
33
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
34
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
35
- exit 2
36
- fi
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)
37
41
 
38
42
  CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
39
43
 
@@ -70,17 +74,21 @@ PATTERN_ENV_FILE='(\.env[a-zA-Z0-9._-]*|\.envrc)([[:space:]]|"|'"'"'|$)'
70
74
  MATCHES_UTILITY=0
71
75
  MATCHES_ENV_FILE=0
72
76
 
73
- if printf '%s' "$CMD" | grep -qE "$PATTERN_UTILITY"; then
77
+ # 0.15.0: per-segment match. Pre-fix this greped the FULL command which
78
+ # false-positived on commit messages: `git commit -m "stop reading .env
79
+ # files via cat"` matched both PATTERN_UTILITY (cat) and PATTERN_ENV_FILE
80
+ # (.env) and the hook blocked a perfectly safe commit.
81
+ if any_segment_matches "$CMD" "$PATTERN_UTILITY"; then
74
82
  MATCHES_UTILITY=1
75
83
  fi
76
84
 
77
- if printf '%s' "$CMD" | grep -qE "$PATTERN_ENV_FILE"; then
85
+ if any_segment_matches "$CMD" "$PATTERN_ENV_FILE"; then
78
86
  MATCHES_ENV_FILE=1
79
87
  fi
80
88
 
81
89
  # Direct source/cp of .env files — always block
82
- if printf '%s' "$CMD" | grep -qE "$PATTERN_SOURCE" || \
83
- printf '%s' "$CMD" | grep -qE "$PATTERN_CP_ENV"; then
90
+ if any_segment_matches "$CMD" "$PATTERN_SOURCE" || \
91
+ any_segment_matches "$CMD" "$PATTERN_CP_ENV"; then
84
92
  TRUNCATED_CMD=$(truncate_cmd "$CMD")
85
93
  {
86
94
  printf 'ENV FILE PROTECTION: Direct sourcing or copying of .env files is blocked.\n'
@@ -0,0 +1,303 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: protected-paths-bash-gate.sh
3
+ # Fires BEFORE every Bash tool call.
4
+ # Refuses Bash commands that write to PROTECTED_PATTERNS via shell
5
+ # redirection or write-flag utilities — the kill-switch and policy
6
+ # files MUST be unreachable via any tool surface, including Bash.
7
+ #
8
+ # Pre-0.15.0, settings-protection.sh §6 protected `.rea/HALT`,
9
+ # `.rea/policy.yaml`, `.claude/settings.json`, `.husky/*` against
10
+ # Write/Edit/MultiEdit tool calls. But shell redirects bypassed it
11
+ # entirely:
12
+ #
13
+ # printf '...' > .rea/HALT # bypass — Bash matcher only
14
+ # tee .rea/policy.yaml < new.yaml # bypass
15
+ # cp new-settings.json .claude/settings.json
16
+ # sed -i '' '/foo/d' .husky/pre-push
17
+ # dd of=.rea/HALT
18
+ #
19
+ # This hook closes that gap by detecting redirect/write patterns
20
+ # whose target matches the same `_lib/protected-paths.sh` allowlist.
21
+ #
22
+ # Exit codes:
23
+ # 0 = no protected-path write detected — allow
24
+ # 2 = protected-path write via Bash detected — block
25
+
26
+ set -uo pipefail
27
+
28
+ # shellcheck source=_lib/protected-paths.sh
29
+ source "$(dirname "$0")/_lib/protected-paths.sh"
30
+ # shellcheck source=_lib/cmd-segments.sh
31
+ source "$(dirname "$0")/_lib/cmd-segments.sh"
32
+
33
+ INPUT=$(cat)
34
+
35
+ if ! command -v jq >/dev/null 2>&1; then
36
+ printf 'REA ERROR: jq is required but not installed.\n' >&2
37
+ exit 2
38
+ fi
39
+
40
+ REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
41
+
42
+ # HALT check — uniform with other hooks.
43
+ HALT_FILE="${REA_ROOT}/.rea/HALT"
44
+ if [ -f "$HALT_FILE" ]; then
45
+ printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
46
+ "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
47
+ exit 2
48
+ fi
49
+
50
+ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
51
+ if [[ -z "$CMD" ]]; then
52
+ exit 0
53
+ fi
54
+
55
+ # Normalize a path token. 0.16.0 codex P1 fixes (helix Findings 015):
56
+ # - resolve `..` segments via realpath when the path exists, OR reject
57
+ # them outright when it doesn't (`.claude/hooks/../settings.json`
58
+ # writes to `.claude/settings.json` but the literal-string match
59
+ # missed it pre-fix)
60
+ # - lowercase the result so case-insensitive matchers (macOS APFS,
61
+ # `.ClAuDe/settings.json`) still match the canonical lowercase
62
+ # pattern (`.claude/settings.json`)
63
+ # - apply shared `_lib/path-normalize.sh::normalize_path` for backslash
64
+ # translation + URL decode + leading-`./` strip
65
+ # shellcheck source=_lib/path-normalize.sh
66
+ source "$(dirname "$0")/_lib/path-normalize.sh"
67
+
68
+ _normalize_target() {
69
+ local t="$1"
70
+ # Strip matching surrounding quotes.
71
+ if [[ "$t" =~ ^\"(.*)\"$ ]]; then t="${BASH_REMATCH[1]}"; fi
72
+ if [[ "$t" =~ ^\'(.*)\'$ ]]; then t="${BASH_REMATCH[1]}"; fi
73
+ # If the path contains `..` segments, resolve them aggressively. We
74
+ # cannot rely on `realpath` being installed; do a manual resolution
75
+ # by walking segments. This is the helix-015 P1 fix: pre-fix, the
76
+ # literal `.claude/hooks/../settings.json` did not match the
77
+ # `.claude/settings.json` pattern even though the OS would resolve
78
+ # the write to that target.
79
+ case "/$t/" in
80
+ */../*)
81
+ # Build absolute then walk and normalize segments.
82
+ # 0.16.0 codex P1-1 fix: use `read -ra` with IFS=/ instead of an
83
+ # unquoted `for part in $abs` loop. The unquoted `for` was subject
84
+ # to pathname expansion — `.claude/*/../settings.json` would glob
85
+ # `*` against the agent's CWD, mangling the resolved path and
86
+ # bypassing the protected-paths matcher. `read -ra` with an
87
+ # explicit delimiter disables both word-splitting (via IFS) AND
88
+ # pathname expansion (read does not glob).
89
+ local abs="$t"
90
+ [[ "$abs" != /* ]] && abs="$REA_ROOT/$abs"
91
+ local -a raw_parts parts=()
92
+ IFS='/' read -ra raw_parts <<<"$abs"
93
+ for part in "${raw_parts[@]}"; do
94
+ case "$part" in
95
+ ''|.) continue ;;
96
+ ..) [[ "${#parts[@]}" -gt 0 ]] && unset 'parts[${#parts[@]}-1]' ;;
97
+ *) parts+=("$part") ;;
98
+ esac
99
+ done
100
+ t="/$(IFS=/; printf '%s' "${parts[*]}")"
101
+ # 0.16.0 codex P2-3 fix: if the resolved absolute path escapes
102
+ # REA_ROOT, emit a sentinel so the caller refuses outright.
103
+ # `exit 2` here would only exit the `$()` subshell, not the parent
104
+ # hook process — sentinel + caller-side handling is the only
105
+ # cross-shell-portable way.
106
+ if [[ "$t" != "$REA_ROOT" && "$t" != "$REA_ROOT"/* ]]; then
107
+ printf '__rea_outside_root__:%s' "$t"
108
+ return 0
109
+ fi
110
+ ;;
111
+ esac
112
+ # Hand off to shared normalize_path (strips $REA_ROOT, URL-decodes,
113
+ # translates `\` → `/`, strips leading `./`).
114
+ t=$(normalize_path "$t")
115
+ # Lowercase for case-insensitive matching (helix-015 P1 fix #2 —
116
+ # macOS APFS allows `.ClAuDe/settings.json` to land on the same
117
+ # file as `.claude/settings.json`, so the matcher must compare
118
+ # lowercased forms).
119
+ printf '%s' "$t" | tr '[:upper:]' '[:lower:]'
120
+ }
121
+
122
+ # Refuse and exit 2 with a uniform error message.
123
+ _refuse() {
124
+ local pattern="$1" target="$2" segment="$3"
125
+ {
126
+ printf 'PROTECTED PATH (bash): write to a package-managed file blocked\n'
127
+ printf '\n'
128
+ printf ' Pattern matched: %s\n' "$pattern"
129
+ printf ' Resolved target: %s\n' "$target"
130
+ printf ' Segment: %s\n' "$segment"
131
+ printf '\n'
132
+ printf ' Rule: protected paths (kill-switch, policy.yaml, settings.json,\n'
133
+ printf ' .husky/*) are unreachable via Bash redirects too — not just\n'
134
+ printf ' Write/Edit/MultiEdit. To modify, a human must edit directly.\n'
135
+ } >&2
136
+ exit 2
137
+ }
138
+
139
+ # Inspect one segment for redirect / write patterns and refuse if the
140
+ # target matches any protected pattern.
141
+ _check_segment() {
142
+ local _raw="$1" segment="$2"
143
+ [[ -z "$segment" ]] && return 0
144
+
145
+ local target_token=""
146
+ local detected_form=""
147
+
148
+ # bash `[[ =~ ]]` regex literals with `|` and `(...)` parsed inline
149
+ # confuse some bash versions on macOS. Use named variables for each
150
+ # pattern so the literal stays in a string context only.
151
+ # 0.16.0 codex P1 fix (helix-015 #3): widened redirect regex. Pre-fix
152
+ # only matched `>`, `>>`, `2>`, `2>>`, `&>`. Missed:
153
+ # - `1>` / `1>>` (explicit stdout fd)
154
+ # - `>|` (noclobber-override redirect)
155
+ # - `[0-9]+>` / `[0-9]+>>` (any fd prefix — `9>file`, `42>>file`)
156
+ # All of these write to the target and bypassed the gate. The new
157
+ # pattern accepts: optional fd-prefix, then `>` or `>>` or `>|`, with
158
+ # optional `&` for stderr-merge variants.
159
+ local re_redirect='(^|[[:space:]])(&>>|&>|[0-9]+>>|[0-9]+>\||[0-9]+>|>>|>\||>)[[:space:]]*([^[:space:]&|;<>]+)'
160
+ local re_cpmv='(^|[[:space:]])(cp|mv)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
161
+ local re_sed='(^|[[:space:]])sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
162
+ local re_dd='(^|[[:space:]])dd[[:space:]]+[^&|;<>]*of=([^[:space:]&|;<>]+)'
163
+ # 0.15.0 codex P1 fix: replaced the bash-3.2-broken `(...)*` pattern
164
+ # for tee/truncate flag-skipping with a token-walk approach that
165
+ # works across BSD bash 3.2 and GNU bash 4+. Walks every token after
166
+ # the command, skips flags (single-dash short, double-dash long with
167
+ # optional =value), returns the first non-flag token as the target.
168
+
169
+ if [[ "$segment" =~ $re_redirect ]]; then
170
+ target_token="${BASH_REMATCH[3]}"
171
+ detected_form="redirect ${BASH_REMATCH[2]}"
172
+ elif [[ "$segment" =~ $re_cpmv ]]; then
173
+ target_token="${BASH_REMATCH[3]}"
174
+ detected_form="${BASH_REMATCH[2]}"
175
+ elif [[ "$segment" =~ $re_sed ]]; then
176
+ target_token="${BASH_REMATCH[3]}"
177
+ detected_form="sed -i"
178
+ elif [[ "$segment" =~ $re_dd ]]; then
179
+ target_token="${BASH_REMATCH[2]}"
180
+ detected_form="dd of="
181
+ else
182
+ # tee / truncate / install / ln — token-walk for cross-bash safety.
183
+ # Read tokens, find the command, then return the first non-flag arg.
184
+ local prev_word="" found_cmd=""
185
+ local _seg_for_walk="$segment"
186
+ # Strip leading whitespace.
187
+ _seg_for_walk="${_seg_for_walk#"${_seg_for_walk%%[![:space:]]*}"}"
188
+ # shellcheck disable=SC2086
189
+ set -- $_seg_for_walk
190
+ while [ "$#" -gt 0 ]; do
191
+ local tok="$1"
192
+ shift
193
+ if [[ -z "$found_cmd" ]]; then
194
+ case "$tok" in
195
+ tee|truncate|install|ln)
196
+ found_cmd="$tok"
197
+ ;;
198
+ esac
199
+ prev_word="$tok"
200
+ continue
201
+ fi
202
+ # We're inside the command's argv. Skip flags.
203
+ case "$tok" in
204
+ --) continue ;;
205
+ --*=*) continue ;;
206
+ --*)
207
+ # Long flag — may take a value as the NEXT token (we don't
208
+ # know which long options take values). For safety, skip
209
+ # only known no-value long flags; otherwise consume the
210
+ # next token too if it looks like a value.
211
+ case "$tok" in
212
+ --append|--ignore-interrupts|--no-clobber|--force|--no-target-directory|--symbolic|--no-dereference|--reference=*) continue ;;
213
+ *) shift 2>/dev/null || true; continue ;;
214
+ esac
215
+ ;;
216
+ -*)
217
+ # Short flag cluster. Skip. truncate -s SIZE — `-s` is a flag,
218
+ # SIZE is its arg. We're conservative: skip the next token if
219
+ # the flag cluster's last char is one of the size-bearing
220
+ # flags (truncate -s, install -m, ln -t).
221
+ case "$tok" in
222
+ -s*|-m*|-o*|-g*|-t*) shift 2>/dev/null || true ;;
223
+ esac
224
+ continue
225
+ ;;
226
+ *)
227
+ # First non-flag token — this is the target (or, for cp/mv-
228
+ # like commands, the first source; the cpmv detector above
229
+ # handles those separately). We treat ALL non-flag args as
230
+ # potential targets and check each — that catches
231
+ # `tee a b c` where any of a/b/c could be a protected file.
232
+ target_token="$tok"
233
+ detected_form="$found_cmd"
234
+ # Check this token immediately; if not protected, keep
235
+ # walking — there may be more positional args.
236
+ local _t
237
+ _t=$(_normalize_target "$target_token")
238
+ # 0.16.0 codex P2-3: outside-REA_ROOT sentinel handling.
239
+ if [[ "$_t" == __rea_outside_root__:* ]]; then
240
+ local resolved="${_t#__rea_outside_root__:}"
241
+ {
242
+ printf 'PROTECTED PATH (bash): path traversal escapes project root\n'
243
+ printf ' Logical: %s\n Resolved: %s\n' "$target_token" "$resolved"
244
+ } >&2
245
+ exit 2
246
+ fi
247
+ if rea_path_is_protected "$_t"; then
248
+ local matched=""
249
+ local pattern_lc
250
+ for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
251
+ pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
252
+ if [[ "$_t" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
253
+ if [[ "$pattern_lc" == */ && "$_t" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
254
+ done
255
+ _refuse "$matched" "$_t" "$segment"
256
+ fi
257
+ # Reset target_token so the post-loop check doesn't double-check.
258
+ target_token=""
259
+ ;;
260
+ esac
261
+ done
262
+ fi
263
+
264
+ if [[ -z "$target_token" ]]; then
265
+ return 0
266
+ fi
267
+
268
+ local target
269
+ target=$(_normalize_target "$target_token")
270
+ # 0.16.0 codex P2-3 fix: outside-REA_ROOT sentinel from _normalize_target.
271
+ if [[ "$target" == __rea_outside_root__:* ]]; then
272
+ local resolved="${target#__rea_outside_root__:}"
273
+ {
274
+ printf 'PROTECTED PATH (bash): path traversal escapes project root\n'
275
+ printf '\n'
276
+ printf ' Logical: %s\n' "$target_token"
277
+ printf ' Resolved: %s\n' "$resolved"
278
+ printf ' Segment: %s\n' "$segment"
279
+ printf '\n'
280
+ printf ' Rule: bash redirects whose target resolves outside REA_ROOT\n'
281
+ printf ' are refused. Use a project-relative path without `..`\n'
282
+ printf ' segments.\n'
283
+ } >&2
284
+ exit 2
285
+ fi
286
+ if rea_path_is_protected "$target"; then
287
+ # Find the matching pattern for the error message. Both `target`
288
+ # and `pattern` lowercased to match `_normalize_target`'s case-
289
+ # insensitive output (helix-015 P1 fix).
290
+ local matched="" pattern_lc
291
+ for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
292
+ pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
293
+ if [[ "$target" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
294
+ if [[ "$pattern_lc" == */ && "$target" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
295
+ done
296
+ _refuse "$matched" "$target" "$segment"
297
+ fi
298
+ return 0
299
+ }
300
+
301
+ for_each_segment "$CMD" _check_segment
302
+
303
+ exit 0
@@ -29,53 +29,24 @@ if ! command -v jq >/dev/null 2>&1; then
29
29
  fi
30
30
 
31
31
  # ── HALT check ────────────────────────────────────────────────────────────────
32
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
33
- HALT_FILE="${REA_ROOT}/.rea/HALT"
34
- if [ -f "$HALT_FILE" ]; then
35
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
36
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
37
- exit 2
38
- fi
39
-
40
- FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
41
- CONTENT_WRITE=$(printf '%s' "$INPUT" | jq -r '.tool_input.content // empty' 2>/dev/null)
42
- CONTENT_EDIT=$(printf '%s' "$INPUT" | jq -r '.tool_input.new_string // empty' 2>/dev/null)
43
- # MultiEdit (0.14.0 fix): the payload is at tool_input.edits[].new_string —
44
- # an array, not a scalar — and the prior versions of this hook never read
45
- # it. Result: any agent could route credential writes through MultiEdit and
46
- # bypass the secret scanner entirely. We extract every `new_string` value
47
- # from the edits array and concatenate them with newlines so the awk-based
48
- # pattern scan below treats them like any other write content.
49
- #
50
- # Defensive coercion (codex round-1 P1): a malformed payload where
51
- # `new_string` is a number, object, or array would make jq error out, the
52
- # `2>/dev/null` would swallow stderr, `CONTENT_MULTIEDIT` would be empty,
53
- # and the precedence chain below would fall through to `exit 0` —
54
- # silently allowing the write. Same fail-open mode for a non-array
55
- # `edits` value. We:
56
- #
57
- # 1. Coerce `.tool_input.edits` to `[]` if it's anything other than an
58
- # array (`if type=="array" then . else [] end`)
59
- # 2. Coerce every `new_string` to a string via `tostring` so jq cannot
60
- # fail on heterogeneous types
61
- #
62
- # Both layers fail closed: a malformed payload either yields the empty
63
- # string (no scan needed, exit 0 from the precedence chain) or yields a
64
- # pattern-scannable string. There is no path where jq errors silently and
65
- # the hook falls through to allow.
66
- CONTENT_MULTIEDIT=$(printf '%s' "$INPUT" | jq -r '
67
- (.tool_input.edits // [] | if type=="array" then . else [] end)
68
- | map((.new_string // "") | tostring)
69
- | join("\n")
70
- ' 2>/dev/null)
71
-
72
- if [[ -n "$CONTENT_WRITE" ]]; then
73
- CONTENT="$CONTENT_WRITE"
74
- elif [[ -n "$CONTENT_EDIT" ]]; then
75
- CONTENT="$CONTENT_EDIT"
76
- elif [[ -n "$CONTENT_MULTIEDIT" ]]; then
77
- CONTENT="$CONTENT_MULTIEDIT"
78
- else
32
+ # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
33
+ # shellcheck source=_lib/halt-check.sh
34
+ source "$(dirname "$0")/_lib/halt-check.sh"
35
+ check_halt
36
+ REA_ROOT=$(rea_root)
37
+
38
+ # 0.16.0: payload extraction moved to `_lib/payload-read.sh`. The shared
39
+ # helpers handle Write content / Edit new_string / MultiEdit edits[] /
40
+ # NotebookEdit new_source with the same defensive type-guards. Adding
41
+ # the next write-tier tool is a one-line edit there, not a sweep
42
+ # across N hooks.
43
+ # shellcheck source=_lib/payload-read.sh
44
+ source "$(dirname "$0")/_lib/payload-read.sh"
45
+
46
+ FILE_PATH=$(extract_file_path "$INPUT")
47
+ CONTENT=$(extract_write_content "$INPUT")
48
+
49
+ if [[ -z "$CONTENT" ]]; then
79
50
  exit 0
80
51
  fi
81
52
 
@@ -33,13 +33,11 @@ if ! command -v jq >/dev/null 2>&1; then
33
33
  fi
34
34
 
35
35
  # ── 3. HALT check ────────────────────────────────────────────────────────────
36
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
37
- HALT_FILE="${REA_ROOT}/.rea/HALT"
38
- if [ -f "$HALT_FILE" ]; then
39
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
40
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
41
- exit 2
42
- fi
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)
43
41
 
44
42
  # ── 4. Extract file path from payload ─────────────────────────────────────────
45
43
  FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
@@ -157,14 +155,27 @@ LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
157
155
  # package-managed body — §5a kills it before this matcher runs.
158
156
  #
159
157
  # SECURITY (defense-in-depth): symlinks INSIDE the .d/ surface are
160
- # refused. A fragment is a short shell script authored in place;
161
- # consumers do not need symlinks here. Without this check, a sequence
162
- # like `ln -s ../pre-push .husky/pre-push.d/00-evil; write 00-evil`
163
- # would be allowed by §5b's path-string match and the downstream
164
- # Write/Edit tool would follow the symlink, overwriting the
165
- # package-managed `.husky/pre-push` body that §6 is meant to protect.
166
- # Costs near-zero (no legitimate use case for symlinked fragments);
167
- # closes the path-string→symlink bypass completely.
158
+ # refused both final-component AND intermediate-directory symlinks.
159
+ # A fragment is a short shell script authored in place; consumers do
160
+ # not need symlinks here. Without these checks the gate has two
161
+ # bypass shapes:
162
+ #
163
+ # (a) Final-component symlink:
164
+ # ln -s ../pre-push .husky/pre-push.d/00-evil; write 00-evil
165
+ # caught by `[ -L "$FILE_PATH" ]`.
166
+ #
167
+ # (b) Intermediate-directory symlink (helix Finding 2 / 0.15.0):
168
+ # mkdir .husky/pre-push.d; ln -s ../ .husky/pre-push.d/linkdir
169
+ # write .husky/pre-push.d/linkdir/pre-push
170
+ # — `[ -L $FILE_PATH ]` only inspects the FINAL component, so a
171
+ # not-yet-existing target whose parent contains a symlink resolves
172
+ # to outside the surface (here: `.husky/pre-push`), letting the
173
+ # attacker write through to the package-managed body.
174
+ #
175
+ # Resolve the realpath of the parent directory and require it to live
176
+ # under the literal extension surface. Use a portable `cd ... && pwd -P`
177
+ # subshell pattern (no Python or readlink -f dependency required).
178
+ # Closes the path-string→symlink bypass completely.
168
179
  case "$LOWER_NORM" in
169
180
  .husky/commit-msg.d/*|.husky/pre-push.d/*)
170
181
  if [ -L "$FILE_PATH" ]; then
@@ -178,6 +189,34 @@ case "$LOWER_NORM" in
178
189
  } >&2
179
190
  exit 2
180
191
  fi
192
+ # Resolve the parent directory's realpath. If any intermediate
193
+ # component is a symlink whose target leaves the surface, the
194
+ # resolved path no longer contains `/.husky/<surface>.d/` and we
195
+ # refuse. The parent dir must already exist for this check; if it
196
+ # doesn't, the write is creating the parent, in which case there
197
+ # is no intermediate symlink to follow yet.
198
+ parent_dir=$(dirname -- "$FILE_PATH")
199
+ if [ -d "$parent_dir" ]; then
200
+ resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
201
+ if [ -n "$resolved_parent" ]; then
202
+ case "$resolved_parent" in
203
+ *"/.husky/commit-msg.d"*|*"/.husky/pre-push.d"*) : ;;
204
+ *)
205
+ {
206
+ printf 'SETTINGS PROTECTION: extension path resolves outside surface\n'
207
+ printf '\n'
208
+ printf ' Logical: %s\n' "$SAFE_FILE_PATH"
209
+ printf ' Resolved: %s\n' "$resolved_parent"
210
+ printf ' Rule: an intermediate directory of the extension path is a\n'
211
+ printf ' symlink whose target leaves .husky/{commit-msg,pre-push}.d/.\n'
212
+ printf ' Refused to prevent symlinked-parent bypass of the\n'
213
+ printf ' package-managed body protection.\n'
214
+ } >&2
215
+ exit 2
216
+ ;;
217
+ esac
218
+ fi
219
+ fi
181
220
  # Documented extension surface — agents can write here freely.
182
221
  exit 0
183
222
  ;;
@@ -294,6 +333,63 @@ if match_protected_ci; then
294
333
  exit 2
295
334
  fi
296
335
 
336
+ # ── 6c. Intermediate-symlink resolution (0.16.0 fix H.1) ──────────────────────
337
+ # Helix Finding 2 reborn against the hard-protected list. The §5b
338
+ # extension-surface fix (0.13.2) resolved parent realpath for the
339
+ # `.husky/{commit-msg,pre-push}.d/*` allowlist; §6 was never given the
340
+ # same protection. Attack:
341
+ #
342
+ # mkdir innocuous_path
343
+ # ln -s ../.husky innocuous_path/maybe # symlink resolves to .husky/
344
+ # write innocuous_path/maybe/pre-push # writes through to .husky/pre-push
345
+ #
346
+ # §5a (`..` traversal) doesn't catch — the path string has no `..`.
347
+ # §6 PROTECTED_PATTERNS sees `innocuous_path/maybe/pre-push` — doesn't
348
+ # match `.husky/` prefix. The write succeeds and the package-managed
349
+ # pre-push body is overwritten.
350
+ #
351
+ # Fix: when the parent directory of the target exists, resolve its
352
+ # realpath via cd -P && pwd -P (same shape as §5b) and check whether
353
+ # the resolved path falls inside any protected directory. Only resolve
354
+ # when the parent already exists — a write that creates the parent has
355
+ # nothing to follow.
356
+ if [[ -e "$FILE_PATH" || -d "$(dirname -- "$FILE_PATH")" ]]; then
357
+ parent_dir=$(dirname -- "$FILE_PATH")
358
+ if [[ -d "$parent_dir" ]]; then
359
+ resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
360
+ if [[ -n "$resolved_parent" ]]; then
361
+ # If the resolved parent is inside REA_ROOT, compute the project-
362
+ # relative path and test it against the protected patterns.
363
+ if [[ "$resolved_parent" == "$REA_ROOT"/* ]]; then
364
+ relative_resolved="${resolved_parent#"$REA_ROOT"/}"
365
+ # Walk every PROTECTED_PATTERN that's a directory prefix and
366
+ # check whether the resolved parent falls inside it. Direct
367
+ # filename matches against PROTECTED_PATTERNS for the resolved
368
+ # final path (parent + basename).
369
+ resolved_target="${relative_resolved}/$(basename -- "$FILE_PATH")"
370
+ resolved_target_lc=$(printf '%s' "$resolved_target" | tr '[:upper:]' '[:lower:]')
371
+ for pattern in "${PROTECTED_PATTERNS[@]}"; do
372
+ pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
373
+ if [[ "$resolved_target_lc" == "$pattern_lc" ]] || \
374
+ { [[ "$pattern_lc" == */ ]] && [[ "$resolved_target_lc" == "$pattern_lc"* ]]; }; then
375
+ {
376
+ printf 'SETTINGS PROTECTION: intermediate-symlink resolution blocked\n'
377
+ printf '\n'
378
+ printf ' Logical: %s\n' "$SAFE_FILE_PATH"
379
+ printf ' Resolved: %s\n' "$resolved_target"
380
+ printf ' Matched: %s\n' "$pattern"
381
+ printf ' Rule: an intermediate directory of the target path is a\n'
382
+ printf ' symlink whose target falls inside a hard-protected\n'
383
+ printf ' path. Refused to prevent symlinked-parent bypass.\n'
384
+ } >&2
385
+ exit 2
386
+ fi
387
+ done
388
+ fi
389
+ fi
390
+ fi
391
+ fi
392
+
297
393
  # ── 6b. Hook-patch session (Defect I / rea#76) ───────────────────────────────
298
394
  # When REA_HOOK_PATCH_SESSION is set to a non-empty reason, allow edits under
299
395
  # .claude/hooks/ and hooks/ for this session. The session boundary IS the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.14.0",
3
+ "version": "0.16.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)",
@@ -96,9 +96,11 @@
96
96
  "lint:regex": "node scripts/lint-safe-regex.mjs",
97
97
  "format": "prettier --write .",
98
98
  "format:check": "prettier --check .",
99
- "test": "vitest run",
99
+ "test": "pnpm run test:dogfood && pnpm run test:bash-syntax && vitest run",
100
100
  "test:watch": "vitest",
101
101
  "test:coverage": "vitest run --coverage",
102
+ "test:dogfood": "node tools/check-dogfood-drift.mjs",
103
+ "test:bash-syntax": "bash -c 'for f in hooks/*.sh hooks/_lib/*.sh; do bash -n \"$f\" || exit 1; done && echo \"[bash-syntax] OK — all hooks parse cleanly\"'",
102
104
  "type-check": "tsc --noEmit",
103
105
  "changeset": "changeset",
104
106
  "changeset:version": "changeset version",