@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.
- package/agents/codex-adversarial.md +1 -1
- package/commands/codex-review.md +6 -11
- package/commands/freeze.md +1 -0
- package/commands/review.md +1 -0
- package/dist/cli/doctor.js +13 -6
- package/dist/cli/install/settings-merge.js +3 -2
- package/hooks/_lib/cmd-segments.sh +176 -0
- package/hooks/_lib/halt-check.sh +7 -1
- package/hooks/_lib/path-normalize.sh +72 -0
- package/hooks/_lib/payload-read.sh +66 -0
- package/hooks/_lib/policy-read.sh +4 -1
- package/hooks/_lib/protected-paths.sh +50 -0
- package/hooks/architecture-review-gate.sh +14 -11
- package/hooks/attribution-advisory.sh +21 -14
- package/hooks/blocked-paths-enforcer.sh +48 -17
- package/hooks/changeset-security-gate.sh +43 -14
- package/hooks/dangerous-bash-interceptor.sh +101 -93
- package/hooks/dependency-audit-gate.sh +59 -31
- package/hooks/env-file-protection.sh +19 -11
- package/hooks/protected-paths-bash-gate.sh +303 -0
- package/hooks/secret-scanner.sh +18 -47
- package/hooks/settings-protection.sh +111 -15
- package/package.json +4 -2
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
83
|
-
|
|
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
|
package/hooks/secret-scanner.sh
CHANGED
|
@@ -29,53 +29,24 @@ if ! command -v jq >/dev/null 2>&1; then
|
|
|
29
29
|
fi
|
|
30
30
|
|
|
31
31
|
# ── HALT check ────────────────────────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
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.
|
|
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",
|