@bookedsolid/rea 0.30.1 → 0.32.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.
Files changed (51) hide show
  1. package/.husky/prepare-commit-msg +80 -6
  2. package/MIGRATING.md +24 -15
  3. package/dist/cli/audit-specialists.d.ts +106 -24
  4. package/dist/cli/audit-specialists.js +239 -64
  5. package/dist/cli/delegation-advisory.d.ts +161 -0
  6. package/dist/cli/delegation-advisory.js +433 -0
  7. package/dist/cli/doctor.d.ts +110 -39
  8. package/dist/cli/doctor.js +302 -90
  9. package/dist/cli/hook.d.ts +6 -0
  10. package/dist/cli/hook.js +45 -22
  11. package/dist/cli/index.js +1 -1
  12. package/dist/cli/install/settings-merge.js +25 -0
  13. package/dist/cli/roster.d.ts +119 -0
  14. package/dist/cli/roster.js +141 -0
  15. package/dist/hooks/_lib/halt-check.d.ts +78 -0
  16. package/dist/hooks/_lib/halt-check.js +106 -0
  17. package/dist/hooks/_lib/payload.d.ts +86 -0
  18. package/dist/hooks/_lib/payload.js +166 -0
  19. package/dist/hooks/_lib/segments.d.ts +100 -0
  20. package/dist/hooks/_lib/segments.js +444 -0
  21. package/dist/hooks/attribution-advisory/index.d.ts +72 -0
  22. package/dist/hooks/attribution-advisory/index.js +233 -0
  23. package/dist/hooks/bash-scanner/protected-scan.js +14 -2
  24. package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
  25. package/dist/hooks/pr-issue-link-gate/index.js +127 -0
  26. package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
  27. package/dist/hooks/security-disclosure-gate/index.js +502 -0
  28. package/dist/policy/loader.d.ts +23 -0
  29. package/dist/policy/loader.js +46 -0
  30. package/dist/policy/profiles.d.ts +23 -0
  31. package/dist/policy/profiles.js +16 -0
  32. package/dist/policy/types.d.ts +61 -0
  33. package/hooks/_lib/protected-paths.sh +10 -3
  34. package/hooks/attribution-advisory.sh +139 -131
  35. package/hooks/delegation-advisory.sh +162 -0
  36. package/hooks/pr-issue-link-gate.sh +114 -45
  37. package/hooks/security-disclosure-gate.sh +148 -316
  38. package/hooks/settings-protection.sh +13 -9
  39. package/package.json +1 -1
  40. package/profiles/bst-internal-no-codex.yaml +12 -0
  41. package/profiles/bst-internal.yaml +13 -0
  42. package/profiles/client-engagement.yaml +11 -0
  43. package/profiles/lit-wc.yaml +10 -0
  44. package/profiles/minimal.yaml +11 -0
  45. package/profiles/open-source-no-codex.yaml +11 -0
  46. package/profiles/open-source.yaml +11 -0
  47. package/templates/attribution-advisory.dogfood-staged.sh +170 -0
  48. package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
  49. package/templates/prepare-commit-msg.husky.sh +80 -6
  50. package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
  51. package/templates/settings-protection.dogfood.patch +58 -0
@@ -1,339 +1,171 @@
1
- #!/usr/bin/env bash
2
- # security-disclosure-gate.sh — PreToolUse: Bash
1
+ #!/bin/bash
2
+ # PreToolUse hook: security-disclosure-gate.sh
3
+ # 0.32.0+ — Node-binary shim for `rea hook security-disclosure-gate`.
3
4
  #
4
- # Intercepts `gh issue create` commands that contain security-sensitive
5
- # keywords and blocks them. Routing depends on REA_DISCLOSURE_MODE:
5
+ # Pre-0.32.0 the gate's full body lived here as bash (339 LOC including
6
+ # the awk body-file resolver, security-patterns array, and mode-aware
7
+ # routing). The migration to the parser-backed Node binary moves all of
8
+ # that into `src/hooks/security-disclosure-gate/index.ts`. This shim is
9
+ # the Claude Code dispatcher's view of the hook — it forwards stdin
10
+ # AND the REA_DISCLOSURE_MODE env var to the CLI and exits with
11
+ # whatever the CLI returns.
6
12
  #
7
- # advisory (default) redirect to GitHub Security Advisories (private)
8
- # Use for public OSS repos
9
- # issues — redirect to gh issue create with security + internal labels
10
- # Use for permanently private client repos
11
- # disabled — pass through (not recommended)
13
+ # Behavioral contract is preserved byte-for-byte: exit 0 on
14
+ # pass-through / no-match, exit 2 on HALT / pattern match / traversal
15
+ # refusal / malformed payload (fail-closed).
12
16
  #
13
- # Set REA_DISCLOSURE_MODE in .rea/policy.yaml (written to settings.json
14
- # env by rea init). Defaults to "advisory" when unset.
17
+ # # CLI-resolution trust boundary
15
18
  #
16
- # Triggered by: PreToolUse Bash tool
17
-
18
- set -euo pipefail
19
+ # Codex round 1 P1 (2026-05-15): realpath sandbox check matches
20
+ # delegation-advisory.sh §3. The resolved CLI MUST live INSIDE
21
+ # realpath(CLAUDE_PROJECT_DIR) AND have an ancestor `package.json`
22
+ # whose `name` is `@bookedsolid/rea`. Defends against symlink-out
23
+ # and tarball-replacement attacks that could otherwise forge the
24
+ # pattern matcher and either suppress real findings or leak a
25
+ # vulnerability through the disclosure gate.
26
+ #
27
+ # Sandboxed resolution order (PATH is INTENTIONALLY OMITTED):
28
+ # 1. node_modules/@bookedsolid/rea/dist/cli/index.js (consumer-side)
29
+ # 2. dist/cli/index.js under CLAUDE_PROJECT_DIR (dogfood)
30
+ #
31
+ # When NO rea CLI is reachable, the hook falls through to allow —
32
+ # same posture as the bash-resident version, which `source`d
33
+ # _lib/common.sh first and exited cleanly if the lib was missing.
19
34
 
20
- # shellcheck source=_lib/common.sh
21
- source "$(dirname "$0")/_lib/common.sh"
35
+ set -uo pipefail
22
36
 
37
+ # 1. HALT check.
38
+ # shellcheck source=_lib/halt-check.sh
39
+ source "$(dirname "$0")/_lib/halt-check.sh"
23
40
  check_halt
41
+ REA_ROOT=$(rea_root)
24
42
 
25
- # Read disclosure mode — default to advisory
26
- DISCLOSURE_MODE="${REA_DISCLOSURE_MODE:-advisory}"
43
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
27
44
 
28
- # Disabled mode: pass through entirely
29
- if [[ "$DISCLOSURE_MODE" == "disabled" ]]; then
45
+ # 2. Relevance pre-gate (0.32.0 round-5 P1, round-6 fix). PreToolUse
46
+ # Bash matchers fire on EVERY shell command, but this hook only
47
+ # enforces against `gh issue create` payloads carrying disclosure
48
+ # keywords. Capture stdin + check relevance FIRST so unrelated
49
+ # commands exit 0 even when the CLI is missing/stale.
50
+ #
51
+ # Match `gh issue create` ANYWHERE in the command string (allow
52
+ # shell prefixes — `sudo`, env assignments). Round-6 P1.
53
+ INPUT=$(cat)
54
+ # Substring scan (NOT JSON-aware). Round-7 P1: any JSON-aware regex
55
+ # anchored on `"command":"...` gets tripped by escaped quotes in
56
+ # quoted env prefixes (`MODE="internal" gh issue create …`). Plain
57
+ # substring match has no such edge — and false-positives just defer
58
+ # to the Node body which handles correctly.
59
+ RELEVANT=0
60
+ if printf '%s' "$INPUT" | grep -qE 'gh[[:space:]]+issue[[:space:]]+create'; then
61
+ RELEVANT=1
62
+ fi
63
+ if [ "$RELEVANT" -eq 0 ]; then
30
64
  exit 0
31
65
  fi
32
66
 
33
- INPUT="$(cat)"
34
- TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
35
-
36
- if [[ "$TOOL_NAME" != "Bash" ]]; then
67
+ # 2b. Mode short-circuit (round-6 P2). The pre-0.32.0 bash body
68
+ # no-op'd ONLY when `REA_DISCLOSURE_MODE=disabled` `advisory`
69
+ # mode and the `issues` mode (default) BOTH enforced. Without
70
+ # this check, an unbuilt/stale install would refuse every relevant
71
+ # `gh issue create` even when the operator has deliberately set
72
+ # mode=disabled.
73
+ MODE="${REA_DISCLOSURE_MODE:-advisory}"
74
+ if [ "$MODE" = "disabled" ]; then
37
75
  exit 0
38
76
  fi
39
77
 
40
- COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
41
-
42
- # Only intercept gh issue create
43
- # 0.16.3 F8: anchor at segment start so `gh pr create --body "context: gh issue create earlier"`
44
- # does not match. Same anchoring class as F5/F6 in this release. Source the
45
- # segment splitter and use any_segment_starts_with — when the cmd-segments
46
- # lib isn't reachable for any reason, fall back to the legacy unanchored
47
- # grep (defense-in-depth: better to over-block prose mentions than miss a
48
- # real `gh issue create`).
49
- # shellcheck source=_lib/cmd-segments.sh
50
- if [ -f "$(dirname "$0")/_lib/cmd-segments.sh" ]; then
51
- # shellcheck source=_lib/cmd-segments.sh
52
- source "$(dirname "$0")/_lib/cmd-segments.sh"
53
- if ! any_segment_starts_with "$COMMAND" 'gh[[:space:]]+issue[[:space:]]+create'; then
54
- exit 0
55
- fi
56
- else
57
- if ! echo "$COMMAND" | grep -qE 'gh\s+issue\s+create'; then
58
- exit 0
59
- fi
78
+ # 3. Resolve the rea CLI.
79
+ REA_ARGV=()
80
+ RESOLVED_CLI_PATH=""
81
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
82
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
83
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
84
+ elif [ -f "$proj/dist/cli/index.js" ]; then
85
+ REA_ARGV=(node "$proj/dist/cli/index.js")
86
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
60
87
  fi
61
88
 
62
- require_jq
63
-
64
- # Security-sensitive keywords that should not appear in public issues
65
- # these terms suggest a vulnerability, exploit path, or bypass technique
66
- SECURITY_PATTERNS=(
67
- # Vulnerability classes
68
- 'bypass'
69
- 'exploit'
70
- 'injection'
71
- 'traversal'
72
- 'exfiltrat'
73
- 'escalat'
74
- 'privilege'
75
- 'rce'
76
- 'remote.code.exec'
77
- 'arbitrary.code'
78
- 'code.execution'
79
- 'zero.day'
80
- '0day'
81
- 'CVE-'
82
- 'CVSS'
83
- 'GHSA-'
84
- # Reagent-specific sensitive terms
85
- 'hook.bypass'
86
- 'HALT.bypass'
87
- 'redaction.bypass'
88
- 'policy.bypass'
89
- 'middleware.bypass'
90
- 'skip.*gate'
91
- 'evad'
92
- # Credential/secret exposure
93
- 'secret.*leak'
94
- 'credential.*leak'
95
- 'token.*leak'
96
- 'key.*expos'
97
- 'expos.*secret'
98
- # Prompt injection
99
- 'prompt.inject'
100
- 'jailbreak'
101
- 'jail.break'
102
- )
103
-
104
- # Scan the full command text (title + body + flags) for sensitive patterns.
105
- #
106
- # 0.16.3 discord-ops Round 9 #2 fix: pre-fix the scan only saw what was on
107
- # the command line, so `gh issue create --body-file leak.md` (or `-F`)
108
- # routed the body through a file the regex never read. We now resolve the
109
- # named flag's path argument(s), read up to 64 KiB of each (cap covers
110
- # realistic issue bodies; a multi-megabyte body is suspicious in itself),
111
- # and prepend the lowercased file contents to FULL_TEXT before the
112
- # pattern scan. Stdin form (`-F -` or `--body-file -`) is intentionally
113
- # skipped — the hook's stdin is the tool payload, not the issue body,
114
- # and re-reading is impossible. Files outside REA_ROOT (resolved via
115
- # `..` traversal) are refused as a defense-in-depth measure mirroring
116
- # protected-paths-bash-gate.sh's outside-root sentinel.
117
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
118
- BODY_FILE_TEXT=""
119
- _extract_body_file_paths() {
120
- # Emit each `--body-file PATH` and `-F PATH` argument on its own line.
121
- # Skips the stdin form (`-`) and emits the path verbatim from the
122
- # equals-form (`--body-file=PATH` / `-F=PATH`).
123
- #
124
- # 0.17.0 helix-019 #2: quote-aware tokenization. The pre-fix awk split
125
- # on whitespace, breaking `--body-file "security notes.md"` into three
126
- # tokens — the hook then tried to read `"security` (with literal
127
- # leading quote), failed, and silently skipped the body scan. Now we
128
- # walk the string with quote-state awareness: whitespace inside
129
- # matched `"..."` / `'...'` spans is part of the token, not a
130
- # separator. Single-quote spans have no escape semantics; double-quote
131
- # spans treat `\"` and `\\` as literal escapes (POSIX shell rules).
132
- printf '%s' "$COMMAND" \
133
- | awk '
134
- BEGIN { skip_next = 0 }
135
- function strip_outer_quotes(s, n, first, last) {
136
- n = length(s)
137
- if (n < 2) return s
138
- first = substr(s, 1, 1)
139
- last = substr(s, n, 1)
140
- if ((first == "\"" && last == "\"") || (first == "'\''" && last == "'\''")) {
141
- return substr(s, 2, n - 2)
142
- }
143
- return s
144
- }
145
- function emit_token(t) {
146
- if (skip_next) {
147
- skip_next = 0
148
- if (t == "-" || t == "") return
149
- t = strip_outer_quotes(t)
150
- print t
151
- return
152
- }
153
- if (t == "--body-file" || t == "-F") { skip_next = 1; return }
154
- if (t ~ /^--body-file=/) {
155
- v = substr(t, length("--body-file=") + 1)
156
- v = strip_outer_quotes(v)
157
- if (v != "" && v != "-") print v
158
- }
159
- if (t ~ /^-F=/) {
160
- v = substr(t, length("-F=") + 1)
161
- v = strip_outer_quotes(v)
162
- if (v != "" && v != "-") print v
163
- }
164
- }
165
- {
166
- line = $0
167
- n = length(line)
168
- i = 1
169
- tok = ""
170
- mode = 0 # 0=plain, 1=double-quoted, 2=single-quoted
171
- while (i <= n) {
172
- ch = substr(line, i, 1)
173
- if (mode == 0) {
174
- # 0.18.0 helix-020 G3.B fix: in plain (unquoted) mode,
175
- # `\X` (any character X) is the POSIX shell escape for
176
- # the literal character X — most commonly a space in
177
- # paths like `path\ with\ spaces.md`. Pre-fix the
178
- # tokenizer treated the `\` as an ordinary character and
179
- # truncated at the following space, dropping the rest of
180
- # the path. We now consume the backslash and emit the
181
- # following byte as a literal part of the current token.
182
- # `\<eol>` (line-continuation) is left intact — emit the
183
- # `\` and let the splitter flow into the next record on
184
- # the assumption that the caller already joined the line.
185
- if (ch == "\\" && i < n) {
186
- nxt = substr(line, i + 1, 1)
187
- tok = tok nxt
188
- i += 2
189
- continue
190
- }
191
- if (ch == " " || ch == "\t") {
192
- if (tok != "") { emit_token(tok); tok = "" }
193
- i++; continue
194
- }
195
- if (ch == "\"") { mode = 1; tok = tok ch; i++; continue }
196
- if (ch == "'\''") { mode = 2; tok = tok ch; i++; continue }
197
- tok = tok ch
198
- i++
199
- continue
200
- }
201
- if (mode == 1) {
202
- if (ch == "\\" && i < n) {
203
- nxt = substr(line, i + 1, 1)
204
- tok = tok ch nxt
205
- i += 2
206
- continue
207
- }
208
- if (ch == "\"") { mode = 0; tok = tok ch; i++; continue }
209
- tok = tok ch
210
- i++
211
- continue
212
- }
213
- # mode == 2
214
- if (ch == "'\''") { mode = 0; tok = tok ch; i++; continue }
215
- tok = tok ch
216
- i++
217
- }
218
- if (tok != "") emit_token(tok)
219
- }'
220
- }
221
- while IFS= read -r body_path; do
222
- [[ -z "$body_path" ]] && continue
223
- raw_path="$body_path"
224
- # Resolve relative to the hook's cwd (the agent's project dir). gh
225
- # accepts both absolute paths (e.g. tmpfiles like /var/folders/…) and
226
- # cwd-relative paths; we honor both. Absolute paths NOT containing
227
- # `..` are taken at face value.
228
- if [[ "$body_path" != /* ]]; then
229
- body_path="$(pwd)/$body_path"
230
- fi
231
- # Walk `..` segments. The only outside-REA_ROOT shape we refuse is one
232
- # where the canonical form contains `..` (i.e. an explicit traversal
233
- # by the caller). Plain absolute tmp paths are NOT refused — gh issue
234
- # body-files are very commonly written to /var/folders or /tmp and
235
- # rejecting those would defeat the scan in routine use.
236
- had_traversal=0
237
- case "/$raw_path/" in */../*) had_traversal=1 ;; esac
238
- resolved="$body_path"
239
- if [[ "$had_traversal" -eq 1 ]]; then
240
- IFS='/' read -ra _bf_parts_raw <<<"$body_path"
241
- _bf_parts=()
242
- for _seg in "${_bf_parts_raw[@]}"; do
243
- case "$_seg" in
244
- ''|.) continue ;;
245
- ..) [[ "${#_bf_parts[@]}" -gt 0 ]] && unset '_bf_parts[${#_bf_parts[@]}-1]' ;;
246
- *) _bf_parts+=("$_seg") ;;
247
- esac
248
- done
249
- resolved="/$(IFS=/; printf '%s' "${_bf_parts[*]}")"
250
- # 0.17.0 helix-019 #1: HARD REFUSAL on traversal escaping REA_ROOT.
251
- # Pre-fix the gate logged "skipping body scan" and exited 0 — every
252
- # sensitive payload at the resolved external path bypassed the
253
- # disclosure check. The traversal-out-of-root shape exists ONLY to
254
- # obfuscate; legitimate workflows pass absolute tmpfile paths
255
- # (`/tmp/...`, `/var/folders/...`) without `..` segments.
256
- if [[ "$resolved" != "$REA_ROOT" && "$resolved" != "$REA_ROOT"/* ]]; then
257
- {
258
- printf 'SECURITY DISCLOSURE GATE: --body-file path traversal escapes project root\n'
259
- printf '\n'
260
- printf ' Path: %s\n' "$raw_path"
261
- printf ' Resolved: %s\n' "$resolved"
262
- printf '\n'
263
- printf ' Rule: --body-file paths whose canonical form uses `..` segments to\n'
264
- printf ' escape REA_ROOT are refused. Move the file inside the project\n'
265
- printf ' tree, or paste the body inline via --body.\n'
266
- } >&2
267
- exit 2
268
- fi
269
- fi
270
- if [[ ! -r "$resolved" ]]; then
271
- printf 'security-disclosure-gate: --body-file %s unreadable; skipping body scan\n' "$raw_path" >&2
272
- continue
273
- fi
274
- # Cap at 64 KiB. Lowercase to match FULL_TEXT case folding.
275
- body_chunk=$(head -c 65536 "$resolved" 2>/dev/null | tr '[:upper:]' '[:lower:]') || body_chunk=""
276
- if [[ -n "$body_chunk" ]]; then
277
- BODY_FILE_TEXT="${BODY_FILE_TEXT}
278
- ${body_chunk}"
279
- fi
280
- done < <(_extract_body_file_paths)
281
-
282
- FULL_TEXT="${BODY_FILE_TEXT}
283
- $(echo "$COMMAND" | tr '[:upper:]' '[:lower:]')"
284
-
285
- MATCHED_PATTERN=""
286
- for PATTERN in "${SECURITY_PATTERNS[@]}"; do
287
- if echo "$FULL_TEXT" | grep -qiE "$PATTERN"; then
288
- MATCHED_PATTERN="$PATTERN"
289
- break
290
- fi
291
- done
292
-
293
- if [[ -z "$MATCHED_PATTERN" ]]; then
294
- exit 0
89
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
90
+ # 0.32.0 round-4 P1: this is a blocking-tier gate — the pre-0.32.0
91
+ # bash body enforced the disclosure policy WITHOUT a compiled CLI.
92
+ # Falling through to exit 0 here would silently disable security-
93
+ # keyword blocking on `gh issue create` until the operator runs
94
+ # `pnpm install` / `pnpm build`. Fail closed: refuse the operation
95
+ # and tell the operator how to restore protection.
96
+ printf 'rea: security-disclosure-gate cannot run — the rea CLI is not built.\n' >&2
97
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
98
+ printf 'This shim fails closed because the pre-0.32.0 bash body enforced disclosure policy without a CLI.\n' >&2
99
+ exit 2
295
100
  fi
296
101
 
297
- # ─── Route based on disclosure mode ──────────────────────────────────────────
298
-
299
- if [[ "$DISCLOSURE_MODE" == "issues" ]]; then
300
- # Private repo mode: redirect to labeled internal issue
301
- json_output "block" \
302
- "SECURITY DISCLOSURE GATE: This issue appears to describe a security finding (matched: '${MATCHED_PATTERN}').
303
-
304
- This project is configured for PRIVATE disclosure (REA_DISCLOSURE_MODE=issues).
305
-
306
- CORRECT PATH for security findings in this private repo:
307
- Use: gh issue create --label 'security,internal' --title '...' --body '...'
308
-
309
- The 'security' and 'internal' labels keep this off public project boards and
310
- mark it for maintainer-only triage. Do NOT use the public issue queue without
311
- these labels for security findings.
312
-
313
- If this is NOT a security finding, rephrase the title/body to avoid triggering
314
- security patterns, then retry."
315
-
316
- else
317
- # Advisory mode (default): redirect to GitHub Security Advisories
318
- json_output "block" \
319
- "SECURITY DISCLOSURE GATE: This issue appears to describe a security vulnerability (matched: '${MATCHED_PATTERN}'). Do NOT create a public GitHub issue for security vulnerabilities.
320
-
321
- CORRECT DISCLOSURE PATH:
322
- 1. Use GitHub Security Advisories (private):
323
- gh api repos/{owner}/{repo}/security-advisories --method POST --input - <<'JSON'
324
- { \"summary\": \"...\", \"description\": \"...\", \"severity\": \"medium|high|critical\",
325
- \"vulnerabilities\": [{\"package\": {\"name\": \"@pkg\", \"ecosystem\": \"npm\"}}] }
326
- JSON
327
- 2. Or navigate to: Security tab → Advisories → 'Report a vulnerability'
328
- 3. Or email security@bookedsolid.tech (see SECURITY.md)
329
-
330
- The finding will be publicly disclosed AFTER a patch is released (coordinated disclosure).
102
+ # 3. Realpath sandbox check.
103
+ if ! command -v node >/dev/null 2>&1; then
104
+ printf 'rea: security-disclosure-gate cannot run `node` is not on PATH.\n' >&2
105
+ printf 'Install Node 22+ (engines.node) to restore disclosure-policy enforcement.\n' >&2
106
+ exit 2
107
+ fi
331
108
 
332
- WHY: Public issues expose vulnerabilities before users can patch. This is enforced by the
333
- security-disclosure-gate hook (REA_DISCLOSURE_MODE=${DISCLOSURE_MODE}).
109
+ sandbox_check=$(node -e '
110
+ const fs = require("fs");
111
+ const path = require("path");
112
+ const cli = process.argv[1];
113
+ const projDir = process.argv[2];
114
+ let real, realProj;
115
+ try { real = fs.realpathSync(cli); } catch (e) {
116
+ process.stdout.write("bad:realpath"); process.exit(1);
117
+ }
118
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
119
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
120
+ }
121
+ const sep = path.sep;
122
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
123
+ if (!(real === realProj || real.startsWith(projWithSep))) {
124
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
125
+ }
126
+ let cur = path.dirname(path.dirname(path.dirname(real)));
127
+ let found = false;
128
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
129
+ const pj = path.join(cur, "package.json");
130
+ if (fs.existsSync(pj)) {
131
+ try {
132
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
133
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
134
+ } catch (e) { /* keep walking */ }
135
+ }
136
+ cur = path.dirname(cur);
137
+ }
138
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
139
+ process.stdout.write("ok");
140
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
141
+
142
+ if [ "$sandbox_check" != "ok" ]; then
143
+ # 0.32.0 round-4 P1: fail closed (blocking-tier — see exit-0 → exit-2
144
+ # rationale at the top). A failed sandbox check means the CLI we
145
+ # would run cannot be authenticated as the rea binary; refusing is
146
+ # both the safest posture AND preserves the pre-0.32.0 bash-body
147
+ # contract that this hook always enforces policy.
148
+ printf 'rea: security-disclosure-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
149
+ exit 2
150
+ fi
334
151
 
335
- If this is NOT a security vulnerability, rephrase the issue to avoid triggering
336
- security patterns, then retry."
152
+ # 4. Version-probe: confirm the resolved CLI implements
153
+ # `hook security-disclosure-gate`. Codex round 1 P1.
154
+ probe_out=$("${REA_ARGV[@]}" hook security-disclosure-gate --help 2>&1)
155
+ probe_status=$?
156
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'security-disclosure-gate'; then
157
+ # 0.32.0 round-4 P1: a stale/older CLI without the new subcommand is
158
+ # NOT a "harmless availability fallback" for this hook — the bash
159
+ # body it replaces always enforced. Fail closed and tell the
160
+ # operator exactly how to fix.
161
+ printf 'rea: this shim requires the `rea hook security-disclosure-gate` subcommand (introduced in 0.32.0).\n' >&2
162
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
163
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
164
+ exit 2
337
165
  fi
338
166
 
339
- exit 2
167
+ # 5. Forward stdin (already captured up-front for the relevance gate).
168
+ # REA_DISCLOSURE_MODE is in env already; the Node binary reads it
169
+ # directly.
170
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook security-disclosure-gate
171
+ exit $?
@@ -172,10 +172,12 @@ fi
172
172
  LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
173
173
 
174
174
  # ── 5b. Extension-surface allow-list ──────────────────────────────────────────
175
- # `.husky/commit-msg.d/*` and `.husky/pre-push.d/*` are the documented
176
- # consumer extension surface (Fix H / 0.13.0). Consumers and the agents
177
- # that govern those consumers are expected to write here freely so they
178
- # can layer commitlint, lint-staged, branch-policy, act-CI, etc. without
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
179
181
  # losing rea coverage on `rea upgrade`.
180
182
  #
181
183
  # The §6 PROTECTED_PATTERNS list below has `.husky/` as a prefix block,
@@ -216,14 +218,14 @@ LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
216
218
  # subshell pattern (no Python or readlink -f dependency required).
217
219
  # Closes the path-string→symlink bypass completely.
218
220
  case "$LOWER_NORM" in
219
- .husky/commit-msg.d/*|.husky/pre-push.d/*)
221
+ .husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/prepare-commit-msg.d/*)
220
222
  if [ -L "$FILE_PATH" ]; then
221
223
  {
222
224
  printf 'SETTINGS PROTECTION: symlink in extension surface refused\n'
223
225
  printf '\n'
224
226
  printf ' File: %s\n' "$SAFE_FILE_PATH"
225
- printf ' Rule: .husky/commit-msg.d/* and .husky/pre-push.d/* must be\n'
226
- printf ' regular files (a symlink could resolve to a protected\n'
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'
227
229
  printf ' package-managed body and bypass §6 protection).\n'
228
230
  } >&2
229
231
  exit 2
@@ -245,8 +247,10 @@ case "$LOWER_NORM" in
245
247
  # to `.husky/pre-push.d.bak/...` and slipped through.
246
248
  # The trailing `/` on each pattern (and the explicit
247
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).
248
252
  case "$resolved_parent" in
249
- */.husky/commit-msg.d|*/.husky/commit-msg.d/*|*/.husky/pre-push.d|*/.husky/pre-push.d/*) : ;;
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/*) : ;;
250
254
  *)
251
255
  {
252
256
  printf 'SETTINGS PROTECTION: extension path resolves outside surface\n'
@@ -254,7 +258,7 @@ case "$LOWER_NORM" in
254
258
  printf ' Logical: %s\n' "$SAFE_FILE_PATH"
255
259
  printf ' Resolved: %s\n' "$resolved_parent"
256
260
  printf ' Rule: an intermediate directory of the extension path is a\n'
257
- printf ' symlink whose target leaves .husky/{commit-msg,pre-push}.d/.\n'
261
+ printf ' symlink whose target leaves .husky/{commit-msg,pre-push,prepare-commit-msg}.d/.\n'
258
262
  printf ' Refused to prevent symlinked-parent bypass of the\n'
259
263
  printf ' package-managed body protection.\n'
260
264
  } >&2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.30.1",
3
+ "version": "0.32.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)",
@@ -58,3 +58,15 @@ context_protection:
58
58
  attribution:
59
59
  co_author:
60
60
  enabled: false
61
+ # 0.31.0 delegation-advisory nudge — enabled for bst-internal-no-codex.
62
+ # This is a bst-internal variant, so it inherits BST's delegation
63
+ # discipline: the delegation-advisory.sh PostToolUse hook emits a
64
+ # one-time stderr advisory when a session crosses `threshold`
65
+ # write-class tool calls (Bash/Edit/Write/MultiEdit/NotebookEdit)
66
+ # without dispatching a curated specialist. Advisory only — never
67
+ # blocks. `exempt_subagents` omitted → schema default applies
68
+ # (general-purpose, Explore, Plan, output-style-setup,
69
+ # statusline-setup don't count as real delegation).
70
+ delegation_advisory:
71
+ enabled: true
72
+ threshold: 25
@@ -67,3 +67,16 @@ architecture_review:
67
67
  attribution:
68
68
  co_author:
69
69
  enabled: false
70
+ # 0.31.0 delegation-advisory nudge — enabled for bst-internal.
71
+ # The delegation-advisory.sh PostToolUse hook emits a one-time stderr
72
+ # advisory when a session crosses `threshold` write-class tool calls
73
+ # (Bash/Edit/Write/MultiEdit/NotebookEdit) without dispatching a
74
+ # curated specialist. Advisory only — never blocks a tool call. BST's
75
+ # own delegation discipline (CLAUDE.md routes all non-trivial work
76
+ # through rea-orchestrator) is load-bearing, so the nudge ships on.
77
+ # `exempt_subagents` omitted → the schema default applies
78
+ # (general-purpose, Explore, Plan, output-style-setup, statusline-setup
79
+ # don't count as real delegation).
80
+ delegation_advisory:
81
+ enabled: true
82
+ threshold: 25
@@ -35,3 +35,14 @@ context_protection:
35
35
  attribution:
36
36
  co_author:
37
37
  enabled: false
38
+ # 0.31.0 delegation-advisory nudge — disabled for client-engagement.
39
+ # The delegation-advisory.sh PostToolUse hook emits a one-time stderr
40
+ # advisory when a session crosses `threshold` write-class tool calls
41
+ # without dispatching a curated specialist. Client projects vary too
42
+ # much in their delegation conventions to ship the nudge on by
43
+ # default — opt in per-repo via .rea/policy.yaml:
44
+ # delegation_advisory:
45
+ # enabled: true
46
+ # threshold: 25
47
+ delegation_advisory:
48
+ enabled: false
@@ -29,3 +29,13 @@ notification_channel: ''
29
29
  attribution:
30
30
  co_author:
31
31
  enabled: false
32
+ # 0.31.0 delegation-advisory nudge — disabled for lit-wc.
33
+ # The delegation-advisory.sh PostToolUse hook emits a one-time stderr
34
+ # advisory when a session crosses `threshold` write-class tool calls
35
+ # without dispatching a curated specialist. External profiles ship
36
+ # `enabled: false` — opt in per-repo via .rea/policy.yaml:
37
+ # delegation_advisory:
38
+ # enabled: true
39
+ # threshold: 25
40
+ delegation_advisory:
41
+ enabled: false
@@ -25,3 +25,14 @@ notification_channel: ''
25
25
  attribution:
26
26
  co_author:
27
27
  enabled: false
28
+ # 0.31.0 delegation-advisory nudge — disabled for minimal.
29
+ # The delegation-advisory.sh PostToolUse hook emits a one-time stderr
30
+ # advisory when a session crosses `threshold` write-class tool calls
31
+ # without dispatching a curated specialist. The minimal profile ships
32
+ # bare defaults — `enabled: false` keeps it opinion-free. Opt in
33
+ # per-repo via .rea/policy.yaml:
34
+ # delegation_advisory:
35
+ # enabled: true
36
+ # threshold: 25
37
+ delegation_advisory:
38
+ enabled: false
@@ -44,3 +44,14 @@ notification_channel: ''
44
44
  attribution:
45
45
  co_author:
46
46
  enabled: false
47
+ # 0.31.0 delegation-advisory nudge — disabled for open-source-no-codex.
48
+ # The delegation-advisory.sh PostToolUse hook emits a one-time stderr
49
+ # advisory when a session crosses `threshold` write-class tool calls
50
+ # without dispatching a curated specialist. "You should delegate more"
51
+ # is an opinion not every OSS team shares, so external profiles ship
52
+ # `enabled: false` — opt in per-repo via .rea/policy.yaml:
53
+ # delegation_advisory:
54
+ # enabled: true
55
+ # threshold: 25
56
+ delegation_advisory:
57
+ enabled: false