@bookedsolid/rea 0.21.1 → 0.23.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 (58) hide show
  1. package/README.md +15 -0
  2. package/THREAT_MODEL.md +582 -0
  3. package/dist/audit/append.js +1 -1
  4. package/dist/cli/doctor.js +11 -12
  5. package/dist/cli/hook.d.ts +37 -3
  6. package/dist/cli/hook.js +167 -5
  7. package/dist/cli/init.js +14 -26
  8. package/dist/cli/install/canonical.js +18 -3
  9. package/dist/cli/install/commit-msg.js +1 -2
  10. package/dist/cli/install/copy.js +4 -13
  11. package/dist/cli/install/fs-safe.js +5 -16
  12. package/dist/cli/install/gitignore.js +1 -5
  13. package/dist/cli/install/pre-push.js +3 -8
  14. package/dist/cli/install/settings-merge.js +79 -16
  15. package/dist/cli/upgrade.js +14 -10
  16. package/dist/gateway/downstream.js +1 -2
  17. package/dist/gateway/live-state.js +3 -1
  18. package/dist/gateway/log.js +1 -3
  19. package/dist/gateway/middleware/audit.js +1 -1
  20. package/dist/gateway/middleware/injection.js +3 -9
  21. package/dist/gateway/middleware/policy.js +3 -1
  22. package/dist/gateway/middleware/redact.js +1 -1
  23. package/dist/gateway/observability/codex-telemetry.js +1 -2
  24. package/dist/gateway/reviewers/claude-self.js +10 -6
  25. package/dist/hooks/bash-scanner/blocked-scan.d.ts +26 -0
  26. package/dist/hooks/bash-scanner/blocked-scan.js +467 -0
  27. package/dist/hooks/bash-scanner/index.d.ts +41 -0
  28. package/dist/hooks/bash-scanner/index.js +62 -0
  29. package/dist/hooks/bash-scanner/parse-fail-closed.d.ts +31 -0
  30. package/dist/hooks/bash-scanner/parse-fail-closed.js +27 -0
  31. package/dist/hooks/bash-scanner/parser.d.ts +42 -0
  32. package/dist/hooks/bash-scanner/parser.js +92 -0
  33. package/dist/hooks/bash-scanner/protected-scan.d.ts +76 -0
  34. package/dist/hooks/bash-scanner/protected-scan.js +815 -0
  35. package/dist/hooks/bash-scanner/verdict.d.ts +80 -0
  36. package/dist/hooks/bash-scanner/verdict.js +49 -0
  37. package/dist/hooks/bash-scanner/walker.d.ts +165 -0
  38. package/dist/hooks/bash-scanner/walker.js +7954 -0
  39. package/dist/hooks/push-gate/base.js +2 -6
  40. package/dist/hooks/push-gate/codex-runner.js +3 -1
  41. package/dist/hooks/push-gate/index.js +9 -10
  42. package/dist/policy/loader.js +4 -1
  43. package/dist/registry/tofu-gate.js +2 -2
  44. package/hooks/_lib/cmd-segments.sh +32 -7
  45. package/hooks/_lib/interpreter-scanner.sh +71 -0
  46. package/hooks/_lib/path-normalize.sh +30 -3
  47. package/hooks/blocked-paths-bash-gate.sh +141 -277
  48. package/hooks/protected-paths-bash-gate.sh +230 -326
  49. package/package.json +3 -2
  50. package/profiles/bst-internal-no-codex.yaml +1 -1
  51. package/profiles/bst-internal.yaml +1 -1
  52. package/profiles/client-engagement.yaml +1 -1
  53. package/profiles/lit-wc.yaml +1 -1
  54. package/profiles/minimal.yaml +1 -1
  55. package/profiles/open-source-no-codex.yaml +1 -1
  56. package/profiles/open-source.yaml +1 -1
  57. package/scripts/postinstall.mjs +1 -2
  58. package/scripts/run-vitest.mjs +117 -0
@@ -1,348 +1,252 @@
1
- #!/bin/bash
1
+ #!/usr/bin/env bash
2
2
  # PreToolUse hook: protected-paths-bash-gate.sh
3
- # Fires BEFORE every Bash tool call.
4
- # Refuses Bash commands that write to PROTECTED_PATTERNS via shell
5
- # redirection or write-flag utilities — the kill-switch and policy
6
- # files MUST be unreachable via any tool surface, including Bash.
7
3
  #
8
- # Pre-0.15.0, settings-protection.sh §6 protected `.rea/HALT`,
9
- # `.rea/policy.yaml`, `.claude/settings.json`, `.husky/*` against
10
- # Write/Edit/MultiEdit tool calls. But shell redirects bypassed it
11
- # entirely:
4
+ # 0.23.0+ — thin shim. Forwards stdin (Claude Code's tool_input JSON)
5
+ # to `rea hook scan-bash --mode protected`, parses the verdict, exits
6
+ # 0/2 accordingly.
12
7
  #
13
- # printf '...' > .rea/HALT # bypass Bash matcher only
14
- # tee .rea/policy.yaml < new.yaml # bypass
15
- # cp new-settings.json .claude/settings.json
16
- # sed -i '' '/foo/d' .husky/pre-push
17
- # dd of=.rea/HALT
8
+ # Pre-0.23.0 this hook was a 536-line bash regex pipeline. The rewrite
9
+ # moved every detection rule into a parser-backed AST walker at
10
+ # `src/hooks/bash-scanner/`. helix-023 and discord-ops Round 13 closed
11
+ # 9 bypass classes that lived in the old segmenter; the new scanner
12
+ # closes them definitionally — there is no segmenter to bypass.
18
13
  #
19
- # This hook closes that gap by detecting redirect/write patterns
20
- # whose target matches the same `_lib/protected-paths.sh` allowlist.
14
+ # Failure mode: if the rea CLI cannot be located in a SANDBOXED tier
15
+ # (consumer's `node_modules/@bookedsolid/rea/dist/cli/index.js` or the
16
+ # rea repo's own `dist/cli/index.js`), we REFUSE the command. NEVER
17
+ # ALLOW on uncertainty. Operators need `@bookedsolid/rea` installed for
18
+ # the gate to work; `rea doctor` flags missing CLI as a P0.
19
+ #
20
+ # Defense in depth: the shim does NOT trust the CLI exit code alone.
21
+ # After capturing stdout we re-parse the verdict with `node -e` to
22
+ # confirm:
23
+ # 1. it is valid JSON,
24
+ # 2. the top-level shape has `.verdict == "allow"|"block"`,
25
+ # 3. the verdict matches the exit code (allow→0, block→2).
26
+ # Any disagreement → exit 2.
27
+ #
28
+ # Codex round 4 Finding 2: dropped tier 1 (PATH `command -v rea`) and
29
+ # tier 2 (`node_modules/.bin/rea` symlink). Both are workspace-attacker
30
+ # controllable: an attacker who can write a file at
31
+ # `node_modules/.bin/rea` (or set PATH to a directory they own) can
32
+ # stage a fake `rea` binary that exits 0 with `{"verdict":"allow"}` and
33
+ # subvert the gate.
34
+ #
35
+ # Trust-boundary HONESTY (codex round 5 F5): the shim's structural
36
+ # defense is the realpath sandbox (round 4 #2 + round 5 F2). It defeats:
37
+ # - PATH-attacker hijack via fake `rea` binary
38
+ # - node_modules/.bin/rea symlink-bin hijack
39
+ # - node_modules/@bookedsolid/rea -> /tmp/sym-attacker symlink-out
40
+ # - intra-project hijack without a matching package.json
41
+ # It does NOT defeat an attacker who writes a forged dist/cli/index.js
42
+ # AND a matching package.json directly into node_modules/. At that level
43
+ # the attacker has already compromised the package install pipeline (npm
44
+ # registry, lockfile, dependency confusion) and any dependency the agent
45
+ # imports is also forgeable — hook-tier defense is past. The trust
46
+ # boundary is package-tier integrity (npm provenance + manifest
47
+ # verification), not the bash gate. See THREAT_MODEL §8.3 + docs/
48
+ # architecture/bash-scanner.md for the full rationale.
49
+ #
50
+ # Tier defense: realpath the resolved CLI before exec. Two complementary
51
+ # checks:
52
+ # PRIMARY (codex round 5 F2): realpath(cli) MUST live INSIDE
53
+ # realpath(CLAUDE_PROJECT_DIR). Catches symlink-out-of-project attacks
54
+ # where the attacker writes `node_modules/@bookedsolid/rea` as a
55
+ # symlink to a tree under `/tmp/sym-attacker` containing a forged
56
+ # `package.json` with name `@bookedsolid/rea` and a forged
57
+ # `dist/cli/index.js` that exits 0 with `{"verdict":"allow"}`. Pre-fix
58
+ # the secondary check (package.json walk-up) was the ONLY guard, and
59
+ # the attacker satisfies it by placing a forged package.json in their
60
+ # own tree.
61
+ # SECONDARY: walk up from the resolved CLI looking for an ancestor
62
+ # `package.json` whose `name` is `@bookedsolid/rea`. This guards
63
+ # against intra-project symlinks where the realpath stays inside
64
+ # the project (e.g. accidentally pointing dist/ at node_modules/).
65
+ #
66
+ # Codex round 2 R2-3 (preserved): REA_NODE_CLI env-var honoring REMOVED.
67
+ # Test harnesses must set CLAUDE_PROJECT_DIR to a directory whose
68
+ # `dist/cli/index.js` (or `node_modules/@bookedsolid/rea/...`) holds
69
+ # the trusted CLI build. The shim NEVER reads REA_NODE_CLI.
21
70
  #
22
71
  # Exit codes:
23
- # 0 = no protected-path write detected — allow
24
- # 2 = protected-path write via Bash detected block
72
+ # 0 = allow (verdict.verdict == "allow")
73
+ # 2 = block (verdict.verdict == "block", or any failure path)
25
74
 
26
75
  set -uo pipefail
27
76
 
28
- # shellcheck source=_lib/protected-paths.sh
29
- source "$(dirname "$0")/_lib/protected-paths.sh"
30
- # shellcheck source=_lib/path-normalize.sh
31
- source "$(dirname "$0")/_lib/path-normalize.sh"
32
- # shellcheck source=_lib/cmd-segments.sh
33
- source "$(dirname "$0")/_lib/cmd-segments.sh"
77
+ proj="${CLAUDE_PROJECT_DIR:-$(pwd)}"
34
78
 
35
- INPUT=$(cat)
79
+ # Resolve the rea CLI through a fixed 2-tier sandboxed order.
80
+ # 1. node_modules/@bookedsolid/rea/dist/cli/index.js (consumer-side
81
+ # direct dist execution — the published artifact)
82
+ # 2. dist/cli/index.js under CLAUDE_PROJECT_DIR (the rea repo's own
83
+ # dogfood install, where `rea` is the package itself)
84
+ #
85
+ # We build an `argv` array rather than a string so paths containing
86
+ # whitespace round-trip safely.
87
+ REA_ARGV=()
88
+ RESOLVED_CLI_PATH=""
89
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
90
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
91
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
92
+ elif [ -f "$proj/dist/cli/index.js" ]; then
93
+ # rea repo dogfood: the project IS @bookedsolid/rea.
94
+ REA_ARGV=(node "$proj/dist/cli/index.js")
95
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
96
+ fi
36
97
 
37
- if ! command -v jq >/dev/null 2>&1; then
38
- printf 'REA ERROR: jq is required but not installed.\n' >&2
98
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
99
+ printf 'rea: CLI not found at sandboxed tiers (node_modules/@bookedsolid/rea/dist or dist/).\n' >&2
100
+ printf 'Install @bookedsolid/rea via npm/pnpm and run `rea doctor`.\n' >&2
101
+ printf 'Refusing the Bash command on uncertainty.\n' >&2
39
102
  exit 2
40
103
  fi
41
104
 
42
- REA_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
43
-
44
- # HALT checkuniform with other hooks.
45
- HALT_FILE="${REA_ROOT}/.rea/HALT"
46
- if [ -f "$HALT_FILE" ]; then
47
- printf 'REA HALT: %s\nAll agent operations suspended. Run: rea unfreeze\n' \
48
- "$(head -c 1024 "$HALT_FILE" 2>/dev/null || echo 'Reason unknown')" >&2
105
+ # Codex round 4 Finding 2 tier defense: realpath the resolved CLI and
106
+ # verify it lives inside the sandboxed dirs. Catches symlink games.
107
+ # We require Node for the verifier anyway (below) use it here too.
108
+ if ! command -v node >/dev/null 2>&1; then
109
+ printf 'rea: node not on PATH (required to realpath verify scan-bash CLI). Refusing.\n' >&2
110
+ exit 2
111
+ fi
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;
118
+ try { real = fs.realpathSync(cli); } catch (e) {
119
+ process.stdout.write("bad:realpath:" + (e && e.message ? e.message : String(e)));
120
+ process.exit(1);
121
+ }
122
+ // Codex round 5 F2 (P0) — PRIMARY check: realpath(cli) must live
123
+ // INSIDE realpath(CLAUDE_PROJECT_DIR). Pre-fix an attacker who
124
+ // writes a symlink at node_modules/@bookedsolid/rea pointing to
125
+ // /tmp/sym-attacker (containing a forged package.json + dist/cli/index.js
126
+ // that exits 0 with verdict:"allow") completely defeated the
127
+ // sandbox — the package.json walk-up was satisfied by the forged
128
+ // file in the attacker tree. Containing realCli to realProj closes
129
+ // every symlink-out attack class because the attacker no longer
130
+ // controls a path inside the project tree.
131
+ let realProj;
132
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
133
+ process.stdout.write("bad:realpath-proj:" + (e && e.message ? e.message : String(e)));
134
+ process.exit(1);
135
+ }
136
+ const projWithSep = realProj.endsWith(path.sep) ? realProj : realProj + path.sep;
137
+ if (!(real === realProj || real.startsWith(projWithSep))) {
138
+ process.stdout.write("bad:cli-escapes-project:" + real + ":proj=" + realProj);
139
+ process.exit(1);
140
+ }
141
+ // Codex round 4 Finding 2 (now SECONDARY) — shape + ancestor pkg.json.
142
+ //
143
+ // Acceptance: the resolved CLI must end in `.../dist/cli/index.js`
144
+ // and have an ancestor `package.json` whose `name` is `@bookedsolid/rea`.
145
+ // This guards against intra-project hijack where an attacker writes
146
+ // a symlink at node_modules/@bookedsolid/rea pointing to a sibling
147
+ // tree INSIDE the project (e.g. ./scratch/) — the PRIMARY check
148
+ // accepts it (still inside project root) but the package.json walk-up
149
+ // refuses unless that tree contains the canonical package metadata.
150
+ const expectedEnd = path.join("dist", "cli", "index.js");
151
+ if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
152
+ process.stdout.write("bad:cli-shape:" + real);
153
+ process.exit(1);
154
+ }
155
+ // Walk up looking for package.json with the protected name.
156
+ let cur = path.dirname(path.dirname(path.dirname(real))); // pkg root
157
+ let found = false;
158
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
159
+ const pj = path.join(cur, "package.json");
160
+ if (fs.existsSync(pj)) {
161
+ try {
162
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
163
+ if (data && data.name === "@bookedsolid/rea") {
164
+ found = true;
165
+ break;
166
+ }
167
+ } catch (e) {
168
+ // Continue walking up.
169
+ }
170
+ }
171
+ cur = path.dirname(cur);
172
+ }
173
+ if (!found) {
174
+ process.stdout.write("bad:no-rea-pkg:" + real);
175
+ process.exit(1);
176
+ }
177
+ process.stdout.write("ok");
178
+ process.exit(0);
179
+ ' "$RESOLVED_CLI_PATH" "$proj" 2>&1)
180
+ sandbox_status=$?
181
+ if [ "$sandbox_status" -ne 0 ] || [ "$sandbox_check" != "ok" ]; then
182
+ printf 'rea: scan-bash CLI realpath escapes sandbox (%s). Refusing.\n' "$sandbox_check" >&2
49
183
  exit 2
50
184
  fi
51
185
 
52
- CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
53
- if [[ -z "$CMD" ]]; then
186
+ # Capture stdin once and forward it to the CLI.
187
+ payload=$(cat)
188
+ if [ -z "$payload" ]; then
54
189
  exit 0
55
190
  fi
56
191
 
57
- # Normalize a path token. 0.16.0 codex P1 fixes (helix Findings 015):
58
- # - resolve `..` segments via realpath when the path exists, OR reject
59
- # them outright when it doesn't (`.claude/hooks/../settings.json`
60
- # writes to `.claude/settings.json` but the literal-string match
61
- # missed it pre-fix)
62
- # - lowercase the result so case-insensitive matchers (macOS APFS,
63
- # `.ClAuDe/settings.json`) still match the canonical lowercase
64
- # pattern (`.claude/settings.json`)
65
- # - apply shared `_lib/path-normalize.sh::normalize_path` for backslash
66
- # translation + URL decode + leading-`./` strip
67
- # shellcheck source=_lib/path-normalize.sh
68
- source "$(dirname "$0")/_lib/path-normalize.sh"
69
-
70
- _normalize_target() {
71
- local t="$1"
72
- # Strip matching surrounding quotes.
73
- if [[ "$t" =~ ^\"(.*)\"$ ]]; then t="${BASH_REMATCH[1]}"; fi
74
- if [[ "$t" =~ ^\'(.*)\'$ ]]; then t="${BASH_REMATCH[1]}"; fi
75
- # If the path contains `..` segments, resolve them aggressively. We
76
- # cannot rely on `realpath` being installed; do a manual resolution
77
- # by walking segments. This is the helix-015 P1 fix: pre-fix, the
78
- # literal `.claude/hooks/../settings.json` did not match the
79
- # `.claude/settings.json` pattern even though the OS would resolve
80
- # the write to that target.
81
- case "/$t/" in
82
- */../*)
83
- # Build absolute then walk and normalize segments.
84
- # 0.16.0 codex P1-1 fix: use `read -ra` with IFS=/ instead of an
85
- # unquoted `for part in $abs` loop. The unquoted `for` was subject
86
- # to pathname expansion `.claude/*/../settings.json` would glob
87
- # `*` against the agent's CWD, mangling the resolved path and
88
- # bypassing the protected-paths matcher. `read -ra` with an
89
- # explicit delimiter disables both word-splitting (via IFS) AND
90
- # pathname expansion (read does not glob).
91
- local abs="$t"
92
- [[ "$abs" != /* ]] && abs="$REA_ROOT/$abs"
93
- local -a raw_parts parts=()
94
- IFS='/' read -ra raw_parts <<<"$abs"
95
- for part in "${raw_parts[@]}"; do
96
- case "$part" in
97
- ''|.) continue ;;
98
- ..) [[ "${#parts[@]}" -gt 0 ]] && unset 'parts[${#parts[@]}-1]' ;;
99
- *) parts+=("$part") ;;
100
- esac
101
- done
102
- t="/$(IFS=/; printf '%s' "${parts[*]}")"
103
- # 0.16.0 codex P2-3 fix: if the resolved absolute path escapes
104
- # REA_ROOT, emit a sentinel so the caller refuses outright.
105
- # `exit 2` here would only exit the `$()` subshell, not the parent
106
- # hook process — sentinel + caller-side handling is the only
107
- # cross-shell-portable way.
108
- if [[ "$t" != "$REA_ROOT" && "$t" != "$REA_ROOT"/* ]]; then
109
- printf '__rea_outside_root__:%s' "$t"
110
- return 0
111
- fi
112
- ;;
113
- esac
114
- # Hand off to shared normalize_path (strips $REA_ROOT, URL-decodes,
115
- # translates `\` → `/`, strips leading `./`).
116
- t=$(normalize_path "$t")
117
- # Lowercase for case-insensitive matching (helix-015 P1 fix #2 —
118
- # macOS APFS allows `.ClAuDe/settings.json` to land on the same
119
- # file as `.claude/settings.json`, so the matcher must compare
120
- # lowercased forms).
121
- printf '%s' "$t" | tr '[:upper:]' '[:lower:]'
122
- }
123
-
124
- # Refuse and exit 2 with a uniform error message.
125
- _refuse() {
126
- local pattern="$1" target="$2" segment="$3"
127
- {
128
- printf 'PROTECTED PATH (bash): write to a package-managed file blocked\n'
129
- printf '\n'
130
- printf ' Pattern matched: %s\n' "$pattern"
131
- printf ' Resolved target: %s\n' "$target"
132
- printf ' Segment: %s\n' "$segment"
133
- printf '\n'
134
- printf ' Rule: protected paths (kill-switch, policy.yaml, settings.json,\n'
135
- printf ' .husky/*) are unreachable via Bash redirects too — not just\n'
136
- printf ' Write/Edit/MultiEdit. To modify, a human must edit directly.\n'
137
- } >&2
138
- exit 2
139
- }
140
-
141
- # Inspect one segment for redirect / write patterns and refuse if the
142
- # target matches any protected pattern.
143
- _check_segment() {
144
- local _raw="$1" segment="$2"
145
- [[ -z "$segment" ]] && return 0
146
-
147
- local target_token=""
148
- local detected_form=""
149
-
150
- # bash `[[ =~ ]]` regex literals with `|` and `(...)` parsed inline
151
- # confuse some bash versions on macOS. Use named variables for each
152
- # pattern so the literal stays in a string context only.
153
- # 0.16.0 codex P1 fix (helix-015 #3): widened redirect regex. Pre-fix
154
- # only matched `>`, `>>`, `2>`, `2>>`, `&>`. Missed:
155
- # - `1>` / `1>>` (explicit stdout fd)
156
- # - `>|` (noclobber-override redirect)
157
- # - `[0-9]+>` / `[0-9]+>>` (any fd prefix — `9>file`, `42>>file`)
158
- # All of these write to the target and bypassed the gate. The new
159
- # pattern accepts: optional fd-prefix, then `>` or `>>` or `>|`, with
160
- # optional `&` for stderr-merge variants.
161
- local re_redirect='(^|[[:space:]])(&>>|&>|[0-9]+>>|[0-9]+>\||[0-9]+>|>>|>\||>)[[:space:]]*([^[:space:]&|;<>]+)'
162
- local re_cpmv='(^|[[:space:]])(cp|mv)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
163
- local re_sed='(^|[[:space:]])sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
164
- local re_dd='(^|[[:space:]])dd[[:space:]]+[^&|;<>]*of=([^[:space:]&|;<>]+)'
165
- # 0.15.0 codex P1 fix: replaced the bash-3.2-broken `(...)*` pattern
166
- # for tee/truncate flag-skipping with a token-walk approach that
167
- # works across BSD bash 3.2 and GNU bash 4+. Walks every token after
168
- # the command, skips flags (single-dash short, double-dash long with
169
- # optional =value), returns the first non-flag token as the target.
170
-
171
- if [[ "$segment" =~ $re_redirect ]]; then
172
- target_token="${BASH_REMATCH[3]}"
173
- detected_form="redirect ${BASH_REMATCH[2]}"
174
- elif [[ "$segment" =~ $re_cpmv ]]; then
175
- target_token="${BASH_REMATCH[3]}"
176
- detected_form="${BASH_REMATCH[2]}"
177
- elif [[ "$segment" =~ $re_sed ]]; then
178
- target_token="${BASH_REMATCH[3]}"
179
- detected_form="sed -i"
180
- elif [[ "$segment" =~ $re_dd ]]; then
181
- target_token="${BASH_REMATCH[2]}"
182
- detected_form="dd of="
183
- else
184
- # tee / truncate / install / ln — token-walk for cross-bash safety.
185
- # Read tokens, find the command, then return the first non-flag arg.
186
- local prev_word="" found_cmd=""
187
- local _seg_for_walk="$segment"
188
- # Strip leading whitespace.
189
- _seg_for_walk="${_seg_for_walk#"${_seg_for_walk%%[![:space:]]*}"}"
190
- # shellcheck disable=SC2086
191
- set -- $_seg_for_walk
192
- while [ "$#" -gt 0 ]; do
193
- local tok="$1"
194
- shift
195
- if [[ -z "$found_cmd" ]]; then
196
- case "$tok" in
197
- tee|truncate|install|ln)
198
- found_cmd="$tok"
199
- ;;
200
- esac
201
- prev_word="$tok"
202
- continue
203
- fi
204
- # We're inside the command's argv. Skip flags.
205
- case "$tok" in
206
- --) continue ;;
207
- --*=*) continue ;;
208
- --*)
209
- # Long flag — may take a value as the NEXT token (we don't
210
- # know which long options take values). For safety, skip
211
- # only known no-value long flags; otherwise consume the
212
- # next token too if it looks like a value.
213
- case "$tok" in
214
- --append|--ignore-interrupts|--no-clobber|--force|--no-target-directory|--symbolic|--no-dereference|--reference=*) continue ;;
215
- *) shift 2>/dev/null || true; continue ;;
216
- esac
217
- ;;
218
- -*)
219
- # Short flag cluster. Skip. truncate -s SIZE — `-s` is a flag,
220
- # SIZE is its arg. We're conservative: skip the next token if
221
- # the flag cluster's last char is one of the size-bearing
222
- # flags (truncate -s, install -m, ln -t).
223
- case "$tok" in
224
- -s*|-m*|-o*|-g*|-t*) shift 2>/dev/null || true ;;
225
- esac
226
- continue
227
- ;;
228
- *)
229
- # First non-flag token — this is the target (or, for cp/mv-
230
- # like commands, the first source; the cpmv detector above
231
- # handles those separately). We treat ALL non-flag args as
232
- # potential targets and check each — that catches
233
- # `tee a b c` where any of a/b/c could be a protected file.
234
- target_token="$tok"
235
- detected_form="$found_cmd"
236
- # Check this token immediately; if not protected, keep
237
- # walking — there may be more positional args.
238
- local _t
239
- _t=$(_normalize_target "$target_token")
240
- # 0.16.0 codex P2-3: outside-REA_ROOT sentinel handling (logical).
241
- if [[ "$_t" == __rea_outside_root__:* ]]; then
242
- local resolved="${_t#__rea_outside_root__:}"
243
- {
244
- printf 'PROTECTED PATH (bash): path traversal escapes project root\n'
245
- printf ' Logical: %s\n Resolved: %s\n' "$target_token" "$resolved"
246
- } >&2
247
- exit 2
248
- fi
249
- # 0.20.1 helix-021 #1: resolve intermediate symlinks via
250
- # `cd -P / pwd -P` parent-canonicalization (Write-tier parity).
251
- # `ln -s ../ .husky/pre-push.d/linkdir; printf x > .husky/pre-push.d/linkdir/pre-push`
252
- # had a logical form of `.husky/pre-push.d/linkdir/pre-push`
253
- # that didn't match any protected pattern; the resolved form
254
- # is `.husky/pre-push` which DOES match. Refuse on either.
255
- local _t_resolved
256
- _t_resolved=$(rea_resolved_relative_form "$target_token")
257
- if [[ "$_t_resolved" == __rea_outside_root__:* ]]; then
258
- local resolved="${_t_resolved#__rea_outside_root__:}"
259
- {
260
- printf 'PROTECTED PATH (bash): symlink resolves outside project root\n'
261
- printf ' Logical: %s\n Resolved: %s\n' "$target_token" "$resolved"
262
- } >&2
263
- exit 2
264
- fi
265
- if rea_path_is_protected "$_t" \
266
- || ([[ -n "$_t_resolved" ]] && rea_path_is_protected "$_t_resolved"); then
267
- local matched=""
268
- local pattern_lc
269
- local hit_form="$_t"
270
- if [[ -n "$_t_resolved" ]] && rea_path_is_protected "$_t_resolved" \
271
- && ! rea_path_is_protected "$_t"; then
272
- hit_form="$_t_resolved"
273
- fi
274
- for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
275
- pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
276
- if [[ "$hit_form" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
277
- if [[ "$pattern_lc" == */ && "$hit_form" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
278
- done
279
- _refuse "$matched" "$hit_form" "$segment"
280
- fi
281
- # Reset target_token so the post-loop check doesn't double-check.
282
- target_token=""
283
- ;;
284
- esac
285
- done
286
- fi
287
-
288
- if [[ -z "$target_token" ]]; then
289
- return 0
290
- fi
291
-
292
- local target
293
- target=$(_normalize_target "$target_token")
294
- # 0.16.0 codex P2-3 fix: outside-REA_ROOT sentinel from _normalize_target.
295
- if [[ "$target" == __rea_outside_root__:* ]]; then
296
- local resolved="${target#__rea_outside_root__:}"
297
- {
298
- printf 'PROTECTED PATH (bash): path traversal escapes project root\n'
299
- printf '\n'
300
- printf ' Logical: %s\n' "$target_token"
301
- printf ' Resolved: %s\n' "$resolved"
302
- printf ' Segment: %s\n' "$segment"
303
- printf '\n'
304
- printf ' Rule: bash redirects whose target resolves outside REA_ROOT\n'
305
- printf ' are refused. Use a project-relative path without `..`\n'
306
- printf ' segments.\n'
307
- } >&2
308
- exit 2
309
- fi
310
- # 0.20.1 helix-021 #1: resolve intermediate symlinks. See parallel
311
- # block in the multi-target loop above for the rationale.
312
- local target_resolved
313
- target_resolved=$(rea_resolved_relative_form "$target_token")
314
- if [[ "$target_resolved" == __rea_outside_root__:* ]]; then
315
- local resolved="${target_resolved#__rea_outside_root__:}"
316
- {
317
- printf 'PROTECTED PATH (bash): symlink resolves outside project root\n'
318
- printf '\n'
319
- printf ' Logical: %s\n' "$target_token"
320
- printf ' Resolved: %s\n' "$resolved"
321
- printf ' Segment: %s\n' "$segment"
322
- } >&2
192
+ # Run the scanner.
193
+ verdict=$(printf '%s' "$payload" | "${REA_ARGV[@]}" hook scan-bash --mode protected)
194
+ status=$?
195
+
196
+ # Defense in depth — verify the verdict JSON matches the exit code.
197
+ verifier='try {
198
+ const raw = require("fs").readFileSync(0, "utf8");
199
+ if (raw.trim().length === 0) { process.stdout.write("bad:empty"); process.exit(1); }
200
+ const v = JSON.parse(raw);
201
+ if (typeof v !== "object" || v === null || Array.isArray(v)) {
202
+ process.stdout.write("bad:non-object"); process.exit(1);
203
+ }
204
+ if (v.verdict !== "allow" && v.verdict !== "block") {
205
+ process.stdout.write("bad:verdict-shape:" + String(v.verdict)); process.exit(1);
206
+ }
207
+ process.stdout.write("ok:" + v.verdict); process.exit(0);
208
+ } catch (e) {
209
+ process.stdout.write("bad:" + (e && e.message ? e.message : String(e))); process.exit(1);
210
+ }'
211
+
212
+ verdict_check=$(printf '%s' "$verdict" | node -e "$verifier" 2>&1)
213
+ verdict_check_status=$?
214
+
215
+ case "$status" in
216
+ 0)
217
+ if [ "$verdict_check_status" -ne 0 ]; then
218
+ printf 'rea: scan-bash exited 0 but verdict JSON is malformed (%s). Refusing on uncertainty.\n' "$verdict_check" >&2
219
+ exit 2
220
+ fi
221
+ if [ "$verdict_check" != "ok:allow" ]; then
222
+ printf 'rea: scan-bash exit 0 but verdict says %s. Refusing on uncertainty.\n' "$verdict_check" >&2
223
+ exit 2
224
+ fi
225
+ exit 0
226
+ ;;
227
+ 2)
228
+ # Block path — the CLI has already emitted the operator-facing
229
+ # reason on stderr. We additionally verify the JSON shape so a
230
+ # forged `/bin/true` (which would never reach here, but be defensive)
231
+ # cannot bypass.
232
+ if [ "$verdict_check_status" -ne 0 ]; then
233
+ # Malformed stdout under exit 2 is unusual but harmless — the
234
+ # block path is still honored.
235
+ exit 2
236
+ fi
237
+ if [ "$verdict_check" != "ok:block" ]; then
238
+ printf 'rea: scan-bash exit 2 but verdict says %s. Refusing on uncertainty.\n' "$verdict_check" >&2
239
+ exit 2
240
+ fi
323
241
  exit 2
324
- fi
325
- if rea_path_is_protected "$target" \
326
- || ([[ -n "$target_resolved" ]] && rea_path_is_protected "$target_resolved"); then
327
- # Find the matching pattern for the error message. Both `target`
328
- # and `pattern` lowercased to match `_normalize_target`'s case-
329
- # insensitive output (helix-015 P1 fix).
330
- local matched="" pattern_lc
331
- local hit_form="$target"
332
- if [[ -n "$target_resolved" ]] && rea_path_is_protected "$target_resolved" \
333
- && ! rea_path_is_protected "$target"; then
334
- hit_form="$target_resolved"
242
+ ;;
243
+ *)
244
+ # Unexpected exit code treat as block on uncertainty. The CLI
245
+ # writes its own diagnostic; we add an explicit refusal.
246
+ printf 'rea: scan-bash exited %d (expected 0/2). Refusing on uncertainty.\n' "$status" >&2
247
+ if [ -n "$verdict" ]; then
248
+ printf 'rea: scan-bash stdout was: %s\n' "$verdict" >&2
335
249
  fi
336
- for pattern in "${REA_PROTECTED_PATTERNS[@]}"; do
337
- pattern_lc=$(printf '%s' "$pattern" | tr '[:upper:]' '[:lower:]')
338
- if [[ "$hit_form" == "$pattern_lc" ]]; then matched="$pattern"; break; fi
339
- if [[ "$pattern_lc" == */ && "$hit_form" == "$pattern_lc"* ]]; then matched="$pattern"; break; fi
340
- done
341
- _refuse "$matched" "$hit_form" "$segment"
342
- fi
343
- return 0
344
- }
345
-
346
- for_each_segment "$CMD" _check_segment
347
-
348
- exit 0
250
+ exit 2
251
+ ;;
252
+ esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.21.1",
3
+ "version": "0.23.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)",
@@ -71,6 +71,7 @@
71
71
  "@clack/prompts": "^1.2.0",
72
72
  "@modelcontextprotocol/sdk": "^1.29.0",
73
73
  "commander": "^14.0.3",
74
+ "mvdan-sh": "0.10.1",
74
75
  "proper-lockfile": "^4.1.2",
75
76
  "safe-regex": "^2.1.1",
76
77
  "yaml": "^2.7.0",
@@ -97,7 +98,7 @@
97
98
  "lint:regex": "node scripts/lint-safe-regex.mjs",
98
99
  "format": "prettier --write .",
99
100
  "format:check": "prettier --check .",
100
- "test": "pnpm run test:dogfood && pnpm run test:bash-syntax && vitest run",
101
+ "test": "pnpm run build && pnpm run test:dogfood && pnpm run test:bash-syntax && node scripts/run-vitest.mjs",
101
102
  "test:watch": "vitest",
102
103
  "test:coverage": "vitest run --coverage",
103
104
  "test:dogfood": "node tools/check-dogfood-drift.mjs",
@@ -31,7 +31,7 @@ blocked_paths:
31
31
  - .github/workflows/release.yml
32
32
  - SECURITY.md
33
33
  - THREAT_MODEL.md
34
- notification_channel: ""
34
+ notification_channel: ''
35
35
  # G9: Booked-internal consumers retain the stricter 0.2.x posture — a single
36
36
  # literal injection match at write/destructive tier denies (does not merely
37
37
  # warn). External profiles inherit the schema default `false`.
@@ -14,7 +14,7 @@ blocked_paths:
14
14
  - .github/workflows/release.yml
15
15
  - SECURITY.md
16
16
  - THREAT_MODEL.md
17
- notification_channel: ""
17
+ notification_channel: ''
18
18
  # G9: Booked-internal consumers retain the stricter 0.2.x posture — a single
19
19
  # literal injection match at write/destructive tier denies (does not merely
20
20
  # warn). External profiles (open-source, client-engagement, minimal, lit-wc)
@@ -15,7 +15,7 @@ blocked_paths:
15
15
  - THREAT_MODEL.md
16
16
  - secrets/
17
17
  - credentials/
18
- notification_channel: ""
18
+ notification_channel: ''
19
19
  context_protection:
20
20
  delegate_to_subagent:
21
21
  - pnpm run build
@@ -14,4 +14,4 @@ blocked_paths:
14
14
  - .github/workflows/release.yml
15
15
  - .github/workflows/publish.yml
16
16
  - tokens/
17
- notification_channel: ""
17
+ notification_channel: ''
@@ -8,4 +8,4 @@ block_ai_attribution: true
8
8
  blocked_paths:
9
9
  - .env
10
10
  - .env.*
11
- notification_channel: ""
11
+ notification_channel: ''