proactive-gate 0.1.2 → 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.
- package/README.md +223 -11
- package/README.tr.md +134 -3
- package/dist/src/adapters/ai-sdk.d.ts +35 -0
- package/dist/src/adapters/ai-sdk.js +17 -0
- package/dist/src/adapters/langchain.d.ts +32 -0
- package/dist/src/adapters/langchain.js +20 -0
- package/dist/src/adapters/mastra.d.ts +25 -0
- package/dist/src/adapters/mastra.js +16 -0
- package/dist/src/adapters/openai-agents.d.ts +31 -0
- package/dist/src/adapters/openai-agents.js +16 -0
- package/dist/src/checks.d.ts +71 -12
- package/dist/src/checks.js +149 -24
- package/dist/src/cli.d.ts +14 -1
- package/dist/src/cli.js +154 -24
- package/dist/src/conformance.d.ts +42 -0
- package/dist/src/conformance.js +83 -0
- package/dist/src/gate.d.ts +13 -5
- package/dist/src/gate.js +73 -28
- package/dist/src/index.d.ts +6 -3
- package/dist/src/index.js +3 -1
- package/dist/src/init.d.ts +16 -0
- package/dist/src/init.js +115 -0
- package/dist/src/policy.d.ts +8 -0
- package/dist/src/policy.js +74 -0
- package/dist/src/presets.d.ts +9 -0
- package/dist/src/presets.js +45 -0
- package/dist/src/stores.js +5 -8
- package/dist/src/types.d.ts +74 -2
- package/package.json +57 -9
- package/dist/test/gate.test.d.ts +0 -1
- package/dist/test/gate.test.js +0 -261
package/dist/src/checks.d.ts
CHANGED
|
@@ -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(
|
|
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,80 @@ 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
|
|
68
|
+
export interface BudgetOptions {
|
|
71
69
|
limit?: number;
|
|
72
70
|
bypassPriority?: Priority;
|
|
73
|
-
|
|
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
75
|
export declare const weeklyBudgetKey: (userId: string, now: Date, timezone?: string) => string;
|
|
76
|
-
export declare
|
|
77
|
-
|
|
78
|
-
|
|
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;
|
|
79
138
|
}): BudgetCheck;
|
|
80
139
|
/** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
|
|
81
140
|
export declare function defaultChecks(options?: {
|
package/dist/src/checks.js
CHANGED
|
@@ -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
|
-
|
|
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,30 @@ export function adaptiveTiming(options = {}) {
|
|
|
196
201
|
},
|
|
197
202
|
};
|
|
198
203
|
}
|
|
199
|
-
|
|
200
|
-
const limit = options.limit ??
|
|
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:
|
|
209
|
+
id: spec.id,
|
|
203
210
|
limit,
|
|
204
|
-
async run(
|
|
205
|
-
if (
|
|
211
|
+
async run(ctx) {
|
|
212
|
+
if (bypass(ctx.priority))
|
|
206
213
|
return pass;
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
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;
|
|
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;
|
|
210
224
|
},
|
|
211
225
|
};
|
|
212
226
|
}
|
|
213
|
-
export const budgetKey = (userId, now, timezone) => `budget:${userId}:${
|
|
227
|
+
export const budgetKey = (userId, now, timezone) => `budget:${userId}:${localDay(now, timezone)}`;
|
|
214
228
|
const isoWeekKey = (day) => {
|
|
215
229
|
const date = new Date(`${day}T00:00:00Z`);
|
|
216
230
|
const weekday = date.getUTCDay() || 7;
|
|
@@ -219,24 +233,135 @@ const isoWeekKey = (day) => {
|
|
|
219
233
|
const week = Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
|
220
234
|
return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
221
235
|
};
|
|
222
|
-
export const weeklyBudgetKey = (userId, now, timezone) => {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
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. */
|
|
226
243
|
export function weeklyBudget(options = {}) {
|
|
227
|
-
|
|
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;
|
|
228
260
|
return {
|
|
229
|
-
id: "
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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)
|
|
233
290
|
return pass;
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
return used < limit ? pass : reject(`weekly budget of ${limit} used (${used})`);
|
|
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 };
|
|
237
293
|
},
|
|
238
294
|
};
|
|
239
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"}`;
|
|
362
|
+
};
|
|
363
|
+
return budget({ id: "windowBudget", label: `window budget`, defaultLimit: options.limit, keyFor, ttlSeconds: options.withinHours * 3600 }, { limit: options.limit, nearLimit: 1 });
|
|
364
|
+
}
|
|
240
365
|
/** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
|
|
241
366
|
export function defaultChecks(options = {}) {
|
|
242
367
|
return [
|
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 <
|
|
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
|
-
*
|
|
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
|
-
*
|
|
8
|
-
* --policy the default check order runs against an
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
22
|
-
|
|
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
|
|
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 = "
|
|
54
|
-
decision.reason = "
|
|
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
|
-
|
|
101
|
+
const by = d.rejectedBy ?? d.deferredBy;
|
|
102
|
+
if (d.allowed || !by)
|
|
67
103
|
continue;
|
|
68
|
-
byCheck.set(
|
|
69
|
-
if (!sample.has(
|
|
70
|
-
sample.set(
|
|
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
|
|
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)} ${"
|
|
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
|
|
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
|
|
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")) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Candidate, Policy, UserState } from "./types.js";
|
|
2
|
+
export interface FixtureExpect {
|
|
3
|
+
allowed: boolean;
|
|
4
|
+
rejectedBy?: string;
|
|
5
|
+
deferredBy?: string;
|
|
6
|
+
retryAt?: string;
|
|
7
|
+
surfaces?: string[];
|
|
8
|
+
deliverAt?: string;
|
|
9
|
+
trace: string[];
|
|
10
|
+
shadowed?: string[];
|
|
11
|
+
nearLimit?: Array<{
|
|
12
|
+
check: string;
|
|
13
|
+
used: number;
|
|
14
|
+
limit: number;
|
|
15
|
+
}>;
|
|
16
|
+
reason_pattern?: string;
|
|
17
|
+
commit?: boolean;
|
|
18
|
+
store_after?: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
export interface FixtureTest {
|
|
21
|
+
description: string;
|
|
22
|
+
input: {
|
|
23
|
+
user: UserState;
|
|
24
|
+
candidate: Candidate;
|
|
25
|
+
now: string;
|
|
26
|
+
};
|
|
27
|
+
commit?: boolean;
|
|
28
|
+
expect: FixtureExpect;
|
|
29
|
+
}
|
|
30
|
+
export interface Fixture {
|
|
31
|
+
spec_version: string;
|
|
32
|
+
since: string;
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
policy: Policy;
|
|
36
|
+
store_seed?: Record<string, string>;
|
|
37
|
+
tests: FixtureTest[];
|
|
38
|
+
}
|
|
39
|
+
export declare function loadFixtures(dir: string): Promise<Fixture[]>;
|
|
40
|
+
export declare function readSkips(file: string): Promise<Map<string, string>>;
|
|
41
|
+
/** Runs one fixture and returns the list of mismatches, empty when it conforms. */
|
|
42
|
+
export declare function runFixture(fixture: Fixture): Promise<string[]>;
|