@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.
- package/README.md +15 -0
- package/THREAT_MODEL.md +582 -0
- package/dist/audit/append.js +1 -1
- package/dist/cli/doctor.js +11 -12
- package/dist/cli/hook.d.ts +37 -3
- package/dist/cli/hook.js +167 -5
- package/dist/cli/init.js +14 -26
- package/dist/cli/install/canonical.js +18 -3
- package/dist/cli/install/commit-msg.js +1 -2
- package/dist/cli/install/copy.js +4 -13
- package/dist/cli/install/fs-safe.js +5 -16
- package/dist/cli/install/gitignore.js +1 -5
- package/dist/cli/install/pre-push.js +3 -8
- package/dist/cli/install/settings-merge.js +79 -16
- package/dist/cli/upgrade.js +14 -10
- package/dist/gateway/downstream.js +1 -2
- package/dist/gateway/live-state.js +3 -1
- package/dist/gateway/log.js +1 -3
- package/dist/gateway/middleware/audit.js +1 -1
- package/dist/gateway/middleware/injection.js +3 -9
- package/dist/gateway/middleware/policy.js +3 -1
- package/dist/gateway/middleware/redact.js +1 -1
- package/dist/gateway/observability/codex-telemetry.js +1 -2
- package/dist/gateway/reviewers/claude-self.js +10 -6
- package/dist/hooks/bash-scanner/blocked-scan.d.ts +26 -0
- package/dist/hooks/bash-scanner/blocked-scan.js +467 -0
- package/dist/hooks/bash-scanner/index.d.ts +41 -0
- package/dist/hooks/bash-scanner/index.js +62 -0
- package/dist/hooks/bash-scanner/parse-fail-closed.d.ts +31 -0
- package/dist/hooks/bash-scanner/parse-fail-closed.js +27 -0
- package/dist/hooks/bash-scanner/parser.d.ts +42 -0
- package/dist/hooks/bash-scanner/parser.js +92 -0
- package/dist/hooks/bash-scanner/protected-scan.d.ts +76 -0
- package/dist/hooks/bash-scanner/protected-scan.js +815 -0
- package/dist/hooks/bash-scanner/verdict.d.ts +80 -0
- package/dist/hooks/bash-scanner/verdict.js +49 -0
- package/dist/hooks/bash-scanner/walker.d.ts +165 -0
- package/dist/hooks/bash-scanner/walker.js +7954 -0
- package/dist/hooks/push-gate/base.js +2 -6
- package/dist/hooks/push-gate/codex-runner.js +3 -1
- package/dist/hooks/push-gate/index.js +9 -10
- package/dist/policy/loader.js +4 -1
- package/dist/registry/tofu-gate.js +2 -2
- package/hooks/_lib/cmd-segments.sh +32 -7
- package/hooks/_lib/interpreter-scanner.sh +71 -0
- package/hooks/_lib/path-normalize.sh +30 -3
- package/hooks/blocked-paths-bash-gate.sh +141 -277
- package/hooks/protected-paths-bash-gate.sh +230 -326
- package/package.json +3 -2
- package/profiles/bst-internal-no-codex.yaml +1 -1
- package/profiles/bst-internal.yaml +1 -1
- package/profiles/client-engagement.yaml +1 -1
- package/profiles/lit-wc.yaml +1 -1
- package/profiles/minimal.yaml +1 -1
- package/profiles/open-source-no-codex.yaml +1 -1
- package/profiles/open-source.yaml +1 -1
- package/scripts/postinstall.mjs +1 -2
- 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
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/policy/loader.js
CHANGED
|
@@ -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
|
|
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
|
|
27
|
-
import { classifyServers, updateStore
|
|
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
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
62
|
-
#
|
|
63
|
-
|
|
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
|