@bookedsolid/rea 0.15.0 → 0.16.1
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 +2 -0
- package/dist/cli/doctor.js +12 -11
- package/dist/cli/install/settings-merge.js +2 -2
- package/hooks/_lib/cmd-segments.sh +54 -1
- 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 +16 -5
- package/hooks/architecture-review-gate.sh +14 -11
- package/hooks/attribution-advisory.sh +5 -7
- package/hooks/blocked-paths-enforcer.sh +48 -25
- package/hooks/changeset-security-gate.sh +14 -23
- package/hooks/dangerous-bash-interceptor.sh +43 -52
- package/hooks/dependency-audit-gate.sh +27 -9
- package/hooks/env-file-protection.sh +5 -7
- package/hooks/protected-paths-bash-gate.sh +105 -15
- package/hooks/secret-scanner.sh +18 -47
- package/hooks/settings-protection.sh +62 -7
- package/package.json +1 -1
|
@@ -33,6 +33,8 @@ You may read additional files in the repo if needed for context, but do so read-
|
|
|
33
33
|
2. **Validate Codex availability** — if `/codex` is not installed, report and stop. Do not silently fall back to another reviewer.
|
|
34
34
|
3. **Prepare the Codex invocation** — construct the adversarial-review prompt with the diff, commit log, and any relevant context files.
|
|
35
35
|
4. **Invoke `/codex:adversarial-review`** — this call flows through the REA middleware chain (audit → kill-switch → tier → policy → redact → injection → execute → result-size-cap).
|
|
36
|
+
|
|
37
|
+
**Model pinning (0.16.1+):** when the codex plugin's adversarial-review supports model overrides, request `gpt-5.4` with `model_reasoning_effort: high` to match the push-gate's iron-gate defaults. Pre-0.16.1, in-session adversarial reviews ran on whatever the plugin defaulted to (likely `codex-auto-review` at medium reasoning) — meaningfully WEAKER than the push-gate's `gpt-5.4` + `high`. This caused a "in-session review passes, push-gate review fails" pattern reported by helix across 014 / 015 / 016. If the plugin call accepts model parameters, pass them. If it does not, fall back to invoking `codex exec review --base <ref> --json --ephemeral -c model="gpt-5.4" -c model_reasoning_effort="high"` directly via `Bash` — same shape the push-gate uses (see `src/hooks/push-gate/codex-runner.ts::runCodexReview`). The cost of the stronger model is small relative to the cost of shipping a release with a P1 bypass that gets caught at consumer push time.
|
|
36
38
|
5. **Parse the Codex output** — extract structured findings.
|
|
37
39
|
6. **Classify findings** by category: security, correctness, edge cases, test gaps, API design, performance.
|
|
38
40
|
7. **Assign verdict**: `pass` (no material findings), `concerns` (findings worth addressing but not blocking), `blocking` (findings that must be fixed before merge).
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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.
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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,36 @@ _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
|
-
|
|
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
|
+
# 0.16.1 helix-016 P1 fix: also split on single `&` (background-process
|
|
75
|
+
# operator). Pre-fix the splitter only broke on `&&|||;|`; a command like
|
|
76
|
+
# `sleep 1 & git push --force` was treated as ONE segment whose first
|
|
77
|
+
# token is `sleep`, and `any_segment_starts_with($CMD, 'git push')`
|
|
78
|
+
# missed the force-push entirely. Add `&` to the separator set, but
|
|
79
|
+
# AFTER `&&` is already swapped out so we don't break it apart.
|
|
80
|
+
printf '%s\n' "$cmd" \
|
|
81
|
+
| sed -E 's/>\|/__REA_GTPIPE_a8f2c1__/g' \
|
|
82
|
+
| sed -E 's/&&/__REA_LOGAND_a8f2c1__/g' \
|
|
83
|
+
| sed -E 's/(\|\||;|\||&)/\n/g' \
|
|
84
|
+
| sed -E 's/__REA_LOGAND_a8f2c1__/\n/g' \
|
|
85
|
+
| sed -E 's/__REA_GTPIPE_a8f2c1__/>|/g'
|
|
57
86
|
}
|
|
58
87
|
|
|
59
88
|
# Strip leading whitespace and well-known command prefixes from a single
|
|
@@ -130,6 +159,30 @@ any_segment_matches() {
|
|
|
130
159
|
return 1
|
|
131
160
|
}
|
|
132
161
|
|
|
162
|
+
# Return 0 if any segment of $1 (RAW — no prefix-stripping) matches the
|
|
163
|
+
# extended regex $2. Use this for checks where the prefix itself IS the
|
|
164
|
+
# signal — e.g. H10's `HUSKY=0 git commit` detection (the prefix-stripper
|
|
165
|
+
# would strip the `HUSKY=0` before any_segment_matches sees it). Also
|
|
166
|
+
# right for H15 (`REA_BYPASS=...`) and H16 (alias/function defs).
|
|
167
|
+
#
|
|
168
|
+
# 0.16.1 helix-016 sibling fix: H10 baseline corpus regressed from
|
|
169
|
+
# 0.15.0 because it migrated to `any_segment_matches` which strips
|
|
170
|
+
# env-var prefixes. The check needs the raw segment to fire.
|
|
171
|
+
any_segment_raw_matches() {
|
|
172
|
+
local cmd="$1"
|
|
173
|
+
local pattern="$2"
|
|
174
|
+
local segment
|
|
175
|
+
while IFS= read -r segment; do
|
|
176
|
+
# Trim leading whitespace for clean anchor matching, but otherwise
|
|
177
|
+
# leave the segment intact (env-var assignments preserved).
|
|
178
|
+
segment="${segment#"${segment%%[![:space:]]*}"}"
|
|
179
|
+
if printf '%s' "$segment" | grep -qiE "$pattern"; then
|
|
180
|
+
return 0
|
|
181
|
+
fi
|
|
182
|
+
done < <(_rea_split_segments "$cmd")
|
|
183
|
+
return 1
|
|
184
|
+
}
|
|
185
|
+
|
|
133
186
|
# Return 0 if any segment of $1 (after prefix-stripping) STARTS WITH
|
|
134
187
|
# the extended regex $2. Case-insensitive. Returns 1 if no segment
|
|
135
188
|
# starts with the pattern.
|
package/hooks/_lib/halt-check.sh
CHANGED
|
@@ -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
|
|
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 -
|
|
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
|
|
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
|
|
29
|
-
|
|
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
|
-
|
|
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 [[ "$
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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.
|
|
30
|
-
#
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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)
|
|
@@ -218,7 +216,7 @@ if any_segment_starts_with "$CMD" 'git[[:space:]]+commit.*--no-verify'; then
|
|
|
218
216
|
fi
|
|
219
217
|
|
|
220
218
|
# H10: HUSKY=0 bypass — suppresses all git hooks without --no-verify
|
|
221
|
-
if
|
|
219
|
+
if any_segment_raw_matches "$CMD" '^HUSKY=0[[:space:]]+git[[:space:]]+(commit|push|tag)'; then
|
|
222
220
|
add_high \
|
|
223
221
|
"HUSKY=0 — bypasses all husky git hooks" \
|
|
224
222
|
"Setting HUSKY=0 disables pre-commit, commit-msg, and pre-push safety gates without --no-verify." \
|
|
@@ -245,8 +243,15 @@ if any_segment_starts_with "$CMD" "rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[[:space:]
|
|
|
245
243
|
"Alt: Move to a temp location first, or use 'rm -ri' for interactive deletion."
|
|
246
244
|
fi
|
|
247
245
|
|
|
248
|
-
# H12: curl/wget piped directly to shell (supply chain attack vector)
|
|
249
|
-
|
|
246
|
+
# H12: curl/wget piped directly to shell (supply chain attack vector).
|
|
247
|
+
# 0.16.1 helix-016 P1 fix: this check requires BOTH the curl/wget call
|
|
248
|
+
# AND the `| sh` to appear in the same shell pipeline. The 0.16.0
|
|
249
|
+
# refactor moved this into `any_segment_matches`, but the segmenter
|
|
250
|
+
# splits on `|` first — so `curl https://x | sh` decomposed into two
|
|
251
|
+
# segments (`curl https://x`, `sh`) and the regex (which requires both
|
|
252
|
+
# in one segment) never matched. Pipe-RCE is fundamentally a
|
|
253
|
+
# multi-segment property and must be checked against the raw command.
|
|
254
|
+
if printf '%s' "$CMD" | grep -qiE '(curl|wget)[^|]*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh|zsh|fish)'; then
|
|
250
255
|
add_high \
|
|
251
256
|
"curl/wget piped to shell — remote code execution" \
|
|
252
257
|
"Executing remote scripts without inspection is a major supply chain risk." \
|
|
@@ -270,7 +275,7 @@ if any_segment_starts_with "$CMD" 'git[[:space:]]+-c[[:space:]]+core\.hookspath'
|
|
|
270
275
|
fi
|
|
271
276
|
|
|
272
277
|
# H15: REA_BYPASS env var — attempted escape hatch
|
|
273
|
-
if
|
|
278
|
+
if any_segment_raw_matches "$CMD" '^REA_BYPASS[[:space:]]*='; then
|
|
274
279
|
add_high \
|
|
275
280
|
"REA_BYPASS env var — unauthorized bypass attempt" \
|
|
276
281
|
"Setting REA_BYPASS is not a supported escape mechanism and indicates a bypass attempt." \
|
|
@@ -278,55 +283,41 @@ if any_segment_matches "$CMD" '(^|[[:space:];]|&&|\|\|)REA_BYPASS[[:space:]]*=';
|
|
|
278
283
|
fi
|
|
279
284
|
|
|
280
285
|
# H16: alias/function definitions containing bypass strings
|
|
281
|
-
if
|
|
286
|
+
if any_segment_raw_matches "$CMD" '^(alias|function)[[:space:]]+[a-zA-Z_]+.*(--(no-verify|force)|HUSKY=0|core\.hookspath)'; then
|
|
282
287
|
add_high \
|
|
283
288
|
"Alias/function definition with bypass — circumventing safety gates" \
|
|
284
289
|
"Defining aliases or functions that embed bypass flags defeats safety hooks." \
|
|
285
290
|
"Alt: Do not wrap bypass patterns in aliases or functions."
|
|
286
291
|
fi
|
|
287
292
|
|
|
288
|
-
# H17: context_protection — block commands that should be delegated to subagents
|
|
293
|
+
# H17: context_protection — block commands that should be delegated to subagents.
|
|
289
294
|
# Reads context_protection.delegate_to_subagent from .rea/policy.yaml.
|
|
290
295
|
# These commands produce excessive output that exhausts coordinator context windows.
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
296
|
+
#
|
|
297
|
+
# 0.16.0 fix J.2: replaced the inline YAML parser (40+ lines reimplementing
|
|
298
|
+
# block-sequence walking) with `policy_list` from `_lib/policy-read.sh`.
|
|
299
|
+
# Same parser shape as every other rea hook now reads policy via the shared
|
|
300
|
+
# helper; drift between hooks is structurally impossible.
|
|
301
|
+
# shellcheck source=_lib/policy-read.sh
|
|
302
|
+
source "$(dirname "$0")/_lib/policy-read.sh"
|
|
303
|
+
|
|
304
|
+
DELEGATE_PATTERNS=()
|
|
305
|
+
while IFS= read -r pattern; do
|
|
306
|
+
[[ -z "$pattern" ]] && continue
|
|
307
|
+
DELEGATE_PATTERNS+=("$pattern")
|
|
308
|
+
done < <(policy_list "delegate_to_subagent")
|
|
309
|
+
|
|
310
|
+
for pattern in "${DELEGATE_PATTERNS[@]+"${DELEGATE_PATTERNS[@]}"}"; do
|
|
311
|
+
# Use fixed-string match — these are command prefixes, not regex.
|
|
312
|
+
if printf '%s' "$CMD" | grep -qF "$pattern"; then
|
|
313
|
+
add_high \
|
|
314
|
+
"Context protection — command must run in a subagent" \
|
|
315
|
+
"This command produces excessive output that will exhaust the coordinator context window. Delegate it to a subagent instead of running it directly." \
|
|
316
|
+
"Alt: Use the Agent tool to delegate: Agent(subagent_type: 'qa-engineer-automation', prompt: 'Run $pattern and report pass/fail summary only.')" \
|
|
317
|
+
"Alt: The context_protection policy in .rea/policy.yaml lists commands that must be delegated."
|
|
318
|
+
break
|
|
319
|
+
fi
|
|
320
|
+
done
|
|
330
321
|
|
|
331
322
|
# ── 10. MEDIUM severity checks ────────────────────────────────────────────────
|
|
332
323
|
|
|
@@ -21,13 +21,11 @@ if ! command -v jq >/dev/null 2>&1; then
|
|
|
21
21
|
fi
|
|
22
22
|
|
|
23
23
|
# ── 3. HALT check ────────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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)
|
|
@@ -75,15 +73,35 @@ extract_packages() {
|
|
|
75
73
|
# Anchor to start: only match when the install command is the FIRST
|
|
76
74
|
# thing on the segment, optionally preceded by `sudo` / `exec` /
|
|
77
75
|
# `time` / etc.
|
|
78
|
-
|
|
76
|
+
#
|
|
77
|
+
# 0.16.1 helix-016 P2 fix: also strip leading KEY=VALUE env-var
|
|
78
|
+
# assignments. Pre-fix the prefix allow-list only permitted
|
|
79
|
+
# sudo/exec/time, so `CI=1 pnpm add foo` and
|
|
80
|
+
# `NODE_ENV=development npm install bar` bypassed the audit
|
|
81
|
+
# entirely. POSIX shell allows any number of leading KEY=VALUE
|
|
82
|
+
# assignments before the command word; we strip them the same
|
|
83
|
+
# way the shell does.
|
|
84
|
+
local stripped_segment
|
|
85
|
+
stripped_segment=$(printf '%s' "$segment" | sed -E 's/^([[:space:]]*[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+)+//')
|
|
86
|
+
|
|
87
|
+
if printf '%s' "$stripped_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
88
|
# Strip the leading prefix wrappers + install command, leaving args.
|
|
80
89
|
local after_cmd
|
|
81
|
-
after_cmd=$(printf '%s' "$
|
|
90
|
+
after_cmd=$(printf '%s' "$stripped_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
91
|
|
|
83
92
|
for token in $after_cmd; do
|
|
84
93
|
if [[ "$token" == -* ]]; then continue; fi
|
|
85
94
|
if [[ "$token" == ./* || "$token" == /* || "$token" == ../* ]]; then continue; fi
|
|
86
95
|
if [[ -z "$token" ]]; then continue; fi
|
|
96
|
+
# 0.16.1: tighten token classification (helix-016 sibling concern).
|
|
97
|
+
# A "package name" is something that doesn't contain shell
|
|
98
|
+
# metacharacters — `2>&1`, `$VAR`, etc. are never valid npm
|
|
99
|
+
# package names. Skip any token containing `=`, `>`, `<`, `&`,
|
|
100
|
+
# `|`, `;`, `$`, backtick, or quotes.
|
|
101
|
+
if [[ "$token" == *=* || "$token" == *">"* || "$token" == *"<"* ||
|
|
102
|
+
"$token" == *"&"* || "$token" == *"|"* || "$token" == *";"* ||
|
|
103
|
+
"$token" == *'$'* || "$token" == *'`'* ||
|
|
104
|
+
"$token" == *'"'* || "$token" == *"'"* ]]; then continue; fi
|
|
87
105
|
# `npm view` can't validate `@workspace:*` / `link:` / `file:`
|
|
88
106
|
# prefixes (workspace protocols). Skip them — they're never npm
|
|
89
107
|
# registry packages.
|
|
@@ -33,13 +33,11 @@ if ! command -v jq >/dev/null 2>&1; then
|
|
|
33
33
|
fi
|
|
34
34
|
|
|
35
35
|
# ── 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
|
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
|
|
56
|
-
#
|
|
57
|
-
#
|
|
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
|
-
#
|
|
64
|
-
|
|
65
|
-
#
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
if [[ "$
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
if [[ "$
|
|
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
|
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)
|
|
@@ -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.
|
|
3
|
+
"version": "0.16.1",
|
|
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)",
|