@seanmozeik/tripwire 0.1.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.
@@ -0,0 +1,238 @@
1
+ // Config installation module for tripwire hooks.
2
+ // Parses and upserts hook configurations for Claude Code, Codex, and pi-guardrails.
3
+
4
+ import { homedir } from 'node:os';
5
+
6
+ import { file } from 'bun';
7
+
8
+ interface ClaudeConfig {
9
+ hooks?: {
10
+ PreToolUse?: { hooks: { type: string; command: string }[] }[];
11
+ PostToolUse?: { hooks: { type: string; command: string }[] }[];
12
+ };
13
+ }
14
+
15
+ interface PiConfig {
16
+ hooks?: {
17
+ PreToolUse?: { hooks: { type: string; command: string }[] }[];
18
+ PostToolUse?: { hooks: { type: string; command: string }[] }[];
19
+ };
20
+ }
21
+
22
+ interface CodexHooksConfig {
23
+ hooks?: {
24
+ PreToolUse?: { hooks: { type: string; command: string; timeout?: number }[] }[];
25
+ PostToolUse?: { hooks: { type: string; command: string; timeout?: number }[] }[];
26
+ };
27
+ }
28
+
29
+ const TRIPWIRE_HOOK = 'tripwire-hook';
30
+
31
+ const addHookIfMissing = (
32
+ hooks: { hooks: { type: string; command: string; timeout?: number }[] }[] | undefined,
33
+ ): [{ hooks: { type: string; command: string; timeout?: number }[] }[], boolean] => {
34
+ if (!hooks) {
35
+ const newHooks: { hooks: { type: string; command: string; timeout?: number }[] }[] = [
36
+ { hooks: [{ type: 'command', command: TRIPWIRE_HOOK }] },
37
+ ];
38
+ return [newHooks, false];
39
+ }
40
+
41
+ let needsNormalization = false;
42
+
43
+ const normalizedHooks = hooks.map((h) => ({
44
+ hooks: h.hooks.map((hook) => {
45
+ if (hook.command === TRIPWIRE_HOOK || hook.command.endsWith('/tripwire-hook')) {
46
+ if (hook.command !== TRIPWIRE_HOOK) {
47
+ needsNormalization = true;
48
+ return { ...hook, command: TRIPWIRE_HOOK };
49
+ }
50
+ return hook;
51
+ }
52
+ return hook;
53
+ }),
54
+ }));
55
+
56
+ const hasTripwire = normalizedHooks.some((h) =>
57
+ h.hooks.some((hook) => hook.command === TRIPWIRE_HOOK),
58
+ );
59
+
60
+ if (hasTripwire) {
61
+ return [normalizedHooks, !needsNormalization];
62
+ }
63
+
64
+ const newHooks: { hooks: { type: string; command: string; timeout?: number }[] }[] = [
65
+ ...normalizedHooks,
66
+ { hooks: [{ type: 'command', command: TRIPWIRE_HOOK }] },
67
+ ];
68
+ return [newHooks, false];
69
+ };
70
+
71
+ export const installClaude = async (): Promise<{ success: boolean; message: string }> => {
72
+ const configPath = `${homedir()}/.claude/settings.json`;
73
+ const configFile = file(configPath);
74
+
75
+ try {
76
+ const raw = await configFile.text();
77
+ const config = JSON.parse(raw) as ClaudeConfig;
78
+
79
+ config.hooks ??= {};
80
+ const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
81
+ const [postToolUse, postSkipped] = addHookIfMissing(config.hooks.PostToolUse);
82
+
83
+ config.hooks.PreToolUse = preToolUse;
84
+ config.hooks.PostToolUse = postToolUse;
85
+
86
+ if (preSkipped && postSkipped) {
87
+ return { success: true, message: `Already configured: ${configPath}` };
88
+ }
89
+
90
+ await configFile.write(`${JSON.stringify(config, null, 2)}\n`);
91
+
92
+ return { success: true, message: `Updated ${configPath}` };
93
+ } catch (error) {
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ if (message.includes('No such file')) {
96
+ return { success: false, message: `Config file not found: ${configPath}` };
97
+ }
98
+ return { success: false, message: `Failed to update Claude config: ${message}` };
99
+ }
100
+ };
101
+
102
+ export const installPi = async (): Promise<{ success: boolean; message: string }> => {
103
+ const configPath = `${homedir()}/.pi/agent/settings.json`;
104
+ const configFile = file(configPath);
105
+
106
+ try {
107
+ const raw = await configFile.text();
108
+ const config = JSON.parse(raw) as PiConfig;
109
+
110
+ config.hooks ??= {};
111
+ const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
112
+ const [postToolUse, postSkipped] = addHookIfMissing(config.hooks.PostToolUse);
113
+
114
+ config.hooks.PreToolUse = preToolUse;
115
+ config.hooks.PostToolUse = postToolUse;
116
+
117
+ if (preSkipped && postSkipped) {
118
+ return { success: true, message: `Already configured: ${configPath}` };
119
+ }
120
+
121
+ await configFile.write(`${JSON.stringify(config, null, 2)}\n`);
122
+
123
+ return { success: true, message: `Updated ${configPath}` };
124
+ } catch (error) {
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ if (message.includes('No such file')) {
127
+ return { success: false, message: `Config file not found: ${configPath}` };
128
+ }
129
+ return { success: false, message: `Failed to update pi config: ${message}` };
130
+ }
131
+ };
132
+
133
+ export const installCodex = async (): Promise<{ success: boolean; message: string }> => {
134
+ const configTomlPath = `${homedir()}/.codex/config.toml`;
135
+ const hooksJsonPath = `${homedir()}/.codex/hooks.json`;
136
+ const hooksJsonFile = file(hooksJsonPath);
137
+ const configTomlFile = file(configTomlPath);
138
+
139
+ let hooksUpdated = false;
140
+ let tomlUpdated = false;
141
+
142
+ // First, update hooks.json
143
+ try {
144
+ const raw = await hooksJsonFile.text();
145
+ const config = JSON.parse(raw) as CodexHooksConfig;
146
+
147
+ config.hooks ??= {};
148
+ const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
149
+ const [postToolUse, postSkipped] = addHookIfMissing(config.hooks.PostToolUse);
150
+
151
+ config.hooks.PreToolUse = preToolUse;
152
+ config.hooks.PostToolUse = postToolUse;
153
+
154
+ if (!preSkipped || !postSkipped) {
155
+ hooksUpdated = true;
156
+ }
157
+
158
+ // Add timeout to tripwire-hook if not present
159
+ const addTimeout = (
160
+ hooks: { hooks: { type: string; command: string; timeout?: number }[] }[] | undefined,
161
+ ): { hooks: { type: string; command: string; timeout?: number }[] }[] => {
162
+ return (
163
+ hooks?.map((h) => ({
164
+ hooks: h.hooks.map((hook) => {
165
+ if (hook.command === TRIPWIRE_HOOK && hook.timeout === undefined) {
166
+ return { ...hook, timeout: 10 };
167
+ }
168
+ return hook;
169
+ }),
170
+ })) ?? []
171
+ );
172
+ };
173
+
174
+ config.hooks.PreToolUse = addTimeout(config.hooks.PreToolUse);
175
+ config.hooks.PostToolUse = addTimeout(config.hooks.PostToolUse);
176
+
177
+ if (hooksUpdated) {
178
+ await hooksJsonFile.write(`${JSON.stringify(config, null, 2)}\n`);
179
+ }
180
+ } catch (error) {
181
+ const message = error instanceof Error ? error.message : String(error);
182
+ if (message.includes('No such file')) {
183
+ return { success: false, message: `Config file not found: ${hooksJsonPath}` };
184
+ }
185
+ return { success: false, message: `Failed to update Codex hooks.json: ${message}` };
186
+ }
187
+
188
+ // Then, update config.toml to enable hooks
189
+ try {
190
+ const raw = await configTomlFile.text();
191
+ let toml = raw;
192
+
193
+ // Enable hooks in [features] section
194
+ if (toml.includes('hooks = true')) {
195
+ // Already enabled, nothing to do
196
+ } else {
197
+ tomlUpdated = true;
198
+ if (toml.includes('[features]')) {
199
+ // Find [features] section and add hooks = true
200
+ const featuresIndex = toml.indexOf('[features]');
201
+ const nextSectionIndex = toml.indexOf('\n[', featuresIndex + 1);
202
+ if (nextSectionIndex === -1) {
203
+ toml += '\nhooks = true';
204
+ } else {
205
+ toml = `${toml.slice(0, nextSectionIndex)}\nhooks = true${toml.slice(nextSectionIndex)}`;
206
+ }
207
+ } else {
208
+ toml += '\n[features]\nhooks = true';
209
+ }
210
+ }
211
+
212
+ if (tomlUpdated) {
213
+ await configTomlFile.write(toml);
214
+ }
215
+ } catch (error) {
216
+ const message = error instanceof Error ? error.message : String(error);
217
+ if (message.includes('No such file')) {
218
+ return { success: false, message: `Config file not found: ${configTomlPath}` };
219
+ }
220
+ return { success: false, message: `Failed to update Codex config.toml: ${message}` };
221
+ }
222
+
223
+ if (!hooksUpdated && !tomlUpdated) {
224
+ return { success: true, message: `Already configured: ${configTomlPath} and ${hooksJsonPath}` };
225
+ }
226
+
227
+ return { success: true, message: `Updated ${configTomlPath} and ${hooksJsonPath}` };
228
+ };
229
+
230
+ export const installAll = async (): Promise<
231
+ { target: string; success: boolean; message: string }[]
232
+ > => {
233
+ return [
234
+ { target: 'claude', ...(await installClaude()) },
235
+ { target: 'codex', ...(await installCodex()) },
236
+ { target: 'pi', ...(await installPi()) },
237
+ ];
238
+ };
@@ -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 };