@bookedsolid/rea 0.15.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.
@@ -203,7 +203,7 @@ function checkSettingsJson(baseDir) {
203
203
  const settingsPath = path.join(baseDir, '.claude', 'settings.json');
204
204
  if (!fs.existsSync(settingsPath)) {
205
205
  return {
206
- label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit',
206
+ label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit|NotebookEdit',
207
207
  status: 'fail',
208
208
  detail: `missing: ${settingsPath}`,
209
209
  };
@@ -216,29 +216,30 @@ function checkSettingsJson(baseDir) {
216
216
  const needs = [];
217
217
  if (!matchers.has('Bash'))
218
218
  needs.push('Bash');
219
- // 0.15.0: matcher was widened from `Write|Edit` to `Write|Edit|MultiEdit`
220
- // in 0.14.0; doctor's check missed the rename. Accept either form so
221
- // pre-0.14.0 installs that haven't run `rea upgrade` still report
222
- // accurately, but the canonical produced by `defaultDesiredHooks()` is
223
- // the wider matcher.
224
- if (!matchers.has('Write|Edit|MultiEdit') && !matchers.has('Write|Edit')) {
225
- needs.push('Write|Edit|MultiEdit');
219
+ // 0.16.0: matcher widened to `Write|Edit|MultiEdit|NotebookEdit`. Doctor
220
+ // accepts any of the three historical shapes so pre-0.14.0 / pre-0.16.0
221
+ // installs that haven't run `rea upgrade` still report accurately. The
222
+ // canonical from `defaultDesiredHooks()` is the widest matcher.
223
+ if (!matchers.has('Write|Edit|MultiEdit|NotebookEdit') &&
224
+ !matchers.has('Write|Edit|MultiEdit') &&
225
+ !matchers.has('Write|Edit')) {
226
+ needs.push('Write|Edit|MultiEdit|NotebookEdit');
226
227
  }
227
228
  if (needs.length === 0) {
228
229
  return {
229
- label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit',
230
+ label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit|NotebookEdit',
230
231
  status: 'pass',
231
232
  };
232
233
  }
233
234
  return {
234
- label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit',
235
+ label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit|NotebookEdit',
235
236
  status: 'fail',
236
237
  detail: `missing PreToolUse matchers: ${needs.join(', ')}`,
237
238
  };
238
239
  }
239
240
  catch (e) {
240
241
  return {
241
- label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit',
242
+ label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit|NotebookEdit',
242
243
  status: 'fail',
243
244
  detail: e instanceof Error ? e.message : String(e),
244
245
  };
@@ -268,7 +268,7 @@ export function defaultDesiredHooks() {
268
268
  },
269
269
  {
270
270
  event: 'PreToolUse',
271
- matcher: 'Write|Edit|MultiEdit',
271
+ matcher: 'Write|Edit|MultiEdit|NotebookEdit',
272
272
  hooks: [
273
273
  { type: 'command', command: `${base}/secret-scanner.sh`, timeout: 15000, statusMessage: 'Scanning for credentials...' },
274
274
  { type: 'command', command: `${base}/settings-protection.sh`, timeout: 5000, statusMessage: 'Checking settings protection...' },
@@ -278,7 +278,7 @@ export function defaultDesiredHooks() {
278
278
  },
279
279
  {
280
280
  event: 'PostToolUse',
281
- matcher: 'Write|Edit|MultiEdit',
281
+ matcher: 'Write|Edit|MultiEdit|NotebookEdit',
282
282
  hooks: [
283
283
  { type: 'command', command: `${base}/architecture-review-gate.sh`, timeout: 10000, statusMessage: 'Checking architecture impact...' },
284
284
  ],
@@ -53,7 +53,28 @@ _rea_split_segments() {
53
53
  # GNU sed and BSD sed both honor `s/PATTERN/\n/g` with `-E` for ERE.
54
54
  # We use printf+sed instead of bash IFS=$'...' read so the splitter
55
55
  # behaves identically across BSD and GNU sed.
56
- printf '%s\n' "$cmd" | sed -E 's/(\|\||&&|;|\|)/\n/g'
56
+ #
57
+ # 0.16.0 codex P1 fix (helix-015 #3): the prior sed split on bare `|`
58
+ # which broke bash's `>|` (noclobber-override redirect) into two
59
+ # segments — `printf x >` then ` .rea/HALT`. The redirect detector
60
+ # then never saw a complete `>|` operator and the bash-gate let the
61
+ # write through.
62
+ #
63
+ # 0.16.0 codex P2-1 fix: the placeholder must NOT collide with any
64
+ # legal byte the agent could supply. The earlier `\x01` (SOH) is a
65
+ # legal UTF-8 byte and rare-but-possible in commands; if a payload
66
+ # contained `\x01` literally, the third sed pass would manufacture
67
+ # a `>|` operator that wasn't in the original — corrupting downstream
68
+ # parsing in either fail-open or fail-closed directions depending on
69
+ # what came after. The new sentinel `__REA_GTPIPE_a8f2c1__` is
70
+ # multi-byte alphanumeric, impossible to collide with shell input
71
+ # under any encoding we care about (any agent that intentionally
72
+ # included this string would already be obviously trying to confuse
73
+ # the splitter — and even then, the worst case is fail-closed).
74
+ printf '%s\n' "$cmd" \
75
+ | sed -E 's/>\|/__REA_GTPIPE_a8f2c1__/g' \
76
+ | sed -E 's/(\|\||&&|;|\|)/\n/g' \
77
+ | sed -E 's/__REA_GTPIPE_a8f2c1__/>|/g'
57
78
  }
58
79
 
59
80
  # Strip leading whitespace and well-known command prefixes from a single
@@ -7,7 +7,13 @@
7
7
  # call with a clear error that surfaces the file's contents so the operator
8
8
  # knows why the system was frozen.
9
9
 
10
- set -euo pipefail
10
+ # NOTE: do NOT set `-e` here. This file is sourced by hooks that
11
+ # intentionally tolerate non-zero exits (e.g. secret-scanner.sh runs
12
+ # multiple grep passes that may legitimately produce non-zero from
13
+ # patterns that don't match). Setting -e in the sourced lib would
14
+ # propagate to the caller and cause spurious exit-1s on any benign
15
+ # non-zero return. Only set the safer subset.
16
+ set -uo pipefail
11
17
 
12
18
  # Find the .rea/ directory by walking up from CLAUDE_PROJECT_DIR or cwd.
13
19
  # Falls back to CLAUDE_PROJECT_DIR or the current working directory when no
@@ -0,0 +1,72 @@
1
+ # shellcheck shell=bash
2
+ # hooks/_lib/path-normalize.sh — canonical path normalization shared
3
+ # across every hook that compares `tool_input.file_path` against a
4
+ # literal/glob policy entry.
5
+ #
6
+ # Pre-0.16.0 each hook reimplemented its own normalize_path. Drift
7
+ # was the result: settings-protection.sh translated `\` → `/` and
8
+ # decoded `%5C` since 0.10.x; blocked-paths-enforcer caught up only
9
+ # in 0.15.0; architecture-review-gate has never normalized at all.
10
+ # This single helper is now the source of truth for both forms.
11
+ #
12
+ # normalize_path PATH
13
+ # Strip $REA_ROOT prefix → URL-decode `%2F`/`%2E`/`%20`/`%5C`
14
+ # → translate `\` → `/` → strip leading `./` segments. Echoes
15
+ # the project-relative form.
16
+ #
17
+ # resolve_parent_realpath PATH
18
+ # Resolve the realpath of the parent dir of PATH via `cd -P && pwd -P`.
19
+ # Returns the empty string if the parent doesn't exist (legitimate
20
+ # "we are creating the parent" case — caller should NOT treat
21
+ # empty as a fail). Used to detect intermediate-symlink bypass:
22
+ # if the realpath of the parent doesn't contain the protected
23
+ # surface anymore, the path resolved out of the surface.
24
+ #
25
+ # All normalization happens in pure bash + sed/tr — no python/perl/
26
+ # readlink -f dependency, so it works on macOS, Alpine, and minimal
27
+ # CI containers.
28
+
29
+ # REA_ROOT is required to be set by the caller (every rea hook sets
30
+ # it from CLAUDE_PROJECT_DIR or pwd). Default to current dir if missing
31
+ # so this lib is sourceable from a test harness that hasn't set it.
32
+ : "${REA_ROOT:=${CLAUDE_PROJECT_DIR:-$(pwd)}}"
33
+
34
+ normalize_path() {
35
+ local p="$1"
36
+ # Strip $REA_ROOT/ prefix (with or without trailing slash).
37
+ if [[ "$p" == "$REA_ROOT"/* ]]; then
38
+ p="${p#"$REA_ROOT"/}"
39
+ fi
40
+ # URL-decode common sequences. Include %5C for backslash so
41
+ # Windows / Git Bash percent-encoded paths normalize the same as
42
+ # forward-slash forms.
43
+ p=$(printf '%s' "$p" | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g; s/%5[Cc]/\\/g')
44
+ # Translate any backslash separators to forward slashes.
45
+ p=$(printf '%s' "$p" | tr '\\\\' '/')
46
+ # Strip leading `./` segments. We deliberately do NOT strip
47
+ # interior `./` — that transformation corrupts `..` traversals
48
+ # (e.g. `.../` collapsed to `../`) and hides traversal from any
49
+ # downstream check.
50
+ while [[ "$p" == ./* ]]; do
51
+ p="${p#./}"
52
+ done
53
+ printf '%s' "$p"
54
+ }
55
+
56
+ resolve_parent_realpath() {
57
+ local target_path="$1"
58
+ local parent_dir
59
+ parent_dir=$(dirname -- "$target_path")
60
+ if [[ ! -d "$parent_dir" ]]; then
61
+ # Parent doesn't exist yet — caller should treat as "no realpath
62
+ # available" and fall back to logical-path checks. Return empty.
63
+ printf ''
64
+ return 0
65
+ fi
66
+ # `cd -P` follows symlinks; `pwd -P` prints the resolved physical
67
+ # path. Subshell scopes the cd so we don't pollute the caller's
68
+ # working directory.
69
+ local resolved
70
+ resolved=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved=""
71
+ printf '%s' "$resolved"
72
+ }
@@ -0,0 +1,66 @@
1
+ # shellcheck shell=bash
2
+ # hooks/_lib/payload-read.sh — shared payload extraction across the
3
+ # write-tier tools (Write, Edit, MultiEdit, NotebookEdit).
4
+ #
5
+ # Pre-0.16.0 every content-scanning hook (secret-scanner.sh,
6
+ # changeset-security-gate.sh) carried its own jq expression that
7
+ # walked tool_input.{content, new_string, edits[].new_string}. When
8
+ # Anthropic added MultiEdit (0.14.0 fix) every hook needed a
9
+ # parallel patch and one was missed. NotebookEdit is the next tool
10
+ # in the same family — `tool_input.notebook_path` (path) +
11
+ # `tool_input.new_source` (cell content). This single helper handles
12
+ # all four tools so adding the next one is a one-line edit here, not
13
+ # a sweep across N hooks.
14
+ #
15
+ # extract_write_content INPUT_JSON
16
+ # Echo the content of the about-to-be-written payload, regardless
17
+ # of which write tool produced it. Tries in order:
18
+ # 1. .tool_input.content (Write)
19
+ # 2. .tool_input.new_string (Edit)
20
+ # 3. .tool_input.edits[].new_string (MultiEdit, joined \n)
21
+ # 4. .tool_input.new_source (NotebookEdit cell)
22
+ # Returns empty string when none of these are present (caller
23
+ # should treat empty as "nothing to scan, exit 0"). Defensive
24
+ # coercion via `tostring` + array-type-guard so a malformed
25
+ # payload (non-string new_string, non-array edits) fails closed
26
+ # to the empty string rather than fail-open via jq error.
27
+ #
28
+ # extract_file_path INPUT_JSON
29
+ # Echo the file_path / notebook_path of the payload. NotebookEdit
30
+ # uses notebook_path; Write/Edit/MultiEdit use file_path. Returns
31
+ # empty string when neither is present.
32
+ #
33
+ # Both helpers require jq.
34
+
35
+ extract_write_content() {
36
+ local input="$1"
37
+ printf '%s' "$input" | jq -r '
38
+ # Try Write content first.
39
+ if (.tool_input.content // "") != "" then
40
+ .tool_input.content
41
+ # Then Edit new_string.
42
+ elif (.tool_input.new_string // "") != "" then
43
+ .tool_input.new_string
44
+ # Then MultiEdit edits[].new_string (defensive: type-guard +
45
+ # tostring so heterogeneous types do not error jq).
46
+ elif ((.tool_input.edits // [] | if type=="array" then . else [] end | length) > 0) then
47
+ (.tool_input.edits // [] | if type=="array" then . else [] end)
48
+ | map((.new_string // "") | tostring)
49
+ | join("\n")
50
+ # Then NotebookEdit new_source (notebook cell content).
51
+ elif (.tool_input.new_source // "") != "" then
52
+ .tool_input.new_source
53
+ else
54
+ ""
55
+ end
56
+ ' 2>/dev/null
57
+ }
58
+
59
+ extract_file_path() {
60
+ local input="$1"
61
+ printf '%s' "$input" | jq -r '
62
+ .tool_input.file_path
63
+ // .tool_input.notebook_path
64
+ // empty
65
+ ' 2>/dev/null
66
+ }
@@ -7,7 +7,10 @@
7
7
  # project root via rea_root() from halt-check.sh, but will re-derive it if
8
8
  # REA_ROOT is unset.
9
9
 
10
- set -euo pipefail
10
+ # NOTE: do NOT set `-e` here — see hooks/_lib/halt-check.sh for the
11
+ # rationale. This is a sourced library; -e would propagate to callers
12
+ # and cause spurious exit-1s on benign non-zero returns from grep/sed.
13
+ set -uo pipefail
11
14
 
12
15
  # Resolve the path to .rea/policy.yaml for the current project.
13
16
  # Prints an empty string if no policy file is found — callers should treat
@@ -12,7 +12,9 @@
12
12
 
13
13
  # The path list is bash glob patterns matched against project-root-
14
14
  # relative paths. Suffix `/` indicates a prefix match; no suffix means
15
- # exact match. Mirrors the array in settings-protection.sh §6.
15
+ # (case-insensitive) exact match see `rea_path_is_protected` for the
16
+ # helix-015 #2 lowercase-comparison rationale. Mirrors the array in
17
+ # settings-protection.sh §6.
16
18
  REA_PROTECTED_PATTERNS=(
17
19
  '.claude/settings.json'
18
20
  '.claude/settings.local.json'
@@ -24,14 +26,23 @@ REA_PROTECTED_PATTERNS=(
24
26
  # Test whether a project-relative path matches any protected pattern.
25
27
  # Usage: if rea_path_is_protected ".rea/HALT"; then echo "blocked"; fi
26
28
  # Returns 0 on match, 1 on no match.
29
+ #
30
+ # 0.16.0 codex P1 fix (helix-015 #2): match case-insensitively.
31
+ # macOS APFS (default case-insensitive) lets `.ClAuDe/settings.json`
32
+ # land on the same file as `.claude/settings.json`. settings-protection.sh
33
+ # §6 has had a CI matcher since 0.10.x; this helper was missing it.
34
+ # We lowercase BOTH sides so the comparison is symmetric — callers can
35
+ # pass either case.
27
36
  rea_path_is_protected() {
28
- local p="$1"
29
- local pattern
37
+ local p_lc
38
+ p_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
39
+ local pattern pattern_lc
30
40
  for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
31
- if [[ "$p" == "$pattern" ]]; then
41
+ pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
42
+ if [[ "$p_lc" == "$pattern_lc" ]]; then
32
43
  return 0
33
44
  fi
34
- if [[ "$pattern" == */ ]] && [[ "$p" == "$pattern"* ]]; then
45
+ if [[ "$pattern_lc" == */ ]] && [[ "$p_lc" == "$pattern_lc"* ]]; then
35
46
  return 0
36
47
  fi
37
48
  done
@@ -18,13 +18,11 @@ if ! command -v jq >/dev/null 2>&1; then
18
18
  fi
19
19
 
20
20
  # ── 3. HALT check ────────────────────────────────────────────────────────────
21
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
22
- HALT_FILE="${REA_ROOT}/.rea/HALT"
23
- if [ -f "$HALT_FILE" ]; then
24
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
25
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
26
- exit 2
27
- fi
21
+ # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
22
+ # shellcheck source=_lib/halt-check.sh
23
+ source "$(dirname "$0")/_lib/halt-check.sh"
24
+ check_halt
25
+ REA_ROOT=$(rea_root)
28
26
 
29
27
  # ── 4. Check if enabled ──────────────────────────────────────────────────────
30
28
  POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
@@ -41,10 +39,15 @@ if [[ -z "$FILE_PATH" ]]; then
41
39
  exit 0
42
40
  fi
43
41
 
44
- # Normalize to relative path
45
- if [[ "$FILE_PATH" == "$REA_ROOT"/* ]]; then
46
- FILE_PATH="${FILE_PATH#$REA_ROOT/}"
47
- fi
42
+ # 0.16.0 fix D.1: normalize via shared `_lib/path-normalize.sh` so
43
+ # Windows / Git Bash backslash paths and URL-encoded forms are handled
44
+ # uniformly with the rest of the hook layer. Pre-fix, this hook only
45
+ # stripped $REA_ROOT prefix — `src\gateway\foo.ts` (Windows) or
46
+ # `src%2Fgateway%2Ffoo.ts` (URL-encoded) silently bypassed the
47
+ # architectural review.
48
+ # shellcheck source=_lib/path-normalize.sh
49
+ source "$(dirname "$0")/_lib/path-normalize.sh"
50
+ FILE_PATH=$(normalize_path "$FILE_PATH")
48
51
 
49
52
  # ── 6. Check architecture-sensitive paths ─────────────────────────────────────
50
53
  ARCH_PATTERNS=(
@@ -26,13 +26,11 @@ if ! command -v jq >/dev/null 2>&1; then
26
26
  fi
27
27
 
28
28
  # ── 3. HALT check ─────────────────────────────────────────────────────────────
29
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
30
- HALT_FILE="${REA_ROOT}/.rea/HALT"
31
- if [ -f "$HALT_FILE" ]; then
32
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
33
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
34
- exit 2
35
- fi
29
+ # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
30
+ # shellcheck source=_lib/halt-check.sh
31
+ source "$(dirname "$0")/_lib/halt-check.sh"
32
+ check_halt
33
+ REA_ROOT=$(rea_root)
36
34
 
37
35
  # ── 4. Check if attribution blocking is enabled ──────────────────────────────
38
36
  POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
@@ -23,13 +23,11 @@ if ! command -v jq >/dev/null 2>&1; then
23
23
  fi
24
24
 
25
25
  # ── 3. HALT check ────────────────────────────────────────────────────────────
26
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
27
- HALT_FILE="${REA_ROOT}/.rea/HALT"
28
- if [ -f "$HALT_FILE" ]; then
29
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
30
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
31
- exit 2
32
- fi
26
+ # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
27
+ # shellcheck source=_lib/halt-check.sh
28
+ source "$(dirname "$0")/_lib/halt-check.sh"
29
+ check_halt
30
+ REA_ROOT=$(rea_root)
33
31
 
34
32
  # ── 4. Extract file path from payload ─────────────────────────────────────────
35
33
  FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
@@ -100,24 +98,12 @@ AGENT_WRITABLE=(
100
98
  '.rea/audit/'
101
99
  )
102
100
 
103
- normalize_path() {
104
- local p="$1"
105
- local root="$REA_ROOT"
106
- if [[ "$p" == "$root"/* ]]; then
107
- p="${p#$root/}"
108
- fi
109
- # 0.15.0 fix: include `\` (and `%5C` percent-encoded form) in the
110
- # normalization. Without this, a path like
111
- # `.github\workflows\release.yml` under Windows / Git Bash reaches
112
- # that file but compares as a different string than
113
- # `.github/workflows/release.yml`, missing the literal blocked-paths
114
- # match. Mirrors settings-protection.sh §4 which has had backslash
115
- # normalization since 0.10.x.
116
- p=$(printf '%s' "$p" | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g; s/%5[Cc]/\\/g')
117
- p=$(printf '%s' "$p" | tr '\\\\' '/')
118
- p="${p#./}"
119
- printf '%s' "$p"
120
- }
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"
121
107
 
122
108
  NORMALIZED=$(normalize_path "$FILE_PATH")
123
109
 
@@ -219,4 +205,41 @@ for blocked in "${BLOCKED_PATHS[@]}"; do
219
205
  fi
220
206
  done
221
207
 
208
+ # ── 0.16.0 fix H.2: intermediate-symlink resolution ──────────────────────────
209
+ # Same shape as Helix Finding 2 against blocked_paths policy entries.
210
+ # If `secrets/` is in blocked_paths and an attacker creates
211
+ # `pretty/ -> ../secrets/`, then writes `pretty/foo`, the literal-match
212
+ # loop above sees `pretty/foo` (no match) and exits 0 — the downstream
213
+ # Write tool follows the symlink and lands the body in `secrets/foo`.
214
+ # Mirrors settings-protection.sh §6c.
215
+ if [[ -e "$FILE_PATH" || -d "$(dirname -- "$FILE_PATH")" ]]; then
216
+ parent_dir=$(dirname -- "$FILE_PATH")
217
+ if [[ -d "$parent_dir" ]]; then
218
+ resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
219
+ if [[ -n "$resolved_parent" && "$resolved_parent" == "$REA_ROOT"/* ]]; then
220
+ relative_resolved="${resolved_parent#"$REA_ROOT"/}"
221
+ resolved_target="${relative_resolved}/$(basename -- "$FILE_PATH")"
222
+ resolved_target_lc=$(printf '%s' "$resolved_target" | tr '[:upper:]' '[:lower:]')
223
+ for blocked in "${BLOCKED_PATHS[@]}"; do
224
+ blocked_lc=$(printf '%s' "$blocked" | tr '[:upper:]' '[:lower:]')
225
+ if [[ "$resolved_target_lc" == "$blocked_lc" ]] || \
226
+ { [[ "$blocked_lc" == */ ]] && [[ "$resolved_target_lc" == "$blocked_lc"* ]]; }; then
227
+ {
228
+ printf 'BLOCKED PATH: intermediate-symlink resolution blocked\n'
229
+ printf '\n'
230
+ printf ' Logical: %s\n' "$FILE_PATH"
231
+ printf ' Resolved: %s\n' "$resolved_target"
232
+ printf ' Blocked by: %s\n' "$blocked"
233
+ printf ' Source: .rea/policy.yaml → blocked_paths\n'
234
+ printf '\n'
235
+ printf ' Rule: an intermediate directory of the path is a symlink\n'
236
+ printf ' whose target falls inside a blocked policy entry.\n'
237
+ } >&2
238
+ exit 2
239
+ fi
240
+ done
241
+ fi
242
+ fi
243
+ fi
244
+
222
245
  exit 0
@@ -26,38 +26,29 @@ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
26
26
  # 0.15.0 fix: MultiEdit was not in the allowed tool_name set, so the gate
27
27
  # silently exited 0 on every MultiEdit call against `.changeset/*.md` —
28
28
  # letting GHSA / CVE pre-disclosure through and skipping frontmatter
29
- # validation. Same bypass shape as the secret-scanner MultiEdit issue
30
- # fixed in 0.14.0; this is the second hook in the same family.
31
- if [[ "$TOOL_NAME" != "Write" && "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "MultiEdit" ]]; then
29
+ # validation. 0.16.0: NotebookEdit added too (changesets are .md files
30
+ # but a malicious agent could in principle route a .md write through
31
+ # NotebookEdit's new_source path; cheap to allow, free to test).
32
+ if [[ "$TOOL_NAME" != "Write" && "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "MultiEdit" && "$TOOL_NAME" != "NotebookEdit" ]]; then
32
33
  exit 0
33
34
  fi
34
35
 
35
- FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
36
+ require_jq
37
+
38
+ # 0.16.0: payload extraction migrated to `_lib/payload-read.sh`. Shared
39
+ # helpers handle every write-tier tool with the same defensive
40
+ # coercion. Adding the next write-tier tool is a one-line edit there.
41
+ # shellcheck source=_lib/payload-read.sh
42
+ source "$(dirname "$0")/_lib/payload-read.sh"
43
+
44
+ FILE_PATH=$(extract_file_path "$INPUT")
36
45
 
37
46
  # Only care about .changeset/*.md files — exclude README.md (changeset tool metadata)
38
47
  if ! echo "$FILE_PATH" | grep -qE '\.changeset/[^/]+\.md$' || echo "$FILE_PATH" | grep -qE '\.changeset/README\.md$'; then
39
48
  exit 0
40
49
  fi
41
50
 
42
- require_jq
43
-
44
- # Extract the content being written. MultiEdit content lives at
45
- # `tool_input.edits[].new_string` (an array, not a scalar) — see the
46
- # corresponding extraction in hooks/secret-scanner.sh. We use the same
47
- # defensive coercion (`tostring` + `if type=="array" then . else [] end`)
48
- # so a malformed payload fails closed: either yields the empty string
49
- # (no scan needed, exit 0) or yields a pattern-scannable string.
50
- if [[ "$TOOL_NAME" == "Write" ]]; then
51
- CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // ""')
52
- elif [[ "$TOOL_NAME" == "Edit" ]]; then
53
- CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // ""')
54
- else
55
- CONTENT=$(echo "$INPUT" | jq -r '
56
- (.tool_input.edits // [] | if type=="array" then . else [] end)
57
- | map((.new_string // "") | tostring)
58
- | join("\n")
59
- ')
60
- fi
51
+ CONTENT=$(extract_write_content "$INPUT")
61
52
 
62
53
  # ─── 1. SECURITY DISCLOSURE CHECK ───────────────────────────────────────────
63
54
  #
@@ -35,13 +35,11 @@ if ! command -v jq >/dev/null 2>&1; then
35
35
  fi
36
36
 
37
37
  # ── 3. HALT check ─────────────────────────────────────────────────────────────
38
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
39
- HALT_FILE="${REA_ROOT}/.rea/HALT"
40
- if [ -f "$HALT_FILE" ]; then
41
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
42
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
43
- exit 2
44
- fi
38
+ # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
39
+ # shellcheck source=_lib/halt-check.sh
40
+ source "$(dirname "$0")/_lib/halt-check.sh"
41
+ check_halt
42
+ REA_ROOT=$(rea_root)
45
43
 
46
44
  # ── 4. Parse tool_input.command from the hook payload ─────────────────────────
47
45
  CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
@@ -285,48 +283,34 @@ if any_segment_matches "$CMD" '(alias|function)[[:space:]]+[a-zA-Z_]+.*(--(no-ve
285
283
  "Alt: Do not wrap bypass patterns in aliases or functions."
286
284
  fi
287
285
 
288
- # H17: context_protection — block commands that should be delegated to subagents
286
+ # H17: context_protection — block commands that should be delegated to subagents.
289
287
  # Reads context_protection.delegate_to_subagent from .rea/policy.yaml.
290
288
  # These commands produce excessive output that exhausts coordinator context windows.
291
- POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
292
- if [[ -f "$POLICY_FILE" ]]; then
293
- DELEGATE_PATTERNS=()
294
- IN_DELEGATE_BLOCK=0
295
- while IFS= read -r line; do
296
- if printf '%s' "$line" | grep -qE '^[[:space:]]*delegate_to_subagent:'; then
297
- # Check for inline empty array
298
- if printf '%s' "$line" | grep -qE 'delegate_to_subagent:[[:space:]]*\[\]'; then
299
- break
300
- fi
301
- IN_DELEGATE_BLOCK=1
302
- continue
303
- fi
304
- if [[ $IN_DELEGATE_BLOCK -eq 1 ]]; then
305
- # Block sequence items start with " - "
306
- if printf '%s' "$line" | grep -qE '^[[:space:]]*-[[:space:]]'; then
307
- pattern=$(printf '%s' "$line" | sed "s/^[[:space:]]*-[[:space:]]*//; s/^[\"']//; s/[\"']$//")
308
- if [[ -n "$pattern" ]]; then
309
- DELEGATE_PATTERNS+=("$pattern")
310
- fi
311
- else
312
- # Non-continuation line = end of block
313
- break
314
- fi
315
- fi
316
- done < "$POLICY_FILE"
317
-
318
- for pattern in "${DELEGATE_PATTERNS[@]+"${DELEGATE_PATTERNS[@]}"}"; do
319
- # Use fixed-string match — these are command prefixes, not regex
320
- if printf '%s' "$CMD" | grep -qF "$pattern"; then
321
- add_high \
322
- "Context protection — command must run in a subagent" \
323
- "This command produces excessive output that will exhaust the coordinator context window. Delegate it to a subagent instead of running it directly." \
324
- "Alt: Use the Agent tool to delegate: Agent(subagent_type: 'qa-engineer-automation', prompt: 'Run $pattern and report pass/fail summary only.')" \
325
- "Alt: The context_protection policy in .rea/policy.yaml lists commands that must be delegated."
326
- break
327
- fi
328
- done
329
- fi
289
+ #
290
+ # 0.16.0 fix J.2: replaced the inline YAML parser (40+ lines reimplementing
291
+ # block-sequence walking) with `policy_list` from `_lib/policy-read.sh`.
292
+ # Same parser shape as every other rea hook now reads policy via the shared
293
+ # helper; drift between hooks is structurally impossible.
294
+ # shellcheck source=_lib/policy-read.sh
295
+ source "$(dirname "$0")/_lib/policy-read.sh"
296
+
297
+ DELEGATE_PATTERNS=()
298
+ while IFS= read -r pattern; do
299
+ [[ -z "$pattern" ]] && continue
300
+ DELEGATE_PATTERNS+=("$pattern")
301
+ done < <(policy_list "delegate_to_subagent")
302
+
303
+ for pattern in "${DELEGATE_PATTERNS[@]+"${DELEGATE_PATTERNS[@]}"}"; do
304
+ # Use fixed-string match these are command prefixes, not regex.
305
+ if printf '%s' "$CMD" | grep -qF "$pattern"; then
306
+ add_high \
307
+ "Context protection — command must run in a subagent" \
308
+ "This command produces excessive output that will exhaust the coordinator context window. Delegate it to a subagent instead of running it directly." \
309
+ "Alt: Use the Agent tool to delegate: Agent(subagent_type: 'qa-engineer-automation', prompt: 'Run $pattern and report pass/fail summary only.')" \
310
+ "Alt: The context_protection policy in .rea/policy.yaml lists commands that must be delegated."
311
+ break
312
+ fi
313
+ done
330
314
 
331
315
  # ── 10. MEDIUM severity checks ────────────────────────────────────────────────
332
316
 
@@ -21,13 +21,11 @@ if ! command -v jq >/dev/null 2>&1; then
21
21
  fi
22
22
 
23
23
  # ── 3. HALT check ────────────────────────────────────────────────────────────
24
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
25
- HALT_FILE="${REA_ROOT}/.rea/HALT"
26
- if [ -f "$HALT_FILE" ]; then
27
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
28
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
29
- exit 2
30
- fi
24
+ # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
25
+ # shellcheck source=_lib/halt-check.sh
26
+ source "$(dirname "$0")/_lib/halt-check.sh"
27
+ check_halt
28
+ REA_ROOT=$(rea_root)
31
29
 
32
30
  # ── 4. Parse command ──────────────────────────────────────────────────────────
33
31
  CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
@@ -33,13 +33,11 @@ if ! command -v jq >/dev/null 2>&1; then
33
33
  fi
34
34
 
35
35
  # ── 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
  CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
45
43
 
@@ -52,19 +52,71 @@ if [[ -z "$CMD" ]]; then
52
52
  exit 0
53
53
  fi
54
54
 
55
- # Normalize a path token: strip enclosing quotes, strip leading
56
- # `$REA_ROOT/`, strip leading `./`. The result is project-relative
57
- # for matching against REA_PROTECTED_PATTERNS.
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
+
58
68
  _normalize_target() {
59
69
  local t="$1"
60
70
  # Strip matching surrounding quotes.
61
71
  if [[ "$t" =~ ^\"(.*)\"$ ]]; then t="${BASH_REMATCH[1]}"; fi
62
72
  if [[ "$t" =~ ^\'(.*)\'$ ]]; then t="${BASH_REMATCH[1]}"; fi
63
- # Strip $REA_ROOT prefix (with or without trailing slash).
64
- if [[ "$t" == "$REA_ROOT"/* ]]; then t="${t#"$REA_ROOT"/}"; fi
65
- # Strip leading ./
66
- while [[ "$t" == ./* ]]; do t="${t#./}"; done
67
- printf '%s' "$t"
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:]'
68
120
  }
69
121
 
70
122
  # Refuse and exit 2 with a uniform error message.
@@ -96,7 +148,15 @@ _check_segment() {
96
148
  # bash `[[ =~ ]]` regex literals with `|` and `(...)` parsed inline
97
149
  # confuse some bash versions on macOS. Use named variables for each
98
150
  # pattern so the literal stays in a string context only.
99
- local re_redirect='(^|[[:space:]])(&>|2>>|2>|>>|>)[[:space:]]*([^[:space:]&|;<>]+)'
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:]&|;<>]+)'
100
160
  local re_cpmv='(^|[[:space:]])(cp|mv)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
101
161
  local re_sed='(^|[[:space:]])sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
102
162
  local re_dd='(^|[[:space:]])dd[[:space:]]+[^&|;<>]*of=([^[:space:]&|;<>]+)'
@@ -175,11 +235,22 @@ _check_segment() {
175
235
  # walking — there may be more positional args.
176
236
  local _t
177
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
178
247
  if rea_path_is_protected "$_t"; then
179
248
  local matched=""
249
+ local pattern_lc
180
250
  for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
181
- if [[ "$_t" == "$pattern" ]]; then matched="$pattern"; break; fi
182
- if [[ "$pattern" == */ && "$_t" == "$pattern"* ]]; then matched="$pattern"; break; fi
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
183
254
  done
184
255
  _refuse "$matched" "$_t" "$segment"
185
256
  fi
@@ -196,12 +267,31 @@ _check_segment() {
196
267
 
197
268
  local target
198
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
199
286
  if rea_path_is_protected "$target"; then
200
- # Find the matching pattern for the error message.
201
- local matched=""
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
202
291
  for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
203
- if [[ "$target" == "$pattern" ]]; then matched="$pattern"; break; fi
204
- if [[ "$pattern" == */ && "$target" == "$pattern"* ]]; then matched="$pattern"; break; fi
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
205
295
  done
206
296
  _refuse "$matched" "$target" "$segment"
207
297
  fi
@@ -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)
@@ -335,6 +333,63 @@ if match_protected_ci; then
335
333
  exit 2
336
334
  fi
337
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
+
338
393
  # ── 6b. Hook-patch session (Defect I / rea#76) ───────────────────────────────
339
394
  # When REA_HOOK_PATCH_SESSION is set to a non-empty reason, allow edits under
340
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.15.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)",