@seanmozeik/tripwire 0.6.7 → 0.7.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/src/lib/bash.ts CHANGED
@@ -7,12 +7,11 @@
7
7
  // Shell operators (`;`, `&&`, `||`, `|`, `&`, newline). Each segment
8
8
  // Records its head token, positional args, flags, and redirect targets.
9
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.
10
+ // Parsing notes:
11
+ // - Shell variables stay literal. Path rules handle known home references.
12
+ // - Command substitutions are inspected as nested commands. The outer
13
+ // command keeps an opaque marker for safe path classification.
14
+ // - Glob entries are expanded with Bun.Glob before path classification.
16
15
 
17
16
  import { parse, quote, type ParseEntry } from 'shell-quote';
18
17
 
@@ -181,7 +180,7 @@ const parseSegment = (entries: readonly ParseEntry[], fdBudget: FdBudget): Segme
181
180
  const last = tokens.at(-1);
182
181
  if (last !== undefined && /^[0-9]+$/.test(last) && fdBudget.remaining > 0) {
183
182
  tokens.pop();
184
- fdBudget.remaining--;
183
+ fdBudget.remaining -= 1;
185
184
  }
186
185
  const target = entries[i + 1];
187
186
  if (target !== undefined && isStringToken(target)) {
@@ -189,19 +188,19 @@ const parseSegment = (entries: readonly ParseEntry[], fdBudget: FdBudget): Segme
189
188
  i += 2;
190
189
  continue;
191
190
  }
192
- i++;
191
+ i += 1;
193
192
  continue;
194
193
  }
195
194
  for (const t of entryToTokens(e)) {
196
195
  tokens.push(t);
197
196
  }
198
- i++;
197
+ i += 1;
199
198
  }
200
199
 
201
200
  if (tokens.length === 0) {
202
201
  return null;
203
202
  }
204
- for (let j = 1; j < tokens.length; j++) {
203
+ for (let j = 1; j < tokens.length; j += 1) {
205
204
  const t = tokens[j]!;
206
205
  if (t.startsWith('-') && t !== '-') {
207
206
  flags.push(t);
@@ -209,13 +208,9 @@ const parseSegment = (entries: readonly ParseEntry[], fdBudget: FdBudget): Segme
209
208
  args.push(t);
210
209
  }
211
210
  }
212
- // Normalise the head to its basename so command-name rules match regardless
213
- // Of whether the command was invoked via absolute path (/bin/rm), a
214
- // Homebrew-prefixed path (/opt/homebrew/bin/gog), or a relative ./rm form.
215
- // This fixes the containment hole where `/bin/rm <unsafe>` bypassed every
216
- // Rule that compared `seg.head === 'rm'`. The `tokens` array is left
217
- // Unchanged (raw reconstruction stays accurate); only the canonical `head`
218
- // Used for matching is normalised.
211
+ // Use the executable basename for command rules. Keep the original tokens
212
+ // so an absolute or relative command path cannot bypass a rule and raw
213
+ // reconstruction stays accurate.
219
214
  const rawHead = tokens[0]!;
220
215
  const slashIdx = rawHead.lastIndexOf('/');
221
216
  const head = slashIdx === -1 ? rawHead : rawHead.slice(slashIdx + 1);
@@ -233,13 +228,13 @@ const PRESERVE_ENV = (key: string): string => `$${key}`;
233
228
  // `&>` / `&>>` before segment splitting.
234
229
  const mergeAmpRedirects = (entries: readonly ParseEntry[]): ParseEntry[] => {
235
230
  const out: ParseEntry[] = [];
236
- for (let i = 0; i < entries.length; i++) {
231
+ for (let i = 0; i < entries.length; i += 1) {
237
232
  const e = entries[i]!;
238
233
  const next = entries[i + 1];
239
234
  if (getOp(e) === '&' && next !== undefined && (getOp(next) === '>' || getOp(next) === '>>')) {
240
235
  const merged = { op: getOp(next) === '>' ? '&>' : '&>>' } as unknown as ParseEntry;
241
236
  out.push(merged);
242
- i++;
237
+ i += 1;
243
238
  continue;
244
239
  }
245
240
  // `>|file` is bash's noclobber-override redirect. shell-quote splits
@@ -249,7 +244,7 @@ const mergeAmpRedirects = (entries: readonly ParseEntry[]): ParseEntry[] => {
249
244
  // It's a write redirect to the following target).
250
245
  if (getOp(e) === '>' && next !== undefined && getOp(next) === '|') {
251
246
  out.push({ op: '>' } as unknown as ParseEntry);
252
- i++;
247
+ i += 1;
253
248
  continue;
254
249
  }
255
250
  out.push(e);
@@ -284,20 +279,19 @@ const heredocFeedsShell = (line: string): boolean => SHELL_STDIN_HEAD_RE.test(li
284
279
  const maskLiteralHeredocBodies = (cmd: string): string => {
285
280
  const lines = cmd.split('\n');
286
281
  const out: string[] = [];
287
- for (let i = 0; i < lines.length; i++) {
282
+ for (let i = 0; i < lines.length; i += 1) {
288
283
  const line = lines[i]!;
289
284
  out.push(line);
290
285
  const delimiter = heredocDelimiterFromLine(line);
291
286
  if (delimiter === null || heredocFeedsShell(line)) {
292
287
  continue;
293
288
  }
294
- i++;
289
+ i += 1;
295
290
  while (i < lines.length && lines[i]!.trim() !== delimiter) {
296
- i++;
291
+ i += 1;
297
292
  }
298
293
  if (i < lines.length) {
299
- out.push('__HEREDOC_BODY__');
300
- out.push(lines[i]!);
294
+ out.push('__HEREDOC_BODY__', lines[i]!);
301
295
  }
302
296
  }
303
297
  return out.join('\n');
@@ -313,17 +307,17 @@ const maskLiteralHeredocBodies = (cmd: string): string => {
313
307
  const collectHeredocBodies = (cmd: string): ReadonlyMap<string, string> => {
314
308
  const map = new Map<string, string>();
315
309
  const lines = cmd.split('\n');
316
- for (let i = 0; i < lines.length; i++) {
310
+ for (let i = 0; i < lines.length; i += 1) {
317
311
  const line = lines[i]!;
318
312
  const delimiter = heredocDelimiterFromLine(line);
319
313
  if (delimiter === null || heredocFeedsShell(line)) {
320
314
  continue;
321
315
  }
322
316
  const body: string[] = [];
323
- i++;
317
+ i += 1;
324
318
  while (i < lines.length && lines[i]!.trim() !== delimiter) {
325
319
  body.push(lines[i]!);
326
- i++;
320
+ i += 1;
327
321
  }
328
322
  if (!map.has(delimiter)) {
329
323
  map.set(delimiter, body.join('\n'));
@@ -335,17 +329,17 @@ const collectHeredocBodies = (cmd: string): ReadonlyMap<string, string> => {
335
329
  const extractShellHeredocCommands = (cmd: string): string[] => {
336
330
  const lines = cmd.split('\n');
337
331
  const out: string[] = [];
338
- for (let i = 0; i < lines.length; i++) {
332
+ for (let i = 0; i < lines.length; i += 1) {
339
333
  const line = lines[i]!;
340
334
  const delimiter = heredocDelimiterFromLine(line);
341
335
  if (delimiter === null || !heredocFeedsShell(line)) {
342
336
  continue;
343
337
  }
344
338
  const body: string[] = [];
345
- i++;
339
+ i += 1;
346
340
  while (i < lines.length && lines[i]!.trim() !== delimiter) {
347
341
  body.push(lines[i]!);
348
- i++;
342
+ i += 1;
349
343
  }
350
344
  if (body.length > 0) {
351
345
  out.push(body.join('\n'));
@@ -365,10 +359,10 @@ const extractShellHeredocCommands = (cmd: string): string[] => {
365
359
  // A literal). Process/command substitutions can nest arbitrarily — a depth
366
360
  // Counter handles the balanced parens.
367
361
  const findBacktickEnd = (cmd: string, start: number): number | null => {
368
- for (let i = start; i < cmd.length; i++) {
362
+ for (let i = start; i < cmd.length; i += 1) {
369
363
  const ch = cmd[i]!;
370
364
  if (ch === '\\') {
371
- i++;
365
+ i += 1;
372
366
  continue;
373
367
  }
374
368
  if (ch === '`') {
@@ -381,10 +375,10 @@ const findBacktickEnd = (cmd: string, start: number): number | null => {
381
375
  const findSubstitutionEnd = (cmd: string, start: number): number | null => {
382
376
  let depth = 1;
383
377
  let quote: 'single' | 'double' | null = null;
384
- for (let j = start; j < cmd.length; j++) {
378
+ for (let j = start; j < cmd.length; j += 1) {
385
379
  const cj = cmd[j]!;
386
380
  if (cj === '\\') {
387
- j++;
381
+ j += 1;
388
382
  continue;
389
383
  }
390
384
  if (quote === 'single') {
@@ -402,11 +396,11 @@ const findSubstitutionEnd = (cmd: string, start: number): number | null => {
402
396
  continue;
403
397
  }
404
398
  if (cj === '(') {
405
- depth++;
399
+ depth += 1;
406
400
  continue;
407
401
  }
408
402
  if (cj === ')') {
409
- depth--;
403
+ depth -= 1;
410
404
  if (depth === 0) {
411
405
  return j;
412
406
  }
@@ -418,10 +412,10 @@ const findSubstitutionEnd = (cmd: string, start: number): number | null => {
418
412
  const extractInnerCommands = (cmd: string): string[] => {
419
413
  const inner: string[] = [];
420
414
  let quote: 'single' | 'double' | null = null;
421
- for (let i = 0; i < cmd.length; i++) {
415
+ for (let i = 0; i < cmd.length; i += 1) {
422
416
  const ch = cmd[i]!;
423
417
  if (ch === '\\') {
424
- i++;
418
+ i += 1;
425
419
  continue;
426
420
  }
427
421
  if (quote === 'single') {
@@ -572,13 +566,13 @@ const pickFdSearchRoot = (tokens: readonly string[], execFlagIdx: number): strin
572
566
  continue;
573
567
  }
574
568
  if (t.startsWith('-')) {
575
- i++;
569
+ i += 1;
576
570
  continue;
577
571
  }
578
572
  if (pathLikeToken(t)) {
579
573
  candidates.push(t);
580
574
  }
581
- i++;
575
+ i += 1;
582
576
  }
583
577
  if (candidates.length === 0) {
584
578
  return '.';
@@ -593,7 +587,7 @@ const pickFdSearchRoot = (tokens: readonly string[], execFlagIdx: number): strin
593
587
  // Path-shaped in the prefix region as candidates.
594
588
  const pickFindSearchRoot = (tokens: readonly string[], execFlagIdx: number): string => {
595
589
  const candidates: string[] = [];
596
- for (let i = 1; i < execFlagIdx; i++) {
590
+ for (let i = 1; i < execFlagIdx; i += 1) {
597
591
  const t = tokens[i]!;
598
592
  if (t.startsWith('-')) {
599
593
  break;
@@ -641,8 +635,8 @@ const extractExecCommands = (seg: Segment): string[] => {
641
635
  return [];
642
636
  }
643
637
  const out: string[] = [];
644
- const tokens = seg.tokens;
645
- for (let i = 1; i < tokens.length; i++) {
638
+ const { tokens } = seg;
639
+ for (let i = 1; i < tokens.length; i += 1) {
646
640
  if (!spec.execFlags.has(tokens[i]!)) {
647
641
  continue;
648
642
  }
@@ -657,7 +651,7 @@ const extractExecCommands = (seg: Segment): string[] => {
657
651
  break;
658
652
  }
659
653
  inner.push(t);
660
- j++;
654
+ j += 1;
661
655
  }
662
656
  if (inner.length === 0) {
663
657
  continue;
@@ -739,8 +733,8 @@ const extractShellWrappedCommands = (seg: Segment): string[] => {
739
733
  if (!SHELL_WRAPPER_HEADS.has(seg.head)) {
740
734
  return [];
741
735
  }
742
- const tokens = seg.tokens;
743
- for (let i = 1; i < tokens.length; i++) {
736
+ const { tokens } = seg;
737
+ for (let i = 1; i < tokens.length; i += 1) {
744
738
  const t = tokens[i]!;
745
739
  if (t === '-c' && i + 1 < tokens.length) {
746
740
  return [tokens[i + 1]!];
@@ -854,7 +848,7 @@ const skipHeadRenamingPrefix = (tokens: readonly string[]): number => {
854
848
  while (i < tokens.length) {
855
849
  const token = tokens[i]!;
856
850
  if (head === 'env' && tokenLooksLikeEnvAssignment(token)) {
857
- i++;
851
+ i += 1;
858
852
  continue;
859
853
  }
860
854
  if (valueFlags.has(token)) {
@@ -862,15 +856,15 @@ const skipHeadRenamingPrefix = (tokens: readonly string[]): number => {
862
856
  continue;
863
857
  }
864
858
  if (token.includes('=') && valueFlags.has(token.slice(0, token.indexOf('=')))) {
865
- i++;
859
+ i += 1;
866
860
  continue;
867
861
  }
868
862
  if (token.startsWith('--') && token !== '--') {
869
- i++;
863
+ i += 1;
870
864
  continue;
871
865
  }
872
866
  if (token.startsWith('-') && token !== '-') {
873
- i++;
867
+ i += 1;
874
868
  continue;
875
869
  }
876
870
  break;
@@ -883,7 +877,7 @@ const extractHeadRenamingCommands = (seg: Segment): string[] => {
883
877
  return [];
884
878
  }
885
879
  if (seg.head === 'script') {
886
- for (let i = 1; i < seg.tokens.length - 1; i++) {
880
+ for (let i = 1; i < seg.tokens.length - 1; i += 1) {
887
881
  const token = seg.tokens[i]!;
888
882
  if (token === '-c' || token === '--command') {
889
883
  return [seg.tokens[i + 1]!];
@@ -947,7 +941,7 @@ const skipRtkGlobalFlags = (tokens: readonly string[]): number => {
947
941
  while (i < tokens.length) {
948
942
  const t = tokens[i]!;
949
943
  if (t.startsWith('-') && t !== '-' && t !== '--') {
950
- i++;
944
+ i += 1;
951
945
  continue;
952
946
  }
953
947
  break;
@@ -956,7 +950,7 @@ const skipRtkGlobalFlags = (tokens: readonly string[]): number => {
956
950
  };
957
951
 
958
952
  const dashCommandArg = (tokens: readonly string[], start: number): string | null => {
959
- for (let k = start; k < tokens.length; k++) {
953
+ for (let k = start; k < tokens.length; k += 1) {
960
954
  const t = tokens[k]!;
961
955
  if ((t === '-c' || t === '--command') && k + 1 < tokens.length) {
962
956
  return tokens[k + 1]!;
@@ -989,11 +983,11 @@ const extractRtkCommands = (seg: Segment): string[] => {
989
983
  while (j < seg.tokens.length) {
990
984
  const t = seg.tokens[j]!;
991
985
  if (t === '--') {
992
- j++;
986
+ j += 1;
993
987
  break;
994
988
  }
995
989
  if (t.startsWith('-') && t !== '-') {
996
- j++;
990
+ j += 1;
997
991
  continue;
998
992
  }
999
993
  break;
@@ -1063,15 +1057,15 @@ const extractPrefixWrapperCommands = (seg: Segment): string[] => {
1063
1057
  continue;
1064
1058
  }
1065
1059
  if (t.includes('=') && spec.valueFlags.has(t.slice(0, t.indexOf('=')))) {
1066
- i++;
1060
+ i += 1;
1067
1061
  continue;
1068
1062
  }
1069
1063
  if (t === '--') {
1070
- i++;
1064
+ i += 1;
1071
1065
  break;
1072
1066
  }
1073
1067
  if (t.startsWith('-') && t !== '-') {
1074
- i++;
1068
+ i += 1;
1075
1069
  continue;
1076
1070
  }
1077
1071
  break;
@@ -1081,6 +1075,28 @@ const extractPrefixWrapperCommands = (seg: Segment): string[] => {
1081
1075
  return inner === '' ? [] : [inner];
1082
1076
  };
1083
1077
 
1078
+ // Shell control keywords keep an executable command on the same token vector.
1079
+ // For example, `then rm -rf /` arrives as one segment headed by `then`, which
1080
+ // hides `rm` from every policy rule. Reparse the tail as a command so nested
1081
+ // conditions and loop bodies pass through the normal rule pipeline.
1082
+ const COMPOUND_COMMAND_HEADS: ReadonlySet<string> = new Set([
1083
+ '!',
1084
+ 'if',
1085
+ 'elif',
1086
+ 'then',
1087
+ 'else',
1088
+ 'while',
1089
+ 'until',
1090
+ 'do',
1091
+ ]);
1092
+
1093
+ const extractCompoundKeywordCommand = (seg: Segment): string[] => {
1094
+ if (!COMPOUND_COMMAND_HEADS.has(seg.head) || seg.tokens.length < 2) {
1095
+ return [];
1096
+ }
1097
+ return [quote(seg.tokens.slice(1))];
1098
+ };
1099
+
1084
1100
  // Each extractor pulls the inner command(s) a wrapper hides on its own arg
1085
1101
  // Vector, to be re-parsed as additional segments so every rule sees what
1086
1102
  // Actually runs. Order is irrelevant — all results are unioned into `out`.
@@ -1091,8 +1107,31 @@ const SEGMENT_EXTRACTORS: readonly ((seg: Segment) => string[])[] = [
1091
1107
  extractEvalCommands,
1092
1108
  extractRtkCommands,
1093
1109
  extractPrefixWrapperCommands,
1110
+ extractCompoundKeywordCommand,
1094
1111
  ];
1095
1112
 
1113
+ const UNSUPPORTED_SHELL_HEAD = '__tripwire_unsupported_shell__';
1114
+
1115
+ const unsupportedShellSegment = (raw: string): Segment => ({
1116
+ head: UNSUPPORTED_SHELL_HEAD,
1117
+ tokens: [UNSUPPORTED_SHELL_HEAD],
1118
+ args: [],
1119
+ flags: [],
1120
+ redirects: [],
1121
+ raw,
1122
+ });
1123
+
1124
+ const containsUnsupportedShellStructure = (segments: readonly Segment[]): boolean =>
1125
+ segments.some((segment) => {
1126
+ if (segment.head === 'case' || segment.head === 'function' || segment.head === '{') {
1127
+ return true;
1128
+ }
1129
+ if (segment.tokens.includes('__op_(__') || segment.tokens.includes('__op_)__')) {
1130
+ return true;
1131
+ }
1132
+ return COMPOUND_COMMAND_HEADS.has(segment.head) && segment.tokens[1] === '{';
1133
+ });
1134
+
1096
1135
  const normalizeTopLevelNewlines = (cmd: string): string => {
1097
1136
  let out = '';
1098
1137
  let inSingle = false;
@@ -1131,7 +1170,7 @@ const parseCommand = (cmd: string): Segment[] => {
1131
1170
  try {
1132
1171
  entries = parse(cmdForParsing, PRESERVE_ENV);
1133
1172
  } catch {
1134
- return [];
1173
+ return [unsupportedShellSegment(cmdForParsing)];
1135
1174
  }
1136
1175
  entries = mergeAmpRedirects(entries);
1137
1176
  const fdBudget: FdBudget = { remaining: countFdPrefixRedirects(cmdForParsing) };
@@ -1155,6 +1194,10 @@ const parseCommand = (cmd: string): Segment[] => {
1155
1194
  out.push(seg);
1156
1195
  }
1157
1196
 
1197
+ if (containsUnsupportedShellStructure(out)) {
1198
+ out.push(unsupportedShellSegment(cmdForParsing));
1199
+ }
1200
+
1158
1201
  // Recursively analyze any embedded commands as additional segments. The
1159
1202
  // Outer segment's args are already opaque sentinels (safe-path-failing);
1160
1203
  // This catches dangerous inner commands the outer call would otherwise
@@ -1174,7 +1217,7 @@ const parseCommand = (cmd: string): Segment[] => {
1174
1217
  // Loop, but should only scan the segments that existed pre-extraction
1175
1218
  // To avoid re-processing extracted ones.
1176
1219
  const preExtractLen = out.length;
1177
- for (let k = 0; k < preExtractLen; k++) {
1220
+ for (let k = 0; k < preExtractLen; k += 1) {
1178
1221
  const seg = out[k]!;
1179
1222
  for (const extract of SEGMENT_EXTRACTORS) {
1180
1223
  for (const sub of extract(seg)) {
@@ -1270,11 +1313,12 @@ const safeScopesSummary = (
1270
1313
  // A legitimate bypass marker sits on the actual command line, which the
1271
1314
  // Mask leaves intact.
1272
1315
  const hasBypass = (cmd: string): boolean =>
1273
- /(?<prefix>^|\s)#\s*tripwire-allow\b/.test(maskLiteralHeredocBodies(cmd));
1316
+ /(?<prefix>^|\s)#\s*tripwire-allow:[ \t]*\S[^\r\n]*/.test(maskLiteralHeredocBodies(cmd));
1274
1317
 
1275
1318
  export type { Redirect, Segment };
1276
1319
  export {
1277
1320
  EXEC_SPECS,
1321
+ UNSUPPORTED_SHELL_HEAD,
1278
1322
  collectHeredocBodies,
1279
1323
  hasBypass,
1280
1324
  isSafePathTarget,
package/src/lib/config.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  // Config system using Effect Schema for validation and Effect for safe loading.
2
2
  // Config file: ~/.config/tripwire/config.json
3
- // Falls back to defaults if file doesn't exist or is invalid.
3
+ // Falls back to defaults only if the file does not exist.
4
4
 
5
- import { accessSync, constants, readFileSync } from 'node:fs';
5
+ import { readFileSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
 
8
- import { Cause, Effect, Schema } from 'effect';
8
+ import { Cause, Data, Effect, Schema } from 'effect';
9
9
 
10
10
  const BlockRuleSchema = Schema.Struct({
11
11
  pattern: Schema.String,
@@ -27,73 +27,102 @@ const SafePathsConfigSchema = Schema.Struct({
27
27
  absolute: Schema.optional(Schema.Array(Schema.String)),
28
28
  });
29
29
 
30
+ const ToolPolicyMatchSchema = Schema.Struct({
31
+ argumentsIncludeAll: Schema.optional(Schema.Array(Schema.String)),
32
+ argumentsStartWith: Schema.optional(Schema.Array(Schema.String)),
33
+ shortFlagsIncludeAll: Schema.optional(Schema.Array(Schema.String)),
34
+ });
35
+
36
+ const ToolPolicySchema = Schema.Struct({
37
+ rule: Schema.String,
38
+ executables: Schema.Array(Schema.String),
39
+ action: Schema.Union([Schema.Literal('deny'), Schema.Literal('warn')]),
40
+ message: Schema.String,
41
+ match: Schema.optional(ToolPolicyMatchSchema),
42
+ });
43
+
44
+ const SecretScannerConfigSchema = Schema.Struct({
45
+ executable: Schema.String,
46
+ timeoutMs: Schema.Finite.check(Schema.isGreaterThan(0)),
47
+ });
48
+
30
49
  const ConfigSchema = Schema.Struct({
31
50
  git: Schema.optional(GitConfigSchema),
32
51
  safePaths: Schema.optional(SafePathsConfigSchema),
52
+ toolPolicies: Schema.optional(Schema.Array(ToolPolicySchema)),
33
53
  blockedCommands: Schema.optional(Schema.Array(BlockRuleSchema)),
34
54
  allowedCommands: Schema.optional(Schema.Array(BlockRuleSchema)),
55
+ secretScanner: Schema.optional(SecretScannerConfigSchema),
35
56
  });
36
57
 
37
58
  const CONFIG_PATH = `${homedir()}/.config/tripwire/config.json`;
38
59
 
39
- const configExists = (path: string): Effect.Effect<boolean> =>
40
- Effect.sync(() => {
41
- try {
42
- accessSync(path, constants.R_OK);
43
- return true;
44
- } catch {
45
- return false;
46
- }
47
- });
60
+ class ConfigReadError extends Data.TaggedError('ConfigReadError')<{ readonly cause: unknown }> {}
61
+
62
+ class ConfigParseError extends Data.TaggedError('ConfigParseError')<{ readonly cause: unknown }> {}
48
63
 
49
- const readConfigFile = (path: string): Effect.Effect<string, Error> =>
50
- Effect.try({ try: () => readFileSync(path, 'utf8'), catch: (error) => error as Error });
64
+ const isMissingFile = (cause: unknown): boolean =>
65
+ cause instanceof Error && 'code' in cause && cause.code === 'ENOENT';
66
+
67
+ const readConfigFile = (path: string): Effect.Effect<string | null, ConfigReadError> =>
68
+ Effect.try({
69
+ try: () => readFileSync(path, 'utf8'),
70
+ catch: (cause) => new ConfigReadError({ cause }),
71
+ }).pipe(
72
+ Effect.catchTag('ConfigReadError', (error) =>
73
+ isMissingFile(error.cause) ? Effect.succeed(null) : Effect.fail(error),
74
+ ),
75
+ );
51
76
 
52
77
  const parseConfigJson = (raw: string): Effect.Effect<unknown, Error> =>
53
- Effect.try({ try: () => JSON.parse(raw) as unknown, catch: (error) => error as Error });
78
+ Effect.try({
79
+ try: () => JSON.parse(raw) as unknown,
80
+ catch: (cause) => new ConfigParseError({ cause }),
81
+ });
54
82
 
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.
83
+ // Reject unknown keys so a misspelled policy cannot disappear silently.
59
84
  const decodeConfig = (unknown: unknown): Effect.Effect<Config, Error> =>
60
85
  Schema.decodeUnknownEffect(ConfigSchema)(unknown, { onExcessProperty: 'error' });
61
86
 
62
- const getDefaultConfig = (): Config => ({
63
- git: {
64
- protectedBranches: ['main', 'master', 'develop', 'production', 'release'],
65
- enforceConventionalCommits: true,
66
- },
87
+ const getDefaultConfig = (): ResolvedConfig => ({
88
+ git: { protectedBranches: [], enforceConventionalCommits: false },
67
89
  safePaths: {},
90
+ toolPolicies: [],
68
91
  blockedCommands: [],
69
92
  allowedCommands: [],
93
+ secretScanner: { executable: 'betterleaks', timeoutMs: 5000 },
70
94
  });
71
95
 
72
- const mergeWithDefaults = (partial: Config): Config => ({
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
- // 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.
96
+ const mergeWithDefaults = (partial: Config): ResolvedConfig => {
97
+ const defaults = getDefaultConfig();
98
+ return {
99
+ git: {
100
+ protectedBranches: partial.git?.protectedBranches ?? defaults.git.protectedBranches,
101
+ enforceConventionalCommits:
102
+ partial.git?.enforceConventionalCommits ?? defaults.git.enforceConventionalCommits,
103
+ },
104
+ safePaths: { ...defaults.safePaths, ...partial.safePaths },
105
+ toolPolicies: partial.toolPolicies ?? defaults.toolPolicies,
106
+ blockedCommands: partial.blockedCommands ?? defaults.blockedCommands,
107
+ allowedCommands: partial.allowedCommands ?? defaults.allowedCommands,
108
+ secretScanner: partial.secretScanner ?? defaults.secretScanner,
109
+ };
110
+ };
111
+
112
+ // A present but invalid config must not fall back to defaults because that
113
+ // would drop custom safety policy. Only a missing file selects defaults.
84
114
  type ConfigLoad =
85
- | { readonly ok: true; readonly config: Config }
115
+ | { readonly ok: true; readonly config: ResolvedConfig }
86
116
  | { readonly ok: false; readonly error: string };
87
117
 
88
118
  export const loadConfigResult = (path: string = CONFIG_PATH): Effect.Effect<ConfigLoad> =>
89
- Effect.gen(function* () {
90
- const exists = yield* configExists(path);
91
- if (!exists) {
119
+ Effect.gen(function* loadConfigResultEffect() {
120
+ const raw = yield* readConfigFile(path);
121
+ if (raw === null) {
92
122
  const result: ConfigLoad = { ok: true, config: getDefaultConfig() };
93
123
  return result;
94
124
  }
95
125
 
96
- const raw = yield* readConfigFile(path);
97
126
  const parsed = yield* parseConfigJson(raw);
98
127
  const config = yield* decodeConfig(parsed);
99
128
  const result: ConfigLoad = { ok: true, config: mergeWithDefaults(config) };
@@ -106,10 +135,8 @@ export const loadConfigResult = (path: string = CONFIG_PATH): Effect.Effect<Conf
106
135
  }),
107
136
  );
108
137
 
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> =>
138
+ // Library consumers get a loud failure instead of an unconfigured fallback.
139
+ export const loadConfig = (path: string = CONFIG_PATH): Effect.Effect<ResolvedConfig> =>
113
140
  loadConfigResult(path).pipe(
114
141
  Effect.flatMap((result) =>
115
142
  result.ok
@@ -121,7 +148,27 @@ export const loadConfig = (path: string = CONFIG_PATH): Effect.Effect<Config> =>
121
148
  export type BlockRule = typeof BlockRuleSchema.Type;
122
149
  export type GitConfig = typeof GitConfigSchema.Type;
123
150
  export type SafePathsConfig = typeof SafePathsConfigSchema.Type;
151
+ export type ToolPolicy = typeof ToolPolicySchema.Type;
152
+ export type SecretScannerConfig = typeof SecretScannerConfigSchema.Type;
124
153
  export type Config = typeof ConfigSchema.Type;
125
154
 
155
+ export interface ResolvedConfig {
156
+ readonly git: {
157
+ readonly protectedBranches: readonly string[];
158
+ readonly enforceConventionalCommits: boolean;
159
+ };
160
+ readonly safePaths: SafePathsConfig;
161
+ readonly toolPolicies: readonly ToolPolicy[];
162
+ readonly blockedCommands: readonly BlockRule[];
163
+ readonly allowedCommands: readonly BlockRule[];
164
+ readonly secretScanner: SecretScannerConfig;
165
+ }
166
+
126
167
  export type { ConfigLoad };
127
- export { CONFIG_PATH, ConfigSchema, getDefaultConfig, mergeWithDefaults };
168
+ export {
169
+ CONFIG_PATH,
170
+ ConfigSchema,
171
+ SecretScannerConfigSchema,
172
+ getDefaultConfig,
173
+ mergeWithDefaults,
174
+ };