@bookedsolid/rea 0.34.0 → 0.36.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 +45 -36
- package/dist/cli/hook.js +28 -0
- package/dist/hooks/_lib/path-normalize.d.ts +81 -0
- package/dist/hooks/_lib/path-normalize.js +171 -0
- package/dist/hooks/_lib/payload.js +1 -1
- package/dist/hooks/_lib/protected-paths.d.ts +0 -0
- package/dist/hooks/_lib/protected-paths.js +232 -0
- package/dist/hooks/_lib/segments.js +67 -7
- package/dist/hooks/blocked-paths-bash-gate/index.d.ts +55 -0
- package/dist/hooks/blocked-paths-bash-gate/index.js +175 -0
- package/dist/hooks/blocked-paths-enforcer/index.d.ts +51 -0
- package/dist/hooks/blocked-paths-enforcer/index.js +287 -0
- package/dist/hooks/protected-paths-bash-gate/index.d.ts +47 -0
- package/dist/hooks/protected-paths-bash-gate/index.js +168 -0
- package/dist/hooks/secret-scanner/index.js +64 -2
- package/dist/hooks/settings-protection/index.d.ts +74 -0
- package/dist/hooks/settings-protection/index.js +485 -0
- package/hooks/blocked-paths-bash-gate.sh +118 -116
- package/hooks/blocked-paths-enforcer.sh +152 -256
- package/hooks/protected-paths-bash-gate.sh +123 -210
- package/hooks/settings-protection.sh +171 -549
- package/package.json +3 -2
- package/scripts/lint-awk-shim-quotes.mjs +386 -0
- package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
- package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
- package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
- package/templates/settings-protection.dogfood-staged.sh +204 -0
|
@@ -1,582 +1,204 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: settings-protection.sh
|
|
3
|
-
#
|
|
4
|
-
# Blocks modifications to critical configuration files that, if tampered with,
|
|
5
|
-
# would disable the entire hook safety layer.
|
|
3
|
+
# 0.35.0+ — Node-binary shim for `rea hook settings-protection`.
|
|
6
4
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
5
|
+
# Pre-0.35.0 this was the LARGEST hook in the repo at 582 LOC of bash:
|
|
6
|
+
# §5a `..` traversal reject, §5a-bis interior `/./` reject, §5b
|
|
7
|
+
# extension-surface allow-list (with final-component + intermediate-
|
|
8
|
+
# directory symlink refusal), §6 hard-protected pattern resolution
|
|
9
|
+
# (PROTECTED_PATTERNS sourced from `_lib/protected-paths.sh` with
|
|
10
|
+
# `protected_writes` override + `protected_paths_relax` subtractor),
|
|
11
|
+
# §6c intermediate-symlink resolution against the hard-protected list,
|
|
12
|
+
# §6b REA_HOOK_PATCH_SESSION unlock for .claude/hooks/ with hash-
|
|
13
|
+
# chained audit append (fail-closed). The full bash body is preserved
|
|
14
|
+
# at `__tests__/hooks/parity/baselines/settings-protection.sh.pre-0.35.0`.
|
|
14
15
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
16
|
+
# The migration moves every section into
|
|
17
|
+
# `src/hooks/settings-protection/index.ts`. This shim is the Claude Code
|
|
18
|
+
# dispatcher's view of the hook — it forwards stdin to the CLI and
|
|
19
|
+
# exits with whatever the CLI returns.
|
|
18
20
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
set -uo pipefail
|
|
24
|
-
|
|
25
|
-
# ── 1. Read ALL stdin immediately ─────────────────────────────────────────────
|
|
26
|
-
INPUT=$(cat)
|
|
27
|
-
|
|
28
|
-
# ── 2. Dependency check ──────────────────────────────────────────────────────
|
|
29
|
-
if ! command -v jq >/dev/null 2>&1; then
|
|
30
|
-
printf 'REA ERROR: jq is required but not installed.\n' >&2
|
|
31
|
-
printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
|
|
32
|
-
exit 2
|
|
33
|
-
fi
|
|
34
|
-
|
|
35
|
-
# ── 3. HALT check ────────────────────────────────────────────────────────────
|
|
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)
|
|
41
|
-
|
|
42
|
-
# ── 4. Extract file path from payload ─────────────────────────────────────────
|
|
43
|
-
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
44
|
-
|
|
45
|
-
if [[ -z "$FILE_PATH" ]]; then
|
|
46
|
-
exit 0
|
|
47
|
-
fi
|
|
48
|
-
|
|
49
|
-
# ── 5. Normalize path for comparison ──────────────────────────────────────────
|
|
50
|
-
# Convert to relative path from project root for consistent matching
|
|
51
|
-
normalize_path() {
|
|
52
|
-
local p="$1"
|
|
53
|
-
local root="$REA_ROOT"
|
|
54
|
-
|
|
55
|
-
# Strip project root prefix if present
|
|
56
|
-
if [[ "$p" == "$root"/* ]]; then
|
|
57
|
-
p="${p#$root/}"
|
|
58
|
-
fi
|
|
59
|
-
|
|
60
|
-
# URL decode common sequences. Include %5C (`\`) so Windows-style or
|
|
61
|
-
# percent-encoded back-slash traversal (`..%5C`, `\..\`) normalizes to the
|
|
62
|
-
# forward-slash form the §5a detector sees.
|
|
63
|
-
p=$(printf '%s' "$p" \
|
|
64
|
-
| sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g; s/%5[Cc]/\\/g')
|
|
65
|
-
|
|
66
|
-
# Translate any backslash separators to forward slashes. Keeps the traversal
|
|
67
|
-
# check in §5a working for `.claude\hooks\..\settings.json`-style inputs.
|
|
68
|
-
p=$(printf '%s' "$p" | tr '\\\\' '/')
|
|
69
|
-
|
|
70
|
-
# Strip leading ./ components only. We intentionally do NOT strip interior
|
|
71
|
-
# ./ sequences — that transformation corrupts `..` traversals (e.g. `.../`
|
|
72
|
-
# collapsed to `../`, or `../` collapsed to `./`) and hides traversal from
|
|
73
|
-
# the §5a detector.
|
|
74
|
-
while [[ "$p" == ./* ]]; do
|
|
75
|
-
p="${p#./}"
|
|
76
|
-
done
|
|
77
|
-
|
|
78
|
-
printf '%s' "$p"
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
# Strip C0/C1 control characters from a string to prevent terminal escape
|
|
82
|
-
# injection when we echo protected paths back to the operator. Escape sequences
|
|
83
|
-
# in file names could otherwise rewrite lines above the deny message.
|
|
84
|
-
#
|
|
85
|
-
# Byte ranges stripped:
|
|
86
|
-
# \000-\037 — C0 controls (BEL, BS, HT, LF, CR, ESC, …)
|
|
87
|
-
# \177 — DEL
|
|
88
|
-
# \200-\237 — C1 controls (CSI 0x9B, OSC 0x9D, …). Many terminals still
|
|
89
|
-
# interpret these as single-byte CSI introducers; without
|
|
90
|
-
# stripping, a UTF-8 file name whose bytes fall in this range
|
|
91
|
-
# could still drive the cursor on older emulators.
|
|
92
|
-
sanitize_for_stderr() {
|
|
93
|
-
printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177\200-\237'
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
NORMALIZED=$(normalize_path "$FILE_PATH")
|
|
97
|
-
SAFE_FILE_PATH=$(sanitize_for_stderr "$FILE_PATH")
|
|
98
|
-
SAFE_NORMALIZED=$(sanitize_for_stderr "$NORMALIZED")
|
|
99
|
-
|
|
100
|
-
# ── 5a. Reject path traversal segments (Codex HIGH: Defect I bypass) ─────────
|
|
101
|
-
# A path containing `..` segments can be used to bypass the protected-path
|
|
102
|
-
# globs in §6 — e.g. `.claude/hooks/../settings.json` would pass the
|
|
103
|
-
# `.claude/hooks/*` case-glob in the patch-session allowlist but actually
|
|
104
|
-
# refers to `.claude/settings.json`. We refuse any path that contains a `..`
|
|
105
|
-
# segment in either the raw input OR the normalized form. The request must
|
|
106
|
-
# be reissued with a canonical path.
|
|
107
|
-
#
|
|
108
|
-
# For the raw-input check, translate backslashes first so a Windows-style
|
|
109
|
-
# `.claude\hooks\..\settings.json` is rejected at the raw stage too (the
|
|
110
|
-
# normalized form also catches it — this is defense in depth).
|
|
111
|
-
RAW_PATH_SLASHED=$(printf '%s' "$FILE_PATH" | tr '\\\\' '/')
|
|
112
|
-
raw_has_traversal=0
|
|
113
|
-
case "/$RAW_PATH_SLASHED/" in
|
|
114
|
-
*/../*) raw_has_traversal=1 ;;
|
|
115
|
-
esac
|
|
116
|
-
norm_has_traversal=0
|
|
117
|
-
case "/$NORMALIZED/" in
|
|
118
|
-
*/../*) norm_has_traversal=1 ;;
|
|
119
|
-
esac
|
|
120
|
-
if [[ "$raw_has_traversal" -eq 1 ]] || [[ "$norm_has_traversal" -eq 1 ]]; then
|
|
121
|
-
{
|
|
122
|
-
printf 'SETTINGS PROTECTION: path traversal rejected\n'
|
|
123
|
-
printf '\n'
|
|
124
|
-
printf ' File: %s\n' "$SAFE_FILE_PATH"
|
|
125
|
-
printf " Rule: path contains a '..' segment; rewrite to a canonical\n"
|
|
126
|
-
printf ' project-relative path without traversal.\n'
|
|
127
|
-
} >&2
|
|
128
|
-
exit 2
|
|
129
|
-
fi
|
|
130
|
-
|
|
131
|
-
# ── 5a-bis. Reject interior single-dot segments (0.29.0 helix-/./-class) ─────
|
|
132
|
-
# Companion to the `..` guard above. The `normalize_path` helper deliberately
|
|
133
|
-
# does NOT collapse interior `./` segments because doing so would corrupt
|
|
134
|
-
# `..` traversals — but that leaves a parallel bypass class. A path like
|
|
135
|
-
# `.husky/./pre-push` resolves on disk to `.husky/pre-push`, yet the literal/
|
|
136
|
-
# prefix matchers in §6 compare against the un-collapsed `.husky/./pre-push`
|
|
137
|
-
# string and miss the match.
|
|
21
|
+
# Behavioral contract is preserved byte-for-byte: exit 0 on allow,
|
|
22
|
+
# exit 2 on HALT / traversal-reject / interior-dot-reject / protected
|
|
23
|
+
# match / patch-session-mismatch / malformed payload.
|
|
138
24
|
#
|
|
139
|
-
#
|
|
140
|
-
# segment exactly like a `..` segment — refuse outright, force the caller
|
|
141
|
-
# to send a canonical path. The corpus design pairs shell-scripting-specialist
|
|
142
|
-
# with adversarial-test-specialist; the canonical attack shapes are:
|
|
25
|
+
# # CLI-resolution trust boundary
|
|
143
26
|
#
|
|
144
|
-
#
|
|
145
|
-
# .husky/././pre-push — repeated segments
|
|
146
|
-
# .husky/.//pre-push — `./` immediately followed by another `/`
|
|
147
|
-
# .claude/hooks/./_lib/halt-check.sh — inside a protected directory
|
|
148
|
-
# %2E%2F — percent-encoded `./`, caught after URL-decode
|
|
149
|
-
# .\.\pre-push — backslash variant, normalize_path → `./`
|
|
27
|
+
# Mirrors the 0.32.0 final shim shape.
|
|
150
28
|
#
|
|
151
|
-
#
|
|
152
|
-
# at start-of-string is a legitimate relative path; `normalize_path` already
|
|
153
|
-
# strips leading `./` segments, so anything that survives into the normalized
|
|
154
|
-
# form's `/./` shape is INTERIOR by construction.
|
|
155
|
-
norm_has_dot_segment=0
|
|
156
|
-
case "/$NORMALIZED/" in
|
|
157
|
-
*/./*) norm_has_dot_segment=1 ;;
|
|
158
|
-
esac
|
|
159
|
-
if [[ "$norm_has_dot_segment" -eq 1 ]]; then
|
|
160
|
-
{
|
|
161
|
-
printf 'SETTINGS PROTECTION: interior dot-segment rejected\n'
|
|
162
|
-
printf '\n'
|
|
163
|
-
printf ' File: %s\n' "$SAFE_FILE_PATH"
|
|
164
|
-
printf " Rule: path contains an interior '/./' segment; rewrite to a\n"
|
|
165
|
-
printf ' canonical project-relative path without dot segments.\n'
|
|
166
|
-
} >&2
|
|
167
|
-
exit 2
|
|
168
|
-
fi
|
|
169
|
-
|
|
170
|
-
# Compute lower-cased path early so the §5b allow-list (and §6/§6b matchers
|
|
171
|
-
# below) all reference a single normalized variable.
|
|
172
|
-
LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
|
|
173
|
-
|
|
174
|
-
# ── 5b. Extension-surface allow-list ──────────────────────────────────────────
|
|
175
|
-
# `.husky/commit-msg.d/*`, `.husky/pre-push.d/*`, and (0.32.0+)
|
|
176
|
-
# `.husky/prepare-commit-msg.d/*` are the documented consumer
|
|
177
|
-
# extension surface (Fix H / 0.13.0; Phase 3 / 0.32.0 for the
|
|
178
|
-
# prepare-commit-msg lane). Consumers — and the agents that govern
|
|
179
|
-
# those consumers — are expected to write here freely so they can
|
|
180
|
-
# layer commitlint, lint-staged, branch-policy, act-CI, etc. without
|
|
181
|
-
# losing rea coverage on `rea upgrade`.
|
|
182
|
-
#
|
|
183
|
-
# The §6 PROTECTED_PATTERNS list below has `.husky/` as a prefix block,
|
|
184
|
-
# which (correctly) keeps `.husky/pre-push`, `.husky/commit-msg`, and
|
|
185
|
-
# the `.husky/_/*` runtime stubs out of agent reach. But the same prefix
|
|
186
|
-
# also caught `.husky/pre-push.d/00-act-ci` and `.husky/commit-msg.d/*`
|
|
187
|
-
# until 0.13.2 — the very directories advertised as the extension
|
|
188
|
-
# surface. This early allow-list closes that contract gap.
|
|
29
|
+
# # Fail-closed posture
|
|
189
30
|
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
#
|
|
31
|
+
# settings-protection is THE gate protecting the entire governance layer
|
|
32
|
+
# from agent self-disable. Pre-0.35.0 the bash body enforced refusal
|
|
33
|
+
# without any compiled CLI; the Node-binary port preserves that — early-
|
|
34
|
+
# exit branches fail closed AFTER the relevance pre-gate passes.
|
|
193
35
|
#
|
|
194
|
-
#
|
|
195
|
-
# `.husky/pre-push.d/../pre-push` cannot bypass §6's protection of the
|
|
196
|
-
# package-managed body — §5a kills it before this matcher runs.
|
|
36
|
+
# # Relevance pre-gate
|
|
197
37
|
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
#
|
|
38
|
+
# Substring scan over the extracted file_path / notebook_path for the
|
|
39
|
+
# protected-path markers (.claude/, .husky/, .rea/policy.yaml, .rea/HALT,
|
|
40
|
+
# the verdict cache paths, plus any policy.blocked_paths entry). When
|
|
41
|
+
# CLI is missing AND none of these substrings appear in the payload's
|
|
42
|
+
# file path, exit 0. The pre-0.35.0 bash body would have allowed.
|
|
203
43
|
#
|
|
204
|
-
#
|
|
205
|
-
# ln -s ../pre-push .husky/pre-push.d/00-evil; write 00-evil
|
|
206
|
-
# — caught by `[ -L "$FILE_PATH" ]`.
|
|
207
|
-
#
|
|
208
|
-
# (b) Intermediate-directory symlink (helix Finding 2 / 0.15.0):
|
|
209
|
-
# mkdir .husky/pre-push.d; ln -s ../ .husky/pre-push.d/linkdir
|
|
210
|
-
# write .husky/pre-push.d/linkdir/pre-push
|
|
211
|
-
# — `[ -L $FILE_PATH ]` only inspects the FINAL component, so a
|
|
212
|
-
# not-yet-existing target whose parent contains a symlink resolves
|
|
213
|
-
# to outside the surface (here: `.husky/pre-push`), letting the
|
|
214
|
-
# attacker write through to the package-managed body.
|
|
215
|
-
#
|
|
216
|
-
# Resolve the realpath of the parent directory and require it to live
|
|
217
|
-
# under the literal extension surface. Use a portable `cd ... && pwd -P`
|
|
218
|
-
# subshell pattern (no Python or readlink -f dependency required).
|
|
219
|
-
# Closes the path-string→symlink bypass completely.
|
|
220
|
-
case "$LOWER_NORM" in
|
|
221
|
-
.husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/prepare-commit-msg.d/*)
|
|
222
|
-
if [ -L "$FILE_PATH" ]; then
|
|
223
|
-
{
|
|
224
|
-
printf 'SETTINGS PROTECTION: symlink in extension surface refused\n'
|
|
225
|
-
printf '\n'
|
|
226
|
-
printf ' File: %s\n' "$SAFE_FILE_PATH"
|
|
227
|
-
printf ' Rule: .husky/{commit-msg,pre-push,prepare-commit-msg}.d/* must\n'
|
|
228
|
-
printf ' be regular files (a symlink could resolve to a protected\n'
|
|
229
|
-
printf ' package-managed body and bypass §6 protection).\n'
|
|
230
|
-
} >&2
|
|
231
|
-
exit 2
|
|
232
|
-
fi
|
|
233
|
-
# Resolve the parent directory's realpath. If any intermediate
|
|
234
|
-
# component is a symlink whose target leaves the surface, the
|
|
235
|
-
# resolved path no longer contains `/.husky/<surface>.d/` and we
|
|
236
|
-
# refuse. The parent dir must already exist for this check; if it
|
|
237
|
-
# doesn't, the write is creating the parent, in which case there
|
|
238
|
-
# is no intermediate symlink to follow yet.
|
|
239
|
-
parent_dir=$(dirname -- "$FILE_PATH")
|
|
240
|
-
if [ -d "$parent_dir" ]; then
|
|
241
|
-
resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
|
|
242
|
-
if [ -n "$resolved_parent" ]; then
|
|
243
|
-
# 0.20.1 helix-021 #3: directory-boundary on the case glob.
|
|
244
|
-
# Pre-fix `*"/.husky/commit-msg.d"*` matched `.husky/commit-msg.d.bak/`
|
|
245
|
-
# too (substring without trailing-slash anchor). A symlink
|
|
246
|
-
# `.husky/pre-push.d/linkdir -> ../pre-push.d.bak` then resolved
|
|
247
|
-
# to `.husky/pre-push.d.bak/...` and slipped through.
|
|
248
|
-
# The trailing `/` on each pattern (and the explicit
|
|
249
|
-
# exact-match arm) requires a real directory boundary.
|
|
250
|
-
# 0.32.0 Phase 3: `.husky/prepare-commit-msg.d/` joins the
|
|
251
|
-
# allow-list (mirrors commit-msg.d/pre-push.d patterns).
|
|
252
|
-
case "$resolved_parent" in
|
|
253
|
-
*/.husky/commit-msg.d|*/.husky/commit-msg.d/*|*/.husky/pre-push.d|*/.husky/pre-push.d/*|*/.husky/prepare-commit-msg.d|*/.husky/prepare-commit-msg.d/*) : ;;
|
|
254
|
-
*)
|
|
255
|
-
{
|
|
256
|
-
printf 'SETTINGS PROTECTION: extension path resolves outside surface\n'
|
|
257
|
-
printf '\n'
|
|
258
|
-
printf ' Logical: %s\n' "$SAFE_FILE_PATH"
|
|
259
|
-
printf ' Resolved: %s\n' "$resolved_parent"
|
|
260
|
-
printf ' Rule: an intermediate directory of the extension path is a\n'
|
|
261
|
-
printf ' symlink whose target leaves .husky/{commit-msg,pre-push,prepare-commit-msg}.d/.\n'
|
|
262
|
-
printf ' Refused to prevent symlinked-parent bypass of the\n'
|
|
263
|
-
printf ' package-managed body protection.\n'
|
|
264
|
-
} >&2
|
|
265
|
-
exit 2
|
|
266
|
-
;;
|
|
267
|
-
esac
|
|
268
|
-
fi
|
|
269
|
-
fi
|
|
270
|
-
# Documented extension surface — agents can write here freely.
|
|
271
|
-
exit 0
|
|
272
|
-
;;
|
|
273
|
-
esac
|
|
274
|
-
|
|
275
|
-
# ── 6. Protected path patterns ────────────────────────────────────────────────
|
|
276
|
-
# §6 runs BEFORE the patch-session allowlist so hook-patch sessions cannot
|
|
277
|
-
# reach .rea/policy.yaml, .rea/HALT, or .claude/settings.json via any glob
|
|
278
|
-
# creativity.
|
|
44
|
+
# # Bootstrap safety
|
|
279
45
|
#
|
|
280
|
-
#
|
|
281
|
-
# the `
|
|
282
|
-
#
|
|
283
|
-
#
|
|
284
|
-
|
|
285
|
-
# Trigger lazy load now so PROTECTED_PATTERNS reflects the relaxed list
|
|
286
|
-
# from the start of this hook process.
|
|
287
|
-
rea_path_is_protected "/__rea_force_load__" >/dev/null 2>&1 || true
|
|
288
|
-
PROTECTED_PATTERNS=("${REA_PROTECTED_PATTERNS[@]}")
|
|
289
|
-
|
|
290
|
-
# Patterns that are protected from general agent edits but can be unlocked by
|
|
291
|
-
# REA_HOOK_PATCH_SESSION. Kept separate from the hard-protected list above so
|
|
292
|
-
# the patch-session gate in §6b only applies to these directories.
|
|
293
|
-
PATCH_SESSION_PATTERNS=(
|
|
294
|
-
'.claude/hooks/'
|
|
295
|
-
)
|
|
296
|
-
|
|
297
|
-
# LOWER_NORM was computed in §5b above and is reused here.
|
|
298
|
-
|
|
299
|
-
# Match $NORMALIZED against PROTECTED_PATTERNS (exact or prefix for patterns
|
|
300
|
-
# ending in '/'). Sets $PROTECTED_MATCH to the matched pattern; exit 0 on hit.
|
|
301
|
-
match_protected() {
|
|
302
|
-
local pattern
|
|
303
|
-
PROTECTED_MATCH=""
|
|
304
|
-
for pattern in "${PROTECTED_PATTERNS[@]}"; do
|
|
305
|
-
if [[ "$NORMALIZED" == "$pattern" ]]; then
|
|
306
|
-
PROTECTED_MATCH="$pattern"
|
|
307
|
-
return 0
|
|
308
|
-
fi
|
|
309
|
-
if [[ "$pattern" == */ ]] && [[ "$NORMALIZED" == "$pattern"* ]]; then
|
|
310
|
-
PROTECTED_MATCH="$pattern"
|
|
311
|
-
return 0
|
|
312
|
-
fi
|
|
313
|
-
done
|
|
314
|
-
return 1
|
|
315
|
-
}
|
|
46
|
+
# This shim is ITSELF protected by `settings-protection.sh`. The new
|
|
47
|
+
# shim must not block legitimate writes — the `bash -n` syntax check
|
|
48
|
+
# in the test:bash-syntax script catches parse errors BEFORE the
|
|
49
|
+
# install lands them. The relevance pre-gate keeps benign writes (like
|
|
50
|
+
# editing `src/foo.ts`) exiting 0 even when the CLI is missing.
|
|
316
51
|
|
|
317
|
-
|
|
318
|
-
local pattern lp
|
|
319
|
-
PROTECTED_MATCH=""
|
|
320
|
-
for pattern in "${PROTECTED_PATTERNS[@]}"; do
|
|
321
|
-
lp=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
|
|
322
|
-
if [[ "$LOWER_NORM" == "$lp" ]]; then
|
|
323
|
-
PROTECTED_MATCH="$pattern"
|
|
324
|
-
return 0
|
|
325
|
-
fi
|
|
326
|
-
if [[ "$lp" == */ ]] && [[ "$LOWER_NORM" == "$lp"* ]]; then
|
|
327
|
-
PROTECTED_MATCH="$pattern"
|
|
328
|
-
return 0
|
|
329
|
-
fi
|
|
330
|
-
done
|
|
331
|
-
return 1
|
|
332
|
-
}
|
|
52
|
+
set -uo pipefail
|
|
333
53
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
PROTECTED_MATCH="$pattern"
|
|
340
|
-
return 0
|
|
341
|
-
fi
|
|
342
|
-
if [[ "$pattern" == */ ]] && [[ "$NORMALIZED" == "$pattern"* ]]; then
|
|
343
|
-
PROTECTED_MATCH="$pattern"
|
|
344
|
-
return 0
|
|
345
|
-
fi
|
|
346
|
-
done
|
|
347
|
-
return 1
|
|
348
|
-
}
|
|
54
|
+
# 1. HALT check.
|
|
55
|
+
# shellcheck source=_lib/halt-check.sh
|
|
56
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
57
|
+
check_halt
|
|
58
|
+
REA_ROOT=$(rea_root)
|
|
349
59
|
|
|
350
|
-
|
|
351
|
-
local pattern lp
|
|
352
|
-
PROTECTED_MATCH=""
|
|
353
|
-
for pattern in "${PATCH_SESSION_PATTERNS[@]}"; do
|
|
354
|
-
lp=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
|
|
355
|
-
if [[ "$LOWER_NORM" == "$lp" ]]; then
|
|
356
|
-
PROTECTED_MATCH="$pattern"
|
|
357
|
-
return 0
|
|
358
|
-
fi
|
|
359
|
-
if [[ "$lp" == */ ]] && [[ "$LOWER_NORM" == "$lp"* ]]; then
|
|
360
|
-
PROTECTED_MATCH="$pattern"
|
|
361
|
-
return 0
|
|
362
|
-
fi
|
|
363
|
-
done
|
|
364
|
-
return 1
|
|
365
|
-
}
|
|
60
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
366
61
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
printf 'SETTINGS PROTECTION: Modification blocked\n'
|
|
370
|
-
printf '\n'
|
|
371
|
-
printf ' File: %s\n' "$SAFE_FILE_PATH"
|
|
372
|
-
printf ' Matched: %s\n' "$PROTECTED_MATCH"
|
|
373
|
-
printf ' Rule: This file is protected from agent modification, including\n'
|
|
374
|
-
printf ' sessions with REA_HOOK_PATCH_SESSION set.\n'
|
|
375
|
-
} >&2
|
|
376
|
-
exit 2
|
|
377
|
-
fi
|
|
62
|
+
# 2. Capture stdin once.
|
|
63
|
+
INPUT=$(cat)
|
|
378
64
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
65
|
+
# 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
|
|
66
|
+
REA_ARGV=()
|
|
67
|
+
RESOLVED_CLI_PATH=""
|
|
68
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
69
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
70
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
71
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
72
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
73
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
387
74
|
fi
|
|
388
75
|
|
|
389
|
-
#
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
#
|
|
399
|
-
# §5a (`..` traversal) doesn't catch — the path string has no `..`.
|
|
400
|
-
# §6 PROTECTED_PATTERNS sees `innocuous_path/maybe/pre-push` — doesn't
|
|
401
|
-
# match `.husky/` prefix. The write succeeds and the package-managed
|
|
402
|
-
# pre-push body is overwritten.
|
|
403
|
-
#
|
|
404
|
-
# Fix: when the parent directory of the target exists, resolve its
|
|
405
|
-
# realpath via cd -P && pwd -P (same shape as §5b) and check whether
|
|
406
|
-
# the resolved path falls inside any protected directory. Only resolve
|
|
407
|
-
# when the parent already exists — a write that creates the parent has
|
|
408
|
-
# nothing to follow.
|
|
409
|
-
if [[ -e "$FILE_PATH" || -d "$(dirname -- "$FILE_PATH")" ]]; then
|
|
410
|
-
parent_dir=$(dirname -- "$FILE_PATH")
|
|
411
|
-
if [[ -d "$parent_dir" ]]; then
|
|
412
|
-
resolved_parent=$(cd -P -- "$parent_dir" 2>/dev/null && pwd -P 2>/dev/null) || resolved_parent=""
|
|
413
|
-
if [[ -n "$resolved_parent" ]]; then
|
|
414
|
-
# If the resolved parent is inside REA_ROOT, compute the project-
|
|
415
|
-
# relative path and test it against the protected patterns.
|
|
416
|
-
if [[ "$resolved_parent" == "$REA_ROOT"/* ]]; then
|
|
417
|
-
relative_resolved="${resolved_parent#"$REA_ROOT"/}"
|
|
418
|
-
# Walk every PROTECTED_PATTERN that's a directory prefix and
|
|
419
|
-
# check whether the resolved parent falls inside it. Direct
|
|
420
|
-
# filename matches against PROTECTED_PATTERNS for the resolved
|
|
421
|
-
# final path (parent + basename).
|
|
422
|
-
resolved_target="${relative_resolved}/$(basename -- "$FILE_PATH")"
|
|
423
|
-
resolved_target_lc=$(printf '%s' "$resolved_target" | tr '[:upper:]' '[:lower:]')
|
|
424
|
-
for pattern in "${PROTECTED_PATTERNS[@]}"; do
|
|
425
|
-
pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
|
|
426
|
-
if [[ "$resolved_target_lc" == "$pattern_lc" ]] || \
|
|
427
|
-
{ [[ "$pattern_lc" == */ ]] && [[ "$resolved_target_lc" == "$pattern_lc"* ]]; }; then
|
|
428
|
-
{
|
|
429
|
-
printf 'SETTINGS PROTECTION: intermediate-symlink resolution blocked\n'
|
|
430
|
-
printf '\n'
|
|
431
|
-
printf ' Logical: %s\n' "$SAFE_FILE_PATH"
|
|
432
|
-
printf ' Resolved: %s\n' "$resolved_target"
|
|
433
|
-
printf ' Matched: %s\n' "$pattern"
|
|
434
|
-
printf ' Rule: an intermediate directory of the target path is a\n'
|
|
435
|
-
printf ' symlink whose target falls inside a hard-protected\n'
|
|
436
|
-
printf ' path. Refused to prevent symlinked-parent bypass.\n'
|
|
437
|
-
} >&2
|
|
438
|
-
exit 2
|
|
439
|
-
fi
|
|
440
|
-
done
|
|
441
|
-
fi
|
|
442
|
-
fi
|
|
76
|
+
# 3b. Relevance pre-gate. Only used when the CLI is missing.
|
|
77
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
78
|
+
CLI_MISSING_FILE_PATH=""
|
|
79
|
+
if command -v jq >/dev/null 2>&1; then
|
|
80
|
+
CLI_MISSING_FILE_PATH=$(printf '%s' "$INPUT" | jq -r '
|
|
81
|
+
(.tool_input.file_path // .tool_input.notebook_path // "") | tostring
|
|
82
|
+
' 2>/dev/null || true)
|
|
83
|
+
else
|
|
84
|
+
CLI_MISSING_FILE_PATH="$INPUT"
|
|
443
85
|
fi
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
REA_AUDIT_SESSION="${CLAUDE_SESSION_ID:-external}" \
|
|
483
|
-
REA_AUDIT_ROOT="$REA_ROOT" \
|
|
484
|
-
node --input-type=module -e '
|
|
485
|
-
const root = process.env.REA_AUDIT_ROOT;
|
|
486
|
-
async function loadMod() {
|
|
487
|
-
// Consumer path: `@bookedsolid/rea` resolvable via node_modules
|
|
488
|
-
// (how `rea init`-installed consumers reach the published package)
|
|
489
|
-
// or via package self-reference when the hook runs inside the rea
|
|
490
|
-
// source repo itself.
|
|
491
|
-
try {
|
|
492
|
-
return await import("@bookedsolid/rea/audit");
|
|
493
|
-
} catch (e1) {
|
|
494
|
-
// Dev path: direct file import from the source repos dist/.
|
|
495
|
-
try {
|
|
496
|
-
return await import(root + "/dist/audit/append.js");
|
|
497
|
-
} catch (e2) {
|
|
498
|
-
process.stderr.write(
|
|
499
|
-
"audit import failed: package=" + (e1 && e1.message ? e1.message : e1) +
|
|
500
|
-
"; dist=" + (e2 && e2.message ? e2.message : e2) + "\n");
|
|
501
|
-
process.exit(1);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
86
|
+
if [ -z "$CLI_MISSING_FILE_PATH" ]; then
|
|
87
|
+
exit 0
|
|
88
|
+
fi
|
|
89
|
+
CLI_MISSING_RELEVANT=0
|
|
90
|
+
case "$CLI_MISSING_FILE_PATH" in
|
|
91
|
+
*".claude/settings"*) CLI_MISSING_RELEVANT=1 ;;
|
|
92
|
+
*".claude/hooks/"*) CLI_MISSING_RELEVANT=1 ;;
|
|
93
|
+
*".husky/"*) CLI_MISSING_RELEVANT=1 ;;
|
|
94
|
+
*".rea/policy.yaml"*) CLI_MISSING_RELEVANT=1 ;;
|
|
95
|
+
*".rea/HALT"*) CLI_MISSING_RELEVANT=1 ;;
|
|
96
|
+
*".rea/last-review"*) CLI_MISSING_RELEVANT=1 ;;
|
|
97
|
+
*".claude\\"*|*".husky\\"*|*".rea\\"*) CLI_MISSING_RELEVANT=1 ;;
|
|
98
|
+
*"..%2F"*|*"%2E%2E"*) CLI_MISSING_RELEVANT=1 ;;
|
|
99
|
+
esac
|
|
100
|
+
# Codex round-1 P2 fix: scan policy.protected_writes entries too so a
|
|
101
|
+
# consumer-defined protected path isn't silently allowed when the CLI
|
|
102
|
+
# is missing.
|
|
103
|
+
if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
|
|
104
|
+
POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
|
|
105
|
+
if [ -f "$POLICY_FILE" ]; then
|
|
106
|
+
while IFS= read -r entry; do
|
|
107
|
+
[ -z "$entry" ] && continue
|
|
108
|
+
base="$entry"
|
|
109
|
+
case "$base" in
|
|
110
|
+
*/) base="${base%/}" ;;
|
|
111
|
+
esac
|
|
112
|
+
[ -z "$base" ] && continue
|
|
113
|
+
case "$CLI_MISSING_FILE_PATH" in
|
|
114
|
+
*"$base"*) CLI_MISSING_RELEVANT=1; break ;;
|
|
115
|
+
esac
|
|
116
|
+
done < <(awk '
|
|
117
|
+
/^protected_writes:/ { in_block=1; next }
|
|
118
|
+
in_block && /^[[:space:]]*-/ {
|
|
119
|
+
sub(/^[[:space:]]*-[[:space:]]*/, "")
|
|
120
|
+
gsub(/^["'\'']/, "")
|
|
121
|
+
gsub(/["'\'']$/, "")
|
|
122
|
+
print
|
|
123
|
+
next
|
|
504
124
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
try {
|
|
508
|
-
await mod.appendAuditRecord(root, {
|
|
509
|
-
session_id: process.env.REA_AUDIT_SESSION,
|
|
510
|
-
tool_name: "hooks.patch.session",
|
|
511
|
-
server_name: "rea",
|
|
512
|
-
tier: "write",
|
|
513
|
-
status: "allowed",
|
|
514
|
-
autonomy_level: "unknown",
|
|
515
|
-
duration_ms: 0,
|
|
516
|
-
metadata: {
|
|
517
|
-
reason: process.env.REA_AUDIT_REASON,
|
|
518
|
-
file: process.env.REA_AUDIT_FILE,
|
|
519
|
-
sha_before: process.env.REA_AUDIT_SHA,
|
|
520
|
-
actor: {
|
|
521
|
-
name: process.env.REA_AUDIT_ACTOR_NAME,
|
|
522
|
-
email: process.env.REA_AUDIT_ACTOR_EMAIL,
|
|
523
|
-
},
|
|
524
|
-
pid: Number(process.env.REA_AUDIT_PID),
|
|
525
|
-
ppid: Number(process.env.REA_AUDIT_PPID),
|
|
526
|
-
},
|
|
527
|
-
});
|
|
528
|
-
process.exit(0);
|
|
529
|
-
} catch (e) {
|
|
530
|
-
process.stderr.write("audit append failed: " + (e && e.message ? e.message : e) + "\n");
|
|
531
|
-
process.exit(1);
|
|
532
|
-
}
|
|
533
|
-
})();
|
|
534
|
-
' 2>&1
|
|
535
|
-
)
|
|
536
|
-
AUDIT_EXIT=$?
|
|
537
|
-
if [[ "$AUDIT_EXIT" -ne 0 ]]; then
|
|
538
|
-
# Fail closed. We deliberately do NOT fall back to a raw `jq … >> audit`
|
|
539
|
-
# write: that path skips prev_hash/hash computation and would silently
|
|
540
|
-
# degrade the hash-chain integrity the rest of REA (and `rea audit verify`)
|
|
541
|
-
# relies on. If the TypeScript chain is unavailable (no `dist/`, missing
|
|
542
|
-
# Node, broken import), refuse the hook-patch edit and surface why. The
|
|
543
|
-
# operator resolves by building the package (`pnpm build`) or running
|
|
544
|
-
# against a published install that ships `dist/`.
|
|
545
|
-
{
|
|
546
|
-
printf 'SETTINGS PROTECTION: audit-append failed; refusing hook-patch edit\n'
|
|
547
|
-
printf ' File: %s\n' "$SAFE_FILE_PATH"
|
|
548
|
-
printf ' Rule: hash-chained audit is required; no raw-jq fallback.\n'
|
|
549
|
-
printf ' Detail: %s\n' "$(sanitize_for_stderr "$AUDIT_PAYLOAD")"
|
|
550
|
-
} >&2
|
|
551
|
-
exit 2
|
|
125
|
+
in_block && /^[^[:space:]-]/ { in_block=0 }
|
|
126
|
+
' "$POLICY_FILE" 2>/dev/null)
|
|
552
127
|
fi
|
|
553
|
-
|
|
554
|
-
|
|
128
|
+
fi
|
|
129
|
+
if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
|
|
555
130
|
exit 0
|
|
556
131
|
fi
|
|
132
|
+
printf 'rea: settings-protection cannot run — the rea CLI is not built.\n' >&2
|
|
133
|
+
printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
|
|
134
|
+
printf 'This shim fails closed because the pre-0.35.0 bash body enforced protected-path refusal without a CLI.\n' >&2
|
|
135
|
+
exit 2
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
# 4. Realpath sandbox check.
|
|
139
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
140
|
+
printf 'rea: settings-protection cannot run — `node` is not on PATH.\n' >&2
|
|
141
|
+
printf 'Install Node 22+ (engines.node) to restore protected-path refusal.\n' >&2
|
|
142
|
+
exit 2
|
|
557
143
|
fi
|
|
558
144
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
145
|
+
sandbox_check=$(node -e '
|
|
146
|
+
const fs = require("fs");
|
|
147
|
+
const path = require("path");
|
|
148
|
+
const cli = process.argv[1];
|
|
149
|
+
const projDir = process.argv[2];
|
|
150
|
+
let real, realProj;
|
|
151
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
152
|
+
process.stdout.write("bad:realpath"); process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
155
|
+
process.stdout.write("bad:realpath-proj"); process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
const sep = path.sep;
|
|
158
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
159
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
160
|
+
process.stdout.write("bad:cli-escapes-project"); process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
// Codex round-1 P1 fix: enforce dist/cli/index.js shape so a
|
|
163
|
+
// workspace attacker who repoints node_modules/@bookedsolid/rea or
|
|
164
|
+
// dist at an arbitrary in-project JS file cannot execute it as the
|
|
165
|
+
// trusted gate CLI. Pre-0.35.0 shims had this check; the 0.34.0
|
|
166
|
+
// round-8 template dropped it; restored here.
|
|
167
|
+
const expectedEnd = path.join("dist", "cli", "index.js");
|
|
168
|
+
if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
|
|
169
|
+
process.stdout.write("bad:cli-shape"); process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
172
|
+
let found = false;
|
|
173
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
174
|
+
const pj = path.join(cur, "package.json");
|
|
175
|
+
if (fs.existsSync(pj)) {
|
|
176
|
+
try {
|
|
177
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
178
|
+
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
179
|
+
} catch (e) { /* keep walking */ }
|
|
180
|
+
}
|
|
181
|
+
cur = path.dirname(cur);
|
|
182
|
+
}
|
|
183
|
+
if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
|
|
184
|
+
process.stdout.write("ok");
|
|
185
|
+
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
186
|
+
|
|
187
|
+
if [ "$sandbox_check" != "ok" ]; then
|
|
188
|
+
printf 'rea: settings-protection FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
|
|
569
189
|
exit 2
|
|
570
190
|
fi
|
|
571
191
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
192
|
+
# 5. Version-probe.
|
|
193
|
+
probe_out=$("${REA_ARGV[@]}" hook settings-protection --help 2>&1)
|
|
194
|
+
probe_status=$?
|
|
195
|
+
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'settings-protection'; then
|
|
196
|
+
printf 'rea: this shim requires the `rea hook settings-protection` subcommand (introduced in 0.35.0).\n' >&2
|
|
197
|
+
printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
|
|
198
|
+
printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
|
|
579
199
|
exit 2
|
|
580
200
|
fi
|
|
581
201
|
|
|
582
|
-
|
|
202
|
+
# 6. Forward stdin (already captured up-front).
|
|
203
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook settings-protection
|
|
204
|
+
exit $?
|