@bookedsolid/rea 0.22.0 → 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 (55) 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/blocked-paths-bash-gate.sh +142 -272
  45. package/hooks/protected-paths-bash-gate.sh +227 -511
  46. package/package.json +3 -2
  47. package/profiles/bst-internal-no-codex.yaml +1 -1
  48. package/profiles/bst-internal.yaml +1 -1
  49. package/profiles/client-engagement.yaml +1 -1
  50. package/profiles/lit-wc.yaml +1 -1
  51. package/profiles/minimal.yaml +1 -1
  52. package/profiles/open-source-no-codex.yaml +1 -1
  53. package/profiles/open-source.yaml +1 -1
  54. package/scripts/postinstall.mjs +1 -2
  55. package/scripts/run-vitest.mjs +117 -0
@@ -55,9 +55,7 @@ export function resolveBaseRef(git, options = {}) {
55
55
  // some-other-branch` invocations, where the local checkout's HEAD is a
56
56
  // different branch entirely and the resulting diff would compare the
57
57
  // wrong commits.
58
- const headRef = options.headRef !== undefined && options.headRef.length > 0
59
- ? options.headRef
60
- : 'HEAD';
58
+ const headRef = options.headRef !== undefined && options.headRef.length > 0 ? options.headRef : 'HEAD';
61
59
  const requested = options.lastNCommits;
62
60
  const tryDepth = (k) => git.tryRevParse(['--verify', '--quiet', `${headRef}~${k}^{commit}`]).trim();
63
61
  // Fast path: requested depth resolves directly.
@@ -164,9 +162,7 @@ export function resolveBaseRef(git, options = {}) {
164
162
  // tracking ref (typically `refs/remotes/origin/<branch>`). Returns
165
163
  // empty on branches without an upstream — which is normal for a brand
166
164
  // new feature branch; fall through.
167
- const upstream = git
168
- .tryRevParse(['--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
169
- .trim();
165
+ const upstream = git.tryRevParse(['--abbrev-ref', '--symbolic-full-name', '@{upstream}']).trim();
170
166
  if (upstream.length > 0) {
171
167
  return { ref: upstream, source: 'upstream' };
172
168
  }
@@ -190,7 +190,9 @@ export async function runCodexReview(options) {
190
190
  '--json',
191
191
  '--ephemeral',
192
192
  ];
193
- const args = options.prompt !== undefined && options.prompt.length > 0 ? [...baseArgs, options.prompt] : baseArgs;
193
+ const args = options.prompt !== undefined && options.prompt.length > 0
194
+ ? [...baseArgs, options.prompt]
195
+ : baseArgs;
194
196
  // 0.16.3 helix-016.1 #1 fix: pre-flight probe for the codex CLI before
195
197
  // we hand control to the long-running review subprocess. The original
196
198
  // try/catch around `spawner(...)` only caught synchronous ENOENT; on
@@ -31,7 +31,7 @@ import { resolveBaseRef } from './base.js';
31
31
  import { createRealGitExecutor, runCodexReview, CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, IRON_GATE_DEFAULT_MODEL, IRON_GATE_DEFAULT_REASONING, } from './codex-runner.js';
32
32
  import { summarizeReview } from './findings.js';
33
33
  import { renderBanner, writeLastReview } from './report.js';
34
- import { isFlip, lookupVerdict, writeVerdict, } from './verdict-cache.js';
34
+ import { isFlip, lookupVerdict, writeVerdict } from './verdict-cache.js';
35
35
  /**
36
36
  * Parse the raw pre-push stdin text into refspecs. Each line is four
37
37
  * whitespace-separated fields. Blank lines and malformed lines are
@@ -266,7 +266,9 @@ export async function runPushGate(deps) {
266
266
  }
267
267
  if (headSha.length === 0) {
268
268
  stderr('PUSH BLOCKED: could not resolve HEAD SHA. Is this a valid git repo?\n');
269
- await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, fullPolicy, { kind: 'head-sha-missing' });
269
+ await safeAppend(appendAuditFn, deps.baseDir, EVT_ERROR, fullPolicy, {
270
+ kind: 'head-sha-missing',
271
+ });
270
272
  return { status: 'error', exitCode: 2, summary: 'head-sha-missing' };
271
273
  }
272
274
  // 4b. Auto-narrow probe (J / 0.13.0). When the resolved base is far
@@ -317,8 +319,7 @@ export async function runPushGate(deps) {
317
319
  baseFromPushedRemoteTip;
318
320
  if (autoNarrowEligible) {
319
321
  originalCommitCount = git.revListCount(base.ref, headSha);
320
- if (originalCommitCount !== null &&
321
- originalCommitCount > policy.auto_narrow_threshold) {
322
+ if (originalCommitCount !== null && originalCommitCount > policy.auto_narrow_threshold) {
322
323
  const fallbackWindow = PUSH_GATE_DEFAULT_LAST_N_COMMITS_FALLBACK;
323
324
  const narrowed = resolveBaseRef(git, {
324
325
  lastNCommits: fallbackWindow,
@@ -361,8 +362,8 @@ export async function runPushGate(deps) {
361
362
  const cacheLookup = policy.cache_ttl_ms > 0 ? lookupVerdict(deps.baseDir, headSha) : { hit: false };
362
363
  if (cacheLookup.hit && cacheLookup.entry !== undefined) {
363
364
  const cached = cacheLookup.entry;
364
- const cachedBlocked = cached.verdict === 'blocking'
365
- || (cached.verdict === 'concerns' && policy.concerns_blocks && !isConcernsOverrideSet(env));
365
+ const cachedBlocked = cached.verdict === 'blocking' ||
366
+ (cached.verdict === 'concerns' && policy.concerns_blocks && !isConcernsOverrideSet(env));
366
367
  // 0.19.1 P3-3 (code-reviewer): emit EVT_CACHE_HIT (forensic detail
367
368
  // for the cache layer specifically) AND EVT_REVIEWED (the canonical
368
369
  // verdict event with `cache_hit: true` metadata). Operators
@@ -421,10 +422,8 @@ export async function runPushGate(deps) {
421
422
  : {}),
422
423
  });
423
424
  const summary = summarizeReview(codexResult.reviewText);
424
- const blocked = summary.verdict === 'blocking'
425
- || (summary.verdict === 'concerns'
426
- && policy.concerns_blocks
427
- && !isConcernsOverrideSet(env));
425
+ const blocked = summary.verdict === 'blocking' ||
426
+ (summary.verdict === 'concerns' && policy.concerns_blocks && !isConcernsOverrideSet(env));
428
427
  const lastReviewPath = path.join(deps.baseDir, '.rea', 'last-review.json');
429
428
  const payload = writeLastReviewFn({
430
429
  baseDir: deps.baseDir,
@@ -63,7 +63,10 @@ const ReviewPolicySchema = z
63
63
  // (NUL, NL, CR, escape sequences) through the `-c model="<value>"`
64
64
  // injection point. Accepts published codex model names; rejects
65
65
  // re-quote / TOML-escape edge cases.
66
- codex_model: z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/).optional(),
66
+ codex_model: z
67
+ .string()
68
+ .regex(/^[a-zA-Z0-9._-]{1,64}$/)
69
+ .optional(),
67
70
  /**
68
71
  * Codex reasoning effort knob (0.13.4+). Pinned via
69
72
  * `-c model_reasoning_effort="<level>"` on every invocation. Only
@@ -23,8 +23,8 @@
23
23
  */
24
24
  import { Tier, InvocationStatus } from '../policy/types.js';
25
25
  import { appendAuditRecord } from '../audit/append.js';
26
- import { loadFingerprintStore, saveFingerprintStore, } from './fingerprints-store.js';
27
- import { classifyServers, updateStore, } from './tofu.js';
26
+ import { loadFingerprintStore, saveFingerprintStore } from './fingerprints-store.js';
27
+ import { classifyServers, updateStore } from './tofu.js';
28
28
  import { createLogger } from '../gateway/log.js';
29
29
  const TOFU_TOOL_NAME = 'rea.tofu';
30
30
  const TOFU_SERVER_NAME = 'rea';
@@ -1,293 +1,163 @@
1
- #!/bin/bash
1
+ #!/usr/bin/env bash
2
2
  # PreToolUse hook: blocked-paths-bash-gate.sh
3
- # Fires BEFORE every Bash tool call.
4
- # Refuses Bash commands that write to entries in policy.yaml's
5
- # `blocked_paths` list via shell redirection or write-flag utilities.
6
3
  #
7
- # Background (0.16.3, discord-ops Round 9 #1): the existing
8
- # blocked-paths-enforcer.sh only fires on Write/Edit/MultiEdit/
9
- # NotebookEdit. Bash-tier writes to blocked_paths entries bypass it
10
- # entirely:
4
+ # 0.23.0+ thin shim. Forwards stdin to `rea hook scan-bash --mode blocked`.
5
+ # See protected-paths-bash-gate.sh for the architectural rationale + CLI
6
+ # resolution strategy + verdict-verification model; this shim differs
7
+ # only in the --mode flag.
11
8
  #
12
- # echo x > .env
13
- # cp src.txt .env
14
- # sed -i '' '1d' .env.production
15
- # node -e "fs.writeFileSync('.env','x')"
9
+ # Codex round 4 Finding 2: 2-tier sandboxed resolver (drops PATH lookup
10
+ # and node_modules/.bin/rea symlink). See protected-paths-bash-gate.sh
11
+ # for rationale.
16
12
  #
17
- # `protected-paths-bash-gate.sh` covers the HARD list (HALT, policy.yaml,
18
- # settings.json, .husky/*) — but the soft, runtime-configurable
19
- # `blocked_paths` list never had a Bash-tier counterpart. discord-ops
20
- # independently caught this gap during their cycle 9 audit.
21
- #
22
- # This hook closes the gap by reading the same `blocked_paths` list that
23
- # blocked-paths-enforcer.sh reads, applying the same redirect / write-
24
- # utility detection pipeline as protected-paths-bash-gate.sh, and
25
- # blocking when the resolved target matches any entry.
13
+ # Codex round 2 R2-3: REA_NODE_CLI env-var honoring REMOVED.
26
14
  #
27
15
  # Exit codes:
28
- # 0 = no blocked-path write detected — allow
29
- # 2 = blocked-path write via Bash detected block
30
- #
31
- # Detection: `node -e "fs.writeFileSync('.env','x')"` — Node's
32
- # fs.writeFileSync called against a blocked path is also detected by
33
- # argument scan. Other interpreter constructions (perl, python, etc.)
34
- # remain a known coverage gap for the same reason the env-file-protection
35
- # hook lists hard caps in its header comment: defense-in-depth, not an
36
- # adversarial firewall.
16
+ # 0 = allow
17
+ # 2 = block (verdict, missing CLI, malformed payload, verdict mismatch)
37
18
 
38
19
  set -uo pipefail
39
20
 
40
- # shellcheck source=_lib/cmd-segments.sh
41
- source "$(dirname "$0")/_lib/cmd-segments.sh"
42
- # shellcheck source=_lib/path-normalize.sh
43
- source "$(dirname "$0")/_lib/path-normalize.sh"
44
- # shellcheck source=_lib/policy-read.sh
45
- source "$(dirname "$0")/_lib/policy-read.sh"
46
- # shellcheck source=_lib/halt-check.sh
47
- source "$(dirname "$0")/_lib/halt-check.sh"
48
- # shellcheck source=_lib/interpreter-scanner.sh
49
- source "$(dirname "$0")/_lib/interpreter-scanner.sh"
50
-
51
- INPUT=$(cat)
21
+ proj="${CLAUDE_PROJECT_DIR:-$(pwd)}"
22
+
23
+ # 2-tier sandboxed CLI resolver. NO PATH lookup, NO env-var override.
24
+ REA_ARGV=()
25
+ RESOLVED_CLI_PATH=""
26
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
27
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
28
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
29
+ elif [ -f "$proj/dist/cli/index.js" ]; then
30
+ REA_ARGV=(node "$proj/dist/cli/index.js")
31
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
32
+ fi
52
33
 
53
- if ! command -v jq >/dev/null 2>&1; then
54
- printf 'REA ERROR: jq is required but not installed.\n' >&2
34
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
35
+ printf 'rea: CLI not found at sandboxed tiers (node_modules/@bookedsolid/rea/dist or dist/).\n' >&2
36
+ printf 'Install @bookedsolid/rea via npm/pnpm and run `rea doctor`.\n' >&2
37
+ printf 'Refusing the Bash command on uncertainty.\n' >&2
55
38
  exit 2
56
39
  fi
57
40
 
58
- check_halt
59
- REA_ROOT=$(rea_root)
60
-
61
- CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
62
- if [[ -z "$CMD" ]]; then
63
- exit 0
41
+ # Codex round 4 Finding 2 + round 5 F2 tier defense: realpath the
42
+ # resolved CLI; PRIMARY check is project-root containment, SECONDARY
43
+ # is ancestor `package.json` with the protected name. See
44
+ # protected-paths-bash-gate.sh for the full rationale.
45
+ if ! command -v node >/dev/null 2>&1; then
46
+ printf 'rea: node not on PATH (required to realpath verify scan-bash CLI). Refusing.\n' >&2
47
+ exit 2
48
+ fi
49
+ sandbox_check=$(node -e '
50
+ const fs = require("fs");
51
+ const path = require("path");
52
+ const cli = process.argv[1];
53
+ const projDir = process.argv[2];
54
+ let real;
55
+ try { real = fs.realpathSync(cli); } catch (e) {
56
+ process.stdout.write("bad:realpath:" + (e && e.message ? e.message : String(e)));
57
+ process.exit(1);
58
+ }
59
+ // PRIMARY (round 5 F2): realCli must live INSIDE realProj. Catches
60
+ // node_modules/@bookedsolid/rea -> /tmp/sym-attacker symlink-out.
61
+ let realProj;
62
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
63
+ process.stdout.write("bad:realpath-proj:" + (e && e.message ? e.message : String(e)));
64
+ process.exit(1);
65
+ }
66
+ const projWithSep = realProj.endsWith(path.sep) ? realProj : realProj + path.sep;
67
+ if (!(real === realProj || real.startsWith(projWithSep))) {
68
+ process.stdout.write("bad:cli-escapes-project:" + real + ":proj=" + realProj);
69
+ process.exit(1);
70
+ }
71
+ // SECONDARY (round 4 #2): shape + ancestor `package.json` with
72
+ // `@bookedsolid/rea`. Guards against intra-project hijack.
73
+ const expectedEnd = path.join("dist", "cli", "index.js");
74
+ if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
75
+ process.stdout.write("bad:cli-shape:" + real);
76
+ process.exit(1);
77
+ }
78
+ let cur = path.dirname(path.dirname(path.dirname(real)));
79
+ let found = false;
80
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
81
+ const pj = path.join(cur, "package.json");
82
+ if (fs.existsSync(pj)) {
83
+ try {
84
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
85
+ if (data && data.name === "@bookedsolid/rea") {
86
+ found = true;
87
+ break;
88
+ }
89
+ } catch (e) {
90
+ // Continue.
91
+ }
92
+ }
93
+ cur = path.dirname(cur);
94
+ }
95
+ if (!found) {
96
+ process.stdout.write("bad:no-rea-pkg:" + real);
97
+ process.exit(1);
98
+ }
99
+ process.stdout.write("ok");
100
+ process.exit(0);
101
+ ' "$RESOLVED_CLI_PATH" "$proj" 2>&1)
102
+ sandbox_status=$?
103
+ if [ "$sandbox_status" -ne 0 ] || [ "$sandbox_check" != "ok" ]; then
104
+ printf 'rea: scan-bash CLI realpath escapes sandbox (%s). Refusing.\n' "$sandbox_check" >&2
105
+ exit 2
64
106
  fi
65
107
 
66
- # Load blocked_paths list. If the policy is missing or the list is empty,
67
- # this hook is a no-op (matches blocked-paths-enforcer.sh semantics).
68
- BLOCKED_PATHS=()
69
- while IFS= read -r entry; do
70
- [[ -z "$entry" ]] && continue
71
- BLOCKED_PATHS+=("$entry")
72
- done < <(policy_list "blocked_paths")
73
-
74
- if [[ ${#BLOCKED_PATHS[@]} -eq 0 ]]; then
108
+ payload=$(cat)
109
+ if [ -z "$payload" ]; then
75
110
  exit 0
76
111
  fi
77
112
 
78
- # Match a normalized project-relative path against the loaded
79
- # blocked_paths list using the same matching rules as
80
- # blocked-paths-enforcer.sh:
81
- # - directory match (entry ends with `/`) → prefix match
82
- # - glob entry (contains `*`) → ERE conversion + anchored match
83
- # - otherwise exact (case-insensitive) match
84
- # Returns 0 + sets MATCHED on hit, 1 on no hit.
85
- MATCHED=""
86
- _match_blocked() {
87
- local target_lc="$1"
88
- MATCHED=""
89
- local entry entry_lc regex
90
- for entry in "${BLOCKED_PATHS[@]}"; do
91
- entry_lc=$(printf '%s' "$entry" | tr '[:upper:]' '[:lower:]')
92
- if [[ "$entry_lc" == */ ]]; then
93
- if [[ "$target_lc" == "$entry_lc"* ]] || [[ "$target_lc" == "${entry_lc%/}" ]]; then
94
- MATCHED="$entry"
95
- return 0
96
- fi
97
- continue
113
+ verdict=$(printf '%s' "$payload" | "${REA_ARGV[@]}" hook scan-bash --mode blocked)
114
+ status=$?
115
+
116
+ verifier='try {
117
+ const raw = require("fs").readFileSync(0, "utf8");
118
+ if (raw.trim().length === 0) { process.stdout.write("bad:empty"); process.exit(1); }
119
+ const v = JSON.parse(raw);
120
+ if (typeof v !== "object" || v === null || Array.isArray(v)) {
121
+ process.stdout.write("bad:non-object"); process.exit(1);
122
+ }
123
+ if (v.verdict !== "allow" && v.verdict !== "block") {
124
+ process.stdout.write("bad:verdict-shape:" + String(v.verdict)); process.exit(1);
125
+ }
126
+ process.stdout.write("ok:" + v.verdict); process.exit(0);
127
+ } catch (e) {
128
+ process.stdout.write("bad:" + (e && e.message ? e.message : String(e))); process.exit(1);
129
+ }'
130
+
131
+ verdict_check=$(printf '%s' "$verdict" | node -e "$verifier" 2>&1)
132
+ verdict_check_status=$?
133
+
134
+ case "$status" in
135
+ 0)
136
+ if [ "$verdict_check_status" -ne 0 ]; then
137
+ printf 'rea: scan-bash exited 0 but verdict JSON is malformed (%s). Refusing on uncertainty.\n' "$verdict_check" >&2
138
+ exit 2
98
139
  fi
99
- if [[ "$entry" == *'*'* ]]; then
100
- regex=$(printf '%s' "$entry_lc" | sed 's/\./\\./g; s/\*/.*/g')
101
- if printf '%s' "$target_lc" | grep -qE "^${regex}$"; then
102
- MATCHED="$entry"
103
- return 0
104
- fi
105
- continue
140
+ if [ "$verdict_check" != "ok:allow" ]; then
141
+ printf 'rea: scan-bash exit 0 but verdict says %s. Refusing on uncertainty.\n' "$verdict_check" >&2
142
+ exit 2
106
143
  fi
107
- if [[ "$target_lc" == "$entry_lc" ]]; then
108
- MATCHED="$entry"
109
- return 0
144
+ exit 0
145
+ ;;
146
+ 2)
147
+ if [ "$verdict_check_status" -ne 0 ]; then
148
+ exit 2
110
149
  fi
111
- done
112
- return 1
113
- }
114
-
115
- # Normalize a path token and apply the same `..` walk + outside-REA_ROOT
116
- # sentinel trick as protected-paths-bash-gate.sh::_normalize_target.
117
- # Returns the normalized lowercased project-relative path on stdout, or
118
- # `__rea_outside_root__:<resolved>` when the path resolves outside the
119
- # project root.
120
- _normalize_target() {
121
- local t="$1"
122
- if [[ "$t" =~ ^\"(.*)\"$ ]]; then t="${BASH_REMATCH[1]}"; fi
123
- if [[ "$t" =~ ^\'(.*)\'$ ]]; then t="${BASH_REMATCH[1]}"; fi
124
- case "/$t/" in
125
- */../*)
126
- local abs="$t"
127
- [[ "$abs" != /* ]] && abs="$REA_ROOT/$abs"
128
- local -a raw_parts parts=()
129
- IFS='/' read -ra raw_parts <<<"$abs"
130
- for part in "${raw_parts[@]}"; do
131
- case "$part" in
132
- ''|.) continue ;;
133
- ..) [[ "${#parts[@]}" -gt 0 ]] && unset 'parts[${#parts[@]}-1]' ;;
134
- *) parts+=("$part") ;;
135
- esac
136
- done
137
- t="/$(IFS=/; printf '%s' "${parts[*]}")"
138
- if [[ "$t" != "$REA_ROOT" && "$t" != "$REA_ROOT"/* ]]; then
139
- printf '__rea_outside_root__:%s' "$t"
140
- return 0
141
- fi
142
- ;;
143
- esac
144
- t=$(normalize_path "$t")
145
- printf '%s' "$t" | tr '[:upper:]' '[:lower:]'
146
- }
147
-
148
- _refuse() {
149
- local pattern="$1" target="$2" segment="$3"
150
- {
151
- printf 'BLOCKED PATH (bash): write denied by policy\n'
152
- printf '\n'
153
- printf ' Blocked by: %s\n' "$pattern"
154
- printf ' Resolved target: %s\n' "$target"
155
- printf ' Segment: %s\n' "$segment"
156
- printf '\n'
157
- printf ' Source: .rea/policy.yaml → blocked_paths\n'
158
- printf ' Rule: blocked_paths entries are unreachable via Bash redirects\n'
159
- printf ' too — not just Write/Edit/MultiEdit. To modify, a human\n'
160
- printf ' must edit directly or update blocked_paths in policy.yaml.\n'
161
- } >&2
162
- exit 2
163
- }
164
-
165
- # Check a single resolved-target token. Refuses on hit.
166
- #
167
- # 0.20.1 helix-021 #2: in addition to the logical post-_normalize_target
168
- # form, also check the symlink-resolved form. Pre-fix `ln -s . linkroot;
169
- # printf x > linkroot/.env` had a logical form of `linkroot/.env`
170
- # (no match against blocked_paths) but a resolved form of `.env`
171
- # (which DOES match). Refuse on either match. Write-tier
172
- # `blocked-paths-enforcer.sh` already has this resolution since 0.10.x.
173
- _check_token() {
174
- local token="$1" segment="$2"
175
- [[ -z "$token" ]] && return 0
176
- local resolved
177
- resolved=$(_normalize_target "$token")
178
- if [[ "$resolved" == __rea_outside_root__:* ]]; then
179
- # Outside REA_ROOT → can't be in blocked_paths (blocked_paths is
180
- # project-relative). Allow; the protected-paths gate handles
181
- # outside-root rejection on the protected list itself.
182
- return 0
183
- fi
184
- # Symlink-resolved form via shared helper. Returns empty when the
185
- # parent doesn't exist (legitimate "creating the parent" case);
186
- # outside-REA_ROOT sentinel when the symlink walks out of the
187
- # project (silently allow — same as the logical-path branch above).
188
- local resolved_symlink
189
- resolved_symlink=$(rea_resolved_relative_form "$token")
190
- if [[ "$resolved_symlink" == __rea_outside_root__:* ]]; then
191
- resolved_symlink=""
192
- fi
193
- if _match_blocked "$resolved"; then
194
- _refuse "$MATCHED" "$resolved" "$segment"
195
- fi
196
- if [[ -n "$resolved_symlink" ]] && _match_blocked "$resolved_symlink"; then
197
- _refuse "$MATCHED" "$resolved_symlink" "$segment"
198
- fi
199
- return 0
200
- }
201
-
202
- # Scan one segment for redirect / write-utility / node-fs targets and
203
- # refuse on any hit. Mirrors protected-paths-bash-gate.sh::_check_segment
204
- # layout, with a few additions to catch discord-ops Round 9 #1's exact
205
- # Node-interpreter and sed-script-on-target shapes.
206
- _check_segment() {
207
- local _raw="$1" segment="$2"
208
- [[ -z "$segment" ]] && return 0
209
-
210
- # Same regex set as protected-paths-bash-gate.sh — fd-prefix-aware
211
- # redirects, cp/mv tail target, sed -i target, dd of=, plus a
212
- # token-walk for tee/truncate/install/ln. Keeps behavior consistent
213
- # across the two bash gates.
214
- local re_redirect='(^|[[:space:]])(&>>|&>|[0-9]+>>|[0-9]+>\||[0-9]+>|>>|>\||>)[[:space:]]*([^[:space:]&|;<>]+)'
215
- local re_cpmv='(^|[[:space:]])(cp|mv)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
216
- local re_sed='(^|[[:space:]])sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*[^[:space:]]*)[[:space:]]+[^&|;<>]+[[:space:]]([^[:space:]&|;<>]+)[[:space:]]*$'
217
- local re_dd='(^|[[:space:]])dd[[:space:]]+[^&|;<>]*of=([^[:space:]&|;<>]+)'
218
-
219
- if [[ "$segment" =~ $re_redirect ]]; then
220
- _check_token "${BASH_REMATCH[3]}" "$segment"
221
- fi
222
- if [[ "$segment" =~ $re_cpmv ]]; then
223
- _check_token "${BASH_REMATCH[3]}" "$segment"
224
- fi
225
- if [[ "$segment" =~ $re_sed ]]; then
226
- _check_token "${BASH_REMATCH[3]}" "$segment"
227
- fi
228
- if [[ "$segment" =~ $re_dd ]]; then
229
- _check_token "${BASH_REMATCH[2]}" "$segment"
230
- fi
231
-
232
- # tee / truncate / install / ln — token-walk identical to
233
- # protected-paths-bash-gate.sh.
234
- local _seg_for_walk="$segment"
235
- _seg_for_walk="${_seg_for_walk#"${_seg_for_walk%%[![:space:]]*}"}"
236
- local first_tok
237
- first_tok=$(printf '%s' "$_seg_for_walk" | awk '{print $1}')
238
- case "$first_tok" in
239
- tee|truncate|install|ln)
240
- local found_cmd=""
241
- # shellcheck disable=SC2086
242
- set -- $_seg_for_walk
243
- while [ "$#" -gt 0 ]; do
244
- local tok="$1"
245
- shift
246
- if [[ -z "$found_cmd" ]]; then
247
- case "$tok" in
248
- tee|truncate|install|ln) found_cmd="$tok" ;;
249
- esac
250
- continue
251
- fi
252
- case "$tok" in
253
- --) continue ;;
254
- --*=*) continue ;;
255
- --*)
256
- case "$tok" in
257
- --append|--ignore-interrupts|--no-clobber|--force|--no-target-directory|--symbolic|--no-dereference|--reference=*) continue ;;
258
- *) shift 2>/dev/null || true; continue ;;
259
- esac
260
- ;;
261
- -*)
262
- case "$tok" in
263
- -s*|-m*|-o*|-g*|-t*) shift 2>/dev/null || true ;;
264
- esac
265
- continue
266
- ;;
267
- *)
268
- _check_token "$tok" "$segment"
269
- ;;
270
- esac
271
- done
272
- ;;
273
- esac
274
-
275
- # 0.21.2 helix-022 #2: interpreter scanner factored to
276
- # _lib/interpreter-scanner.sh and shared with protected-paths-bash-gate.
277
- # Covers node -e fs.writeFileSync, python -c open(...,'w'),
278
- # ruby -e File.write, perl -e open(FH,'>...').
279
- local interp_targets
280
- interp_targets=$(rea_interpreter_write_targets "$segment")
281
- if [[ -n "$interp_targets" ]]; then
282
- while IFS= read -r tgt; do
283
- [[ -z "$tgt" ]] && continue
284
- _check_token "$tgt" "$segment"
285
- done <<<"$interp_targets"
286
- fi
287
-
288
- return 0
289
- }
290
-
291
- for_each_segment "$CMD" _check_segment
292
-
293
- exit 0
150
+ if [ "$verdict_check" != "ok:block" ]; then
151
+ printf 'rea: scan-bash exit 2 but verdict says %s. Refusing on uncertainty.\n' "$verdict_check" >&2
152
+ exit 2
153
+ fi
154
+ exit 2
155
+ ;;
156
+ *)
157
+ printf 'rea: scan-bash exited %d (expected 0/2). Refusing on uncertainty.\n' "$status" >&2
158
+ if [ -n "$verdict" ]; then
159
+ printf 'rea: scan-bash stdout was: %s\n' "$verdict" >&2
160
+ fi
161
+ exit 2
162
+ ;;
163
+ esac