@bookedsolid/rea 0.36.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 (37) hide show
  1. package/hooks/_lib/policy-reader.sh +948 -0
  2. package/hooks/_lib/shim-runtime.sh +405 -0
  3. package/hooks/architecture-review-gate.sh +11 -103
  4. package/hooks/attribution-advisory.sh +43 -155
  5. package/hooks/blocked-paths-bash-gate.sh +35 -149
  6. package/hooks/blocked-paths-enforcer.sh +35 -140
  7. package/hooks/changeset-security-gate.sh +26 -119
  8. package/hooks/dangerous-bash-interceptor.sh +46 -170
  9. package/hooks/delegation-advisory.sh +26 -144
  10. package/hooks/delegation-capture.sh +33 -139
  11. package/hooks/dependency-audit-gate.sh +29 -121
  12. package/hooks/env-file-protection.sh +30 -141
  13. package/hooks/local-review-gate.sh +191 -396
  14. package/hooks/pr-issue-link-gate.sh +16 -118
  15. package/hooks/protected-paths-bash-gate.sh +57 -160
  16. package/hooks/secret-scanner.sh +90 -213
  17. package/hooks/security-disclosure-gate.sh +32 -155
  18. package/hooks/settings-protection.sh +56 -179
  19. package/package.json +1 -1
  20. package/templates/_lib_policy-reader.dogfood-staged.sh +948 -0
  21. package/templates/_lib_shim-runtime.dogfood-staged.sh +405 -0
  22. package/templates/architecture-review-gate.dogfood-staged.sh +11 -103
  23. package/templates/attribution-advisory.dogfood-staged.sh +43 -155
  24. package/templates/blocked-paths-bash-gate.dogfood-staged.sh +35 -149
  25. package/templates/blocked-paths-enforcer.dogfood-staged.sh +35 -140
  26. package/templates/changeset-security-gate.dogfood-staged.sh +26 -119
  27. package/templates/dangerous-bash-interceptor.dogfood-staged.sh +46 -170
  28. package/templates/delegation-advisory.dogfood-staged.sh +44 -0
  29. package/templates/delegation-capture.dogfood-staged.sh +52 -0
  30. package/templates/dependency-audit-gate.dogfood-staged.sh +29 -121
  31. package/templates/env-file-protection.dogfood-staged.sh +30 -141
  32. package/templates/local-review-gate.dogfood-staged.sh +191 -396
  33. package/templates/pr-issue-link-gate.dogfood-staged.sh +16 -118
  34. package/templates/protected-paths-bash-gate.dogfood-staged.sh +57 -160
  35. package/templates/secret-scanner.dogfood-staged.sh +90 -213
  36. package/templates/security-disclosure-gate.dogfood-staged.sh +32 -155
  37. package/templates/settings-protection.dogfood-staged.sh +56 -179
@@ -1,170 +1,58 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: attribution-advisory.sh
3
3
  # 0.32.0+ — Node-binary shim for `rea hook attribution-advisory`.
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 (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.
6
+ # Blocking-tier when `policy.block_ai_attribution: true`. Pre-port body
7
+ # was 162 LOC; full migration in `src/hooks/attribution-advisory/index.ts`.
11
8
  #
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).
9
+ # # Relevance pre-gate
15
10
  #
16
- # # CLI-resolution trust boundary
11
+ # Substring match for `git commit` or `gh pr create|edit` ANYWHERE in
12
+ # the command string (allow shell prefixes). Plain substring scan is
13
+ # used instead of JSON-aware regex because escaped quotes in quoted
14
+ # env prefixes (`MODE="x" gh pr create …`) trip JSON-anchored patterns.
15
+ # Over-trigger costs one CLI spawn; the Node body handles correctness.
17
16
  #
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.
17
+ # # Policy short-circuit (codex round 2 P1 from 0.37.0)
18
+ #
19
+ # The block_ai_attribution policy read runs AFTER the sandbox check so
20
+ # REA_ARGV is trusted for Tier-1 reads. When the policy is disabled,
21
+ # exit 0 cleanly the pre-port bash body no-op'd when the key was
22
+ # absent or false.
23
23
 
24
24
  set -uo pipefail
25
25
 
26
- # 1. HALT check.
27
26
  # shellcheck source=_lib/halt-check.sh
28
27
  source "$(dirname "$0")/_lib/halt-check.sh"
29
28
  check_halt
30
29
  REA_ROOT=$(rea_root)
31
30
 
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 $?
31
+ SHIM_NAME="attribution-advisory"
32
+ SHIM_INTRODUCED_IN="0.32.0"
33
+ SHIM_FAIL_OPEN=0
34
+ SHIM_REFUSAL_NOUN="attribution-policy enforcement"
35
+
36
+ shim_is_relevant() {
37
+ if printf '%s' "$INPUT" | grep -qE '(git[[:space:]]+commit|gh[[:space:]]+pr[[:space:]]+(create|edit))'; then
38
+ return 0
39
+ fi
40
+ return 1
41
+ }
42
+
43
+ shim_policy_short_circuit() {
44
+ # shellcheck source=_lib/policy-reader.sh
45
+ source "$(dirname "$0")/_lib/policy-reader.sh"
46
+ local attr_enabled
47
+ attr_enabled=$(policy_reader_get block_ai_attribution)
48
+ if [ "$attr_enabled" != "true" ]; then
49
+ # Attribution blocking disabled (or unreadable on Tier 3 fallback +
50
+ # missing policy file) pre-port body exit 0.
51
+ return 0
52
+ fi
53
+ return 1
54
+ }
55
+
56
+ # shellcheck source=_lib/shim-runtime.sh
57
+ source "$(dirname "$0")/_lib/shim-runtime.sh"
58
+ shim_run
@@ -1,177 +1,63 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: blocked-paths-bash-gate.sh
3
3
  # 0.35.0+ — Node-binary shim for `rea hook blocked-paths-bash-gate`.
4
+ # 0.38.0+ — migrated to `_lib/shim-runtime.sh` (shared runtime).
4
5
  #
5
- # Pre-0.35.0 this was a thin bash shim over `rea hook scan-bash --mode
6
- # blocked` (the parser-backed AST walker that closes 9 bypass classes
7
- # from helix-023 + discord-ops Round 13 — see `src/hooks/bash-scanner/`).
8
- # The full bash body is preserved at
6
+ # Tier-1 Bash gate. Full bash body preserved at
9
7
  # `__tests__/hooks/parity/baselines/blocked-paths-bash-gate.sh.pre-0.35.0`.
8
+ # Migration lives in `src/hooks/blocked-paths-bash-gate/index.ts`.
10
9
  #
11
- # This shim now resolves the CLI through the same 2-tier sandboxed
12
- # resolver as the 0.32.0+ pilots and calls `rea hook blocked-paths-
13
- # bash-gate` directly — eliminating the shim → CLI → scanner-module
14
- # subprocess hop entirely.
10
+ # SHIM_ENFORCE_CLI_SHAPE=1: 0.35.0 codex round-1 P1 enforce
11
+ # dist/cli/index.js shape on the resolved CLI.
15
12
  #
16
- # Behavioral contract is preserved byte-for-byte: exit 0 on allow,
17
- # exit 2 on HALT / verdict block / malformed payload / sandbox fail.
13
+ # # Relevance pre-gate (CLI-missing only)
18
14
  #
19
- # # CLI-resolution trust boundary
20
- #
21
- # Mirrors the 0.32.0 final shim shape. The resolved CLI MUST live
22
- # INSIDE realpath(CLAUDE_PROJECT_DIR) AND have an ancestor
23
- # `package.json` whose `name` is `@bookedsolid/rea`. Defends against
24
- # symlink-out and tarball-replacement attacks on the resolved CLI.
25
- #
26
- # # Fail-closed posture
27
- #
28
- # blocked-paths-bash-gate is a Tier-1 security gate (PreToolUse Bash).
29
- # The pre-0.35.0 bash body refused on uncertainty for every failure
30
- # class. Early-exit branches (CLI missing, node missing, sandbox failed,
31
- # version skew) fail closed AFTER the relevance pre-gate passes.
32
- # Irrelevant Bash calls exit 0 regardless of CLI state.
33
- #
34
- # # Relevance pre-gate
35
- #
36
- # Same posture as 0.34.0 dangerous-bash + secret-scanner. When the CLI
37
- # is missing, refuse only when the extracted command MENTIONS a path
38
- # from `policy.blocked_paths`. Empty policy → no enforcement, exit 0.
39
- # This unblocks the install path itself: `npx rea init`, pre-`pnpm build`
40
- # checkouts can still run benign Bash like `ls`/`mkdir`/`pnpm install`.
15
+ # Substring scan over the extracted command against any
16
+ # policy.blocked_paths entry. Empty/missing policy → no enforcement,
17
+ # exit 0 (matches the pre-port bash body's allow-on-no-policy posture).
41
18
 
42
19
  set -uo pipefail
43
20
 
44
- # 1. HALT check.
45
21
  # shellcheck source=_lib/halt-check.sh
46
22
  source "$(dirname "$0")/_lib/halt-check.sh"
47
23
  check_halt
48
24
  REA_ROOT=$(rea_root)
49
25
 
50
- proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
51
-
52
- # 2. Capture stdin once.
53
- INPUT=$(cat)
26
+ SHIM_NAME="blocked-paths-bash-gate"
27
+ SHIM_INTRODUCED_IN="0.35.0"
28
+ SHIM_FAIL_OPEN=0
29
+ SHIM_ENFORCE_CLI_SHAPE=1
30
+ SHIM_REFUSAL_NOUN="blocked_paths refusal"
54
31
 
55
- # 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
56
- REA_ARGV=()
57
- RESOLVED_CLI_PATH=""
58
- if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
59
- REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
60
- RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
61
- elif [ -f "$proj/dist/cli/index.js" ]; then
62
- REA_ARGV=(node "$proj/dist/cli/index.js")
63
- RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
64
- fi
65
-
66
- # 3b. Relevance pre-gate. Only used when the CLI is missing.
67
- if [ "${#REA_ARGV[@]}" -eq 0 ]; then
68
- CLI_MISSING_CMD=""
32
+ shim_cli_missing_relevant() {
33
+ local cli_missing_cmd=""
69
34
  if command -v jq >/dev/null 2>&1; then
70
- CLI_MISSING_CMD=$(printf '%s' "$INPUT" | jq -r '
35
+ cli_missing_cmd=$(printf '%s' "$INPUT" | jq -r '
71
36
  (.tool_input.command // "") | tostring
72
37
  ' 2>/dev/null || true)
73
38
  else
74
- CLI_MISSING_CMD="$INPUT"
39
+ cli_missing_cmd="$INPUT"
75
40
  fi
76
- if [ -z "$CLI_MISSING_CMD" ]; then
77
- # Empty/non-Bash payload → pre-0.35.0 body would have exited 0.
78
- exit 0
41
+ if [ -z "$cli_missing_cmd" ]; then
42
+ return 1
79
43
  fi
80
- # Empty policy.blocked_paths → no enforcement, exit 0.
81
- POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
82
- if [ ! -f "$POLICY_FILE" ]; then
83
- exit 0
44
+ local policy_file="${REA_ROOT}/.rea/policy.yaml"
45
+ if [ ! -f "$policy_file" ]; then
46
+ return 1
84
47
  fi
85
- # Substring scan: does the command mention any blocked_paths entry?
86
- # Coarse — over-trigger is fine, under-trigger is the bypass we MUST
87
- # avoid. Strip YAML quotes/comments via a minimal awk filter.
88
- CLI_MISSING_RELEVANT=0
48
+ # 0.37.0: route blocked_paths reads through the unified policy-reader.
49
+ # shellcheck source=_lib/policy-reader.sh
50
+ source "$(dirname "$0")/_lib/policy-reader.sh"
51
+ local entry
89
52
  while IFS= read -r entry; do
90
53
  [ -z "$entry" ] && continue
91
- case "$CLI_MISSING_CMD" in
92
- *"$entry"*) CLI_MISSING_RELEVANT=1; break ;;
54
+ case "$cli_missing_cmd" in
55
+ *"$entry"*) return 0 ;;
93
56
  esac
94
- done < <(awk '
95
- /^blocked_paths:/ { in_block=1; next }
96
- in_block && /^[[:space:]]*-/ {
97
- sub(/^[[:space:]]*-[[:space:]]*/, "")
98
- gsub(/^["'\'']/, "")
99
- gsub(/["'\'']$/, "")
100
- print
101
- next
102
- }
103
- in_block && /^[^[:space:]-]/ { in_block=0 }
104
- ' "$POLICY_FILE" 2>/dev/null)
105
- if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
106
- exit 0
107
- fi
108
- printf 'rea: blocked-paths-bash-gate cannot run — the rea CLI is not built.\n' >&2
109
- printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
110
- printf 'This shim fails closed because the pre-0.35.0 bash body enforced blocked_paths refusal without a CLI.\n' >&2
111
- exit 2
112
- fi
113
-
114
- # 4. Realpath sandbox check.
115
- if ! command -v node >/dev/null 2>&1; then
116
- printf 'rea: blocked-paths-bash-gate cannot run — `node` is not on PATH.\n' >&2
117
- printf 'Install Node 22+ (engines.node) to restore blocked_paths refusal.\n' >&2
118
- exit 2
119
- fi
120
-
121
- sandbox_check=$(node -e '
122
- const fs = require("fs");
123
- const path = require("path");
124
- const cli = process.argv[1];
125
- const projDir = process.argv[2];
126
- let real, realProj;
127
- try { real = fs.realpathSync(cli); } catch (e) {
128
- process.stdout.write("bad:realpath"); process.exit(1);
129
- }
130
- try { realProj = fs.realpathSync(projDir); } catch (e) {
131
- process.stdout.write("bad:realpath-proj"); process.exit(1);
132
- }
133
- const sep = path.sep;
134
- const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
135
- if (!(real === realProj || real.startsWith(projWithSep))) {
136
- process.stdout.write("bad:cli-escapes-project"); process.exit(1);
137
- }
138
- // Codex round-1 P1 fix: enforce dist/cli/index.js shape (see
139
- // settings-protection.sh).
140
- const expectedEnd = path.join("dist", "cli", "index.js");
141
- if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
142
- process.stdout.write("bad:cli-shape"); process.exit(1);
143
- }
144
- let cur = path.dirname(path.dirname(path.dirname(real)));
145
- let found = false;
146
- for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
147
- const pj = path.join(cur, "package.json");
148
- if (fs.existsSync(pj)) {
149
- try {
150
- const data = JSON.parse(fs.readFileSync(pj, "utf8"));
151
- if (data && data.name === "@bookedsolid/rea") { found = true; break; }
152
- } catch (e) { /* keep walking */ }
153
- }
154
- cur = path.dirname(cur);
155
- }
156
- if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
157
- process.stdout.write("ok");
158
- ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
159
-
160
- if [ "$sandbox_check" != "ok" ]; then
161
- printf 'rea: blocked-paths-bash-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
162
- exit 2
163
- fi
164
-
165
- # 5. Version-probe.
166
- probe_out=$("${REA_ARGV[@]}" hook blocked-paths-bash-gate --help 2>&1)
167
- probe_status=$?
168
- if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'blocked-paths-bash-gate'; then
169
- printf 'rea: this shim requires the `rea hook blocked-paths-bash-gate` subcommand (introduced in 0.35.0).\n' >&2
170
- printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
171
- printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
172
- exit 2
173
- fi
57
+ done < <(policy_reader_get_list blocked_paths 2>/dev/null)
58
+ return 1
59
+ }
174
60
 
175
- # 6. Forward stdin (already captured up-front).
176
- printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook blocked-paths-bash-gate
177
- exit $?
61
+ # shellcheck source=_lib/shim-runtime.sh
62
+ source "$(dirname "$0")/_lib/shim-runtime.sh"
63
+ shim_run
@@ -1,79 +1,54 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: blocked-paths-enforcer.sh
3
3
  # 0.35.0+ — Node-binary shim for `rea hook blocked-paths-enforcer`.
4
+ # 0.38.0+ — migrated to `_lib/shim-runtime.sh` (shared runtime).
4
5
  #
5
- # Pre-0.35.0 the gate's full body lived here as bash (284 LOC). The
6
- # full bash body is preserved at
6
+ # Write/Edit/MultiEdit/NotebookEdit-tier blocking gate. Full bash body
7
+ # preserved at
7
8
  # `__tests__/hooks/parity/baselines/blocked-paths-enforcer.sh.pre-0.35.0`.
9
+ # Migration in `src/hooks/blocked-paths-enforcer/index.ts`.
8
10
  #
9
- # Migration moves the enforcement logic (path normalization, traversal
10
- # reject, glob/prefix/exact matching, symlink resolution, agent-
11
- # writable allow-list) into `src/hooks/blocked-paths-enforcer/index.ts`.
12
- # This shim is the Claude Code dispatcher's view of the hook — it
13
- # forwards stdin to the CLI and exits with whatever the CLI returns.
11
+ # SHIM_ENFORCE_CLI_SHAPE=1: 0.35.0 codex round-1 P1 enforce
12
+ # dist/cli/index.js shape.
14
13
  #
15
- # Behavioral contract is preserved byte-for-byte: exit 0 on allow,
16
- # exit 2 on HALT / blocked-paths match / malformed payload.
14
+ # # Relevance pre-gate (CLI-missing only)
17
15
  #
18
- # # CLI-resolution trust boundary
19
- #
20
- # Mirrors the 0.32.0 final shim shape.
21
- #
22
- # # Fail-closed posture
23
- #
24
- # blocked-paths-enforcer is a Write/Edit/MultiEdit/NotebookEdit tier
25
- # security gate. The pre-0.35.0 bash body refused on uncertainty.
26
- # Early-exit branches fail closed AFTER the relevance pre-gate passes.
27
- #
28
- # # Relevance pre-gate
29
- #
30
- # Extract file_path / notebook_path from the payload, substring-scan
31
- # against the policy's blocked_paths entries. When CLI is missing AND
32
- # no policy.blocked_paths entry matches, exit 0. Empty/missing policy
33
- # → no enforcement, exit 0.
16
+ # Extract file_path / notebook_path; substring-scan against any
17
+ # policy.blocked_paths entry. Empty/missing policy → exit 0.
34
18
 
35
19
  set -uo pipefail
36
20
 
37
- # 1. HALT check.
38
21
  # shellcheck source=_lib/halt-check.sh
39
22
  source "$(dirname "$0")/_lib/halt-check.sh"
40
23
  check_halt
41
24
  REA_ROOT=$(rea_root)
42
25
 
43
- proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
44
-
45
- # 2. Capture stdin once.
46
- INPUT=$(cat)
26
+ SHIM_NAME="blocked-paths-enforcer"
27
+ SHIM_INTRODUCED_IN="0.35.0"
28
+ SHIM_FAIL_OPEN=0
29
+ SHIM_ENFORCE_CLI_SHAPE=1
30
+ SHIM_REFUSAL_NOUN="blocked_paths refusal"
47
31
 
48
- # 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
49
- REA_ARGV=()
50
- RESOLVED_CLI_PATH=""
51
- if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
52
- REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
53
- RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
54
- elif [ -f "$proj/dist/cli/index.js" ]; then
55
- REA_ARGV=(node "$proj/dist/cli/index.js")
56
- RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
57
- fi
58
-
59
- # 3b. Relevance pre-gate. Only used when the CLI is missing.
60
- if [ "${#REA_ARGV[@]}" -eq 0 ]; then
61
- CLI_MISSING_FILE_PATH=""
32
+ shim_cli_missing_relevant() {
33
+ local cli_missing_file_path=""
62
34
  if command -v jq >/dev/null 2>&1; then
63
- CLI_MISSING_FILE_PATH=$(printf '%s' "$INPUT" | jq -r '
35
+ cli_missing_file_path=$(printf '%s' "$INPUT" | jq -r '
64
36
  (.tool_input.file_path // .tool_input.notebook_path // "") | tostring
65
37
  ' 2>/dev/null || true)
66
38
  else
67
- CLI_MISSING_FILE_PATH="$INPUT"
39
+ cli_missing_file_path="$INPUT"
68
40
  fi
69
- if [ -z "$CLI_MISSING_FILE_PATH" ]; then
70
- exit 0
41
+ if [ -z "$cli_missing_file_path" ]; then
42
+ return 1
71
43
  fi
72
- POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
73
- if [ ! -f "$POLICY_FILE" ]; then
74
- exit 0
44
+ local policy_file="${REA_ROOT}/.rea/policy.yaml"
45
+ if [ ! -f "$policy_file" ]; then
46
+ return 1
75
47
  fi
76
- CLI_MISSING_RELEVANT=0
48
+ # 0.37.0: route blocked_paths reads through the unified policy-reader.
49
+ # shellcheck source=_lib/policy-reader.sh
50
+ source "$(dirname "$0")/_lib/policy-reader.sh"
51
+ local entry base
77
52
  while IFS= read -r entry; do
78
53
  [ -z "$entry" ] && continue
79
54
  # Substring scan — for directory prefixes the entry ends with /
@@ -84,97 +59,17 @@ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
84
59
  case "$base" in
85
60
  */) base="${base%/}" ;;
86
61
  esac
87
- # Strip glob wildcards for substring testing — `src/*.ts` becomes
88
- # `src/` + `.ts`. The simplest safe form is to scan the literal
89
- # part before the first `*`.
90
62
  case "$base" in
91
63
  *'*'*) base="${base%%\**}" ;;
92
64
  esac
93
65
  [ -z "$base" ] && continue
94
- case "$CLI_MISSING_FILE_PATH" in
95
- *"$base"*) CLI_MISSING_RELEVANT=1; break ;;
66
+ case "$cli_missing_file_path" in
67
+ *"$base"*) return 0 ;;
96
68
  esac
97
- done < <(awk '
98
- /^blocked_paths:/ { in_block=1; next }
99
- in_block && /^[[:space:]]*-/ {
100
- sub(/^[[:space:]]*-[[:space:]]*/, "")
101
- gsub(/^["'\'']/, "")
102
- gsub(/["'\'']$/, "")
103
- print
104
- next
105
- }
106
- in_block && /^[^[:space:]-]/ { in_block=0 }
107
- ' "$POLICY_FILE" 2>/dev/null)
108
- if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
109
- exit 0
110
- fi
111
- printf 'rea: blocked-paths-enforcer cannot run — the rea CLI is not built.\n' >&2
112
- printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
113
- printf 'This shim fails closed because the pre-0.35.0 bash body enforced blocked_paths refusal without a CLI.\n' >&2
114
- exit 2
115
- fi
116
-
117
- # 4. Realpath sandbox check.
118
- if ! command -v node >/dev/null 2>&1; then
119
- printf 'rea: blocked-paths-enforcer cannot run — `node` is not on PATH.\n' >&2
120
- printf 'Install Node 22+ (engines.node) to restore blocked_paths refusal.\n' >&2
121
- exit 2
122
- fi
123
-
124
- sandbox_check=$(node -e '
125
- const fs = require("fs");
126
- const path = require("path");
127
- const cli = process.argv[1];
128
- const projDir = process.argv[2];
129
- let real, realProj;
130
- try { real = fs.realpathSync(cli); } catch (e) {
131
- process.stdout.write("bad:realpath"); process.exit(1);
132
- }
133
- try { realProj = fs.realpathSync(projDir); } catch (e) {
134
- process.stdout.write("bad:realpath-proj"); process.exit(1);
135
- }
136
- const sep = path.sep;
137
- const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
138
- if (!(real === realProj || real.startsWith(projWithSep))) {
139
- process.stdout.write("bad:cli-escapes-project"); process.exit(1);
140
- }
141
- // Codex round-1 P1 fix: enforce dist/cli/index.js shape (see
142
- // settings-protection.sh).
143
- const expectedEnd = path.join("dist", "cli", "index.js");
144
- if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
145
- process.stdout.write("bad:cli-shape"); process.exit(1);
146
- }
147
- let cur = path.dirname(path.dirname(path.dirname(real)));
148
- let found = false;
149
- for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
150
- const pj = path.join(cur, "package.json");
151
- if (fs.existsSync(pj)) {
152
- try {
153
- const data = JSON.parse(fs.readFileSync(pj, "utf8"));
154
- if (data && data.name === "@bookedsolid/rea") { found = true; break; }
155
- } catch (e) { /* keep walking */ }
156
- }
157
- cur = path.dirname(cur);
158
- }
159
- if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
160
- process.stdout.write("ok");
161
- ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
162
-
163
- if [ "$sandbox_check" != "ok" ]; then
164
- printf 'rea: blocked-paths-enforcer FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
165
- exit 2
166
- fi
167
-
168
- # 5. Version-probe.
169
- probe_out=$("${REA_ARGV[@]}" hook blocked-paths-enforcer --help 2>&1)
170
- probe_status=$?
171
- if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'blocked-paths-enforcer'; then
172
- printf 'rea: this shim requires the `rea hook blocked-paths-enforcer` subcommand (introduced in 0.35.0).\n' >&2
173
- printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
174
- printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
175
- exit 2
176
- fi
69
+ done < <(policy_reader_get_list blocked_paths 2>/dev/null)
70
+ return 1
71
+ }
177
72
 
178
- # 6. Forward stdin (already captured up-front).
179
- printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook blocked-paths-enforcer
180
- exit $?
73
+ # shellcheck source=_lib/shim-runtime.sh
74
+ source "$(dirname "$0")/_lib/shim-runtime.sh"
75
+ shim_run