@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,89 +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
- # 0.37.0: route blocked_paths reads through the unified
77
- # policy-reader (Tier 1 CLI → Tier 2 python3 → Tier 3 awk
78
- # block-form). Pre-0.37.0 the inline awk parser missed flow-form
79
- # arrays (`blocked_paths: [.env, .env.*, ...]`), silently allowing
80
- # writes to those paths when the CLI was unreachable. The 4-tier
81
- # ladder closes the bypass via Tier 2 when python3 + PyYAML are
82
- # reachable; Tier 3 preserves the pre-0.37.0 block-only posture as
83
- # a no-dep fallback.
48
+ # 0.37.0: route blocked_paths reads through the unified policy-reader.
84
49
  # shellcheck source=_lib/policy-reader.sh
85
50
  source "$(dirname "$0")/_lib/policy-reader.sh"
86
- CLI_MISSING_RELEVANT=0
51
+ local entry base
87
52
  while IFS= read -r entry; do
88
53
  [ -z "$entry" ] && continue
89
54
  # Substring scan — for directory prefixes the entry ends with /
@@ -94,87 +59,17 @@ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
94
59
  case "$base" in
95
60
  */) base="${base%/}" ;;
96
61
  esac
97
- # Strip glob wildcards for substring testing — `src/*.ts` becomes
98
- # `src/` + `.ts`. The simplest safe form is to scan the literal
99
- # part before the first `*`.
100
62
  case "$base" in
101
63
  *'*'*) base="${base%%\**}" ;;
102
64
  esac
103
65
  [ -z "$base" ] && continue
104
- case "$CLI_MISSING_FILE_PATH" in
105
- *"$base"*) CLI_MISSING_RELEVANT=1; break ;;
66
+ case "$cli_missing_file_path" in
67
+ *"$base"*) return 0 ;;
106
68
  esac
107
69
  done < <(policy_reader_get_list blocked_paths 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
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
@@ -1,137 +1,44 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: changeset-security-gate.sh
3
3
  # 0.33.0+ — Node-binary shim for `rea hook changeset-security-gate`.
4
+ # 0.38.0+ — migrated to `_lib/shim-runtime.sh` (shared runtime).
4
5
  #
5
- # Pre-0.33.0 the gate's full body lived here as bash (172 LOC, frontmatter
6
- # validation + GHSA/CVE scan + MultiEdit-aware tool handling). The
7
- # migration to the parser-backed Node binary moves all of that into
6
+ # Blocking-tier: frontmatter validation + GHSA/CVE scan over
7
+ # .changeset/ writes. Full logic in
8
8
  # `src/hooks/changeset-security-gate/index.ts`.
9
9
  #
10
- # Behavioral contract is preserved byte-for-byte: exit 0 on
11
- # pass-through / non-changeset / valid frontmatter, exit 2 on HALT /
12
- # disclosure leak / malformed frontmatter / malformed payload.
10
+ # # Relevance pre-gate
13
11
  #
14
- # # CLI-resolution trust boundary
15
- #
16
- # Realpath sandbox check + version probe. Same shape as the 0.32.0
17
- # pilots.
18
- #
19
- # # Fail-closed posture
20
- #
21
- # changeset-security-gate is BLOCKING-tier — the pre-0.33.0 bash body
22
- # refused on GHSA/CVE patterns and on malformed frontmatter. Early-exit
23
- # branches fail closed AFTER the relevance pre-gate passes.
12
+ # 2026-05-15 codex round-2 P2 fix: scan `tool_input.file_path` /
13
+ # `tool_input.notebook_path` ONLY, not the raw JSON payload, so a
14
+ # Write to README.md mentioning `.changeset/` in its content body
15
+ # doesn't trip the fail-closed branch.
24
16
 
25
17
  set -uo pipefail
26
18
 
27
- # 1. HALT check.
28
19
  # shellcheck source=_lib/halt-check.sh
29
20
  source "$(dirname "$0")/_lib/halt-check.sh"
30
21
  check_halt
31
22
  REA_ROOT=$(rea_root)
32
23
 
33
- proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
34
-
35
- # 2. Relevance pre-gate. This is a PreToolUse Write/Edit/MultiEdit/
36
- # NotebookEdit matcher, so the payload always has a `tool_input.
37
- # file_path` (or `notebook_path`).
38
- #
39
- # 2026-05-15 codex round-2 P2 fix: scan `tool_input.file_path` /
40
- # `tool_input.notebook_path` ONLY, NOT the raw JSON payload. Pre-fix
41
- # a Write to `README.md` whose body merely mentions `.changeset/`
42
- # (e.g. "See .changeset/example.md") tripped the fail-closed branch
43
- # when the CLI was unbuilt — the substring lived in the
44
- # tool_input.content blob, not in the target path. The Node body
45
- # correctly filters by file_path; the shim's pre-gate must match
46
- # that posture.
47
- INPUT=$(cat)
48
- RELEVANT=0
49
- PROBE=""
50
- if command -v jq >/dev/null 2>&1; then
51
- PROBE=$(printf '%s' "$INPUT" | jq -r '(.tool_input.file_path // .tool_input.notebook_path // "")' 2>/dev/null || true)
52
- if printf '%s' "$PROBE" | grep -qE '\.changeset/'; then
53
- RELEVANT=1
24
+ SHIM_NAME="changeset-security-gate"
25
+ SHIM_INTRODUCED_IN="0.33.0"
26
+ SHIM_FAIL_OPEN=0
27
+ SHIM_REFUSAL_NOUN="changeset disclosure refusal"
28
+
29
+ shim_is_relevant() {
30
+ local probe
31
+ if command -v jq >/dev/null 2>&1; then
32
+ probe=$(printf '%s' "$INPUT" | jq -r '(.tool_input.file_path // .tool_input.notebook_path // "")' 2>/dev/null || true)
33
+ else
34
+ probe="$INPUT"
54
35
  fi
55
- else
56
- if printf '%s' "$INPUT" | grep -qE '\.changeset/'; then
57
- RELEVANT=1
36
+ if printf '%s' "$probe" | grep -qE '\.changeset/'; then
37
+ return 0
58
38
  fi
59
- fi
60
- if [ "$RELEVANT" -eq 0 ]; then
61
- exit 0
62
- fi
63
-
64
- # 3. Resolve the rea CLI.
65
- REA_ARGV=()
66
- RESOLVED_CLI_PATH=""
67
- if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
68
- REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
69
- RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
70
- elif [ -f "$proj/dist/cli/index.js" ]; then
71
- REA_ARGV=(node "$proj/dist/cli/index.js")
72
- RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
73
- fi
74
-
75
- if [ "${#REA_ARGV[@]}" -eq 0 ]; then
76
- printf 'rea: changeset-security-gate cannot run — the rea CLI is not built.\n' >&2
77
- printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
78
- exit 2
79
- fi
80
-
81
- # 4. Realpath sandbox check.
82
- if ! command -v node >/dev/null 2>&1; then
83
- printf 'rea: changeset-security-gate cannot run — `node` is not on PATH.\n' >&2
84
- exit 2
85
- fi
86
-
87
- sandbox_check=$(node -e '
88
- const fs = require("fs");
89
- const path = require("path");
90
- const cli = process.argv[1];
91
- const projDir = process.argv[2];
92
- let real, realProj;
93
- try { real = fs.realpathSync(cli); } catch (e) {
94
- process.stdout.write("bad:realpath"); process.exit(1);
95
- }
96
- try { realProj = fs.realpathSync(projDir); } catch (e) {
97
- process.stdout.write("bad:realpath-proj"); process.exit(1);
98
- }
99
- const sep = path.sep;
100
- const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
101
- if (!(real === realProj || real.startsWith(projWithSep))) {
102
- process.stdout.write("bad:cli-escapes-project"); process.exit(1);
103
- }
104
- let cur = path.dirname(path.dirname(path.dirname(real)));
105
- let found = false;
106
- for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
107
- const pj = path.join(cur, "package.json");
108
- if (fs.existsSync(pj)) {
109
- try {
110
- const data = JSON.parse(fs.readFileSync(pj, "utf8"));
111
- if (data && data.name === "@bookedsolid/rea") { found = true; break; }
112
- } catch (e) { /* keep walking */ }
113
- }
114
- cur = path.dirname(cur);
115
- }
116
- if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
117
- process.stdout.write("ok");
118
- ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
119
-
120
- if [ "$sandbox_check" != "ok" ]; then
121
- printf 'rea: changeset-security-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
122
- exit 2
123
- fi
124
-
125
- # 5. Version-probe.
126
- probe_out=$("${REA_ARGV[@]}" hook changeset-security-gate --help 2>&1)
127
- probe_status=$?
128
- if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'changeset-security-gate'; then
129
- printf 'rea: this shim requires the `rea hook changeset-security-gate` subcommand (introduced in 0.33.0).\n' >&2
130
- printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
131
- printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
132
- exit 2
133
- fi
39
+ return 1
40
+ }
134
41
 
135
- # 6. Forward stdin.
136
- printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook changeset-security-gate
137
- exit $?
42
+ # shellcheck source=_lib/shim-runtime.sh
43
+ source "$(dirname "$0")/_lib/shim-runtime.sh"
44
+ shim_run
@@ -1,196 +1,72 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: dangerous-bash-interceptor.sh
3
3
  # 0.34.0+ — Node-binary shim for `rea hook dangerous-bash-interceptor`.
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 (414 LOC, every
6
- # refusal class H1-H17 + M1 plus their bypass-corpus regressions). The
7
- # migration to the parser-backed Node binary moves all of that into
8
- # `src/hooks/dangerous-bash-interceptor/index.ts`. This shim is the
9
- # Claude Code dispatcher's view of the hook it forwards stdin to
10
- # the CLI and exits with whatever the CLI returns.
6
+ # Pre-0.34.0 the gate's full body lived here as bash (414 LOC: refusal
7
+ # classes H1-H17 + M1 plus their bypass-corpus regressions). Migration
8
+ # in `src/hooks/dangerous-bash-interceptor/index.ts`. Behavioral
9
+ # contract preserved byte-for-byte: exit 0 on pass-through / MEDIUM-only
10
+ # advisory, exit 2 on HALT / HIGH match / malformed payload.
11
11
  #
12
- # Behavioral contract is preserved byte-for-byte: exit 0 on
13
- # pass-through / MEDIUM-only advisory, exit 2 on HALT / HIGH rule
14
- # match / malformed payload (fail-closed).
12
+ # # Relevance pre-gate (CLI-missing only)
15
13
  #
16
- # # CLI-resolution trust boundary
14
+ # 0.34.0 round-7 P1 fix: substring scan over the EXTRACTED command for
15
+ # destructive-catalog keywords. When CLI is missing AND no keyword
16
+ # matches, exit 0 (the pre-port bash body would have done the same —
17
+ # no rule to match). When CLI is missing AND a keyword DOES match,
18
+ # fail closed.
17
19
  #
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
- # dangerous-bash-interceptor is the agent-runaway gate — the pre-0.34.0
27
- # bash body refused destructive commands without any compiled CLI. The
28
- # early-exit branches (CLI missing, node missing, sandbox failed,
29
- # version skew) fail closed AFTER the relevance pre-gate passes.
30
- # Irrelevant Bash calls exit 0 regardless of CLI state.
31
- #
32
- # # Relevance pre-gate
33
- #
34
- # 0.34.0 round-7 P1 fix: the pre-0.34.0 bash body refused destructive
35
- # commands without any compiled CLI. The round-0 shim preserved that
36
- # fail-closed-on-CLI-missing posture for ALL Bash, but that's stricter
37
- # than the pre-0.34.0 body which only refused commands matching the
38
- # destructive catalog. On a fresh / unbuilt install (`npx rea init`,
39
- # pre-`pnpm build` checkout) the shim blocked benign Bash like `ls`,
40
- # `mkdir`, `pnpm install` — defeating the install path itself.
41
- #
42
- # Fix: substring pre-gate over the EXTRACTED command (not raw payload —
43
- # the local-review-gate round-2 lesson). When CLI is missing AND no
44
- # destructive-keyword appears in the extracted command, exit 0 (the
45
- # pre-0.34.0 bash body would have done the same — there's no rule to
46
- # match). When CLI is missing AND a destructive-keyword DOES appear,
47
- # preserve the original fail-closed posture (we'd rather refuse than
48
- # silently allow a destructive command).
49
- #
50
- # The keyword list is coarse — it over-triggers (e.g. `git status` hits
51
- # `git` substring) but that's fine: the CLI does the real evaluation
52
- # and lets benign forms through. Over-trigger costs one node-spawn;
53
- # under-trigger is the bypass we MUST avoid. Same posture as the
54
- # 0.32.0 secret-scanner `gh issue create` substring fix.
20
+ # Keywords cover every rule head H1-H17 + M1. Coarse by design — the
21
+ # CLI does the real per-rule evaluation when reachable; over-trigger
22
+ # costs one node-spawn, under-trigger is the bypass we MUST avoid.
55
23
 
56
24
  set -uo pipefail
57
25
 
58
- # 1. HALT check.
59
26
  # shellcheck source=_lib/halt-check.sh
60
27
  source "$(dirname "$0")/_lib/halt-check.sh"
61
28
  check_halt
62
29
  REA_ROOT=$(rea_root)
63
30
 
64
- proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
65
-
66
- # 2. Capture stdin once. The CLI consumes it via stdin pipe below.
67
- INPUT=$(cat)
31
+ SHIM_NAME="dangerous-bash-interceptor"
32
+ SHIM_INTRODUCED_IN="0.34.0"
33
+ SHIM_FAIL_OPEN=0
34
+ SHIM_REFUSAL_NOUN="destructive-command refusal"
68
35
 
69
- # 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
70
- REA_ARGV=()
71
- RESOLVED_CLI_PATH=""
72
- if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
73
- REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
74
- RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
75
- elif [ -f "$proj/dist/cli/index.js" ]; then
76
- REA_ARGV=(node "$proj/dist/cli/index.js")
77
- RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
78
- fi
79
-
80
- # 3b. Relevance pre-gate (round-7 P1). Only used when the CLI is
81
- # missing — when present, every Bash call goes through the CLI.
82
- # Extract the command string from the payload, then substring-scan
83
- # it for destructive-catalog keywords. Mirrors the H1-H17 + M1
84
- # rule heads.
85
- if [ "${#REA_ARGV[@]}" -eq 0 ]; then
86
- CLI_MISSING_CMD=""
36
+ shim_cli_missing_relevant() {
37
+ local cli_missing_cmd=""
87
38
  if command -v jq >/dev/null 2>&1; then
88
- # Match the CLI's payload schema: tool_input.command. tostring so
89
- # a non-string value (object/number) doesn't blow up jq.
90
- CLI_MISSING_CMD=$(printf '%s' "$INPUT" | jq -r '
39
+ cli_missing_cmd=$(printf '%s' "$INPUT" | jq -r '
91
40
  (.tool_input.command // "") | tostring
92
41
  ' 2>/dev/null || true)
93
42
  else
94
43
  # jq missing — fall back to scanning the raw payload. Over-trigger
95
44
  # by design (the CLI is the source of truth; this is fail-closed
96
- # only when keywords match). Substring scan still catches the
97
- # destructive forms in JSON-string-encoded payloads.
98
- CLI_MISSING_CMD="$INPUT"
45
+ # only when keywords match).
46
+ cli_missing_cmd="$INPUT"
99
47
  fi
100
- # If we couldn't extract a command, treat as relevant (fail closed).
101
- CLI_MISSING_RELEVANT=0
102
- if [ -z "$CLI_MISSING_CMD" ]; then
103
- # Empty command (or non-Bash payload). The pre-0.34.0 bash body
104
- # would have exited 0 here — no command, no rule match.
105
- exit 0
48
+ if [ -z "$cli_missing_cmd" ]; then
49
+ # Empty/non-Bash payload → pre-port body would have exit 0'd.
50
+ return 1
106
51
  fi
107
- # Substring scan. Keywords cover every rule head H1-H17 + M1. Coarse
108
- # by design we're a safety net, not the source of truth. The CLI
109
- # does the precise per-rule evaluation when reachable.
110
- case "$CLI_MISSING_CMD" in
111
- *"git "*) CLI_MISSING_RELEVANT=1 ;;
112
- *"git "*) CLI_MISSING_RELEVANT=1 ;; # tab after git
113
- *"rm "*|*"rm "*) CLI_MISSING_RELEVANT=1 ;;
114
- *"psql"*|*"pgcli"*) CLI_MISSING_RELEVANT=1 ;;
115
- *"DROP "*|*"DROP "*) CLI_MISSING_RELEVANT=1 ;;
116
- *"kill "*|*"kill "*|*"killall"*) CLI_MISSING_RELEVANT=1 ;;
117
- *"HUSKY="*) CLI_MISSING_RELEVANT=1 ;;
118
- *"curl"*|*"wget"*) CLI_MISSING_RELEVANT=1 ;;
119
- *"REA_BYPASS"*) CLI_MISSING_RELEVANT=1 ;;
120
- *"alias "*|*"function "*) CLI_MISSING_RELEVANT=1 ;;
121
- *"core.hooksPath"*|*"core.hookspath"*) CLI_MISSING_RELEVANT=1 ;;
122
- *"npm "*|*"pnpm "*|*"yarn "*) CLI_MISSING_RELEVANT=1 ;;
123
- *"--no-verify"*|*"--force"*) CLI_MISSING_RELEVANT=1 ;;
52
+ case "$cli_missing_cmd" in
53
+ *"git "*) return 0 ;;
54
+ *"git "*) return 0 ;;
55
+ *"rm "*|*"rm "*) return 0 ;;
56
+ *"psql"*|*"pgcli"*) return 0 ;;
57
+ *"DROP "*|*"DROP "*) return 0 ;;
58
+ *"kill "*|*"kill "*|*"killall"*) return 0 ;;
59
+ *"HUSKY="*) return 0 ;;
60
+ *"curl"*|*"wget"*) return 0 ;;
61
+ *"REA_BYPASS"*) return 0 ;;
62
+ *"alias "*|*"function "*) return 0 ;;
63
+ *"core.hooksPath"*|*"core.hookspath"*) return 0 ;;
64
+ *"npm "*|*"pnpm "*|*"yarn "*) return 0 ;;
65
+ *"--no-verify"*|*"--force"*) return 0 ;;
124
66
  esac
125
- if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
126
- # No destructive-keyword in the extracted command. The pre-0.34.0
127
- # bash body would have allowed this — exit 0 to preserve install-
128
- # path / unbuilt-checkout workflows.
129
- exit 0
130
- fi
131
- # Keyword matched. Preserve fail-closed posture — the pre-0.34.0
132
- # bash body would have evaluated this command and potentially refused.
133
- printf 'rea: dangerous-bash-interceptor cannot run — the rea CLI is not built.\n' >&2
134
- printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
135
- printf 'This shim fails closed because the pre-0.34.0 bash body enforced destructive-command refusal without a CLI.\n' >&2
136
- exit 2
137
- fi
138
-
139
- # 4. Realpath sandbox check.
140
- if ! command -v node >/dev/null 2>&1; then
141
- printf 'rea: dangerous-bash-interceptor cannot run — `node` is not on PATH.\n' >&2
142
- printf 'Install Node 22+ (engines.node) to restore destructive-command refusal.\n' >&2
143
- exit 2
144
- fi
145
-
146
- sandbox_check=$(node -e '
147
- const fs = require("fs");
148
- const path = require("path");
149
- const cli = process.argv[1];
150
- const projDir = process.argv[2];
151
- let real, realProj;
152
- try { real = fs.realpathSync(cli); } catch (e) {
153
- process.stdout.write("bad:realpath"); process.exit(1);
154
- }
155
- try { realProj = fs.realpathSync(projDir); } catch (e) {
156
- process.stdout.write("bad:realpath-proj"); process.exit(1);
157
- }
158
- const sep = path.sep;
159
- const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
160
- if (!(real === realProj || real.startsWith(projWithSep))) {
161
- process.stdout.write("bad:cli-escapes-project"); process.exit(1);
162
- }
163
- let cur = path.dirname(path.dirname(path.dirname(real)));
164
- let found = false;
165
- for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
166
- const pj = path.join(cur, "package.json");
167
- if (fs.existsSync(pj)) {
168
- try {
169
- const data = JSON.parse(fs.readFileSync(pj, "utf8"));
170
- if (data && data.name === "@bookedsolid/rea") { found = true; break; }
171
- } catch (e) { /* keep walking */ }
172
- }
173
- cur = path.dirname(cur);
174
- }
175
- if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
176
- process.stdout.write("ok");
177
- ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
178
-
179
- if [ "$sandbox_check" != "ok" ]; then
180
- printf 'rea: dangerous-bash-interceptor FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
181
- exit 2
182
- fi
183
-
184
- # 5. Version-probe.
185
- probe_out=$("${REA_ARGV[@]}" hook dangerous-bash-interceptor --help 2>&1)
186
- probe_status=$?
187
- if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'dangerous-bash-interceptor'; then
188
- printf 'rea: this shim requires the `rea hook dangerous-bash-interceptor` subcommand (introduced in 0.34.0).\n' >&2
189
- printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
190
- printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
191
- exit 2
192
- fi
67
+ return 1
68
+ }
193
69
 
194
- # 6. Forward stdin (already captured up-front).
195
- printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook dangerous-bash-interceptor
196
- exit $?
70
+ # shellcheck source=_lib/shim-runtime.sh
71
+ source "$(dirname "$0")/_lib/shim-runtime.sh"
72
+ shim_run