@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.
@@ -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 };
@@ -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 };