@seanmozeik/tripwire 0.1.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 +186 -0
- package/dist/tripwire-cli.js +6 -0
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +90 -0
- package/dist/tripwire.js.jsc +0 -0
- package/package.json +49 -0
- package/src/cli.ts +148 -0
- package/src/dispatch.ts +340 -0
- package/src/index.ts +4 -0
- package/src/lib/bash.ts +428 -0
- package/src/lib/config.ts +106 -0
- package/src/lib/decision.ts +49 -0
- package/src/lib/diff.ts +26 -0
- package/src/lib/event.ts +106 -0
- package/src/lib/log.ts +23 -0
- package/src/lib/rtk.ts +96 -0
- package/src/lib/secrets.ts +120 -0
- package/src/rules/bash-deny.ts +346 -0
- package/src/rules/bash-git.ts +592 -0
- package/src/rules/bash-network-install.ts +72 -0
- package/src/rules/bash-redirect.ts +91 -0
- package/src/rules/bash-scoped-rm.ts +84 -0
- package/src/rules/bash-tar-explosion.ts +76 -0
- package/src/rules/bash-tool-policy.ts +134 -0
- package/src/rules/config-custom.ts +51 -0
- package/src/rules/lazy-code.ts +95 -0
- package/src/rules/path-protect.ts +59 -0
- package/src/rules/post-secret-scrub.ts +38 -0
- package/src/rules/read-protect.ts +62 -0
package/src/lib/bash.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// Generic bash command parsing built on `shell-quote`. Used by every bash
|
|
2
|
+
// Rule — bash-deny, bash-scoped-rm, bash-redirect, bash-network-install,
|
|
3
|
+
// Bash-tar-explosion, bash-tool-policy.
|
|
4
|
+
//
|
|
5
|
+
// Shell-quote.parse(cmd) returns a flat array of tokens and operator
|
|
6
|
+
// Objects. We post-process it into structured `Segment`s split at top-level
|
|
7
|
+
// Shell operators (`;`, `&&`, `||`, `|`, `&`, newline). Each segment
|
|
8
|
+
// Records its head token, positional args, flags, and redirect targets.
|
|
9
|
+
//
|
|
10
|
+
// Limitations:
|
|
11
|
+
// - No variable expansion. `$HOME` stays literal — rules that care about
|
|
12
|
+
// Paths should either reject literal env-var references or accept them.
|
|
13
|
+
// - Command substitution `$(...)` is collapsed into a single opaque
|
|
14
|
+
// Token (`__tripwire_cmd_sub__`) so safe-path checks fail safely.
|
|
15
|
+
// - Glob expansion is not performed.
|
|
16
|
+
|
|
17
|
+
import { parse, type ParseEntry } from 'shell-quote';
|
|
18
|
+
|
|
19
|
+
interface Segment {
|
|
20
|
+
readonly head: string; // First non-flag token, e.g. `rm`, `npm`
|
|
21
|
+
readonly tokens: readonly string[]; // All string tokens incl. head, in order
|
|
22
|
+
readonly args: readonly string[]; // Tokens[1..], minus pure flag tokens
|
|
23
|
+
readonly flags: readonly string[]; // Tokens that start with `-`
|
|
24
|
+
readonly redirects: readonly Redirect[];
|
|
25
|
+
readonly raw: string; // Best-effort reconstruction
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Redirect {
|
|
29
|
+
readonly op: '>' | '>>' | '<' | '<<' | '<<<' | '<>' | '>&' | '<&' | '&>' | '&>>';
|
|
30
|
+
readonly target: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SAFE_RELATIVE: readonly string[] = [
|
|
34
|
+
'dist',
|
|
35
|
+
'build',
|
|
36
|
+
'_build',
|
|
37
|
+
'out',
|
|
38
|
+
'target',
|
|
39
|
+
'.next',
|
|
40
|
+
'.nuxt',
|
|
41
|
+
'.svelte-kit',
|
|
42
|
+
'.output',
|
|
43
|
+
'.astro',
|
|
44
|
+
'.angular',
|
|
45
|
+
'.vite',
|
|
46
|
+
'.parcel-cache',
|
|
47
|
+
'.turbo',
|
|
48
|
+
'.vercel',
|
|
49
|
+
'.netlify',
|
|
50
|
+
'.fly',
|
|
51
|
+
'.wrangler',
|
|
52
|
+
'.serverless',
|
|
53
|
+
'coverage',
|
|
54
|
+
'.nyc_output',
|
|
55
|
+
'.cache',
|
|
56
|
+
'.ruff_cache',
|
|
57
|
+
'.mypy_cache',
|
|
58
|
+
'.pytest_cache',
|
|
59
|
+
'.ty_cache',
|
|
60
|
+
'.tox',
|
|
61
|
+
'__pycache__',
|
|
62
|
+
'.venv',
|
|
63
|
+
'venv',
|
|
64
|
+
'node_modules',
|
|
65
|
+
'.gradle',
|
|
66
|
+
'DerivedData',
|
|
67
|
+
'.bundle',
|
|
68
|
+
'.cargo-target',
|
|
69
|
+
'tmp',
|
|
70
|
+
'.tmp',
|
|
71
|
+
'.state',
|
|
72
|
+
'.terraform',
|
|
73
|
+
'.yarn/cache',
|
|
74
|
+
'.yarn/install-state.gz',
|
|
75
|
+
'.pnpm-store',
|
|
76
|
+
'.bun',
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const SAFE_ABSOLUTE: readonly string[] = [
|
|
80
|
+
'/tmp',
|
|
81
|
+
'/var/tmp',
|
|
82
|
+
'/var/folders',
|
|
83
|
+
'/private/tmp',
|
|
84
|
+
'/private/var/tmp',
|
|
85
|
+
'/private/var/folders',
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const REDIRECT_OPS: ReadonlySet<string> = new Set([
|
|
89
|
+
'>',
|
|
90
|
+
'>>',
|
|
91
|
+
'<',
|
|
92
|
+
'<<',
|
|
93
|
+
'<<<',
|
|
94
|
+
'<>',
|
|
95
|
+
'>&',
|
|
96
|
+
'<&',
|
|
97
|
+
'&>',
|
|
98
|
+
'&>>',
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
// `|&` is bash shorthand for "pipe stdout AND stderr to the next command"
|
|
102
|
+
// — semantically equivalent to `2>&1 |` for our purposes. shell-quote
|
|
103
|
+
// Emits it as a single op; without classifying it as a segment break,
|
|
104
|
+
// `cmd1 |& cmd2` collapses into one segment with `__op_|&__` as a fake
|
|
105
|
+
// Positional arg, hiding `cmd2` from every rule.
|
|
106
|
+
const SEGMENT_OPS: ReadonlySet<string> = new Set([';', '&&', '||', '|', '|&', '&']);
|
|
107
|
+
|
|
108
|
+
// Type guards over `ParseEntry`.
|
|
109
|
+
const isStringToken = (e: ParseEntry): e is string => typeof e === 'string';
|
|
110
|
+
const getOp = (e: ParseEntry): string | null => {
|
|
111
|
+
if (typeof e === 'object' && 'op' in e && typeof e.op === 'string') {
|
|
112
|
+
return e.op;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
};
|
|
116
|
+
const isCommentToken = (e: ParseEntry): boolean => typeof e === 'object' && 'comment' in e;
|
|
117
|
+
|
|
118
|
+
// Glob entries from shell-quote are `{ op: 'glob', pattern: '...' }`. We
|
|
119
|
+
// Expand them against the hook's cwd via `Bun.Glob` so safe-path rules
|
|
120
|
+
// See concrete files (e.g. `.state/foo*` → `.state/foo-1.json`,
|
|
121
|
+
// `.state/foo-2.json`) instead of an opaque `__op_glob__` sentinel that
|
|
122
|
+
// Always fails safe-path checks. If a pattern matches nothing, we keep
|
|
123
|
+
// The literal pattern so the rule can still reason about its prefix
|
|
124
|
+
// (e.g. `.state/foo*` resolves under `.state/` regardless).
|
|
125
|
+
const expandGlob = (pattern: string): string[] => {
|
|
126
|
+
try {
|
|
127
|
+
const matches = [...new Bun.Glob(pattern).scanSync({ onlyFiles: false, dot: true })];
|
|
128
|
+
if (matches.length > 0) {
|
|
129
|
+
return matches;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Fall through to the literal pattern.
|
|
133
|
+
}
|
|
134
|
+
return [pattern];
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Convert one entry to one or more string tokens. Operators and
|
|
138
|
+
// Command-sub markers become opaque sentinel tokens; globs expand.
|
|
139
|
+
const entryToTokens = (e: ParseEntry): string[] => {
|
|
140
|
+
if (isStringToken(e)) {
|
|
141
|
+
return [e];
|
|
142
|
+
}
|
|
143
|
+
if (typeof e === 'object' && 'op' in e && e.op === 'glob' && 'pattern' in e) {
|
|
144
|
+
return expandGlob(String((e as { pattern: unknown }).pattern));
|
|
145
|
+
}
|
|
146
|
+
const op = getOp(e);
|
|
147
|
+
if (op !== null) {
|
|
148
|
+
if (REDIRECT_OPS.has(op) || SEGMENT_OPS.has(op)) {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
return [`__op_${op}__`];
|
|
152
|
+
}
|
|
153
|
+
if (isCommentToken(e)) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
return ['__tripwire_cmd_sub__'];
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
interface FdBudget {
|
|
160
|
+
remaining: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const parseSegment = (entries: readonly ParseEntry[], fdBudget: FdBudget): Segment | null => {
|
|
164
|
+
const tokens: string[] = [];
|
|
165
|
+
const args: string[] = [];
|
|
166
|
+
const flags: string[] = [];
|
|
167
|
+
const redirects: Redirect[] = [];
|
|
168
|
+
|
|
169
|
+
let i = 0;
|
|
170
|
+
while (i < entries.length) {
|
|
171
|
+
const e = entries[i]!;
|
|
172
|
+
const op = getOp(e);
|
|
173
|
+
if (op !== null && REDIRECT_OPS.has(op)) {
|
|
174
|
+
// Shell-quote emits a leading file-descriptor digit (e.g. the `2` in
|
|
175
|
+
// `2>&1`) as a separate string token *before* the redirect op. It
|
|
176
|
+
// Also drops the whitespace, so `echo 2 >file` and `echo 2>file`
|
|
177
|
+
// Produce identical token streams. We pre-scanned the original
|
|
178
|
+
// Command for digit-then-redirect-with-no-space patterns and stored
|
|
179
|
+
// The count in fdBudget; only consume one when we see a digit
|
|
180
|
+
// Adjacent to a redirect op here.
|
|
181
|
+
const last = tokens.at(-1);
|
|
182
|
+
if (last !== undefined && /^[0-9]+$/.test(last) && fdBudget.remaining > 0) {
|
|
183
|
+
tokens.pop();
|
|
184
|
+
fdBudget.remaining--;
|
|
185
|
+
}
|
|
186
|
+
const target = entries[i + 1];
|
|
187
|
+
if (target !== undefined && isStringToken(target)) {
|
|
188
|
+
redirects.push({ op: op as Redirect['op'], target });
|
|
189
|
+
i += 2;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
i++;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
for (const t of entryToTokens(e)) {
|
|
196
|
+
tokens.push(t);
|
|
197
|
+
}
|
|
198
|
+
i++;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (tokens.length === 0) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
for (let j = 1; j < tokens.length; j++) {
|
|
205
|
+
const t = tokens[j]!;
|
|
206
|
+
if (t.startsWith('-') && t !== '-') {
|
|
207
|
+
flags.push(t);
|
|
208
|
+
} else {
|
|
209
|
+
args.push(t);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { head: tokens[0]!, tokens, args, flags, redirects, raw: tokens.join(' ') };
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// Pass an env function that preserves variable references as literals,
|
|
216
|
+
// Otherwise shell-quote treats `$HOME` as an empty string and we lose
|
|
217
|
+
// The ability to reason about home-directory references.
|
|
218
|
+
const PRESERVE_ENV = (key: string): string => `$${key}`;
|
|
219
|
+
|
|
220
|
+
// Shell-quote splits `&>file` into two ops — `{op:"&"}` then `{op:">"}` —
|
|
221
|
+
// Which would (a) make the `&` look like a backgrounding segment break and
|
|
222
|
+
// (b) hide the redirect from rule analysis. Merge those pairs back into
|
|
223
|
+
// `&>` / `&>>` before segment splitting.
|
|
224
|
+
const mergeAmpRedirects = (entries: readonly ParseEntry[]): ParseEntry[] => {
|
|
225
|
+
const out: ParseEntry[] = [];
|
|
226
|
+
for (let i = 0; i < entries.length; i++) {
|
|
227
|
+
const e = entries[i]!;
|
|
228
|
+
const next = entries[i + 1];
|
|
229
|
+
if (getOp(e) === '&' && next !== undefined && (getOp(next) === '>' || getOp(next) === '>>')) {
|
|
230
|
+
const merged = { op: getOp(next) === '>' ? '&>' : '&>>' } as unknown as ParseEntry;
|
|
231
|
+
out.push(merged);
|
|
232
|
+
i++;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
// `>|file` is bash's noclobber-override redirect. shell-quote splits
|
|
236
|
+
// It into `{op:">"}, {op:"|"}` — the `|` then trips segment splitting
|
|
237
|
+
// And the redirect target is lost. Re-merge to a single `>` op (the
|
|
238
|
+
// Noclobber bit doesn't matter to rule analysis; what matters is that
|
|
239
|
+
// It's a write redirect to the following target).
|
|
240
|
+
if (getOp(e) === '>' && next !== undefined && getOp(next) === '|') {
|
|
241
|
+
out.push({ op: '>' } as unknown as ParseEntry);
|
|
242
|
+
i++;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
out.push(e);
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Count digit-then-redirect adjacencies in the source string (`2>file`,
|
|
251
|
+
// `1>&2`, `2>>log`). The `(?<![\w$])` rejects matches inside identifiers
|
|
252
|
+
// Like `foo2>bar`. A trailing `>` or `<` with no whitespace is required —
|
|
253
|
+
// `echo 2 >file` keeps `2` as a positional arg.
|
|
254
|
+
const countFdPrefixRedirects = (cmd: string): number => {
|
|
255
|
+
const matches = cmd.match(/(?<![\w$])\d+(?=[<>])/g);
|
|
256
|
+
return matches?.length ?? 0;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Extract inner commands from `$(...)`, `<(...)`, `>(...)`, and `` `...` ``.
|
|
260
|
+
// Shell-quote collapses these into opaque sentinel tokens (which is correct
|
|
261
|
+
// For safe-path checks — substituted output is unknown), but it also hides
|
|
262
|
+
// The inner commands themselves from rule analysis. So `tee >(rm -rf /etc)`
|
|
263
|
+
// Would let the `rm` slip through. We pull the inner commands out and
|
|
264
|
+
// Analyze them as additional segments.
|
|
265
|
+
//
|
|
266
|
+
// Backticks don't nest (bash needs `\` escaping for that, which we treat as
|
|
267
|
+
// A literal). Process/command substitutions can nest arbitrarily — a depth
|
|
268
|
+
// Counter handles the balanced parens.
|
|
269
|
+
const extractInnerCommands = (cmd: string): string[] => {
|
|
270
|
+
const inner: string[] = [];
|
|
271
|
+
// Backticks: simple, non-nesting.
|
|
272
|
+
const bt = cmd.match(/`([^`]+)`/g);
|
|
273
|
+
if (bt !== null) {
|
|
274
|
+
for (const m of bt) {
|
|
275
|
+
inner.push(m.slice(1, -1));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// $( ), <( ), >( ) with balanced parens.
|
|
279
|
+
for (let i = 0; i < cmd.length - 1; i++) {
|
|
280
|
+
const ch = cmd[i]!;
|
|
281
|
+
const next = cmd[i + 1]!;
|
|
282
|
+
const isSubStart = (ch === '$' || ch === '<' || ch === '>') && next === '(';
|
|
283
|
+
if (!isSubStart) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
let depth = 1;
|
|
287
|
+
let j = i + 2;
|
|
288
|
+
while (j < cmd.length && depth > 0) {
|
|
289
|
+
const cj = cmd[j]!;
|
|
290
|
+
if (cj === '(') {
|
|
291
|
+
depth++;
|
|
292
|
+
} else if (cj === ')') {
|
|
293
|
+
depth--;
|
|
294
|
+
}
|
|
295
|
+
if (depth > 0) {
|
|
296
|
+
j++;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (depth === 0) {
|
|
300
|
+
inner.push(cmd.slice(i + 2, j));
|
|
301
|
+
i = j;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return inner;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const parseCommand = (cmd: string): Segment[] => {
|
|
308
|
+
let entries: ParseEntry[];
|
|
309
|
+
try {
|
|
310
|
+
entries = parse(cmd, PRESERVE_ENV);
|
|
311
|
+
} catch {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
entries = mergeAmpRedirects(entries);
|
|
315
|
+
const fdBudget: FdBudget = { remaining: countFdPrefixRedirects(cmd) };
|
|
316
|
+
|
|
317
|
+
const out: Segment[] = [];
|
|
318
|
+
let buf: ParseEntry[] = [];
|
|
319
|
+
for (const e of entries) {
|
|
320
|
+
const op = getOp(e);
|
|
321
|
+
if (op !== null && SEGMENT_OPS.has(op)) {
|
|
322
|
+
const seg = parseSegment(buf, fdBudget);
|
|
323
|
+
if (seg !== null) {
|
|
324
|
+
out.push(seg);
|
|
325
|
+
}
|
|
326
|
+
buf = [];
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
buf.push(e);
|
|
330
|
+
}
|
|
331
|
+
const seg = parseSegment(buf, fdBudget);
|
|
332
|
+
if (seg !== null) {
|
|
333
|
+
out.push(seg);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Recursively analyze any embedded commands as additional segments. The
|
|
337
|
+
// Outer segment's args are already opaque sentinels (safe-path-failing);
|
|
338
|
+
// This catches dangerous inner commands the outer call would otherwise
|
|
339
|
+
// Hide.
|
|
340
|
+
for (const sub of extractInnerCommands(cmd)) {
|
|
341
|
+
for (const innerSeg of parseCommand(sub)) {
|
|
342
|
+
out.push(innerSeg);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return out;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const stripLeadingDotSlash = (p: string): string => (p.startsWith('./') ? p.slice(2) : p);
|
|
350
|
+
|
|
351
|
+
const isSafePathTarget = (
|
|
352
|
+
raw: string,
|
|
353
|
+
extraRelative: readonly string[] = [],
|
|
354
|
+
extraAbsolute: readonly string[] = [],
|
|
355
|
+
): boolean => {
|
|
356
|
+
if (raw === '') {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
const t = stripLeadingDotSlash(raw);
|
|
360
|
+
if (t === '..' || t.startsWith('../') || t.includes('/../')) {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
for (const abs of [...SAFE_ABSOLUTE, ...extraAbsolute]) {
|
|
364
|
+
if (t === abs || t.startsWith(`${abs}/`)) {
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const rel of [...SAFE_RELATIVE, ...extraRelative]) {
|
|
369
|
+
if (t === rel || t.startsWith(`${rel}/`)) {
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return false;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const safeScopesSummary = (
|
|
377
|
+
extraRelative: readonly string[] = [],
|
|
378
|
+
extraAbsolute: readonly string[] = [],
|
|
379
|
+
): string => {
|
|
380
|
+
const groups: Record<string, readonly string[]> = {
|
|
381
|
+
'build outputs': ['dist', 'build', '_build', 'out', 'target'],
|
|
382
|
+
'js framework outputs': [
|
|
383
|
+
'.next',
|
|
384
|
+
'.nuxt',
|
|
385
|
+
'.svelte-kit',
|
|
386
|
+
'.output',
|
|
387
|
+
'.astro',
|
|
388
|
+
'.angular',
|
|
389
|
+
'.vite',
|
|
390
|
+
'.parcel-cache',
|
|
391
|
+
'.turbo',
|
|
392
|
+
'.vercel',
|
|
393
|
+
'.netlify',
|
|
394
|
+
'.fly',
|
|
395
|
+
'.wrangler',
|
|
396
|
+
'.serverless',
|
|
397
|
+
],
|
|
398
|
+
'tests / coverage': ['coverage', '.nyc_output'],
|
|
399
|
+
caches: ['.cache', '.ruff_cache', '.mypy_cache', '.pytest_cache', '.ty_cache', '.tox'],
|
|
400
|
+
'language / package': [
|
|
401
|
+
'__pycache__',
|
|
402
|
+
'.venv',
|
|
403
|
+
'venv',
|
|
404
|
+
'node_modules',
|
|
405
|
+
'.gradle',
|
|
406
|
+
'DerivedData',
|
|
407
|
+
'.bundle',
|
|
408
|
+
'.cargo-target',
|
|
409
|
+
],
|
|
410
|
+
'tmp / state': ['tmp', '.tmp', '.state', '/tmp', '/var/tmp', '/var/folders'],
|
|
411
|
+
iac: ['.terraform'],
|
|
412
|
+
'bundler dev': ['.yarn/cache', '.yarn/install-state.gz', '.pnpm-store', '.bun'],
|
|
413
|
+
};
|
|
414
|
+
if (extraRelative.length > 0) {
|
|
415
|
+
groups['custom relative'] = extraRelative;
|
|
416
|
+
}
|
|
417
|
+
if (extraAbsolute.length > 0) {
|
|
418
|
+
groups['custom absolute'] = extraAbsolute;
|
|
419
|
+
}
|
|
420
|
+
return Object.entries(groups)
|
|
421
|
+
.map(([k, v]) => ` ${k}: ${v.join(', ')}`)
|
|
422
|
+
.join('\n');
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const hasBypass = (cmd: string): boolean => /(^|\s)#\s*tripwire-allow\b/.test(cmd);
|
|
426
|
+
|
|
427
|
+
export type { Redirect, Segment };
|
|
428
|
+
export { hasBypass, isSafePathTarget, parseCommand, safeScopesSummary };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Config system using Effect Schema for validation and Effect for safe loading.
|
|
2
|
+
// Config file: ~/.config/tripwire/config.json
|
|
3
|
+
// Falls back to defaults if file doesn't exist or is invalid.
|
|
4
|
+
|
|
5
|
+
import { accessSync, constants, readFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
import { Effect, Schema } from 'effect';
|
|
9
|
+
|
|
10
|
+
const BlockRuleSchema = Schema.Struct({
|
|
11
|
+
pattern: Schema.String,
|
|
12
|
+
message: Schema.String,
|
|
13
|
+
action: Schema.optional(Schema.Union([Schema.Literal('deny'), Schema.Literal('ask')])),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const RtkConfigSchema = Schema.Struct({
|
|
17
|
+
enabled: Schema.optional(Schema.Boolean),
|
|
18
|
+
path: Schema.optional(Schema.String),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const GitConfigSchema = Schema.Struct({
|
|
22
|
+
protectedBranches: Schema.optional(Schema.Array(Schema.String)),
|
|
23
|
+
enforceConventionalCommits: Schema.optional(Schema.Boolean),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const SafePathsConfigSchema = Schema.Struct({
|
|
27
|
+
relative: Schema.optional(Schema.Array(Schema.String)),
|
|
28
|
+
absolute: Schema.optional(Schema.Array(Schema.String)),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const ConfigSchema = Schema.Struct({
|
|
32
|
+
rtk: Schema.optional(RtkConfigSchema),
|
|
33
|
+
git: Schema.optional(GitConfigSchema),
|
|
34
|
+
safePaths: Schema.optional(SafePathsConfigSchema),
|
|
35
|
+
blockedCommands: Schema.optional(Schema.Array(BlockRuleSchema)),
|
|
36
|
+
allowedCommands: Schema.optional(Schema.Array(BlockRuleSchema)),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const CONFIG_PATH = `${homedir()}/.config/tripwire/config.json`;
|
|
40
|
+
|
|
41
|
+
const configExists = (): Effect.Effect<boolean> =>
|
|
42
|
+
Effect.sync(() => {
|
|
43
|
+
try {
|
|
44
|
+
accessSync(CONFIG_PATH, constants.R_OK);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const readConfigFile = (): Effect.Effect<string, Error> =>
|
|
52
|
+
Effect.try({ try: () => readFileSync(CONFIG_PATH, 'utf8'), catch: (error) => error as Error });
|
|
53
|
+
|
|
54
|
+
const parseConfigJson = (raw: string): Effect.Effect<unknown, Error> =>
|
|
55
|
+
Effect.try({ try: () => JSON.parse(raw) as unknown, catch: (error) => error as Error });
|
|
56
|
+
|
|
57
|
+
const decodeConfig = (unknown: unknown): Effect.Effect<Config, Error> =>
|
|
58
|
+
Schema.decodeUnknownEffect(ConfigSchema)(unknown);
|
|
59
|
+
|
|
60
|
+
const getDefaultConfig = (): Config => ({
|
|
61
|
+
rtk: { enabled: false },
|
|
62
|
+
git: {
|
|
63
|
+
protectedBranches: ['main', 'master', 'develop', 'production', 'release'],
|
|
64
|
+
enforceConventionalCommits: true,
|
|
65
|
+
},
|
|
66
|
+
safePaths: {},
|
|
67
|
+
blockedCommands: [],
|
|
68
|
+
allowedCommands: [],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const mergeWithDefaults = (partial: Config): Config => ({
|
|
72
|
+
rtk: partial.rtk ?? getDefaultConfig().rtk,
|
|
73
|
+
git: partial.git ?? getDefaultConfig().git,
|
|
74
|
+
safePaths: partial.safePaths ?? getDefaultConfig().safePaths,
|
|
75
|
+
blockedCommands: partial.blockedCommands ?? getDefaultConfig().blockedCommands,
|
|
76
|
+
allowedCommands: partial.allowedCommands ?? getDefaultConfig().allowedCommands,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const loadConfig = (): Effect.Effect<Config> =>
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
const exists = yield* configExists();
|
|
82
|
+
if (!exists) {
|
|
83
|
+
return getDefaultConfig();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const raw = yield* readConfigFile();
|
|
87
|
+
const parsed = yield* parseConfigJson(raw);
|
|
88
|
+
const config = yield* decodeConfig(parsed);
|
|
89
|
+
return mergeWithDefaults(config);
|
|
90
|
+
}).pipe(
|
|
91
|
+
Effect.timeout(1000),
|
|
92
|
+
// eslint-disable-next-line promise/prefer-await-to-then
|
|
93
|
+
Effect.catch(() => {
|
|
94
|
+
// Log error but return defaults to never block the agent
|
|
95
|
+
console.error('[tripwire] Config loading failed, using defaults');
|
|
96
|
+
return Effect.succeed(getDefaultConfig());
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
export type BlockRule = typeof BlockRuleSchema.Type;
|
|
101
|
+
export type RtkConfig = typeof RtkConfigSchema.Type;
|
|
102
|
+
export type GitConfig = typeof GitConfigSchema.Type;
|
|
103
|
+
export type SafePathsConfig = typeof SafePathsConfigSchema.Type;
|
|
104
|
+
export type Config = typeof ConfigSchema.Type;
|
|
105
|
+
|
|
106
|
+
export { CONFIG_PATH, ConfigSchema };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Decisions are ordered by restrictiveness:
|
|
2
|
+
// Allow — let the tool call proceed silently
|
|
3
|
+
// Warn — let the tool call proceed but inject a system message so the
|
|
4
|
+
// Agent sees the advisory in its next turn
|
|
5
|
+
// Ask — Claude Code prompts the user before letting the call proceed
|
|
6
|
+
// Deny — block the tool call (PreToolUse) or refuse to surface its
|
|
7
|
+
// Output to the model (PostToolUse)
|
|
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
|
+
type DecisionKind = 'allow' | 'warn' | 'ask' | 'deny';
|
|
13
|
+
|
|
14
|
+
interface Decision {
|
|
15
|
+
readonly kind: DecisionKind;
|
|
16
|
+
readonly rule: string;
|
|
17
|
+
readonly message: string;
|
|
18
|
+
readonly rewriteCommand?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const order: Record<DecisionKind, number> = { allow: 0, warn: 1, ask: 2, deny: 3 };
|
|
22
|
+
|
|
23
|
+
const allow = (rule: string): Decision => ({ kind: 'allow', rule, message: '' });
|
|
24
|
+
const warn = (rule: string, message: string): Decision => ({ kind: 'warn', rule, message });
|
|
25
|
+
const ask = (rule: string, message: string): Decision => ({ kind: 'ask', rule, message });
|
|
26
|
+
const deny = (rule: string, message: string): Decision => ({ kind: 'deny', rule, message });
|
|
27
|
+
|
|
28
|
+
// Merge picks the most restrictive kind. Rewrite commands are preserved
|
|
29
|
+
// From the most restrictive decision that carries one, falling back to the
|
|
30
|
+
// First non-empty rewrite if no restrictive rule has one.
|
|
31
|
+
const merge = (decisions: readonly Decision[]): Decision => {
|
|
32
|
+
let best: Decision = allow('none');
|
|
33
|
+
let rewriteCommand: string | undefined;
|
|
34
|
+
for (const d of decisions) {
|
|
35
|
+
if (order[d.kind] > order[best.kind]) {
|
|
36
|
+
best = d;
|
|
37
|
+
}
|
|
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
|
+
}
|
|
45
|
+
return best;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type { Decision, DecisionKind };
|
|
49
|
+
export { allow, ask, deny, merge, warn };
|
package/src/lib/diff.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
// Lines present in `next` but not in `prev`, compared trimmed.
|
|
4
|
+
// Whitespace-only differences are ignored — we want semantic additions.
|
|
5
|
+
const addedLines = (prev: string, next: string): string[] => {
|
|
6
|
+
const prevSet = new Set(
|
|
7
|
+
prev
|
|
8
|
+
.split('\n')
|
|
9
|
+
.map((l) => l.trim())
|
|
10
|
+
.filter((l) => l.length > 0),
|
|
11
|
+
);
|
|
12
|
+
return next
|
|
13
|
+
.split('\n')
|
|
14
|
+
.filter((l) => l.trim().length > 0)
|
|
15
|
+
.filter((l) => !prevSet.has(l.trim()));
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const readFileOrEmpty = (path: string): string => {
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(path, 'utf8');
|
|
21
|
+
} catch {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export { addedLines, readFileOrEmpty };
|