@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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanmozeik/tripwire",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
5
5
  "license": "MIT",
6
6
  "bin": {
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
- const DISPATCH_BIN = `${import.meta.dir}/../dist/tripwire.js`;
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.sync(() => {
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 result = Bun.spawnSync([DISPATCH_BIN], {
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
- return heredoc[2] ?? value;
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
- const hasBypass = (cmd: string): boolean => /(^|\s)#\s*tripwire-allow\b/.test(cmd);
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,
@@ -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 = (subArgs: readonly string[]): string | null => {
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 = (inv: GitInvocation, config: GitConfig): Decision | null => {
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
  }