proactive-gate 0.1.1 → 0.2.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,31 @@
1
+ /**
2
+ * An OpenAI Agents SDK tool input guardrail. Attach it to the send tool with
3
+ * `defineToolInputGuardrail`-compatible shape; a rejection trips the wire with
4
+ * the reason in outputInfo.
5
+ *
6
+ * tool({ name: "send_message", inputGuardrails: [gateToolInputGuardrail({ gate, toInput: ({ input }) => input.gate })] })
7
+ */
8
+ import type { Gate } from "../gate.js";
9
+ import type { EvaluateInput } from "../types.js";
10
+ export interface GuardrailArgs<I = unknown> {
11
+ input: I;
12
+ context?: unknown;
13
+ }
14
+ export interface GuardrailResult {
15
+ tripwireTriggered: boolean;
16
+ outputInfo: {
17
+ reason?: string;
18
+ surfaces?: string[];
19
+ deliverAt?: string;
20
+ };
21
+ }
22
+ export interface ToolInputGuardrail<I = unknown> {
23
+ name: string;
24
+ execute(args: GuardrailArgs<I>): Promise<GuardrailResult>;
25
+ }
26
+ export declare function gateToolInputGuardrail<I = unknown>(options: {
27
+ gate: Gate;
28
+ toInput: (args: GuardrailArgs<I>) => EvaluateInput;
29
+ name?: string;
30
+ commit?: boolean;
31
+ }): ToolInputGuardrail<I>;
@@ -0,0 +1,16 @@
1
+ import { describe } from "./ai-sdk.js";
2
+ export function gateToolInputGuardrail(options) {
3
+ const commit = options.commit ?? true;
4
+ return {
5
+ name: options.name ?? "proactive-gate",
6
+ async execute(args) {
7
+ const input = options.toInput(args);
8
+ const decision = await options.gate.evaluate(input);
9
+ if (!decision.allowed)
10
+ return { tripwireTriggered: true, outputInfo: { reason: describe(decision) } };
11
+ if (commit && !(await options.gate.commit(decision, input)))
12
+ return { tripwireTriggered: true, outputInfo: { reason: "a budget was exhausted at commit" } };
13
+ return { tripwireTriggered: false, outputInfo: { surfaces: decision.surfaces, ...(decision.deliverAt ? { deliverAt: decision.deliverAt.toISOString() } : {}) } };
14
+ },
15
+ };
16
+ }
@@ -17,8 +17,10 @@ export declare function enabled(): Check;
17
17
  export declare function mode(options: {
18
18
  allow: string[];
19
19
  }): Check;
20
- /** A global pause until an instant. */
21
- export declare function snooze(): Check;
20
+ /** A global pause until an instant. With `defer: true` the decision carries the instant as `retryAt` instead of rejecting. */
21
+ export declare function snooze(options?: {
22
+ defer?: boolean;
23
+ }): Check;
22
24
  /** Per-type mute. */
23
25
  export declare function mute(): Check;
24
26
  /**
@@ -59,23 +61,86 @@ export declare function adaptiveTiming(options?: {
59
61
  nextGoodMoment?: (ctx: CheckContext) => Promise<Date | null> | Date | null;
60
62
  surfacesFor?: (ctx: CheckContext) => Surface[] | null;
61
63
  }): Check;
62
- /**
63
- * At most `limit` deliveries per user per local day. The check reads the
64
- * counter; gate.commit() increments it atomically and can still refuse when
65
- * two instances race, which is the only race-safe place to enforce a cap.
66
- */
67
64
  export interface BudgetCheck extends Check {
68
65
  limit: number;
66
+ consume(ctx: CheckContext): Promise<boolean>;
69
67
  }
70
- export declare function dailyBudget(options?: {
68
+ export interface BudgetOptions {
71
69
  limit?: number;
72
70
  bypassPriority?: Priority;
73
- }): BudgetCheck;
71
+ /** Fraction of the limit at which a pass carries a nearLimit note. Default 0.8. */
72
+ nearLimit?: number;
73
+ }
74
74
  export declare const budgetKey: (userId: string, now: Date, timezone?: string) => string;
75
+ export declare const weeklyBudgetKey: (userId: string, now: Date, timezone?: string) => string;
76
+ export declare const monthlyBudgetKey: (userId: string, now: Date, timezone?: string) => string;
77
+ /** At most `limit` deliveries per user per local day. */
78
+ export declare function dailyBudget(options?: BudgetOptions): BudgetCheck;
79
+ /** At most `limit` deliveries per user per local ISO week. */
80
+ export declare function weeklyBudget(options?: BudgetOptions): BudgetCheck;
81
+ /** At most `limit` deliveries per user per local calendar month. */
82
+ export declare function monthlyBudget(options?: BudgetOptions): BudgetCheck;
83
+ /**
84
+ * Horvitz's expected-utility rule with the PRISM threshold: act only when the
85
+ * caller's estimate of acceptance clears tau = cFA / (cFA + pNeed * cFN).
86
+ * `candidate.pAccept` and `candidate.pNeed` come from the caller's own model.
87
+ */
88
+ export declare function utilityFloor(options: {
89
+ costFalseAlarm: number;
90
+ costMissedHelp: number;
91
+ }): Check;
92
+ /**
93
+ * Bounded deferral (Horvitz): when the user is busy, wait t* = min(bound,
94
+ * lambda * interruptCost / (2 * staleness)), the optimum of a quadratic
95
+ * staleness loss against the cost of interrupting a busy person, with the
96
+ * user becoming free at rate lambda. Never rejects; only moves deliverAt.
97
+ */
98
+ export declare function boundedDeferral(options?: {
99
+ lambda?: number;
100
+ interruptCost?: number;
101
+ staleness?: number;
102
+ boundSeconds?: number;
103
+ isBusy?: (ctx: CheckContext) => boolean;
104
+ }): Check;
105
+ /** Deliveries only inside [start, end) local time in a fixed zone or the user's. */
106
+ export declare function allowedWindow(options: {
107
+ start: string;
108
+ end: string;
109
+ timezone: string;
110
+ priorityFloor?: Priority;
111
+ id?: string;
112
+ }): Check;
113
+ /** Requires `user.consents[name]`, always or only inside a local-time window. */
114
+ export declare function requiresConsent(options: {
115
+ name: string;
116
+ when?: {
117
+ start: string;
118
+ end: string;
119
+ timezone: string;
120
+ };
121
+ id?: string;
122
+ }): Check;
123
+ /** Fixed-window rate limit keyed by user or by candidate.channel; consumed at commit. */
124
+ export declare function rateLimit(options: {
125
+ limit: number;
126
+ perSeconds: number;
127
+ keyBy?: "user" | "channel";
128
+ id?: string;
129
+ }): BudgetCheck;
130
+ /** The user wrote to the assistant within the last `withinHours`. */
131
+ export declare function recentInteraction(options: {
132
+ withinHours: number;
133
+ }): Check;
134
+ /** At most `limit` deliveries in the `withinHours` window that opened with the user's last inbound message. */
135
+ export declare function windowBudget(options: {
136
+ limit: number;
137
+ withinHours: number;
138
+ }): BudgetCheck;
75
139
  /** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
76
140
  export declare function defaultChecks(options?: {
77
141
  killSwitch?: () => boolean | Promise<boolean>;
78
142
  modes?: string[];
79
143
  dailyLimit?: number;
144
+ weeklyLimit?: number;
80
145
  quietHoursFloor?: Priority;
81
146
  }): Check[];
@@ -2,6 +2,7 @@ import { PRIORITY_RANK } from "./types.js";
2
2
  const pass = { kind: "pass" };
3
3
  const reject = (reason) => ({ kind: "reject", reason });
4
4
  const skip = (reason) => ({ kind: "skip", reason });
5
+ const defer = (reason, retryAt) => ({ kind: "defer", reason, retryAt });
5
6
  const atLeast = (priority, floor) => PRIORITY_RANK[priority] >= PRIORITY_RANK[floor];
6
7
  const toDate = (value) => {
7
8
  if (value === null || value === undefined)
@@ -37,6 +38,7 @@ export function inWindow(minutes, start, end) {
37
38
  return false;
38
39
  return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end;
39
40
  }
41
+ const localDay = (now, timezone) => (timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10));
40
42
  /* ------------------------------------------------------------------------ */
41
43
  /* The checks, in the order LILA runs them. Compose your own order freely. */
42
44
  /* ------------------------------------------------------------------------ */
@@ -72,13 +74,16 @@ export function mode(options) {
72
74
  : pass,
73
75
  };
74
76
  }
75
- /** A global pause until an instant. */
76
- export function snooze() {
77
+ /** A global pause until an instant. With `defer: true` the decision carries the instant as `retryAt` instead of rejecting. */
78
+ export function snooze(options = {}) {
77
79
  return {
78
80
  id: "snooze",
79
81
  run: ({ user, now }) => {
80
82
  const until = toDate(user.snoozedUntil);
81
- return until && until > now ? reject(`snoozed until ${until.toISOString()}`) : pass;
83
+ if (!until || until <= now)
84
+ return pass;
85
+ const reason = `snoozed until ${until.toISOString()}`;
86
+ return options.defer ? defer(reason, until) : reject(reason);
82
87
  },
83
88
  };
84
89
  }
@@ -196,21 +201,167 @@ export function adaptiveTiming(options = {}) {
196
201
  },
197
202
  };
198
203
  }
199
- export function dailyBudget(options = {}) {
200
- const limit = options.limit ?? 5;
204
+ function budget(spec, options) {
205
+ const limit = options.limit ?? spec.defaultLimit;
206
+ const nearAt = Math.max(1, Math.ceil(limit * (options.nearLimit ?? 0.8)));
207
+ const bypass = (priority) => options.bypassPriority !== undefined && atLeast(priority, options.bypassPriority);
201
208
  return {
202
- id: "dailyBudget",
209
+ id: spec.id,
203
210
  limit,
204
- async run({ user, now, store, priority }) {
205
- if (options.bypassPriority && atLeast(priority, options.bypassPriority))
211
+ async run(ctx) {
212
+ if (bypass(ctx.priority))
206
213
  return pass;
207
- const key = budgetKey(user.id, now, user.timezone);
208
- const used = Number((await store.get(key)) ?? 0);
209
- return used < limit ? pass : reject(`daily budget of ${limit} used (${used})`);
214
+ const used = Number((await ctx.store.get(spec.keyFor(ctx))) ?? 0);
215
+ if (used >= limit)
216
+ return reject(`${spec.label} of ${limit} used (${used})`);
217
+ return used >= nearAt ? { kind: "pass", reason: `${used} of ${limit} used`, nearLimit: { used, limit } } : pass;
210
218
  },
219
+ async consume(ctx) {
220
+ if (bypass(ctx.priority))
221
+ return true;
222
+ const used = await ctx.store.incr(spec.keyFor(ctx), spec.ttlSeconds);
223
+ return used <= limit;
224
+ },
225
+ };
226
+ }
227
+ export const budgetKey = (userId, now, timezone) => `budget:${userId}:${localDay(now, timezone)}`;
228
+ const isoWeekKey = (day) => {
229
+ const date = new Date(`${day}T00:00:00Z`);
230
+ const weekday = date.getUTCDay() || 7;
231
+ date.setUTCDate(date.getUTCDate() + 4 - weekday);
232
+ const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
233
+ const week = Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
234
+ return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
235
+ };
236
+ export const weeklyBudgetKey = (userId, now, timezone) => `weeklyBudget:${userId}:${isoWeekKey(localDay(now, timezone))}`;
237
+ export const monthlyBudgetKey = (userId, now, timezone) => `monthlyBudget:${userId}:${localDay(now, timezone).slice(0, 7)}`;
238
+ /** At most `limit` deliveries per user per local day. */
239
+ export function dailyBudget(options = {}) {
240
+ return budget({ id: "dailyBudget", label: "daily budget", defaultLimit: 5, keyFor: ({ user, now }) => budgetKey(user.id, now, user.timezone), ttlSeconds: 2 * DAY_SECONDS }, options);
241
+ }
242
+ /** At most `limit` deliveries per user per local ISO week. */
243
+ export function weeklyBudget(options = {}) {
244
+ return budget({ id: "weeklyBudget", label: "weekly budget", defaultLimit: 20, keyFor: ({ user, now }) => weeklyBudgetKey(user.id, now, user.timezone), ttlSeconds: 8 * DAY_SECONDS }, options);
245
+ }
246
+ /** At most `limit` deliveries per user per local calendar month. */
247
+ export function monthlyBudget(options = {}) {
248
+ return budget({ id: "monthlyBudget", label: "monthly budget", defaultLimit: 60, keyFor: ({ user, now }) => monthlyBudgetKey(user.id, now, user.timezone), ttlSeconds: 32 * DAY_SECONDS }, options);
249
+ }
250
+ /* ------------------------------------------------------------------------ */
251
+ /* Optional, caller-fed checks. Off by default; the package ships no model. */
252
+ /* ------------------------------------------------------------------------ */
253
+ /**
254
+ * Horvitz's expected-utility rule with the PRISM threshold: act only when the
255
+ * caller's estimate of acceptance clears tau = cFA / (cFA + pNeed * cFN).
256
+ * `candidate.pAccept` and `candidate.pNeed` come from the caller's own model.
257
+ */
258
+ export function utilityFloor(options) {
259
+ const { costFalseAlarm: cFA, costMissedHelp: cFN } = options;
260
+ return {
261
+ id: "utilityFloor",
262
+ run: ({ candidate }) => {
263
+ if (typeof candidate.pAccept !== "number")
264
+ return skip("no pAccept on the candidate; utility floor cannot be evaluated");
265
+ const pNeed = typeof candidate.pNeed === "number" ? candidate.pNeed : 1;
266
+ const tau = cFA / (cFA + pNeed * cFN);
267
+ return candidate.pAccept >= tau ? pass : reject(`pAccept ${round3(candidate.pAccept)} < tau ${round3(tau)}`);
268
+ },
269
+ };
270
+ }
271
+ const round3 = (n) => Math.round(n * 1000) / 1000;
272
+ /**
273
+ * Bounded deferral (Horvitz): when the user is busy, wait t* = min(bound,
274
+ * lambda * interruptCost / (2 * staleness)), the optimum of a quadratic
275
+ * staleness loss against the cost of interrupting a busy person, with the
276
+ * user becoming free at rate lambda. Never rejects; only moves deliverAt.
277
+ */
278
+ export function boundedDeferral(options = {}) {
279
+ const lambda = options.lambda ?? 1 / 43;
280
+ const cost = options.interruptCost ?? 1;
281
+ const staleness = options.staleness ?? 0.0001;
282
+ const bound = options.boundSeconds ?? 240;
283
+ const tStar = Math.min(bound, (lambda * cost) / (2 * staleness));
284
+ return {
285
+ id: "boundedDeferral",
286
+ nonRejecting: true,
287
+ run: (ctx) => {
288
+ const busy = options.isBusy ? options.isBusy(ctx) : ctx.candidate.busy === true;
289
+ if (!busy)
290
+ return pass;
291
+ const at = new Date(ctx.now.getTime() + Math.round(tStar * 1000));
292
+ return { kind: "adjust", reason: `user busy; deliver at ${at.toISOString()} (t* ${Math.round(tStar)} s)`, deliverAt: at };
293
+ },
294
+ };
295
+ }
296
+ /* ------------------------------------------------------------------------ */
297
+ /* Primitives the presets compose. */
298
+ /* ------------------------------------------------------------------------ */
299
+ const zoneOf = (ctx, timezone) => (timezone === "user" ? ctx.user.timezone : timezone);
300
+ /** Deliveries only inside [start, end) local time in a fixed zone or the user's. */
301
+ export function allowedWindow(options) {
302
+ const start = parseHHMM(options.start);
303
+ const end = parseHHMM(options.end);
304
+ return {
305
+ id: options.id ?? "allowedWindow",
306
+ run: (ctx) => {
307
+ const zone = zoneOf(ctx, options.timezone);
308
+ if (!zone)
309
+ return skip("no timezone on the user; window cannot be evaluated");
310
+ if (options.priorityFloor && atLeast(ctx.priority, options.priorityFloor))
311
+ return pass;
312
+ const { minutes } = localClock(ctx.now, zone);
313
+ return inWindow(minutes, start, end) ? pass : reject(`outside the allowed window ${options.start} to ${options.end} ${zone}`);
314
+ },
315
+ };
316
+ }
317
+ /** Requires `user.consents[name]`, always or only inside a local-time window. */
318
+ export function requiresConsent(options) {
319
+ const when = options.when ? { start: parseHHMM(options.when.start), end: parseHHMM(options.when.end), timezone: options.when.timezone } : null;
320
+ return {
321
+ id: options.id ?? `consent:${options.name}`,
322
+ run: (ctx) => {
323
+ if (when) {
324
+ const zone = zoneOf(ctx, when.timezone);
325
+ if (!zone)
326
+ return skip("no timezone on the user; consent window cannot be evaluated");
327
+ if (!inWindow(localClock(ctx.now, zone).minutes, when.start, when.end))
328
+ return pass;
329
+ }
330
+ return ctx.user.consents?.[options.name] ? pass : reject(`consent "${options.name}" is missing${when ? ` (required ${options.when.start} to ${options.when.end})` : ""}`);
331
+ },
332
+ };
333
+ }
334
+ /** Fixed-window rate limit keyed by user or by candidate.channel; consumed at commit. */
335
+ export function rateLimit(options) {
336
+ const keyBy = options.keyBy ?? "user";
337
+ const keyFor = (ctx) => {
338
+ const scope = keyBy === "channel" ? ctx.candidate.channel ?? ctx.user.id : ctx.user.id;
339
+ return `rate:${keyBy}:${scope}:${options.perSeconds}:${Math.floor(ctx.now.getTime() / 1000 / options.perSeconds)}`;
340
+ };
341
+ const id = options.id ?? `rate:${options.limit}/${options.perSeconds}s`;
342
+ return budget({ id, label: `rate limit ${options.limit} per ${options.perSeconds} s`, defaultLimit: options.limit, keyFor, ttlSeconds: options.perSeconds * 2 }, { limit: options.limit, nearLimit: 1 });
343
+ }
344
+ /** The user wrote to the assistant within the last `withinHours`. */
345
+ export function recentInteraction(options) {
346
+ return {
347
+ id: "recentInteraction",
348
+ run: ({ user, now }) => {
349
+ const last = toDate(user.lastInboundAt);
350
+ if (!last)
351
+ return reject("no inbound message from the user on record");
352
+ const age = (now.getTime() - last.getTime()) / 3600000;
353
+ return age <= options.withinHours ? pass : reject(`last inbound message ${Math.floor(age)} h ago, window is ${options.withinHours} h`);
354
+ },
355
+ };
356
+ }
357
+ /** At most `limit` deliveries in the `withinHours` window that opened with the user's last inbound message. */
358
+ export function windowBudget(options) {
359
+ const keyFor = ({ user }) => {
360
+ const last = toDate(user.lastInboundAt);
361
+ return `windowBudget:${user.id}:${last ? Math.floor(last.getTime() / 1000) : "none"}`;
211
362
  };
363
+ return budget({ id: "windowBudget", label: `window budget`, defaultLimit: options.limit, keyFor, ttlSeconds: options.withinHours * 3600 }, { limit: options.limit, nearLimit: 1 });
212
364
  }
213
- export const budgetKey = (userId, now, timezone) => `budget:${userId}:${timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10)}`;
214
365
  /** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
215
366
  export function defaultChecks(options = {}) {
216
367
  return [
@@ -225,6 +376,7 @@ export function defaultChecks(options = {}) {
225
376
  trustRamp(),
226
377
  dismissalCooldown(),
227
378
  adaptiveTiming(),
379
+ ...(options.weeklyLimit === undefined ? [] : [weeklyBudget({ limit: options.weeklyLimit })]),
228
380
  dailyBudget({ limit: options.dailyLimit ?? 5 }),
229
381
  ];
230
382
  }
package/dist/src/cli.d.ts CHANGED
@@ -1,6 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import type { Gate } from "./gate.js";
3
- import type { Decision } from "./types.js";
3
+ import type { Decision, EvaluateInput } from "./types.js";
4
4
  export declare function loadPolicy(path?: string): Promise<Gate>;
5
5
  export declare function replay(lines: string[], gate: Gate, commit: boolean): Promise<Decision[]>;
6
6
  export declare function summarize(decisions: Decision[]): string;
7
+ interface PreToolUseEvent {
8
+ tool_name?: string;
9
+ tool_input?: {
10
+ gate?: {
11
+ user: EvaluateInput["user"];
12
+ candidate: EvaluateInput["candidate"];
13
+ now?: string;
14
+ };
15
+ };
16
+ }
17
+ /** Turns a PreToolUse event into the hook output Claude Code expects, or null when the tool does not match. */
18
+ export declare function hookDecision(event: PreToolUseEvent, gate: Gate, tool: string): Promise<string | null>;
19
+ export {};
package/dist/src/cli.js CHANGED
@@ -1,31 +1,65 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * proactive-gate replay <events.jsonl> [--policy <module>] [--json]
3
+ * proactive-gate replay <events.jsonl> [--policy <file>] [--json] [--commit]
4
+ * proactive-gate replay --fixtures <dir>
5
+ * proactive-gate hook --policy <file> [--tool <name>]
4
6
  *
5
- * Replays candidate messages through a gate and prints why each one was or
7
+ * replay feeds candidate messages through a gate and prints why each one was or
6
8
  * was not allowed. Each JSONL line is an EvaluateInput: { user, candidate, now? }.
7
- * The policy module must export `gate` (a Gate) or default-export one; without
8
- * --policy the default check order runs against an in-memory store.
9
+ * A policy is a JSON document (spec/schema/policy.schema.json) or an ES module
10
+ * that exports `gate`; without --policy the default check order runs against an
11
+ * in-memory store. --fixtures runs the conformance suite instead.
12
+ *
13
+ * hook reads a Claude Code PreToolUse event on stdin and prints a permission
14
+ * decision for the matching tool.
9
15
  */
10
- import { readFile } from "node:fs/promises";
16
+ import { readFile, writeFile } from "node:fs/promises";
17
+ import { existsSync } from "node:fs";
11
18
  import { pathToFileURL } from "node:url";
12
19
  import { resolve } from "node:path";
20
+ import { createRequire } from "node:module";
13
21
  import { createGate } from "./gate.js";
14
22
  import { defaultChecks } from "./checks.js";
15
- const HELP = `usage: proactive-gate replay <events.jsonl> [--policy <module.js>] [--json] [--commit]
23
+ import { loadFixtures, readSkips, runFixture } from "./conformance.js";
24
+ import { FRAMEWORKS, listText, plan } from "./init.js";
25
+ const HELP = `usage: proactive-gate init [--preset <name>] [--framework <name>] [--out <file>]
26
+ proactive-gate replay <events.jsonl> [--policy <file>] [--json] [--commit]
27
+ proactive-gate replay --fixtures <dir> [--skip <file>]
28
+ proactive-gate hook --policy <file> [--tool <name>]
29
+
30
+ init writes a policy you can read and edit, and prints the lines that wire it in.
16
31
 
17
- Replays candidates through a gate and reports what was allowed and why not.
32
+ --preset <name> a platform or legal preset to append (see --list)
33
+ --framework <name> ${FRAMEWORKS.join(", ")} (default none)
34
+ --out <file> where to write (default proactive-gate.policy.json)
35
+ --force overwrite an existing file
36
+ --list print the presets and frameworks and exit
18
37
 
19
- --policy <module> ES module exporting \`gate\` (or default) built with createGate()
38
+ replay reports what was allowed and why not.
39
+
40
+ --policy <file> policy.json (spec/schema/policy.schema.json) or an ES module
41
+ exporting \`gate\` (or default) built with createGate()
20
42
  --json one Decision per line instead of the summary table
21
- --commit also call gate.commit() for allowed decisions, so the daily
22
- budget is consumed in order, as it would be in production
43
+ --commit also call gate.commit() for allowed decisions, so budgets are
44
+ consumed in order, as they would be in production
45
+ --fixtures <dir> run the conformance fixtures under <dir> and report failures
46
+ --skip <file> fixture names to skip, one per line (default spec/skip/ts.txt)
47
+
48
+ hook reads a PreToolUse event (JSON) on stdin; when tool_name matches --tool
49
+ (default send_message) it evaluates tool_input.gate = { user, candidate, now? }
50
+ and prints a permissionDecision. Other tools print nothing.
51
+
23
52
  -h, --help this text
53
+ --version print the version
24
54
 
25
- Each line of the file is {"user": {...}, "candidate": {...}, "now": "ISO date"}.`;
55
+ Each line of an events file is {"user": {...}, "candidate": {...}, "now": "ISO date"}.`;
26
56
  export async function loadPolicy(path) {
27
57
  if (!path)
28
58
  return createGate({ checks: defaultChecks() });
59
+ if (path.endsWith(".json")) {
60
+ const policy = JSON.parse(await readFile(path, "utf8"));
61
+ return createGate({ policy });
62
+ }
29
63
  const mod = await import(pathToFileURL(resolve(path)).href);
30
64
  const gate = mod.gate ?? mod.default;
31
65
  if (!gate || typeof gate.evaluate !== "function")
@@ -50,8 +84,8 @@ export async function replay(lines, gate, commit) {
50
84
  const ok = await gate.commit(decision, input);
51
85
  if (!ok) {
52
86
  decision.allowed = false;
53
- decision.rejectedBy = "dailyBudget";
54
- decision.reason = "daily budget exhausted at commit";
87
+ decision.rejectedBy = "commit";
88
+ decision.reason = "a budget was exhausted at commit";
55
89
  }
56
90
  }
57
91
  decisions.push(decision);
@@ -60,23 +94,26 @@ export async function replay(lines, gate, commit) {
60
94
  }
61
95
  export function summarize(decisions) {
62
96
  const allowed = decisions.filter((d) => d.allowed).length;
97
+ const deferred = decisions.filter((d) => d.deferredBy).length;
63
98
  const byCheck = new Map();
64
99
  const sample = new Map();
65
100
  for (const d of decisions) {
66
- if (d.allowed || !d.rejectedBy)
101
+ const by = d.rejectedBy ?? d.deferredBy;
102
+ if (d.allowed || !by)
67
103
  continue;
68
- byCheck.set(d.rejectedBy, (byCheck.get(d.rejectedBy) ?? 0) + 1);
69
- if (!sample.has(d.rejectedBy) && d.reason)
70
- sample.set(d.rejectedBy, d.reason);
104
+ byCheck.set(by, (byCheck.get(by) ?? 0) + 1);
105
+ if (!sample.has(by) && d.reason)
106
+ sample.set(by, d.reason);
71
107
  }
72
- const deferred = decisions.filter((d) => d.allowed && d.deliverAt).length;
108
+ const later = decisions.filter((d) => d.allowed && d.deliverAt).length;
109
+ const shadowed = decisions.reduce((n, d) => n + d.shadowed.length, 0);
73
110
  const lines = [
74
- `${decisions.length} candidates · ${allowed} allowed (${pct(allowed, decisions.length)}) · ${decisions.length - allowed} rejected${deferred ? ` · ${deferred} deferred to a later moment` : ""}`,
111
+ `${decisions.length} candidates · ${allowed} allowed (${pct(allowed, decisions.length)}) · ${decisions.length - allowed - deferred} rejected${deferred ? ` · ${deferred} deferred` : ""}${later ? ` · ${later} moved to a later moment` : ""}${shadowed ? ` · ${shadowed} shadow rejections` : ""}`,
75
112
  "",
76
113
  ];
77
114
  if (byCheck.size) {
78
115
  const w = Math.max(...[...byCheck.keys()].map((k) => k.length), 5);
79
- lines.push(`${"check".padEnd(w)} ${"rejected".padStart(8)} example`);
116
+ lines.push(`${"check".padEnd(w)} ${"stopped".padStart(8)} example`);
80
117
  lines.push("-".repeat(w + 2 + 8 + 2 + 40));
81
118
  for (const [id, n] of [...byCheck.entries()].sort((a, b) => b[1] - a[1])) {
82
119
  lines.push(`${id.padEnd(w)} ${String(n).padStart(8)} ${sample.get(id) ?? ""}`);
@@ -88,19 +125,112 @@ export function summarize(decisions) {
88
125
  return lines.join("\n");
89
126
  }
90
127
  const pct = (n, total) => (total ? `${((100 * n) / total).toFixed(1)}%` : "0%");
128
+ const argValue = (argv, flag) => {
129
+ const i = argv.indexOf(flag);
130
+ return i >= 0 ? argv[i + 1] : undefined;
131
+ };
132
+ async function runFixtures(dir, skipFile) {
133
+ const fixtures = await loadFixtures(dir);
134
+ const skips = await readSkips(skipFile ?? resolve(dir, "..", "skip", "ts.txt"));
135
+ let failed = 0;
136
+ let skipped = 0;
137
+ for (const fixture of fixtures) {
138
+ const reason = skips.get(fixture.name);
139
+ if (reason !== undefined) {
140
+ skipped++;
141
+ console.log(`skip ${fixture.name} (${reason})`);
142
+ continue;
143
+ }
144
+ const failures = await runFixture(fixture);
145
+ if (failures.length) {
146
+ failed++;
147
+ console.log(`FAIL ${fixture.name}`);
148
+ for (const f of failures)
149
+ console.log(` ${f}`);
150
+ }
151
+ else {
152
+ console.log(`ok ${fixture.name}`);
153
+ }
154
+ }
155
+ console.log(`${fixtures.length - failed - skipped} passed, ${failed} failed, ${skipped} skipped`);
156
+ return failed ? 1 : 0;
157
+ }
158
+ /** Turns a PreToolUse event into the hook output Claude Code expects, or null when the tool does not match. */
159
+ export async function hookDecision(event, gate, tool) {
160
+ if (event.tool_name !== tool)
161
+ return null;
162
+ const payload = event.tool_input?.gate;
163
+ const out = (permissionDecision, permissionDecisionReason) => JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, permissionDecisionReason } });
164
+ if (!payload?.user || !payload.candidate)
165
+ return out("deny", "tool_input.gate must carry { user, candidate } for proactive-gate to decide");
166
+ const decision = await gate.evaluate({ user: payload.user, candidate: payload.candidate, ...(payload.now ? { now: new Date(payload.now) } : {}) });
167
+ if (decision.allowed) {
168
+ const ok = await gate.commit(decision, { user: payload.user, candidate: payload.candidate, ...(decision.evaluatedAt ? { now: decision.evaluatedAt } : {}) });
169
+ return ok ? out("allow", `proactive-gate: allowed on ${decision.surfaces.join(",")}${decision.deliverAt ? `, deliver at ${decision.deliverAt.toISOString()}` : ""}`) : out("deny", "proactive-gate: a budget was exhausted at commit");
170
+ }
171
+ if (decision.deferredBy)
172
+ return out("deny", `proactive-gate: deferred by ${decision.deferredBy} until ${decision.retryAt?.toISOString()} (${decision.reason})`);
173
+ return out("deny", `proactive-gate: rejected by ${decision.rejectedBy} (${decision.reason})`);
174
+ }
175
+ async function readStdin() {
176
+ const chunks = [];
177
+ for await (const chunk of process.stdin)
178
+ chunks.push(chunk);
179
+ return Buffer.concat(chunks).toString("utf8");
180
+ }
181
+ const VERSION = createRequire(import.meta.url)("../../package.json").version;
91
182
  async function main(argv) {
183
+ if (argv.includes("--version")) {
184
+ console.log(VERSION);
185
+ return;
186
+ }
92
187
  if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
93
188
  console.log(HELP);
94
189
  return;
95
190
  }
96
191
  const [command, file] = argv;
97
- if (command !== "replay" || !file) {
192
+ if (command === "init") {
193
+ if (argv.includes("--list")) {
194
+ console.log(listText());
195
+ return;
196
+ }
197
+ const out = argValue(argv, "--out") ?? "proactive-gate.policy.json";
198
+ const presetName = argValue(argv, "--preset");
199
+ const frameworkName = argValue(argv, "--framework");
200
+ const { policy, message } = plan({
201
+ ...(presetName === undefined ? {} : { preset: presetName }),
202
+ ...(frameworkName === undefined ? {} : { framework: frameworkName }),
203
+ out,
204
+ });
205
+ if (!argv.includes("--force") && existsSync(out)) {
206
+ console.error(`${out} already exists; pass --force to overwrite it`);
207
+ process.exit(1);
208
+ }
209
+ await writeFile(out, policy);
210
+ console.log(message);
211
+ return;
212
+ }
213
+ if (command === "hook") {
214
+ const gate = await loadPolicy(argValue(argv, "--policy"));
215
+ const event = JSON.parse((await readStdin()) || "{}");
216
+ const output = await hookDecision(event, gate, argValue(argv, "--tool") ?? "send_message");
217
+ if (output)
218
+ console.log(output);
219
+ return;
220
+ }
221
+ if (command !== "replay") {
222
+ console.error(HELP);
223
+ process.exit(2);
224
+ }
225
+ const fixtures = argValue(argv, "--fixtures");
226
+ if (fixtures) {
227
+ process.exit(await runFixtures(fixtures, argValue(argv, "--skip")));
228
+ }
229
+ if (!file || file.startsWith("--")) {
98
230
  console.error(HELP);
99
231
  process.exit(2);
100
232
  }
101
- const policyIndex = argv.indexOf("--policy");
102
- const policy = policyIndex >= 0 ? argv[policyIndex + 1] : undefined;
103
- const gate = await loadPolicy(policy);
233
+ const gate = await loadPolicy(argValue(argv, "--policy"));
104
234
  const text = await readFile(file, "utf8");
105
235
  const decisions = await replay(text.split("\n"), gate, argv.includes("--commit"));
106
236
  if (argv.includes("--json")) {