@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.
- package/README.md +25 -7
- package/dist/tripwire-cli.js +9 -4
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +49 -49
- package/dist/tripwire.js.jsc +0 -0
- package/package.json +1 -1
- package/src/cli.ts +190 -98
- package/src/dispatch.ts +34 -10
- package/src/index.ts +2 -0
- package/src/lib/config.ts +6 -2
- package/src/lib/install.ts +238 -0
- package/src/rules/config-custom.ts +120 -11
|
@@ -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[],
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
44
|
-
|
|
45
|
-
?
|
|
46
|
-
:
|
|
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 };
|