@seanmozeik/tripwire 0.2.0 → 0.4.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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanmozeik/tripwire",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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
@@ -19,7 +19,7 @@ import { BunRuntime } from '@effect/platform-bun';
19
19
  import { Cause, Effect, Exit, Schema } from 'effect';
20
20
 
21
21
  import { parseCommand } from './lib/bash';
22
- import { loadConfig, type Config } from './lib/config';
22
+ import { getDefaultConfig, loadConfig, mergeWithDefaults, type Config } from './lib/config';
23
23
  import { type Decision, allow, merge } from './lib/decision';
24
24
  import {
25
25
  type BashInput,
@@ -48,9 +48,6 @@ import { pathProtect } from './rules/path-protect';
48
48
  import { postSecretScrub } from './rules/post-secret-scrub';
49
49
  import { readProtect } from './rules/read-protect';
50
50
 
51
- const RULE_TIMEOUT_MS = 250;
52
- const POST_RULE_TIMEOUT_MS = 5000; // Betterleaks subprocess can take longer
53
-
54
51
  const readStdin = async (): Promise<string> => {
55
52
  const chunks: Buffer[] = [];
56
53
  for await (const chunk of process.stdin) {
@@ -243,6 +240,25 @@ const runRules = (rules: readonly Rule[], timeoutMs: number): Effect.Effect<Deci
243
240
  return merge(decisions);
244
241
  });
245
242
 
243
+ const runRulesSync = (rules: readonly Rule[]): Decision => {
244
+ if (rules.length === 0) {
245
+ return allow('no-rules');
246
+ }
247
+ return merge(rules.map((r) => r.fn()));
248
+ };
249
+
250
+ const decide = (event: HookEvent, config: Config = getDefaultConfig()): Decision => {
251
+ const mergedConfig = mergeWithDefaults(config);
252
+ const tool = normalizeToolName(event.tool_name ?? '');
253
+ if (event.hook_event_name === 'PreToolUse') {
254
+ return runRulesSync(collectPreToolUseRules(tool, event.tool_input, mergedConfig));
255
+ }
256
+ if (event.hook_event_name === 'PostToolUse') {
257
+ return runRulesSync(collectPostToolUseRules(tool, event.tool_response));
258
+ }
259
+ return allow('no-rules');
260
+ };
261
+
246
262
  const handleBashAllow = (event: HookEvent, decision: Decision, config: Config): void => {
247
263
  // After the gate passes (allow or warn), apply rtk command-rewrite. If
248
264
  // Rtk doesn't change the command, fall through to normal allow / warn.
@@ -302,11 +318,9 @@ const program = Effect.gen(function* () {
302
318
  return;
303
319
  }
304
320
  const event = decodeExit.value;
305
- const tool = normalizeToolName(event.tool_name ?? '');
306
321
 
307
322
  if (event.hook_event_name === 'PreToolUse') {
308
- const rules = collectPreToolUseRules(tool, event.tool_input, config);
309
- const decision = yield* runRules(rules, RULE_TIMEOUT_MS);
323
+ const decision = decide(event, config);
310
324
  if (decision.kind === 'deny' || decision.kind === 'ask') {
311
325
  writePreToolGate(event.hook_event_name, decision);
312
326
  return;
@@ -316,8 +330,7 @@ const program = Effect.gen(function* () {
316
330
  }
317
331
 
318
332
  if (event.hook_event_name === 'PostToolUse') {
319
- const rules = collectPostToolUseRules(tool, event.tool_response);
320
- const decision = yield* runRules(rules, POST_RULE_TIMEOUT_MS);
333
+ const decision = decide(event, config);
321
334
  if (decision.kind === 'deny') {
322
335
  writePostToolBlock(decision);
323
336
  return;
@@ -337,4 +350,15 @@ const handled = program.pipe(
337
350
  }),
338
351
  );
339
352
 
340
- BunRuntime.runMain(handled);
353
+ if (import.meta.main) {
354
+ BunRuntime.runMain(handled);
355
+ }
356
+
357
+ export {
358
+ collectPostToolUseRules,
359
+ collectPreToolUseRules,
360
+ decide,
361
+ normalizeToolName,
362
+ runRules,
363
+ runRulesSync,
364
+ };
package/src/index.ts CHANGED
@@ -2,3 +2,5 @@ export type { Decision } from './lib/decision.ts';
2
2
  export type { HookEvent } from './lib/event.ts';
3
3
  export type { Config } from './lib/config.ts';
4
4
  export { allow, deny, ask, warn } from './lib/decision.ts';
5
+ export { decide } from './dispatch.ts';
6
+ export { getDefaultConfig, loadConfig, mergeWithDefaults } from './lib/config.ts';
package/src/lib/config.ts CHANGED
@@ -11,6 +11,10 @@ const BlockRuleSchema = Schema.Struct({
11
11
  pattern: Schema.String,
12
12
  message: Schema.String,
13
13
  action: Schema.optional(Schema.Union([Schema.Literal('deny'), Schema.Literal('ask')])),
14
+ requiresFlags: Schema.optional(Schema.Array(Schema.String)),
15
+ forbidsFlagValues: Schema.optional(
16
+ Schema.Array(Schema.Struct({ flag: Schema.String, values: Schema.Array(Schema.String) })),
17
+ ),
14
18
  });
15
19
 
16
20
  const RtkConfigSchema = Schema.Struct({
@@ -103,4 +107,4 @@ export type GitConfig = typeof GitConfigSchema.Type;
103
107
  export type SafePathsConfig = typeof SafePathsConfigSchema.Type;
104
108
  export type Config = typeof ConfigSchema.Type;
105
109
 
106
- export { CONFIG_PATH, ConfigSchema };
110
+ export { CONFIG_PATH, ConfigSchema, getDefaultConfig, mergeWithDefaults };
@@ -1,51 +1,160 @@
1
1
  // Config-based custom blocking/allowing rules.
2
2
  // Uses shell parsing utilities to match command patterns from config.
3
3
 
4
- import { parseCommand, type Segment } from '../lib/bash';
4
+ import { hasBypass, parseCommand, type Segment } from '../lib/bash';
5
5
  import type { BlockRule } from '../lib/config';
6
6
  import { type Decision, allow, deny, ask } from '../lib/decision';
7
7
 
8
+ const BYPASS_HELP = 'If this is intentional, append ` # tripwire-allow: <reason>` to the command.';
9
+
10
+ const ALIASES: ReadonlyMap<string, string> = new Map([
11
+ ['add', 'create'],
12
+ ['new', 'create'],
13
+ ['edit', 'update'],
14
+ ['set', 'update'],
15
+ ['rm', 'delete'],
16
+ ['del', 'delete'],
17
+ ['remove', 'delete'],
18
+ ]);
19
+
20
+ const canonical = (token: string): string => ALIASES.get(token) ?? token;
21
+
22
+ // Strip directory prefix so an absolute or homebrew-style path matches its
23
+ // Basename — `/opt/homebrew/bin/gog` and `gog` are the same command for
24
+ // Policy purposes. shim's typed dispatcher resolves CLIs to absolute paths,
25
+ // So matchers that compare `seg.head` literally would otherwise miss every
26
+ // Rule for those invocations.
27
+ const basename = (token: string): string => {
28
+ const idx = token.lastIndexOf('/');
29
+ return idx === -1 ? token : token.slice(idx + 1);
30
+ };
31
+
32
+ const flagPresent = (tokens: readonly string[], flag: string): boolean =>
33
+ tokens.some((t) => t === flag || t.startsWith(`${flag}=`));
34
+
35
+ const flagValue = (tokens: readonly string[], flag: string): string | null => {
36
+ for (let i = 0; i < tokens.length; i++) {
37
+ const t = tokens[i]!;
38
+ if (t === flag) {
39
+ return tokens[i + 1] ?? '';
40
+ }
41
+ if (t.startsWith(`${flag}=`)) {
42
+ return t.slice(flag.length + 1);
43
+ }
44
+ }
45
+ return null;
46
+ };
47
+
48
+ const subcommandTokens = (seg: Segment): string[] => {
49
+ const out: string[] = [];
50
+ const tokens = seg.tokens.slice(1);
51
+ for (let i = 0; i < tokens.length; i++) {
52
+ const t = tokens[i]!;
53
+ if (t.startsWith('-')) {
54
+ // Without per-CLI flag metadata, we conservatively treat
55
+ // `--flag value` / `-f value` as one option pair and `--flag=value`
56
+ // As one token. This keeps global selectors like `--account X`
57
+ // Out of the subcommand path, at the cost of not distinguishing
58
+ // Boolean flags that precede positional args.
59
+ if (!t.includes('=') && tokens[i + 1] !== undefined && !tokens[i + 1]!.startsWith('-')) {
60
+ i++;
61
+ }
62
+ continue;
63
+ }
64
+ out.push(t);
65
+ }
66
+ return out;
67
+ };
68
+
8
69
  // Match a pattern against parsed segments using shell parsing.
9
70
  // This is more powerful than simple regex because it uses the same
10
71
  // Parsing logic as the rest of tripwire.
11
- const matchPattern = (segments: readonly Segment[], pattern: string): boolean => {
72
+ const matchPattern = (segments: readonly Segment[], rule: BlockRule): boolean => {
73
+ const pattern = rule.pattern;
12
74
  const patternSegs = parseCommand(pattern);
13
75
  if (patternSegs.length === 0) {
14
76
  return false;
15
77
  }
16
78
 
17
- const patternHead = patternSegs[0]!.head;
79
+ const patternTokens = patternSegs[0]!.tokens;
80
+ const patternHead = patternTokens[0];
81
+ if (patternHead === undefined) {
82
+ return false;
83
+ }
84
+ const patternSubcommands = patternTokens.slice(1);
18
85
 
19
- // Simple head match for now - can be extended to match flags, args, etc.
20
86
  for (const seg of segments) {
21
- if (seg.head === patternHead) {
87
+ if (basename(seg.head) !== basename(patternHead)) {
88
+ continue;
89
+ }
90
+
91
+ if (patternSubcommands.length > 0) {
92
+ const actualSubcommands = subcommandTokens(seg);
93
+ const pathMatches = patternSubcommands.every(
94
+ (p, i) =>
95
+ actualSubcommands[i] !== undefined && canonical(actualSubcommands[i]) === canonical(p),
96
+ );
97
+ if (!pathMatches) {
98
+ continue;
99
+ }
100
+ }
101
+
102
+ if ((rule.requiresFlags ?? []).some((flag) => !flagPresent(seg.tokens, flag))) {
103
+ continue;
104
+ }
105
+
106
+ const valueChecks = rule.forbidsFlagValues ?? [];
107
+ const valuesMatch = valueChecks.every((check) => {
108
+ const value = flagValue(seg.tokens, check.flag);
109
+ return value !== null && check.values.includes(value);
110
+ });
111
+ if (!valuesMatch) {
112
+ continue;
113
+ }
114
+
115
+ if (
116
+ patternSubcommands.length === 0 &&
117
+ rule.requiresFlags === undefined &&
118
+ rule.forbidsFlagValues === undefined
119
+ ) {
22
120
  return true;
23
121
  }
122
+
123
+ return true;
24
124
  }
25
125
  return false;
26
126
  };
27
127
 
28
128
  export const configCustom = (
29
129
  segments: readonly Segment[],
30
- _cmd: string,
130
+ cmd: string,
31
131
  blockedCommands: readonly BlockRule[],
32
132
  allowedCommands: readonly BlockRule[],
33
133
  ): Decision => {
134
+ if (hasBypass(cmd)) {
135
+ return allow('config-custom');
136
+ }
137
+
34
138
  // Check allowed first (overrides blocks)
35
139
  for (const allowRule of allowedCommands) {
36
- if (matchPattern(segments, allowRule.pattern)) {
140
+ if (matchPattern(segments, allowRule)) {
37
141
  return allow('config-custom');
38
142
  }
39
143
  }
40
144
 
41
145
  // Then check blocked
42
146
  for (const blockRule of blockedCommands) {
43
- if (matchPattern(segments, blockRule.pattern)) {
44
- return blockRule.action === 'deny'
45
- ? deny('config-custom', blockRule.message)
46
- : ask('config-custom', blockRule.message);
147
+ if (matchPattern(segments, blockRule)) {
148
+ const message = blockRule.message.includes('tripwire-allow')
149
+ ? blockRule.message
150
+ : `${blockRule.message} ${BYPASS_HELP}`;
151
+ return blockRule.action === 'ask'
152
+ ? ask('config-custom', message)
153
+ : deny('config-custom', message);
47
154
  }
48
155
  }
49
156
 
50
157
  return allow('config-custom');
51
158
  };
159
+
160
+ export { matchPattern };