@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,201 +1,81 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: settings-protection.sh
3
3
  # 0.35.0+ — Node-binary shim for `rea hook settings-protection`.
4
+ # 0.38.0+ — migrated to `_lib/shim-runtime.sh` (shared runtime).
4
5
  #
5
- # Pre-0.35.0 this was the LARGEST hook in the repo at 582 LOC of bash:
6
- # §5a `..` traversal reject, §5a-bis interior `/./` reject, §5b
7
- # extension-surface allow-list (with final-component + intermediate-
8
- # directory symlink refusal), §6 hard-protected pattern resolution
9
- # (PROTECTED_PATTERNS sourced from `_lib/protected-paths.sh` with
10
- # `protected_writes` override + `protected_paths_relax` subtractor),
11
- # §6c intermediate-symlink resolution against the hard-protected list,
12
- # §6b REA_HOOK_PATCH_SESSION unlock for .claude/hooks/ with hash-
13
- # chained audit append (fail-closed). The full bash body is preserved
14
- # at `__tests__/hooks/parity/baselines/settings-protection.sh.pre-0.35.0`.
6
+ # Pre-0.35.0 this was the LARGEST hook in the repo at 582 LOC of bash;
7
+ # the full bash body is preserved at
8
+ # `__tests__/hooks/parity/baselines/settings-protection.sh.pre-0.35.0`.
9
+ # Migration in `src/hooks/settings-protection/index.ts`.
15
10
  #
16
- # The migration moves every section into
17
- # `src/hooks/settings-protection/index.ts`. This shim is the Claude Code
18
- # dispatcher's view of the hook it forwards stdin to the CLI and
19
- # exits with whatever the CLI returns.
11
+ # THE gate protecting the entire governance layer from agent self-
12
+ # disable. SHIM_ENFORCE_CLI_SHAPE=1 closes the 0.35.0 codex round-1 P1
13
+ # (forged in-project JS as the trusted gate CLI).
20
14
  #
21
- # Behavioral contract is preserved byte-for-byte: exit 0 on allow,
22
- # exit 2 on HALT / traversal-reject / interior-dot-reject / protected
23
- # match / patch-session-mismatch / malformed payload.
15
+ # # Relevance pre-gate (CLI-missing only)
24
16
  #
25
- # # CLI-resolution trust boundary
26
- #
27
- # Mirrors the 0.32.0 final shim shape.
28
- #
29
- # # Fail-closed posture
30
- #
31
- # settings-protection is THE gate protecting the entire governance layer
32
- # from agent self-disable. Pre-0.35.0 the bash body enforced refusal
33
- # without any compiled CLI; the Node-binary port preserves that — early-
34
- # exit branches fail closed AFTER the relevance pre-gate passes.
35
- #
36
- # # Relevance pre-gate
37
- #
38
- # Substring scan over the extracted file_path / notebook_path for the
39
- # protected-path markers (.claude/, .husky/, .rea/policy.yaml, .rea/HALT,
40
- # the verdict cache paths, plus any policy.blocked_paths entry). When
41
- # CLI is missing AND none of these substrings appear in the payload's
42
- # file path, exit 0. The pre-0.35.0 bash body would have allowed.
43
- #
44
- # # Bootstrap safety
45
- #
46
- # This shim is ITSELF protected by `settings-protection.sh`. The new
47
- # shim must not block legitimate writes — the `bash -n` syntax check
48
- # in the test:bash-syntax script catches parse errors BEFORE the
49
- # install lands them. The relevance pre-gate keeps benign writes (like
50
- # editing `src/foo.ts`) exiting 0 even when the CLI is missing.
17
+ # Substring scan over file_path / notebook_path for protected-path
18
+ # markers (.claude/, .husky/, .rea/policy.yaml, .rea/HALT, the
19
+ # verdict cache paths), plus any policy.protected_writes entry. Empty
20
+ # / missing policy is OK — the static marker set still catches the
21
+ # canonical protected paths.
51
22
 
52
23
  set -uo pipefail
53
24
 
54
- # 1. HALT check.
55
25
  # shellcheck source=_lib/halt-check.sh
56
26
  source "$(dirname "$0")/_lib/halt-check.sh"
57
27
  check_halt
58
28
  REA_ROOT=$(rea_root)
59
29
 
60
- proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
30
+ SHIM_NAME="settings-protection"
31
+ SHIM_INTRODUCED_IN="0.35.0"
32
+ SHIM_FAIL_OPEN=0
33
+ SHIM_ENFORCE_CLI_SHAPE=1
34
+ SHIM_REFUSAL_NOUN="protected-path refusal"
61
35
 
62
- # 2. Capture stdin once.
63
- INPUT=$(cat)
64
-
65
- # 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
66
- REA_ARGV=()
67
- RESOLVED_CLI_PATH=""
68
- if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
69
- REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
70
- RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
71
- elif [ -f "$proj/dist/cli/index.js" ]; then
72
- REA_ARGV=(node "$proj/dist/cli/index.js")
73
- RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
74
- fi
75
-
76
- # 3b. Relevance pre-gate. Only used when the CLI is missing.
77
- if [ "${#REA_ARGV[@]}" -eq 0 ]; then
78
- CLI_MISSING_FILE_PATH=""
36
+ shim_cli_missing_relevant() {
37
+ local cli_missing_file_path=""
79
38
  if command -v jq >/dev/null 2>&1; then
80
- CLI_MISSING_FILE_PATH=$(printf '%s' "$INPUT" | jq -r '
39
+ cli_missing_file_path=$(printf '%s' "$INPUT" | jq -r '
81
40
  (.tool_input.file_path // .tool_input.notebook_path // "") | tostring
82
41
  ' 2>/dev/null || true)
83
42
  else
84
- CLI_MISSING_FILE_PATH="$INPUT"
43
+ cli_missing_file_path="$INPUT"
85
44
  fi
86
- if [ -z "$CLI_MISSING_FILE_PATH" ]; then
87
- exit 0
45
+ if [ -z "$cli_missing_file_path" ]; then
46
+ return 1
88
47
  fi
89
- CLI_MISSING_RELEVANT=0
90
- case "$CLI_MISSING_FILE_PATH" in
91
- *".claude/settings"*) CLI_MISSING_RELEVANT=1 ;;
92
- *".claude/hooks/"*) CLI_MISSING_RELEVANT=1 ;;
93
- *".husky/"*) CLI_MISSING_RELEVANT=1 ;;
94
- *".rea/policy.yaml"*) CLI_MISSING_RELEVANT=1 ;;
95
- *".rea/HALT"*) CLI_MISSING_RELEVANT=1 ;;
96
- *".rea/last-review"*) CLI_MISSING_RELEVANT=1 ;;
97
- *".claude\\"*|*".husky\\"*|*".rea\\"*) CLI_MISSING_RELEVANT=1 ;;
98
- *"..%2F"*|*"%2E%2E"*) CLI_MISSING_RELEVANT=1 ;;
48
+ case "$cli_missing_file_path" in
49
+ *".claude/settings"*) return 0 ;;
50
+ *".claude/hooks/"*) return 0 ;;
51
+ *".husky/"*) return 0 ;;
52
+ *".rea/policy.yaml"*) return 0 ;;
53
+ *".rea/HALT"*) return 0 ;;
54
+ *".rea/last-review"*) return 0 ;;
55
+ *".claude\\"*|*".husky\\"*|*".rea\\"*) return 0 ;;
56
+ *"..%2F"*|*"%2E%2E"*) return 0 ;;
99
57
  esac
100
- # 0.37.0: route protected_writes reads through the unified
101
- # policy-reader (Tier 1 CLI → Tier 2 python3 → Tier 3 awk
102
- # block-form). Pre-0.37.0 the inline awk parser missed flow-form
103
- # arrays (`protected_writes: [path/a, path/b]`), silently allowing
104
- # writes to consumer-defined protected paths when the CLI was
105
- # unreachable. The 4-tier ladder closes the bypass via Tier 2 when
106
- # python3 + PyYAML are reachable; Tier 3 preserves the pre-0.37.0
107
- # block-only posture as a no-dep fallback.
108
- if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
109
- POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
110
- if [ -f "$POLICY_FILE" ]; then
111
- # shellcheck source=_lib/policy-reader.sh
112
- source "$(dirname "$0")/_lib/policy-reader.sh"
113
- while IFS= read -r entry; do
114
- [ -z "$entry" ] && continue
115
- base="$entry"
116
- case "$base" in
117
- */) base="${base%/}" ;;
118
- esac
119
- [ -z "$base" ] && continue
120
- case "$CLI_MISSING_FILE_PATH" in
121
- *"$base"*) CLI_MISSING_RELEVANT=1; break ;;
122
- esac
123
- done < <(policy_reader_get_list protected_writes 2>/dev/null)
124
- fi
58
+ # 0.37.0: route protected_writes reads through the unified policy-reader.
59
+ local policy_file="${REA_ROOT}/.rea/policy.yaml"
60
+ if [ -f "$policy_file" ]; then
61
+ # shellcheck source=_lib/policy-reader.sh
62
+ source "$(dirname "$0")/_lib/policy-reader.sh"
63
+ local entry base
64
+ while IFS= read -r entry; do
65
+ [ -z "$entry" ] && continue
66
+ base="$entry"
67
+ case "$base" in
68
+ */) base="${base%/}" ;;
69
+ esac
70
+ [ -z "$base" ] && continue
71
+ case "$cli_missing_file_path" in
72
+ *"$base"*) return 0 ;;
73
+ esac
74
+ done < <(policy_reader_get_list protected_writes 2>/dev/null)
125
75
  fi
126
- if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
127
- exit 0
128
- fi
129
- printf 'rea: settings-protection cannot run — the rea CLI is not built.\n' >&2
130
- printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
131
- printf 'This shim fails closed because the pre-0.35.0 bash body enforced protected-path refusal without a CLI.\n' >&2
132
- exit 2
133
- fi
134
-
135
- # 4. Realpath sandbox check.
136
- if ! command -v node >/dev/null 2>&1; then
137
- printf 'rea: settings-protection cannot run — `node` is not on PATH.\n' >&2
138
- printf 'Install Node 22+ (engines.node) to restore protected-path refusal.\n' >&2
139
- exit 2
140
- fi
141
-
142
- sandbox_check=$(node -e '
143
- const fs = require("fs");
144
- const path = require("path");
145
- const cli = process.argv[1];
146
- const projDir = process.argv[2];
147
- let real, realProj;
148
- try { real = fs.realpathSync(cli); } catch (e) {
149
- process.stdout.write("bad:realpath"); process.exit(1);
150
- }
151
- try { realProj = fs.realpathSync(projDir); } catch (e) {
152
- process.stdout.write("bad:realpath-proj"); process.exit(1);
153
- }
154
- const sep = path.sep;
155
- const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
156
- if (!(real === realProj || real.startsWith(projWithSep))) {
157
- process.stdout.write("bad:cli-escapes-project"); process.exit(1);
158
- }
159
- // Codex round-1 P1 fix: enforce dist/cli/index.js shape so a
160
- // workspace attacker who repoints node_modules/@bookedsolid/rea or
161
- // dist at an arbitrary in-project JS file cannot execute it as the
162
- // trusted gate CLI. Pre-0.35.0 shims had this check; the 0.34.0
163
- // round-8 template dropped it; restored here.
164
- const expectedEnd = path.join("dist", "cli", "index.js");
165
- if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
166
- process.stdout.write("bad:cli-shape"); process.exit(1);
167
- }
168
- let cur = path.dirname(path.dirname(path.dirname(real)));
169
- let found = false;
170
- for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
171
- const pj = path.join(cur, "package.json");
172
- if (fs.existsSync(pj)) {
173
- try {
174
- const data = JSON.parse(fs.readFileSync(pj, "utf8"));
175
- if (data && data.name === "@bookedsolid/rea") { found = true; break; }
176
- } catch (e) { /* keep walking */ }
177
- }
178
- cur = path.dirname(cur);
179
- }
180
- if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
181
- process.stdout.write("ok");
182
- ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
183
-
184
- if [ "$sandbox_check" != "ok" ]; then
185
- printf 'rea: settings-protection FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
186
- exit 2
187
- fi
188
-
189
- # 5. Version-probe.
190
- probe_out=$("${REA_ARGV[@]}" hook settings-protection --help 2>&1)
191
- probe_status=$?
192
- if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'settings-protection'; then
193
- printf 'rea: this shim requires the `rea hook settings-protection` subcommand (introduced in 0.35.0).\n' >&2
194
- printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
195
- printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
196
- exit 2
197
- fi
76
+ return 1
77
+ }
198
78
 
199
- # 6. Forward stdin (already captured up-front).
200
- printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook settings-protection
201
- exit $?
79
+ # shellcheck source=_lib/shim-runtime.sh
80
+ source "$(dirname "$0")/_lib/shim-runtime.sh"
81
+ shim_run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
@@ -0,0 +1,405 @@
1
+ #!/bin/bash
2
+ # hooks/_lib/shim-runtime.sh — shared Node-binary shim runtime.
3
+ # Introduced 0.38.0.
4
+ #
5
+ # Source via:
6
+ # source "$(dirname "$0")/_lib/shim-runtime.sh"
7
+ # shim_run
8
+ #
9
+ # # Problem this solves
10
+ #
11
+ # Releases 0.32.0 → 0.35.0 ported all 14 PreToolUse/PostToolUse hooks
12
+ # from bash to Node-binary CLIs. Each port left a ~120-LOC shell shim
13
+ # that does the same five things:
14
+ #
15
+ # 1. HALT check
16
+ # 2. Capture stdin
17
+ # 3. Resolve the rea CLI through the fixed 2-tier sandboxed order
18
+ # 4. Realpath sandbox check (cli inside CLAUDE_PROJECT_DIR + ancestor
19
+ # package.json with `name`=`@bookedsolid/rea`)
20
+ # 5. Version-probe `rea hook <NAME> --help`, then forward stdin
21
+ #
22
+ # Plus standardized fail-closed / fail-open banners. The duplication
23
+ # was the single largest source of drift bugs in the marathon — every
24
+ # round of codex review found at least one shim that had drifted (e.g.
25
+ # settings-protection.sh / blocked-paths-bash-gate.sh / blocked-paths-
26
+ # enforcer.sh gained the `dist/cli/index.js` shape check at codex
27
+ # round-1 of 0.35.0; pr-issue-link-gate / attribution-advisory got
28
+ # the sandbox-before-policy-read fix at codex round-2 of 0.37.0).
29
+ #
30
+ # 0.38.0 consolidates the duplicated infrastructure into this helper.
31
+ # Each shim becomes ~20 LOC of hook-specific customization plus a
32
+ # single `shim_run` invocation.
33
+ #
34
+ # # Public API
35
+ #
36
+ # Variables the shim sets BEFORE sourcing this lib + calling shim_run:
37
+ #
38
+ # SHIM_NAME (required) — subcommand name like
39
+ # "dangerous-bash-interceptor". Used in
40
+ # banners, the `rea hook <name>` invocation,
41
+ # and the version-probe content match.
42
+ #
43
+ # SHIM_INTRODUCED_IN (required) — version string like "0.34.0".
44
+ # Used in the version-skew banner ("requires
45
+ # the … subcommand (introduced in X)").
46
+ #
47
+ # SHIM_FAIL_OPEN (default 0) — 1 = advisory-tier (exit 0
48
+ # on every CLI-failure branch except HALT);
49
+ # 0 = blocking-tier (exit 2). Advisory shims
50
+ # (pr-issue-link-gate, architecture-review-
51
+ # gate, delegation-advisory, delegation-
52
+ # capture) set this to 1.
53
+ #
54
+ # SHIM_ENFORCE_CLI_SHAPE (default 0) — 1 = ALSO require that the
55
+ # resolved CLI's realpath ends in
56
+ # `dist/cli/index.js`. Closes the codex
57
+ # round-1 P1 finding from 0.35.0 (an attacker
58
+ # who repoints node_modules/@bookedsolid/rea
59
+ # → arbitrary in-project JS would otherwise
60
+ # execute that file as the trusted gate CLI).
61
+ # settings-protection, blocked-paths-bash-
62
+ # gate, blocked-paths-enforcer, protected-
63
+ # paths-bash-gate all set this to 1.
64
+ #
65
+ # SHIM_REFUSAL_NOUN (default "protection") — used in the
66
+ # fail-closed CLI-missing banner ("to restore
67
+ # $SHIM_REFUSAL_NOUN"). Per-shim wording.
68
+ #
69
+ # SHIM_NODE_MISSING_NOUN (default same as SHIM_REFUSAL_NOUN) — used
70
+ # in the "node not on PATH" banner.
71
+ #
72
+ # SHIM_SKIP_VERSION_PROBE (default 0) — 1 = skip the version-probe
73
+ # step entirely. delegation-capture sets this
74
+ # because the pre-port body had no probe (the
75
+ # forward is fire-and-forget; a stale CLI
76
+ # drops the signal silently rather than
77
+ # spamming the operator with a probe banner
78
+ # on every Agent/Skill dispatch).
79
+ #
80
+ # Optional shim-defined callbacks (functions). Each runs in the same
81
+ # process as the shim — they have access to INPUT, REA_ROOT, proj,
82
+ # REA_ARGV, RESOLVED_CLI_PATH. To take effect they MUST be defined
83
+ # BEFORE `shim_run` is called.
84
+ #
85
+ # shim_is_relevant Return 0 if the payload should pass through
86
+ # the gate; return 1 to exit 0 immediately
87
+ # (irrelevant Bash/Write call). Runs AFTER
88
+ # stdin capture, BEFORE any CLI work. Most
89
+ # shims define this for the relevance pre-
90
+ # gate.
91
+ #
92
+ # shim_cli_missing_relevant
93
+ # Called when the CLI is unreachable (no
94
+ # node_modules/@bookedsolid/rea AND no
95
+ # dist/cli/index.js). Return 0 to fail-closed
96
+ # (emit banner + exit 2 or exit 0 per
97
+ # FAIL_OPEN); return 1 to exit 0 silently
98
+ # (pre-bash-body behavior allowed the payload
99
+ # when no rule matched). When this hook is
100
+ # NOT defined, default behavior is:
101
+ # - SHIM_FAIL_OPEN=0 → emit banner, exit 2
102
+ # - SHIM_FAIL_OPEN=1 → exit 0 silently
103
+ # dangerous-bash-interceptor / secret-scanner
104
+ # / settings-protection define this to mirror
105
+ # the pre-port body's keyword-relevance scan.
106
+ #
107
+ # shim_policy_short_circuit
108
+ # Called AFTER sandbox-check, BEFORE version-
109
+ # probe. Return 0 to exit 0 cleanly (policy
110
+ # disabled the gate); return 1 to continue
111
+ # with version-probe + forward. Used by
112
+ # attribution-advisory (`block_ai_attribution`
113
+ # check) and security-disclosure-gate
114
+ # (`REA_DISCLOSURE_MODE=disabled` check).
115
+ # Can call `policy_reader_get` etc. since
116
+ # REA_ARGV is sandbox-validated by this point.
117
+ #
118
+ # shim_forward Override the final stdin-forward step.
119
+ # Default: `printf '%s' "$INPUT" |
120
+ # "${REA_ARGV[@]}" hook "$SHIM_NAME"; exit $?`.
121
+ # delegation-capture overrides this to detach
122
+ # (background + disown). Receives INPUT,
123
+ # REA_ARGV in env.
124
+ #
125
+ # # Bash 3.2 compatibility
126
+ #
127
+ # This lib targets macOS bash 3.2 (and POSIX-ish where possible).
128
+ # Avoid: `mapfile`, `read -d`, `${VAR^^}`, associative arrays.
129
+ # OK: arrays, indirect expansion (`${!VAR}`), `[[`.
130
+ #
131
+ # # Trust boundary
132
+ #
133
+ # `shim_run` is sourced into the same shell as the shim. It assumes
134
+ # the shim has set `set -uo pipefail` at the top. It does NOT
135
+ # re-source halt-check.sh — the shim does that explicitly so the
136
+ # REA_ROOT helper is visible BEFORE the lib is sourced.
137
+
138
+ set -uo pipefail
139
+
140
+ # -----------------------------------------------------------------------------
141
+ # Defaults — applied by `shim_run` when the shim hasn't set them. We use
142
+ # the `:=` operator to assign-if-unset so callers can override.
143
+ # -----------------------------------------------------------------------------
144
+ _shim_apply_defaults() {
145
+ : "${SHIM_NAME:?shim-runtime: SHIM_NAME must be set before shim_run}"
146
+ : "${SHIM_INTRODUCED_IN:?shim-runtime: SHIM_INTRODUCED_IN must be set before shim_run}"
147
+ : "${SHIM_FAIL_OPEN:=0}"
148
+ : "${SHIM_ENFORCE_CLI_SHAPE:=0}"
149
+ : "${SHIM_REFUSAL_NOUN:=protection}"
150
+ : "${SHIM_NODE_MISSING_NOUN:=$SHIM_REFUSAL_NOUN}"
151
+ : "${SHIM_SKIP_VERSION_PROBE:=0}"
152
+ }
153
+
154
+ # -----------------------------------------------------------------------------
155
+ # CLI resolution — fixed 2-tier sandboxed order. PATH is INTENTIONALLY
156
+ # OMITTED (agent-controlled $PATH would let a forged `rea` binary
157
+ # intercept every hook dispatch).
158
+ #
159
+ # Sets REA_ARGV (array) and RESOLVED_CLI_PATH (string) on success.
160
+ # When neither tier resolves, REA_ARGV stays empty and RESOLVED_CLI_PATH
161
+ # stays empty.
162
+ # -----------------------------------------------------------------------------
163
+ shim_resolve_cli() {
164
+ REA_ARGV=()
165
+ RESOLVED_CLI_PATH=""
166
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
167
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
168
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
169
+ elif [ -f "$proj/dist/cli/index.js" ]; then
170
+ REA_ARGV=(node "$proj/dist/cli/index.js")
171
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
172
+ fi
173
+ }
174
+
175
+ # -----------------------------------------------------------------------------
176
+ # Realpath sandbox check — validates the resolved CLI:
177
+ # 1. realpath(CLI) lives INSIDE realpath(CLAUDE_PROJECT_DIR)
178
+ # 2. an ancestor package.json has `name`=`@bookedsolid/rea`
179
+ # 3. (when SHIM_ENFORCE_CLI_SHAPE=1) realpath ends in dist/cli/index.js
180
+ #
181
+ # Echoes "ok" on success or "bad:<reason>" on failure. Caller compares
182
+ # to "ok".
183
+ #
184
+ # Args:
185
+ # $1 — resolved CLI path
186
+ # $2 — CLAUDE_PROJECT_DIR
187
+ # $3 — "1" to enforce dist/cli/index.js shape, "0" otherwise
188
+ # -----------------------------------------------------------------------------
189
+ shim_sandbox_check() {
190
+ local cli_path="$1"
191
+ local proj_dir="$2"
192
+ local enforce_shape="${3:-0}"
193
+ node -e '
194
+ const fs = require("fs");
195
+ const path = require("path");
196
+ const cli = process.argv[1];
197
+ const projDir = process.argv[2];
198
+ const enforceShape = process.argv[3] === "1";
199
+ let real, realProj;
200
+ try { real = fs.realpathSync(cli); } catch (e) {
201
+ process.stdout.write("bad:realpath"); process.exit(1);
202
+ }
203
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
204
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
205
+ }
206
+ const sep = path.sep;
207
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
208
+ if (!(real === realProj || real.startsWith(projWithSep))) {
209
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
210
+ }
211
+ if (enforceShape) {
212
+ // 0.35.0 codex round-1 P1 fix: enforce dist/cli/index.js shape so a
213
+ // workspace attacker who repoints node_modules/@bookedsolid/rea or
214
+ // dist at an arbitrary in-project JS file cannot execute it as the
215
+ // trusted gate CLI.
216
+ const expectedEnd = path.join("dist", "cli", "index.js");
217
+ if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
218
+ process.stdout.write("bad:cli-shape"); process.exit(1);
219
+ }
220
+ }
221
+ let cur = path.dirname(path.dirname(path.dirname(real)));
222
+ let found = false;
223
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
224
+ const pj = path.join(cur, "package.json");
225
+ if (fs.existsSync(pj)) {
226
+ try {
227
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
228
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
229
+ } catch (e) { /* keep walking */ }
230
+ }
231
+ cur = path.dirname(cur);
232
+ }
233
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
234
+ process.stdout.write("ok");
235
+ ' -- "$cli_path" "$proj_dir" "$enforce_shape" 2>/dev/null
236
+ }
237
+
238
+ # -----------------------------------------------------------------------------
239
+ # Standardized banners — keep stderr templates identical across shims.
240
+ # -----------------------------------------------------------------------------
241
+ shim_emit_cli_missing_banner() {
242
+ printf 'rea: %s cannot run — the rea CLI is not built.\n' "$SHIM_NAME" >&2
243
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore %s.\n' "$SHIM_REFUSAL_NOUN" >&2
244
+ printf 'This shim fails closed because the pre-port bash body enforced %s refusal without a CLI.\n' "$SHIM_NAME" >&2
245
+ }
246
+
247
+ shim_emit_node_missing_banner() {
248
+ printf 'rea: %s cannot run — `node` is not on PATH.\n' "$SHIM_NAME" >&2
249
+ printf 'Install Node 22+ (engines.node) to restore %s.\n' "$SHIM_NODE_MISSING_NOUN" >&2
250
+ }
251
+
252
+ shim_emit_sandbox_failure_banner() {
253
+ local reason="$1"
254
+ printf 'rea: %s FAILED sandbox check (%s) — refusing.\n' "$SHIM_NAME" "$reason" >&2
255
+ }
256
+
257
+ shim_emit_sandbox_skip_banner() {
258
+ local reason="$1"
259
+ printf 'rea: %s skipped (sandbox check: %s)\n' "$SHIM_NAME" "$reason" >&2
260
+ }
261
+
262
+ shim_emit_version_skew_banner_blocking() {
263
+ printf 'rea: this shim requires the `rea hook %s` subcommand (introduced in %s).\n' "$SHIM_NAME" "$SHIM_INTRODUCED_IN" >&2
264
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
265
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
266
+ }
267
+
268
+ shim_emit_version_skew_banner_advisory() {
269
+ printf 'rea: this shim requires the `rea hook %s` subcommand (introduced in %s).\n' "$SHIM_NAME" "$SHIM_INTRODUCED_IN" >&2
270
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; falling through silently.\n' >&2
271
+ }
272
+
273
+ # -----------------------------------------------------------------------------
274
+ # Default stdin forward. shim_forward can override (delegation-capture).
275
+ # -----------------------------------------------------------------------------
276
+ shim_default_forward() {
277
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook "$SHIM_NAME"
278
+ exit $?
279
+ }
280
+
281
+ # -----------------------------------------------------------------------------
282
+ # Main entry point. Reads SHIM_* variables, runs the standard flow.
283
+ # -----------------------------------------------------------------------------
284
+ shim_run() {
285
+ _shim_apply_defaults
286
+
287
+ # 1. HALT check — the shim is expected to have sourced halt-check.sh
288
+ # and called `check_halt` BEFORE sourcing this lib, so REA_ROOT is
289
+ # already set. We just use it.
290
+ : "${REA_ROOT:?shim-runtime: REA_ROOT must be set (source halt-check.sh + call check_halt first)}"
291
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
292
+
293
+ # 2. Capture stdin once.
294
+ INPUT=$(cat)
295
+
296
+ # 3. Relevance pre-gate. If the shim defined `shim_is_relevant`, call it.
297
+ if declare -F shim_is_relevant >/dev/null 2>&1; then
298
+ if ! shim_is_relevant; then
299
+ exit 0
300
+ fi
301
+ fi
302
+
303
+ # 4. Resolve CLI.
304
+ shim_resolve_cli
305
+
306
+ # 5. Sandbox check (when CLI was resolved). On failure clear REA_ARGV
307
+ # + stash the reason so the eventual CLI-required branch can emit
308
+ # the correct banner. Running the sandbox check BEFORE the policy
309
+ # short-circuit prevents an unsandboxed CLI from being invoked by
310
+ # Tier-1 of the policy reader (0.37.0 codex round-2 P1: applies to
311
+ # shims like attribution-advisory whose policy_short_circuit may
312
+ # use `policy_reader_get`).
313
+ #
314
+ # Advisory-tier: a sandbox failure exits 0 with the skip banner —
315
+ # nothing to enforce for nudges. Blocking-tier: deferred to the
316
+ # CLI-required branch below so we emit ONE banner per refusal
317
+ # (instead of double-emitting sandbox + cli-missing).
318
+ local sandbox_result=""
319
+ local sandbox_failed=0
320
+ if [ "${#REA_ARGV[@]}" -gt 0 ]; then
321
+ if ! command -v node >/dev/null 2>&1; then
322
+ if [ "$SHIM_FAIL_OPEN" -eq 1 ]; then
323
+ exit 0
324
+ fi
325
+ # Blocking-tier: node missing means we cannot sandbox-validate the
326
+ # CLI — refuse with the dedicated banner.
327
+ shim_emit_node_missing_banner
328
+ exit 2
329
+ fi
330
+ sandbox_result=$(shim_sandbox_check "$RESOLVED_CLI_PATH" "$proj" "$SHIM_ENFORCE_CLI_SHAPE")
331
+ if [ "$sandbox_result" != "ok" ]; then
332
+ sandbox_failed=1
333
+ if [ "$SHIM_FAIL_OPEN" -eq 1 ]; then
334
+ shim_emit_sandbox_skip_banner "$sandbox_result"
335
+ exit 0
336
+ fi
337
+ # Blocking-tier: clear REA_ARGV so Tier-1 policy reads (in
338
+ # shim_policy_short_circuit) degrade to Tier 2 / Tier 3 instead
339
+ # of invoking the untrusted CLI.
340
+ REA_ARGV=()
341
+ fi
342
+ fi
343
+
344
+ # 6. Policy short-circuit. Runs BEFORE the CLI-missing branch so a
345
+ # shim whose policy says "disabled" exits 0 cleanly even when the
346
+ # CLI is unbuilt (matches the pre-port body's no-op-on-disabled
347
+ # posture). The policy reader's 4-tier ladder produces correct
348
+ # answers even when REA_ARGV is empty (falls back to Tier 2
349
+ # python3 / Tier 3 awk).
350
+ if declare -F shim_policy_short_circuit >/dev/null 2>&1; then
351
+ if shim_policy_short_circuit; then
352
+ exit 0
353
+ fi
354
+ fi
355
+
356
+ # 7. CLI-required branch. If REA_ARGV is empty either (a) the CLI
357
+ # wasn't installed/built, OR (b) the sandbox check failed and we
358
+ # cleared it above. Distinguish.
359
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
360
+ if [ "$sandbox_failed" -eq 1 ]; then
361
+ shim_emit_sandbox_failure_banner "$sandbox_result"
362
+ exit 2
363
+ fi
364
+ if declare -F shim_cli_missing_relevant >/dev/null 2>&1; then
365
+ if ! shim_cli_missing_relevant; then
366
+ # CLI missing AND payload is not relevant per shim's keyword
367
+ # scan — the pre-port bash body would have allowed this.
368
+ exit 0
369
+ fi
370
+ fi
371
+ # Either no callback defined OR the callback said "yes, relevant".
372
+ if [ "$SHIM_FAIL_OPEN" -eq 1 ]; then
373
+ # Advisory tier — drop the gate silently. No banner; advisory
374
+ # hooks are nudges, not security claims.
375
+ exit 0
376
+ fi
377
+ shim_emit_cli_missing_banner
378
+ exit 2
379
+ fi
380
+
381
+ # 8. Version probe (skipped when SHIM_SKIP_VERSION_PROBE=1, used by
382
+ # delegation-capture whose pre-port body had no probe — a stale
383
+ # CLI drops the signal silently rather than spamming the operator
384
+ # on every Agent/Skill dispatch).
385
+ if [ "$SHIM_SKIP_VERSION_PROBE" -eq 0 ]; then
386
+ local probe_out probe_status
387
+ probe_out=$("${REA_ARGV[@]}" hook "$SHIM_NAME" --help 2>&1)
388
+ probe_status=$?
389
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e "$SHIM_NAME"; then
390
+ if [ "$SHIM_FAIL_OPEN" -eq 1 ]; then
391
+ shim_emit_version_skew_banner_advisory
392
+ exit 0
393
+ fi
394
+ shim_emit_version_skew_banner_blocking
395
+ exit 2
396
+ fi
397
+ fi
398
+
399
+ # 9. Forward stdin.
400
+ if declare -F shim_forward >/dev/null 2>&1; then
401
+ shim_forward
402
+ else
403
+ shim_default_forward
404
+ fi
405
+ }