@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
@@ -29,3 +29,14 @@ notification_channel: ''
29
29
  attribution:
30
30
  co_author:
31
31
  enabled: false
32
+ # 0.31.0 delegation-advisory nudge — disabled for open-source.
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. "You should delegate more"
36
+ # is an opinion not every OSS team shares, so external profiles ship
37
+ # `enabled: false` — opt in per-repo via .rea/policy.yaml:
38
+ # delegation_advisory:
39
+ # enabled: true
40
+ # threshold: 25
41
+ delegation_advisory:
42
+ enabled: false
@@ -0,0 +1,170 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: attribution-advisory.sh
3
+ # 0.32.0+ — Node-binary shim for `rea hook attribution-advisory`.
4
+ #
5
+ # Pre-0.32.0 the gate's full body lived here as bash (162 LOC,
6
+ # including the AI-attribution pattern catalog and segment-relevance
7
+ # gating). The migration to the parser-backed Node binary moves all
8
+ # of that into `src/hooks/attribution-advisory/index.ts`. This shim
9
+ # 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
+ # disabled-policy / non-relevant / clean-command, exit 2 on HALT /
14
+ # attribution detected / malformed payload (fail-closed).
15
+ #
16
+ # # CLI-resolution trust boundary
17
+ #
18
+ # Codex round 1 P1 (2026-05-15): realpath sandbox check + version
19
+ # probe. Mirrors delegation-advisory.sh §3. Defends against
20
+ # symlink-out + tarball-replacement attacks on the resolved CLI AND
21
+ # stale-node_modules version skew that would otherwise turn every
22
+ # Bash dispatch into a hard failure.
23
+
24
+ set -uo pipefail
25
+
26
+ # 1. HALT check.
27
+ # shellcheck source=_lib/halt-check.sh
28
+ source "$(dirname "$0")/_lib/halt-check.sh"
29
+ check_halt
30
+ REA_ROOT=$(rea_root)
31
+
32
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
33
+
34
+ # 2. Relevance pre-gate (0.32.0 round-5 P1, round-6 fix). PreToolUse
35
+ # Bash matchers fire on EVERY shell command, but this hook only
36
+ # enforces against `git commit` / `gh pr create|edit`. Capture
37
+ # stdin + check relevance FIRST so unrelated commands (ls,
38
+ # pnpm test, …) exit 0 even when the CLI is missing/stale/
39
+ # sandboxed-out.
40
+ #
41
+ # Match the pattern ANYWHERE in the command string (after the
42
+ # opening quote, then `[^"]*` for any leading shell prefix —
43
+ # `sudo`, `time`, env assignments like `FOO=x git commit …`).
44
+ # Round-6 P1: prior round-5 pattern anchored at the start of the
45
+ # JSON value and missed all prefixed forms.
46
+ INPUT=$(cat)
47
+ # Substring scan (NOT JSON-aware). Round-7 P2: any JSON-aware regex
48
+ # anchored on `"command":"...` gets tripped by escaped quotes in
49
+ # quoted env prefixes (`FOO="two words" git commit …` → the payload
50
+ # carries `\"two words\"` and `[^"]*` stops at the escaped quote).
51
+ # Plain substring match has no such edge: it over-triggers only on
52
+ # the rare case where the pattern appears inside a quoted argument
53
+ # (`echo "gh pr create"`), and the Node body handles that correctly.
54
+ # This hook only fires on `tool_name=Bash`, so we don't risk matching
55
+ # unrelated payload shapes.
56
+ RELEVANT=0
57
+ if printf '%s' "$INPUT" | grep -qE '(git[[:space:]]+commit|gh[[:space:]]+pr[[:space:]]+(create|edit))'; then
58
+ RELEVANT=1
59
+ fi
60
+ if [ "$RELEVANT" -eq 0 ]; then
61
+ # Irrelevant Bash call — nothing the pre-0.32.0 body would have
62
+ # processed. Always exit 0 regardless of CLI state.
63
+ exit 0
64
+ fi
65
+
66
+ # 2b. Policy short-circuit (round-6 P2). The pre-0.32.0 bash body
67
+ # no-op'd when `block_ai_attribution` was absent or false. Without
68
+ # this check, an unbuilt/stale install would refuse `git commit`
69
+ # even on repos that DELIBERATELY disable the attribution gate.
70
+ # Read the policy via a simple grep — the canonical loader
71
+ # handles inline forms but we only need block form here, and a
72
+ # conservative "true-and-only-true counts" rule matches the
73
+ # intent (false / absent / inline-only all → no enforcement).
74
+ POLICY_FILE="$REA_ROOT/.rea/policy.yaml"
75
+ if [ ! -f "$POLICY_FILE" ] || ! grep -qE '^block_ai_attribution:[[:space:]]*true([[:space:]]|$)' "$POLICY_FILE"; then
76
+ # Attribution blocking disabled — pre-0.32.0 bash body would have
77
+ # exited 0 here. Don't refuse on stale-install grounds.
78
+ exit 0
79
+ fi
80
+
81
+ # 3. Resolve the rea CLI.
82
+ REA_ARGV=()
83
+ RESOLVED_CLI_PATH=""
84
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
85
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
86
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
87
+ elif [ -f "$proj/dist/cli/index.js" ]; then
88
+ REA_ARGV=(node "$proj/dist/cli/index.js")
89
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
90
+ fi
91
+
92
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
93
+ # 0.32.0 round-4 P2: when `block_ai_attribution: true`, this hook is
94
+ # blocking-tier — the pre-0.32.0 bash body enforced the policy
95
+ # without a compiled CLI. Falling through to exit 0 would silently
96
+ # let AI-attribution patterns through every git commit / gh pr
97
+ # create-or-edit until the operator rebuilds. Fail closed and tell
98
+ # the operator how to restore protection.
99
+ printf 'rea: attribution-advisory cannot run — the rea CLI is not built.\n' >&2
100
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
101
+ printf 'This shim fails closed because the pre-0.32.0 bash body enforced attribution policy without a CLI.\n' >&2
102
+ exit 2
103
+ fi
104
+
105
+ # 3. Realpath sandbox check.
106
+ if ! command -v node >/dev/null 2>&1; then
107
+ printf 'rea: attribution-advisory cannot run — `node` is not on PATH.\n' >&2
108
+ printf 'Install Node 22+ (engines.node) to restore enforcement.\n' >&2
109
+ exit 2
110
+ fi
111
+
112
+ sandbox_check=$(node -e '
113
+ const fs = require("fs");
114
+ const path = require("path");
115
+ const cli = process.argv[1];
116
+ const projDir = process.argv[2];
117
+ let real, realProj;
118
+ try { real = fs.realpathSync(cli); } catch (e) {
119
+ process.stdout.write("bad:realpath"); process.exit(1);
120
+ }
121
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
122
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
123
+ }
124
+ const sep = path.sep;
125
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
126
+ if (!(real === realProj || real.startsWith(projWithSep))) {
127
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
128
+ }
129
+ let cur = path.dirname(path.dirname(path.dirname(real)));
130
+ let found = false;
131
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
132
+ const pj = path.join(cur, "package.json");
133
+ if (fs.existsSync(pj)) {
134
+ try {
135
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
136
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
137
+ } catch (e) { /* keep walking */ }
138
+ }
139
+ cur = path.dirname(cur);
140
+ }
141
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
142
+ process.stdout.write("ok");
143
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
144
+
145
+ if [ "$sandbox_check" != "ok" ]; then
146
+ # 0.32.0 round-4 P2: fail closed (blocking-tier when policy enables —
147
+ # see top-of-file rationale). Sandbox failure means the CLI cannot
148
+ # be authenticated; refuse rather than silently bypass.
149
+ printf 'rea: attribution-advisory FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
150
+ exit 2
151
+ fi
152
+
153
+ # 4. Version-probe: confirm the resolved CLI implements
154
+ # `hook attribution-advisory`. Codex round 1 P1.
155
+ probe_out=$("${REA_ARGV[@]}" hook attribution-advisory --help 2>&1)
156
+ probe_status=$?
157
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'attribution-advisory'; then
158
+ # 0.32.0 round-4 P2: stale/older CLI without the new subcommand is
159
+ # NOT advisory-tier fall-through — the bash body it replaces
160
+ # enforced when policy enabled. Fail closed and tell the operator
161
+ # exactly how to fix.
162
+ printf 'rea: this shim requires the `rea hook attribution-advisory` subcommand (introduced in 0.32.0).\n' >&2
163
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
164
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
165
+ exit 2
166
+ fi
167
+
168
+ # 5. Forward stdin (already captured up-front for the relevance gate).
169
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook attribution-advisory
170
+ 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 $?