@seanmozeik/tripwire 0.5.3 → 0.6.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/dist/tripwire-cli.js +2 -2
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +70 -70
- package/dist/tripwire.js.jsc +0 -0
- package/package.json +7 -7
- package/src/cli.ts +2 -1
- package/src/lib/bash.ts +272 -38
- package/src/lib/log.ts +1 -0
- package/src/lib/secrets.ts +1 -0
- package/src/rules/bash-deny.ts +16 -9
- package/src/rules/bash-git.ts +4 -4
- package/src/rules/bash-network-install.ts +1 -1
- package/src/rules/bash-redirect.ts +7 -7
- package/src/rules/bash-scoped-rm.ts +1 -1
- package/src/rules/bash-tar-explosion.ts +1 -1
- package/src/rules/bash-tool-policy.ts +8 -8
- package/src/rules/lazy-code.ts +4 -4
- package/src/rules/path-protect.ts +18 -9
- package/src/rules/read-protect.ts +13 -8
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.6.1",
|
|
4
4
|
"description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -31,16 +31,16 @@
|
|
|
31
31
|
"typecheck": "tsc --noEmit"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@effect/platform-bun": "^4.0.0-beta.
|
|
35
|
-
"effect": "^4.0.0-beta.
|
|
36
|
-
"shell-quote": "^1.8.
|
|
34
|
+
"@effect/platform-bun": "^4.0.0-beta.78",
|
|
35
|
+
"effect": "^4.0.0-beta.78",
|
|
36
|
+
"shell-quote": "^1.8.4"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/bun": "^1.3.14",
|
|
40
40
|
"@types/shell-quote": "^1.7.5",
|
|
41
|
-
"oxfmt": "^0.
|
|
42
|
-
"oxlint": "^1.
|
|
43
|
-
"oxlint-tsgolint": "^0.
|
|
41
|
+
"oxfmt": "^0.53.0",
|
|
42
|
+
"oxlint": "^1.68.0",
|
|
43
|
+
"oxlint-tsgolint": "^0.23.0",
|
|
44
44
|
"typescript": "^6.0.3"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
package/src/cli.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// Bun src/cli.ts install pi
|
|
15
15
|
// Bun src/cli.ts install all
|
|
16
16
|
|
|
17
|
+
// oxlint-disable-next-line unicorn/import-style
|
|
17
18
|
import { dirname } from 'node:path';
|
|
18
19
|
|
|
19
20
|
import { BunServices } from '@effect/platform-bun';
|
|
@@ -27,7 +28,7 @@ import { installAll, installClaude, installCodex, installPi } from './lib/instal
|
|
|
27
28
|
// Resolve tripwire-hook path at runtime using process.argv
|
|
28
29
|
// This works in both script mode (bun run) and compiled/bundled mode
|
|
29
30
|
const runtimeSelf = (): string => {
|
|
30
|
-
const isBunCli = /\/bun(
|
|
31
|
+
const isBunCli = /\/bun(?<ext>\.exe)?$/.test(process.argv[0] ?? '');
|
|
31
32
|
return isBunCli ? process.argv[1]! : process.argv[0]!;
|
|
32
33
|
};
|
|
33
34
|
|
package/src/lib/bash.ts
CHANGED
|
@@ -209,7 +209,17 @@ const parseSegment = (entries: readonly ParseEntry[], fdBudget: FdBudget): Segme
|
|
|
209
209
|
args.push(t);
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
|
-
|
|
212
|
+
// Normalise the head to its basename so command-name rules match regardless
|
|
213
|
+
// Of whether the command was invoked via absolute path (/bin/rm), a
|
|
214
|
+
// Homebrew-prefixed path (/opt/homebrew/bin/gog), or a relative ./rm form.
|
|
215
|
+
// This fixes the containment hole where `/bin/rm <unsafe>` bypassed every
|
|
216
|
+
// Rule that compared `seg.head === 'rm'`. The `tokens` array is left
|
|
217
|
+
// Unchanged (raw reconstruction stays accurate); only the canonical `head`
|
|
218
|
+
// Used for matching is normalised.
|
|
219
|
+
const rawHead = tokens[0]!;
|
|
220
|
+
const slashIdx = rawHead.lastIndexOf('/');
|
|
221
|
+
const head = slashIdx === -1 ? rawHead : rawHead.slice(slashIdx + 1);
|
|
222
|
+
return { head, tokens, args, flags, redirects, raw: tokens.join(' ') };
|
|
213
223
|
};
|
|
214
224
|
|
|
215
225
|
// Pass an env function that preserves variable references as literals,
|
|
@@ -257,8 +267,13 @@ const countFdPrefixRedirects = (cmd: string): number => {
|
|
|
257
267
|
};
|
|
258
268
|
|
|
259
269
|
const heredocDelimiterFromLine = (line: string): string | null => {
|
|
260
|
-
const match =
|
|
261
|
-
|
|
270
|
+
const match =
|
|
271
|
+
/<<-?\s*(?:"(?<quoted>[^"]+)"|'(?<single>[^']+)'|(?<unquoted>[A-Za-z_][A-Za-z0-9_]*))/u.exec(
|
|
272
|
+
line,
|
|
273
|
+
);
|
|
274
|
+
return (
|
|
275
|
+
match?.groups?.['quoted'] ?? match?.groups?.['single'] ?? match?.groups?.['unquoted'] ?? null
|
|
276
|
+
);
|
|
262
277
|
};
|
|
263
278
|
|
|
264
279
|
const SHELL_STDIN_HEAD_RE =
|
|
@@ -483,7 +498,7 @@ const pathDangerScore = (t: string): number => {
|
|
|
483
498
|
if (t === '~' || HOME_VAR_RE.test(t)) {
|
|
484
499
|
return 90;
|
|
485
500
|
}
|
|
486
|
-
if (/^\/(etc|usr|bin|sbin|System|Library|var|boot|root|home)(
|
|
501
|
+
if (/^\/(?<dir>etc|usr|bin|sbin|System|Library|var|boot|root|home)(?<suffix>\/|$)/.test(t)) {
|
|
487
502
|
return 80;
|
|
488
503
|
}
|
|
489
504
|
if (t.startsWith('/Users/')) {
|
|
@@ -679,15 +694,16 @@ const extractExecCommands = (seg: Segment): string[] => {
|
|
|
679
694
|
// Treats substitutions as opaque sentinels — and any rule that touches
|
|
680
695
|
// Agent-controlled content should route through one of them.
|
|
681
696
|
|
|
682
|
-
const HEREDOC_SUBST_RE =
|
|
683
|
-
|
|
697
|
+
const HEREDOC_SUBST_RE =
|
|
698
|
+
/\$\(\s*cat\s+<<-?\s*['"]?(?<delimiter>\w+)['"]?\s*\n(?<body>[\s\S]*?)\n\s*\k<delimiter>\s*\)/u;
|
|
699
|
+
const ECHO_PRINTF_SUBST_RE = /\$\(\s*(?:printf|echo)\s+(?:-[a-zA-Z]+\s+)*'(?<content>[^']*)'/u;
|
|
684
700
|
|
|
685
701
|
const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, string>): string => {
|
|
686
702
|
const heredoc = HEREDOC_SUBST_RE.exec(value);
|
|
687
703
|
if (heredoc !== null) {
|
|
688
|
-
const captured = heredoc[
|
|
704
|
+
const captured = heredoc.groups?.['body'] ?? value;
|
|
689
705
|
if (heredocBodies !== undefined && captured.trim() === '__HEREDOC_BODY__') {
|
|
690
|
-
const delimiter = heredoc[
|
|
706
|
+
const delimiter = heredoc.groups?.['delimiter'];
|
|
691
707
|
const real = delimiter === undefined ? undefined : heredocBodies.get(delimiter);
|
|
692
708
|
if (real !== undefined) {
|
|
693
709
|
return real;
|
|
@@ -697,7 +713,7 @@ const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, s
|
|
|
697
713
|
}
|
|
698
714
|
const printf = ECHO_PRINTF_SUBST_RE.exec(value);
|
|
699
715
|
if (printf !== null) {
|
|
700
|
-
return printf[
|
|
716
|
+
return printf.groups?.['content'] ?? value;
|
|
701
717
|
}
|
|
702
718
|
return value;
|
|
703
719
|
};
|
|
@@ -708,6 +724,8 @@ const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, s
|
|
|
708
724
|
// Otherwise sees `sh` as the head and the script as an opaque positional
|
|
709
725
|
// Arg. We pull the script out and feed it back through `parseCommand` so
|
|
710
726
|
// All existing rules apply.
|
|
727
|
+
// Head normalisation in `parseSegment` strips directory prefixes, so this set
|
|
728
|
+
// Only needs bare basenames — `/bin/bash` etc. are now unreachable as heads.
|
|
711
729
|
const SHELL_WRAPPER_HEADS: ReadonlySet<string> = new Set([
|
|
712
730
|
'sh',
|
|
713
731
|
'bash',
|
|
@@ -715,16 +733,6 @@ const SHELL_WRAPPER_HEADS: ReadonlySet<string> = new Set([
|
|
|
715
733
|
'dash',
|
|
716
734
|
'ksh',
|
|
717
735
|
'ash',
|
|
718
|
-
'/bin/sh',
|
|
719
|
-
'/bin/bash',
|
|
720
|
-
'/bin/zsh',
|
|
721
|
-
'/bin/dash',
|
|
722
|
-
'/bin/ksh',
|
|
723
|
-
'/usr/bin/sh',
|
|
724
|
-
'/usr/bin/bash',
|
|
725
|
-
'/usr/bin/zsh',
|
|
726
|
-
'/usr/local/bin/bash',
|
|
727
|
-
'/opt/homebrew/bin/bash',
|
|
728
736
|
]);
|
|
729
737
|
|
|
730
738
|
const extractShellWrappedCommands = (seg: Segment): string[] => {
|
|
@@ -752,6 +760,13 @@ const extractShellWrappedCommands = (seg: Segment): string[] => {
|
|
|
752
760
|
return [];
|
|
753
761
|
};
|
|
754
762
|
|
|
763
|
+
// Heads that take `[flags] <command> [args]` on the same arg vector: the
|
|
764
|
+
// First non-flag token after the prefix is the real command. `sudo`/`doas`
|
|
765
|
+
// (privilege escalation), `xargs` (stdin-driven exec), and `watch` (repeated
|
|
766
|
+
// Exec) all hide a sibling command this way, so the same unwrap that handles
|
|
767
|
+
// `command`/`env`/`nohup` applies. `sudo` is also matched by bash-deny's
|
|
768
|
+
// `ask` rule on the outer segment — both fire, and the more-restrictive
|
|
769
|
+
// Interior decision (e.g. `sudo rm -rf /` → deny) wins on merge.
|
|
755
770
|
const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
|
|
756
771
|
'command',
|
|
757
772
|
'exec',
|
|
@@ -766,6 +781,10 @@ const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
|
|
|
766
781
|
'unbuffer',
|
|
767
782
|
'script',
|
|
768
783
|
'taskset',
|
|
784
|
+
'sudo',
|
|
785
|
+
'doas',
|
|
786
|
+
'xargs',
|
|
787
|
+
'watch',
|
|
769
788
|
]);
|
|
770
789
|
|
|
771
790
|
const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
@@ -777,6 +796,52 @@ const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> =
|
|
|
777
796
|
stdbuf: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
|
|
778
797
|
script: new Set(['-c', '--command']),
|
|
779
798
|
taskset: new Set(),
|
|
799
|
+
sudo: new Set([
|
|
800
|
+
'-u',
|
|
801
|
+
'--user',
|
|
802
|
+
'-g',
|
|
803
|
+
'--group',
|
|
804
|
+
'-C',
|
|
805
|
+
'--close-from',
|
|
806
|
+
'-D',
|
|
807
|
+
'--chdir',
|
|
808
|
+
'-h',
|
|
809
|
+
'--host',
|
|
810
|
+
'-p',
|
|
811
|
+
'--prompt',
|
|
812
|
+
'-r',
|
|
813
|
+
'--role',
|
|
814
|
+
'-t',
|
|
815
|
+
'--type',
|
|
816
|
+
'-U',
|
|
817
|
+
'--other-user',
|
|
818
|
+
'-R',
|
|
819
|
+
'--chroot',
|
|
820
|
+
'-T',
|
|
821
|
+
'--command-timeout',
|
|
822
|
+
]),
|
|
823
|
+
doas: new Set(['-a', '-C', '-u']),
|
|
824
|
+
xargs: new Set([
|
|
825
|
+
'-I',
|
|
826
|
+
'-i',
|
|
827
|
+
'-J',
|
|
828
|
+
'-n',
|
|
829
|
+
'--max-args',
|
|
830
|
+
'-P',
|
|
831
|
+
'--max-procs',
|
|
832
|
+
'-s',
|
|
833
|
+
'--max-chars',
|
|
834
|
+
'-L',
|
|
835
|
+
'--max-lines',
|
|
836
|
+
'-E',
|
|
837
|
+
'--eof',
|
|
838
|
+
'-d',
|
|
839
|
+
'--delimiter',
|
|
840
|
+
'-a',
|
|
841
|
+
'--arg-file',
|
|
842
|
+
'--replace',
|
|
843
|
+
]),
|
|
844
|
+
watch: new Set(['-n', '--interval']),
|
|
780
845
|
};
|
|
781
846
|
|
|
782
847
|
const tokenLooksLikeEnvAssignment = (token: string): boolean =>
|
|
@@ -843,6 +908,188 @@ const extractEvalCommands = (seg: Segment): string[] => {
|
|
|
843
908
|
return sub === '' ? [] : [sub];
|
|
844
909
|
};
|
|
845
910
|
|
|
911
|
+
// ── rtk (token-optimizing CLI proxy) ─────────────────────────────────
|
|
912
|
+
// Rtk wraps real commands so their output is filtered before reaching the
|
|
913
|
+
// Agent's context, and Codex auto-prepends it. The wrapper hides the real
|
|
914
|
+
// Command from every rule: `rtk proxy rm -rf /` parses with head `rtk` and
|
|
915
|
+
// The destructive `rm` buried in opaque positional args. Strip the `rtk`
|
|
916
|
+
// Prefix and reconstruct the interior command so the existing rules decide
|
|
917
|
+
// On what actually runs.
|
|
918
|
+
//
|
|
919
|
+
// Grammar: `rtk [global-opts] <subcommand> [args]`. Two subcommand classes
|
|
920
|
+
// Exec a sibling command:
|
|
921
|
+
// • wrapper subs — the keyword is dropped, the remainder is an arbitrary
|
|
922
|
+
// Command: `run` (also `-c <string>`), `proxy`, `err`, `test`, `summary`.
|
|
923
|
+
// • tool-proxy subs — the keyword *is* the binary: `git`, `find`, `npm`,
|
|
924
|
+
// `docker`, … Reconstructing from the subcommand onward yields the real
|
|
925
|
+
// Invocation (`git push …`, `find … -delete`). rtk-internal filters that
|
|
926
|
+
// Aren't real binaries (`gain`, `config`, `diff`, …) reconstruct to inert
|
|
927
|
+
// Heads no rule matches, so no allowlist is needed.
|
|
928
|
+
const RTK_WRAPPER_SUBCOMMANDS: ReadonlySet<string> = new Set([
|
|
929
|
+
'run',
|
|
930
|
+
'proxy',
|
|
931
|
+
'err',
|
|
932
|
+
'test',
|
|
933
|
+
'summary',
|
|
934
|
+
]);
|
|
935
|
+
|
|
936
|
+
// Head normalisation in `parseSegment` strips directory prefixes, so only the
|
|
937
|
+
// Bare basename is needed — the `endsWith` fallbacks are now unreachable.
|
|
938
|
+
const isRtkHead = (head: string): boolean => head === 'rtk';
|
|
939
|
+
|
|
940
|
+
const skipRtkGlobalFlags = (tokens: readonly string[]): number => {
|
|
941
|
+
// Rtk's global options (`-v`/`-vv`/`--verbose`, `--ultra-compact`,
|
|
942
|
+
// `--skip-env`) are all boolean, so any leading flag token can be skipped.
|
|
943
|
+
let i = 1;
|
|
944
|
+
while (i < tokens.length) {
|
|
945
|
+
const t = tokens[i]!;
|
|
946
|
+
if (t.startsWith('-') && t !== '-' && t !== '--') {
|
|
947
|
+
i++;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
return i;
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
const dashCommandArg = (tokens: readonly string[], start: number): string | null => {
|
|
956
|
+
for (let k = start; k < tokens.length; k++) {
|
|
957
|
+
const t = tokens[k]!;
|
|
958
|
+
if ((t === '-c' || t === '--command') && k + 1 < tokens.length) {
|
|
959
|
+
return tokens[k + 1]!;
|
|
960
|
+
}
|
|
961
|
+
if (t.startsWith('--command=')) {
|
|
962
|
+
return t.slice('--command='.length);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return null;
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
const extractRtkCommands = (seg: Segment): string[] => {
|
|
969
|
+
if (!isRtkHead(seg.head)) {
|
|
970
|
+
return [];
|
|
971
|
+
}
|
|
972
|
+
const subIdx = skipRtkGlobalFlags(seg.tokens);
|
|
973
|
+
const sub = seg.tokens[subIdx];
|
|
974
|
+
if (sub === undefined) {
|
|
975
|
+
return [];
|
|
976
|
+
}
|
|
977
|
+
if (RTK_WRAPPER_SUBCOMMANDS.has(sub)) {
|
|
978
|
+
// `rtk run -c '<cmd>'` carries the command in a flag value.
|
|
979
|
+
const viaFlag = dashCommandArg(seg.tokens, subIdx + 1);
|
|
980
|
+
if (viaFlag !== null) {
|
|
981
|
+
return viaFlag === '' ? [] : [viaFlag];
|
|
982
|
+
}
|
|
983
|
+
// Otherwise the command is the positional remainder after the keyword,
|
|
984
|
+
// Skipping any wrapper-local boolean flags.
|
|
985
|
+
let j = subIdx + 1;
|
|
986
|
+
while (j < seg.tokens.length) {
|
|
987
|
+
const t = seg.tokens[j]!;
|
|
988
|
+
if (t === '--') {
|
|
989
|
+
j++;
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
if (t.startsWith('-') && t !== '-') {
|
|
993
|
+
j++;
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
const inner = seg.tokens.slice(j).join(' ');
|
|
999
|
+
return inner === '' ? [] : [inner];
|
|
1000
|
+
}
|
|
1001
|
+
// Tool-proxy subcommand: the subcommand token is the real binary name.
|
|
1002
|
+
const inner = seg.tokens.slice(subIdx).join(' ');
|
|
1003
|
+
return inner === '' ? [] : [inner];
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// ── Positional-prefix wrappers ───────────────────────────────────────
|
|
1007
|
+
// Heads where the real command follows one or more positional arguments
|
|
1008
|
+
// The wrapper consumes itself: `timeout <duration> <cmd>`, `chroot <newroot>
|
|
1009
|
+
// <cmd>`, `flock <lockfile> <cmd>` (or `flock <lockfile> -c '<cmd>'`), and
|
|
1010
|
+
// `su [user] -c '<cmd>'`. Skip the flag region, drop the wrapper's own
|
|
1011
|
+
// Positionals, and the remainder is the command.
|
|
1012
|
+
interface PrefixWrapperSpec {
|
|
1013
|
+
readonly valueFlags: ReadonlySet<string>;
|
|
1014
|
+
// Positional args the wrapper consumes before the command (duration, newroot…).
|
|
1015
|
+
readonly skipPositionals: number;
|
|
1016
|
+
// Whether the command can also arrive via `-c <string>` (su, flock).
|
|
1017
|
+
readonly dashCommand: boolean;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const PREFIX_WRAPPER_SPECS: Readonly<Record<string, PrefixWrapperSpec>> = {
|
|
1021
|
+
timeout: {
|
|
1022
|
+
valueFlags: new Set(['-s', '--signal', '-k', '--kill-after']),
|
|
1023
|
+
skipPositionals: 1,
|
|
1024
|
+
dashCommand: false,
|
|
1025
|
+
},
|
|
1026
|
+
gtimeout: {
|
|
1027
|
+
valueFlags: new Set(['-s', '--signal', '-k', '--kill-after']),
|
|
1028
|
+
skipPositionals: 1,
|
|
1029
|
+
dashCommand: false,
|
|
1030
|
+
},
|
|
1031
|
+
chroot: {
|
|
1032
|
+
valueFlags: new Set(['--userspec', '--groups']),
|
|
1033
|
+
skipPositionals: 1,
|
|
1034
|
+
dashCommand: false,
|
|
1035
|
+
},
|
|
1036
|
+
flock: {
|
|
1037
|
+
valueFlags: new Set(['-w', '--wait', '--timeout', '-E', '--conflict-exit-code']),
|
|
1038
|
+
skipPositionals: 1,
|
|
1039
|
+
dashCommand: true,
|
|
1040
|
+
},
|
|
1041
|
+
su: { valueFlags: new Set(), skipPositionals: 0, dashCommand: true },
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
const extractPrefixWrapperCommands = (seg: Segment): string[] => {
|
|
1045
|
+
const spec = PREFIX_WRAPPER_SPECS[seg.head];
|
|
1046
|
+
if (spec === undefined) {
|
|
1047
|
+
return [];
|
|
1048
|
+
}
|
|
1049
|
+
if (spec.dashCommand) {
|
|
1050
|
+
const viaFlag = dashCommandArg(seg.tokens, 1);
|
|
1051
|
+
if (viaFlag !== null) {
|
|
1052
|
+
return viaFlag === '' ? [] : [viaFlag];
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
let i = 1;
|
|
1056
|
+
while (i < seg.tokens.length) {
|
|
1057
|
+
const t = seg.tokens[i]!;
|
|
1058
|
+
if (spec.valueFlags.has(t)) {
|
|
1059
|
+
i += 2;
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
if (t.includes('=') && spec.valueFlags.has(t.slice(0, t.indexOf('=')))) {
|
|
1063
|
+
i++;
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
if (t === '--') {
|
|
1067
|
+
i++;
|
|
1068
|
+
break;
|
|
1069
|
+
}
|
|
1070
|
+
if (t.startsWith('-') && t !== '-') {
|
|
1071
|
+
i++;
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
i += spec.skipPositionals;
|
|
1077
|
+
const inner = seg.tokens.slice(i).join(' ');
|
|
1078
|
+
return inner === '' ? [] : [inner];
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
// Each extractor pulls the inner command(s) a wrapper hides on its own arg
|
|
1082
|
+
// Vector, to be re-parsed as additional segments so every rule sees what
|
|
1083
|
+
// Actually runs. Order is irrelevant — all results are unioned into `out`.
|
|
1084
|
+
const SEGMENT_EXTRACTORS: readonly ((seg: Segment) => string[])[] = [
|
|
1085
|
+
extractExecCommands,
|
|
1086
|
+
extractShellWrappedCommands,
|
|
1087
|
+
extractHeadRenamingCommands,
|
|
1088
|
+
extractEvalCommands,
|
|
1089
|
+
extractRtkCommands,
|
|
1090
|
+
extractPrefixWrapperCommands,
|
|
1091
|
+
];
|
|
1092
|
+
|
|
846
1093
|
const parseCommand = (cmd: string): Segment[] => {
|
|
847
1094
|
let entries: ParseEntry[];
|
|
848
1095
|
const cmdForParsing = maskLiteralHeredocBodies(cmd);
|
|
@@ -894,24 +1141,11 @@ const parseCommand = (cmd: string): Segment[] => {
|
|
|
894
1141
|
const preExtractLen = out.length;
|
|
895
1142
|
for (let k = 0; k < preExtractLen; k++) {
|
|
896
1143
|
const seg = out[k]!;
|
|
897
|
-
for (const
|
|
898
|
-
for (const
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
for (const sub of extractShellWrappedCommands(seg)) {
|
|
903
|
-
for (const innerSeg of parseCommand(sub)) {
|
|
904
|
-
out.push(innerSeg);
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
for (const sub of extractHeadRenamingCommands(seg)) {
|
|
908
|
-
for (const innerSeg of parseCommand(sub)) {
|
|
909
|
-
out.push(innerSeg);
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
for (const sub of extractEvalCommands(seg)) {
|
|
913
|
-
for (const innerSeg of parseCommand(sub)) {
|
|
914
|
-
out.push(innerSeg);
|
|
1144
|
+
for (const extract of SEGMENT_EXTRACTORS) {
|
|
1145
|
+
for (const sub of extract(seg)) {
|
|
1146
|
+
for (const innerSeg of parseCommand(sub)) {
|
|
1147
|
+
out.push(innerSeg);
|
|
1148
|
+
}
|
|
915
1149
|
}
|
|
916
1150
|
}
|
|
917
1151
|
}
|
|
@@ -1001,7 +1235,7 @@ const safeScopesSummary = (
|
|
|
1001
1235
|
// A legitimate bypass marker sits on the actual command line, which the
|
|
1002
1236
|
// Mask leaves intact.
|
|
1003
1237
|
const hasBypass = (cmd: string): boolean =>
|
|
1004
|
-
/(
|
|
1238
|
+
/(?<prefix>^|\s)#\s*tripwire-allow\b/.test(maskLiteralHeredocBodies(cmd));
|
|
1005
1239
|
|
|
1006
1240
|
export type { Redirect, Segment };
|
|
1007
1241
|
export {
|
package/src/lib/log.ts
CHANGED
package/src/lib/secrets.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { spawnSync } from 'node:child_process';
|
|
17
17
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
|
+
// oxlint-disable-next-line unicorn/import-style
|
|
19
20
|
import { join } from 'node:path';
|
|
20
21
|
|
|
21
22
|
interface BetterleaksFinding {
|
package/src/rules/bash-deny.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Segment, hasBypass } from '../lib/bash';
|
|
2
|
-
import { type Decision, allow, ask, deny } from '../lib/decision';
|
|
2
|
+
import { type Decision, allow, ask, deny, merge } from '../lib/decision';
|
|
3
3
|
|
|
4
4
|
interface Spec {
|
|
5
5
|
readonly rule: string;
|
|
@@ -32,7 +32,7 @@ const SPECS: readonly Spec[] = [
|
|
|
32
32
|
match: (seg) =>
|
|
33
33
|
seg.head === 'rm' &&
|
|
34
34
|
flagPresent(seg, '-rf', '-fr', '-Rf', '-fR') &&
|
|
35
|
-
seg.tokens.some((t) => /^(
|
|
35
|
+
seg.tokens.some((t) => /^(?<home>~|\$HOME|\$\{HOME\})$/.test(t)),
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
rule: 'fork-bomb',
|
|
@@ -51,13 +51,14 @@ const SPECS: readonly Spec[] = [
|
|
|
51
51
|
rule: 'dd-raw-device',
|
|
52
52
|
action: 'deny',
|
|
53
53
|
message: 'dd writing to a raw block device wipes the disk. Refuse.',
|
|
54
|
-
match: (seg) =>
|
|
54
|
+
match: (seg) =>
|
|
55
|
+
seg.head === 'dd' && /\bof=\/dev\/(?<type>disk|sd|nvme|rdisk)/i.test(argsJoined(seg)),
|
|
55
56
|
},
|
|
56
57
|
{
|
|
57
58
|
rule: 'mkfs',
|
|
58
59
|
action: 'deny',
|
|
59
60
|
message: 'mkfs formats a filesystem. Refuse.',
|
|
60
|
-
match: (seg) => /^mkfs(
|
|
61
|
+
match: (seg) => /^mkfs(?<ext>\.[a-z0-9]+)?$/i.test(seg.head),
|
|
61
62
|
},
|
|
62
63
|
{
|
|
63
64
|
rule: 'kill-all',
|
|
@@ -162,7 +163,7 @@ const SPECS: readonly Spec[] = [
|
|
|
162
163
|
rule: 'osascript',
|
|
163
164
|
action: 'ask',
|
|
164
165
|
message:
|
|
165
|
-
'osascript runs arbitrary AppleScript and can do almost anything (move files, send emails, drive apps). Confirm with
|
|
166
|
+
'osascript runs arbitrary AppleScript and can do almost anything (move files, send emails, drive apps). Confirm with the user what you want done before running it.',
|
|
166
167
|
match: (seg) => seg.head === 'osascript',
|
|
167
168
|
},
|
|
168
169
|
{
|
|
@@ -194,7 +195,7 @@ const SPECS: readonly Spec[] = [
|
|
|
194
195
|
rule: 'pmset-write',
|
|
195
196
|
action: 'deny',
|
|
196
197
|
message:
|
|
197
|
-
'`pmset` (with arguments) writes power-management settings. Read-only `pmset -g` is fine; mutations need
|
|
198
|
+
'`pmset` (with arguments) writes power-management settings. Read-only `pmset -g` is fine; mutations need the user.',
|
|
198
199
|
match: (seg) =>
|
|
199
200
|
seg.head === 'pmset' &&
|
|
200
201
|
seg.tokens.length > 1 &&
|
|
@@ -237,7 +238,7 @@ const SPECS: readonly Spec[] = [
|
|
|
237
238
|
rule: 'kextload',
|
|
238
239
|
action: 'deny',
|
|
239
240
|
message:
|
|
240
|
-
'Loading a kernel extension (`kextload`, `kmutil load`) is a system-level mutation. Refuse —
|
|
241
|
+
'Loading a kernel extension (`kextload`, `kmutil load`) is a system-level mutation. Refuse — the user handles this manually.',
|
|
241
242
|
match: (seg) =>
|
|
242
243
|
seg.head === 'kextload' ||
|
|
243
244
|
seg.head === 'kextunload' ||
|
|
@@ -369,6 +370,11 @@ const UNBYPASSABLE_RULES: ReadonlySet<string> = new Set([
|
|
|
369
370
|
|
|
370
371
|
const bashDeny = (segments: readonly Segment[], cmd: string): Decision => {
|
|
371
372
|
const bypass = hasBypass(cmd);
|
|
373
|
+
// Collect the first matching spec per segment, then return the most
|
|
374
|
+
// Restrictive across all of them. Returning the first match outright lets
|
|
375
|
+
// A weaker `ask` on an early segment (e.g. the outer `sudo`) shadow a
|
|
376
|
+
// `deny` on a later unwrapped segment (e.g. the interior `shutdown`).
|
|
377
|
+
const hits: Decision[] = [];
|
|
372
378
|
for (const seg of segments) {
|
|
373
379
|
for (const s of SPECS) {
|
|
374
380
|
if (!s.match(seg, cmd)) {
|
|
@@ -378,10 +384,11 @@ const bashDeny = (segments: readonly Segment[], cmd: string): Decision => {
|
|
|
378
384
|
// Caller asserted in-turn approval; honor it for this rule.
|
|
379
385
|
continue;
|
|
380
386
|
}
|
|
381
|
-
|
|
387
|
+
hits.push(s.action === 'deny' ? deny(s.rule, s.message) : ask(s.rule, s.message));
|
|
388
|
+
break;
|
|
382
389
|
}
|
|
383
390
|
}
|
|
384
|
-
return allow('bash-deny');
|
|
391
|
+
return hits.length === 0 ? allow('bash-deny') : merge(hits);
|
|
385
392
|
};
|
|
386
393
|
|
|
387
394
|
export { bashDeny, UNBYPASSABLE_RULES };
|
package/src/rules/bash-git.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { type Decision, allow, ask, deny, warn } from '../lib/decision';
|
|
|
19
19
|
// And no -F) — deny (would hang the agent).
|
|
20
20
|
// - Rebase / cherry-pick / merge — ask (creates conflicts).
|
|
21
21
|
// - Config — allow read; deny write to --global / --system; deny local
|
|
22
|
-
// Write (
|
|
22
|
+
// Write (the user's identity / workflow).
|
|
23
23
|
//
|
|
24
24
|
// `git -C <dir>`, `git --git-dir=<path>`, `git --work-tree=<path>`,
|
|
25
25
|
// `git -c key=value` are stripped before subcommand dispatch — `git -C ../foo
|
|
@@ -38,7 +38,7 @@ const getProtectedBranches = (config: GitConfig): readonly string[] =>
|
|
|
38
38
|
|
|
39
39
|
// Conventional Commits 1.0.0 — type(scope)?(!)?: description
|
|
40
40
|
const CONVENTIONAL_RE =
|
|
41
|
-
/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(
|
|
41
|
+
/^(?<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?<scope>\([\w./\- ]+\))?!?:\s+\S/;
|
|
42
42
|
|
|
43
43
|
const PRE_SUB_FLAG_TAKES_VALUE: ReadonlySet<string> = new Set([
|
|
44
44
|
'-C',
|
|
@@ -502,7 +502,7 @@ const HANDLERS: ReadonlyMap<string, Handler> = new Map<string, Handler>([
|
|
|
502
502
|
({ subcommand }) =>
|
|
503
503
|
warn(
|
|
504
504
|
`git-${subcommand}`,
|
|
505
|
-
`\`git ${subcommand}\` is allowed but unusual mid-session. Make sure this is what
|
|
505
|
+
`\`git ${subcommand}\` is allowed but unusual mid-session. Make sure this is what the user asked for.`,
|
|
506
506
|
),
|
|
507
507
|
],
|
|
508
508
|
[
|
|
@@ -510,7 +510,7 @@ const HANDLERS: ReadonlyMap<string, Handler> = new Map<string, Handler>([
|
|
|
510
510
|
({ subcommand }) =>
|
|
511
511
|
warn(
|
|
512
512
|
`git-${subcommand}`,
|
|
513
|
-
`\`git ${subcommand}\` is allowed but unusual mid-session. Make sure this is what
|
|
513
|
+
`\`git ${subcommand}\` is allowed but unusual mid-session. Make sure this is what the user asked for.`,
|
|
514
514
|
),
|
|
515
515
|
],
|
|
516
516
|
]);
|
|
@@ -56,7 +56,7 @@ const bashNetworkInstall = (segments: readonly Segment[], cmd: string): Decision
|
|
|
56
56
|
if (isFetchPipedToShell(segments)) {
|
|
57
57
|
return deny(
|
|
58
58
|
'curl-pipe-shell',
|
|
59
|
-
"Piping `curl` / `wget` directly into a shell runs whatever the remote URL serves. Refuse — download to a file, inspect, then run if appropriate. If you genuinely need this, append ` # tripwire-allow: <reason>` (and explain to
|
|
59
|
+
"Piping `curl` / `wget` directly into a shell runs whatever the remote URL serves. Refuse — download to a file, inspect, then run if appropriate. If you genuinely need this, append ` # tripwire-allow: <reason>` (and explain to the user what you're running).",
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
for (const seg of segments) {
|
|
@@ -8,38 +8,38 @@ import { type Decision, allow, deny } from '../lib/decision';
|
|
|
8
8
|
const PROTECTED_TARGET_RE: readonly { rule: string; pattern: RegExp; message: string }[] = [
|
|
9
9
|
{
|
|
10
10
|
rule: 'redirect-env',
|
|
11
|
-
pattern: /(
|
|
11
|
+
pattern: /(?<prefix>^|\/)\.env(?<ext>\.[^/]+)?$/,
|
|
12
12
|
message:
|
|
13
13
|
'Refusing to write into a .env file via shell redirect / tee / cp / mv. .env files hold secrets — never overwrite from a tool call.',
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
rule: 'redirect-dev-vars',
|
|
17
|
-
pattern: /(
|
|
17
|
+
pattern: /(?<prefix>^|\/)\.dev\.vars(?<ext>\.[^/]+)?$/,
|
|
18
18
|
message: 'Refusing to write into .dev.vars (Cloudflare/Wrangler secrets).',
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
rule: 'redirect-ssh',
|
|
22
|
-
pattern: /(
|
|
22
|
+
pattern: /(?<prefix>^|\/)\.ssh\//,
|
|
23
23
|
message: 'Refusing to write into ~/.ssh/ via shell.',
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
rule: 'redirect-key',
|
|
27
|
-
pattern: /\.(pem|key|p12|pfx)$/i,
|
|
27
|
+
pattern: /\.(?<ext>pem|key|p12|pfx)$/i,
|
|
28
28
|
message: 'Refusing to overwrite a private-key-shaped file via shell.',
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
rule: 'redirect-aws-credentials',
|
|
32
|
-
pattern: /(
|
|
32
|
+
pattern: /(?<prefix>^|\/)\.aws\/credentials$/,
|
|
33
33
|
message: 'Refusing to write into ~/.aws/credentials via shell.',
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
rule: 'redirect-netrc',
|
|
37
|
-
pattern: /(
|
|
37
|
+
pattern: /(?<prefix>^|\/)\.netrc$/,
|
|
38
38
|
message: 'Refusing to write into ~/.netrc via shell.',
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
rule: 'redirect-block-device',
|
|
42
|
-
pattern: /^\/dev\/(sd|disk|nvme|rdisk)/i,
|
|
42
|
+
pattern: /^\/dev\/(?<type>sd|disk|nvme|rdisk)/i,
|
|
43
43
|
message: 'Redirecting into a raw block device wipes the disk. Refuse.',
|
|
44
44
|
},
|
|
45
45
|
];
|
|
@@ -77,7 +77,7 @@ const bashScopedRm = (
|
|
|
77
77
|
.join('\n');
|
|
78
78
|
return deny(
|
|
79
79
|
'destructive-outside-safe-paths',
|
|
80
|
-
`Destructive deletion outside known-safe scopes is blocked. Use \`trash\` (macOS Trash, recoverable) or \`rip\` (graveyard at
|
|
80
|
+
`Destructive deletion outside known-safe scopes is blocked. Use \`trash\` (macOS Trash, recoverable) or \`rip\` (graveyard at /tmp/graveyard-$USER, recoverable until reboot) instead. Real \`rm\` and \`find -delete\` are allowed only inside ephemeral build / cache / state directories:\n${safeScopesSummary(extraRelative, extraAbsolute)}\n\nFlagged targets:\n${detail}\n\nIf raw \`rm\` is genuinely needed, append \` # tripwire-allow: <reason>\` to the command.`,
|
|
81
81
|
);
|
|
82
82
|
};
|
|
83
83
|
|
|
@@ -30,7 +30,7 @@ const findChangeDir = (seg: Segment): string | null => {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
const isUnsafeExtractDest = (dest: string): boolean => {
|
|
33
|
-
return dest === '/' || /^(
|
|
33
|
+
return dest === '/' || /^(?<home>~|\$HOME|\$\{HOME\})$/.test(dest);
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
const bashTarExplosion = (segments: readonly Segment[], cmd: string): Decision => {
|