@seanmozeik/tripwire 0.6.1 → 0.6.3

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.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
5
5
  "license": "MIT",
6
6
  "bin": {
package/src/dispatch.ts CHANGED
@@ -3,8 +3,7 @@
3
3
  //
4
4
  // Reads a hook event JSON payload on stdin, routes by hook_event_name +
5
5
  // Tool_name, runs rules with per-rule timeouts, merges decisions
6
- // (most-restrictive wins), wraps allowed Bash commands through rtk for
7
- // Token-saver rewriting, scans PostToolUse output for secrets via
6
+ // (most-restrictive wins), scans PostToolUse output for secrets via
8
7
  // Betterleaks, and writes Claude Code's expected JSON response on stdout.
9
8
  //
10
9
  // Design rules:
@@ -34,7 +33,6 @@ import {
34
33
  isWriteInput,
35
34
  } from './lib/event.ts';
36
35
  import { logError } from './lib/log';
37
- import { runRtkRewrite } from './lib/rtk';
38
36
  import { bashDeny } from './rules/bash-deny';
39
37
  import { bashGit } from './rules/bash-git';
40
38
  import { bashNetworkInstall } from './rules/bash-network-install';
@@ -61,50 +59,26 @@ const writeAllow = (): void => {
61
59
  };
62
60
 
63
61
  // Codex's PreToolUse hook rejects `hookSpecificOutput.additionalContext`
64
- // (openai/codex issue #19385) and `updatedInput` (#18491). Detect Codex
65
- // Via its `turn_id` extension and downgrade output accordingly. Claude
66
- // Code accepts both, so we only narrow when we can confirm we're on Codex.
62
+ // (openai/codex issue #19385). Detect Codex via its `turn_id` extension
63
+ // And downgrade output accordingly. Claude Code accepts it, so we only
64
+ // Narrow when we can confirm we're on Codex.
67
65
  const isCodex = (event: HookEvent): boolean => event.turn_id !== undefined;
68
66
 
69
- const writeRewriteAllow = (event: HookEvent, command: string, _reason?: string): void => {
70
- // Codex's PreToolUse parser strict-rejects `updatedInput` (openai/codex
71
- // #18491 — parsed but unimplemented as of codex-cli 0.12x). On Codex we
72
- // Can't transparently rewrite, so pass the original command through
73
- // Silently. Until #18491 lands, RTK savings are Claude-only.
74
- if (isCodex(event)) {
75
- writeAllow();
76
- return;
77
- }
78
- // Claude Code: rewrite silently. We deliberately omit
79
- // `permissionDecisionReason` so the model's context isn't polluted with
80
- // "rtk rewrote your command" chatter every Bash call.
81
- const out = {
82
- continue: true,
83
- hookSpecificOutput: { hookEventName: event.hook_event_name, updatedInput: { command } },
84
- };
85
- process.stdout.write(`${JSON.stringify(out)}\n`);
86
- };
87
-
88
67
  interface WarnOutput {
89
68
  hookEventName: string;
90
69
  additionalContext?: string;
91
- updatedInput?: { command: string };
92
70
  }
93
71
 
94
72
  const writeWarn = (event: HookEvent, decision: Decision): void => {
95
73
  const eventName = event.hook_event_name;
96
74
  const reason = `[tripwire:${decision.rule}] ${decision.message}`;
97
75
  if (isCodex(event)) {
98
- // Codex rejects both `additionalContext` and `updatedInput` on
99
- // PreToolUse. Send only `systemMessage`; the rewrite (if any) is
100
- // Dropped and the original command runs.
76
+ // Codex rejects `additionalContext` on PreToolUse. Send only
77
+ // `systemMessage`.
101
78
  process.stdout.write(`${JSON.stringify({ continue: true, systemMessage: reason })}\n`);
102
79
  return;
103
80
  }
104
81
  const hookSpecificOutput: WarnOutput = { hookEventName: eventName, additionalContext: reason };
105
- if (decision.rewriteCommand !== undefined) {
106
- hookSpecificOutput.updatedInput = { command: decision.rewriteCommand };
107
- }
108
82
  process.stdout.write(`${JSON.stringify({ continue: true, hookSpecificOutput })}\n`);
109
83
  };
110
84
 
@@ -259,26 +233,11 @@ const decide = (event: HookEvent, config: Config = getDefaultConfig()): Decision
259
233
  return allow('no-rules');
260
234
  };
261
235
 
262
- const handleBashAllow = (event: HookEvent, decision: Decision, config: Config): void => {
263
- // After the gate passes (allow or warn), apply rtk command-rewrite. If
264
- // Rtk doesn't change the command, fall through to normal allow / warn.
265
- const rtk = runRtkRewrite(event, config.rtk ?? { enabled: false });
266
- const original = (event.tool_input as { command?: string } | undefined)?.command ?? '';
267
- const rewritten =
268
- rtk.updatedCommand !== undefined && rtk.updatedCommand !== original ? rtk.updatedCommand : null;
269
-
236
+ const handleBashAllow = (event: HookEvent, decision: Decision, _config: Config): void => {
270
237
  if (decision.kind === 'warn') {
271
- if (rewritten !== null) {
272
- writeWarn(event, { ...decision, rewriteCommand: rewritten });
273
- return;
274
- }
275
238
  writeWarn(event, decision);
276
239
  return;
277
240
  }
278
- if (rewritten !== null) {
279
- writeRewriteAllow(event, rewritten, rtk.reason);
280
- return;
281
- }
282
241
  writeAllow();
283
242
  };
284
243
 
package/src/lib/bash.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // Token (`__tripwire_cmd_sub__`) so safe-path checks fail safely.
15
15
  // - Glob expansion is not performed.
16
16
 
17
- import { parse, type ParseEntry } from 'shell-quote';
17
+ import { parse, quote, type ParseEntry } from 'shell-quote';
18
18
 
19
19
  interface Segment {
20
20
  readonly head: string; // First non-flag token, e.g. `rm`, `npm`
@@ -667,7 +667,7 @@ const extractExecCommands = (seg: Segment): string[] => {
667
667
  continue;
668
668
  }
669
669
  const root = spec.pickRoot(tokens, i);
670
- out.push(substitutePlaceholders(inner, spec, root).join(' '));
670
+ out.push(quote(substitutePlaceholders(inner, spec, root)));
671
671
  i = j;
672
672
  }
673
673
  return out;
@@ -894,7 +894,7 @@ const extractHeadRenamingCommands = (seg: Segment): string[] => {
894
894
  }
895
895
  }
896
896
  const start = skipHeadRenamingPrefix(seg.tokens);
897
- const inner = seg.tokens.slice(start).join(' ');
897
+ const inner = quote(seg.tokens.slice(start));
898
898
  return inner === '' ? [] : [inner];
899
899
  };
900
900
 
@@ -904,7 +904,10 @@ const extractEvalCommands = (seg: Segment): string[] => {
904
904
  if (!EVAL_HEADS.has(seg.head)) {
905
905
  return [];
906
906
  }
907
- const sub = seg.tokens.slice(1).join(' ');
907
+ if (seg.tokens.length === 2) {
908
+ return [seg.tokens[1]!];
909
+ }
910
+ const sub = quote(seg.tokens.slice(1));
908
911
  return sub === '' ? [] : [sub];
909
912
  };
910
913
 
@@ -995,11 +998,11 @@ const extractRtkCommands = (seg: Segment): string[] => {
995
998
  }
996
999
  break;
997
1000
  }
998
- const inner = seg.tokens.slice(j).join(' ');
1001
+ const inner = quote(seg.tokens.slice(j));
999
1002
  return inner === '' ? [] : [inner];
1000
1003
  }
1001
1004
  // Tool-proxy subcommand: the subcommand token is the real binary name.
1002
- const inner = seg.tokens.slice(subIdx).join(' ');
1005
+ const inner = quote(seg.tokens.slice(subIdx));
1003
1006
  return inner === '' ? [] : [inner];
1004
1007
  };
1005
1008
 
@@ -1074,7 +1077,7 @@ const extractPrefixWrapperCommands = (seg: Segment): string[] => {
1074
1077
  break;
1075
1078
  }
1076
1079
  i += spec.skipPositionals;
1077
- const inner = seg.tokens.slice(i).join(' ');
1080
+ const inner = quote(seg.tokens.slice(i));
1078
1081
  return inner === '' ? [] : [inner];
1079
1082
  };
1080
1083
 
@@ -1090,9 +1093,41 @@ const SEGMENT_EXTRACTORS: readonly ((seg: Segment) => string[])[] = [
1090
1093
  extractPrefixWrapperCommands,
1091
1094
  ];
1092
1095
 
1096
+ const normalizeTopLevelNewlines = (cmd: string): string => {
1097
+ let out = '';
1098
+ let inSingle = false;
1099
+ let inDouble = false;
1100
+ let escaped = false;
1101
+
1102
+ for (const ch of cmd) {
1103
+ if (escaped) {
1104
+ out += ch;
1105
+ escaped = false;
1106
+ continue;
1107
+ }
1108
+ if (ch === '\\') {
1109
+ out += ch;
1110
+ escaped = true;
1111
+ continue;
1112
+ }
1113
+ if (ch === "'" && !inDouble) {
1114
+ inSingle = !inSingle;
1115
+ out += ch;
1116
+ continue;
1117
+ }
1118
+ if (ch === '"' && !inSingle) {
1119
+ inDouble = !inDouble;
1120
+ out += ch;
1121
+ continue;
1122
+ }
1123
+ out += ch === '\n' && !inSingle && !inDouble ? ' ; ' : ch;
1124
+ }
1125
+ return out;
1126
+ };
1127
+
1093
1128
  const parseCommand = (cmd: string): Segment[] => {
1094
1129
  let entries: ParseEntry[];
1095
- const cmdForParsing = maskLiteralHeredocBodies(cmd);
1130
+ const cmdForParsing = normalizeTopLevelNewlines(maskLiteralHeredocBodies(cmd));
1096
1131
  try {
1097
1132
  entries = parse(cmdForParsing, PRESERVE_ENV);
1098
1133
  } catch {
@@ -1214,7 +1249,7 @@ const safeScopesSummary = (
1214
1249
  '.bundle',
1215
1250
  '.cargo-target',
1216
1251
  ],
1217
- 'tmp / state': ['tmp', '.tmp', '.state', '/tmp', '/var/tmp', '/var/folders'],
1252
+ 'tmp / state': ['tmp', '.tmp', '.state', ...SAFE_ABSOLUTE],
1218
1253
  iac: ['.terraform'],
1219
1254
  'bundler dev': ['.yarn/cache', '.yarn/install-state.gz', '.pnpm-store', '.bun'],
1220
1255
  };
package/src/lib/config.ts CHANGED
@@ -17,11 +17,6 @@ const BlockRuleSchema = Schema.Struct({
17
17
  ),
18
18
  });
19
19
 
20
- const RtkConfigSchema = Schema.Struct({
21
- enabled: Schema.optional(Schema.Boolean),
22
- path: Schema.optional(Schema.String),
23
- });
24
-
25
20
  const GitConfigSchema = Schema.Struct({
26
21
  protectedBranches: Schema.optional(Schema.Array(Schema.String)),
27
22
  enforceConventionalCommits: Schema.optional(Schema.Boolean),
@@ -33,7 +28,6 @@ const SafePathsConfigSchema = Schema.Struct({
33
28
  });
34
29
 
35
30
  const ConfigSchema = Schema.Struct({
36
- rtk: Schema.optional(RtkConfigSchema),
37
31
  git: Schema.optional(GitConfigSchema),
38
32
  safePaths: Schema.optional(SafePathsConfigSchema),
39
33
  blockedCommands: Schema.optional(Schema.Array(BlockRuleSchema)),
@@ -62,7 +56,6 @@ const decodeConfig = (unknown: unknown): Effect.Effect<Config, Error> =>
62
56
  Schema.decodeUnknownEffect(ConfigSchema)(unknown);
63
57
 
64
58
  const getDefaultConfig = (): Config => ({
65
- rtk: { enabled: false },
66
59
  git: {
67
60
  protectedBranches: ['main', 'master', 'develop', 'production', 'release'],
68
61
  enforceConventionalCommits: true,
@@ -73,7 +66,6 @@ const getDefaultConfig = (): Config => ({
73
66
  });
74
67
 
75
68
  const mergeWithDefaults = (partial: Config): Config => ({
76
- rtk: partial.rtk ?? getDefaultConfig().rtk,
77
69
  git: partial.git ?? getDefaultConfig().git,
78
70
  safePaths: partial.safePaths ?? getDefaultConfig().safePaths,
79
71
  blockedCommands: partial.blockedCommands ?? getDefaultConfig().blockedCommands,
@@ -102,7 +94,6 @@ export const loadConfig = (): Effect.Effect<Config> =>
102
94
  );
103
95
 
104
96
  export type BlockRule = typeof BlockRuleSchema.Type;
105
- export type RtkConfig = typeof RtkConfigSchema.Type;
106
97
  export type GitConfig = typeof GitConfigSchema.Type;
107
98
  export type SafePathsConfig = typeof SafePathsConfigSchema.Type;
108
99
  export type Config = typeof ConfigSchema.Type;
@@ -6,16 +6,12 @@
6
6
  // Deny — block the tool call (PreToolUse) or refuse to surface its
7
7
  // Output to the model (PostToolUse)
8
8
  //
9
- // Rewrites are a separate axis: a rule may attach a rewriteCommand even on
10
- // `allow` or `warn` to substitute a different command before execution.
11
-
12
9
  type DecisionKind = 'allow' | 'warn' | 'ask' | 'deny';
13
10
 
14
11
  interface Decision {
15
12
  readonly kind: DecisionKind;
16
13
  readonly rule: string;
17
14
  readonly message: string;
18
- readonly rewriteCommand?: string;
19
15
  }
20
16
 
21
17
  const order: Record<DecisionKind, number> = { allow: 0, warn: 1, ask: 2, deny: 3 };
@@ -25,22 +21,13 @@ const warn = (rule: string, message: string): Decision => ({ kind: 'warn', rule,
25
21
  const ask = (rule: string, message: string): Decision => ({ kind: 'ask', rule, message });
26
22
  const deny = (rule: string, message: string): Decision => ({ kind: 'deny', rule, message });
27
23
 
28
- // Merge picks the most restrictive kind. Rewrite commands are preserved
29
- // From the most restrictive decision that carries one, falling back to the
30
- // First non-empty rewrite if no restrictive rule has one.
24
+ // Merge picks the most restrictive kind.
31
25
  const merge = (decisions: readonly Decision[]): Decision => {
32
26
  let best: Decision = allow('none');
33
- let rewriteCommand: string | undefined;
34
27
  for (const d of decisions) {
35
28
  if (order[d.kind] > order[best.kind]) {
36
29
  best = d;
37
30
  }
38
- if (rewriteCommand === undefined && d.rewriteCommand !== undefined) {
39
- rewriteCommand = d.rewriteCommand;
40
- }
41
- }
42
- if (rewriteCommand !== undefined && best.rewriteCommand === undefined) {
43
- return { ...best, rewriteCommand };
44
31
  }
45
32
  return best;
46
33
  };
package/src/lib/rtk.ts DELETED
@@ -1,96 +0,0 @@
1
- // Wrap the `rtk hook claude` subprocess. After tripwire's gate passes on a
2
- // Bash tool call, we hand the original event to rtk to apply its
3
- // Command-rewrite logic (token-saver). If rtk returns an updatedInput, we
4
- // Merge that into our hook response.
5
-
6
- import { spawnSync } from 'node:child_process';
7
-
8
- import type { RtkConfig } from './config';
9
-
10
- interface RtkOutput {
11
- readonly updatedCommand?: string;
12
- readonly reason?: string;
13
- }
14
-
15
- const findRtkBin = (config: RtkConfig): string | null => {
16
- // If config specifies a path, try it first
17
- if (config.path !== undefined) {
18
- return config.path;
19
- }
20
-
21
- // Try common locations
22
- const home = process.env['HOME'] ?? '';
23
- const commonPaths = ['/opt/homebrew/bin/rtk', '/usr/local/bin/rtk', `${home}/.local/bin/rtk`];
24
-
25
- for (const path of commonPaths) {
26
- try {
27
- spawnSync('test', ['-x', path], { stdio: 'ignore' });
28
- return path;
29
- } catch {
30
- continue;
31
- }
32
- }
33
-
34
- // Try searching PATH
35
- try {
36
- const whichResult = spawnSync('which', ['rtk'], { stdio: 'pipe' });
37
- if (whichResult.status === 0) {
38
- const stdout = whichResult.stdout as string | Buffer | null;
39
- if (stdout !== null) {
40
- const path = String(stdout).trim();
41
- if (path.length > 0) {
42
- return path;
43
- }
44
- }
45
- }
46
- } catch {
47
- // Ignore errors
48
- }
49
-
50
- return null;
51
- };
52
-
53
- const runRtkRewrite = (event: unknown, config: RtkConfig, timeoutMs = 2000): RtkOutput => {
54
- // If rtk is disabled, skip it
55
- if (!config.enabled) {
56
- return {};
57
- }
58
-
59
- const rtkBin = findRtkBin(config);
60
- if (rtkBin === null) {
61
- // Rtk not found, silently continue
62
- return {};
63
- }
64
-
65
- const payload = JSON.stringify(event);
66
- const result = spawnSync(rtkBin, ['hook', 'claude'], {
67
- input: payload,
68
- encoding: 'utf8',
69
- timeout: timeoutMs,
70
- maxBuffer: 1024 * 1024,
71
- });
72
- if (result.error !== undefined || typeof result.stdout !== 'string') {
73
- return {};
74
- }
75
- try {
76
- const parsed = JSON.parse(result.stdout) as {
77
- hookSpecificOutput?: {
78
- permissionDecisionReason?: string;
79
- updatedInput?: { command?: string };
80
- };
81
- };
82
- const cmd = parsed.hookSpecificOutput?.updatedInput?.command;
83
- const reason = parsed.hookSpecificOutput?.permissionDecisionReason;
84
- if (typeof cmd !== 'string') {
85
- return {};
86
- }
87
- if (typeof reason === 'string') {
88
- return { updatedCommand: cmd, reason };
89
- }
90
- return { updatedCommand: cmd };
91
- } catch {
92
- return {};
93
- }
94
- };
95
-
96
- export { runRtkRewrite };