@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.
- package/dist/tripwire-cli.js +1 -1
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +63 -63
- package/dist/tripwire.js.jsc +0 -0
- package/package.json +1 -1
- package/src/dispatch.ts +41 -51
- package/src/index.ts +2 -2
- package/src/lib/bash.ts +44 -9
- package/src/lib/config.ts +42 -25
- package/src/lib/decision.ts +1 -14
- package/src/lib/grep-sanitize.ts +0 -254
- package/src/lib/rtk.ts +0 -112
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:
|
|
@@ -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 {
|
|
23
|
-
|
|
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)
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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
|
|
99
|
-
//
|
|
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,
|
|
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
|
|
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)
|
|
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
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
+
const result: ConfigLoad = { ok: true, config: mergeWithDefaults(config) };
|
|
100
|
+
return result;
|
|
94
101
|
}).pipe(
|
|
95
102
|
Effect.timeout(1000),
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 };
|
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
|
};
|