@bookedsolid/rea 0.13.3 → 0.15.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.
@@ -43,30 +43,60 @@ fi
43
43
  extract_packages() {
44
44
  local cmd="$1"
45
45
 
46
- # npm install/add with packages (skip flags and local paths)
47
- if printf '%s' "$cmd" | grep -qiE '(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install)|yarn[[:space:]]+add)[[:space:]]'; then
48
- # Extract the part after the install command
49
- local after_cmd
50
- after_cmd=$(printf '%s' "$cmd" | sed -E 's/.*(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install)|yarn[[:space:]]+add)[[:space:]]+//')
51
-
52
- # Split on spaces and filter
53
- for token in $after_cmd; do
54
- # Skip flags
55
- if [[ "$token" == -* ]]; then continue; fi
56
- # Skip local paths
57
- if [[ "$token" == ./* || "$token" == /* || "$token" == ../* ]]; then continue; fi
58
- # Skip empty
59
- if [[ -z "$token" ]]; then continue; fi
60
- # Strip version specifier for lookup
61
- local pkg_name
62
- pkg_name=$(printf '%s' "$token" | sed -E 's/@[^@/]+$//')
63
- # Handle scoped packages (@scope/name)
64
- if [[ -z "$pkg_name" ]]; then
65
- pkg_name="$token"
66
- fi
67
- printf '%s\n' "$pkg_name"
68
- done
69
- fi
46
+ # 0.15.0 fix: the previous parser ran `grep` against the entire bash
47
+ # command string with no segment boundary anchor. A heredoc body or
48
+ # commit-message containing `pnpm install` (e.g. inside
49
+ # `git commit -m "$(cat <<EOF ... pnpm install ... EOF)"`) matched the
50
+ # grep, the `.*` in the sed stripped up to that occurrence, and the rest
51
+ # of the command (`chore:`, `&&`, `||`, etc.) was passed to
52
+ # `npm view <token> name` and reported as missing packages. The hook
53
+ # then refused to commit perfectly innocent code.
54
+ #
55
+ # Fix: split the command on shell command separators (`;`, `&&`, `||`,
56
+ # `|`, newlines) and only run the install-detection on segments whose
57
+ # FIRST non-whitespace token is one of the install commands. Heredoc
58
+ # bodies inside `$()` substitutions are NOT split into separate segments
59
+ # the entire `$(cat <<EOF ... EOF)` is one token attached to the
60
+ # outer command but they're never the FIRST token on a segment, so
61
+ # the anchor rejects them.
62
+
63
+ # Tokenize on shell separators. Each `IFS=` entry becomes a separate
64
+ # segment we can anchor against. We use bash's `mapfile` with a sed
65
+ # to inject newlines at separators; awk-based splitting handles the
66
+ # quoting heuristic well enough for the realistic cases (agent-issued
67
+ # commands rarely have separators inside single-quoted strings that
68
+ # would confuse this).
69
+ local segments
70
+ segments=$(printf '%s\n' "$cmd" | sed -E 's/(\|\||\&\&|;|\|)/\n/g')
71
+
72
+ while IFS= read -r segment; do
73
+ # Trim leading whitespace.
74
+ segment="${segment#"${segment%%[![:space:]]*}"}"
75
+ # Anchor to start: only match when the install command is the FIRST
76
+ # thing on the segment, optionally preceded by `sudo` / `exec` /
77
+ # `time` / etc.
78
+ if printf '%s' "$segment" | grep -qiE '^(sudo[[:space:]]+|exec[[:space:]]+|time[[:space:]]+)*(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]+'; then
79
+ # Strip the leading prefix wrappers + install command, leaving args.
80
+ local after_cmd
81
+ after_cmd=$(printf '%s' "$segment" | sed -E 's/^(sudo[[:space:]]+|exec[[:space:]]+|time[[:space:]]+)*(npm[[:space:]]+(install|i|add)|pnpm[[:space:]]+(add|install|i)|yarn[[:space:]]+add)[[:space:]]+//')
82
+
83
+ for token in $after_cmd; do
84
+ if [[ "$token" == -* ]]; then continue; fi
85
+ if [[ "$token" == ./* || "$token" == /* || "$token" == ../* ]]; then continue; fi
86
+ if [[ -z "$token" ]]; then continue; fi
87
+ # `npm view` can't validate `@workspace:*` / `link:` / `file:`
88
+ # prefixes (workspace protocols). Skip them — they're never npm
89
+ # registry packages.
90
+ if [[ "$token" == workspace:* || "$token" == link:* || "$token" == file:* || "$token" == git+* ]]; then continue; fi
91
+ local pkg_name
92
+ pkg_name=$(printf '%s' "$token" | sed -E 's/@[^@/]+$//')
93
+ if [[ -z "$pkg_name" ]]; then
94
+ pkg_name="$token"
95
+ fi
96
+ printf '%s\n' "$pkg_name"
97
+ done
98
+ fi
99
+ done <<< "$segments"
70
100
  }
71
101
 
72
102
  PACKAGES=$(extract_packages "$CMD")
@@ -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 ──────────────────────────────────────────────────────────
@@ -70,17 +76,21 @@ PATTERN_ENV_FILE='(\.env[a-zA-Z0-9._-]*|\.envrc)([[:space:]]|"|'"'"'|$)'
70
76
  MATCHES_UTILITY=0
71
77
  MATCHES_ENV_FILE=0
72
78
 
73
- if printf '%s' "$CMD" | grep -qE "$PATTERN_UTILITY"; then
79
+ # 0.15.0: per-segment match. Pre-fix this greped the FULL command which
80
+ # false-positived on commit messages: `git commit -m "stop reading .env
81
+ # files via cat"` matched both PATTERN_UTILITY (cat) and PATTERN_ENV_FILE
82
+ # (.env) and the hook blocked a perfectly safe commit.
83
+ if any_segment_matches "$CMD" "$PATTERN_UTILITY"; then
74
84
  MATCHES_UTILITY=1
75
85
  fi
76
86
 
77
- if printf '%s' "$CMD" | grep -qE "$PATTERN_ENV_FILE"; then
87
+ if any_segment_matches "$CMD" "$PATTERN_ENV_FILE"; then
78
88
  MATCHES_ENV_FILE=1
79
89
  fi
80
90
 
81
91
  # 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
92
+ if any_segment_matches "$CMD" "$PATTERN_SOURCE" || \
93
+ any_segment_matches "$CMD" "$PATTERN_CP_ENV"; then
84
94
  TRUNCATED_CMD=$(truncate_cmd "$CMD")
85
95
  {
86
96
  printf 'ENV FILE PROTECTION: Direct sourcing or copying of .env files is blocked.\n'
@@ -0,0 +1,213 @@
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: strip enclosing quotes, strip leading
56
+ # `$REA_ROOT/`, strip leading `./`. The result is project-relative
57
+ # for matching against REA_PROTECTED_PATTERNS.
58
+ _normalize_target() {
59
+ local t="$1"
60
+ # Strip matching surrounding quotes.
61
+ if [[ "$t" =~ ^\"(.*)\"$ ]]; then t="${BASH_REMATCH[1]}"; fi
62
+ 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"
68
+ }
69
+
70
+ # Refuse and exit 2 with a uniform error message.
71
+ _refuse() {
72
+ local pattern="$1" target="$2" segment="$3"
73
+ {
74
+ printf 'PROTECTED PATH (bash): write to a package-managed file blocked\n'
75
+ printf '\n'
76
+ printf ' Pattern matched: %s\n' "$pattern"
77
+ printf ' Resolved target: %s\n' "$target"
78
+ printf ' Segment: %s\n' "$segment"
79
+ printf '\n'
80
+ printf ' Rule: protected paths (kill-switch, policy.yaml, settings.json,\n'
81
+ printf ' .husky/*) are unreachable via Bash redirects too — not just\n'
82
+ printf ' Write/Edit/MultiEdit. To modify, a human must edit directly.\n'
83
+ } >&2
84
+ exit 2
85
+ }
86
+
87
+ # Inspect one segment for redirect / write patterns and refuse if the
88
+ # target matches any protected pattern.
89
+ _check_segment() {
90
+ local _raw="$1" segment="$2"
91
+ [[ -z "$segment" ]] && return 0
92
+
93
+ local target_token=""
94
+ local detected_form=""
95
+
96
+ # bash `[[ =~ ]]` regex literals with `|` and `(...)` parsed inline
97
+ # confuse some bash versions on macOS. Use named variables for each
98
+ # pattern so the literal stays in a string context only.
99
+ local re_redirect='(^|[[:space:]])(&>|2>>|2>|>>|>)[[:space:]]*([^[:space:]&|;<>]+)'
100
+ local re_cpmv='(^|[[:space:]])(cp|mv)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
101
+ local re_sed='(^|[[:space:]])sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
102
+ local re_dd='(^|[[:space:]])dd[[:space:]]+[^&|;<>]*of=([^[:space:]&|;<>]+)'
103
+ # 0.15.0 codex P1 fix: replaced the bash-3.2-broken `(...)*` pattern
104
+ # for tee/truncate flag-skipping with a token-walk approach that
105
+ # works across BSD bash 3.2 and GNU bash 4+. Walks every token after
106
+ # the command, skips flags (single-dash short, double-dash long with
107
+ # optional =value), returns the first non-flag token as the target.
108
+
109
+ if [[ "$segment" =~ $re_redirect ]]; then
110
+ target_token="${BASH_REMATCH[3]}"
111
+ detected_form="redirect ${BASH_REMATCH[2]}"
112
+ elif [[ "$segment" =~ $re_cpmv ]]; then
113
+ target_token="${BASH_REMATCH[3]}"
114
+ detected_form="${BASH_REMATCH[2]}"
115
+ elif [[ "$segment" =~ $re_sed ]]; then
116
+ target_token="${BASH_REMATCH[3]}"
117
+ detected_form="sed -i"
118
+ elif [[ "$segment" =~ $re_dd ]]; then
119
+ target_token="${BASH_REMATCH[2]}"
120
+ detected_form="dd of="
121
+ else
122
+ # tee / truncate / install / ln — token-walk for cross-bash safety.
123
+ # Read tokens, find the command, then return the first non-flag arg.
124
+ local prev_word="" found_cmd=""
125
+ local _seg_for_walk="$segment"
126
+ # Strip leading whitespace.
127
+ _seg_for_walk="${_seg_for_walk#"${_seg_for_walk%%[![:space:]]*}"}"
128
+ # shellcheck disable=SC2086
129
+ set -- $_seg_for_walk
130
+ while [ "$#" -gt 0 ]; do
131
+ local tok="$1"
132
+ shift
133
+ if [[ -z "$found_cmd" ]]; then
134
+ case "$tok" in
135
+ tee|truncate|install|ln)
136
+ found_cmd="$tok"
137
+ ;;
138
+ esac
139
+ prev_word="$tok"
140
+ continue
141
+ fi
142
+ # We're inside the command's argv. Skip flags.
143
+ case "$tok" in
144
+ --) continue ;;
145
+ --*=*) continue ;;
146
+ --*)
147
+ # Long flag — may take a value as the NEXT token (we don't
148
+ # know which long options take values). For safety, skip
149
+ # only known no-value long flags; otherwise consume the
150
+ # next token too if it looks like a value.
151
+ case "$tok" in
152
+ --append|--ignore-interrupts|--no-clobber|--force|--no-target-directory|--symbolic|--no-dereference|--reference=*) continue ;;
153
+ *) shift 2>/dev/null || true; continue ;;
154
+ esac
155
+ ;;
156
+ -*)
157
+ # Short flag cluster. Skip. truncate -s SIZE — `-s` is a flag,
158
+ # SIZE is its arg. We're conservative: skip the next token if
159
+ # the flag cluster's last char is one of the size-bearing
160
+ # flags (truncate -s, install -m, ln -t).
161
+ case "$tok" in
162
+ -s*|-m*|-o*|-g*|-t*) shift 2>/dev/null || true ;;
163
+ esac
164
+ continue
165
+ ;;
166
+ *)
167
+ # First non-flag token — this is the target (or, for cp/mv-
168
+ # like commands, the first source; the cpmv detector above
169
+ # handles those separately). We treat ALL non-flag args as
170
+ # potential targets and check each — that catches
171
+ # `tee a b c` where any of a/b/c could be a protected file.
172
+ target_token="$tok"
173
+ detected_form="$found_cmd"
174
+ # Check this token immediately; if not protected, keep
175
+ # walking — there may be more positional args.
176
+ local _t
177
+ _t=$(_normalize_target "$target_token")
178
+ if rea_path_is_protected "$_t"; then
179
+ local matched=""
180
+ 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
183
+ done
184
+ _refuse "$matched" "$_t" "$segment"
185
+ fi
186
+ # Reset target_token so the post-loop check doesn't double-check.
187
+ target_token=""
188
+ ;;
189
+ esac
190
+ done
191
+ fi
192
+
193
+ if [[ -z "$target_token" ]]; then
194
+ return 0
195
+ fi
196
+
197
+ local target
198
+ target=$(_normalize_target "$target_token")
199
+ if rea_path_is_protected "$target"; then
200
+ # Find the matching pattern for the error message.
201
+ local matched=""
202
+ 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
205
+ done
206
+ _refuse "$matched" "$target" "$segment"
207
+ fi
208
+ return 0
209
+ }
210
+
211
+ for_each_segment "$CMD" _check_segment
212
+
213
+ exit 0
@@ -40,11 +40,41 @@ fi
40
40
  FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
41
41
  CONTENT_WRITE=$(printf '%s' "$INPUT" | jq -r '.tool_input.content // empty' 2>/dev/null)
42
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)
43
71
 
44
72
  if [[ -n "$CONTENT_WRITE" ]]; then
45
73
  CONTENT="$CONTENT_WRITE"
46
74
  elif [[ -n "$CONTENT_EDIT" ]]; then
47
75
  CONTENT="$CONTENT_EDIT"
76
+ elif [[ -n "$CONTENT_MULTIEDIT" ]]; then
77
+ CONTENT="$CONTENT_MULTIEDIT"
48
78
  else
49
79
  exit 0
50
80
  fi
@@ -157,14 +157,27 @@ LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
157
157
  # package-managed body — §5a kills it before this matcher runs.
158
158
  #
159
159
  # 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.
160
+ # refused both final-component AND intermediate-directory symlinks.
161
+ # A fragment is a short shell script authored in place; consumers do
162
+ # not need symlinks here. Without these checks the gate has two
163
+ # bypass shapes:
164
+ #
165
+ # (a) Final-component symlink:
166
+ # ln -s ../pre-push .husky/pre-push.d/00-evil; write 00-evil
167
+ # caught by `[ -L "$FILE_PATH" ]`.
168
+ #
169
+ # (b) Intermediate-directory symlink (helix Finding 2 / 0.15.0):
170
+ # mkdir .husky/pre-push.d; ln -s ../ .husky/pre-push.d/linkdir
171
+ # write .husky/pre-push.d/linkdir/pre-push
172
+ # — `[ -L $FILE_PATH ]` only inspects the FINAL component, so a
173
+ # not-yet-existing target whose parent contains a symlink resolves
174
+ # to outside the surface (here: `.husky/pre-push`), letting the
175
+ # attacker write through to the package-managed body.
176
+ #
177
+ # Resolve the realpath of the parent directory and require it to live
178
+ # under the literal extension surface. Use a portable `cd ... && pwd -P`
179
+ # subshell pattern (no Python or readlink -f dependency required).
180
+ # Closes the path-string→symlink bypass completely.
168
181
  case "$LOWER_NORM" in
169
182
  .husky/commit-msg.d/*|.husky/pre-push.d/*)
170
183
  if [ -L "$FILE_PATH" ]; then
@@ -178,6 +191,34 @@ case "$LOWER_NORM" in
178
191
  } >&2
179
192
  exit 2
180
193
  fi
194
+ # Resolve the parent directory's realpath. If any intermediate
195
+ # component is a symlink whose target leaves the surface, the
196
+ # resolved path no longer contains `/.husky/<surface>.d/` and we
197
+ # refuse. The parent dir must already exist for this check; if it
198
+ # doesn't, the write is creating the parent, in which case there
199
+ # is no intermediate symlink to follow yet.
200
+ parent_dir=$(dirname -- "$FILE_PATH")
201
+ if [ -d "$parent_dir" ]; then
202
+ resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
203
+ if [ -n "$resolved_parent" ]; then
204
+ case "$resolved_parent" in
205
+ *"/.husky/commit-msg.d"*|*"/.husky/pre-push.d"*) : ;;
206
+ *)
207
+ {
208
+ printf 'SETTINGS PROTECTION: extension path resolves outside surface\n'
209
+ printf '\n'
210
+ printf ' Logical: %s\n' "$SAFE_FILE_PATH"
211
+ printf ' Resolved: %s\n' "$resolved_parent"
212
+ printf ' Rule: an intermediate directory of the extension path is a\n'
213
+ printf ' symlink whose target leaves .husky/{commit-msg,pre-push}.d/.\n'
214
+ printf ' Refused to prevent symlinked-parent bypass of the\n'
215
+ printf ' package-managed body protection.\n'
216
+ } >&2
217
+ exit 2
218
+ ;;
219
+ esac
220
+ fi
221
+ fi
181
222
  # Documented extension surface — agents can write here freely.
182
223
  exit 0
183
224
  ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.13.3",
3
+ "version": "0.15.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",