@seanmozeik/tripwire 0.5.0 → 0.5.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/dist/tripwire-cli.js +4 -4
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +50 -48
- package/dist/tripwire.js.jsc +0 -0
- package/package.json +1 -1
- package/src/cli.ts +21 -3
- package/src/lib/bash.ts +47 -3
- package/src/rules/bash-git.ts +19 -10
package/dist/tripwire.js.jsc
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -14,14 +14,31 @@
|
|
|
14
14
|
// Bun src/cli.ts install pi
|
|
15
15
|
// Bun src/cli.ts install all
|
|
16
16
|
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
|
|
17
19
|
import { BunServices } from '@effect/platform-bun';
|
|
20
|
+
import { file } from 'bun';
|
|
18
21
|
import { Effect, Option } from 'effect';
|
|
19
22
|
import { Argument, Command, Flag } from 'effect/unstable/cli';
|
|
20
23
|
|
|
21
24
|
import pkg from '../package.json' with { type: 'json' };
|
|
22
25
|
import { installAll, installClaude, installCodex, installPi } from './lib/install';
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
// Resolve tripwire-hook path relative to this CLI's installed location
|
|
28
|
+
const cliDir = import.meta.dirname;
|
|
29
|
+
|
|
30
|
+
// Try installed location first (both binaries in same dir), fall back to dev location
|
|
31
|
+
const installedPath = resolve(cliDir, 'tripwire-hook');
|
|
32
|
+
const devPath = resolve(cliDir, '../dist/tripwire.js');
|
|
33
|
+
const dispatchBin = async (): Promise<string> => {
|
|
34
|
+
try {
|
|
35
|
+
// Check if installed path exists
|
|
36
|
+
await file(installedPath).text();
|
|
37
|
+
return installedPath;
|
|
38
|
+
} catch {
|
|
39
|
+
return devPath;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
25
42
|
|
|
26
43
|
interface BuiltEvent {
|
|
27
44
|
hook_event_name: string;
|
|
@@ -89,10 +106,11 @@ const runTest = (config: {
|
|
|
89
106
|
readonly stdout: string | undefined;
|
|
90
107
|
readonly tool: string;
|
|
91
108
|
}): Effect.Effect<void> =>
|
|
92
|
-
Effect.
|
|
109
|
+
Effect.gen(function* () {
|
|
93
110
|
const { command, content, path, post, stderr, stdout, tool } = config;
|
|
94
111
|
const event = buildEvent({ tool, post, command, path, stdout, stderr, content });
|
|
95
|
-
const
|
|
112
|
+
const bin = yield* Effect.promise(() => dispatchBin());
|
|
113
|
+
const result = Bun.spawnSync([bin], {
|
|
96
114
|
stdin: new TextEncoder().encode(JSON.stringify(event)),
|
|
97
115
|
timeout: 10_000,
|
|
98
116
|
stdout: 'pipe',
|
package/src/lib/bash.ts
CHANGED
|
@@ -288,6 +288,35 @@ const maskLiteralHeredocBodies = (cmd: string): string => {
|
|
|
288
288
|
return out.join('\n');
|
|
289
289
|
};
|
|
290
290
|
|
|
291
|
+
// Side-channel for static-string lookup. Mask-protected rules (hasBypass,
|
|
292
|
+
// Bash-deny scanning) must never see heredoc body characters — a body
|
|
293
|
+
// Containing `# tripwire-allow` or `rm -rf /` would slip past them.
|
|
294
|
+
// `unwrapStaticString` still needs the original body of `$(cat <<EOF ... EOF)`
|
|
295
|
+
// To validate the wrapped commit message. Keep masking universal, and pass
|
|
296
|
+
// The original-body lookup through a separate channel only the static-string
|
|
297
|
+
// Extractor consults.
|
|
298
|
+
const collectHeredocBodies = (cmd: string): ReadonlyMap<string, string> => {
|
|
299
|
+
const map = new Map<string, string>();
|
|
300
|
+
const lines = cmd.split('\n');
|
|
301
|
+
for (let i = 0; i < lines.length; i++) {
|
|
302
|
+
const line = lines[i]!;
|
|
303
|
+
const delimiter = heredocDelimiterFromLine(line);
|
|
304
|
+
if (delimiter === null || heredocFeedsShell(line)) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const body: string[] = [];
|
|
308
|
+
i++;
|
|
309
|
+
while (i < lines.length && lines[i]!.trim() !== delimiter) {
|
|
310
|
+
body.push(lines[i]!);
|
|
311
|
+
i++;
|
|
312
|
+
}
|
|
313
|
+
if (!map.has(delimiter)) {
|
|
314
|
+
map.set(delimiter, body.join('\n'));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return map;
|
|
318
|
+
};
|
|
319
|
+
|
|
291
320
|
const extractShellHeredocCommands = (cmd: string): string[] => {
|
|
292
321
|
const lines = cmd.split('\n');
|
|
293
322
|
const out: string[] = [];
|
|
@@ -653,10 +682,18 @@ const extractExecCommands = (seg: Segment): string[] => {
|
|
|
653
682
|
const HEREDOC_SUBST_RE = /\$\(\s*cat\s+<<-?\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\s*\1\s*\)/u;
|
|
654
683
|
const ECHO_PRINTF_SUBST_RE = /\$\(\s*(?:printf|echo)\s+(?:-[a-zA-Z]+\s+)*'([^']*)'/u;
|
|
655
684
|
|
|
656
|
-
const unwrapStaticString = (value: string): string => {
|
|
685
|
+
const unwrapStaticString = (value: string, heredocBodies?: ReadonlyMap<string, string>): string => {
|
|
657
686
|
const heredoc = HEREDOC_SUBST_RE.exec(value);
|
|
658
687
|
if (heredoc !== null) {
|
|
659
|
-
|
|
688
|
+
const captured = heredoc[2] ?? value;
|
|
689
|
+
if (heredocBodies !== undefined && captured.trim() === '__HEREDOC_BODY__') {
|
|
690
|
+
const delimiter = heredoc[1];
|
|
691
|
+
const real = delimiter === undefined ? undefined : heredocBodies.get(delimiter);
|
|
692
|
+
if (real !== undefined) {
|
|
693
|
+
return real;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return captured;
|
|
660
697
|
}
|
|
661
698
|
const printf = ECHO_PRINTF_SUBST_RE.exec(value);
|
|
662
699
|
if (printf !== null) {
|
|
@@ -958,11 +995,18 @@ const safeScopesSummary = (
|
|
|
958
995
|
.join('\n');
|
|
959
996
|
};
|
|
960
997
|
|
|
961
|
-
|
|
998
|
+
// Mask heredoc bodies before scanning, otherwise a `# tripwire-allow`
|
|
999
|
+
// Smuggled inside a heredoc body (e.g. a commit message piped via
|
|
1000
|
+
// `$(cat <<EOF ... EOF)`) disarms every rule for the surrounding command.
|
|
1001
|
+
// A legitimate bypass marker sits on the actual command line, which the
|
|
1002
|
+
// Mask leaves intact.
|
|
1003
|
+
const hasBypass = (cmd: string): boolean =>
|
|
1004
|
+
/(^|\s)#\s*tripwire-allow\b/.test(maskLiteralHeredocBodies(cmd));
|
|
962
1005
|
|
|
963
1006
|
export type { Redirect, Segment };
|
|
964
1007
|
export {
|
|
965
1008
|
EXEC_SPECS,
|
|
1009
|
+
collectHeredocBodies,
|
|
966
1010
|
hasBypass,
|
|
967
1011
|
isSafePathTarget,
|
|
968
1012
|
parseCommand,
|
package/src/rules/bash-git.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Segment, hasBypass, unwrapStaticString } from '../lib/bash';
|
|
1
|
+
import { type Segment, collectHeredocBodies, hasBypass, unwrapStaticString } from '../lib/bash';
|
|
2
2
|
import type { GitConfig } from '../lib/config';
|
|
3
3
|
import { type Decision, allow, ask, deny, warn } from '../lib/decision';
|
|
4
4
|
|
|
@@ -107,21 +107,24 @@ const parseGit = (seg: Segment): GitInvocation | null => {
|
|
|
107
107
|
return null;
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
-
const messageOf = (
|
|
110
|
+
const messageOf = (
|
|
111
|
+
subArgs: readonly string[],
|
|
112
|
+
heredocBodies?: ReadonlyMap<string, string>,
|
|
113
|
+
): string | null => {
|
|
111
114
|
for (let i = 0; i < subArgs.length; i++) {
|
|
112
115
|
const t = subArgs[i]!;
|
|
113
116
|
if (t === '-m' || t === '--message') {
|
|
114
117
|
const raw = subArgs[i + 1];
|
|
115
|
-
return raw === undefined ? null : unwrapStaticString(raw);
|
|
118
|
+
return raw === undefined ? null : unwrapStaticString(raw, heredocBodies);
|
|
116
119
|
}
|
|
117
120
|
if (t.startsWith('--message=')) {
|
|
118
|
-
return unwrapStaticString(t.slice('--message='.length));
|
|
121
|
+
return unwrapStaticString(t.slice('--message='.length), heredocBodies);
|
|
119
122
|
}
|
|
120
123
|
// Combined short flags like `-am`, `-ma`, `-amS` carry the message
|
|
121
124
|
// In the next positional arg — same as `-m` alone.
|
|
122
125
|
if (/^-[a-zA-Z]*m[a-zA-Z]*$/.test(t)) {
|
|
123
126
|
const raw = subArgs[i + 1];
|
|
124
|
-
return raw === undefined ? null : unwrapStaticString(raw);
|
|
127
|
+
return raw === undefined ? null : unwrapStaticString(raw, heredocBodies);
|
|
125
128
|
}
|
|
126
129
|
}
|
|
127
130
|
return null;
|
|
@@ -153,6 +156,7 @@ interface HandlerCtx {
|
|
|
153
156
|
readonly flags: readonly string[];
|
|
154
157
|
readonly positional: readonly string[];
|
|
155
158
|
readonly config: GitConfig;
|
|
159
|
+
readonly heredocBodies: ReadonlyMap<string, string>;
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
type Handler = (ctx: HandlerCtx) => Decision;
|
|
@@ -306,14 +310,14 @@ const handleMerge: Handler = ({ subArgs }) => {
|
|
|
306
310
|
return ask('git-merge', '`git merge <branch>` may create merge conflicts. Confirm intent.');
|
|
307
311
|
};
|
|
308
312
|
|
|
309
|
-
const handleCommit: Handler = ({ subArgs, config }) => {
|
|
313
|
+
const handleCommit: Handler = ({ subArgs, config, heredocBodies }) => {
|
|
310
314
|
if (has(subArgs, '--amend')) {
|
|
311
315
|
return deny(
|
|
312
316
|
'git-commit-amend',
|
|
313
317
|
'`git commit --amend` rewrites the last commit. If it has been pushed, this causes upstream divergence. Refuse — surface the intent.',
|
|
314
318
|
);
|
|
315
319
|
}
|
|
316
|
-
const msg = messageOf(subArgs);
|
|
320
|
+
const msg = messageOf(subArgs, heredocBodies);
|
|
317
321
|
const hasFile = has(subArgs, '-F', '--file', '-c', '-C', '--reuse-message', '--reedit-message');
|
|
318
322
|
const hasNoEdit = has(subArgs, '--no-edit');
|
|
319
323
|
if (msg === null && !hasFile && !hasNoEdit) {
|
|
@@ -511,7 +515,11 @@ const HANDLERS: ReadonlyMap<string, Handler> = new Map<string, Handler>([
|
|
|
511
515
|
],
|
|
512
516
|
]);
|
|
513
517
|
|
|
514
|
-
const evalGit = (
|
|
518
|
+
const evalGit = (
|
|
519
|
+
inv: GitInvocation,
|
|
520
|
+
config: GitConfig,
|
|
521
|
+
heredocBodies: ReadonlyMap<string, string>,
|
|
522
|
+
): Decision | null => {
|
|
515
523
|
const { subcommand, subArgs } = inv;
|
|
516
524
|
const flags = flagsOf(subArgs);
|
|
517
525
|
const positional = positionalOf(subArgs);
|
|
@@ -566,7 +574,7 @@ const evalGit = (inv: GitInvocation, config: GitConfig): Decision | null => {
|
|
|
566
574
|
|
|
567
575
|
const handler = HANDLERS.get(subcommand);
|
|
568
576
|
if (handler !== undefined) {
|
|
569
|
-
return handler({ subcommand, subArgs, flags, positional, config });
|
|
577
|
+
return handler({ subcommand, subArgs, flags, positional, config, heredocBodies });
|
|
570
578
|
}
|
|
571
579
|
return warn(
|
|
572
580
|
'git-unknown-subcommand',
|
|
@@ -578,12 +586,13 @@ const bashGit = (segments: readonly Segment[], cmd: string, config: GitConfig):
|
|
|
578
586
|
if (hasBypass(cmd)) {
|
|
579
587
|
return allow('bash-git');
|
|
580
588
|
}
|
|
589
|
+
const heredocBodies = collectHeredocBodies(cmd);
|
|
581
590
|
for (const seg of segments) {
|
|
582
591
|
const inv = parseGit(seg);
|
|
583
592
|
if (inv === null) {
|
|
584
593
|
continue;
|
|
585
594
|
}
|
|
586
|
-
const d = evalGit(inv, config);
|
|
595
|
+
const d = evalGit(inv, config, heredocBodies);
|
|
587
596
|
if (d !== null && d.kind !== 'allow') {
|
|
588
597
|
return d;
|
|
589
598
|
}
|