@seanmozeik/tripwire 0.5.3 → 0.6.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 +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 +5 -5
- package/src/cli.ts +2 -1
- package/src/lib/bash.ts +257 -27
- 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.0",
|
|
4
4
|
"description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -33,14 +33,14 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@effect/platform-bun": "^4.0.0-beta.66",
|
|
35
35
|
"effect": "^4.0.0-beta.66",
|
|
36
|
-
"shell-quote": "^1.8.
|
|
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
|
@@ -257,8 +257,13 @@ const countFdPrefixRedirects = (cmd: string): number => {
|
|
|
257
257
|
};
|
|
258
258
|
|
|
259
259
|
const heredocDelimiterFromLine = (line: string): string | null => {
|
|
260
|
-
const match =
|
|
261
|
-
|
|
260
|
+
const match =
|
|
261
|
+
/<<-?\s*(?:"(?<quoted>[^"]+)"|'(?<single>[^']+)'|(?<unquoted>[A-Za-z_][A-Za-z0-9_]*))/u.exec(
|
|
262
|
+
line,
|
|
263
|
+
);
|
|
264
|
+
return (
|
|
265
|
+
match?.groups?.['quoted'] ?? match?.groups?.['single'] ?? match?.groups?.['unquoted'] ?? null
|
|
266
|
+
);
|
|
262
267
|
};
|
|
263
268
|
|
|
264
269
|
const SHELL_STDIN_HEAD_RE =
|
|
@@ -483,7 +488,7 @@ const pathDangerScore = (t: string): number => {
|
|
|
483
488
|
if (t === '~' || HOME_VAR_RE.test(t)) {
|
|
484
489
|
return 90;
|
|
485
490
|
}
|
|
486
|
-
if (/^\/(etc|usr|bin|sbin|System|Library|var|boot|root|home)(
|
|
491
|
+
if (/^\/(?<dir>etc|usr|bin|sbin|System|Library|var|boot|root|home)(?<suffix>\/|$)/.test(t)) {
|
|
487
492
|
return 80;
|
|
488
493
|
}
|
|
489
494
|
if (t.startsWith('/Users/')) {
|
|
@@ -679,15 +684,16 @@ const extractExecCommands = (seg: Segment): string[] => {
|
|
|
679
684
|
// Treats substitutions as opaque sentinels — and any rule that touches
|
|
680
685
|
// Agent-controlled content should route through one of them.
|
|
681
686
|
|
|
682
|
-
const HEREDOC_SUBST_RE =
|
|
683
|
-
|
|
687
|
+
const HEREDOC_SUBST_RE =
|
|
688
|
+
/\$\(\s*cat\s+<<-?\s*['"]?(?<delimiter>\w+)['"]?\s*\n(?<body>[\s\S]*?)\n\s*\k<delimiter>\s*\)/u;
|
|
689
|
+
const ECHO_PRINTF_SUBST_RE = /\$\(\s*(?:printf|echo)\s+(?:-[a-zA-Z]+\s+)*'(?<content>[^']*)'/u;
|
|
684
690
|
|
|
685
691
|
const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, string>): string => {
|
|
686
692
|
const heredoc = HEREDOC_SUBST_RE.exec(value);
|
|
687
693
|
if (heredoc !== null) {
|
|
688
|
-
const captured = heredoc[
|
|
694
|
+
const captured = heredoc.groups?.['body'] ?? value;
|
|
689
695
|
if (heredocBodies !== undefined && captured.trim() === '__HEREDOC_BODY__') {
|
|
690
|
-
const delimiter = heredoc[
|
|
696
|
+
const delimiter = heredoc.groups?.['delimiter'];
|
|
691
697
|
const real = delimiter === undefined ? undefined : heredocBodies.get(delimiter);
|
|
692
698
|
if (real !== undefined) {
|
|
693
699
|
return real;
|
|
@@ -697,7 +703,7 @@ const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, s
|
|
|
697
703
|
}
|
|
698
704
|
const printf = ECHO_PRINTF_SUBST_RE.exec(value);
|
|
699
705
|
if (printf !== null) {
|
|
700
|
-
return printf[
|
|
706
|
+
return printf.groups?.['content'] ?? value;
|
|
701
707
|
}
|
|
702
708
|
return value;
|
|
703
709
|
};
|
|
@@ -752,6 +758,13 @@ const extractShellWrappedCommands = (seg: Segment): string[] => {
|
|
|
752
758
|
return [];
|
|
753
759
|
};
|
|
754
760
|
|
|
761
|
+
// Heads that take `[flags] <command> [args]` on the same arg vector: the
|
|
762
|
+
// First non-flag token after the prefix is the real command. `sudo`/`doas`
|
|
763
|
+
// (privilege escalation), `xargs` (stdin-driven exec), and `watch` (repeated
|
|
764
|
+
// Exec) all hide a sibling command this way, so the same unwrap that handles
|
|
765
|
+
// `command`/`env`/`nohup` applies. `sudo` is also matched by bash-deny's
|
|
766
|
+
// `ask` rule on the outer segment — both fire, and the more-restrictive
|
|
767
|
+
// Interior decision (e.g. `sudo rm -rf /` → deny) wins on merge.
|
|
755
768
|
const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
|
|
756
769
|
'command',
|
|
757
770
|
'exec',
|
|
@@ -766,6 +779,10 @@ const HEAD_RENAMING_HEADS: ReadonlySet<string> = new Set([
|
|
|
766
779
|
'unbuffer',
|
|
767
780
|
'script',
|
|
768
781
|
'taskset',
|
|
782
|
+
'sudo',
|
|
783
|
+
'doas',
|
|
784
|
+
'xargs',
|
|
785
|
+
'watch',
|
|
769
786
|
]);
|
|
770
787
|
|
|
771
788
|
const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
@@ -777,6 +794,52 @@ const HEAD_RENAMING_VALUE_FLAGS: Readonly<Record<string, ReadonlySet<string>>> =
|
|
|
777
794
|
stdbuf: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
|
|
778
795
|
script: new Set(['-c', '--command']),
|
|
779
796
|
taskset: new Set(),
|
|
797
|
+
sudo: new Set([
|
|
798
|
+
'-u',
|
|
799
|
+
'--user',
|
|
800
|
+
'-g',
|
|
801
|
+
'--group',
|
|
802
|
+
'-C',
|
|
803
|
+
'--close-from',
|
|
804
|
+
'-D',
|
|
805
|
+
'--chdir',
|
|
806
|
+
'-h',
|
|
807
|
+
'--host',
|
|
808
|
+
'-p',
|
|
809
|
+
'--prompt',
|
|
810
|
+
'-r',
|
|
811
|
+
'--role',
|
|
812
|
+
'-t',
|
|
813
|
+
'--type',
|
|
814
|
+
'-U',
|
|
815
|
+
'--other-user',
|
|
816
|
+
'-R',
|
|
817
|
+
'--chroot',
|
|
818
|
+
'-T',
|
|
819
|
+
'--command-timeout',
|
|
820
|
+
]),
|
|
821
|
+
doas: new Set(['-a', '-C', '-u']),
|
|
822
|
+
xargs: new Set([
|
|
823
|
+
'-I',
|
|
824
|
+
'-i',
|
|
825
|
+
'-J',
|
|
826
|
+
'-n',
|
|
827
|
+
'--max-args',
|
|
828
|
+
'-P',
|
|
829
|
+
'--max-procs',
|
|
830
|
+
'-s',
|
|
831
|
+
'--max-chars',
|
|
832
|
+
'-L',
|
|
833
|
+
'--max-lines',
|
|
834
|
+
'-E',
|
|
835
|
+
'--eof',
|
|
836
|
+
'-d',
|
|
837
|
+
'--delimiter',
|
|
838
|
+
'-a',
|
|
839
|
+
'--arg-file',
|
|
840
|
+
'--replace',
|
|
841
|
+
]),
|
|
842
|
+
watch: new Set(['-n', '--interval']),
|
|
780
843
|
};
|
|
781
844
|
|
|
782
845
|
const tokenLooksLikeEnvAssignment = (token: string): boolean =>
|
|
@@ -843,6 +906,186 @@ const extractEvalCommands = (seg: Segment): string[] => {
|
|
|
843
906
|
return sub === '' ? [] : [sub];
|
|
844
907
|
};
|
|
845
908
|
|
|
909
|
+
// ── rtk (token-optimizing CLI proxy) ─────────────────────────────────
|
|
910
|
+
// Rtk wraps real commands so their output is filtered before reaching the
|
|
911
|
+
// Agent's context, and Codex auto-prepends it. The wrapper hides the real
|
|
912
|
+
// Command from every rule: `rtk proxy rm -rf /` parses with head `rtk` and
|
|
913
|
+
// The destructive `rm` buried in opaque positional args. Strip the `rtk`
|
|
914
|
+
// Prefix and reconstruct the interior command so the existing rules decide
|
|
915
|
+
// On what actually runs.
|
|
916
|
+
//
|
|
917
|
+
// Grammar: `rtk [global-opts] <subcommand> [args]`. Two subcommand classes
|
|
918
|
+
// Exec a sibling command:
|
|
919
|
+
// • wrapper subs — the keyword is dropped, the remainder is an arbitrary
|
|
920
|
+
// Command: `run` (also `-c <string>`), `proxy`, `err`, `test`, `summary`.
|
|
921
|
+
// • tool-proxy subs — the keyword *is* the binary: `git`, `find`, `npm`,
|
|
922
|
+
// `docker`, … Reconstructing from the subcommand onward yields the real
|
|
923
|
+
// Invocation (`git push …`, `find … -delete`). rtk-internal filters that
|
|
924
|
+
// Aren't real binaries (`gain`, `config`, `diff`, …) reconstruct to inert
|
|
925
|
+
// Heads no rule matches, so no allowlist is needed.
|
|
926
|
+
const RTK_WRAPPER_SUBCOMMANDS: ReadonlySet<string> = new Set([
|
|
927
|
+
'run',
|
|
928
|
+
'proxy',
|
|
929
|
+
'err',
|
|
930
|
+
'test',
|
|
931
|
+
'summary',
|
|
932
|
+
]);
|
|
933
|
+
|
|
934
|
+
const isRtkHead = (head: string): boolean => head === 'rtk' || head.endsWith('/rtk');
|
|
935
|
+
|
|
936
|
+
const skipRtkGlobalFlags = (tokens: readonly string[]): number => {
|
|
937
|
+
// Rtk's global options (`-v`/`-vv`/`--verbose`, `--ultra-compact`,
|
|
938
|
+
// `--skip-env`) are all boolean, so any leading flag token can be skipped.
|
|
939
|
+
let i = 1;
|
|
940
|
+
while (i < tokens.length) {
|
|
941
|
+
const t = tokens[i]!;
|
|
942
|
+
if (t.startsWith('-') && t !== '-' && t !== '--') {
|
|
943
|
+
i++;
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
return i;
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
const dashCommandArg = (tokens: readonly string[], start: number): string | null => {
|
|
952
|
+
for (let k = start; k < tokens.length; k++) {
|
|
953
|
+
const t = tokens[k]!;
|
|
954
|
+
if ((t === '-c' || t === '--command') && k + 1 < tokens.length) {
|
|
955
|
+
return tokens[k + 1]!;
|
|
956
|
+
}
|
|
957
|
+
if (t.startsWith('--command=')) {
|
|
958
|
+
return t.slice('--command='.length);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return null;
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
const extractRtkCommands = (seg: Segment): string[] => {
|
|
965
|
+
if (!isRtkHead(seg.head)) {
|
|
966
|
+
return [];
|
|
967
|
+
}
|
|
968
|
+
const subIdx = skipRtkGlobalFlags(seg.tokens);
|
|
969
|
+
const sub = seg.tokens[subIdx];
|
|
970
|
+
if (sub === undefined) {
|
|
971
|
+
return [];
|
|
972
|
+
}
|
|
973
|
+
if (RTK_WRAPPER_SUBCOMMANDS.has(sub)) {
|
|
974
|
+
// `rtk run -c '<cmd>'` carries the command in a flag value.
|
|
975
|
+
const viaFlag = dashCommandArg(seg.tokens, subIdx + 1);
|
|
976
|
+
if (viaFlag !== null) {
|
|
977
|
+
return viaFlag === '' ? [] : [viaFlag];
|
|
978
|
+
}
|
|
979
|
+
// Otherwise the command is the positional remainder after the keyword,
|
|
980
|
+
// Skipping any wrapper-local boolean flags.
|
|
981
|
+
let j = subIdx + 1;
|
|
982
|
+
while (j < seg.tokens.length) {
|
|
983
|
+
const t = seg.tokens[j]!;
|
|
984
|
+
if (t === '--') {
|
|
985
|
+
j++;
|
|
986
|
+
break;
|
|
987
|
+
}
|
|
988
|
+
if (t.startsWith('-') && t !== '-') {
|
|
989
|
+
j++;
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
const inner = seg.tokens.slice(j).join(' ');
|
|
995
|
+
return inner === '' ? [] : [inner];
|
|
996
|
+
}
|
|
997
|
+
// Tool-proxy subcommand: the subcommand token is the real binary name.
|
|
998
|
+
const inner = seg.tokens.slice(subIdx).join(' ');
|
|
999
|
+
return inner === '' ? [] : [inner];
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
// ── Positional-prefix wrappers ───────────────────────────────────────
|
|
1003
|
+
// Heads where the real command follows one or more positional arguments
|
|
1004
|
+
// The wrapper consumes itself: `timeout <duration> <cmd>`, `chroot <newroot>
|
|
1005
|
+
// <cmd>`, `flock <lockfile> <cmd>` (or `flock <lockfile> -c '<cmd>'`), and
|
|
1006
|
+
// `su [user] -c '<cmd>'`. Skip the flag region, drop the wrapper's own
|
|
1007
|
+
// Positionals, and the remainder is the command.
|
|
1008
|
+
interface PrefixWrapperSpec {
|
|
1009
|
+
readonly valueFlags: ReadonlySet<string>;
|
|
1010
|
+
// Positional args the wrapper consumes before the command (duration, newroot…).
|
|
1011
|
+
readonly skipPositionals: number;
|
|
1012
|
+
// Whether the command can also arrive via `-c <string>` (su, flock).
|
|
1013
|
+
readonly dashCommand: boolean;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const PREFIX_WRAPPER_SPECS: Readonly<Record<string, PrefixWrapperSpec>> = {
|
|
1017
|
+
timeout: {
|
|
1018
|
+
valueFlags: new Set(['-s', '--signal', '-k', '--kill-after']),
|
|
1019
|
+
skipPositionals: 1,
|
|
1020
|
+
dashCommand: false,
|
|
1021
|
+
},
|
|
1022
|
+
gtimeout: {
|
|
1023
|
+
valueFlags: new Set(['-s', '--signal', '-k', '--kill-after']),
|
|
1024
|
+
skipPositionals: 1,
|
|
1025
|
+
dashCommand: false,
|
|
1026
|
+
},
|
|
1027
|
+
chroot: {
|
|
1028
|
+
valueFlags: new Set(['--userspec', '--groups']),
|
|
1029
|
+
skipPositionals: 1,
|
|
1030
|
+
dashCommand: false,
|
|
1031
|
+
},
|
|
1032
|
+
flock: {
|
|
1033
|
+
valueFlags: new Set(['-w', '--wait', '--timeout', '-E', '--conflict-exit-code']),
|
|
1034
|
+
skipPositionals: 1,
|
|
1035
|
+
dashCommand: true,
|
|
1036
|
+
},
|
|
1037
|
+
su: { valueFlags: new Set(), skipPositionals: 0, dashCommand: true },
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
const extractPrefixWrapperCommands = (seg: Segment): string[] => {
|
|
1041
|
+
const spec = PREFIX_WRAPPER_SPECS[seg.head];
|
|
1042
|
+
if (spec === undefined) {
|
|
1043
|
+
return [];
|
|
1044
|
+
}
|
|
1045
|
+
if (spec.dashCommand) {
|
|
1046
|
+
const viaFlag = dashCommandArg(seg.tokens, 1);
|
|
1047
|
+
if (viaFlag !== null) {
|
|
1048
|
+
return viaFlag === '' ? [] : [viaFlag];
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
let i = 1;
|
|
1052
|
+
while (i < seg.tokens.length) {
|
|
1053
|
+
const t = seg.tokens[i]!;
|
|
1054
|
+
if (spec.valueFlags.has(t)) {
|
|
1055
|
+
i += 2;
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
if (t.includes('=') && spec.valueFlags.has(t.slice(0, t.indexOf('=')))) {
|
|
1059
|
+
i++;
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
if (t === '--') {
|
|
1063
|
+
i++;
|
|
1064
|
+
break;
|
|
1065
|
+
}
|
|
1066
|
+
if (t.startsWith('-') && t !== '-') {
|
|
1067
|
+
i++;
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
break;
|
|
1071
|
+
}
|
|
1072
|
+
i += spec.skipPositionals;
|
|
1073
|
+
const inner = seg.tokens.slice(i).join(' ');
|
|
1074
|
+
return inner === '' ? [] : [inner];
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
// Each extractor pulls the inner command(s) a wrapper hides on its own arg
|
|
1078
|
+
// Vector, to be re-parsed as additional segments so every rule sees what
|
|
1079
|
+
// Actually runs. Order is irrelevant — all results are unioned into `out`.
|
|
1080
|
+
const SEGMENT_EXTRACTORS: readonly ((seg: Segment) => string[])[] = [
|
|
1081
|
+
extractExecCommands,
|
|
1082
|
+
extractShellWrappedCommands,
|
|
1083
|
+
extractHeadRenamingCommands,
|
|
1084
|
+
extractEvalCommands,
|
|
1085
|
+
extractRtkCommands,
|
|
1086
|
+
extractPrefixWrapperCommands,
|
|
1087
|
+
];
|
|
1088
|
+
|
|
846
1089
|
const parseCommand = (cmd: string): Segment[] => {
|
|
847
1090
|
let entries: ParseEntry[];
|
|
848
1091
|
const cmdForParsing = maskLiteralHeredocBodies(cmd);
|
|
@@ -894,24 +1137,11 @@ const parseCommand = (cmd: string): Segment[] => {
|
|
|
894
1137
|
const preExtractLen = out.length;
|
|
895
1138
|
for (let k = 0; k < preExtractLen; k++) {
|
|
896
1139
|
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);
|
|
1140
|
+
for (const extract of SEGMENT_EXTRACTORS) {
|
|
1141
|
+
for (const sub of extract(seg)) {
|
|
1142
|
+
for (const innerSeg of parseCommand(sub)) {
|
|
1143
|
+
out.push(innerSeg);
|
|
1144
|
+
}
|
|
915
1145
|
}
|
|
916
1146
|
}
|
|
917
1147
|
}
|
|
@@ -1001,7 +1231,7 @@ const safeScopesSummary = (
|
|
|
1001
1231
|
// A legitimate bypass marker sits on the actual command line, which the
|
|
1002
1232
|
// Mask leaves intact.
|
|
1003
1233
|
const hasBypass = (cmd: string): boolean =>
|
|
1004
|
-
/(
|
|
1234
|
+
/(?<prefix>^|\s)#\s*tripwire-allow\b/.test(maskLiteralHeredocBodies(cmd));
|
|
1005
1235
|
|
|
1006
1236
|
export type { Redirect, Segment };
|
|
1007
1237
|
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 => {
|