@seanmozeik/tripwire 0.6.3 → 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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanmozeik/tripwire",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
5
5
  "license": "MIT",
6
6
  "bin": {
package/src/dispatch.ts CHANGED
@@ -18,8 +18,14 @@ import { BunRuntime } from '@effect/platform-bun';
18
18
  import { Cause, Effect, Exit, Schema } from 'effect';
19
19
 
20
20
  import { parseCommand } from './lib/bash';
21
- import { getDefaultConfig, loadConfig, mergeWithDefaults, type Config } from './lib/config';
22
- import { type Decision, allow, merge } from './lib/decision';
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';
23
29
  import {
24
30
  type BashInput,
25
31
  type EditInput,
@@ -255,8 +261,17 @@ const handleAllow = (event: HookEvent, decision: Decision, config: Config): void
255
261
  writeAllow();
256
262
  };
257
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
+
258
273
  const program = Effect.gen(function* () {
259
- const config = yield* loadConfig();
274
+ const configLoad = yield* loadConfigResult();
260
275
  const raw = yield* Effect.promise(readStdin);
261
276
 
262
277
  const parseExit = yield* Effect.exit(
@@ -278,6 +293,22 @@ const program = Effect.gen(function* () {
278
293
  }
279
294
  const event = decodeExit.value;
280
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
+
281
312
  if (event.hook_event_name === 'PreToolUse') {
282
313
  const decision = decide(event, config);
283
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/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,
@@ -36,24 +36,28 @@ const ConfigSchema = Schema.Struct({
36
36
 
37
37
  const CONFIG_PATH = `${homedir()}/.config/tripwire/config.json`;
38
38
 
39
- const configExists = (): Effect.Effect<boolean> =>
39
+ const configExists = (path: string): Effect.Effect<boolean> =>
40
40
  Effect.sync(() => {
41
41
  try {
42
- accessSync(CONFIG_PATH, constants.R_OK);
42
+ accessSync(path, constants.R_OK);
43
43
  return true;
44
44
  } catch {
45
45
  return false;
46
46
  }
47
47
  });
48
48
 
49
- const readConfigFile = (): Effect.Effect<string, Error> =>
50
- Effect.try({ try: () => readFileSync(CONFIG_PATH, 'utf8'), catch: (error) => error as Error });
49
+ const readConfigFile = (path: string): Effect.Effect<string, Error> =>
50
+ Effect.try({ try: () => readFileSync(path, 'utf8'), catch: (error) => error as Error });
51
51
 
52
52
  const parseConfigJson = (raw: string): Effect.Effect<unknown, Error> =>
53
53
  Effect.try({ try: () => JSON.parse(raw) as unknown, catch: (error) => error as Error });
54
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.
55
59
  const decodeConfig = (unknown: unknown): Effect.Effect<Config, Error> =>
56
- Schema.decodeUnknownEffect(ConfigSchema)(unknown);
60
+ Schema.decodeUnknownEffect(ConfigSchema)(unknown, { onExcessProperty: 'error' });
57
61
 
58
62
  const getDefaultConfig = (): Config => ({
59
63
  git: {
@@ -72,30 +76,52 @@ const mergeWithDefaults = (partial: Config): Config => ({
72
76
  allowedCommands: partial.allowedCommands ?? getDefaultConfig().allowedCommands,
73
77
  });
74
78
 
75
- export const loadConfig = (): Effect.Effect<Config> =>
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> =>
76
89
  Effect.gen(function* () {
77
- const exists = yield* configExists();
90
+ const exists = yield* configExists(path);
78
91
  if (!exists) {
79
- return getDefaultConfig();
92
+ const result: ConfigLoad = { ok: true, config: getDefaultConfig() };
93
+ return result;
80
94
  }
81
95
 
82
- const raw = yield* readConfigFile();
96
+ const raw = yield* readConfigFile(path);
83
97
  const parsed = yield* parseConfigJson(raw);
84
98
  const config = yield* decodeConfig(parsed);
85
- return mergeWithDefaults(config);
99
+ const result: ConfigLoad = { ok: true, config: mergeWithDefaults(config) };
100
+ return result;
86
101
  }).pipe(
87
102
  Effect.timeout(1000),
88
- // oxlint-disable-next-line promise/prefer-await-to-then
89
- Effect.catch(() => {
90
- // Log error but return defaults to never block the agent
91
- console.error('[tripwire] Config loading failed, using defaults');
92
- return Effect.succeed(getDefaultConfig());
103
+ Effect.catchCause((cause) => {
104
+ const result: ConfigLoad = { ok: false, error: Cause.pretty(cause) };
105
+ return Effect.succeed(result);
93
106
  }),
94
107
  );
95
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
+
96
121
  export type BlockRule = typeof BlockRuleSchema.Type;
97
122
  export type GitConfig = typeof GitConfigSchema.Type;
98
123
  export type SafePathsConfig = typeof SafePathsConfigSchema.Type;
99
124
  export type Config = typeof ConfigSchema.Type;
100
125
 
126
+ export type { ConfigLoad };
101
127
  export { CONFIG_PATH, ConfigSchema, getDefaultConfig, mergeWithDefaults };