@seanmozeik/tripwire 0.4.0 → 0.5.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/dist/tripwire-cli.js +1 -1
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +57 -53
- package/dist/tripwire.js.jsc +0 -0
- package/package.json +6 -6
- package/src/lib/bash.ts +572 -29
- package/src/rules/bash-deny.ts +47 -6
- package/src/rules/bash-git.ts +6 -4
package/dist/tripwire.js.jsc
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seanmozeik/tripwire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -31,15 +31,15 @@
|
|
|
31
31
|
"typecheck": "tsc --noEmit"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@effect/platform-bun": "^4.0.0-beta.
|
|
35
|
-
"effect": "^4.0.0-beta.
|
|
34
|
+
"@effect/platform-bun": "^4.0.0-beta.66",
|
|
35
|
+
"effect": "^4.0.0-beta.66",
|
|
36
36
|
"shell-quote": "^1.8.3"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@types/bun": "^1.3.
|
|
39
|
+
"@types/bun": "^1.3.14",
|
|
40
40
|
"@types/shell-quote": "^1.7.5",
|
|
41
|
-
"oxfmt": "^0.
|
|
42
|
-
"oxlint": "^1.
|
|
41
|
+
"oxfmt": "^0.50.0",
|
|
42
|
+
"oxlint": "^1.65.0",
|
|
43
43
|
"oxlint-tsgolint": "^0.22.1",
|
|
44
44
|
"typescript": "^6.0.3"
|
|
45
45
|
},
|
package/src/lib/bash.ts
CHANGED
|
@@ -256,6 +256,60 @@ const countFdPrefixRedirects = (cmd: string): number => {
|
|
|
256
256
|
return matches?.length ?? 0;
|
|
257
257
|
};
|
|
258
258
|
|
|
259
|
+
const heredocDelimiterFromLine = (line: string): string | null => {
|
|
260
|
+
const match = /<<-?\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z_][A-Za-z0-9_]*))/u.exec(line);
|
|
261
|
+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const SHELL_STDIN_HEAD_RE =
|
|
265
|
+
/(?:^|[|;&]\s*)(?:\/(?:usr\/bin|bin|usr\/local\/bin|opt\/homebrew\/bin)\/)?(?:sh|bash|zsh|dash|ksh|ash)(?:\s|$)/u;
|
|
266
|
+
|
|
267
|
+
const heredocFeedsShell = (line: string): boolean => SHELL_STDIN_HEAD_RE.test(line);
|
|
268
|
+
|
|
269
|
+
const maskLiteralHeredocBodies = (cmd: string): string => {
|
|
270
|
+
const lines = cmd.split('\n');
|
|
271
|
+
const out: string[] = [];
|
|
272
|
+
for (let i = 0; i < lines.length; i++) {
|
|
273
|
+
const line = lines[i]!;
|
|
274
|
+
out.push(line);
|
|
275
|
+
const delimiter = heredocDelimiterFromLine(line);
|
|
276
|
+
if (delimiter === null || heredocFeedsShell(line)) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
i++;
|
|
280
|
+
while (i < lines.length && lines[i]!.trim() !== delimiter) {
|
|
281
|
+
i++;
|
|
282
|
+
}
|
|
283
|
+
if (i < lines.length) {
|
|
284
|
+
out.push('__HEREDOC_BODY__');
|
|
285
|
+
out.push(lines[i]!);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return out.join('\n');
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const extractShellHeredocCommands = (cmd: string): string[] => {
|
|
292
|
+
const lines = cmd.split('\n');
|
|
293
|
+
const out: string[] = [];
|
|
294
|
+
for (let i = 0; i < lines.length; i++) {
|
|
295
|
+
const line = lines[i]!;
|
|
296
|
+
const delimiter = heredocDelimiterFromLine(line);
|
|
297
|
+
if (delimiter === null || !heredocFeedsShell(line)) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const body: string[] = [];
|
|
301
|
+
i++;
|
|
302
|
+
while (i < lines.length && lines[i]!.trim() !== delimiter) {
|
|
303
|
+
body.push(lines[i]!);
|
|
304
|
+
i++;
|
|
305
|
+
}
|
|
306
|
+
if (body.length > 0) {
|
|
307
|
+
out.push(body.join('\n'));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
};
|
|
312
|
+
|
|
259
313
|
// Extract inner commands from `$(...)`, `<(...)`, `>(...)`, and `` `...` ``.
|
|
260
314
|
// Shell-quote collapses these into opaque sentinel tokens (which is correct
|
|
261
315
|
// For safe-path checks — substituted output is unknown), but it also hides
|
|
@@ -266,53 +320,502 @@ const countFdPrefixRedirects = (cmd: string): number => {
|
|
|
266
320
|
// Backticks don't nest (bash needs `\` escaping for that, which we treat as
|
|
267
321
|
// A literal). Process/command substitutions can nest arbitrarily — a depth
|
|
268
322
|
// Counter handles the balanced parens.
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
323
|
+
const findBacktickEnd = (cmd: string, start: number): number | null => {
|
|
324
|
+
for (let i = start; i < cmd.length; i++) {
|
|
325
|
+
const ch = cmd[i]!;
|
|
326
|
+
if (ch === '\\') {
|
|
327
|
+
i++;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (ch === '`') {
|
|
331
|
+
return i;
|
|
276
332
|
}
|
|
277
333
|
}
|
|
278
|
-
|
|
279
|
-
|
|
334
|
+
return null;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const findSubstitutionEnd = (cmd: string, start: number): number | null => {
|
|
338
|
+
let depth = 1;
|
|
339
|
+
let quote: 'single' | 'double' | null = null;
|
|
340
|
+
for (let j = start; j < cmd.length; j++) {
|
|
341
|
+
const cj = cmd[j]!;
|
|
342
|
+
if (cj === '\\') {
|
|
343
|
+
j++;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (quote === 'single') {
|
|
347
|
+
if (cj === "'") {
|
|
348
|
+
quote = null;
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (cj === "'") {
|
|
353
|
+
quote ??= 'single';
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (cj === '"') {
|
|
357
|
+
quote = quote === 'double' ? null : 'double';
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (cj === '(') {
|
|
361
|
+
depth++;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (cj === ')') {
|
|
365
|
+
depth--;
|
|
366
|
+
if (depth === 0) {
|
|
367
|
+
return j;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const extractInnerCommands = (cmd: string): string[] => {
|
|
375
|
+
const inner: string[] = [];
|
|
376
|
+
let quote: 'single' | 'double' | null = null;
|
|
377
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
280
378
|
const ch = cmd[i]!;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (!isSubStart) {
|
|
379
|
+
if (ch === '\\') {
|
|
380
|
+
i++;
|
|
284
381
|
continue;
|
|
285
382
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const cj = cmd[j]!;
|
|
290
|
-
if (cj === '(') {
|
|
291
|
-
depth++;
|
|
292
|
-
} else if (cj === ')') {
|
|
293
|
-
depth--;
|
|
383
|
+
if (quote === 'single') {
|
|
384
|
+
if (ch === "'") {
|
|
385
|
+
quote = null;
|
|
294
386
|
}
|
|
295
|
-
|
|
296
|
-
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (ch === "'") {
|
|
390
|
+
quote ??= 'single';
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (ch === '"') {
|
|
394
|
+
quote = quote === 'double' ? null : 'double';
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (ch === '`') {
|
|
398
|
+
const end = findBacktickEnd(cmd, i + 1);
|
|
399
|
+
if (end !== null) {
|
|
400
|
+
inner.push(cmd.slice(i + 1, end));
|
|
401
|
+
i = end;
|
|
297
402
|
}
|
|
403
|
+
continue;
|
|
298
404
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
405
|
+
const next = cmd[i + 1];
|
|
406
|
+
const isCommandSubStart = ch === '$' && next === '(';
|
|
407
|
+
const isProcessSubStart = quote === null && (ch === '<' || ch === '>') && next === '(';
|
|
408
|
+
if (!isCommandSubStart && !isProcessSubStart) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const end = findSubstitutionEnd(cmd, i + 2);
|
|
412
|
+
if (end !== null) {
|
|
413
|
+
inner.push(cmd.slice(i + 2, end));
|
|
414
|
+
i = end;
|
|
302
415
|
}
|
|
303
416
|
}
|
|
304
417
|
return inner;
|
|
305
418
|
};
|
|
306
419
|
|
|
420
|
+
// ── Exec-flag extraction (fd -x, find -exec, etc.) ───────────────────
|
|
421
|
+
// Tools that take a subcommand on the same arg vector hide that
|
|
422
|
+
// Subcommand from rule analysis. Pull it out, substitute the user-
|
|
423
|
+
// Provided search root into the placeholder(s), and feed the
|
|
424
|
+
// Reconstructed command back through parseCommand so every existing
|
|
425
|
+
// Bash rule (deny / scoped-rm / redirect / etc.) sees it.
|
|
426
|
+
|
|
427
|
+
const HOME_VAR_RE = /^\$\{?HOME\}?(?:\/|$)/;
|
|
428
|
+
|
|
429
|
+
const pathLikeToken = (t: string): boolean => {
|
|
430
|
+
if (t === '' || t === '-') {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
if (t === '/' || t === '~' || t === '.' || t === '..') {
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
if (
|
|
437
|
+
t.startsWith('/') ||
|
|
438
|
+
t.startsWith('~') ||
|
|
439
|
+
t.startsWith('./') ||
|
|
440
|
+
t.startsWith('../') ||
|
|
441
|
+
HOME_VAR_RE.test(t)
|
|
442
|
+
) {
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
return false;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// Rank candidate search roots by how dangerous a `cmd <root>` invocation
|
|
449
|
+
// Would be. Higher wins.
|
|
450
|
+
const pathDangerScore = (t: string): number => {
|
|
451
|
+
if (t === '/') {
|
|
452
|
+
return 100;
|
|
453
|
+
}
|
|
454
|
+
if (t === '~' || HOME_VAR_RE.test(t)) {
|
|
455
|
+
return 90;
|
|
456
|
+
}
|
|
457
|
+
if (/^\/(etc|usr|bin|sbin|System|Library|var|boot|root|home)(\/|$)/.test(t)) {
|
|
458
|
+
return 80;
|
|
459
|
+
}
|
|
460
|
+
if (t.startsWith('/Users/')) {
|
|
461
|
+
return 70;
|
|
462
|
+
}
|
|
463
|
+
if (t.startsWith('/') || t.startsWith('~')) {
|
|
464
|
+
return 60;
|
|
465
|
+
}
|
|
466
|
+
if (t.startsWith('../')) {
|
|
467
|
+
return 40;
|
|
468
|
+
}
|
|
469
|
+
if (t === '..' || t === '.' || t.startsWith('./')) {
|
|
470
|
+
return 10;
|
|
471
|
+
}
|
|
472
|
+
return 50;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
interface ExecSpec {
|
|
476
|
+
// Flag tokens that introduce a nested command, e.g. `-x` / `-exec`.
|
|
477
|
+
readonly execFlags: ReadonlySet<string>;
|
|
478
|
+
// Placeholder tokens the tool substitutes with each match path.
|
|
479
|
+
readonly placeholders: ReadonlySet<string>;
|
|
480
|
+
// Walk tokens[1..execFlagIdx) and return the most-suspicious search root
|
|
481
|
+
// The tool would feed into placeholders, or `.` if nothing path-shaped
|
|
482
|
+
// Is present.
|
|
483
|
+
readonly pickRoot: (tokens: readonly string[], execFlagIdx: number) => string;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Fd's flag layout: flags can appear before or after the pattern/path
|
|
487
|
+
// Positionals, and some flags consume a value (-e ts, -t f, -d 3). We
|
|
488
|
+
// Need to skip those value tokens, otherwise `ts` is misread as a path.
|
|
489
|
+
const FD_VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
490
|
+
'-e',
|
|
491
|
+
'--extension',
|
|
492
|
+
'-t',
|
|
493
|
+
'--type',
|
|
494
|
+
'-E',
|
|
495
|
+
'--exclude',
|
|
496
|
+
'-d',
|
|
497
|
+
'--max-depth',
|
|
498
|
+
'--min-depth',
|
|
499
|
+
'--exact-depth',
|
|
500
|
+
'-c',
|
|
501
|
+
'--color',
|
|
502
|
+
'--changed-within',
|
|
503
|
+
'--changed-before',
|
|
504
|
+
'-S',
|
|
505
|
+
'--size',
|
|
506
|
+
'-o',
|
|
507
|
+
'--owner',
|
|
508
|
+
'-j',
|
|
509
|
+
'--threads',
|
|
510
|
+
'-g',
|
|
511
|
+
'--glob',
|
|
512
|
+
'--format',
|
|
513
|
+
'--max-results',
|
|
514
|
+
'--ignore-file',
|
|
515
|
+
'--search-path',
|
|
516
|
+
'--base-directory',
|
|
517
|
+
'--path-separator',
|
|
518
|
+
'--and',
|
|
519
|
+
]);
|
|
520
|
+
|
|
521
|
+
const pickFdSearchRoot = (tokens: readonly string[], execFlagIdx: number): string => {
|
|
522
|
+
const candidates: string[] = [];
|
|
523
|
+
let i = 1;
|
|
524
|
+
while (i < execFlagIdx) {
|
|
525
|
+
const t = tokens[i]!;
|
|
526
|
+
if (FD_VALUE_FLAGS.has(t)) {
|
|
527
|
+
i += 2;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (t.startsWith('-')) {
|
|
531
|
+
i++;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (pathLikeToken(t)) {
|
|
535
|
+
candidates.push(t);
|
|
536
|
+
}
|
|
537
|
+
i++;
|
|
538
|
+
}
|
|
539
|
+
if (candidates.length === 0) {
|
|
540
|
+
return '.';
|
|
541
|
+
}
|
|
542
|
+
candidates.sort((a, b) => pathDangerScore(b) - pathDangerScore(a));
|
|
543
|
+
return candidates[0]!;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
// Find's grammar: PATHs come first, before any flag-shaped token. Once we
|
|
547
|
+
// Hit a `-`-prefixed token (a test predicate or action), no more paths.
|
|
548
|
+
// `find` defaults to cwd if no path is given. We collect everything
|
|
549
|
+
// Path-shaped in the prefix region as candidates.
|
|
550
|
+
const pickFindSearchRoot = (tokens: readonly string[], execFlagIdx: number): string => {
|
|
551
|
+
const candidates: string[] = [];
|
|
552
|
+
for (let i = 1; i < execFlagIdx; i++) {
|
|
553
|
+
const t = tokens[i]!;
|
|
554
|
+
if (t.startsWith('-')) {
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
if (pathLikeToken(t)) {
|
|
558
|
+
candidates.push(t);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (candidates.length === 0) {
|
|
562
|
+
return '.';
|
|
563
|
+
}
|
|
564
|
+
candidates.sort((a, b) => pathDangerScore(b) - pathDangerScore(a));
|
|
565
|
+
return candidates[0]!;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const FD_SPEC: ExecSpec = {
|
|
569
|
+
execFlags: new Set(['-x', '-X', '--exec', '--exec-batch']),
|
|
570
|
+
placeholders: new Set(['{}', '{/}', '{//}', '{.}', '{/.}']),
|
|
571
|
+
pickRoot: pickFdSearchRoot,
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const FIND_SPEC: ExecSpec = {
|
|
575
|
+
// `-ok` / `-okdir` prompt interactively per-match, but the executed
|
|
576
|
+
// Command is still constructed from agent-controlled input, so treat
|
|
577
|
+
// It the same as `-exec`.
|
|
578
|
+
execFlags: new Set(['-exec', '-execdir', '-ok', '-okdir']),
|
|
579
|
+
placeholders: new Set(['{}']),
|
|
580
|
+
pickRoot: pickFindSearchRoot,
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const EXEC_SPECS: Readonly<Record<string, ExecSpec>> = Object.assign(
|
|
584
|
+
Object.create(null) as Record<string, ExecSpec>,
|
|
585
|
+
{ fd: FD_SPEC, fdfind: FD_SPEC, find: FIND_SPEC, gfind: FIND_SPEC },
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
const substitutePlaceholders = (
|
|
589
|
+
tokens: readonly string[],
|
|
590
|
+
spec: ExecSpec,
|
|
591
|
+
root: string,
|
|
592
|
+
): string[] => tokens.map((t) => (spec.placeholders.has(t) ? root : t));
|
|
593
|
+
|
|
594
|
+
const extractExecCommands = (seg: Segment): string[] => {
|
|
595
|
+
const spec = EXEC_SPECS[seg.head];
|
|
596
|
+
if (spec === undefined) {
|
|
597
|
+
return [];
|
|
598
|
+
}
|
|
599
|
+
const out: string[] = [];
|
|
600
|
+
const tokens = seg.tokens;
|
|
601
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
602
|
+
if (!spec.execFlags.has(tokens[i]!)) {
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
// Collect tokens until the exec terminator (`;` or `+`, both shared
|
|
606
|
+
// By fd and find) or end of segment. shell-quote turns `\;` into the
|
|
607
|
+
// Literal string token `;`.
|
|
608
|
+
const inner: string[] = [];
|
|
609
|
+
let j = i + 1;
|
|
610
|
+
while (j < tokens.length) {
|
|
611
|
+
const t = tokens[j]!;
|
|
612
|
+
if (t === ';' || t === '+') {
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
inner.push(t);
|
|
616
|
+
j++;
|
|
617
|
+
}
|
|
618
|
+
if (inner.length === 0) {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const head = inner[0]!;
|
|
622
|
+
if (spec.placeholders.has(head)) {
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const root = spec.pickRoot(tokens, i);
|
|
626
|
+
out.push(substitutePlaceholders(inner, spec, root).join(' '));
|
|
627
|
+
i = j;
|
|
628
|
+
}
|
|
629
|
+
return out;
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
// ── Substitution unwrappers ──────────────────────────────────────────
|
|
633
|
+
//
|
|
634
|
+
// Bash hides agent-controlled content inside command substitutions and
|
|
635
|
+
// Shell wrappers two different ways, and rules need two different shapes
|
|
636
|
+
// Of unwrap:
|
|
637
|
+
//
|
|
638
|
+
// 1. `sh -c '<script>'` and `bash -c '<script>'` carry a script the
|
|
639
|
+
// Outer parser can't see into. Extracted with
|
|
640
|
+
// `extractShellWrappedCommands(seg)` and re-parsed as bash segments
|
|
641
|
+
// So every existing rule applies. Used by `parseCommand`.
|
|
642
|
+
//
|
|
643
|
+
// 2. `$(cat <<'TAG' ... TAG)` and `$(echo '...')` / `$(printf '...')`
|
|
644
|
+
// Compute a static string value at runtime. Rules that inspect arg
|
|
645
|
+
// Values (commit-message convention, redirect targets, etc.) need
|
|
646
|
+
// The string, not a re-parse. `unwrapStaticString(token)` returns
|
|
647
|
+
// It; pass-through if the token isn't a recognised substitution.
|
|
648
|
+
//
|
|
649
|
+
// Both layers cover the same underlying gap — the shell-quote parser
|
|
650
|
+
// Treats substitutions as opaque sentinels — and any rule that touches
|
|
651
|
+
// Agent-controlled content should route through one of them.
|
|
652
|
+
|
|
653
|
+
const HEREDOC_SUBST_RE = /\$\(\s*cat\s+<<-?\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\s*\1\s*\)/u;
|
|
654
|
+
const ECHO_PRINTF_SUBST_RE = /\$\(\s*(?:printf|echo)\s+(?:-[a-zA-Z]+\s+)*'([^']*)'/u;
|
|
655
|
+
|
|
656
|
+
const unwrapStaticString = (value: string): string => {
|
|
657
|
+
const heredoc = HEREDOC_SUBST_RE.exec(value);
|
|
658
|
+
if (heredoc !== null) {
|
|
659
|
+
return heredoc[2] ?? value;
|
|
660
|
+
}
|
|
661
|
+
const printf = ECHO_PRINTF_SUBST_RE.exec(value);
|
|
662
|
+
if (printf !== null) {
|
|
663
|
+
return printf[1] ?? value;
|
|
664
|
+
}
|
|
665
|
+
return value;
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// Recover commands hidden inside a `sh -c '...'` / `bash -c '...'` wrapper.
|
|
669
|
+
// Without this, every redirect / deny / scoped-rm rule can be trivially
|
|
670
|
+
// Bypassed by wrapping the offending command in `sh -c`. The shell parser
|
|
671
|
+
// Otherwise sees `sh` as the head and the script as an opaque positional
|
|
672
|
+
// Arg. We pull the script out and feed it back through `parseCommand` so
|
|
673
|
+
// All existing rules apply.
|
|
674
|
+
const SHELL_WRAPPER_HEADS: ReadonlySet<string> = new Set([
|
|
675
|
+
'sh',
|
|
676
|
+
'bash',
|
|
677
|
+
'zsh',
|
|
678
|
+
'dash',
|
|
679
|
+
'ksh',
|
|
680
|
+
'ash',
|
|
681
|
+
'/bin/sh',
|
|
682
|
+
'/bin/bash',
|
|
683
|
+
'/bin/zsh',
|
|
684
|
+
'/bin/dash',
|
|
685
|
+
'/bin/ksh',
|
|
686
|
+
'/usr/bin/sh',
|
|
687
|
+
'/usr/bin/bash',
|
|
688
|
+
'/usr/bin/zsh',
|
|
689
|
+
'/usr/local/bin/bash',
|
|
690
|
+
'/opt/homebrew/bin/bash',
|
|
691
|
+
]);
|
|
692
|
+
|
|
693
|
+
const extractShellWrappedCommands = (seg: Segment): string[] => {
|
|
694
|
+
if (!SHELL_WRAPPER_HEADS.has(seg.head)) {
|
|
695
|
+
return [];
|
|
696
|
+
}
|
|
697
|
+
const tokens = seg.tokens;
|
|
698
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
699
|
+
const t = tokens[i]!;
|
|
700
|
+
if (t === '-c' && i + 1 < tokens.length) {
|
|
701
|
+
return [tokens[i + 1]!];
|
|
702
|
+
}
|
|
703
|
+
// Combined short flags that include `c`: `-ec`, `-xc`, `-eu c` won't —
|
|
704
|
+
// Only treat `c` as the last char so the next token is the script.
|
|
705
|
+
if (
|
|
706
|
+
t.startsWith('-') &&
|
|
707
|
+
!t.startsWith('--') &&
|
|
708
|
+
t.endsWith('c') &&
|
|
709
|
+
t.length > 2 &&
|
|
710
|
+
i + 1 < tokens.length
|
|
711
|
+
) {
|
|
712
|
+
return [tokens[i + 1]!];
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return [];
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
|
|
719
|
+
'command',
|
|
720
|
+
'exec',
|
|
721
|
+
'env',
|
|
722
|
+
'time',
|
|
723
|
+
'nohup',
|
|
724
|
+
'setsid',
|
|
725
|
+
'nice',
|
|
726
|
+
'ionice',
|
|
727
|
+
'chronic',
|
|
728
|
+
'stdbuf',
|
|
729
|
+
'unbuffer',
|
|
730
|
+
'script',
|
|
731
|
+
'taskset',
|
|
732
|
+
]);
|
|
733
|
+
|
|
734
|
+
const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
735
|
+
command: new Set(),
|
|
736
|
+
env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string', '--block-signal']),
|
|
737
|
+
time: new Set(['-f', '--format', '-o', '--output']),
|
|
738
|
+
nice: new Set(['-n', '--adjustment']),
|
|
739
|
+
ionice: new Set(['-c', '--class', '-n', '--classdata', '-p', '--pid']),
|
|
740
|
+
stdbuf: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
|
|
741
|
+
script: new Set(['-c', '--command']),
|
|
742
|
+
taskset: new Set(),
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
const tokenLooksLikeEnvAssignment = (token: string): boolean =>
|
|
746
|
+
/^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(token);
|
|
747
|
+
|
|
748
|
+
const skipHeadRenamingPrefix = (tokens: readonly string[]): number => {
|
|
749
|
+
const head = tokens[0]!;
|
|
750
|
+
const valueFlags = HEAD_RENAMING_VALUE_FLAGS[head] ?? new Set<string>();
|
|
751
|
+
let i = 1;
|
|
752
|
+
while (i < tokens.length) {
|
|
753
|
+
const token = tokens[i]!;
|
|
754
|
+
if (head === 'env' && tokenLooksLikeEnvAssignment(token)) {
|
|
755
|
+
i++;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (valueFlags.has(token)) {
|
|
759
|
+
i += 2;
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (token.includes('=') && valueFlags.has(token.slice(0, token.indexOf('=')))) {
|
|
763
|
+
i++;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (token.startsWith('--') && token !== '--') {
|
|
767
|
+
i++;
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (token.startsWith('-') && token !== '-') {
|
|
771
|
+
i++;
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
return i;
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const extractHeadRenamingCommands = (seg: Segment): string[] => {
|
|
780
|
+
if (!HEAD_RENAMING_HEADS.has(seg.head)) {
|
|
781
|
+
return [];
|
|
782
|
+
}
|
|
783
|
+
if (seg.head === 'script') {
|
|
784
|
+
for (let i = 1; i < seg.tokens.length - 1; i++) {
|
|
785
|
+
const token = seg.tokens[i]!;
|
|
786
|
+
if (token === '-c' || token === '--command') {
|
|
787
|
+
return [seg.tokens[i + 1]!];
|
|
788
|
+
}
|
|
789
|
+
if (token.startsWith('--command=')) {
|
|
790
|
+
return [token.slice('--command='.length)];
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
const start = skipHeadRenamingPrefix(seg.tokens);
|
|
795
|
+
const inner = seg.tokens.slice(start).join(' ');
|
|
796
|
+
return inner === '' ? [] : [inner];
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const EVAL_HEADS: ReadonlySet<string> = new Set(['eval']);
|
|
800
|
+
|
|
801
|
+
const extractEvalCommands = (seg: Segment): string[] => {
|
|
802
|
+
if (!EVAL_HEADS.has(seg.head)) {
|
|
803
|
+
return [];
|
|
804
|
+
}
|
|
805
|
+
const sub = seg.tokens.slice(1).join(' ');
|
|
806
|
+
return sub === '' ? [] : [sub];
|
|
807
|
+
};
|
|
808
|
+
|
|
307
809
|
const parseCommand = (cmd: string): Segment[] => {
|
|
308
810
|
let entries: ParseEntry[];
|
|
811
|
+
const cmdForParsing = maskLiteralHeredocBodies(cmd);
|
|
309
812
|
try {
|
|
310
|
-
entries = parse(
|
|
813
|
+
entries = parse(cmdForParsing, PRESERVE_ENV);
|
|
311
814
|
} catch {
|
|
312
815
|
return [];
|
|
313
816
|
}
|
|
314
817
|
entries = mergeAmpRedirects(entries);
|
|
315
|
-
const fdBudget: FdBudget = { remaining: countFdPrefixRedirects(
|
|
818
|
+
const fdBudget: FdBudget = { remaining: countFdPrefixRedirects(cmdForParsing) };
|
|
316
819
|
|
|
317
820
|
const out: Segment[] = [];
|
|
318
821
|
let buf: ParseEntry[] = [];
|
|
@@ -337,12 +840,45 @@ const parseCommand = (cmd: string): Segment[] => {
|
|
|
337
840
|
// Outer segment's args are already opaque sentinels (safe-path-failing);
|
|
338
841
|
// This catches dangerous inner commands the outer call would otherwise
|
|
339
842
|
// Hide.
|
|
340
|
-
for (const sub of extractInnerCommands(cmd)) {
|
|
843
|
+
for (const sub of [...extractInnerCommands(cmd), ...extractShellHeredocCommands(cmd)]) {
|
|
341
844
|
for (const innerSeg of parseCommand(sub)) {
|
|
342
845
|
out.push(innerSeg);
|
|
343
846
|
}
|
|
344
847
|
}
|
|
345
848
|
|
|
849
|
+
// Tools like `fd -x …` and `find -exec …` carry an inner subcommand
|
|
850
|
+
// On the same arg vector. Without extraction the executed command is
|
|
851
|
+
// Hidden and slips past every rule. Pull it out (with the user's
|
|
852
|
+
// Search root substituted into placeholders) and parse it as its own
|
|
853
|
+
// Segments so bash-deny et al. see it.
|
|
854
|
+
// Snapshot length: we push new segments into `out` from within the
|
|
855
|
+
// Loop, but should only scan the segments that existed pre-extraction
|
|
856
|
+
// To avoid re-processing extracted ones.
|
|
857
|
+
const preExtractLen = out.length;
|
|
858
|
+
for (let k = 0; k < preExtractLen; k++) {
|
|
859
|
+
const seg = out[k]!;
|
|
860
|
+
for (const sub of extractExecCommands(seg)) {
|
|
861
|
+
for (const innerSeg of parseCommand(sub)) {
|
|
862
|
+
out.push(innerSeg);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
for (const sub of extractShellWrappedCommands(seg)) {
|
|
866
|
+
for (const innerSeg of parseCommand(sub)) {
|
|
867
|
+
out.push(innerSeg);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
for (const sub of extractHeadRenamingCommands(seg)) {
|
|
871
|
+
for (const innerSeg of parseCommand(sub)) {
|
|
872
|
+
out.push(innerSeg);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
for (const sub of extractEvalCommands(seg)) {
|
|
876
|
+
for (const innerSeg of parseCommand(sub)) {
|
|
877
|
+
out.push(innerSeg);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
346
882
|
return out;
|
|
347
883
|
};
|
|
348
884
|
|
|
@@ -425,4 +961,11 @@ const safeScopesSummary = (
|
|
|
425
961
|
const hasBypass = (cmd: string): boolean => /(^|\s)#\s*tripwire-allow\b/.test(cmd);
|
|
426
962
|
|
|
427
963
|
export type { Redirect, Segment };
|
|
428
|
-
export {
|
|
964
|
+
export {
|
|
965
|
+
EXEC_SPECS,
|
|
966
|
+
hasBypass,
|
|
967
|
+
isSafePathTarget,
|
|
968
|
+
parseCommand,
|
|
969
|
+
safeScopesSummary,
|
|
970
|
+
unwrapStaticString,
|
|
971
|
+
};
|