@bookedsolid/rea 0.22.0 → 0.23.1
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 +753 -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 +868 -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 +9087 -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/blocked-paths-bash-gate.sh +142 -272
- package/hooks/protected-paths-bash-gate.sh +227 -511
- 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
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocked-paths policy composition. Mirrors `blocked-paths-bash-gate.sh`
|
|
3
|
+
* + the `_match_blocked` helper byte-for-byte:
|
|
4
|
+
*
|
|
5
|
+
* - directory entry (ends with `/`): prefix match OR exact match
|
|
6
|
+
* against the bare-dir form (entry without trailing slash)
|
|
7
|
+
* - glob entry (contains `*`): convert to ERE (escape `.`, `*` → `.*`),
|
|
8
|
+
* anchored, case-insensitive
|
|
9
|
+
* - exact (case-insensitive) otherwise
|
|
10
|
+
*
|
|
11
|
+
* Path normalization is identical to protected-scan.ts: URL-decode,
|
|
12
|
+
* backslash → slash, leading-./ strip, `..` walk-up + outside-root
|
|
13
|
+
* sentinel, optional symlink-resolved form.
|
|
14
|
+
*
|
|
15
|
+
* Out-of-scope-of-blocked: paths outside REA_ROOT. blocked_paths is a
|
|
16
|
+
* project-relative concept; an outside-root write can't match a
|
|
17
|
+
* blocked_paths entry. The PROTECTED-paths gate handles outside-root
|
|
18
|
+
* rejection on the protected list itself.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { allowVerdict, blockVerdict } from './verdict.js';
|
|
23
|
+
function normalizeTarget(reaRoot, raw, form) {
|
|
24
|
+
let t = raw;
|
|
25
|
+
if (t.length >= 2 && t.startsWith('"') && t.endsWith('"'))
|
|
26
|
+
t = t.slice(1, -1);
|
|
27
|
+
if (t.length >= 2 && t.startsWith("'") && t.endsWith("'"))
|
|
28
|
+
t = t.slice(1, -1);
|
|
29
|
+
// Codex round 1 F-15: strip backslash-escapes prefixing path chars.
|
|
30
|
+
t = stripBashBackslashEscapes(t);
|
|
31
|
+
// Codex round 1 F-16: ANSI-C $'…' quoting → dynamic.
|
|
32
|
+
if (t.startsWith("$'") || t.includes("$'")) {
|
|
33
|
+
return {
|
|
34
|
+
pathLc: '__rea_unresolved_expansion__',
|
|
35
|
+
outsideRoot: false,
|
|
36
|
+
expansion: true,
|
|
37
|
+
original: raw,
|
|
38
|
+
resolvedLc: null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (t.includes('$') || t.includes('`')) {
|
|
42
|
+
return {
|
|
43
|
+
pathLc: '__rea_unresolved_expansion__',
|
|
44
|
+
outsideRoot: false,
|
|
45
|
+
expansion: true,
|
|
46
|
+
original: raw,
|
|
47
|
+
resolvedLc: null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Codex round 1 F-14: glob metachars in REDIRECT targets → dynamic.
|
|
51
|
+
// See protected-scan.ts::normalizeTarget for the rationale on
|
|
52
|
+
// scoping this to redirect-form only.
|
|
53
|
+
if (form === 'redirect' && containsGlobMetachar(t)) {
|
|
54
|
+
return {
|
|
55
|
+
pathLc: '__rea_unresolved_expansion__',
|
|
56
|
+
outsideRoot: false,
|
|
57
|
+
expansion: true,
|
|
58
|
+
original: raw,
|
|
59
|
+
resolvedLc: null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Codex round 1 F-24: tilde expansion → dynamic.
|
|
63
|
+
if (t === '~' || t.startsWith('~/') || t.startsWith('~')) {
|
|
64
|
+
return {
|
|
65
|
+
pathLc: '__rea_unresolved_expansion__',
|
|
66
|
+
outsideRoot: false,
|
|
67
|
+
expansion: true,
|
|
68
|
+
original: raw,
|
|
69
|
+
resolvedLc: null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
let normalized = t;
|
|
73
|
+
try {
|
|
74
|
+
normalized = decodeURIComponent(t);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
normalized = t;
|
|
78
|
+
}
|
|
79
|
+
normalized = normalized.replace(/\\/g, '/');
|
|
80
|
+
while (normalized.startsWith('./')) {
|
|
81
|
+
normalized = normalized.slice(2);
|
|
82
|
+
}
|
|
83
|
+
let abs = normalized;
|
|
84
|
+
if (!abs.startsWith('/')) {
|
|
85
|
+
abs = path.join(reaRoot, abs);
|
|
86
|
+
}
|
|
87
|
+
const collapsed = collapseDotDot(abs);
|
|
88
|
+
if (!isInsideRoot(collapsed, reaRoot)) {
|
|
89
|
+
// blocked_paths is project-relative; an outside-root write can't
|
|
90
|
+
// match. Return a non-matching sentinel form that the matcher
|
|
91
|
+
// ignores — same posture as the bash hook's "outside root → exit 0".
|
|
92
|
+
return {
|
|
93
|
+
pathLc: `__outside_root_allowed:${collapsed.toLowerCase()}`,
|
|
94
|
+
outsideRoot: true,
|
|
95
|
+
expansion: false,
|
|
96
|
+
original: raw,
|
|
97
|
+
resolvedLc: null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const projectRelative = collapsed === reaRoot ? '' : collapsed.slice(reaRoot.length + 1);
|
|
101
|
+
const inputHadTrailingSlash = normalized.endsWith('/');
|
|
102
|
+
const pathLc = (inputHadTrailingSlash && projectRelative.length > 0 && !projectRelative.endsWith('/')
|
|
103
|
+
? projectRelative + '/'
|
|
104
|
+
: projectRelative).toLowerCase();
|
|
105
|
+
let resolvedLc = null;
|
|
106
|
+
try {
|
|
107
|
+
const resolved = resolveSymlinksWalkUp(collapsed);
|
|
108
|
+
if (resolved === SYMLINK_DYNAMIC_SENTINEL) {
|
|
109
|
+
// Codex round 2 R2-2: cycle / depth-cap → refuse on uncertainty.
|
|
110
|
+
return {
|
|
111
|
+
pathLc: '__rea_unresolved_expansion__',
|
|
112
|
+
outsideRoot: false,
|
|
113
|
+
expansion: true,
|
|
114
|
+
original: raw,
|
|
115
|
+
resolvedLc: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (resolved !== null) {
|
|
119
|
+
const realRoot = realpathSafe(reaRoot) ?? reaRoot;
|
|
120
|
+
let resolvedRelative = null;
|
|
121
|
+
if (resolved === realRoot)
|
|
122
|
+
resolvedRelative = '';
|
|
123
|
+
else if (resolved.startsWith(realRoot + '/'))
|
|
124
|
+
resolvedRelative = resolved.slice(realRoot.length + 1);
|
|
125
|
+
else if (resolved.startsWith(reaRoot + '/'))
|
|
126
|
+
resolvedRelative = resolved.slice(reaRoot.length + 1);
|
|
127
|
+
if (resolvedRelative !== null) {
|
|
128
|
+
const candidate = resolvedRelative.toLowerCase();
|
|
129
|
+
if (candidate !== pathLc)
|
|
130
|
+
resolvedLc = candidate;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* best-effort */
|
|
136
|
+
}
|
|
137
|
+
return { pathLc, outsideRoot: false, expansion: false, original: raw, resolvedLc };
|
|
138
|
+
}
|
|
139
|
+
function collapseDotDot(absPath) {
|
|
140
|
+
const parts = absPath.split('/');
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const p of parts) {
|
|
143
|
+
if (p === '' || p === '.')
|
|
144
|
+
continue;
|
|
145
|
+
if (p === '..') {
|
|
146
|
+
out.pop();
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
out.push(p);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return '/' + out.join('/');
|
|
153
|
+
}
|
|
154
|
+
function isInsideRoot(absPath, reaRoot) {
|
|
155
|
+
if (absPath === reaRoot)
|
|
156
|
+
return true;
|
|
157
|
+
const realRoot = realpathSafe(reaRoot);
|
|
158
|
+
if (realRoot && absPath === realRoot)
|
|
159
|
+
return true;
|
|
160
|
+
if (absPath.startsWith(reaRoot + '/'))
|
|
161
|
+
return true;
|
|
162
|
+
if (realRoot && absPath.startsWith(realRoot + '/'))
|
|
163
|
+
return true;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
function realpathSafe(p) {
|
|
167
|
+
try {
|
|
168
|
+
return fs.realpathSync(p);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Walk to the nearest existing-or-symlink ancestor and resolve. Codex
|
|
176
|
+
* round 1 F-2 — see protected-scan.ts::resolveSymlinksWalkUp for the
|
|
177
|
+
* full rationale.
|
|
178
|
+
*
|
|
179
|
+
* Codex round 2 R2-2: same cycle-guard + depth-cap as protected-scan.
|
|
180
|
+
* Returns SYMLINK_DYNAMIC_SENTINEL to signal "refuse on uncertainty".
|
|
181
|
+
*/
|
|
182
|
+
const SYMLINK_DYNAMIC_SENTINEL = Symbol('symlink-dynamic-blocked');
|
|
183
|
+
const SYMLINK_DEPTH_CAP = 32;
|
|
184
|
+
function resolveSymlinksWalkUp(absPath) {
|
|
185
|
+
return resolveSymlinksWalkUpInner(absPath, new Set(), 0);
|
|
186
|
+
}
|
|
187
|
+
function resolveSymlinksWalkUpInner(absPath, visited, depth) {
|
|
188
|
+
if (depth >= SYMLINK_DEPTH_CAP)
|
|
189
|
+
return SYMLINK_DYNAMIC_SENTINEL;
|
|
190
|
+
if (visited.has(absPath))
|
|
191
|
+
return SYMLINK_DYNAMIC_SENTINEL;
|
|
192
|
+
visited.add(absPath);
|
|
193
|
+
const parts = absPath.split('/').filter((p) => p.length > 0);
|
|
194
|
+
for (let i = parts.length; i >= 0; i -= 1) {
|
|
195
|
+
const prefix = '/' + parts.slice(0, i).join('/');
|
|
196
|
+
const lstat = lstatSafe(prefix);
|
|
197
|
+
if (lstat !== null) {
|
|
198
|
+
if (lstat.isSymbolicLink()) {
|
|
199
|
+
const linkTarget = readlinkSafe(prefix);
|
|
200
|
+
if (linkTarget === null)
|
|
201
|
+
return null;
|
|
202
|
+
const linkDir = '/' + parts.slice(0, i - 1).join('/');
|
|
203
|
+
const targetAbs = linkTarget.startsWith('/')
|
|
204
|
+
? linkTarget
|
|
205
|
+
: path.resolve(linkDir, linkTarget);
|
|
206
|
+
const recursive = resolveSymlinksWalkUpInner(targetAbs, visited, depth + 1);
|
|
207
|
+
if (recursive === SYMLINK_DYNAMIC_SENTINEL)
|
|
208
|
+
return SYMLINK_DYNAMIC_SENTINEL;
|
|
209
|
+
if (recursive === null)
|
|
210
|
+
return null;
|
|
211
|
+
const tail = parts.slice(i).join('/');
|
|
212
|
+
return tail.length === 0
|
|
213
|
+
? recursive
|
|
214
|
+
: recursive === '/'
|
|
215
|
+
? '/' + tail
|
|
216
|
+
: recursive + '/' + tail;
|
|
217
|
+
}
|
|
218
|
+
const real = realpathSafe(prefix);
|
|
219
|
+
if (real === null)
|
|
220
|
+
return null;
|
|
221
|
+
const tail = parts.slice(i).join('/');
|
|
222
|
+
if (tail.length === 0)
|
|
223
|
+
return real;
|
|
224
|
+
return real === '/' ? '/' + tail : real + '/' + tail;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
function lstatSafe(p) {
|
|
230
|
+
try {
|
|
231
|
+
return fs.lstatSync(p);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function readlinkSafe(p) {
|
|
238
|
+
try {
|
|
239
|
+
return fs.readlinkSync(p);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function stripBashBackslashEscapes(s) {
|
|
246
|
+
return s.replace(/\\([A-Za-z0-9./_~\-])/g, '$1');
|
|
247
|
+
}
|
|
248
|
+
function containsGlobMetachar(s) {
|
|
249
|
+
return /[*?[{]/.test(s);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Match a normalized lowercase path against the blocked_paths list.
|
|
253
|
+
* Returns the matching entry (preserving original case for the error
|
|
254
|
+
* message) or null.
|
|
255
|
+
*/
|
|
256
|
+
function matchBlockedEntry(pathLc, blockedPaths, options) {
|
|
257
|
+
const inputHadTrailingSlash = pathLc.endsWith('/');
|
|
258
|
+
const inputIsDir = inputHadTrailingSlash || (options?.forceDirSemantics ?? false);
|
|
259
|
+
const inputBase = inputHadTrailingSlash ? pathLc.slice(0, -1) : pathLc;
|
|
260
|
+
for (const entry of blockedPaths) {
|
|
261
|
+
const entryLc = entry.toLowerCase();
|
|
262
|
+
// Directory match.
|
|
263
|
+
if (entryLc.endsWith('/')) {
|
|
264
|
+
if (pathLc.startsWith(entryLc))
|
|
265
|
+
return entry;
|
|
266
|
+
if (pathLc === entryLc.slice(0, -1))
|
|
267
|
+
return entry;
|
|
268
|
+
// Codex round 1 F-7: dir-target input matches a dir-pattern
|
|
269
|
+
// even when `pathLc` is `.rea` (no trailing slash) but the
|
|
270
|
+
// walker flagged it as dir-target.
|
|
271
|
+
if (inputIsDir && entryLc.startsWith(inputBase + '/'))
|
|
272
|
+
return entry;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
// Glob match.
|
|
276
|
+
if (entry.includes('*')) {
|
|
277
|
+
const re = globToRegex(entryLc);
|
|
278
|
+
if (re.test(pathLc))
|
|
279
|
+
return entry;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
// Exact match.
|
|
283
|
+
if (pathLc === entryLc)
|
|
284
|
+
return entry;
|
|
285
|
+
// Dir-target input vs file entry inside that dir.
|
|
286
|
+
if (inputIsDir && entryLc.startsWith(inputBase + '/'))
|
|
287
|
+
return entry;
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Convert a glob entry to a case-insensitive anchored regex. Mirrors
|
|
293
|
+
* the bash `sed` transform applied in the pre-0.23.0 hook: escape `.`,
|
|
294
|
+
* convert `*` to `.*`, anchor both ends. We additionally escape every
|
|
295
|
+
* other regex metacharacter so values like `[`, `(`, `+` in a
|
|
296
|
+
* blocked_paths entry don't blow up at runtime.
|
|
297
|
+
*/
|
|
298
|
+
function globToRegex(glob) {
|
|
299
|
+
let re = '';
|
|
300
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
301
|
+
const c = glob.charAt(i);
|
|
302
|
+
if (c === '*') {
|
|
303
|
+
re += '.*';
|
|
304
|
+
}
|
|
305
|
+
else if (c === '?') {
|
|
306
|
+
re += '.';
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
// Escape any regex-meta characters.
|
|
310
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return new RegExp('^' + re + '$', 'i');
|
|
314
|
+
}
|
|
315
|
+
function buildBlockReason(args) {
|
|
316
|
+
return [
|
|
317
|
+
'BLOCKED PATH (bash): write denied by policy',
|
|
318
|
+
'',
|
|
319
|
+
` Blocked by: ${args.entry}`,
|
|
320
|
+
` Resolved target: ${args.hitForm}`,
|
|
321
|
+
` Original token: ${args.originalToken}`,
|
|
322
|
+
` Detected as: ${args.detectedForm}`,
|
|
323
|
+
'',
|
|
324
|
+
' Source: .rea/policy.yaml → blocked_paths',
|
|
325
|
+
' Rule: blocked_paths entries are unreachable via Bash redirects',
|
|
326
|
+
' too — not just Write/Edit/MultiEdit. To modify, a human',
|
|
327
|
+
' must edit directly or update blocked_paths in policy.yaml.',
|
|
328
|
+
].join('\n');
|
|
329
|
+
}
|
|
330
|
+
export function scanForBlockedViolations(ctx, detections) {
|
|
331
|
+
if (ctx.blockedPaths.length === 0)
|
|
332
|
+
return allowVerdict();
|
|
333
|
+
if (detections.length === 0)
|
|
334
|
+
return allowVerdict();
|
|
335
|
+
for (const d of detections) {
|
|
336
|
+
if (d.dynamic) {
|
|
337
|
+
if (d.form === 'xargs_unresolvable') {
|
|
338
|
+
return blockVerdict({
|
|
339
|
+
reason: [
|
|
340
|
+
'BLOCKED PATH (bash): xargs destination is fed via stdin and cannot be statically resolved.',
|
|
341
|
+
'',
|
|
342
|
+
' rea refuses on uncertainty against the blocked_paths policy. Rewrite without',
|
|
343
|
+
' xargs (use a loop with explicit destinations).',
|
|
344
|
+
].join('\n'),
|
|
345
|
+
hitPattern: '(xargs unresolvable stdin)',
|
|
346
|
+
detectedForm: d.form,
|
|
347
|
+
...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
if (d.form === 'nested_shell_inner') {
|
|
351
|
+
return blockVerdict({
|
|
352
|
+
reason: [
|
|
353
|
+
'BLOCKED PATH (bash): nested-shell payload is dynamic or exceeds the recursion depth cap (8).',
|
|
354
|
+
'',
|
|
355
|
+
' rea refuses on uncertainty.',
|
|
356
|
+
].join('\n'),
|
|
357
|
+
hitPattern: '(nested-shell unresolvable)',
|
|
358
|
+
detectedForm: d.form,
|
|
359
|
+
...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
// Codex round 11 F11-1 / F11-5 / F11-4: refuse-on-uncertainty
|
|
363
|
+
// forms surfaced as dynamic detections. Same pattern as
|
|
364
|
+
// protected-scan: each form gets its own message.
|
|
365
|
+
if (d.form === 'find_exec_placeholder_unresolvable') {
|
|
366
|
+
return blockVerdict({
|
|
367
|
+
reason: [
|
|
368
|
+
'BLOCKED PATH (bash): find -exec with `{}` placeholder targets runtime-resolved paths.',
|
|
369
|
+
'',
|
|
370
|
+
' rea refuses on uncertainty.',
|
|
371
|
+
].join('\n'),
|
|
372
|
+
hitPattern: '(find -exec placeholder unresolvable)',
|
|
373
|
+
detectedForm: d.form,
|
|
374
|
+
...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
if (d.form === 'parallel_stdin_unresolvable') {
|
|
378
|
+
return blockVerdict({
|
|
379
|
+
reason: [
|
|
380
|
+
'BLOCKED PATH (bash): parallel without `:::` reads inputs from stdin and cannot be statically resolved.',
|
|
381
|
+
'',
|
|
382
|
+
' rea refuses on uncertainty.',
|
|
383
|
+
].join('\n'),
|
|
384
|
+
hitPattern: '(parallel stdin unresolvable)',
|
|
385
|
+
detectedForm: d.form,
|
|
386
|
+
...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (d.form === 'archive_extract_unresolvable') {
|
|
390
|
+
return blockVerdict({
|
|
391
|
+
reason: [
|
|
392
|
+
'BLOCKED PATH (bash): archive extraction targets are unresolvable.',
|
|
393
|
+
'',
|
|
394
|
+
' rea refuses on uncertainty.',
|
|
395
|
+
].join('\n'),
|
|
396
|
+
hitPattern: '(archive extract unresolvable)',
|
|
397
|
+
detectedForm: d.form,
|
|
398
|
+
...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
return blockVerdict({
|
|
402
|
+
reason: [
|
|
403
|
+
'BLOCKED PATH (bash): unresolved shell expansion in target.',
|
|
404
|
+
'',
|
|
405
|
+
` Token: ${d.path}`,
|
|
406
|
+
` Detected as: ${d.form}`,
|
|
407
|
+
].join('\n'),
|
|
408
|
+
hitPattern: '(dynamic target)',
|
|
409
|
+
detectedForm: d.form,
|
|
410
|
+
...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (d.path.length === 0)
|
|
414
|
+
continue;
|
|
415
|
+
const norm = normalizeTarget(ctx.reaRoot, d.path, d.form);
|
|
416
|
+
if (norm.expansion) {
|
|
417
|
+
return blockVerdict({
|
|
418
|
+
reason: [
|
|
419
|
+
'BLOCKED PATH (bash): unresolved shell expansion in target.',
|
|
420
|
+
'',
|
|
421
|
+
` Token: ${norm.original}`,
|
|
422
|
+
` Detected as: ${d.form}`,
|
|
423
|
+
].join('\n'),
|
|
424
|
+
hitPattern: '(dynamic target)',
|
|
425
|
+
detectedForm: d.form,
|
|
426
|
+
...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (norm.outsideRoot) {
|
|
430
|
+
// blocked_paths is project-relative; outside-root paths can't
|
|
431
|
+
// match. Continue to the next detection.
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const dirOptions = d.isDirTarget === true ? { forceDirSemantics: true } : undefined;
|
|
435
|
+
const logicalHit = matchBlockedEntry(norm.pathLc, ctx.blockedPaths, dirOptions);
|
|
436
|
+
if (logicalHit !== null) {
|
|
437
|
+
return blockVerdict({
|
|
438
|
+
reason: buildBlockReason({
|
|
439
|
+
entry: logicalHit,
|
|
440
|
+
hitForm: norm.pathLc,
|
|
441
|
+
detectedForm: d.form,
|
|
442
|
+
originalToken: norm.original,
|
|
443
|
+
}),
|
|
444
|
+
hitPattern: logicalHit,
|
|
445
|
+
detectedForm: d.form,
|
|
446
|
+
...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
if (norm.resolvedLc !== null) {
|
|
450
|
+
const resolvedHit = matchBlockedEntry(norm.resolvedLc, ctx.blockedPaths, dirOptions);
|
|
451
|
+
if (resolvedHit !== null) {
|
|
452
|
+
return blockVerdict({
|
|
453
|
+
reason: buildBlockReason({
|
|
454
|
+
entry: resolvedHit,
|
|
455
|
+
hitForm: norm.resolvedLc,
|
|
456
|
+
detectedForm: d.form,
|
|
457
|
+
originalToken: norm.original,
|
|
458
|
+
}),
|
|
459
|
+
hitPattern: resolvedHit,
|
|
460
|
+
detectedForm: d.form,
|
|
461
|
+
...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return allowVerdict();
|
|
467
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `src/hooks/bash-scanner/` — parser-backed bash-tier scanner. Replaces
|
|
3
|
+
* the regex-and-segmenter pipeline at `hooks/_lib/cmd-segments.sh` +
|
|
4
|
+
* `hooks/_lib/interpreter-scanner.sh` with an AST-driven walker.
|
|
5
|
+
*
|
|
6
|
+
* Public surface:
|
|
7
|
+
*
|
|
8
|
+
* - `runProtectedScan(ctx, cmd)` — protected-paths gate
|
|
9
|
+
* - `runBlockedScan(ctx, cmd)` — blocked_paths gate
|
|
10
|
+
*
|
|
11
|
+
* Both return a `Verdict` (allow|block + reason). The CLI subcommand
|
|
12
|
+
* `rea hook scan-bash` consumes the verdict and translates to exit
|
|
13
|
+
* codes for the bash shims.
|
|
14
|
+
*/
|
|
15
|
+
import { type ProtectedScanContext } from './protected-scan.js';
|
|
16
|
+
import { type BlockedScanContext } from './blocked-scan.js';
|
|
17
|
+
import type { Verdict } from './verdict.js';
|
|
18
|
+
export type { Verdict, DetectedForm, SourcePosition } from './verdict.js';
|
|
19
|
+
export type { DetectedWrite } from './walker.js';
|
|
20
|
+
export type { ProtectedScanContext } from './protected-scan.js';
|
|
21
|
+
export type { BlockedScanContext } from './blocked-scan.js';
|
|
22
|
+
export { allowVerdict, blockVerdict, parseFailureVerdict } from './verdict.js';
|
|
23
|
+
/**
|
|
24
|
+
* Run the protected-paths scanner against a bash command string.
|
|
25
|
+
*
|
|
26
|
+
* Empty / whitespace-only commands are an immediate allow (the bash
|
|
27
|
+
* gate's `[[ -z "$CMD" ]] && exit 0` guard).
|
|
28
|
+
*
|
|
29
|
+
* Parse failures BLOCK — see `parse-fail-closed.ts` for the contract
|
|
30
|
+
* rationale. The bash gates pre-0.23.0 silently allowed on segmenter
|
|
31
|
+
* failure; the rewrite closes that bug class definitionally.
|
|
32
|
+
*/
|
|
33
|
+
export declare function runProtectedScan(ctx: ProtectedScanContext, cmd: string): Verdict;
|
|
34
|
+
/**
|
|
35
|
+
* Run the blocked_paths scanner against a bash command string. Identical
|
|
36
|
+
* structure to runProtectedScan; the policy data shape differs.
|
|
37
|
+
*
|
|
38
|
+
* Empty blockedPaths list → allow (matches the bash gate's no-op
|
|
39
|
+
* exit-0 behavior when policy.blocked_paths is empty).
|
|
40
|
+
*/
|
|
41
|
+
export declare function runBlockedScan(ctx: BlockedScanContext, cmd: string): Verdict;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `src/hooks/bash-scanner/` — parser-backed bash-tier scanner. Replaces
|
|
3
|
+
* the regex-and-segmenter pipeline at `hooks/_lib/cmd-segments.sh` +
|
|
4
|
+
* `hooks/_lib/interpreter-scanner.sh` with an AST-driven walker.
|
|
5
|
+
*
|
|
6
|
+
* Public surface:
|
|
7
|
+
*
|
|
8
|
+
* - `runProtectedScan(ctx, cmd)` — protected-paths gate
|
|
9
|
+
* - `runBlockedScan(ctx, cmd)` — blocked_paths gate
|
|
10
|
+
*
|
|
11
|
+
* Both return a `Verdict` (allow|block + reason). The CLI subcommand
|
|
12
|
+
* `rea hook scan-bash` consumes the verdict and translates to exit
|
|
13
|
+
* codes for the bash shims.
|
|
14
|
+
*/
|
|
15
|
+
import { parseBashCommand } from './parser.js';
|
|
16
|
+
import { walkForWrites } from './walker.js';
|
|
17
|
+
import { scanForProtectedViolations } from './protected-scan.js';
|
|
18
|
+
import { scanForBlockedViolations } from './blocked-scan.js';
|
|
19
|
+
import { buildParseFailureVerdict } from './parse-fail-closed.js';
|
|
20
|
+
export { allowVerdict, blockVerdict, parseFailureVerdict } from './verdict.js';
|
|
21
|
+
/**
|
|
22
|
+
* Run the protected-paths scanner against a bash command string.
|
|
23
|
+
*
|
|
24
|
+
* Empty / whitespace-only commands are an immediate allow (the bash
|
|
25
|
+
* gate's `[[ -z "$CMD" ]] && exit 0` guard).
|
|
26
|
+
*
|
|
27
|
+
* Parse failures BLOCK — see `parse-fail-closed.ts` for the contract
|
|
28
|
+
* rationale. The bash gates pre-0.23.0 silently allowed on segmenter
|
|
29
|
+
* failure; the rewrite closes that bug class definitionally.
|
|
30
|
+
*/
|
|
31
|
+
export function runProtectedScan(ctx, cmd) {
|
|
32
|
+
if (cmd.trim().length === 0) {
|
|
33
|
+
return { verdict: 'allow' };
|
|
34
|
+
}
|
|
35
|
+
const parsed = parseBashCommand(cmd);
|
|
36
|
+
if (!parsed.ok) {
|
|
37
|
+
return buildParseFailureVerdict({ parserMessage: parsed.error, originalCommand: cmd });
|
|
38
|
+
}
|
|
39
|
+
const detections = walkForWrites(parsed.file);
|
|
40
|
+
return scanForProtectedViolations(ctx, detections);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Run the blocked_paths scanner against a bash command string. Identical
|
|
44
|
+
* structure to runProtectedScan; the policy data shape differs.
|
|
45
|
+
*
|
|
46
|
+
* Empty blockedPaths list → allow (matches the bash gate's no-op
|
|
47
|
+
* exit-0 behavior when policy.blocked_paths is empty).
|
|
48
|
+
*/
|
|
49
|
+
export function runBlockedScan(ctx, cmd) {
|
|
50
|
+
if (cmd.trim().length === 0) {
|
|
51
|
+
return { verdict: 'allow' };
|
|
52
|
+
}
|
|
53
|
+
if (ctx.blockedPaths.length === 0) {
|
|
54
|
+
return { verdict: 'allow' };
|
|
55
|
+
}
|
|
56
|
+
const parsed = parseBashCommand(cmd);
|
|
57
|
+
if (!parsed.ok) {
|
|
58
|
+
return buildParseFailureVerdict({ parserMessage: parsed.error, originalCommand: cmd });
|
|
59
|
+
}
|
|
60
|
+
const detections = walkForWrites(parsed.file);
|
|
61
|
+
return scanForBlockedViolations(ctx, detections);
|
|
62
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser-failure handling. The contract: any failure of the parser
|
|
3
|
+
* (malformed bash, unterminated quote, etc.) returns BLOCK with a
|
|
4
|
+
* canned reason. NEVER ALLOW on parse failure — that is the entire
|
|
5
|
+
* bug class this 0.23.0 rewrite exists to close.
|
|
6
|
+
*
|
|
7
|
+
* Lifted into its own module so the bash-shim contract is easy to
|
|
8
|
+
* eyeball: the verdict shape on parse failure is a stable wire format,
|
|
9
|
+
* and snapshot-tested in `verdict-shape.test.ts`.
|
|
10
|
+
*/
|
|
11
|
+
import { type Verdict } from './verdict.js';
|
|
12
|
+
export interface ParseFailureInput {
|
|
13
|
+
/** The raw parser error message (already normalized to a string). */
|
|
14
|
+
parserMessage: string;
|
|
15
|
+
/** The original command string the parser rejected (truncated for log size). */
|
|
16
|
+
originalCommand?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build a parse-failure verdict. Wraps `parseFailureVerdict` so callers
|
|
20
|
+
* have a single import point for both the construction logic and the
|
|
21
|
+
* "must always block" contract.
|
|
22
|
+
*
|
|
23
|
+
* Implementation note: we intentionally DO NOT include `originalCommand`
|
|
24
|
+
* in the verdict body. The parser message alone is enough for the
|
|
25
|
+
* operator to debug, and including the full command in a JSON wire-
|
|
26
|
+
* format that flows through stderr risks log-injection vectors (a
|
|
27
|
+
* crafted command could embed ANSI escapes in its literals). The
|
|
28
|
+
* `input` field is consumed only by structured logging in callers
|
|
29
|
+
* that opt in to it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildParseFailureVerdict(input: ParseFailureInput): Verdict;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser-failure handling. The contract: any failure of the parser
|
|
3
|
+
* (malformed bash, unterminated quote, etc.) returns BLOCK with a
|
|
4
|
+
* canned reason. NEVER ALLOW on parse failure — that is the entire
|
|
5
|
+
* bug class this 0.23.0 rewrite exists to close.
|
|
6
|
+
*
|
|
7
|
+
* Lifted into its own module so the bash-shim contract is easy to
|
|
8
|
+
* eyeball: the verdict shape on parse failure is a stable wire format,
|
|
9
|
+
* and snapshot-tested in `verdict-shape.test.ts`.
|
|
10
|
+
*/
|
|
11
|
+
import { parseFailureVerdict } from './verdict.js';
|
|
12
|
+
/**
|
|
13
|
+
* Build a parse-failure verdict. Wraps `parseFailureVerdict` so callers
|
|
14
|
+
* have a single import point for both the construction logic and the
|
|
15
|
+
* "must always block" contract.
|
|
16
|
+
*
|
|
17
|
+
* Implementation note: we intentionally DO NOT include `originalCommand`
|
|
18
|
+
* in the verdict body. The parser message alone is enough for the
|
|
19
|
+
* operator to debug, and including the full command in a JSON wire-
|
|
20
|
+
* format that flows through stderr risks log-injection vectors (a
|
|
21
|
+
* crafted command could embed ANSI escapes in its literals). The
|
|
22
|
+
* `input` field is consumed only by structured logging in callers
|
|
23
|
+
* that opt in to it.
|
|
24
|
+
*/
|
|
25
|
+
export function buildParseFailureVerdict(input) {
|
|
26
|
+
return parseFailureVerdict(input.parserMessage);
|
|
27
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around `mvdan-sh` — the GopherJS-compiled JS port of
|
|
3
|
+
* the upstream Go bash parser at `mvdan.cc/sh/v3/syntax`.
|
|
4
|
+
*
|
|
5
|
+
* Why a wrapper:
|
|
6
|
+
* 1. The parser instance is mutable (it tracks state across calls
|
|
7
|
+
* to `Parse`). Construct once per process; serialize all parses
|
|
8
|
+
* through it. Multiple concurrent Node CLI invocations get one
|
|
9
|
+
* parser each — fine, it's cheap.
|
|
10
|
+
* 2. The library throws Go-style error objects with `.Error()`
|
|
11
|
+
* methods, not native JS Errors. Normalize them to native Error
|
|
12
|
+
* with a clean message.
|
|
13
|
+
* 3. Callers care only about three outcomes: parsed, parse-failed,
|
|
14
|
+
* or unexpected JS-level throw. Collapse the Go-vs-JS-error
|
|
15
|
+
* ambiguity to a single discriminated union.
|
|
16
|
+
*
|
|
17
|
+
* 0.23.0 — first release of this module. Pinned to mvdan-sh@0.10.1
|
|
18
|
+
* (deprecated upstream but functionally complete; see issue 1145).
|
|
19
|
+
* If we ever migrate to `sh-syntax` (the WASM successor), this
|
|
20
|
+
* wrapper is the only file that changes — everything downstream
|
|
21
|
+
* works against `BashFile`/`BashNode` from our local d.ts shim.
|
|
22
|
+
*/
|
|
23
|
+
import type { BashFile } from 'mvdan-sh';
|
|
24
|
+
export type ParseResult = {
|
|
25
|
+
ok: true;
|
|
26
|
+
file: BashFile;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Parse a bash command string into an AST. Returns a tagged union so
|
|
33
|
+
* the caller never has to wrap this in try/catch — every failure mode
|
|
34
|
+
* (Go parse error, JS throw, weird native return) is collapsed to
|
|
35
|
+
* `{ ok: false, error }`.
|
|
36
|
+
*
|
|
37
|
+
* Empty / whitespace-only input is a no-op success — the parser
|
|
38
|
+
* returns a `File` with zero `Stmts`, which the walker will yield
|
|
39
|
+
* zero detections for. Equivalent to the bash gates' `[[ -z "$CMD" ]]
|
|
40
|
+
* && exit 0` guard.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseBashCommand(src: string): ParseResult;
|