@seanmozeik/tripwire 0.7.0 → 0.7.2
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 +16 -14
- package/dist/index.js +35 -0
- package/dist/tripwire-cli.js +3 -0
- package/dist/tripwire-hook.js +3 -0
- package/dist/tripwire-pi.js +6 -4
- package/dist/tripwire.js +141 -0
- package/dist/types/dispatch.d.ts +18 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/lib/bash.d.ts +27 -0
- package/dist/types/lib/config.d.ts +110 -0
- package/dist/types/lib/cursor.d.ts +16 -0
- package/dist/types/lib/decision.d.ts +13 -0
- package/dist/types/lib/diff.d.ts +3 -0
- package/dist/types/lib/event.d.ts +45 -0
- package/dist/types/lib/log.d.ts +2 -0
- package/dist/types/lib/secrets.d.ts +41 -0
- package/dist/types/rules/bash-deny.d.ts +5 -0
- package/dist/types/rules/bash-git.d.ts +5 -0
- package/dist/types/rules/bash-network-install.d.ts +4 -0
- package/dist/types/rules/bash-redirect.d.ts +4 -0
- package/dist/types/rules/bash-scoped-rm.d.ts +5 -0
- package/dist/types/rules/bash-tar-explosion.d.ts +4 -0
- package/dist/types/rules/config-custom.d.ts +6 -0
- package/dist/types/rules/lazy-code.d.ts +4 -0
- package/dist/types/rules/path-protect.d.ts +12 -0
- package/dist/types/rules/post-secret-scrub.d.ts +12 -0
- package/dist/types/rules/read-protect.d.ts +4 -0
- package/dist/types/rules/tool-policy.d.ts +5 -0
- package/package.json +16 -13
- package/dist/tripwire +0 -0
- package/scripts/tripwire-cli +0 -12
- package/src/cli.ts +0 -271
- package/src/dispatch.ts +0 -562
- package/src/index.ts +0 -6
- package/src/lib/bash.ts +0 -1328
- package/src/lib/config.ts +0 -174
- package/src/lib/cursor.ts +0 -336
- package/src/lib/decision.ts +0 -36
- package/src/lib/diff.ts +0 -29
- package/src/lib/event.ts +0 -105
- package/src/lib/install.ts +0 -610
- package/src/lib/log.ts +0 -23
- package/src/lib/secrets.ts +0 -184
- package/src/main.ts +0 -31
- package/src/pi-extension.ts +0 -337
- package/src/rules/bash-deny.ts +0 -404
- package/src/rules/bash-git.ts +0 -590
- package/src/rules/bash-network-install.ts +0 -75
- package/src/rules/bash-redirect.ts +0 -91
- package/src/rules/bash-scoped-rm.ts +0 -84
- package/src/rules/bash-tar-explosion.ts +0 -77
- package/src/rules/config-custom.ts +0 -166
- package/src/rules/lazy-code.ts +0 -95
- package/src/rules/path-protect.ts +0 -131
- package/src/rules/post-secret-scrub.ts +0 -49
- package/src/rules/read-protect.ts +0 -57
- package/src/rules/tool-policy.ts +0 -54
package/src/lib/bash.ts
DELETED
|
@@ -1,1328 +0,0 @@
|
|
|
1
|
-
// Generic bash command parsing built on `shell-quote`. Used by every bash
|
|
2
|
-
// Rule — bash-deny, bash-scoped-rm, bash-redirect, bash-network-install,
|
|
3
|
-
// Bash-tar-explosion, bash-tool-policy.
|
|
4
|
-
//
|
|
5
|
-
// Shell-quote.parse(cmd) returns a flat array of tokens and operator
|
|
6
|
-
// Objects. We post-process it into structured `Segment`s split at top-level
|
|
7
|
-
// Shell operators (`;`, `&&`, `||`, `|`, `&`, newline). Each segment
|
|
8
|
-
// Records its head token, positional args, flags, and redirect targets.
|
|
9
|
-
//
|
|
10
|
-
// Parsing notes:
|
|
11
|
-
// - Shell variables stay literal. Path rules handle known home references.
|
|
12
|
-
// - Command substitutions are inspected as nested commands. The outer
|
|
13
|
-
// command keeps an opaque marker for safe path classification.
|
|
14
|
-
// - Glob entries are expanded with Bun.Glob before path classification.
|
|
15
|
-
|
|
16
|
-
import { parse, quote, type ParseEntry } from 'shell-quote';
|
|
17
|
-
|
|
18
|
-
interface Segment {
|
|
19
|
-
readonly head: string; // First non-flag token, e.g. `rm`, `npm`
|
|
20
|
-
readonly tokens: readonly string[]; // All string tokens incl. head, in order
|
|
21
|
-
readonly args: readonly string[]; // Tokens[1..], minus pure flag tokens
|
|
22
|
-
readonly flags: readonly string[]; // Tokens that start with `-`
|
|
23
|
-
readonly redirects: readonly Redirect[];
|
|
24
|
-
readonly raw: string; // Best-effort reconstruction
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface Redirect {
|
|
28
|
-
readonly op: '>' | '>>' | '<' | '<<' | '<<<' | '<>' | '>&' | '<&' | '&>' | '&>>';
|
|
29
|
-
readonly target: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const SAFE_RELATIVE: readonly string[] = [
|
|
33
|
-
'dist',
|
|
34
|
-
'build',
|
|
35
|
-
'_build',
|
|
36
|
-
'out',
|
|
37
|
-
'target',
|
|
38
|
-
'.next',
|
|
39
|
-
'.nuxt',
|
|
40
|
-
'.svelte-kit',
|
|
41
|
-
'.output',
|
|
42
|
-
'.astro',
|
|
43
|
-
'.angular',
|
|
44
|
-
'.vite',
|
|
45
|
-
'.parcel-cache',
|
|
46
|
-
'.turbo',
|
|
47
|
-
'.vercel',
|
|
48
|
-
'.netlify',
|
|
49
|
-
'.fly',
|
|
50
|
-
'.wrangler',
|
|
51
|
-
'.serverless',
|
|
52
|
-
'coverage',
|
|
53
|
-
'.nyc_output',
|
|
54
|
-
'.cache',
|
|
55
|
-
'.ruff_cache',
|
|
56
|
-
'.mypy_cache',
|
|
57
|
-
'.pytest_cache',
|
|
58
|
-
'.ty_cache',
|
|
59
|
-
'.tox',
|
|
60
|
-
'__pycache__',
|
|
61
|
-
'.venv',
|
|
62
|
-
'venv',
|
|
63
|
-
'node_modules',
|
|
64
|
-
'.gradle',
|
|
65
|
-
'DerivedData',
|
|
66
|
-
'.bundle',
|
|
67
|
-
'.cargo-target',
|
|
68
|
-
'tmp',
|
|
69
|
-
'.tmp',
|
|
70
|
-
'.state',
|
|
71
|
-
'.terraform',
|
|
72
|
-
'.yarn/cache',
|
|
73
|
-
'.yarn/install-state.gz',
|
|
74
|
-
'.pnpm-store',
|
|
75
|
-
'.bun',
|
|
76
|
-
];
|
|
77
|
-
|
|
78
|
-
const SAFE_ABSOLUTE: readonly string[] = [
|
|
79
|
-
'/tmp',
|
|
80
|
-
'/var/tmp',
|
|
81
|
-
'/var/folders',
|
|
82
|
-
'/private/tmp',
|
|
83
|
-
'/private/var/tmp',
|
|
84
|
-
'/private/var/folders',
|
|
85
|
-
];
|
|
86
|
-
|
|
87
|
-
const REDIRECT_OPS: ReadonlySet<string> = new Set([
|
|
88
|
-
'>',
|
|
89
|
-
'>>',
|
|
90
|
-
'<',
|
|
91
|
-
'<<',
|
|
92
|
-
'<<<',
|
|
93
|
-
'<>',
|
|
94
|
-
'>&',
|
|
95
|
-
'<&',
|
|
96
|
-
'&>',
|
|
97
|
-
'&>>',
|
|
98
|
-
]);
|
|
99
|
-
|
|
100
|
-
// `|&` is bash shorthand for "pipe stdout AND stderr to the next command"
|
|
101
|
-
// — semantically equivalent to `2>&1 |` for our purposes. shell-quote
|
|
102
|
-
// Emits it as a single op; without classifying it as a segment break,
|
|
103
|
-
// `cmd1 |& cmd2` collapses into one segment with `__op_|&__` as a fake
|
|
104
|
-
// Positional arg, hiding `cmd2` from every rule.
|
|
105
|
-
const SEGMENT_OPS: ReadonlySet<string> = new Set([';', '&&', '||', '|', '|&', '&']);
|
|
106
|
-
|
|
107
|
-
// Type guards over `ParseEntry`.
|
|
108
|
-
const isStringToken = (e: ParseEntry): e is string => typeof e === 'string';
|
|
109
|
-
const getOp = (e: ParseEntry): string | null => {
|
|
110
|
-
if (typeof e === 'object' && 'op' in e && typeof e.op === 'string') {
|
|
111
|
-
return e.op;
|
|
112
|
-
}
|
|
113
|
-
return null;
|
|
114
|
-
};
|
|
115
|
-
const isCommentToken = (e: ParseEntry): boolean => typeof e === 'object' && 'comment' in e;
|
|
116
|
-
|
|
117
|
-
// Glob entries from shell-quote are `{ op: 'glob', pattern: '...' }`. We
|
|
118
|
-
// Expand them against the hook's cwd via `Bun.Glob` so safe-path rules
|
|
119
|
-
// See concrete files (e.g. `.state/foo*` → `.state/foo-1.json`,
|
|
120
|
-
// `.state/foo-2.json`) instead of an opaque `__op_glob__` sentinel that
|
|
121
|
-
// Always fails safe-path checks. If a pattern matches nothing, we keep
|
|
122
|
-
// The literal pattern so the rule can still reason about its prefix
|
|
123
|
-
// (e.g. `.state/foo*` resolves under `.state/` regardless).
|
|
124
|
-
const expandGlob = (pattern: string): string[] => {
|
|
125
|
-
try {
|
|
126
|
-
const matches = [...new Bun.Glob(pattern).scanSync({ onlyFiles: false, dot: true })];
|
|
127
|
-
if (matches.length > 0) {
|
|
128
|
-
return matches;
|
|
129
|
-
}
|
|
130
|
-
} catch {
|
|
131
|
-
// Fall through to the literal pattern.
|
|
132
|
-
}
|
|
133
|
-
return [pattern];
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
// Convert one entry to one or more string tokens. Operators and
|
|
137
|
-
// Command-sub markers become opaque sentinel tokens; globs expand.
|
|
138
|
-
const entryToTokens = (e: ParseEntry): string[] => {
|
|
139
|
-
if (isStringToken(e)) {
|
|
140
|
-
return [e];
|
|
141
|
-
}
|
|
142
|
-
if (typeof e === 'object' && 'op' in e && e.op === 'glob' && 'pattern' in e) {
|
|
143
|
-
return expandGlob(String((e as { pattern: unknown }).pattern));
|
|
144
|
-
}
|
|
145
|
-
const op = getOp(e);
|
|
146
|
-
if (op !== null) {
|
|
147
|
-
if (REDIRECT_OPS.has(op) || SEGMENT_OPS.has(op)) {
|
|
148
|
-
return [];
|
|
149
|
-
}
|
|
150
|
-
return [`__op_${op}__`];
|
|
151
|
-
}
|
|
152
|
-
if (isCommentToken(e)) {
|
|
153
|
-
return [];
|
|
154
|
-
}
|
|
155
|
-
return ['__tripwire_cmd_sub__'];
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
interface FdBudget {
|
|
159
|
-
remaining: number;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const parseSegment = (entries: readonly ParseEntry[], fdBudget: FdBudget): Segment | null => {
|
|
163
|
-
const tokens: string[] = [];
|
|
164
|
-
const args: string[] = [];
|
|
165
|
-
const flags: string[] = [];
|
|
166
|
-
const redirects: Redirect[] = [];
|
|
167
|
-
|
|
168
|
-
let i = 0;
|
|
169
|
-
while (i < entries.length) {
|
|
170
|
-
const e = entries[i]!;
|
|
171
|
-
const op = getOp(e);
|
|
172
|
-
if (op !== null && REDIRECT_OPS.has(op)) {
|
|
173
|
-
// Shell-quote emits a leading file-descriptor digit (e.g. the `2` in
|
|
174
|
-
// `2>&1`) as a separate string token *before* the redirect op. It
|
|
175
|
-
// Also drops the whitespace, so `echo 2 >file` and `echo 2>file`
|
|
176
|
-
// Produce identical token streams. We pre-scanned the original
|
|
177
|
-
// Command for digit-then-redirect-with-no-space patterns and stored
|
|
178
|
-
// The count in fdBudget; only consume one when we see a digit
|
|
179
|
-
// Adjacent to a redirect op here.
|
|
180
|
-
const last = tokens.at(-1);
|
|
181
|
-
if (last !== undefined && /^[0-9]+$/.test(last) && fdBudget.remaining > 0) {
|
|
182
|
-
tokens.pop();
|
|
183
|
-
fdBudget.remaining -= 1;
|
|
184
|
-
}
|
|
185
|
-
const target = entries[i + 1];
|
|
186
|
-
if (target !== undefined && isStringToken(target)) {
|
|
187
|
-
redirects.push({ op: op as Redirect['op'], target });
|
|
188
|
-
i += 2;
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
i += 1;
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
for (const t of entryToTokens(e)) {
|
|
195
|
-
tokens.push(t);
|
|
196
|
-
}
|
|
197
|
-
i += 1;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (tokens.length === 0) {
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
for (let j = 1; j < tokens.length; j += 1) {
|
|
204
|
-
const t = tokens[j]!;
|
|
205
|
-
if (t.startsWith('-') && t !== '-') {
|
|
206
|
-
flags.push(t);
|
|
207
|
-
} else {
|
|
208
|
-
args.push(t);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
// Use the executable basename for command rules. Keep the original tokens
|
|
212
|
-
// so an absolute or relative command path cannot bypass a rule and raw
|
|
213
|
-
// reconstruction stays accurate.
|
|
214
|
-
const rawHead = tokens[0]!;
|
|
215
|
-
const slashIdx = rawHead.lastIndexOf('/');
|
|
216
|
-
const head = slashIdx === -1 ? rawHead : rawHead.slice(slashIdx + 1);
|
|
217
|
-
return { head, tokens, args, flags, redirects, raw: tokens.join(' ') };
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
// Pass an env function that preserves variable references as literals,
|
|
221
|
-
// Otherwise shell-quote treats `$HOME` as an empty string and we lose
|
|
222
|
-
// The ability to reason about home-directory references.
|
|
223
|
-
const PRESERVE_ENV = (key: string): string => `$${key}`;
|
|
224
|
-
|
|
225
|
-
// Shell-quote splits `&>file` into two ops — `{op:"&"}` then `{op:">"}` —
|
|
226
|
-
// Which would (a) make the `&` look like a backgrounding segment break and
|
|
227
|
-
// (b) hide the redirect from rule analysis. Merge those pairs back into
|
|
228
|
-
// `&>` / `&>>` before segment splitting.
|
|
229
|
-
const mergeAmpRedirects = (entries: readonly ParseEntry[]): ParseEntry[] => {
|
|
230
|
-
const out: ParseEntry[] = [];
|
|
231
|
-
for (let i = 0; i < entries.length; i += 1) {
|
|
232
|
-
const e = entries[i]!;
|
|
233
|
-
const next = entries[i + 1];
|
|
234
|
-
if (getOp(e) === '&' && next !== undefined && (getOp(next) === '>' || getOp(next) === '>>')) {
|
|
235
|
-
const merged = { op: getOp(next) === '>' ? '&>' : '&>>' } as unknown as ParseEntry;
|
|
236
|
-
out.push(merged);
|
|
237
|
-
i += 1;
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
// `>|file` is bash's noclobber-override redirect. shell-quote splits
|
|
241
|
-
// It into `{op:">"}, {op:"|"}` — the `|` then trips segment splitting
|
|
242
|
-
// And the redirect target is lost. Re-merge to a single `>` op (the
|
|
243
|
-
// Noclobber bit doesn't matter to rule analysis; what matters is that
|
|
244
|
-
// It's a write redirect to the following target).
|
|
245
|
-
if (getOp(e) === '>' && next !== undefined && getOp(next) === '|') {
|
|
246
|
-
out.push({ op: '>' } as unknown as ParseEntry);
|
|
247
|
-
i += 1;
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
out.push(e);
|
|
251
|
-
}
|
|
252
|
-
return out;
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
// Count digit-then-redirect adjacencies in the source string (`2>file`,
|
|
256
|
-
// `1>&2`, `2>>log`). The `(?<![\w$])` rejects matches inside identifiers
|
|
257
|
-
// Like `foo2>bar`. A trailing `>` or `<` with no whitespace is required —
|
|
258
|
-
// `echo 2 >file` keeps `2` as a positional arg.
|
|
259
|
-
const countFdPrefixRedirects = (cmd: string): number => {
|
|
260
|
-
const matches = cmd.match(/(?<![\w$])\d+(?=[<>])/g);
|
|
261
|
-
return matches?.length ?? 0;
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
const heredocDelimiterFromLine = (line: string): string | null => {
|
|
265
|
-
const match =
|
|
266
|
-
/<<-?\s*(?:"(?<quoted>[^"]+)"|'(?<single>[^']+)'|(?<unquoted>[A-Za-z_][A-Za-z0-9_]*))/u.exec(
|
|
267
|
-
line,
|
|
268
|
-
);
|
|
269
|
-
return (
|
|
270
|
-
match?.groups?.['quoted'] ?? match?.groups?.['single'] ?? match?.groups?.['unquoted'] ?? null
|
|
271
|
-
);
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
const SHELL_STDIN_HEAD_RE =
|
|
275
|
-
/(?:^|[|;&]\s*)(?:\/(?:usr\/bin|bin|usr\/local\/bin|opt\/homebrew\/bin)\/)?(?:sh|bash|zsh|dash|ksh|ash)(?:\s|$)/u;
|
|
276
|
-
|
|
277
|
-
const heredocFeedsShell = (line: string): boolean => SHELL_STDIN_HEAD_RE.test(line);
|
|
278
|
-
|
|
279
|
-
const maskLiteralHeredocBodies = (cmd: string): string => {
|
|
280
|
-
const lines = cmd.split('\n');
|
|
281
|
-
const out: string[] = [];
|
|
282
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
283
|
-
const line = lines[i]!;
|
|
284
|
-
out.push(line);
|
|
285
|
-
const delimiter = heredocDelimiterFromLine(line);
|
|
286
|
-
if (delimiter === null || heredocFeedsShell(line)) {
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
i += 1;
|
|
290
|
-
while (i < lines.length && lines[i]!.trim() !== delimiter) {
|
|
291
|
-
i += 1;
|
|
292
|
-
}
|
|
293
|
-
if (i < lines.length) {
|
|
294
|
-
out.push('__HEREDOC_BODY__', lines[i]!);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
return out.join('\n');
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
// Side-channel for static-string lookup. Mask-protected rules (hasBypass,
|
|
301
|
-
// Bash-deny scanning) must never see heredoc body characters — a body
|
|
302
|
-
// Containing `# tripwire-allow` or `rm -rf /` would slip past them.
|
|
303
|
-
// `unwrapStaticString` still needs the original body of `$(cat <<EOF ... EOF)`
|
|
304
|
-
// To validate the wrapped commit message. Keep masking universal, and pass
|
|
305
|
-
// The original-body lookup through a separate channel only the static-string
|
|
306
|
-
// Extractor consults.
|
|
307
|
-
const collectHeredocBodies = (cmd: string): ReadonlyMap<string, string> => {
|
|
308
|
-
const map = new Map<string, string>();
|
|
309
|
-
const lines = cmd.split('\n');
|
|
310
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
311
|
-
const line = lines[i]!;
|
|
312
|
-
const delimiter = heredocDelimiterFromLine(line);
|
|
313
|
-
if (delimiter === null || heredocFeedsShell(line)) {
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
const body: string[] = [];
|
|
317
|
-
i += 1;
|
|
318
|
-
while (i < lines.length && lines[i]!.trim() !== delimiter) {
|
|
319
|
-
body.push(lines[i]!);
|
|
320
|
-
i += 1;
|
|
321
|
-
}
|
|
322
|
-
if (!map.has(delimiter)) {
|
|
323
|
-
map.set(delimiter, body.join('\n'));
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
return map;
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
const extractShellHeredocCommands = (cmd: string): string[] => {
|
|
330
|
-
const lines = cmd.split('\n');
|
|
331
|
-
const out: string[] = [];
|
|
332
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
333
|
-
const line = lines[i]!;
|
|
334
|
-
const delimiter = heredocDelimiterFromLine(line);
|
|
335
|
-
if (delimiter === null || !heredocFeedsShell(line)) {
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
const body: string[] = [];
|
|
339
|
-
i += 1;
|
|
340
|
-
while (i < lines.length && lines[i]!.trim() !== delimiter) {
|
|
341
|
-
body.push(lines[i]!);
|
|
342
|
-
i += 1;
|
|
343
|
-
}
|
|
344
|
-
if (body.length > 0) {
|
|
345
|
-
out.push(body.join('\n'));
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
return out;
|
|
349
|
-
};
|
|
350
|
-
|
|
351
|
-
// Extract inner commands from `$(...)`, `<(...)`, `>(...)`, and `` `...` ``.
|
|
352
|
-
// Shell-quote collapses these into opaque sentinel tokens (which is correct
|
|
353
|
-
// For safe-path checks — substituted output is unknown), but it also hides
|
|
354
|
-
// The inner commands themselves from rule analysis. So `tee >(rm -rf /etc)`
|
|
355
|
-
// Would let the `rm` slip through. We pull the inner commands out and
|
|
356
|
-
// Analyze them as additional segments.
|
|
357
|
-
//
|
|
358
|
-
// Backticks don't nest (bash needs `\` escaping for that, which we treat as
|
|
359
|
-
// A literal). Process/command substitutions can nest arbitrarily — a depth
|
|
360
|
-
// Counter handles the balanced parens.
|
|
361
|
-
const findBacktickEnd = (cmd: string, start: number): number | null => {
|
|
362
|
-
for (let i = start; i < cmd.length; i += 1) {
|
|
363
|
-
const ch = cmd[i]!;
|
|
364
|
-
if (ch === '\\') {
|
|
365
|
-
i += 1;
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
if (ch === '`') {
|
|
369
|
-
return i;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
return null;
|
|
373
|
-
};
|
|
374
|
-
|
|
375
|
-
const findSubstitutionEnd = (cmd: string, start: number): number | null => {
|
|
376
|
-
let depth = 1;
|
|
377
|
-
let quote: 'single' | 'double' | null = null;
|
|
378
|
-
for (let j = start; j < cmd.length; j += 1) {
|
|
379
|
-
const cj = cmd[j]!;
|
|
380
|
-
if (cj === '\\') {
|
|
381
|
-
j += 1;
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
if (quote === 'single') {
|
|
385
|
-
if (cj === "'") {
|
|
386
|
-
quote = null;
|
|
387
|
-
}
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
if (cj === "'") {
|
|
391
|
-
quote ??= 'single';
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
if (cj === '"') {
|
|
395
|
-
quote = quote === 'double' ? null : 'double';
|
|
396
|
-
continue;
|
|
397
|
-
}
|
|
398
|
-
if (cj === '(') {
|
|
399
|
-
depth += 1;
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
402
|
-
if (cj === ')') {
|
|
403
|
-
depth -= 1;
|
|
404
|
-
if (depth === 0) {
|
|
405
|
-
return j;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
return null;
|
|
410
|
-
};
|
|
411
|
-
|
|
412
|
-
const extractInnerCommands = (cmd: string): string[] => {
|
|
413
|
-
const inner: string[] = [];
|
|
414
|
-
let quote: 'single' | 'double' | null = null;
|
|
415
|
-
for (let i = 0; i < cmd.length; i += 1) {
|
|
416
|
-
const ch = cmd[i]!;
|
|
417
|
-
if (ch === '\\') {
|
|
418
|
-
i += 1;
|
|
419
|
-
continue;
|
|
420
|
-
}
|
|
421
|
-
if (quote === 'single') {
|
|
422
|
-
if (ch === "'") {
|
|
423
|
-
quote = null;
|
|
424
|
-
}
|
|
425
|
-
continue;
|
|
426
|
-
}
|
|
427
|
-
if (ch === "'") {
|
|
428
|
-
quote ??= 'single';
|
|
429
|
-
continue;
|
|
430
|
-
}
|
|
431
|
-
if (ch === '"') {
|
|
432
|
-
quote = quote === 'double' ? null : 'double';
|
|
433
|
-
continue;
|
|
434
|
-
}
|
|
435
|
-
if (ch === '`') {
|
|
436
|
-
const end = findBacktickEnd(cmd, i + 1);
|
|
437
|
-
if (end !== null) {
|
|
438
|
-
inner.push(cmd.slice(i + 1, end));
|
|
439
|
-
i = end;
|
|
440
|
-
}
|
|
441
|
-
continue;
|
|
442
|
-
}
|
|
443
|
-
const next = cmd[i + 1];
|
|
444
|
-
const isCommandSubStart = ch === '$' && next === '(';
|
|
445
|
-
const isProcessSubStart = quote === null && (ch === '<' || ch === '>') && next === '(';
|
|
446
|
-
if (!isCommandSubStart && !isProcessSubStart) {
|
|
447
|
-
continue;
|
|
448
|
-
}
|
|
449
|
-
const end = findSubstitutionEnd(cmd, i + 2);
|
|
450
|
-
if (end !== null) {
|
|
451
|
-
inner.push(cmd.slice(i + 2, end));
|
|
452
|
-
i = end;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
return inner;
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
// ── Exec-flag extraction (fd -x, find -exec, etc.) ───────────────────
|
|
459
|
-
// Tools that take a subcommand on the same arg vector hide that
|
|
460
|
-
// Subcommand from rule analysis. Pull it out, substitute the user-
|
|
461
|
-
// Provided search root into the placeholder(s), and feed the
|
|
462
|
-
// Reconstructed command back through parseCommand so every existing
|
|
463
|
-
// Bash rule (deny / scoped-rm / redirect / etc.) sees it.
|
|
464
|
-
|
|
465
|
-
const HOME_VAR_RE = /^\$\{?HOME\}?(?:\/|$)/;
|
|
466
|
-
|
|
467
|
-
const pathLikeToken = (t: string): boolean => {
|
|
468
|
-
if (t === '' || t === '-') {
|
|
469
|
-
return false;
|
|
470
|
-
}
|
|
471
|
-
if (t === '/' || t === '~' || t === '.' || t === '..') {
|
|
472
|
-
return true;
|
|
473
|
-
}
|
|
474
|
-
if (
|
|
475
|
-
t.startsWith('/') ||
|
|
476
|
-
t.startsWith('~') ||
|
|
477
|
-
t.startsWith('./') ||
|
|
478
|
-
t.startsWith('../') ||
|
|
479
|
-
HOME_VAR_RE.test(t)
|
|
480
|
-
) {
|
|
481
|
-
return true;
|
|
482
|
-
}
|
|
483
|
-
return false;
|
|
484
|
-
};
|
|
485
|
-
|
|
486
|
-
// Rank candidate search roots by how dangerous a `cmd <root>` invocation
|
|
487
|
-
// Would be. Higher wins.
|
|
488
|
-
const pathDangerScore = (t: string): number => {
|
|
489
|
-
if (t === '/') {
|
|
490
|
-
return 100;
|
|
491
|
-
}
|
|
492
|
-
if (t === '~' || HOME_VAR_RE.test(t)) {
|
|
493
|
-
return 90;
|
|
494
|
-
}
|
|
495
|
-
if (/^\/(?<dir>etc|usr|bin|sbin|System|Library|var|boot|root|home)(?<suffix>\/|$)/.test(t)) {
|
|
496
|
-
return 80;
|
|
497
|
-
}
|
|
498
|
-
if (t.startsWith('/Users/')) {
|
|
499
|
-
return 70;
|
|
500
|
-
}
|
|
501
|
-
if (t.startsWith('/') || t.startsWith('~')) {
|
|
502
|
-
return 60;
|
|
503
|
-
}
|
|
504
|
-
if (t.startsWith('../')) {
|
|
505
|
-
return 40;
|
|
506
|
-
}
|
|
507
|
-
if (t === '..' || t === '.' || t.startsWith('./')) {
|
|
508
|
-
return 10;
|
|
509
|
-
}
|
|
510
|
-
return 50;
|
|
511
|
-
};
|
|
512
|
-
|
|
513
|
-
interface ExecSpec {
|
|
514
|
-
// Flag tokens that introduce a nested command, e.g. `-x` / `-exec`.
|
|
515
|
-
readonly execFlags: ReadonlySet<string>;
|
|
516
|
-
// Placeholder tokens the tool substitutes with each match path.
|
|
517
|
-
readonly placeholders: ReadonlySet<string>;
|
|
518
|
-
// Walk tokens[1..execFlagIdx) and return the most-suspicious search root
|
|
519
|
-
// The tool would feed into placeholders, or `.` if nothing path-shaped
|
|
520
|
-
// Is present.
|
|
521
|
-
readonly pickRoot: (tokens: readonly string[], execFlagIdx: number) => string;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Fd's flag layout: flags can appear before or after the pattern/path
|
|
525
|
-
// Positionals, and some flags consume a value (-e ts, -t f, -d 3). We
|
|
526
|
-
// Need to skip those value tokens, otherwise `ts` is misread as a path.
|
|
527
|
-
const FD_VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
528
|
-
'-e',
|
|
529
|
-
'--extension',
|
|
530
|
-
'-t',
|
|
531
|
-
'--type',
|
|
532
|
-
'-E',
|
|
533
|
-
'--exclude',
|
|
534
|
-
'-d',
|
|
535
|
-
'--max-depth',
|
|
536
|
-
'--min-depth',
|
|
537
|
-
'--exact-depth',
|
|
538
|
-
'-c',
|
|
539
|
-
'--color',
|
|
540
|
-
'--changed-within',
|
|
541
|
-
'--changed-before',
|
|
542
|
-
'-S',
|
|
543
|
-
'--size',
|
|
544
|
-
'-o',
|
|
545
|
-
'--owner',
|
|
546
|
-
'-j',
|
|
547
|
-
'--threads',
|
|
548
|
-
'-g',
|
|
549
|
-
'--glob',
|
|
550
|
-
'--format',
|
|
551
|
-
'--max-results',
|
|
552
|
-
'--ignore-file',
|
|
553
|
-
'--search-path',
|
|
554
|
-
'--base-directory',
|
|
555
|
-
'--path-separator',
|
|
556
|
-
'--and',
|
|
557
|
-
]);
|
|
558
|
-
|
|
559
|
-
const pickFdSearchRoot = (tokens: readonly string[], execFlagIdx: number): string => {
|
|
560
|
-
const candidates: string[] = [];
|
|
561
|
-
let i = 1;
|
|
562
|
-
while (i < execFlagIdx) {
|
|
563
|
-
const t = tokens[i]!;
|
|
564
|
-
if (FD_VALUE_FLAGS.has(t)) {
|
|
565
|
-
i += 2;
|
|
566
|
-
continue;
|
|
567
|
-
}
|
|
568
|
-
if (t.startsWith('-')) {
|
|
569
|
-
i += 1;
|
|
570
|
-
continue;
|
|
571
|
-
}
|
|
572
|
-
if (pathLikeToken(t)) {
|
|
573
|
-
candidates.push(t);
|
|
574
|
-
}
|
|
575
|
-
i += 1;
|
|
576
|
-
}
|
|
577
|
-
if (candidates.length === 0) {
|
|
578
|
-
return '.';
|
|
579
|
-
}
|
|
580
|
-
candidates.sort((a, b) => pathDangerScore(b) - pathDangerScore(a));
|
|
581
|
-
return candidates[0]!;
|
|
582
|
-
};
|
|
583
|
-
|
|
584
|
-
// Find's grammar: PATHs come first, before any flag-shaped token. Once we
|
|
585
|
-
// Hit a `-`-prefixed token (a test predicate or action), no more paths.
|
|
586
|
-
// `find` defaults to cwd if no path is given. We collect everything
|
|
587
|
-
// Path-shaped in the prefix region as candidates.
|
|
588
|
-
const pickFindSearchRoot = (tokens: readonly string[], execFlagIdx: number): string => {
|
|
589
|
-
const candidates: string[] = [];
|
|
590
|
-
for (let i = 1; i < execFlagIdx; i += 1) {
|
|
591
|
-
const t = tokens[i]!;
|
|
592
|
-
if (t.startsWith('-')) {
|
|
593
|
-
break;
|
|
594
|
-
}
|
|
595
|
-
if (pathLikeToken(t)) {
|
|
596
|
-
candidates.push(t);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
if (candidates.length === 0) {
|
|
600
|
-
return '.';
|
|
601
|
-
}
|
|
602
|
-
candidates.sort((a, b) => pathDangerScore(b) - pathDangerScore(a));
|
|
603
|
-
return candidates[0]!;
|
|
604
|
-
};
|
|
605
|
-
|
|
606
|
-
const FD_SPEC: ExecSpec = {
|
|
607
|
-
execFlags: new Set(['-x', '-X', '--exec', '--exec-batch']),
|
|
608
|
-
placeholders: new Set(['{}', '{/}', '{//}', '{.}', '{/.}']),
|
|
609
|
-
pickRoot: pickFdSearchRoot,
|
|
610
|
-
};
|
|
611
|
-
|
|
612
|
-
const FIND_SPEC: ExecSpec = {
|
|
613
|
-
// `-ok` / `-okdir` prompt interactively per-match, but the executed
|
|
614
|
-
// Command is still constructed from agent-controlled input, so treat
|
|
615
|
-
// It the same as `-exec`.
|
|
616
|
-
execFlags: new Set(['-exec', '-execdir', '-ok', '-okdir']),
|
|
617
|
-
placeholders: new Set(['{}']),
|
|
618
|
-
pickRoot: pickFindSearchRoot,
|
|
619
|
-
};
|
|
620
|
-
|
|
621
|
-
const EXEC_SPECS: Readonly<Record<string, ExecSpec>> = Object.assign(
|
|
622
|
-
Object.create(null) as Record<string, ExecSpec>,
|
|
623
|
-
{ fd: FD_SPEC, fdfind: FD_SPEC, find: FIND_SPEC, gfind: FIND_SPEC },
|
|
624
|
-
);
|
|
625
|
-
|
|
626
|
-
const substitutePlaceholders = (
|
|
627
|
-
tokens: readonly string[],
|
|
628
|
-
spec: ExecSpec,
|
|
629
|
-
root: string,
|
|
630
|
-
): string[] => tokens.map((t) => (spec.placeholders.has(t) ? root : t));
|
|
631
|
-
|
|
632
|
-
const extractExecCommands = (seg: Segment): string[] => {
|
|
633
|
-
const spec = EXEC_SPECS[seg.head];
|
|
634
|
-
if (spec === undefined) {
|
|
635
|
-
return [];
|
|
636
|
-
}
|
|
637
|
-
const out: string[] = [];
|
|
638
|
-
const { tokens } = seg;
|
|
639
|
-
for (let i = 1; i < tokens.length; i += 1) {
|
|
640
|
-
if (!spec.execFlags.has(tokens[i]!)) {
|
|
641
|
-
continue;
|
|
642
|
-
}
|
|
643
|
-
// Collect tokens until the exec terminator (`;` or `+`, both shared
|
|
644
|
-
// By fd and find) or end of segment. shell-quote turns `\;` into the
|
|
645
|
-
// Literal string token `;`.
|
|
646
|
-
const inner: string[] = [];
|
|
647
|
-
let j = i + 1;
|
|
648
|
-
while (j < tokens.length) {
|
|
649
|
-
const t = tokens[j]!;
|
|
650
|
-
if (t === ';' || t === '+') {
|
|
651
|
-
break;
|
|
652
|
-
}
|
|
653
|
-
inner.push(t);
|
|
654
|
-
j += 1;
|
|
655
|
-
}
|
|
656
|
-
if (inner.length === 0) {
|
|
657
|
-
continue;
|
|
658
|
-
}
|
|
659
|
-
const head = inner[0]!;
|
|
660
|
-
if (spec.placeholders.has(head)) {
|
|
661
|
-
continue;
|
|
662
|
-
}
|
|
663
|
-
const root = spec.pickRoot(tokens, i);
|
|
664
|
-
out.push(quote(substitutePlaceholders(inner, spec, root)));
|
|
665
|
-
i = j;
|
|
666
|
-
}
|
|
667
|
-
return out;
|
|
668
|
-
};
|
|
669
|
-
|
|
670
|
-
// ── Substitution unwrappers ──────────────────────────────────────────
|
|
671
|
-
//
|
|
672
|
-
// Bash hides agent-controlled content inside command substitutions and
|
|
673
|
-
// Shell wrappers two different ways, and rules need two different shapes
|
|
674
|
-
// Of unwrap:
|
|
675
|
-
//
|
|
676
|
-
// 1. `sh -c '<script>'` and `bash -c '<script>'` carry a script the
|
|
677
|
-
// Outer parser can't see into. Extracted with
|
|
678
|
-
// `extractShellWrappedCommands(seg)` and re-parsed as bash segments
|
|
679
|
-
// So every existing rule applies. Used by `parseCommand`.
|
|
680
|
-
//
|
|
681
|
-
// 2. `$(cat <<'TAG' ... TAG)` and `$(echo '...')` / `$(printf '...')`
|
|
682
|
-
// Compute a static string value at runtime. Rules that inspect arg
|
|
683
|
-
// Values (commit-message convention, redirect targets, etc.) need
|
|
684
|
-
// The string, not a re-parse. `unwrapStaticString(token)` returns
|
|
685
|
-
// It; pass-through if the token isn't a recognised substitution.
|
|
686
|
-
//
|
|
687
|
-
// Both layers cover the same underlying gap — the shell-quote parser
|
|
688
|
-
// Treats substitutions as opaque sentinels — and any rule that touches
|
|
689
|
-
// Agent-controlled content should route through one of them.
|
|
690
|
-
|
|
691
|
-
const HEREDOC_SUBST_RE =
|
|
692
|
-
/\$\(\s*cat\s+<<-?\s*['"]?(?<delimiter>\w+)['"]?\s*\n(?<body>[\s\S]*?)\n\s*\k<delimiter>\s*\)/u;
|
|
693
|
-
const ECHO_PRINTF_SUBST_RE = /\$\(\s*(?:printf|echo)\s+(?:-[a-zA-Z]+\s+)*'(?<content>[^']*)'/u;
|
|
694
|
-
|
|
695
|
-
const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, string>): string => {
|
|
696
|
-
const heredoc = HEREDOC_SUBST_RE.exec(value);
|
|
697
|
-
if (heredoc !== null) {
|
|
698
|
-
const captured = heredoc.groups?.['body'] ?? value;
|
|
699
|
-
if (heredocBodies !== undefined && captured.trim() === '__HEREDOC_BODY__') {
|
|
700
|
-
const delimiter = heredoc.groups?.['delimiter'];
|
|
701
|
-
const real = delimiter === undefined ? undefined : heredocBodies.get(delimiter);
|
|
702
|
-
if (real !== undefined) {
|
|
703
|
-
return real;
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
return captured;
|
|
707
|
-
}
|
|
708
|
-
const printf = ECHO_PRINTF_SUBST_RE.exec(value);
|
|
709
|
-
if (printf !== null) {
|
|
710
|
-
return printf.groups?.['content'] ?? value;
|
|
711
|
-
}
|
|
712
|
-
return value;
|
|
713
|
-
};
|
|
714
|
-
|
|
715
|
-
// Recover commands hidden inside a `sh -c '...'` / `bash -c '...'` wrapper.
|
|
716
|
-
// Without this, every redirect / deny / scoped-rm rule can be trivially
|
|
717
|
-
// Bypassed by wrapping the offending command in `sh -c`. The shell parser
|
|
718
|
-
// Otherwise sees `sh` as the head and the script as an opaque positional
|
|
719
|
-
// Arg. We pull the script out and feed it back through `parseCommand` so
|
|
720
|
-
// All existing rules apply.
|
|
721
|
-
// Head normalisation in `parseSegment` strips directory prefixes, so this set
|
|
722
|
-
// Only needs bare basenames — `/bin/bash` etc. are now unreachable as heads.
|
|
723
|
-
const SHELL_WRAPPER_HEADS: ReadonlySet<string> = new Set([
|
|
724
|
-
'sh',
|
|
725
|
-
'bash',
|
|
726
|
-
'zsh',
|
|
727
|
-
'dash',
|
|
728
|
-
'ksh',
|
|
729
|
-
'ash',
|
|
730
|
-
]);
|
|
731
|
-
|
|
732
|
-
const extractShellWrappedCommands = (seg: Segment): string[] => {
|
|
733
|
-
if (!SHELL_WRAPPER_HEADS.has(seg.head)) {
|
|
734
|
-
return [];
|
|
735
|
-
}
|
|
736
|
-
const { tokens } = seg;
|
|
737
|
-
for (let i = 1; i < tokens.length; i += 1) {
|
|
738
|
-
const t = tokens[i]!;
|
|
739
|
-
if (t === '-c' && i + 1 < tokens.length) {
|
|
740
|
-
return [tokens[i + 1]!];
|
|
741
|
-
}
|
|
742
|
-
// Combined short flags that include `c`: `-ec`, `-xc`, `-eu c` won't —
|
|
743
|
-
// Only treat `c` as the last char so the next token is the script.
|
|
744
|
-
if (
|
|
745
|
-
t.startsWith('-') &&
|
|
746
|
-
!t.startsWith('--') &&
|
|
747
|
-
t.endsWith('c') &&
|
|
748
|
-
t.length > 2 &&
|
|
749
|
-
i + 1 < tokens.length
|
|
750
|
-
) {
|
|
751
|
-
return [tokens[i + 1]!];
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
return [];
|
|
755
|
-
};
|
|
756
|
-
|
|
757
|
-
// Heads that take `[flags] <command> [args]` on the same arg vector: the
|
|
758
|
-
// First non-flag token after the prefix is the real command. `sudo`/`doas`
|
|
759
|
-
// (privilege escalation), `xargs` (stdin-driven exec), and `watch` (repeated
|
|
760
|
-
// Exec) all hide a sibling command this way, so the same unwrap that handles
|
|
761
|
-
// `command`/`env`/`nohup` applies. `sudo` is also matched by bash-deny's
|
|
762
|
-
// `ask` rule on the outer segment — both fire, and the more-restrictive
|
|
763
|
-
// Interior decision (e.g. `sudo rm -rf /` → deny) wins on merge.
|
|
764
|
-
const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
|
|
765
|
-
'command',
|
|
766
|
-
'exec',
|
|
767
|
-
'env',
|
|
768
|
-
'time',
|
|
769
|
-
'nohup',
|
|
770
|
-
'setsid',
|
|
771
|
-
'nice',
|
|
772
|
-
'ionice',
|
|
773
|
-
'chronic',
|
|
774
|
-
'stdbuf',
|
|
775
|
-
'unbuffer',
|
|
776
|
-
'script',
|
|
777
|
-
'taskset',
|
|
778
|
-
'sudo',
|
|
779
|
-
'doas',
|
|
780
|
-
'xargs',
|
|
781
|
-
'watch',
|
|
782
|
-
]);
|
|
783
|
-
|
|
784
|
-
const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
785
|
-
command: new Set(),
|
|
786
|
-
env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string', '--block-signal']),
|
|
787
|
-
time: new Set(['-f', '--format', '-o', '--output']),
|
|
788
|
-
nice: new Set(['-n', '--adjustment']),
|
|
789
|
-
ionice: new Set(['-c', '--class', '-n', '--classdata', '-p', '--pid']),
|
|
790
|
-
stdbuf: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
|
|
791
|
-
script: new Set(['-c', '--command']),
|
|
792
|
-
taskset: new Set(),
|
|
793
|
-
sudo: new Set([
|
|
794
|
-
'-u',
|
|
795
|
-
'--user',
|
|
796
|
-
'-g',
|
|
797
|
-
'--group',
|
|
798
|
-
'-C',
|
|
799
|
-
'--close-from',
|
|
800
|
-
'-D',
|
|
801
|
-
'--chdir',
|
|
802
|
-
'-h',
|
|
803
|
-
'--host',
|
|
804
|
-
'-p',
|
|
805
|
-
'--prompt',
|
|
806
|
-
'-r',
|
|
807
|
-
'--role',
|
|
808
|
-
'-t',
|
|
809
|
-
'--type',
|
|
810
|
-
'-U',
|
|
811
|
-
'--other-user',
|
|
812
|
-
'-R',
|
|
813
|
-
'--chroot',
|
|
814
|
-
'-T',
|
|
815
|
-
'--command-timeout',
|
|
816
|
-
]),
|
|
817
|
-
doas: new Set(['-a', '-C', '-u']),
|
|
818
|
-
xargs: new Set([
|
|
819
|
-
'-I',
|
|
820
|
-
'-i',
|
|
821
|
-
'-J',
|
|
822
|
-
'-n',
|
|
823
|
-
'--max-args',
|
|
824
|
-
'-P',
|
|
825
|
-
'--max-procs',
|
|
826
|
-
'-s',
|
|
827
|
-
'--max-chars',
|
|
828
|
-
'-L',
|
|
829
|
-
'--max-lines',
|
|
830
|
-
'-E',
|
|
831
|
-
'--eof',
|
|
832
|
-
'-d',
|
|
833
|
-
'--delimiter',
|
|
834
|
-
'-a',
|
|
835
|
-
'--arg-file',
|
|
836
|
-
'--replace',
|
|
837
|
-
]),
|
|
838
|
-
watch: new Set(['-n', '--interval']),
|
|
839
|
-
};
|
|
840
|
-
|
|
841
|
-
const tokenLooksLikeEnvAssignment = (token: string): boolean =>
|
|
842
|
-
/^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(token);
|
|
843
|
-
|
|
844
|
-
const skipHeadRenamingPrefix = (tokens: readonly string[]): number => {
|
|
845
|
-
const head = tokens[0]!;
|
|
846
|
-
const valueFlags = HEAD_RENAMING_VALUE_FLAGS[head] ?? new Set<string>();
|
|
847
|
-
let i = 1;
|
|
848
|
-
while (i < tokens.length) {
|
|
849
|
-
const token = tokens[i]!;
|
|
850
|
-
if (head === 'env' && tokenLooksLikeEnvAssignment(token)) {
|
|
851
|
-
i += 1;
|
|
852
|
-
continue;
|
|
853
|
-
}
|
|
854
|
-
if (valueFlags.has(token)) {
|
|
855
|
-
i += 2;
|
|
856
|
-
continue;
|
|
857
|
-
}
|
|
858
|
-
if (token.includes('=') && valueFlags.has(token.slice(0, token.indexOf('=')))) {
|
|
859
|
-
i += 1;
|
|
860
|
-
continue;
|
|
861
|
-
}
|
|
862
|
-
if (token.startsWith('--') && token !== '--') {
|
|
863
|
-
i += 1;
|
|
864
|
-
continue;
|
|
865
|
-
}
|
|
866
|
-
if (token.startsWith('-') && token !== '-') {
|
|
867
|
-
i += 1;
|
|
868
|
-
continue;
|
|
869
|
-
}
|
|
870
|
-
break;
|
|
871
|
-
}
|
|
872
|
-
return i;
|
|
873
|
-
};
|
|
874
|
-
|
|
875
|
-
const extractHeadRenamingCommands = (seg: Segment): string[] => {
|
|
876
|
-
if (!HEAD_RENAMING_HEADS.has(seg.head)) {
|
|
877
|
-
return [];
|
|
878
|
-
}
|
|
879
|
-
if (seg.head === 'script') {
|
|
880
|
-
for (let i = 1; i < seg.tokens.length - 1; i += 1) {
|
|
881
|
-
const token = seg.tokens[i]!;
|
|
882
|
-
if (token === '-c' || token === '--command') {
|
|
883
|
-
return [seg.tokens[i + 1]!];
|
|
884
|
-
}
|
|
885
|
-
if (token.startsWith('--command=')) {
|
|
886
|
-
return [token.slice('--command='.length)];
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
const start = skipHeadRenamingPrefix(seg.tokens);
|
|
891
|
-
const inner = quote(seg.tokens.slice(start));
|
|
892
|
-
return inner === '' ? [] : [inner];
|
|
893
|
-
};
|
|
894
|
-
|
|
895
|
-
const EVAL_HEADS: ReadonlySet<string> = new Set(['eval']);
|
|
896
|
-
|
|
897
|
-
const extractEvalCommands = (seg: Segment): string[] => {
|
|
898
|
-
if (!EVAL_HEADS.has(seg.head)) {
|
|
899
|
-
return [];
|
|
900
|
-
}
|
|
901
|
-
if (seg.tokens.length === 2) {
|
|
902
|
-
return [seg.tokens[1]!];
|
|
903
|
-
}
|
|
904
|
-
const sub = quote(seg.tokens.slice(1));
|
|
905
|
-
return sub === '' ? [] : [sub];
|
|
906
|
-
};
|
|
907
|
-
|
|
908
|
-
// ── rtk (token-optimizing CLI proxy) ─────────────────────────────────
|
|
909
|
-
// Rtk wraps real commands so their output is filtered before reaching the
|
|
910
|
-
// Agent's context, and Codex auto-prepends it. The wrapper hides the real
|
|
911
|
-
// Command from every rule: `rtk proxy rm -rf /` parses with head `rtk` and
|
|
912
|
-
// The destructive `rm` buried in opaque positional args. Strip the `rtk`
|
|
913
|
-
// Prefix and reconstruct the interior command so the existing rules decide
|
|
914
|
-
// On what actually runs.
|
|
915
|
-
//
|
|
916
|
-
// Grammar: `rtk [global-opts] <subcommand> [args]`. Two subcommand classes
|
|
917
|
-
// Exec a sibling command:
|
|
918
|
-
// • wrapper subs — the keyword is dropped, the remainder is an arbitrary
|
|
919
|
-
// Command: `run` (also `-c <string>`), `proxy`, `err`, `test`, `summary`.
|
|
920
|
-
// • tool-proxy subs — the keyword *is* the binary: `git`, `find`, `npm`,
|
|
921
|
-
// `docker`, … Reconstructing from the subcommand onward yields the real
|
|
922
|
-
// Invocation (`git push …`, `find … -delete`). rtk-internal filters that
|
|
923
|
-
// Aren't real binaries (`gain`, `config`, `diff`, …) reconstruct to inert
|
|
924
|
-
// Heads no rule matches, so no allowlist is needed.
|
|
925
|
-
const RTK_WRAPPER_SUBCOMMANDS: ReadonlySet<string> = new Set([
|
|
926
|
-
'run',
|
|
927
|
-
'proxy',
|
|
928
|
-
'err',
|
|
929
|
-
'test',
|
|
930
|
-
'summary',
|
|
931
|
-
]);
|
|
932
|
-
|
|
933
|
-
// Head normalisation in `parseSegment` strips directory prefixes, so only the
|
|
934
|
-
// Bare basename is needed — the `endsWith` fallbacks are now unreachable.
|
|
935
|
-
const isRtkHead = (head: string): boolean => head === 'rtk';
|
|
936
|
-
|
|
937
|
-
const skipRtkGlobalFlags = (tokens: readonly string[]): number => {
|
|
938
|
-
// Rtk's global options (`-v`/`-vv`/`--verbose`, `--ultra-compact`,
|
|
939
|
-
// `--skip-env`) are all boolean, so any leading flag token can be skipped.
|
|
940
|
-
let i = 1;
|
|
941
|
-
while (i < tokens.length) {
|
|
942
|
-
const t = tokens[i]!;
|
|
943
|
-
if (t.startsWith('-') && t !== '-' && t !== '--') {
|
|
944
|
-
i += 1;
|
|
945
|
-
continue;
|
|
946
|
-
}
|
|
947
|
-
break;
|
|
948
|
-
}
|
|
949
|
-
return i;
|
|
950
|
-
};
|
|
951
|
-
|
|
952
|
-
const dashCommandArg = (tokens: readonly string[], start: number): string | null => {
|
|
953
|
-
for (let k = start; k < tokens.length; k += 1) {
|
|
954
|
-
const t = tokens[k]!;
|
|
955
|
-
if ((t === '-c' || t === '--command') && k + 1 < tokens.length) {
|
|
956
|
-
return tokens[k + 1]!;
|
|
957
|
-
}
|
|
958
|
-
if (t.startsWith('--command=')) {
|
|
959
|
-
return t.slice('--command='.length);
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
return null;
|
|
963
|
-
};
|
|
964
|
-
|
|
965
|
-
const extractRtkCommands = (seg: Segment): string[] => {
|
|
966
|
-
if (!isRtkHead(seg.head)) {
|
|
967
|
-
return [];
|
|
968
|
-
}
|
|
969
|
-
const subIdx = skipRtkGlobalFlags(seg.tokens);
|
|
970
|
-
const sub = seg.tokens[subIdx];
|
|
971
|
-
if (sub === undefined) {
|
|
972
|
-
return [];
|
|
973
|
-
}
|
|
974
|
-
if (RTK_WRAPPER_SUBCOMMANDS.has(sub)) {
|
|
975
|
-
// `rtk run -c '<cmd>'` carries the command in a flag value.
|
|
976
|
-
const viaFlag = dashCommandArg(seg.tokens, subIdx + 1);
|
|
977
|
-
if (viaFlag !== null) {
|
|
978
|
-
return viaFlag === '' ? [] : [viaFlag];
|
|
979
|
-
}
|
|
980
|
-
// Otherwise the command is the positional remainder after the keyword,
|
|
981
|
-
// Skipping any wrapper-local boolean flags.
|
|
982
|
-
let j = subIdx + 1;
|
|
983
|
-
while (j < seg.tokens.length) {
|
|
984
|
-
const t = seg.tokens[j]!;
|
|
985
|
-
if (t === '--') {
|
|
986
|
-
j += 1;
|
|
987
|
-
break;
|
|
988
|
-
}
|
|
989
|
-
if (t.startsWith('-') && t !== '-') {
|
|
990
|
-
j += 1;
|
|
991
|
-
continue;
|
|
992
|
-
}
|
|
993
|
-
break;
|
|
994
|
-
}
|
|
995
|
-
const inner = quote(seg.tokens.slice(j));
|
|
996
|
-
return inner === '' ? [] : [inner];
|
|
997
|
-
}
|
|
998
|
-
// Tool-proxy subcommand: the subcommand token is the real binary name.
|
|
999
|
-
const inner = quote(seg.tokens.slice(subIdx));
|
|
1000
|
-
return inner === '' ? [] : [inner];
|
|
1001
|
-
};
|
|
1002
|
-
|
|
1003
|
-
// ── Positional-prefix wrappers ───────────────────────────────────────
|
|
1004
|
-
// Heads where the real command follows one or more positional arguments
|
|
1005
|
-
// The wrapper consumes itself: `timeout <duration> <cmd>`, `chroot <newroot>
|
|
1006
|
-
// <cmd>`, `flock <lockfile> <cmd>` (or `flock <lockfile> -c '<cmd>'`), and
|
|
1007
|
-
// `su [user] -c '<cmd>'`. Skip the flag region, drop the wrapper's own
|
|
1008
|
-
// Positionals, and the remainder is the command.
|
|
1009
|
-
interface PrefixWrapperSpec {
|
|
1010
|
-
readonly valueFlags: ReadonlySet<string>;
|
|
1011
|
-
// Positional args the wrapper consumes before the command (duration, newroot…).
|
|
1012
|
-
readonly skipPositionals: number;
|
|
1013
|
-
// Whether the command can also arrive via `-c <string>` (su, flock).
|
|
1014
|
-
readonly dashCommand: boolean;
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
const PREFIX_WRAPPER_SPECS: Readonly<Record<string, PrefixWrapperSpec>> = {
|
|
1018
|
-
timeout: {
|
|
1019
|
-
valueFlags: new Set(['-s', '--signal', '-k', '--kill-after']),
|
|
1020
|
-
skipPositionals: 1,
|
|
1021
|
-
dashCommand: false,
|
|
1022
|
-
},
|
|
1023
|
-
gtimeout: {
|
|
1024
|
-
valueFlags: new Set(['-s', '--signal', '-k', '--kill-after']),
|
|
1025
|
-
skipPositionals: 1,
|
|
1026
|
-
dashCommand: false,
|
|
1027
|
-
},
|
|
1028
|
-
chroot: {
|
|
1029
|
-
valueFlags: new Set(['--userspec', '--groups']),
|
|
1030
|
-
skipPositionals: 1,
|
|
1031
|
-
dashCommand: false,
|
|
1032
|
-
},
|
|
1033
|
-
flock: {
|
|
1034
|
-
valueFlags: new Set(['-w', '--wait', '--timeout', '-E', '--conflict-exit-code']),
|
|
1035
|
-
skipPositionals: 1,
|
|
1036
|
-
dashCommand: true,
|
|
1037
|
-
},
|
|
1038
|
-
su: { valueFlags: new Set(), skipPositionals: 0, dashCommand: true },
|
|
1039
|
-
};
|
|
1040
|
-
|
|
1041
|
-
const extractPrefixWrapperCommands = (seg: Segment): string[] => {
|
|
1042
|
-
const spec = PREFIX_WRAPPER_SPECS[seg.head];
|
|
1043
|
-
if (spec === undefined) {
|
|
1044
|
-
return [];
|
|
1045
|
-
}
|
|
1046
|
-
if (spec.dashCommand) {
|
|
1047
|
-
const viaFlag = dashCommandArg(seg.tokens, 1);
|
|
1048
|
-
if (viaFlag !== null) {
|
|
1049
|
-
return viaFlag === '' ? [] : [viaFlag];
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
let i = 1;
|
|
1053
|
-
while (i < seg.tokens.length) {
|
|
1054
|
-
const t = seg.tokens[i]!;
|
|
1055
|
-
if (spec.valueFlags.has(t)) {
|
|
1056
|
-
i += 2;
|
|
1057
|
-
continue;
|
|
1058
|
-
}
|
|
1059
|
-
if (t.includes('=') && spec.valueFlags.has(t.slice(0, t.indexOf('=')))) {
|
|
1060
|
-
i += 1;
|
|
1061
|
-
continue;
|
|
1062
|
-
}
|
|
1063
|
-
if (t === '--') {
|
|
1064
|
-
i += 1;
|
|
1065
|
-
break;
|
|
1066
|
-
}
|
|
1067
|
-
if (t.startsWith('-') && t !== '-') {
|
|
1068
|
-
i += 1;
|
|
1069
|
-
continue;
|
|
1070
|
-
}
|
|
1071
|
-
break;
|
|
1072
|
-
}
|
|
1073
|
-
i += spec.skipPositionals;
|
|
1074
|
-
const inner = quote(seg.tokens.slice(i));
|
|
1075
|
-
return inner === '' ? [] : [inner];
|
|
1076
|
-
};
|
|
1077
|
-
|
|
1078
|
-
// Shell control keywords keep an executable command on the same token vector.
|
|
1079
|
-
// For example, `then rm -rf /` arrives as one segment headed by `then`, which
|
|
1080
|
-
// hides `rm` from every policy rule. Reparse the tail as a command so nested
|
|
1081
|
-
// conditions and loop bodies pass through the normal rule pipeline.
|
|
1082
|
-
const COMPOUND_COMMAND_HEADS: ReadonlySet<string> = new Set([
|
|
1083
|
-
'!',
|
|
1084
|
-
'if',
|
|
1085
|
-
'elif',
|
|
1086
|
-
'then',
|
|
1087
|
-
'else',
|
|
1088
|
-
'while',
|
|
1089
|
-
'until',
|
|
1090
|
-
'do',
|
|
1091
|
-
]);
|
|
1092
|
-
|
|
1093
|
-
const extractCompoundKeywordCommand = (seg: Segment): string[] => {
|
|
1094
|
-
if (!COMPOUND_COMMAND_HEADS.has(seg.head) || seg.tokens.length < 2) {
|
|
1095
|
-
return [];
|
|
1096
|
-
}
|
|
1097
|
-
return [quote(seg.tokens.slice(1))];
|
|
1098
|
-
};
|
|
1099
|
-
|
|
1100
|
-
// Each extractor pulls the inner command(s) a wrapper hides on its own arg
|
|
1101
|
-
// Vector, to be re-parsed as additional segments so every rule sees what
|
|
1102
|
-
// Actually runs. Order is irrelevant — all results are unioned into `out`.
|
|
1103
|
-
const SEGMENT_EXTRACTORS: readonly ((seg: Segment) => string[])[] = [
|
|
1104
|
-
extractExecCommands,
|
|
1105
|
-
extractShellWrappedCommands,
|
|
1106
|
-
extractHeadRenamingCommands,
|
|
1107
|
-
extractEvalCommands,
|
|
1108
|
-
extractRtkCommands,
|
|
1109
|
-
extractPrefixWrapperCommands,
|
|
1110
|
-
extractCompoundKeywordCommand,
|
|
1111
|
-
];
|
|
1112
|
-
|
|
1113
|
-
const UNSUPPORTED_SHELL_HEAD = '__tripwire_unsupported_shell__';
|
|
1114
|
-
|
|
1115
|
-
const unsupportedShellSegment = (raw: string): Segment => ({
|
|
1116
|
-
head: UNSUPPORTED_SHELL_HEAD,
|
|
1117
|
-
tokens: [UNSUPPORTED_SHELL_HEAD],
|
|
1118
|
-
args: [],
|
|
1119
|
-
flags: [],
|
|
1120
|
-
redirects: [],
|
|
1121
|
-
raw,
|
|
1122
|
-
});
|
|
1123
|
-
|
|
1124
|
-
const containsUnsupportedShellStructure = (segments: readonly Segment[]): boolean =>
|
|
1125
|
-
segments.some((segment) => {
|
|
1126
|
-
if (segment.head === 'case' || segment.head === 'function' || segment.head === '{') {
|
|
1127
|
-
return true;
|
|
1128
|
-
}
|
|
1129
|
-
if (segment.tokens.includes('__op_(__') || segment.tokens.includes('__op_)__')) {
|
|
1130
|
-
return true;
|
|
1131
|
-
}
|
|
1132
|
-
return COMPOUND_COMMAND_HEADS.has(segment.head) && segment.tokens[1] === '{';
|
|
1133
|
-
});
|
|
1134
|
-
|
|
1135
|
-
const normalizeTopLevelNewlines = (cmd: string): string => {
|
|
1136
|
-
let out = '';
|
|
1137
|
-
let inSingle = false;
|
|
1138
|
-
let inDouble = false;
|
|
1139
|
-
let escaped = false;
|
|
1140
|
-
|
|
1141
|
-
for (const ch of cmd) {
|
|
1142
|
-
if (escaped) {
|
|
1143
|
-
out += ch;
|
|
1144
|
-
escaped = false;
|
|
1145
|
-
continue;
|
|
1146
|
-
}
|
|
1147
|
-
if (ch === '\\') {
|
|
1148
|
-
out += ch;
|
|
1149
|
-
escaped = true;
|
|
1150
|
-
continue;
|
|
1151
|
-
}
|
|
1152
|
-
if (ch === "'" && !inDouble) {
|
|
1153
|
-
inSingle = !inSingle;
|
|
1154
|
-
out += ch;
|
|
1155
|
-
continue;
|
|
1156
|
-
}
|
|
1157
|
-
if (ch === '"' && !inSingle) {
|
|
1158
|
-
inDouble = !inDouble;
|
|
1159
|
-
out += ch;
|
|
1160
|
-
continue;
|
|
1161
|
-
}
|
|
1162
|
-
out += ch === '\n' && !inSingle && !inDouble ? ' ; ' : ch;
|
|
1163
|
-
}
|
|
1164
|
-
return out;
|
|
1165
|
-
};
|
|
1166
|
-
|
|
1167
|
-
const parseCommand = (cmd: string): Segment[] => {
|
|
1168
|
-
let entries: ParseEntry[];
|
|
1169
|
-
const cmdForParsing = normalizeTopLevelNewlines(maskLiteralHeredocBodies(cmd));
|
|
1170
|
-
try {
|
|
1171
|
-
entries = parse(cmdForParsing, PRESERVE_ENV);
|
|
1172
|
-
} catch {
|
|
1173
|
-
return [unsupportedShellSegment(cmdForParsing)];
|
|
1174
|
-
}
|
|
1175
|
-
entries = mergeAmpRedirects(entries);
|
|
1176
|
-
const fdBudget: FdBudget = { remaining: countFdPrefixRedirects(cmdForParsing) };
|
|
1177
|
-
|
|
1178
|
-
const out: Segment[] = [];
|
|
1179
|
-
let buf: ParseEntry[] = [];
|
|
1180
|
-
for (const e of entries) {
|
|
1181
|
-
const op = getOp(e);
|
|
1182
|
-
if (op !== null && SEGMENT_OPS.has(op)) {
|
|
1183
|
-
const seg = parseSegment(buf, fdBudget);
|
|
1184
|
-
if (seg !== null) {
|
|
1185
|
-
out.push(seg);
|
|
1186
|
-
}
|
|
1187
|
-
buf = [];
|
|
1188
|
-
continue;
|
|
1189
|
-
}
|
|
1190
|
-
buf.push(e);
|
|
1191
|
-
}
|
|
1192
|
-
const seg = parseSegment(buf, fdBudget);
|
|
1193
|
-
if (seg !== null) {
|
|
1194
|
-
out.push(seg);
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
if (containsUnsupportedShellStructure(out)) {
|
|
1198
|
-
out.push(unsupportedShellSegment(cmdForParsing));
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
// Recursively analyze any embedded commands as additional segments. The
|
|
1202
|
-
// Outer segment's args are already opaque sentinels (safe-path-failing);
|
|
1203
|
-
// This catches dangerous inner commands the outer call would otherwise
|
|
1204
|
-
// Hide.
|
|
1205
|
-
for (const sub of [...extractInnerCommands(cmd), ...extractShellHeredocCommands(cmd)]) {
|
|
1206
|
-
for (const innerSeg of parseCommand(sub)) {
|
|
1207
|
-
out.push(innerSeg);
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
// Tools like `fd -x …` and `find -exec …` carry an inner subcommand
|
|
1212
|
-
// On the same arg vector. Without extraction the executed command is
|
|
1213
|
-
// Hidden and slips past every rule. Pull it out (with the user's
|
|
1214
|
-
// Search root substituted into placeholders) and parse it as its own
|
|
1215
|
-
// Segments so bash-deny et al. see it.
|
|
1216
|
-
// Snapshot length: we push new segments into `out` from within the
|
|
1217
|
-
// Loop, but should only scan the segments that existed pre-extraction
|
|
1218
|
-
// To avoid re-processing extracted ones.
|
|
1219
|
-
const preExtractLen = out.length;
|
|
1220
|
-
for (let k = 0; k < preExtractLen; k += 1) {
|
|
1221
|
-
const seg = out[k]!;
|
|
1222
|
-
for (const extract of SEGMENT_EXTRACTORS) {
|
|
1223
|
-
for (const sub of extract(seg)) {
|
|
1224
|
-
for (const innerSeg of parseCommand(sub)) {
|
|
1225
|
-
out.push(innerSeg);
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
return out;
|
|
1232
|
-
};
|
|
1233
|
-
|
|
1234
|
-
const stripLeadingDotSlash = (p: string): string => (p.startsWith('./') ? p.slice(2) : p);
|
|
1235
|
-
|
|
1236
|
-
const isSafePathTarget = (
|
|
1237
|
-
raw: string,
|
|
1238
|
-
extraRelative: readonly string[] = [],
|
|
1239
|
-
extraAbsolute: readonly string[] = [],
|
|
1240
|
-
): boolean => {
|
|
1241
|
-
if (raw === '') {
|
|
1242
|
-
return false;
|
|
1243
|
-
}
|
|
1244
|
-
const t = stripLeadingDotSlash(raw);
|
|
1245
|
-
if (t === '..' || t.startsWith('../') || t.includes('/../')) {
|
|
1246
|
-
return false;
|
|
1247
|
-
}
|
|
1248
|
-
for (const abs of [...SAFE_ABSOLUTE, ...extraAbsolute]) {
|
|
1249
|
-
if (t === abs || t.startsWith(`${abs}/`)) {
|
|
1250
|
-
return true;
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
for (const rel of [...SAFE_RELATIVE, ...extraRelative]) {
|
|
1254
|
-
if (t === rel || t.startsWith(`${rel}/`)) {
|
|
1255
|
-
return true;
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
return false;
|
|
1259
|
-
};
|
|
1260
|
-
|
|
1261
|
-
const safeScopesSummary = (
|
|
1262
|
-
extraRelative: readonly string[] = [],
|
|
1263
|
-
extraAbsolute: readonly string[] = [],
|
|
1264
|
-
): string => {
|
|
1265
|
-
const groups: Record<string, readonly string[]> = {
|
|
1266
|
-
'build outputs': ['dist', 'build', '_build', 'out', 'target'],
|
|
1267
|
-
'js framework outputs': [
|
|
1268
|
-
'.next',
|
|
1269
|
-
'.nuxt',
|
|
1270
|
-
'.svelte-kit',
|
|
1271
|
-
'.output',
|
|
1272
|
-
'.astro',
|
|
1273
|
-
'.angular',
|
|
1274
|
-
'.vite',
|
|
1275
|
-
'.parcel-cache',
|
|
1276
|
-
'.turbo',
|
|
1277
|
-
'.vercel',
|
|
1278
|
-
'.netlify',
|
|
1279
|
-
'.fly',
|
|
1280
|
-
'.wrangler',
|
|
1281
|
-
'.serverless',
|
|
1282
|
-
],
|
|
1283
|
-
'tests / coverage': ['coverage', '.nyc_output'],
|
|
1284
|
-
caches: ['.cache', '.ruff_cache', '.mypy_cache', '.pytest_cache', '.ty_cache', '.tox'],
|
|
1285
|
-
'language / package': [
|
|
1286
|
-
'__pycache__',
|
|
1287
|
-
'.venv',
|
|
1288
|
-
'venv',
|
|
1289
|
-
'node_modules',
|
|
1290
|
-
'.gradle',
|
|
1291
|
-
'DerivedData',
|
|
1292
|
-
'.bundle',
|
|
1293
|
-
'.cargo-target',
|
|
1294
|
-
],
|
|
1295
|
-
'tmp / state': ['tmp', '.tmp', '.state', ...SAFE_ABSOLUTE],
|
|
1296
|
-
iac: ['.terraform'],
|
|
1297
|
-
'bundler dev': ['.yarn/cache', '.yarn/install-state.gz', '.pnpm-store', '.bun'],
|
|
1298
|
-
};
|
|
1299
|
-
if (extraRelative.length > 0) {
|
|
1300
|
-
groups['custom relative'] = extraRelative;
|
|
1301
|
-
}
|
|
1302
|
-
if (extraAbsolute.length > 0) {
|
|
1303
|
-
groups['custom absolute'] = extraAbsolute;
|
|
1304
|
-
}
|
|
1305
|
-
return Object.entries(groups)
|
|
1306
|
-
.map(([k, v]) => ` ${k}: ${v.join(', ')}`)
|
|
1307
|
-
.join('\n');
|
|
1308
|
-
};
|
|
1309
|
-
|
|
1310
|
-
// Mask heredoc bodies before scanning, otherwise a `# tripwire-allow`
|
|
1311
|
-
// Smuggled inside a heredoc body (e.g. a commit message piped via
|
|
1312
|
-
// `$(cat <<EOF ... EOF)`) disarms every rule for the surrounding command.
|
|
1313
|
-
// A legitimate bypass marker sits on the actual command line, which the
|
|
1314
|
-
// Mask leaves intact.
|
|
1315
|
-
const hasBypass = (cmd: string): boolean =>
|
|
1316
|
-
/(?<prefix>^|\s)#\s*tripwire-allow:[ \t]*\S[^\r\n]*/.test(maskLiteralHeredocBodies(cmd));
|
|
1317
|
-
|
|
1318
|
-
export type { Redirect, Segment };
|
|
1319
|
-
export {
|
|
1320
|
-
EXEC_SPECS,
|
|
1321
|
-
UNSUPPORTED_SHELL_HEAD,
|
|
1322
|
-
collectHeredocBodies,
|
|
1323
|
-
hasBypass,
|
|
1324
|
-
isSafePathTarget,
|
|
1325
|
-
parseCommand,
|
|
1326
|
-
safeScopesSummary,
|
|
1327
|
-
unwrapStaticString,
|
|
1328
|
-
};
|