@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
@@ -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';
@@ -87,14 +87,29 @@
87
87
  # correct. We only need the mask to suppress matching; the captured
88
88
  # payload is read off the original string.
89
89
  #
90
- # Limitation: ONE level of unwrapping. A wrapper inside a wrapper
91
- # (`bash -c "bash -c 'innermost'"`) emits only the second-level payload
92
- # (`bash -c 'innermost'`), not the third-level. This is enough for
93
- # every consumer-reported bypass; deeper nesting can be added later
94
- # without changing the contract.
90
+ # 0.21.2 helix-022 #3: recurse to fixed point with depth bound 8.
91
+ # Pre-fix the function did exactly ONE level of unwrap, so
92
+ # `bash -lc "bash -lc 'printf x > .rea/HALT'"` emitted the
93
+ # middle wrapper as a segment but NEVER the inner `printf x > ...`.
94
+ # Now each extracted payload is re-fed through the unwrap until
95
+ # either no payload is found (fixed point) or depth 8 is reached.
96
+ # Depth limit prevents pathological inputs; on overflow the helper
97
+ # emits a stderr advisory but does not refuse — caller falls back
98
+ # to logical-form-only enforcement of the partial unwrap.
95
99
  _rea_unwrap_nested_shells() {
100
+ _rea_unwrap_at_depth "$1" 0
101
+ }
102
+
103
+ _rea_unwrap_at_depth() {
96
104
  local cmd="$1"
105
+ local depth="$2"
106
+ local max_depth=8
97
107
  printf '%s\n' "$cmd"
108
+ if [[ $depth -ge $max_depth ]]; then
109
+ printf 'rea: nested-shell unwrap depth limit (%d) reached on payload %.80s...\n' \
110
+ "$max_depth" "$cmd" >&2
111
+ return 0
112
+ fi
98
113
  # Build a mask where in-quote `"` `'` `;` `&` `|` characters are
99
114
  # replaced with multi-byte sentinels so the wrapper regex below
100
115
  # cannot match wrapper syntax that lives inside outer quoted prose.
@@ -172,7 +187,10 @@ _rea_unwrap_nested_shells() {
172
187
  # masked form; payload extraction reads the raw form using the same
173
188
  # offsets. Because the mask is byte-for-byte width-preserving, the
174
189
  # same RSTART/RLENGTH applies to both.
175
- printf '' | awk -v raw="$cmd" -v masked="$masked" '
190
+ #
191
+ # 0.21.2: capture payloads to a local var; iterate to recurse.
192
+ local _unwrap_payloads
193
+ _unwrap_payloads=$(printf '' | awk -v raw="$cmd" -v masked="$masked" '
176
194
  BEGIN {
177
195
  # Wrapper-prefix regex: shell-name + optional flag tokens + -c-style flag.
178
196
  # Each flag token is `-` followed by 1+ letters and trailing space.
@@ -263,7 +281,14 @@ _rea_unwrap_nested_shells() {
263
281
  }
264
282
  # Empty action with no input rules — explicitly drive the loop from
265
283
  # END so awk does not require any input records.
266
- END {}'
284
+ END {}')
285
+ # Recurse on each extracted payload with depth+1.
286
+ if [[ -n "$_unwrap_payloads" ]]; then
287
+ while IFS= read -r _unwrap_p; do
288
+ [[ -z "$_unwrap_p" ]] && continue
289
+ _rea_unwrap_at_depth "$_unwrap_p" $((depth + 1))
290
+ done <<< "$_unwrap_payloads"
291
+ fi
267
292
  }
268
293
 
269
294
  # Split $1 on shell command separators. Emits one segment per line on
@@ -0,0 +1,71 @@
1
+ # shellcheck shell=bash
2
+ # hooks/_lib/interpreter-scanner.sh — extract write-operation targets
3
+ # from interpreter `-e` / `--eval` invocations.
4
+ #
5
+ # 0.21.2 helix-022 #2: shared between blocked-paths-bash-gate.sh and
6
+ # protected-paths-bash-gate.sh. Pre-fix the protected gate had no
7
+ # interpreter scanner — `node -e "fs.writeFileSync('.rea/HALT','x')"`
8
+ # bypassed the protected-path check while the soft blocked-paths gate
9
+ # (which had its own copy of the scanner) caught equivalent writes
10
+ # against soft-list paths.
11
+ #
12
+ # Coverage:
13
+ # node -e | --eval | -p | --print (also fs.writeFile / appendFile
14
+ # / createWriteStream variants)
15
+ # python | python2 | python3 -c (open(...,'w'), pathlib.write_*)
16
+ # ruby -e (File.write, IO.write)
17
+ # perl -e (open + print, syswrite)
18
+ #
19
+ # Returns: stdout — one path per line, raw (post-quote-strip but no
20
+ # normalization). Caller passes each through their own _normalize_target
21
+ # / _check_token / rea_path_is_protected pipeline.
22
+
23
+ # Extract write targets from an interpreter -e / --eval invocation.
24
+ # Usage: rea_interpreter_write_targets "$segment"
25
+ # Returns each path on a separate line on stdout.
26
+ # No output means no interpreter-write-shape was detected.
27
+ rea_interpreter_write_targets() {
28
+ local segment="$1"
29
+
30
+ # Node — fs.writeFileSync / fs.writeFile / fs.appendFileSync /
31
+ # fs.appendFile / fs.createWriteStream
32
+ if [[ "$segment" =~ (^|[[:space:]])node[[:space:]]+(-e|--eval|-p|--print)[[:space:]]+ ]]; then
33
+ printf '%s' "$segment" \
34
+ | grep -oE "fs\.(writeFileSync|writeFile|appendFileSync|appendFile|createWriteStream)\([[:space:]]*[\"'][^\"']+[\"']" \
35
+ | sed -E "s/.*\([[:space:]]*[\"']([^\"']+)[\"'].*/\\1/" \
36
+ || true
37
+ fi
38
+
39
+ # Python — open(PATH, 'w'|'wb'|'a'|'ab'|'w+'|'r+'|'x'|'xb') |
40
+ # pathlib.Path(PATH).write_text|.write_bytes
41
+ if [[ "$segment" =~ (^|[[:space:]])python[23]?[[:space:]]+(-c) ]]; then
42
+ # open(...,'w'-style)
43
+ printf '%s' "$segment" \
44
+ | grep -oE "open\([[:space:]]*[\"'][^\"']+[\"'][[:space:]]*,[[:space:]]*[\"'](w|wb|a|ab|w\+|r\+|x|xb)[\"']" \
45
+ | sed -E "s/open\([[:space:]]*[\"']([^\"']+)[\"'].*/\\1/" \
46
+ || true
47
+ # pathlib write_text / write_bytes
48
+ printf '%s' "$segment" \
49
+ | grep -oE "Path\([[:space:]]*[\"'][^\"']+[\"'][[:space:]]*\)\.(write_text|write_bytes)" \
50
+ | sed -E "s/Path\([[:space:]]*[\"']([^\"']+)[\"'].*/\\1/" \
51
+ || true
52
+ fi
53
+
54
+ # Ruby — File.write(PATH, ...) | IO.write(PATH, ...)
55
+ if [[ "$segment" =~ (^|[[:space:]])ruby[[:space:]]+(-e) ]]; then
56
+ printf '%s' "$segment" \
57
+ | grep -oE "(File|IO)\.write\([[:space:]]*[\"'][^\"']+[\"']" \
58
+ | sed -E "s/.*\([[:space:]]*[\"']([^\"']+)[\"'].*/\\1/" \
59
+ || true
60
+ fi
61
+
62
+ # Perl — open(FH, '>file') | open(FH, '>>file') | sysopen(... O_WRONLY)
63
+ # Conservative: capture the literal `>file` / `>>file` form, which is
64
+ # the common shell-style spelling Perl accepts in 2-arg open.
65
+ if [[ "$segment" =~ (^|[[:space:]])perl[[:space:]]+(-e) ]]; then
66
+ printf '%s' "$segment" \
67
+ | grep -oE "open\([[:space:]]*[A-Z_]+,[[:space:]]*[\"']>{1,2}[^\"']+[\"']" \
68
+ | sed -E "s/.*[\"']>+([^\"']+)[\"'].*/\\1/" \
69
+ || true
70
+ fi
71
+ }
@@ -58,9 +58,36 @@ resolve_parent_realpath() {
58
58
  local parent_dir
59
59
  parent_dir=$(dirname -- "$target_path")
60
60
  if [[ ! -d "$parent_dir" ]]; then
61
- # Parent doesn't exist yetcaller should treat as "no realpath
62
- # available" and fall back to logical-path checks. Return empty.
63
- printf ''
61
+ # 0.21.2 helix-022 #1: parent doesn't exist on disk but a
62
+ # SYMLINK along the path might. Walk up to the nearest existing
63
+ # ancestor, resolve THAT, then append the unresolved tail. Pre-fix
64
+ # this returned empty for any path whose terminal directory is
65
+ # created mid-segment (`mkdir -p linkroot/.husky/sub` followed by
66
+ # a redirect into `.husky/sub/X`); the caller fell back to logical-
67
+ # path-only enforcement, restoring the symlink-walk bypass.
68
+ local walk="$parent_dir"
69
+ local tail=""
70
+ while [[ -n "$walk" && "$walk" != "/" && "$walk" != "." && ! -d "$walk" ]]; do
71
+ tail="$(basename -- "$walk")${tail:+/$tail}"
72
+ walk="$(dirname -- "$walk")"
73
+ done
74
+ if [[ -z "$walk" || "$walk" == "/" ]]; then
75
+ # Walked all the way up; no existing ancestor inside the project.
76
+ # Caller still has the logical-path check; return empty.
77
+ printf ''
78
+ return 0
79
+ fi
80
+ local resolved_walk
81
+ resolved_walk=$(cd -P -- "$walk" 2>/dev/null && pwd -P 2>/dev/null) || resolved_walk=""
82
+ if [[ -z "$resolved_walk" ]]; then
83
+ printf ''
84
+ return 0
85
+ fi
86
+ if [[ -n "$tail" ]]; then
87
+ printf '%s/%s' "$resolved_walk" "$tail"
88
+ else
89
+ printf '%s' "$resolved_walk"
90
+ fi
64
91
  return 0
65
92
  fi
66
93
  # `cd -P` follows symlinks; `pwd -P` prints the resolved physical