@bookedsolid/rea 0.37.0 → 0.38.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 (35) hide show
  1. package/hooks/_lib/shim-runtime.sh +405 -0
  2. package/hooks/architecture-review-gate.sh +11 -103
  3. package/hooks/attribution-advisory.sh +38 -209
  4. package/hooks/blocked-paths-bash-gate.sh +32 -146
  5. package/hooks/blocked-paths-enforcer.sh +32 -137
  6. package/hooks/changeset-security-gate.sh +26 -119
  7. package/hooks/dangerous-bash-interceptor.sh +46 -170
  8. package/hooks/delegation-advisory.sh +26 -144
  9. package/hooks/delegation-capture.sh +33 -139
  10. package/hooks/dependency-audit-gate.sh +29 -121
  11. package/hooks/env-file-protection.sh +30 -141
  12. package/hooks/local-review-gate.sh +117 -352
  13. package/hooks/pr-issue-link-gate.sh +16 -118
  14. package/hooks/protected-paths-bash-gate.sh +53 -152
  15. package/hooks/secret-scanner.sh +90 -213
  16. package/hooks/security-disclosure-gate.sh +32 -155
  17. package/hooks/settings-protection.sh +56 -176
  18. package/package.json +1 -1
  19. package/templates/_lib_shim-runtime.dogfood-staged.sh +405 -0
  20. package/templates/architecture-review-gate.dogfood-staged.sh +11 -103
  21. package/templates/attribution-advisory.dogfood-staged.sh +38 -209
  22. package/templates/blocked-paths-bash-gate.dogfood-staged.sh +32 -146
  23. package/templates/blocked-paths-enforcer.dogfood-staged.sh +32 -137
  24. package/templates/changeset-security-gate.dogfood-staged.sh +26 -119
  25. package/templates/dangerous-bash-interceptor.dogfood-staged.sh +46 -170
  26. package/templates/delegation-advisory.dogfood-staged.sh +44 -0
  27. package/templates/delegation-capture.dogfood-staged.sh +52 -0
  28. package/templates/dependency-audit-gate.dogfood-staged.sh +29 -121
  29. package/templates/env-file-protection.dogfood-staged.sh +30 -141
  30. package/templates/local-review-gate.dogfood-staged.sh +117 -352
  31. package/templates/pr-issue-link-gate.dogfood-staged.sh +16 -118
  32. package/templates/protected-paths-bash-gate.dogfood-staged.sh +53 -152
  33. package/templates/secret-scanner.dogfood-staged.sh +90 -213
  34. package/templates/security-disclosure-gate.dogfood-staged.sh +32 -155
  35. package/templates/settings-protection.dogfood-staged.sh +56 -176
@@ -1,240 +1,117 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: secret-scanner.sh
3
3
  # 0.34.0+ — Node-binary shim for `rea hook secret-scanner`.
4
+ # 0.38.0+ — migrated to `_lib/shim-runtime.sh` (shared runtime).
4
5
  #
5
- # Pre-0.34.0 the gate's full body lived here as bash (230 LOC, the
6
- # awk line filter + 17-pattern catalog + placeholder-rejection + the
7
- # MultiEdit fragment join). The migration to the Node binary moves
8
- # the pattern catalog + filter + placeholder evaluation into
9
- # `src/hooks/secret-scanner/index.ts`. This shim is the Claude Code
10
- # dispatcher's view of the hook — it forwards stdin to the CLI and
11
- # exits with whatever the CLI returns.
6
+ # Pre-0.34.0 the gate's full body lived here as bash (230 LOC, awk
7
+ # line filter + 17-pattern catalog + placeholder-rejection + MultiEdit
8
+ # fragment join). Migration in `src/hooks/secret-scanner/index.ts`.
9
+ # Behavioral contract preserved byte-for-byte: exit 0 on no-match or
10
+ # MEDIUM-only advisory, exit 2 on HALT / HIGH match / malformed payload.
12
11
  #
13
- # Behavioral contract is preserved byte-for-byte: exit 0 on no-match
14
- # or MEDIUM-only advisory, exit 2 on HALT / HIGH match / malformed
15
- # payload.
12
+ # # Shim short-circuits (codex round-1 P2 fix from 0.34.0)
16
13
  #
17
- # # Shim short-circuits (codex round-1 P2 fix)
18
- #
19
- # The 0.34.0 round-0 shim deferred ALL decisions to the CLI, including
20
- # empty-content and `.env.example` suffix exclusion. That regressed
21
- # benign workflows on fresh/unbuilt installs: clearing a file or
22
- # editing an example env file would fail closed when `dist/cli/index.js`
23
- # wasn't built yet.
24
- #
25
- # Round-1 P2 fix: replicate the pre-0.34.0 bash body's three
26
- # short-circuits in the shim BEFORE CLI resolution:
14
+ # Replicate the pre-0.34.0 bash body's two short-circuits BEFORE CLI
15
+ # resolution:
27
16
  # - Empty content (no `content`, `new_string`, `edits[]`, or
28
- # `new_source` in the payload) → exit 0 silently.
17
+ # `new_source` in the payload) → exit 0.
29
18
  # - file_path / notebook_path with `.env.example` or `.env.sample`
30
- # suffix → exit 0 silently.
31
- # The full pattern catalog + filter + placeholder rejection still
32
- # lives in the CLI.
33
- #
34
- # # CLI-resolution trust boundary
19
+ # suffix → exit 0.
20
+ # This unblocks workflows on fresh/unbuilt installs (clearing a file
21
+ # or editing an example env file would otherwise fail closed).
35
22
  #
36
- # Mirrors the 0.32.0 final shim shape.
23
+ # # CLI-missing relevance scan (round-7 P1)
37
24
  #
38
- # # Fail-closed posture
39
- #
40
- # secret-scanner is Write/Edit/MultiEdit/NotebookEdit tier — the
41
- # pre-0.34.0 bash body refused credential-bearing writes without any
42
- # compiled CLI. Early-exit branches fail closed AFTER the shim
43
- # short-circuits.
25
+ # When the CLI is missing AND content contains a credential marker
26
+ # from the catalog, preserve fail-closed. When no marker matches,
27
+ # exit 0 (pre-port body would have allowed).
44
28
 
45
29
  set -uo pipefail
46
30
 
47
- # 1. HALT check.
48
31
  # shellcheck source=_lib/halt-check.sh
49
32
  source "$(dirname "$0")/_lib/halt-check.sh"
50
33
  check_halt
51
34
  REA_ROOT=$(rea_root)
52
35
 
53
- proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
36
+ SHIM_NAME="secret-scanner"
37
+ SHIM_INTRODUCED_IN="0.34.0"
38
+ SHIM_FAIL_OPEN=0
39
+ SHIM_REFUSAL_NOUN="credential refusal"
54
40
 
55
- # 2. Capture stdin once.
56
- INPUT=$(cat)
41
+ # Module-level: populated by shim_is_relevant for use by
42
+ # shim_cli_missing_relevant (avoids re-parsing INPUT via jq twice).
43
+ _SS_CONTENT=""
44
+ _SS_FILE_PATH=""
45
+ _SS_JQ_PARSE_CLEAN=0
57
46
 
58
- # 3. Short-circuit: empty-content / file-suffix exclusion. Mirrors
59
- # the pre-0.34.0 bash body's `[[ -z "$CONTENT" ]] && exit 0` and
60
- # the `*.env.example | *.env.sample` suffix check. We do these in
61
- # the shim so unbuilt installs don't fail closed on benign writes.
62
- if command -v jq >/dev/null 2>&1; then
63
- # Compose content the same way `parseWriteHookPayload` does:
64
- # priority content > new_string > join(edits[].new_string) > new_source.
65
- # 0.34.0 round-2 fix: every value goes through `tostring` so a
66
- # non-string `new_string` (object/number/null) doesn't trip jq with
67
- # a "Cannot iterate" error → empty CONTENT → exit 0 bypass. Mirrors
68
- # the 0.14.0 secret-scanner fix that originally closed this class.
69
- #
70
- # 0.34.0 round-4 P2 fix: capture jq's exit code SEPARATELY rather
71
- # than swallowing it with `|| true`. Pre-fix, invalid JSON or a
72
- # schema mismatch yielded empty CONTENT → exit 0 silent allow.
73
- # Post-fix we distinguish:
74
- # - jq exit 0 + empty CONTENT → valid payload, no content (the
75
- # bash hook also exit 0'd here)
76
- # - jq exit 0 + non-empty → enter suffix-check + CLI forward
77
- # - jq exit != 0 (parse fail) → fall through to CLI forward;
78
- # the CLI re-parses with Zod and
79
- # refuses on malformed payload
80
- # The third branch does NOT exit 0 — we want CLI enforcement to
81
- # decide. The CLI's parser fails closed.
82
- CONTENT=$(printf '%s' "$INPUT" | jq -r '
83
- (.tool_input.content // .tool_input.new_string //
84
- (
85
- if (.tool_input.edits | type) == "array"
86
- then (.tool_input.edits | map((.new_string // "") | tostring) | join("\n"))
87
- else ""
88
- end
89
- ) //
90
- .tool_input.new_source // ""
91
- ) | tostring
92
- ' 2>/dev/null)
93
- jq_content_status=$?
94
- FILE_PATH=$(printf '%s' "$INPUT" | jq -r '
95
- .tool_input.file_path // .tool_input.notebook_path // ""
96
- ' 2>/dev/null)
97
- jq_path_status=$?
98
- # Only honor the shim short-circuits when BOTH jq probes parsed
99
- # cleanly. Otherwise forward to the CLI which fails closed via Zod.
100
- if [ "$jq_content_status" -eq 0 ] && [ "$jq_path_status" -eq 0 ]; then
101
- if [ -z "$CONTENT" ]; then
102
- exit 0
47
+ shim_is_relevant() {
48
+ # Two short-circuits: empty content, and *.env.example / *.env.sample
49
+ # suffix. Only honored when BOTH jq probes parse cleanly; on parse
50
+ # failure fall through to the CLI which fails closed via Zod.
51
+ if command -v jq >/dev/null 2>&1; then
52
+ # 0.34.0 round-2 fix: tostring so non-string `new_string`
53
+ # (object/number/null) doesn't trip jq with "Cannot iterate".
54
+ _SS_CONTENT=$(printf '%s' "$INPUT" | jq -r '
55
+ (.tool_input.content // .tool_input.new_string //
56
+ (
57
+ if (.tool_input.edits | type) == "array"
58
+ then (.tool_input.edits | map((.new_string // "") | tostring) | join("\n"))
59
+ else ""
60
+ end
61
+ ) //
62
+ .tool_input.new_source // ""
63
+ ) | tostring
64
+ ' 2>/dev/null)
65
+ local jq_content_status=$?
66
+ _SS_FILE_PATH=$(printf '%s' "$INPUT" | jq -r '
67
+ .tool_input.file_path // .tool_input.notebook_path // ""
68
+ ' 2>/dev/null)
69
+ local jq_path_status=$?
70
+ if [ "$jq_content_status" -eq 0 ] && [ "$jq_path_status" -eq 0 ]; then
71
+ _SS_JQ_PARSE_CLEAN=1
72
+ if [ -z "$_SS_CONTENT" ]; then
73
+ # Empty content — pre-port body exit 0.
74
+ return 1
75
+ fi
76
+ case "$_SS_FILE_PATH" in
77
+ *.env.example|*.env.sample) return 1 ;;
78
+ esac
103
79
  fi
104
- # Suffix-based exclusion. Mirrors the bash hook's:
105
- # if [[ "$FILE_PATH" == *.env.example || "$FILE_PATH" == *.env.sample ]]; then exit 0; fi
106
- case "$FILE_PATH" in
107
- *.env.example|*.env.sample) exit 0 ;;
108
- esac
109
80
  fi
110
- # jq parse failure do NOT short-circuit. Fall through to the CLI
111
- # forward at section 7. The CLI will refuse on malformed payload.
112
- fi
113
- # When jq is unavailable, fall through — the CLI does the same parse
114
- # in TypeScript-space and will short-circuit on empty content there.
115
-
116
- # 4. Resolve the rea CLI through the fixed 2-tier sandboxed order.
117
- REA_ARGV=()
118
- RESOLVED_CLI_PATH=""
119
- if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
120
- REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
121
- RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
122
- elif [ -f "$proj/dist/cli/index.js" ]; then
123
- REA_ARGV=(node "$proj/dist/cli/index.js")
124
- RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
125
- fi
81
+ # Either jq missing OR jq parse failure OR non-excluded payload relevant.
82
+ return 0
83
+ }
126
84
 
127
- if [ "${#REA_ARGV[@]}" -eq 0 ]; then
128
- # 4b. Relevance pre-gate (round-7 P1). The round-0 shim refused ALL
129
- # writes when the CLI was missing, but the pre-0.34.0 bash body
130
- # only refused writes containing credential patterns. On a fresh
131
- # install (`npx rea init` flow, pre-`pnpm build` checkout) the
132
- # CLI isn't built yet but consumers need to write files — config,
133
- # source, docs, etc. Fix: substring scan the content for the
134
- # credential markers in the catalog. When CLI is missing AND no
135
- # marker matches, exit 0 (the pre-0.34.0 body would have done
136
- # the same — no pattern hit). When CLI is missing AND a marker
137
- # DOES match, preserve fail-closed (refuse rather than silently
138
- # allow a credential-shaped write).
139
- #
140
- # Substrings cover every entry in SECRET_PATTERNS (catalog in
141
- # `src/hooks/secret-scanner/index.ts`). Coarse — over-trigger is
142
- # fine, under-trigger is the bypass we MUST avoid. Same posture
143
- # as the round-7 dangerous-bash relevance pre-gate.
144
- CONTENT_FOR_SCAN=""
145
- if [ -n "${CONTENT:-}" ]; then
146
- CONTENT_FOR_SCAN="$CONTENT"
85
+ shim_cli_missing_relevant() {
86
+ # 0.34.0 round-7 P1: when the CLI is missing AND the content carries
87
+ # a credential marker, preserve fail-closed. When no marker matches,
88
+ # the pre-port bash body would have allowed.
89
+ local content_for_scan
90
+ if [ -n "$_SS_CONTENT" ]; then
91
+ content_for_scan="$_SS_CONTENT"
147
92
  else
148
- # CONTENT may not have been populated (jq missing, parse failure).
149
- # Fall back to the raw payload so the substring scan still catches
150
- # credential markers embedded in JSON-string form.
151
- CONTENT_FOR_SCAN="$INPUT"
93
+ # jq missing or parse-failed substring scan the raw payload.
94
+ content_for_scan="$INPUT"
152
95
  fi
153
- CRED_RELEVANT=0
154
- case "$CONTENT_FOR_SCAN" in
155
- *"AKIA"*) CRED_RELEVANT=1 ;;
156
- *"AWS_SECRET_ACCESS_KEY"*|*"aws_secret_access_key"*) CRED_RELEVANT=1 ;;
157
- *"-----BEGIN"*) CRED_RELEVANT=1 ;;
158
- *"sk-ant-"*) CRED_RELEVANT=1 ;;
159
- *"ghp_"*|*"ghs_"*|*"gho_"*|*"ghu_"*|*"ghr_"*) CRED_RELEVANT=1 ;;
160
- *"github_pat_"*) CRED_RELEVANT=1 ;;
161
- *"sk_live_"*|*"rk_live_"*|*"pk_live_"*) CRED_RELEVANT=1 ;;
162
- *"sk_test_"*|*"rk_test_"*|*"pk_test_"*) CRED_RELEVANT=1 ;;
163
- *"whsec_"*) CRED_RELEVANT=1 ;;
164
- *"SECRET"*|*"PASSWORD"*|*"PRIVATE_KEY"*|*"API_SECRET"*) CRED_RELEVANT=1 ;;
165
- *"SUPABASE_SERVICE_ROLE_KEY"*|*"SUPABASE_ANON_KEY"*) CRED_RELEVANT=1 ;;
166
- *"ANTHROPIC_API_KEY"*|*"STRIPE_SECRET"*|*"DATABASE_URL"*) CRED_RELEVANT=1 ;;
167
- *"postgresql://"*) CRED_RELEVANT=1 ;;
168
- *"eyJ"*) CRED_RELEVANT=1 ;; # JWT prefix — catches Supabase keys
96
+ case "$content_for_scan" in
97
+ *"AKIA"*) return 0 ;;
98
+ *"AWS_SECRET_ACCESS_KEY"*|*"aws_secret_access_key"*) return 0 ;;
99
+ *"-----BEGIN"*) return 0 ;;
100
+ *"sk-ant-"*) return 0 ;;
101
+ *"ghp_"*|*"ghs_"*|*"gho_"*|*"ghu_"*|*"ghr_"*) return 0 ;;
102
+ *"github_pat_"*) return 0 ;;
103
+ *"sk_live_"*|*"rk_live_"*|*"pk_live_"*) return 0 ;;
104
+ *"sk_test_"*|*"rk_test_"*|*"pk_test_"*) return 0 ;;
105
+ *"whsec_"*) return 0 ;;
106
+ *"SECRET"*|*"PASSWORD"*|*"PRIVATE_KEY"*|*"API_SECRET"*) return 0 ;;
107
+ *"SUPABASE_SERVICE_ROLE_KEY"*|*"SUPABASE_ANON_KEY"*) return 0 ;;
108
+ *"ANTHROPIC_API_KEY"*|*"STRIPE_SECRET"*|*"DATABASE_URL"*) return 0 ;;
109
+ *"postgresql://"*) return 0 ;;
110
+ *"eyJ"*) return 0 ;;
169
111
  esac
170
- if [ "$CRED_RELEVANT" -eq 0 ]; then
171
- # No credential marker. The pre-0.34.0 bash body would have allowed
172
- # this write — exit 0 to unblock `npx rea init` and pre-build
173
- # checkouts.
174
- exit 0
175
- fi
176
- # Credential marker matched. Preserve fail-closed posture.
177
- printf 'rea: secret-scanner cannot run — the rea CLI is not built.\n' >&2
178
- printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
179
- printf 'This shim fails closed because the pre-0.34.0 bash body enforced secret refusal without a CLI.\n' >&2
180
- exit 2
181
- fi
182
-
183
- # 5. Realpath sandbox check.
184
- if ! command -v node >/dev/null 2>&1; then
185
- printf 'rea: secret-scanner cannot run — `node` is not on PATH.\n' >&2
186
- printf 'Install Node 22+ (engines.node) to restore credential refusal.\n' >&2
187
- exit 2
188
- fi
189
-
190
- sandbox_check=$(node -e '
191
- const fs = require("fs");
192
- const path = require("path");
193
- const cli = process.argv[1];
194
- const projDir = process.argv[2];
195
- let real, realProj;
196
- try { real = fs.realpathSync(cli); } catch (e) {
197
- process.stdout.write("bad:realpath"); process.exit(1);
198
- }
199
- try { realProj = fs.realpathSync(projDir); } catch (e) {
200
- process.stdout.write("bad:realpath-proj"); process.exit(1);
201
- }
202
- const sep = path.sep;
203
- const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
204
- if (!(real === realProj || real.startsWith(projWithSep))) {
205
- process.stdout.write("bad:cli-escapes-project"); process.exit(1);
206
- }
207
- let cur = path.dirname(path.dirname(path.dirname(real)));
208
- let found = false;
209
- for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
210
- const pj = path.join(cur, "package.json");
211
- if (fs.existsSync(pj)) {
212
- try {
213
- const data = JSON.parse(fs.readFileSync(pj, "utf8"));
214
- if (data && data.name === "@bookedsolid/rea") { found = true; break; }
215
- } catch (e) { /* keep walking */ }
216
- }
217
- cur = path.dirname(cur);
218
- }
219
- if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
220
- process.stdout.write("ok");
221
- ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
222
-
223
- if [ "$sandbox_check" != "ok" ]; then
224
- printf 'rea: secret-scanner FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
225
- exit 2
226
- fi
227
-
228
- # 6. Version-probe.
229
- probe_out=$("${REA_ARGV[@]}" hook secret-scanner --help 2>&1)
230
- probe_status=$?
231
- if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'secret-scanner'; then
232
- printf 'rea: this shim requires the `rea hook secret-scanner` subcommand (introduced in 0.34.0).\n' >&2
233
- printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
234
- printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
235
- exit 2
236
- fi
112
+ return 1
113
+ }
237
114
 
238
- # 7. Forward stdin (already captured up-front).
239
- printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook secret-scanner
240
- exit $?
115
+ # shellcheck source=_lib/shim-runtime.sh
116
+ source "$(dirname "$0")/_lib/shim-runtime.sh"
117
+ shim_run
@@ -1,171 +1,48 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: security-disclosure-gate.sh
3
3
  # 0.32.0+ — Node-binary shim for `rea hook security-disclosure-gate`.
4
+ # 0.38.0+ — migrated to `_lib/shim-runtime.sh` (shared runtime).
4
5
  #
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
+ # Blocking-tier: refuses `gh issue create` payloads carrying
7
+ # disclosure keywords. Pre-port body was 339 LOC; migration in
8
+ # `src/hooks/security-disclosure-gate/index.ts`.
12
9
  #
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).
10
+ # # Relevance pre-gate
16
11
  #
17
- # # CLI-resolution trust boundary
12
+ # Substring scan for `gh issue create`. Plain (NOT JSON-aware) so
13
+ # escaped quotes in quoted env prefixes don't break the match.
18
14
  #
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.
15
+ # # Mode short-circuit (round-6 P2)
26
16
  #
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.
17
+ # `REA_DISCLOSURE_MODE=disabled` exits 0 pre-port body no-op'd only
18
+ # in that mode (advisory + issues modes both enforced). This runs
19
+ # BEFORE sandbox check because it reads an env-var, no policy/CLI.
34
20
 
35
21
  set -uo pipefail
36
22
 
37
- # 1. HALT check.
38
23
  # shellcheck source=_lib/halt-check.sh
39
24
  source "$(dirname "$0")/_lib/halt-check.sh"
40
25
  check_halt
41
26
  REA_ROOT=$(rea_root)
42
27
 
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 $?
28
+ SHIM_NAME="security-disclosure-gate"
29
+ SHIM_INTRODUCED_IN="0.32.0"
30
+ SHIM_FAIL_OPEN=0
31
+ SHIM_REFUSAL_NOUN="disclosure-policy enforcement"
32
+
33
+ shim_is_relevant() {
34
+ if ! printf '%s' "$INPUT" | grep -qE 'gh[[:space:]]+issue[[:space:]]+create'; then
35
+ return 1
36
+ fi
37
+ # Mode short-circuit: REA_DISCLOSURE_MODE=disabled bypasses BEFORE
38
+ # any CLI work. Implemented inline (no policy read needed).
39
+ local mode="${REA_DISCLOSURE_MODE:-advisory}"
40
+ if [ "$mode" = "disabled" ]; then
41
+ return 1
42
+ fi
43
+ return 0
44
+ }
45
+
46
+ # shellcheck source=_lib/shim-runtime.sh
47
+ source "$(dirname "$0")/_lib/shim-runtime.sh"
48
+ shim_run