@bookedsolid/rea 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/codex-adversarial.md +1 -1
- package/commands/codex-review.md +6 -11
- package/commands/freeze.md +1 -0
- package/commands/review.md +1 -0
- package/dist/cli/doctor.js +13 -6
- package/dist/cli/install/settings-merge.js +3 -2
- package/hooks/_lib/cmd-segments.sh +176 -0
- package/hooks/_lib/halt-check.sh +7 -1
- package/hooks/_lib/path-normalize.sh +72 -0
- package/hooks/_lib/payload-read.sh +66 -0
- package/hooks/_lib/policy-read.sh +4 -1
- package/hooks/_lib/protected-paths.sh +50 -0
- package/hooks/architecture-review-gate.sh +14 -11
- package/hooks/attribution-advisory.sh +21 -14
- package/hooks/blocked-paths-enforcer.sh +48 -17
- package/hooks/changeset-security-gate.sh +43 -14
- package/hooks/dangerous-bash-interceptor.sh +101 -93
- package/hooks/dependency-audit-gate.sh +59 -31
- package/hooks/env-file-protection.sh +19 -11
- package/hooks/protected-paths-bash-gate.sh +303 -0
- package/hooks/secret-scanner.sh +18 -47
- package/hooks/settings-protection.sh +111 -15
- package/package.json +4 -2
|
@@ -36,7 +36,7 @@ You may read additional files in the repo if needed for context, but do so read-
|
|
|
36
36
|
5. **Parse the Codex output** — extract structured findings.
|
|
37
37
|
6. **Classify findings** by category: security, correctness, edge cases, test gaps, API design, performance.
|
|
38
38
|
7. **Assign verdict**: `pass` (no material findings), `concerns` (findings worth addressing but not blocking), `blocking` (findings that must be fixed before merge).
|
|
39
|
-
8. **Emit an audit entry**
|
|
39
|
+
8. **Emit an audit entry — REQUIRED** for every `/codex-review` invocation. The pre-push gate does not consult audit records to decide pass/fail (post-0.11.0 the gate is stateless), but the `/codex-review` slash command's Step 3 verifies an audit entry was appended for this run and surfaces "review never happened" to the user when one is missing. The two specs are a contract pair — audit emission is what tells the operator their interactive review actually completed. Append via the public `@bookedsolid/rea/audit` helper:
|
|
40
40
|
|
|
41
41
|
```ts
|
|
42
42
|
import { appendAuditRecord, CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, Tier, InvocationStatus } from '@bookedsolid/rea/audit';
|
package/commands/codex-review.md
CHANGED
|
@@ -7,6 +7,7 @@ allowed-tools:
|
|
|
7
7
|
- Bash(git branch:*)
|
|
8
8
|
- Bash(git rev-parse:*)
|
|
9
9
|
- Read
|
|
10
|
+
- Agent
|
|
10
11
|
---
|
|
11
12
|
|
|
12
13
|
# /codex-review — Adversarial Review via Codex
|
|
@@ -54,23 +55,17 @@ Invoke the `codex-adversarial` agent with:
|
|
|
54
55
|
|
|
55
56
|
The agent wraps `/codex:adversarial-review` and returns structured findings.
|
|
56
57
|
|
|
57
|
-
## Step 3 —
|
|
58
|
+
## Step 3 — (Optional) verify audit entry
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
Audit emission is **optional** in 0.11.0+. The pre-push gate is stateless and does not consult audit records to decide pass/fail; the agent's structured findings ARE the review. The agent will append an audit entry when it helps forensic traceability (intermittent verdicts, review-history audits) but its absence is not a failure.
|
|
61
|
+
|
|
62
|
+
If you want to confirm an entry was written for this run:
|
|
60
63
|
|
|
61
64
|
```bash
|
|
62
65
|
tail -n 1 .rea/audit.jsonl
|
|
63
66
|
```
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
- `tool: "codex-adversarial-review"`
|
|
68
|
-
- `head_sha: <SHA>`
|
|
69
|
-
- `target: <ref>`
|
|
70
|
-
- `finding_count: <N>`
|
|
71
|
-
- `verdict: pass | concerns | blocking`
|
|
72
|
-
|
|
73
|
-
If the audit entry is missing, report it clearly — do not proceed as if the review happened.
|
|
68
|
+
A `codex-adversarial-review` entry with `head_sha`, `target`, `finding_count`, and `verdict` fields is informative — but DO NOT treat its absence as a failure. The review happened if the agent returned text. (Pre-0.15.0 this step was a hard verification gate that contradicted the agent's "audit optional" contract — see Helix Finding 3, 2026-05-03.)
|
|
74
69
|
|
|
75
70
|
## Step 4 — Report
|
|
76
71
|
|
package/commands/freeze.md
CHANGED
package/commands/review.md
CHANGED
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',
|
|
206
|
+
label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit|NotebookEdit',
|
|
207
207
|
status: 'fail',
|
|
208
208
|
detail: `missing: ${settingsPath}`,
|
|
209
209
|
};
|
|
@@ -216,23 +216,30 @@ function checkSettingsJson(baseDir) {
|
|
|
216
216
|
const needs = [];
|
|
217
217
|
if (!matchers.has('Bash'))
|
|
218
218
|
needs.push('Bash');
|
|
219
|
-
|
|
220
|
-
|
|
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');
|
|
227
|
+
}
|
|
221
228
|
if (needs.length === 0) {
|
|
222
229
|
return {
|
|
223
|
-
label: 'settings.json matchers cover Bash + Write|Edit',
|
|
230
|
+
label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit|NotebookEdit',
|
|
224
231
|
status: 'pass',
|
|
225
232
|
};
|
|
226
233
|
}
|
|
227
234
|
return {
|
|
228
|
-
label: 'settings.json matchers cover Bash + Write|Edit',
|
|
235
|
+
label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit|NotebookEdit',
|
|
229
236
|
status: 'fail',
|
|
230
237
|
detail: `missing PreToolUse matchers: ${needs.join(', ')}`,
|
|
231
238
|
};
|
|
232
239
|
}
|
|
233
240
|
catch (e) {
|
|
234
241
|
return {
|
|
235
|
-
label: 'settings.json matchers cover Bash + Write|Edit',
|
|
242
|
+
label: 'settings.json matchers cover Bash + Write|Edit|MultiEdit|NotebookEdit',
|
|
236
243
|
status: 'fail',
|
|
237
244
|
detail: e instanceof Error ? e.message : String(e),
|
|
238
245
|
};
|
|
@@ -259,6 +259,7 @@ export function defaultDesiredHooks() {
|
|
|
259
259
|
hooks: [
|
|
260
260
|
{ type: 'command', command: `${base}/dangerous-bash-interceptor.sh`, timeout: 10000, statusMessage: 'Checking command safety...' },
|
|
261
261
|
{ type: 'command', command: `${base}/env-file-protection.sh`, timeout: 5000, statusMessage: 'Checking for .env file reads...' },
|
|
262
|
+
{ type: 'command', command: `${base}/protected-paths-bash-gate.sh`, timeout: 5000, statusMessage: 'Checking for shell-redirect to protected paths...' },
|
|
262
263
|
{ type: 'command', command: `${base}/dependency-audit-gate.sh`, timeout: 15000, statusMessage: 'Verifying package exists...' },
|
|
263
264
|
{ type: 'command', command: `${base}/security-disclosure-gate.sh`, timeout: 5000, statusMessage: 'Checking disclosure policy...' },
|
|
264
265
|
{ type: 'command', command: `${base}/pr-issue-link-gate.sh`, timeout: 5000, statusMessage: 'Checking PR for issue reference...' },
|
|
@@ -267,7 +268,7 @@ export function defaultDesiredHooks() {
|
|
|
267
268
|
},
|
|
268
269
|
{
|
|
269
270
|
event: 'PreToolUse',
|
|
270
|
-
matcher: 'Write|Edit',
|
|
271
|
+
matcher: 'Write|Edit|MultiEdit|NotebookEdit',
|
|
271
272
|
hooks: [
|
|
272
273
|
{ type: 'command', command: `${base}/secret-scanner.sh`, timeout: 15000, statusMessage: 'Scanning for credentials...' },
|
|
273
274
|
{ type: 'command', command: `${base}/settings-protection.sh`, timeout: 5000, statusMessage: 'Checking settings protection...' },
|
|
@@ -277,7 +278,7 @@ export function defaultDesiredHooks() {
|
|
|
277
278
|
},
|
|
278
279
|
{
|
|
279
280
|
event: 'PostToolUse',
|
|
280
|
-
matcher: 'Write|Edit',
|
|
281
|
+
matcher: 'Write|Edit|MultiEdit|NotebookEdit',
|
|
281
282
|
hooks: [
|
|
282
283
|
{ type: 'command', command: `${base}/architecture-review-gate.sh`, timeout: 10000, statusMessage: 'Checking architecture impact...' },
|
|
283
284
|
],
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# shellcheck shell=bash
|
|
2
|
+
# hooks/_lib/cmd-segments.sh — shell-segment splitting for Bash-tier hooks.
|
|
3
|
+
#
|
|
4
|
+
# Background: hooks that gate `Bash` tool calls grep `.tool_input.command`
|
|
5
|
+
# for danger words (`rm -rf`, `git restore`, `pnpm install`, etc.). Pre-
|
|
6
|
+
# 0.15.0 every hook ran a single `grep -qE PATTERN "$cmd"` against the
|
|
7
|
+
# whole command string. That false-positives on heredoc bodies and
|
|
8
|
+
# commit messages where the trigger word appears inside content rather
|
|
9
|
+
# than as a command:
|
|
10
|
+
#
|
|
11
|
+
# git commit -m "$(cat <<'EOF'
|
|
12
|
+
# docs: explain why we don't run rm -rf node_modules in CI
|
|
13
|
+
# EOF
|
|
14
|
+
# )"
|
|
15
|
+
#
|
|
16
|
+
# The unanchored regex matches `rm -rf` inside the heredoc body and the
|
|
17
|
+
# hook blocks a perfectly safe commit. Hit during the 2026-05-03 session
|
|
18
|
+
# repeatedly — the pattern that motivated dependency-audit-gate's 0.15.0
|
|
19
|
+
# segment-split fix.
|
|
20
|
+
#
|
|
21
|
+
# This helper exposes two primitives every Bash-tier hook should use:
|
|
22
|
+
#
|
|
23
|
+
# for_each_segment "$CMD" CALLBACK
|
|
24
|
+
# Splits $CMD on shell command separators (`;`, `&&`, `||`, `|`,
|
|
25
|
+
# newlines) and invokes CALLBACK with each segment as $1, plus the
|
|
26
|
+
# leading-prefix-stripped form as $2 (with `sudo`/`exec`/`time`/
|
|
27
|
+
# `then`/`do`/env-var-assignment prefixes removed). Returns 0 if
|
|
28
|
+
# CALLBACK returned 0 for every segment, or the first non-zero
|
|
29
|
+
# CALLBACK exit otherwise.
|
|
30
|
+
#
|
|
31
|
+
# any_segment_matches "$CMD" PATTERN
|
|
32
|
+
# Iterates segments and returns 0 if any segment's prefix-stripped
|
|
33
|
+
# form matches PATTERN (a `grep -qiE` extended regex). Returns 1
|
|
34
|
+
# if no segment matches.
|
|
35
|
+
#
|
|
36
|
+
# Quoting awareness: the splitter is NOT quote-aware. A separator inside
|
|
37
|
+
# a quoted string would be split. This is INTENTIONAL and SAFE: the
|
|
38
|
+
# segments-vs-callback contract is "find segments that anchor on a
|
|
39
|
+
# trigger word." Over-splitting produces extra segments that don't
|
|
40
|
+
# anchor; they're ignored. Under-splitting (treating a quoted separator
|
|
41
|
+
# as part of one segment) is what the original bug was. The trade-off
|
|
42
|
+
# explicitly accepts over-splitting.
|
|
43
|
+
#
|
|
44
|
+
# Quoting note for future maintainers: do not "fix" the over-splitting
|
|
45
|
+
# without breaking the security property. Quote-aware splitting in pure
|
|
46
|
+
# bash is a real lift; if needed it should move to a Node helper.
|
|
47
|
+
|
|
48
|
+
# Split $1 on shell command separators. Emits one segment per line on
|
|
49
|
+
# stdout (empty segments preserved). Used by both higher-level helpers
|
|
50
|
+
# below; not generally called by hooks directly.
|
|
51
|
+
_rea_split_segments() {
|
|
52
|
+
local cmd="$1"
|
|
53
|
+
# GNU sed and BSD sed both honor `s/PATTERN/\n/g` with `-E` for ERE.
|
|
54
|
+
# We use printf+sed instead of bash IFS=$'...' read so the splitter
|
|
55
|
+
# behaves identically across BSD and GNU sed.
|
|
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'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Strip leading whitespace and well-known command prefixes from a single
|
|
81
|
+
# segment. Returns the prefix-stripped form on stdout. Examples:
|
|
82
|
+
# " sudo pnpm install foo" → "pnpm install foo"
|
|
83
|
+
# "NODE_ENV=production pnpm add x" → "pnpm add x"
|
|
84
|
+
# "then pnpm add lodash" → "pnpm add lodash"
|
|
85
|
+
_rea_strip_prefix() {
|
|
86
|
+
local seg="$1"
|
|
87
|
+
# Trim leading whitespace.
|
|
88
|
+
seg="${seg#"${seg%%[![:space:]]*}"}"
|
|
89
|
+
# Strip ONE prefix at a time, looping. This handles compounds like
|
|
90
|
+
# `sudo NODE_ENV=production pnpm add foo`.
|
|
91
|
+
while :; do
|
|
92
|
+
case "$seg" in
|
|
93
|
+
sudo[[:space:]]*|exec[[:space:]]*|time[[:space:]]*|then[[:space:]]*|do[[:space:]]*|else[[:space:]]*)
|
|
94
|
+
# Drop the prefix word and any subsequent whitespace.
|
|
95
|
+
seg="${seg#* }"
|
|
96
|
+
seg="${seg#"${seg%%[![:space:]]*}"}"
|
|
97
|
+
;;
|
|
98
|
+
*)
|
|
99
|
+
# Env-var assignment prefix (`KEY=value `) — only strip if the
|
|
100
|
+
# token before the first space looks like NAME=value.
|
|
101
|
+
if [[ "$seg" =~ ^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+ ]]; then
|
|
102
|
+
seg="${seg#* }"
|
|
103
|
+
seg="${seg#"${seg%%[![:space:]]*}"}"
|
|
104
|
+
else
|
|
105
|
+
break
|
|
106
|
+
fi
|
|
107
|
+
;;
|
|
108
|
+
esac
|
|
109
|
+
done
|
|
110
|
+
printf '%s' "$seg"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Iterate every segment of $1 and invoke $2 (a function name) with the
|
|
114
|
+
# raw segment as $1 and the prefix-stripped form as $2. The callback's
|
|
115
|
+
# return value is honored: a non-zero return aborts the iteration and
|
|
116
|
+
# becomes the helper's return value.
|
|
117
|
+
for_each_segment() {
|
|
118
|
+
local cmd="$1"
|
|
119
|
+
local callback="$2"
|
|
120
|
+
local segment stripped rc
|
|
121
|
+
while IFS= read -r segment; do
|
|
122
|
+
stripped=$(_rea_strip_prefix "$segment")
|
|
123
|
+
"$callback" "$segment" "$stripped"
|
|
124
|
+
rc=$?
|
|
125
|
+
if [ "$rc" -ne 0 ]; then
|
|
126
|
+
return "$rc"
|
|
127
|
+
fi
|
|
128
|
+
done < <(_rea_split_segments "$cmd")
|
|
129
|
+
return 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Return 0 if any segment of $1 (after prefix-stripping) matches the
|
|
133
|
+
# extended regex $2 ANYWHERE (not anchored). Case-insensitive. Returns 1
|
|
134
|
+
# if no segment matches.
|
|
135
|
+
#
|
|
136
|
+
# Use this for patterns that may legitimately appear mid-segment, e.g.
|
|
137
|
+
# `Co-Authored-By:` in a commit message body. For "is the segment a
|
|
138
|
+
# call to <command>" use `any_segment_starts_with` instead — that
|
|
139
|
+
# anchors on the start so `echo "rm -rf foo"` doesn't trip an
|
|
140
|
+
# `rm -rf` detector.
|
|
141
|
+
any_segment_matches() {
|
|
142
|
+
local cmd="$1"
|
|
143
|
+
local pattern="$2"
|
|
144
|
+
local segment stripped
|
|
145
|
+
while IFS= read -r segment; do
|
|
146
|
+
stripped=$(_rea_strip_prefix "$segment")
|
|
147
|
+
if printf '%s' "$stripped" | grep -qiE "$pattern"; then
|
|
148
|
+
return 0
|
|
149
|
+
fi
|
|
150
|
+
done < <(_rea_split_segments "$cmd")
|
|
151
|
+
return 1
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Return 0 if any segment of $1 (after prefix-stripping) STARTS WITH
|
|
155
|
+
# the extended regex $2. Case-insensitive. Returns 1 if no segment
|
|
156
|
+
# starts with the pattern.
|
|
157
|
+
#
|
|
158
|
+
# This is the right shape for "is this segment a call to <command>"
|
|
159
|
+
# checks. `echo "rm -rf foo"` does NOT trigger an `rm -rf` detector
|
|
160
|
+
# because the segment starts with `echo`, not `rm`. Compare to
|
|
161
|
+
# `any_segment_matches`, which matches anywhere in the segment and
|
|
162
|
+
# would fire on the echo'd argument.
|
|
163
|
+
any_segment_starts_with() {
|
|
164
|
+
local cmd="$1"
|
|
165
|
+
local pattern="$2"
|
|
166
|
+
local segment stripped
|
|
167
|
+
while IFS= read -r segment; do
|
|
168
|
+
stripped=$(_rea_strip_prefix "$segment")
|
|
169
|
+
# `^` anchor + caller pattern. `(?:)` non-capturing group not
|
|
170
|
+
# supported in BSD ERE; we use a simple literal `^` prepend.
|
|
171
|
+
if printf '%s' "$stripped" | grep -qiE "^${pattern}"; then
|
|
172
|
+
return 0
|
|
173
|
+
fi
|
|
174
|
+
done < <(_rea_split_segments "$cmd")
|
|
175
|
+
return 1
|
|
176
|
+
}
|
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
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# shellcheck shell=bash
|
|
2
|
+
# hooks/_lib/protected-paths.sh — single source of truth for the
|
|
3
|
+
# hard-protected path list shared between the Write/Edit tier
|
|
4
|
+
# (`settings-protection.sh`) and the Bash tier (`protected-paths-bash-gate.sh`).
|
|
5
|
+
#
|
|
6
|
+
# Pre-0.15.0 this list was duplicated inline in settings-protection.sh;
|
|
7
|
+
# the bash-redirect bypass (`> .rea/HALT`, `tee .rea/policy.yaml`,
|
|
8
|
+
# `cp X .claude/settings.json`, `sed -i .husky/pre-push`) was caught
|
|
9
|
+
# by the principal-engineer audit. The fix: factor the list out so
|
|
10
|
+
# both hooks read the same data, and protect against shell redirects
|
|
11
|
+
# in addition to Write/Edit/MultiEdit tools.
|
|
12
|
+
|
|
13
|
+
# The path list is bash glob patterns matched against project-root-
|
|
14
|
+
# relative paths. Suffix `/` indicates a prefix match; no suffix means
|
|
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.
|
|
18
|
+
REA_PROTECTED_PATTERNS=(
|
|
19
|
+
'.claude/settings.json'
|
|
20
|
+
'.claude/settings.local.json'
|
|
21
|
+
'.husky/'
|
|
22
|
+
'.rea/policy.yaml'
|
|
23
|
+
'.rea/HALT'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Test whether a project-relative path matches any protected pattern.
|
|
27
|
+
# Usage: if rea_path_is_protected ".rea/HALT"; then echo "blocked"; fi
|
|
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.
|
|
36
|
+
rea_path_is_protected() {
|
|
37
|
+
local p_lc
|
|
38
|
+
p_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
|
|
39
|
+
local pattern pattern_lc
|
|
40
|
+
for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
|
|
41
|
+
pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
|
|
42
|
+
if [[ "$p_lc" == "$pattern_lc" ]]; then
|
|
43
|
+
return 0
|
|
44
|
+
fi
|
|
45
|
+
if [[ "$pattern_lc" == */ ]] && [[ "$p_lc" == "$pattern_lc"* ]]; then
|
|
46
|
+
return 0
|
|
47
|
+
fi
|
|
48
|
+
done
|
|
49
|
+
return 1
|
|
50
|
+
}
|
|
@@ -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"
|
|
@@ -50,14 +48,23 @@ if [[ -z "$CMD" ]]; then
|
|
|
50
48
|
exit 0
|
|
51
49
|
fi
|
|
52
50
|
|
|
51
|
+
# 0.15.0: source the shared shell-segment splitter. Pre-fix, the
|
|
52
|
+
# attribution patterns greped the FULL command — `git commit -m "Note:
|
|
53
|
+
# Co-Authored-By with AI was removed in 0.14"` matched and the commit
|
|
54
|
+
# was blocked even though the message was COMMENTING on attribution
|
|
55
|
+
# rather than including it. Per-segment anchoring scopes detection to
|
|
56
|
+
# segments whose first token is `git commit` / `gh pr create|edit`.
|
|
57
|
+
# shellcheck source=_lib/cmd-segments.sh
|
|
58
|
+
source "$(dirname "$0")/_lib/cmd-segments.sh"
|
|
59
|
+
|
|
53
60
|
# ── 6. Check if this is a relevant command ────────────────────────────────────
|
|
54
61
|
IS_RELEVANT=0
|
|
55
62
|
|
|
56
|
-
if
|
|
63
|
+
if any_segment_matches "$CMD" 'gh[[:space:]]+pr[[:space:]]+(create|edit)'; then
|
|
57
64
|
IS_RELEVANT=1
|
|
58
65
|
fi
|
|
59
66
|
|
|
60
|
-
if
|
|
67
|
+
if any_segment_matches "$CMD" 'git[[:space:]]+commit'; then
|
|
61
68
|
IS_RELEVANT=1
|
|
62
69
|
fi
|
|
63
70
|
|
|
@@ -70,27 +77,27 @@ fi
|
|
|
70
77
|
FOUND=0
|
|
71
78
|
|
|
72
79
|
# Co-Authored-By with noreply@ email
|
|
73
|
-
if
|
|
80
|
+
if any_segment_matches "$CMD" 'Co-Authored-By:.*noreply@'; then
|
|
74
81
|
FOUND=1
|
|
75
82
|
fi
|
|
76
83
|
|
|
77
84
|
# Co-Authored-By with known AI names
|
|
78
|
-
if
|
|
85
|
+
if any_segment_matches "$CMD" 'Co-Authored-By:.*\b(Claude|Sonnet|Opus|Haiku|Copilot|GPT|ChatGPT|Gemini|Cursor|Codeium|Tabnine|Amazon Q|CodeWhisperer|Devin|Windsurf|Cline|Aider|Anthropic|OpenAI|GitHub Copilot)\b'; then
|
|
79
86
|
FOUND=1
|
|
80
87
|
fi
|
|
81
88
|
|
|
82
89
|
# "Generated/Built/Powered with/by [AI Tool]" lines
|
|
83
|
-
if
|
|
90
|
+
if any_segment_matches "$CMD" '(Generated|Created|Built|Powered|Authored|Written|Produced)[[:space:]]+(with|by)[[:space:]]+(Claude|Copilot|GPT|ChatGPT|Gemini|Cursor|Codeium|Tabnine|CodeWhisperer|Devin|Windsurf|Cline|Aider|AI|an? AI)\b'; then
|
|
84
91
|
FOUND=1
|
|
85
92
|
fi
|
|
86
93
|
|
|
87
94
|
# Markdown-linked attribution
|
|
88
|
-
if
|
|
95
|
+
if any_segment_matches "$CMD" '\[Claude Code\]|\[GitHub Copilot\]|\[ChatGPT\]|\[Gemini\]|\[Cursor\]'; then
|
|
89
96
|
FOUND=1
|
|
90
97
|
fi
|
|
91
98
|
|
|
92
99
|
# Emoji attribution
|
|
93
|
-
if
|
|
100
|
+
if any_segment_matches "$CMD" '🤖.*[Gg]enerated'; then
|
|
94
101
|
FOUND=1
|
|
95
102
|
fi
|
|
96
103
|
|