@bookedsolid/rea 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/doctor.js +12 -11
- package/dist/cli/install/settings-merge.js +2 -2
- package/hooks/_lib/cmd-segments.sh +22 -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 +31 -47
- package/hooks/dependency-audit-gate.sh +5 -7
- 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
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,28 @@ _rea_split_segments() {
|
|
|
53
53
|
# GNU sed and BSD sed both honor `s/PATTERN/\n/g` with `-E` for ERE.
|
|
54
54
|
# We use printf+sed instead of bash IFS=$'...' read so the splitter
|
|
55
55
|
# behaves identically across BSD and GNU sed.
|
|
56
|
-
|
|
56
|
+
#
|
|
57
|
+
# 0.16.0 codex P1 fix (helix-015 #3): the prior sed split on bare `|`
|
|
58
|
+
# which broke bash's `>|` (noclobber-override redirect) into two
|
|
59
|
+
# segments — `printf x >` then ` .rea/HALT`. The redirect detector
|
|
60
|
+
# then never saw a complete `>|` operator and the bash-gate let the
|
|
61
|
+
# write through.
|
|
62
|
+
#
|
|
63
|
+
# 0.16.0 codex P2-1 fix: the placeholder must NOT collide with any
|
|
64
|
+
# legal byte the agent could supply. The earlier `\x01` (SOH) is a
|
|
65
|
+
# legal UTF-8 byte and rare-but-possible in commands; if a payload
|
|
66
|
+
# contained `\x01` literally, the third sed pass would manufacture
|
|
67
|
+
# a `>|` operator that wasn't in the original — corrupting downstream
|
|
68
|
+
# parsing in either fail-open or fail-closed directions depending on
|
|
69
|
+
# what came after. The new sentinel `__REA_GTPIPE_a8f2c1__` is
|
|
70
|
+
# multi-byte alphanumeric, impossible to collide with shell input
|
|
71
|
+
# under any encoding we care about (any agent that intentionally
|
|
72
|
+
# included this string would already be obviously trying to confuse
|
|
73
|
+
# the splitter — and even then, the worst case is fail-closed).
|
|
74
|
+
printf '%s\n' "$cmd" \
|
|
75
|
+
| sed -E 's/>\|/__REA_GTPIPE_a8f2c1__/g' \
|
|
76
|
+
| sed -E 's/(\|\||&&|;|\|)/\n/g' \
|
|
77
|
+
| sed -E 's/__REA_GTPIPE_a8f2c1__/>|/g'
|
|
57
78
|
}
|
|
58
79
|
|
|
59
80
|
# Strip leading whitespace and well-known command prefixes from a single
|
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)
|
|
@@ -285,48 +283,34 @@ if any_segment_matches "$CMD" '(alias|function)[[:space:]]+[a-zA-Z_]+.*(--(no-ve
|
|
|
285
283
|
"Alt: Do not wrap bypass patterns in aliases or functions."
|
|
286
284
|
fi
|
|
287
285
|
|
|
288
|
-
# H17: context_protection — block commands that should be delegated to subagents
|
|
286
|
+
# H17: context_protection — block commands that should be delegated to subagents.
|
|
289
287
|
# Reads context_protection.delegate_to_subagent from .rea/policy.yaml.
|
|
290
288
|
# These commands produce excessive output that exhausts coordinator context windows.
|
|
291
|
-
|
|
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
|
|
289
|
+
#
|
|
290
|
+
# 0.16.0 fix J.2: replaced the inline YAML parser (40+ lines reimplementing
|
|
291
|
+
# block-sequence walking) with `policy_list` from `_lib/policy-read.sh`.
|
|
292
|
+
# Same parser shape as every other rea hook now reads policy via the shared
|
|
293
|
+
# helper; drift between hooks is structurally impossible.
|
|
294
|
+
# shellcheck source=_lib/policy-read.sh
|
|
295
|
+
source "$(dirname "$0")/_lib/policy-read.sh"
|
|
296
|
+
|
|
297
|
+
DELEGATE_PATTERNS=()
|
|
298
|
+
while IFS= read -r pattern; do
|
|
299
|
+
[[ -z "$pattern" ]] && continue
|
|
300
|
+
DELEGATE_PATTERNS+=("$pattern")
|
|
301
|
+
done < <(policy_list "delegate_to_subagent")
|
|
302
|
+
|
|
303
|
+
for pattern in "${DELEGATE_PATTERNS[@]+"${DELEGATE_PATTERNS[@]}"}"; do
|
|
304
|
+
# Use fixed-string match — these are command prefixes, not regex.
|
|
305
|
+
if printf '%s' "$CMD" | grep -qF "$pattern"; then
|
|
306
|
+
add_high \
|
|
307
|
+
"Context protection — command must run in a subagent" \
|
|
308
|
+
"This command produces excessive output that will exhaust the coordinator context window. Delegate it to a subagent instead of running it directly." \
|
|
309
|
+
"Alt: Use the Agent tool to delegate: Agent(subagent_type: 'qa-engineer-automation', prompt: 'Run $pattern and report pass/fail summary only.')" \
|
|
310
|
+
"Alt: The context_protection policy in .rea/policy.yaml lists commands that must be delegated."
|
|
311
|
+
break
|
|
312
|
+
fi
|
|
313
|
+
done
|
|
330
314
|
|
|
331
315
|
# ── 10. MEDIUM severity checks ────────────────────────────────────────────────
|
|
332
316
|
|
|
@@ -21,13 +21,11 @@ if ! command -v jq >/dev/null 2>&1; then
|
|
|
21
21
|
fi
|
|
22
22
|
|
|
23
23
|
# ── 3. HALT check ────────────────────────────────────────────────────────────
|
|
24
|
-
|
|
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)
|
|
@@ -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.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)",
|