@seanmozeik/tripwire 0.6.2 → 0.6.4

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.2",
3
+ "version": "0.6.4",
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:
@@ -19,8 +18,14 @@ import { BunRuntime } from '@effect/platform-bun';
19
18
  import { Cause, Effect, Exit, Schema } from 'effect';
20
19
 
21
20
  import { parseCommand } from './lib/bash';
22
- import { getDefaultConfig, loadConfig, mergeWithDefaults, type Config } from './lib/config';
23
- import { type Decision, allow, merge } from './lib/decision';
21
+ import {
22
+ CONFIG_PATH,
23
+ getDefaultConfig,
24
+ loadConfigResult,
25
+ mergeWithDefaults,
26
+ type Config,
27
+ } from './lib/config';
28
+ import { type Decision, allow, deny, merge } from './lib/decision';
24
29
  import {
25
30
  type BashInput,
26
31
  type EditInput,
@@ -34,7 +39,6 @@ import {
34
39
  isWriteInput,
35
40
  } from './lib/event.ts';
36
41
  import { logError } from './lib/log';
37
- import { runRtkRewrite } from './lib/rtk';
38
42
  import { bashDeny } from './rules/bash-deny';
39
43
  import { bashGit } from './rules/bash-git';
40
44
  import { bashNetworkInstall } from './rules/bash-network-install';
@@ -61,50 +65,26 @@ const writeAllow = (): void => {
61
65
  };
62
66
 
63
67
  // 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.
68
+ // (openai/codex issue #19385). Detect Codex via its `turn_id` extension
69
+ // And downgrade output accordingly. Claude Code accepts it, so we only
70
+ // Narrow when we can confirm we're on Codex.
67
71
  const isCodex = (event: HookEvent): boolean => event.turn_id !== undefined;
68
72
 
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
73
  interface WarnOutput {
89
74
  hookEventName: string;
90
75
  additionalContext?: string;
91
- updatedInput?: { command: string };
92
76
  }
93
77
 
94
78
  const writeWarn = (event: HookEvent, decision: Decision): void => {
95
79
  const eventName = event.hook_event_name;
96
80
  const reason = `[tripwire:${decision.rule}] ${decision.message}`;
97
81
  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.
82
+ // Codex rejects `additionalContext` on PreToolUse. Send only
83
+ // `systemMessage`.
101
84
  process.stdout.write(`${JSON.stringify({ continue: true, systemMessage: reason })}\n`);
102
85
  return;
103
86
  }
104
87
  const hookSpecificOutput: WarnOutput = { hookEventName: eventName, additionalContext: reason };
105
- if (decision.rewriteCommand !== undefined) {
106
- hookSpecificOutput.updatedInput = { command: decision.rewriteCommand };
107
- }
108
88
  process.stdout.write(`${JSON.stringify({ continue: true, hookSpecificOutput })}\n`);
109
89
  };
110
90
 
@@ -259,26 +239,11 @@ const decide = (event: HookEvent, config: Config = getDefaultConfig()): Decision
259
239
  return allow('no-rules');
260
240
  };
261
241
 
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
-
242
+ const handleBashAllow = (event: HookEvent, decision: Decision, _config: Config): void => {
270
243
  if (decision.kind === 'warn') {
271
- if (rewritten !== null) {
272
- writeWarn(event, { ...decision, rewriteCommand: rewritten });
273
- return;
274
- }
275
244
  writeWarn(event, decision);
276
245
  return;
277
246
  }
278
- if (rewritten !== null) {
279
- writeRewriteAllow(event, rewritten, rtk.reason);
280
- return;
281
- }
282
247
  writeAllow();
283
248
  };
284
249
 
@@ -296,8 +261,17 @@ const handleAllow = (event: HookEvent, decision: Decision, config: Config): void
296
261
  writeAllow();
297
262
  };
298
263
 
264
+ // A broken config (bad JSON / unknown field / decode failure) silently dropping
265
+ // All custom policy is the dangerous case this guards. Fail closed: deny the
266
+ // Pending PreToolUse call with the decode error inline so the agent halts and
267
+ // The user sees it, rather than running on bare defaults unannounced.
268
+ const configErrorMessage = (error: string): string =>
269
+ `tripwire config at ${CONFIG_PATH} failed to load, so ALL custom safety policy is ` +
270
+ `inactive. Failing closed until it is fixed. Fix the JSON, then this clears on the next ` +
271
+ `call (the shim daemon caches config at warm — restart it there). Error: ${error}`;
272
+
299
273
  const program = Effect.gen(function* () {
300
- const config = yield* loadConfig();
274
+ const configLoad = yield* loadConfigResult();
301
275
  const raw = yield* Effect.promise(readStdin);
302
276
 
303
277
  const parseExit = yield* Effect.exit(
@@ -319,6 +293,22 @@ const program = Effect.gen(function* () {
319
293
  }
320
294
  const event = decodeExit.value;
321
295
 
296
+ if (!configLoad.ok) {
297
+ if (event.hook_event_name === 'PreToolUse') {
298
+ writePreToolGate(
299
+ event.hook_event_name,
300
+ deny('config-error', configErrorMessage(configLoad.error)),
301
+ );
302
+ return;
303
+ }
304
+ // Config governs PreToolUse gating; PostToolUse secret-scrub is config-
305
+ // Independent and there is always an imminent next PreToolUse to surface the
306
+ // Deny, so don't block already-run output here.
307
+ writeAllow();
308
+ return;
309
+ }
310
+ const config = configLoad.config;
311
+
322
312
  if (event.hook_event_name === 'PreToolUse') {
323
313
  const decision = decide(event, config);
324
314
  if (decision.kind === 'deny' || decision.kind === 'ask') {
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type { Decision } from './lib/decision.ts';
2
2
  export type { HookEvent } from './lib/event.ts';
3
- export type { Config } from './lib/config.ts';
3
+ export type { Config, ConfigLoad } from './lib/config.ts';
4
4
  export { allow, deny, ask, warn } from './lib/decision.ts';
5
5
  export { decide } from './dispatch.ts';
6
- export { getDefaultConfig, loadConfig, mergeWithDefaults } from './lib/config.ts';
6
+ export { getDefaultConfig, loadConfig, loadConfigResult, mergeWithDefaults } from './lib/config.ts';
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
@@ -5,7 +5,7 @@
5
5
  import { accessSync, constants, readFileSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
 
8
- import { Effect, Schema } from 'effect';
8
+ import { Cause, Effect, Schema } from 'effect';
9
9
 
10
10
  const BlockRuleSchema = Schema.Struct({
11
11
  pattern: Schema.String,
@@ -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)),
@@ -42,27 +36,30 @@ const ConfigSchema = Schema.Struct({
42
36
 
43
37
  const CONFIG_PATH = `${homedir()}/.config/tripwire/config.json`;
44
38
 
45
- const configExists = (): Effect.Effect<boolean> =>
39
+ const configExists = (path: string): Effect.Effect<boolean> =>
46
40
  Effect.sync(() => {
47
41
  try {
48
- accessSync(CONFIG_PATH, constants.R_OK);
42
+ accessSync(path, constants.R_OK);
49
43
  return true;
50
44
  } catch {
51
45
  return false;
52
46
  }
53
47
  });
54
48
 
55
- const readConfigFile = (): Effect.Effect<string, Error> =>
56
- Effect.try({ try: () => readFileSync(CONFIG_PATH, 'utf8'), catch: (error) => error as Error });
49
+ const readConfigFile = (path: string): Effect.Effect<string, Error> =>
50
+ Effect.try({ try: () => readFileSync(path, 'utf8'), catch: (error) => error as Error });
57
51
 
58
52
  const parseConfigJson = (raw: string): Effect.Effect<unknown, Error> =>
59
53
  Effect.try({ try: () => JSON.parse(raw) as unknown, catch: (error) => error as Error });
60
54
 
55
+ // `onExcessProperty: 'error'` rejects unknown keys (the default 'ignore' would
56
+ // Silently strip them — a typo'd `blockedComands` would vanish unnoticed, the
57
+ // Same silent-policy-drop class this whole change exists to kill). A stray key
58
+ // Now fails loud, e.g. the `rtk` block that triggered MTA-137.
61
59
  const decodeConfig = (unknown: unknown): Effect.Effect<Config, Error> =>
62
- Schema.decodeUnknownEffect(ConfigSchema)(unknown);
60
+ Schema.decodeUnknownEffect(ConfigSchema)(unknown, { onExcessProperty: 'error' });
63
61
 
64
62
  const getDefaultConfig = (): Config => ({
65
- rtk: { enabled: false },
66
63
  git: {
67
64
  protectedBranches: ['main', 'master', 'develop', 'production', 'release'],
68
65
  enforceConventionalCommits: true,
@@ -73,38 +70,58 @@ const getDefaultConfig = (): Config => ({
73
70
  });
74
71
 
75
72
  const mergeWithDefaults = (partial: Config): Config => ({
76
- rtk: partial.rtk ?? getDefaultConfig().rtk,
77
73
  git: partial.git ?? getDefaultConfig().git,
78
74
  safePaths: partial.safePaths ?? getDefaultConfig().safePaths,
79
75
  blockedCommands: partial.blockedCommands ?? getDefaultConfig().blockedCommands,
80
76
  allowedCommands: partial.allowedCommands ?? getDefaultConfig().allowedCommands,
81
77
  });
82
78
 
83
- export const loadConfig = (): Effect.Effect<Config> =>
79
+ // A present-but-broken config (bad JSON, schema decode failure, timeout) must
80
+ // Never be papered over with defaults — that silently drops all custom safety
81
+ // Policy. `loadConfigResult` reports the failure as data so callers can fail
82
+ // Closed loudly (see `loadConfig` and the dispatcher). A *missing* file is the
83
+ // One legitimate defaults case.
84
+ type ConfigLoad =
85
+ | { readonly ok: true; readonly config: Config }
86
+ | { readonly ok: false; readonly error: string };
87
+
88
+ export const loadConfigResult = (path: string = CONFIG_PATH): Effect.Effect<ConfigLoad> =>
84
89
  Effect.gen(function* () {
85
- const exists = yield* configExists();
90
+ const exists = yield* configExists(path);
86
91
  if (!exists) {
87
- return getDefaultConfig();
92
+ const result: ConfigLoad = { ok: true, config: getDefaultConfig() };
93
+ return result;
88
94
  }
89
95
 
90
- const raw = yield* readConfigFile();
96
+ const raw = yield* readConfigFile(path);
91
97
  const parsed = yield* parseConfigJson(raw);
92
98
  const config = yield* decodeConfig(parsed);
93
- return mergeWithDefaults(config);
99
+ const result: ConfigLoad = { ok: true, config: mergeWithDefaults(config) };
100
+ return result;
94
101
  }).pipe(
95
102
  Effect.timeout(1000),
96
- // oxlint-disable-next-line promise/prefer-await-to-then
97
- Effect.catch(() => {
98
- // Log error but return defaults to never block the agent
99
- console.error('[tripwire] Config loading failed, using defaults');
100
- return Effect.succeed(getDefaultConfig());
103
+ Effect.catchCause((cause) => {
104
+ const result: ConfigLoad = { ok: false, error: Cause.pretty(cause) };
105
+ return Effect.succeed(result);
101
106
  }),
102
107
  );
103
108
 
109
+ // Loud loader for library consumers (e.g. the shim daemon) that expect a
110
+ // `Config`. A broken config dies rather than silently defaulting, so the
111
+ // Consumer fails closed visibly until the file is fixed.
112
+ export const loadConfig = (path: string = CONFIG_PATH): Effect.Effect<Config> =>
113
+ loadConfigResult(path).pipe(
114
+ Effect.flatMap((result) =>
115
+ result.ok
116
+ ? Effect.succeed(result.config)
117
+ : Effect.die(new Error(`[tripwire] config load failed (${path}): ${result.error}`)),
118
+ ),
119
+ );
120
+
104
121
  export type BlockRule = typeof BlockRuleSchema.Type;
105
- export type RtkConfig = typeof RtkConfigSchema.Type;
106
122
  export type GitConfig = typeof GitConfigSchema.Type;
107
123
  export type SafePathsConfig = typeof SafePathsConfigSchema.Type;
108
124
  export type Config = typeof ConfigSchema.Type;
109
125
 
126
+ export type { ConfigLoad };
110
127
  export { CONFIG_PATH, ConfigSchema, getDefaultConfig, mergeWithDefaults };
@@ -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
  };