@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.
- package/dist/tripwire-cli.js +1 -1
- 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/dispatch.ts +7 -48
- package/src/lib/bash.ts +44 -9
- package/src/lib/config.ts +0 -9
- package/src/lib/decision.ts +1 -14
- package/src/lib/rtk.ts +0 -96
package/dist/tripwire.js.jsc
CHANGED
|
Binary file
|
package/package.json
CHANGED
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),
|
|
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)
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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
|
|
99
|
-
//
|
|
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,
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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',
|
|
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;
|
package/src/lib/decision.ts
CHANGED
|
@@ -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.
|
|
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 };
|