@bookedsolid/rea 0.31.0 → 0.33.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 (43) hide show
  1. package/.husky/prepare-commit-msg +80 -6
  2. package/MIGRATING.md +24 -15
  3. package/dist/cli/hook.js +60 -22
  4. package/dist/hooks/_lib/halt-check.d.ts +78 -0
  5. package/dist/hooks/_lib/halt-check.js +106 -0
  6. package/dist/hooks/_lib/payload.d.ts +124 -0
  7. package/dist/hooks/_lib/payload.js +245 -0
  8. package/dist/hooks/_lib/segments.d.ts +125 -0
  9. package/dist/hooks/_lib/segments.js +766 -0
  10. package/dist/hooks/architecture-review-gate/index.d.ts +58 -0
  11. package/dist/hooks/architecture-review-gate/index.js +250 -0
  12. package/dist/hooks/attribution-advisory/index.d.ts +72 -0
  13. package/dist/hooks/attribution-advisory/index.js +233 -0
  14. package/dist/hooks/bash-scanner/protected-scan.js +14 -2
  15. package/dist/hooks/changeset-security-gate/index.d.ts +71 -0
  16. package/dist/hooks/changeset-security-gate/index.js +330 -0
  17. package/dist/hooks/dependency-audit-gate/index.d.ts +91 -0
  18. package/dist/hooks/dependency-audit-gate/index.js +294 -0
  19. package/dist/hooks/env-file-protection/index.d.ts +55 -0
  20. package/dist/hooks/env-file-protection/index.js +159 -0
  21. package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
  22. package/dist/hooks/pr-issue-link-gate/index.js +127 -0
  23. package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
  24. package/dist/hooks/security-disclosure-gate/index.js +502 -0
  25. package/hooks/_lib/protected-paths.sh +10 -3
  26. package/hooks/architecture-review-gate.sh +92 -77
  27. package/hooks/attribution-advisory.sh +139 -131
  28. package/hooks/changeset-security-gate.sh +114 -149
  29. package/hooks/dependency-audit-gate.sh +115 -156
  30. package/hooks/env-file-protection.sh +130 -97
  31. package/hooks/pr-issue-link-gate.sh +114 -45
  32. package/hooks/security-disclosure-gate.sh +148 -316
  33. package/hooks/settings-protection.sh +13 -9
  34. package/package.json +1 -1
  35. package/templates/architecture-review-gate.dogfood-staged.sh +116 -0
  36. package/templates/attribution-advisory.dogfood-staged.sh +170 -0
  37. package/templates/changeset-security-gate.dogfood-staged.sh +137 -0
  38. package/templates/dependency-audit-gate.dogfood-staged.sh +138 -0
  39. package/templates/env-file-protection.dogfood-staged.sh +157 -0
  40. package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
  41. package/templates/prepare-commit-msg.husky.sh +80 -6
  42. package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
  43. package/templates/settings-protection.dogfood.patch +58 -0
@@ -0,0 +1,157 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: env-file-protection.sh
3
+ # 0.33.0+ — Node-binary shim for `rea hook env-file-protection`.
4
+ #
5
+ # Pre-0.33.0 the gate's full body lived here as bash (124 LOC, the
6
+ # segment splitter + `source`/`cp` anchor patterns + utility-vs-.env
7
+ # co-occurrence check). The migration to the parser-backed Node binary
8
+ # moves all of that into `src/hooks/env-file-protection/index.ts`. This
9
+ # shim is the Claude Code dispatcher's view of the hook — it forwards
10
+ # stdin to the CLI and exits with whatever the CLI returns.
11
+ #
12
+ # Behavioral contract is preserved byte-for-byte: exit 0 on
13
+ # pass-through / no-match, exit 2 on HALT / .env access detected /
14
+ # malformed payload (fail-closed).
15
+ #
16
+ # # CLI-resolution trust boundary
17
+ #
18
+ # Mirrors the 0.32.0 final shim shape (round-8 of the codex iteration
19
+ # on the three Phase 1 pilots). The resolved CLI MUST live INSIDE
20
+ # realpath(CLAUDE_PROJECT_DIR) AND have an ancestor `package.json`
21
+ # whose `name` is `@bookedsolid/rea`. Defends against symlink-out and
22
+ # tarball-replacement attacks on the resolved CLI.
23
+ #
24
+ # # Fail-closed posture
25
+ #
26
+ # env-file-protection is a BLOCKING-tier gate — the pre-0.33.0 bash
27
+ # body refused on .env access without a compiled CLI. The early-exit
28
+ # branches (CLI missing, node missing, sandbox failed, version skew)
29
+ # fail closed AFTER the relevance pre-gate passes. Irrelevant Bash
30
+ # calls exit 0 regardless of CLI state.
31
+
32
+ set -uo pipefail
33
+
34
+ # 1. HALT check.
35
+ # shellcheck source=_lib/halt-check.sh
36
+ source "$(dirname "$0")/_lib/halt-check.sh"
37
+ check_halt
38
+ REA_ROOT=$(rea_root)
39
+
40
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
41
+
42
+ # 2. Relevance pre-gate. Capture stdin + check for `.env` substring
43
+ # BEFORE any CLI/sandbox/probe work so unrelated Bash calls
44
+ # (`ls`, `pnpm test`, `git status`, …) exit 0 even when the CLI
45
+ # is missing/stale/sandboxed-out.
46
+ #
47
+ # 2026-05-15 codex round-2 P2 fix: the substring scan MUST run
48
+ # against `tool_input.command` ONLY, not the raw JSON payload —
49
+ # otherwise a benign `git commit -m "stop reading .env"` (where
50
+ # `.env` appears inside the commit message ARG, NOT as a file
51
+ # target) would hit the fail-closed branch on a fresh checkout
52
+ # where the CLI is unbuilt. Pre-fix the raw scan saw the substring
53
+ # inside the payload's "command" string-quoted body and refused.
54
+ #
55
+ # Strategy: extract `tool_input.command` via `jq` (already required
56
+ # by 5 other hooks; trust assumption is consistent). When `jq` is
57
+ # not installed, fall back to scanning the raw payload — the cost
58
+ # is the same over-trigger the bash original had, NOT a new
59
+ # regression. When `jq` IS installed (the common case), the
60
+ # pre-gate is field-scoped.
61
+ INPUT=$(cat)
62
+ RELEVANT=0
63
+ PROBE=""
64
+ if command -v jq >/dev/null 2>&1; then
65
+ PROBE=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
66
+ if printf '%s' "$PROBE" | grep -qE '\.env'; then
67
+ RELEVANT=1
68
+ fi
69
+ else
70
+ # jq-less fallback — match the pre-0.33.0 over-trigger posture.
71
+ if printf '%s' "$INPUT" | grep -qE '\.env'; then
72
+ RELEVANT=1
73
+ fi
74
+ fi
75
+ if [ "$RELEVANT" -eq 0 ]; then
76
+ exit 0
77
+ fi
78
+
79
+ # 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
80
+ REA_ARGV=()
81
+ RESOLVED_CLI_PATH=""
82
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
83
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
84
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
85
+ elif [ -f "$proj/dist/cli/index.js" ]; then
86
+ REA_ARGV=(node "$proj/dist/cli/index.js")
87
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
88
+ fi
89
+
90
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
91
+ # Blocking-tier: fail closed. The pre-0.33.0 bash body enforced
92
+ # .env protection without a CLI. Refuse and tell the operator how
93
+ # to restore protection.
94
+ printf 'rea: env-file-protection cannot run — the rea CLI is not built.\n' >&2
95
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
96
+ printf 'This shim fails closed because the pre-0.33.0 bash body enforced .env protection without a CLI.\n' >&2
97
+ exit 2
98
+ fi
99
+
100
+ # 4. Realpath sandbox check.
101
+ if ! command -v node >/dev/null 2>&1; then
102
+ printf 'rea: env-file-protection cannot run — `node` is not on PATH.\n' >&2
103
+ printf 'Install Node 22+ (engines.node) to restore .env protection.\n' >&2
104
+ exit 2
105
+ fi
106
+
107
+ sandbox_check=$(node -e '
108
+ const fs = require("fs");
109
+ const path = require("path");
110
+ const cli = process.argv[1];
111
+ const projDir = process.argv[2];
112
+ let real, realProj;
113
+ try { real = fs.realpathSync(cli); } catch (e) {
114
+ process.stdout.write("bad:realpath"); process.exit(1);
115
+ }
116
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
117
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
118
+ }
119
+ const sep = path.sep;
120
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
121
+ if (!(real === realProj || real.startsWith(projWithSep))) {
122
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
123
+ }
124
+ let cur = path.dirname(path.dirname(path.dirname(real)));
125
+ let found = false;
126
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
127
+ const pj = path.join(cur, "package.json");
128
+ if (fs.existsSync(pj)) {
129
+ try {
130
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
131
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
132
+ } catch (e) { /* keep walking */ }
133
+ }
134
+ cur = path.dirname(cur);
135
+ }
136
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
137
+ process.stdout.write("ok");
138
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
139
+
140
+ if [ "$sandbox_check" != "ok" ]; then
141
+ printf 'rea: env-file-protection FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
142
+ exit 2
143
+ fi
144
+
145
+ # 5. Version-probe.
146
+ probe_out=$("${REA_ARGV[@]}" hook env-file-protection --help 2>&1)
147
+ probe_status=$?
148
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'env-file-protection'; then
149
+ printf 'rea: this shim requires the `rea hook env-file-protection` subcommand (introduced in 0.33.0).\n' >&2
150
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
151
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
152
+ exit 2
153
+ fi
154
+
155
+ # 6. Forward stdin (already captured up-front for the relevance gate).
156
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook env-file-protection
157
+ exit $?
@@ -0,0 +1,134 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: pr-issue-link-gate.sh
3
+ # 0.32.0+ — Node-binary shim for `rea hook pr-issue-link-gate`.
4
+ #
5
+ # Pre-0.32.0 the gate's full body lived here as bash; the migration to
6
+ # the parser-backed Node binary moves the matching + advisory logic
7
+ # into `src/hooks/pr-issue-link-gate/index.ts`. This shim is the
8
+ # Claude Code dispatcher's view of the hook — it forwards stdin to the
9
+ # CLI and exits with whatever the CLI returns.
10
+ #
11
+ # Behavioral contract is preserved byte-for-byte: ALWAYS exit 0 except
12
+ # under HALT (exit 2) or a malformed payload (exit 2, fail-closed).
13
+ #
14
+ # # CLI-resolution trust boundary
15
+ #
16
+ # Codex round 1 P1 (2026-05-15): mirrors the realpath sandbox check
17
+ # from `delegation-advisory.sh` §3 and `protected-paths-bash-gate.sh`
18
+ # §6. The resolved CLI MUST live INSIDE realpath(CLAUDE_PROJECT_DIR)
19
+ # AND have an ancestor `package.json` whose `name` is
20
+ # `@bookedsolid/rea`. Pre-fix the shim executed
21
+ # `node_modules/@bookedsolid/rea/dist/cli/index.js` directly without
22
+ # realpathing the target, which would let an attacker who controlled
23
+ # `node_modules/@bookedsolid/rea` (symlink-out, postinstall script,
24
+ # tarball-replacement) ship forged review code that intercepts every
25
+ # Bash dispatch.
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 through the sandboxed order, this hook
32
+ # falls through to allow (exit 0) — the advisory is a nudge, not a
33
+ # security claim. The bash-tier path gates fail-closed because they
34
+ # protect write surfaces; this gate only emits prose.
35
+ #
36
+ # # Version skew
37
+ #
38
+ # Codex round 1 P1 (2026-05-15): a fresh `rea init` against a stale
39
+ # `node_modules/@bookedsolid/rea` would deliver this 0.32.0 shim while
40
+ # the installed CLI lacks the `hook pr-issue-link-gate` subcommand —
41
+ # every Bash dispatch would then fail with `unknown command` (exit 1).
42
+ # Probe the subcommand's `--help` output before propagating the exit
43
+ # code; on probe failure, advise the operator to `pnpm install` and
44
+ # fall through silently so the workspace stays usable.
45
+
46
+ set -uo pipefail
47
+
48
+ # 1. HALT check. Even though the CLI re-checks for defense-in-depth,
49
+ # short-circuit here so we never spawn `node` while frozen.
50
+ # shellcheck source=_lib/halt-check.sh
51
+ source "$(dirname "$0")/_lib/halt-check.sh"
52
+ check_halt
53
+ REA_ROOT=$(rea_root)
54
+
55
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
56
+
57
+ # 2. Resolve the rea CLI through the fixed 2-tier sandboxed order.
58
+ REA_ARGV=()
59
+ RESOLVED_CLI_PATH=""
60
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
61
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
62
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
63
+ elif [ -f "$proj/dist/cli/index.js" ]; then
64
+ REA_ARGV=(node "$proj/dist/cli/index.js")
65
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
66
+ fi
67
+
68
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
69
+ exit 0
70
+ fi
71
+
72
+ # 3. Realpath sandbox check — mirrors delegation-advisory.sh §3.
73
+ if ! command -v node >/dev/null 2>&1; then
74
+ exit 0
75
+ fi
76
+
77
+ sandbox_check=$(node -e '
78
+ const fs = require("fs");
79
+ const path = require("path");
80
+ const cli = process.argv[1];
81
+ const projDir = process.argv[2];
82
+ let real, realProj;
83
+ try { real = fs.realpathSync(cli); } catch (e) {
84
+ process.stdout.write("bad:realpath"); process.exit(1);
85
+ }
86
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
87
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
88
+ }
89
+ const sep = path.sep;
90
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
91
+ if (!(real === realProj || real.startsWith(projWithSep))) {
92
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
93
+ }
94
+ let cur = path.dirname(path.dirname(path.dirname(real)));
95
+ let found = false;
96
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
97
+ const pj = path.join(cur, "package.json");
98
+ if (fs.existsSync(pj)) {
99
+ try {
100
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
101
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
102
+ } catch (e) { /* keep walking */ }
103
+ }
104
+ cur = path.dirname(cur);
105
+ }
106
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
107
+ process.stdout.write("ok");
108
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
109
+
110
+ if [ "$sandbox_check" != "ok" ]; then
111
+ printf 'rea: pr-issue-link-gate skipped (sandbox check: %s)\n' "$sandbox_check" >&2
112
+ exit 0
113
+ fi
114
+
115
+ # 4. Version-probe: confirm the resolved CLI implements the
116
+ # `hook pr-issue-link-gate` subcommand. A stale node_modules from
117
+ # a fresh `rea init` against an older installed version would
118
+ # otherwise turn every Bash dispatch into a hard failure.
119
+ probe_out=$("${REA_ARGV[@]}" hook pr-issue-link-gate --help 2>&1)
120
+ probe_status=$?
121
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'pr-issue-link-gate'; then
122
+ printf 'rea: this shim requires the `rea hook pr-issue-link-gate` subcommand (introduced in 0.32.0).\n' >&2
123
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
124
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI to the version this shim expects.\n' >&2
125
+ exit 0
126
+ fi
127
+
128
+ # 5. Forward stdin to the CLI synchronously. The advisory text must
129
+ # reach the operator's stderr before this hook returns; the CLI's
130
+ # own exit code is the hook's exit code (0 normally, 2 under HALT
131
+ # or malformed payload).
132
+ INPUT=$(cat)
133
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook pr-issue-link-gate
134
+ exit $?
@@ -36,28 +36,65 @@ set -u
36
36
  COMMIT_MSG_FILE="${1:-}"
37
37
  COMMIT_SOURCE="${2:-}"
38
38
 
39
+ REA_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
40
+
41
+ # Forward declaration — the extension-chain runner is defined further
42
+ # down (after $REA_ROOT is set so the dir lookup is anchored). We call
43
+ # it from every "augmenter skipped" exit point so consumer fragments
44
+ # under .husky/prepare-commit-msg.d/* run regardless of whether rea's
45
+ # own augmenter ran. The function fires fragments in lex order,
46
+ # logs-and-continues on non-zero exits, and is a no-op if the dir is
47
+ # absent or empty.
48
+ #
49
+ # 0.32.0 Phase 3: the pre-0.32.0 layout exited early at every
50
+ # precondition gate, which made the extension surface unreachable
51
+ # when (a) attribution was disabled, (b) HALT was active, or (c)
52
+ # REA_SKIP_ATTRIBUTION was set. The new layout runs the chain at the
53
+ # end of every exit path EXCEPT when the message file itself is
54
+ # missing/unparseable (no point running fragments against a path that
55
+ # doesn't exist).
56
+ run_extension_chain() {
57
+ ext_dir="${REA_ROOT}/.husky/prepare-commit-msg.d"
58
+ if [ -d "$ext_dir" ]; then
59
+ for frag in "$ext_dir"/*; do
60
+ [ -e "$frag" ] || continue
61
+ [ -f "$frag" ] || continue
62
+ [ -x "$frag" ] || continue
63
+ if ! "$frag" "$COMMIT_MSG_FILE" "$COMMIT_SOURCE"; then
64
+ printf 'rea: prepare-commit-msg.d fragment exited non-zero: %s (continuing)\n' \
65
+ "$(basename "$frag")" >&2
66
+ fi
67
+ done
68
+ fi
69
+ }
70
+
39
71
  # Skip conditions: any missing precondition exits 0 silently. The hook
40
72
  # is purely additive; refusing here would break commits with no upside.
41
73
 
42
- # Missing message file → nothing to augment.
74
+ # Missing message file → nothing to augment AND nothing for fragments
75
+ # to act on either. Exit immediately without running the chain.
43
76
  if [ -z "$COMMIT_MSG_FILE" ] || [ ! -f "$COMMIT_MSG_FILE" ]; then
44
77
  exit 0
45
78
  fi
46
79
 
47
- # Per-invocation override.
80
+ # Per-invocation override — skip the augmenter, but still run consumer
81
+ # fragments. The flag is named REA_SKIP_ATTRIBUTION, not REA_SKIP_HOOK,
82
+ # precisely so the rest of the chain runs.
48
83
  if [ -n "${REA_SKIP_ATTRIBUTION:-}" ]; then
84
+ run_extension_chain
49
85
  exit 0
50
86
  fi
51
87
 
52
- REA_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
53
-
54
- # HALT kill switch refuse to mutate anything while frozen.
88
+ # HALT kill switch refuse to mutate anything while frozen. The
89
+ # extension chain is also skipped under HALT: a frozen system means
90
+ # "no agent-side actions" and consumer fragments are agent-side too.
55
91
  if [ -f "${REA_ROOT}/.rea/HALT" ]; then
56
92
  exit 0
57
93
  fi
58
94
 
59
95
  POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
60
96
  if [ ! -f "$POLICY_FILE" ]; then
97
+ run_extension_chain
61
98
  exit 0
62
99
  fi
63
100
 
@@ -172,6 +209,7 @@ print(enabled); print(name); print(email); print(skip_merge)
172
209
  PY
173
210
  )
174
211
  if [ -z "$CO_AUTHOR_PARSE" ]; then
212
+ run_extension_chain
175
213
  exit 0
176
214
  fi
177
215
  ENABLED=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '1p')
@@ -179,11 +217,15 @@ PY
179
217
  CO_EMAIL=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '3p')
180
218
  SKIP_MERGE=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '4p')
181
219
  else
182
- # Neither rea CLI nor python3 reachable — silent no-op.
220
+ # Neither rea CLI nor python3 reachable — silent no-op for the
221
+ # augmenter, but still run consumer fragments. The chain doesn't
222
+ # need policy values; it just runs `.husky/prepare-commit-msg.d/*`.
223
+ run_extension_chain
183
224
  exit 0
184
225
  fi
185
226
 
186
227
  if [ "$ENABLED" != "true" ]; then
228
+ run_extension_chain
187
229
  exit 0
188
230
  fi
189
231
 
@@ -204,11 +246,13 @@ if [ -z "$CO_NAME" ] || [ -z "$CO_EMAIL" ]; then
204
246
  "$([ -z "$CO_NAME" ] && [ -z "$CO_EMAIL" ] && printf '+')" \
205
247
  "$([ -z "$CO_EMAIL" ] && printf email)" >&2
206
248
  printf 'rea: edit .rea/policy.yaml — set name + email, OR set enabled: false.\n' >&2
249
+ run_extension_chain
207
250
  exit 0
208
251
  fi
209
252
 
210
253
  # skip_merge: true → skip when commit source is 'merge'.
211
254
  if [ "$SKIP_MERGE" = "true" ] && [ "$COMMIT_SOURCE" = "merge" ]; then
255
+ run_extension_chain
212
256
  exit 0
213
257
  fi
214
258
 
@@ -226,6 +270,7 @@ LOWER_EMAIL=$(printf '%s' "$CO_EMAIL" | tr '[:upper:]' '[:lower:]')
226
270
  ESCAPED_EMAIL=$(printf '%s' "$LOWER_EMAIL" | sed 's/[.[\*^$(){}+?|]/\\&/g')
227
271
  if grep -iE "^co-authored-by:[[:space:]]*[^<]*<${ESCAPED_EMAIL}>[[:space:]]*$" \
228
272
  "$COMMIT_MSG_FILE" >/dev/null 2>&1; then
273
+ run_extension_chain
229
274
  exit 0
230
275
  fi
231
276
 
@@ -311,4 +356,33 @@ awk '
311
356
  } > "${COMMIT_MSG_FILE}.rea-tmp" && mv "${COMMIT_MSG_FILE}.rea-tmp" "$COMMIT_MSG_FILE"
312
357
 
313
358
  rm -f "$TMP_BODY_TRIMMED"
359
+
360
+ # ── Extension-hook chaining ───────────────────────────────────────────────────
361
+ # 0.32.0 — `.husky/prepare-commit-msg.d/*` extension surface mirrors
362
+ # the `.husky/commit-msg.d/*` and `.husky/pre-push.d/*` patterns from
363
+ # 0.13.0. Source every executable file under
364
+ # `.husky/prepare-commit-msg.d/` in lexical order. Missing directory
365
+ # is a no-op (backward compatible). Each fragment receives the same
366
+ # `$1` (commit message file path) and `$2` (commit source) that git
367
+ # delivered to this hook so consumers can layer on their own
368
+ # augmenters (lint-staged --on-prepare, branch-name-injection,
369
+ # ticket-reference-prepend, …) without losing rea coverage.
370
+ #
371
+ # Fragments run AFTER rea's attribution augmenter so the
372
+ # `Co-Authored-By` trailer is already in the file before any consumer
373
+ # fragment reads it; that lets a fragment reorder trailers, dedupe,
374
+ # or run its own template substitution against the augmented body.
375
+ #
376
+ # A non-zero exit from a fragment does NOT fail the commit — this
377
+ # hook is purely additive (its bash counterpart `commit-msg` is the
378
+ # blocking gate). We log the failure to stderr and continue so a
379
+ # broken consumer fragment can't take down `git commit`.
380
+ #
381
+ # The actual chain body lives in `run_extension_chain` (defined near
382
+ # the top of the file). The reason for the early definition: several
383
+ # augmenter-skip exit paths (enabled: false, missing identity, idempo-
384
+ # tency hit, skip_merge match) need to run the chain too, so consumer
385
+ # fragments fire regardless of whether rea's own augmenter activated.
386
+ run_extension_chain
387
+
314
388
  exit 0
@@ -0,0 +1,171 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: security-disclosure-gate.sh
3
+ # 0.32.0+ — Node-binary shim for `rea hook security-disclosure-gate`.
4
+ #
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.
12
+ #
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).
16
+ #
17
+ # # CLI-resolution trust boundary
18
+ #
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.
34
+
35
+ set -uo pipefail
36
+
37
+ # 1. HALT check.
38
+ # shellcheck source=_lib/halt-check.sh
39
+ source "$(dirname "$0")/_lib/halt-check.sh"
40
+ check_halt
41
+ REA_ROOT=$(rea_root)
42
+
43
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
44
+
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
64
+ exit 0
65
+ fi
66
+
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
75
+ exit 0
76
+ fi
77
+
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"
87
+ fi
88
+
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
100
+ fi
101
+
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
108
+
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
151
+
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
165
+ fi
166
+
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 $?
@@ -0,0 +1,58 @@
1
+ --- /Volumes/Development/booked/rea/.claude/hooks/settings-protection.sh 2026-05-12 21:19:51
2
+ +++ /Volumes/Development/booked/rea/hooks/settings-protection.sh 2026-05-14 23:50:17
3
+ @@ -172,10 +172,12 @@
4
+ LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
5
+
6
+ # ── 5b. Extension-surface allow-list ──────────────────────────────────────────
7
+ -# `.husky/commit-msg.d/*` and `.husky/pre-push.d/*` are the documented
8
+ -# consumer extension surface (Fix H / 0.13.0). Consumers — and the agents
9
+ -# that govern those consumers — are expected to write here freely so they
10
+ -# can layer commitlint, lint-staged, branch-policy, act-CI, etc. without
11
+ +# `.husky/commit-msg.d/*`, `.husky/pre-push.d/*`, and (0.32.0+)
12
+ +# `.husky/prepare-commit-msg.d/*` are the documented consumer
13
+ +# extension surface (Fix H / 0.13.0; Phase 3 / 0.32.0 for the
14
+ +# prepare-commit-msg lane). Consumers — and the agents that govern
15
+ +# those consumers — are expected to write here freely so they can
16
+ +# layer commitlint, lint-staged, branch-policy, act-CI, etc. without
17
+ # losing rea coverage on `rea upgrade`.
18
+ #
19
+ # The §6 PROTECTED_PATTERNS list below has `.husky/` as a prefix block,
20
+ @@ -216,14 +218,14 @@
21
+ # subshell pattern (no Python or readlink -f dependency required).
22
+ # Closes the path-string→symlink bypass completely.
23
+ case "$LOWER_NORM" in
24
+ - .husky/commit-msg.d/*|.husky/pre-push.d/*)
25
+ + .husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/prepare-commit-msg.d/*)
26
+ if [ -L "$FILE_PATH" ]; then
27
+ {
28
+ printf 'SETTINGS PROTECTION: symlink in extension surface refused\n'
29
+ printf '\n'
30
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
31
+ - printf ' Rule: .husky/commit-msg.d/* and .husky/pre-push.d/* must be\n'
32
+ - printf ' regular files (a symlink could resolve to a protected\n'
33
+ + printf ' Rule: .husky/{commit-msg,pre-push,prepare-commit-msg}.d/* must\n'
34
+ + printf ' be regular files (a symlink could resolve to a protected\n'
35
+ printf ' package-managed body and bypass §6 protection).\n'
36
+ } >&2
37
+ exit 2
38
+ @@ -245,8 +247,10 @@
39
+ # to `.husky/pre-push.d.bak/...` and slipped through.
40
+ # The trailing `/` on each pattern (and the explicit
41
+ # exact-match arm) requires a real directory boundary.
42
+ + # 0.32.0 Phase 3: `.husky/prepare-commit-msg.d/` joins the
43
+ + # allow-list (mirrors commit-msg.d/pre-push.d patterns).
44
+ case "$resolved_parent" in
45
+ - */.husky/commit-msg.d|*/.husky/commit-msg.d/*|*/.husky/pre-push.d|*/.husky/pre-push.d/*) : ;;
46
+ + */.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/*) : ;;
47
+ *)
48
+ {
49
+ printf 'SETTINGS PROTECTION: extension path resolves outside surface\n'
50
+ @@ -254,7 +258,7 @@
51
+ printf ' Logical: %s\n' "$SAFE_FILE_PATH"
52
+ printf ' Resolved: %s\n' "$resolved_parent"
53
+ printf ' Rule: an intermediate directory of the extension path is a\n'
54
+ - printf ' symlink whose target leaves .husky/{commit-msg,pre-push}.d/.\n'
55
+ + printf ' symlink whose target leaves .husky/{commit-msg,pre-push,prepare-commit-msg}.d/.\n'
56
+ printf ' Refused to prevent symlinked-parent bypass of the\n'
57
+ printf ' package-managed body protection.\n'
58
+ } >&2