@bookedsolid/rea 0.9.4 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,89 +59,322 @@ normalize_path() {
59
59
  p="${p#$root/}"
60
60
  fi
61
61
 
62
- # URL decode common sequences
63
- p=$(printf '%s' "$p" | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g')
62
+ # URL decode common sequences. Include %5C (`\`) so Windows-style or
63
+ # percent-encoded back-slash traversal (`..%5C`, `\..\`) normalizes to the
64
+ # forward-slash form the §5a detector sees.
65
+ p=$(printf '%s' "$p" \
66
+ | sed 's/%2[Ff]/\//g; s/%2[Ee]/./g; s/%20/ /g; s/%5[Cc]/\\/g')
64
67
 
65
- # Collapse path traversals
66
- # Remove ./ components
67
- p=$(printf '%s' "$p" | sed 's|\./||g')
68
+ # Translate any backslash separators to forward slashes. Keeps the traversal
69
+ # check in §5a working for `.claude\hooks\..\settings.json`-style inputs.
70
+ p=$(printf '%s' "$p" | tr '\\\\' '/')
68
71
 
69
- # Remove leading ./
70
- p="${p#./}"
72
+ # Strip leading ./ components only. We intentionally do NOT strip interior
73
+ # ./ sequences — that transformation corrupts `..` traversals (e.g. `.../`
74
+ # collapsed to `../`, or `../` collapsed to `./`) and hides traversal from
75
+ # the §5a detector.
76
+ while [[ "$p" == ./* ]]; do
77
+ p="${p#./}"
78
+ done
71
79
 
72
80
  printf '%s' "$p"
73
81
  }
74
82
 
83
+ # Strip C0/C1 control characters from a string to prevent terminal escape
84
+ # injection when we echo protected paths back to the operator. Escape sequences
85
+ # in file names could otherwise rewrite lines above the deny message.
86
+ #
87
+ # Byte ranges stripped:
88
+ # \000-\037 — C0 controls (BEL, BS, HT, LF, CR, ESC, …)
89
+ # \177 — DEL
90
+ # \200-\237 — C1 controls (CSI 0x9B, OSC 0x9D, …). Many terminals still
91
+ # interpret these as single-byte CSI introducers; without
92
+ # stripping, a UTF-8 file name whose bytes fall in this range
93
+ # could still drive the cursor on older emulators.
94
+ sanitize_for_stderr() {
95
+ printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177\200-\237'
96
+ }
97
+
75
98
  NORMALIZED=$(normalize_path "$FILE_PATH")
99
+ SAFE_FILE_PATH=$(sanitize_for_stderr "$FILE_PATH")
100
+ SAFE_NORMALIZED=$(sanitize_for_stderr "$NORMALIZED")
101
+
102
+ # ── 5a. Reject path traversal segments (Codex HIGH: Defect I bypass) ─────────
103
+ # A path containing `..` segments can be used to bypass the protected-path
104
+ # globs in §6 — e.g. `.claude/hooks/../settings.json` would pass the
105
+ # `.claude/hooks/*` case-glob in the patch-session allowlist but actually
106
+ # refers to `.claude/settings.json`. We refuse any path that contains a `..`
107
+ # segment in either the raw input OR the normalized form. The request must
108
+ # be reissued with a canonical path.
109
+ #
110
+ # For the raw-input check, translate backslashes first so a Windows-style
111
+ # `.claude\hooks\..\settings.json` is rejected at the raw stage too (the
112
+ # normalized form also catches it — this is defense in depth).
113
+ RAW_PATH_SLASHED=$(printf '%s' "$FILE_PATH" | tr '\\\\' '/')
114
+ raw_has_traversal=0
115
+ case "/$RAW_PATH_SLASHED/" in
116
+ */../*) raw_has_traversal=1 ;;
117
+ esac
118
+ norm_has_traversal=0
119
+ case "/$NORMALIZED/" in
120
+ */../*) norm_has_traversal=1 ;;
121
+ esac
122
+ if [[ "$raw_has_traversal" -eq 1 ]] || [[ "$norm_has_traversal" -eq 1 ]]; then
123
+ {
124
+ printf 'SETTINGS PROTECTION: path traversal rejected\n'
125
+ printf '\n'
126
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
127
+ printf " Rule: path contains a '..' segment; rewrite to a canonical\n"
128
+ printf ' project-relative path without traversal.\n'
129
+ } >&2
130
+ exit 2
131
+ fi
76
132
 
77
133
  # ── 6. Protected path patterns ────────────────────────────────────────────────
134
+ # §6 runs BEFORE the patch-session allowlist so hook-patch sessions cannot
135
+ # reach .rea/policy.yaml, .rea/HALT, or .claude/settings.json via any glob
136
+ # creativity.
78
137
  PROTECTED_PATTERNS=(
79
138
  '.claude/settings.json'
80
139
  '.claude/settings.local.json'
81
- '.claude/hooks/'
82
140
  '.husky/'
83
141
  '.rea/policy.yaml'
84
142
  '.rea/HALT'
85
143
  )
86
144
 
87
- for pattern in "${PROTECTED_PATTERNS[@]}"; do
88
- # Exact match
89
- if [[ "$NORMALIZED" == "$pattern" ]]; then
90
- {
91
- printf 'SETTINGS PROTECTION: Modification blocked\n'
92
- printf '\n'
93
- printf ' File: %s\n' "$FILE_PATH"
94
- printf ' Rule: This file is protected from agent modification.\n'
95
- printf '\n'
96
- printf ' Protected files include hook scripts, settings, policy,\n'
97
- printf ' and kill switch files. These must be modified by humans\n'
98
- printf ' via rea CLI or direct editing.\n'
99
- printf '\n'
100
- printf ' Use: rea init (to update hooks/settings)\n'
101
- printf ' rea freeze/unfreeze (for HALT file)\n'
102
- printf ' Edit .rea/policy.yaml manually\n'
103
- } >&2
104
- exit 2
105
- fi
106
-
107
- # Directory prefix match (patterns ending in /)
108
- if [[ "$pattern" == */ ]] && [[ "$NORMALIZED" == "$pattern"* ]]; then
109
- {
110
- printf 'SETTINGS PROTECTION: Modification blocked\n'
111
- printf '\n'
112
- printf ' File: %s\n' "$FILE_PATH"
113
- printf ' Rule: Files under %s are protected from agent modification.\n' "$pattern"
114
- printf '\n'
115
- printf ' These files control the hook safety layer and must be\n'
116
- printf ' modified by humans via rea CLI or direct editing.\n'
117
- } >&2
118
- exit 2
119
- fi
120
- done
145
+ # Patterns that are protected from general agent edits but can be unlocked by
146
+ # REA_HOOK_PATCH_SESSION. Kept separate from the hard-protected list above so
147
+ # the patch-session gate in §6b only applies to these directories.
148
+ PATCH_SESSION_PATTERNS=(
149
+ '.claude/hooks/'
150
+ )
121
151
 
122
- # ── 7. Case-insensitive fallback check ────────────────────────────────────────
123
- # Catch case-manipulation bypass attempts (e.g., .Claude/Settings.json)
124
152
  LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
125
- for pattern in "${PROTECTED_PATTERNS[@]}"; do
126
- LOWER_PATTERN=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
127
- if [[ "$LOWER_NORM" == "$LOWER_PATTERN" ]]; then
128
- {
129
- printf 'SETTINGS PROTECTION: Modification blocked (case-insensitive match)\n'
130
- printf '\n'
131
- printf ' File: %s\n' "$FILE_PATH"
132
- printf ' Matched: %s\n' "$pattern"
133
- } >&2
134
- exit 2
135
- fi
136
- if [[ "$LOWER_PATTERN" == */ ]] && [[ "$LOWER_NORM" == "$LOWER_PATTERN"* ]]; then
137
- {
138
- printf 'SETTINGS PROTECTION: Modification blocked (case-insensitive match)\n'
139
- printf '\n'
140
- printf ' File: %s\n' "$FILE_PATH"
141
- printf ' Matched: %s*\n' "$pattern"
142
- } >&2
143
- exit 2
153
+
154
+ # Match $NORMALIZED against PROTECTED_PATTERNS (exact or prefix for patterns
155
+ # ending in '/'). Sets $PROTECTED_MATCH to the matched pattern; exit 0 on hit.
156
+ match_protected() {
157
+ local pattern
158
+ PROTECTED_MATCH=""
159
+ for pattern in "${PROTECTED_PATTERNS[@]}"; do
160
+ if [[ "$NORMALIZED" == "$pattern" ]]; then
161
+ PROTECTED_MATCH="$pattern"
162
+ return 0
163
+ fi
164
+ if [[ "$pattern" == */ ]] && [[ "$NORMALIZED" == "$pattern"* ]]; then
165
+ PROTECTED_MATCH="$pattern"
166
+ return 0
167
+ fi
168
+ done
169
+ return 1
170
+ }
171
+
172
+ match_protected_ci() {
173
+ local pattern lp
174
+ PROTECTED_MATCH=""
175
+ for pattern in "${PROTECTED_PATTERNS[@]}"; do
176
+ lp=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
177
+ if [[ "$LOWER_NORM" == "$lp" ]]; then
178
+ PROTECTED_MATCH="$pattern"
179
+ return 0
180
+ fi
181
+ if [[ "$lp" == */ ]] && [[ "$LOWER_NORM" == "$lp"* ]]; then
182
+ PROTECTED_MATCH="$pattern"
183
+ return 0
184
+ fi
185
+ done
186
+ return 1
187
+ }
188
+
189
+ match_patch_session() {
190
+ local pattern
191
+ PROTECTED_MATCH=""
192
+ for pattern in "${PATCH_SESSION_PATTERNS[@]}"; do
193
+ if [[ "$NORMALIZED" == "$pattern" ]]; then
194
+ PROTECTED_MATCH="$pattern"
195
+ return 0
196
+ fi
197
+ if [[ "$pattern" == */ ]] && [[ "$NORMALIZED" == "$pattern"* ]]; then
198
+ PROTECTED_MATCH="$pattern"
199
+ return 0
200
+ fi
201
+ done
202
+ return 1
203
+ }
204
+
205
+ match_patch_session_ci() {
206
+ local pattern lp
207
+ PROTECTED_MATCH=""
208
+ for pattern in "${PATCH_SESSION_PATTERNS[@]}"; do
209
+ lp=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
210
+ if [[ "$LOWER_NORM" == "$lp" ]]; then
211
+ PROTECTED_MATCH="$pattern"
212
+ return 0
213
+ fi
214
+ if [[ "$lp" == */ ]] && [[ "$LOWER_NORM" == "$lp"* ]]; then
215
+ PROTECTED_MATCH="$pattern"
216
+ return 0
217
+ fi
218
+ done
219
+ return 1
220
+ }
221
+
222
+ if match_protected; then
223
+ {
224
+ printf 'SETTINGS PROTECTION: Modification blocked\n'
225
+ printf '\n'
226
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
227
+ printf ' Matched: %s\n' "$PROTECTED_MATCH"
228
+ printf ' Rule: This file is protected from agent modification, including\n'
229
+ printf ' sessions with REA_HOOK_PATCH_SESSION set.\n'
230
+ } >&2
231
+ exit 2
232
+ fi
233
+
234
+ if match_protected_ci; then
235
+ {
236
+ printf 'SETTINGS PROTECTION: Modification blocked (case-insensitive match)\n'
237
+ printf '\n'
238
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
239
+ printf ' Matched: %s\n' "$PROTECTED_MATCH"
240
+ } >&2
241
+ exit 2
242
+ fi
243
+
244
+ # ── 6b. Hook-patch session (Defect I / rea#76) ───────────────────────────────
245
+ # When REA_HOOK_PATCH_SESSION is set to a non-empty reason, allow edits under
246
+ # .claude/hooks/ and hooks/ for this session. The session boundary IS the
247
+ # expiry — a new shell requires a fresh opt-in. Every allowed edit is audited
248
+ # as hooks.patch.session so the bypass is never silent.
249
+ #
250
+ # SECURITY: runs AFTER §5a (traversal reject) and §6 (hard-protected denies),
251
+ # so no glob creativity can reach policy/HALT/settings files from here.
252
+ if [[ -n "${REA_HOOK_PATCH_SESSION:-}" ]]; then
253
+ if match_patch_session; then
254
+ SAFE_REASON=$(sanitize_for_stderr "${REA_HOOK_PATCH_SESSION}")
255
+ # Audit record via the TypeScript chain so the hash chain stays intact.
256
+ # If the append fails, block the edit — silent failure would let an
257
+ # attacker disable audit logging and then patch hooks unobserved.
258
+ SHA_BEFORE=""
259
+ if [[ -f "$FILE_PATH" ]]; then
260
+ if command -v sha256sum >/dev/null 2>&1; then
261
+ SHA_BEFORE=$(sha256sum "$FILE_PATH" 2>/dev/null | awk '{print $1}')
262
+ elif command -v shasum >/dev/null 2>&1; then
263
+ SHA_BEFORE=$(shasum -a 256 "$FILE_PATH" 2>/dev/null | awk '{print $1}')
264
+ elif command -v openssl >/dev/null 2>&1; then
265
+ SHA_BEFORE=$(openssl dgst -sha256 "$FILE_PATH" 2>/dev/null | awk '{print $NF}')
266
+ fi
267
+ fi
268
+ ACTOR_NAME=$(git -C "$REA_ROOT" config user.name 2>/dev/null || printf 'unknown')
269
+ ACTOR_EMAIL=$(git -C "$REA_ROOT" config user.email 2>/dev/null || printf 'unknown')
270
+
271
+ AUDIT_PAYLOAD=$(
272
+ cd "$REA_ROOT" 2>/dev/null || true
273
+ REA_AUDIT_REASON="${REA_HOOK_PATCH_SESSION}" \
274
+ REA_AUDIT_FILE="$NORMALIZED" \
275
+ REA_AUDIT_SHA="$SHA_BEFORE" \
276
+ REA_AUDIT_ACTOR_NAME="$ACTOR_NAME" \
277
+ REA_AUDIT_ACTOR_EMAIL="$ACTOR_EMAIL" \
278
+ REA_AUDIT_PID="$$" \
279
+ REA_AUDIT_PPID="$PPID" \
280
+ REA_AUDIT_SESSION="${CLAUDE_SESSION_ID:-external}" \
281
+ REA_AUDIT_ROOT="$REA_ROOT" \
282
+ node --input-type=module -e '
283
+ const root = process.env.REA_AUDIT_ROOT;
284
+ async function loadMod() {
285
+ // Consumer path: `@bookedsolid/rea` resolvable via node_modules
286
+ // (how `rea init`-installed consumers reach the published package)
287
+ // or via package self-reference when the hook runs inside the rea
288
+ // source repo itself.
289
+ try {
290
+ return await import("@bookedsolid/rea/audit");
291
+ } catch (e1) {
292
+ // Dev path: direct file import from the source repos dist/.
293
+ try {
294
+ return await import(root + "/dist/audit/append.js");
295
+ } catch (e2) {
296
+ process.stderr.write(
297
+ "audit import failed: package=" + (e1 && e1.message ? e1.message : e1) +
298
+ "; dist=" + (e2 && e2.message ? e2.message : e2) + "\n");
299
+ process.exit(1);
300
+ }
301
+ }
302
+ }
303
+ (async () => {
304
+ const mod = await loadMod();
305
+ try {
306
+ await mod.appendAuditRecord(root, {
307
+ session_id: process.env.REA_AUDIT_SESSION,
308
+ tool_name: "hooks.patch.session",
309
+ server_name: "rea",
310
+ tier: "write",
311
+ status: "allowed",
312
+ autonomy_level: "unknown",
313
+ duration_ms: 0,
314
+ metadata: {
315
+ reason: process.env.REA_AUDIT_REASON,
316
+ file: process.env.REA_AUDIT_FILE,
317
+ sha_before: process.env.REA_AUDIT_SHA,
318
+ actor: {
319
+ name: process.env.REA_AUDIT_ACTOR_NAME,
320
+ email: process.env.REA_AUDIT_ACTOR_EMAIL,
321
+ },
322
+ pid: Number(process.env.REA_AUDIT_PID),
323
+ ppid: Number(process.env.REA_AUDIT_PPID),
324
+ },
325
+ });
326
+ process.exit(0);
327
+ } catch (e) {
328
+ process.stderr.write("audit append failed: " + (e && e.message ? e.message : e) + "\n");
329
+ process.exit(1);
330
+ }
331
+ })();
332
+ ' 2>&1
333
+ )
334
+ AUDIT_EXIT=$?
335
+ if [[ "$AUDIT_EXIT" -ne 0 ]]; then
336
+ # Fail closed. We deliberately do NOT fall back to a raw `jq … >> audit`
337
+ # write: that path skips prev_hash/hash computation and would silently
338
+ # degrade the hash-chain integrity the rest of REA (and `rea audit verify`)
339
+ # relies on. If the TypeScript chain is unavailable (no `dist/`, missing
340
+ # Node, broken import), refuse the hook-patch edit and surface why. The
341
+ # operator resolves by building the package (`pnpm build`) or running
342
+ # against a published install that ships `dist/`.
343
+ {
344
+ printf 'SETTINGS PROTECTION: audit-append failed; refusing hook-patch edit\n'
345
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
346
+ printf ' Rule: hash-chained audit is required; no raw-jq fallback.\n'
347
+ printf ' Detail: %s\n' "$(sanitize_for_stderr "$AUDIT_PAYLOAD")"
348
+ } >&2
349
+ exit 2
350
+ fi
351
+ printf 'REA_HOOK_PATCH_SESSION: allowing edit to %s (reason: %s)\n' \
352
+ "$SAFE_NORMALIZED" "$SAFE_REASON" >&2
353
+ exit 0
144
354
  fi
145
- done
355
+ fi
356
+
357
+ # ── 6c. Patch-session patterns are still blocked when env var is NOT set ─────
358
+ if match_patch_session; then
359
+ {
360
+ printf 'SETTINGS PROTECTION: Modification blocked\n'
361
+ printf '\n'
362
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
363
+ printf ' Matched: %s\n' "$PROTECTED_MATCH"
364
+ printf ' Rule: Files under this path are protected. To apply an upstream\n'
365
+ printf ' hook finding, set REA_HOOK_PATCH_SESSION=<reason> and retry.\n'
366
+ } >&2
367
+ exit 2
368
+ fi
369
+
370
+ if match_patch_session_ci; then
371
+ {
372
+ printf 'SETTINGS PROTECTION: Modification blocked (case-insensitive match)\n'
373
+ printf '\n'
374
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
375
+ printf ' Matched: %s\n' "$PROTECTED_MATCH"
376
+ } >&2
377
+ exit 2
378
+ fi
146
379
 
147
380
  exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.9.4",
3
+ "version": "0.10.1",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",