proactive-gate 0.1.2 → 0.2.1
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 +305 -11
- package/README.tr.md +209 -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 +118 -12
- package/dist/src/checks.js +196 -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/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[]>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the language-neutral fixtures under spec/fixtures against a gate. The
|
|
3
|
+
* TypeScript test suite and `proactive-gate replay --fixtures` both use it.
|
|
4
|
+
*/
|
|
5
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createGate } from "./gate.js";
|
|
8
|
+
import { MemoryStore } from "./stores.js";
|
|
9
|
+
export async function loadFixtures(dir) {
|
|
10
|
+
const files = [];
|
|
11
|
+
const walk = async (d) => {
|
|
12
|
+
for (const entry of await readdir(d, { withFileTypes: true })) {
|
|
13
|
+
const path = join(d, entry.name);
|
|
14
|
+
if (entry.isDirectory())
|
|
15
|
+
await walk(path);
|
|
16
|
+
else if (entry.name.endsWith(".json"))
|
|
17
|
+
files.push(path);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
await walk(dir);
|
|
21
|
+
files.sort();
|
|
22
|
+
return Promise.all(files.map(async (f) => JSON.parse(await readFile(f, "utf8"))));
|
|
23
|
+
}
|
|
24
|
+
export async function readSkips(file) {
|
|
25
|
+
const skips = new Map();
|
|
26
|
+
let text = "";
|
|
27
|
+
try {
|
|
28
|
+
text = await readFile(file, "utf8");
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return skips;
|
|
32
|
+
}
|
|
33
|
+
for (const line of text.split("\n")) {
|
|
34
|
+
const trimmed = line.trim();
|
|
35
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
36
|
+
continue;
|
|
37
|
+
const [name, ...reason] = trimmed.split("#");
|
|
38
|
+
skips.set(name.trim(), reason.join("#").trim());
|
|
39
|
+
}
|
|
40
|
+
return skips;
|
|
41
|
+
}
|
|
42
|
+
const iso = (d) => (d ? d.toISOString() : undefined);
|
|
43
|
+
/** Runs one fixture and returns the list of mismatches, empty when it conforms. */
|
|
44
|
+
export async function runFixture(fixture) {
|
|
45
|
+
const failures = [];
|
|
46
|
+
const store = new MemoryStore();
|
|
47
|
+
const prefix = fixture.policy.keyPrefix ?? "pg:";
|
|
48
|
+
for (const [key, value] of Object.entries(fixture.store_seed ?? {}))
|
|
49
|
+
await store.set(prefix + key, value);
|
|
50
|
+
const gate = createGate({ policy: fixture.policy, store });
|
|
51
|
+
for (const [i, t] of fixture.tests.entries()) {
|
|
52
|
+
const at = `${fixture.name} [${i}] ${t.description}`;
|
|
53
|
+
const input = { user: t.input.user, candidate: t.input.candidate, now: new Date(t.input.now) };
|
|
54
|
+
const decision = await gate.evaluate(input);
|
|
55
|
+
const e = t.expect;
|
|
56
|
+
const check = (field, actual, expected) => {
|
|
57
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected))
|
|
58
|
+
failures.push(`${at}: ${field} expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
59
|
+
};
|
|
60
|
+
check("allowed", decision.allowed, e.allowed);
|
|
61
|
+
check("trace", decision.trace.map((x) => x.id), e.trace);
|
|
62
|
+
check("rejectedBy", decision.rejectedBy, e.rejectedBy);
|
|
63
|
+
check("deferredBy", decision.deferredBy, e.deferredBy);
|
|
64
|
+
check("retryAt", iso(decision.retryAt), e.retryAt);
|
|
65
|
+
if (e.surfaces)
|
|
66
|
+
check("surfaces", decision.surfaces, e.surfaces);
|
|
67
|
+
check("deliverAt", iso(decision.deliverAt), e.deliverAt);
|
|
68
|
+
if (e.shadowed)
|
|
69
|
+
check("shadowed", decision.shadowed, e.shadowed);
|
|
70
|
+
if (e.nearLimit)
|
|
71
|
+
check("nearLimit", decision.nearLimit, e.nearLimit);
|
|
72
|
+
if (e.reason_pattern && !(decision.reason && new RegExp(e.reason_pattern).test(decision.reason)))
|
|
73
|
+
failures.push(`${at}: reason ${JSON.stringify(decision.reason)} does not match /${e.reason_pattern}/`);
|
|
74
|
+
if (t.commit) {
|
|
75
|
+
const committed = await gate.commit(decision, input);
|
|
76
|
+
if (e.commit !== undefined)
|
|
77
|
+
check("commit", committed, e.commit);
|
|
78
|
+
}
|
|
79
|
+
for (const [key, value] of Object.entries(e.store_after ?? {}))
|
|
80
|
+
check(`store ${key}`, await store.get(prefix + key), value);
|
|
81
|
+
}
|
|
82
|
+
return failures;
|
|
83
|
+
}
|
package/dist/src/gate.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import type { Candidate, Check, Decision, EvaluateInput, GateOptions, OutcomeEvent, UserState } from "./types.js";
|
|
1
|
+
import type { Candidate, Check, Decision, EvaluateInput, GateHooks, GateOptions, OutcomeEvent, Policy, Store, UserState } from "./types.js";
|
|
2
2
|
export interface Gate {
|
|
3
3
|
/** Run every check in order. Never throws for a check failure; see the trace. */
|
|
4
4
|
evaluate(input: EvaluateInput): Promise<Decision>;
|
|
5
5
|
/**
|
|
6
|
-
* Call right before you actually send.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Call right before you actually send. Consumes one unit of every budget-like
|
|
7
|
+
* check, in order, and returns false if a unit was taken by a concurrent
|
|
8
|
+
* delivery in the meantime. Idempotent on decision.id: a second call returns
|
|
9
|
+
* the first result without consuming again.
|
|
9
10
|
*/
|
|
10
11
|
commit(decision: Decision, input: EvaluateInput): Promise<boolean>;
|
|
11
12
|
/** Tell the gate what happened after delivery, so cooldowns can learn. */
|
|
@@ -17,4 +18,11 @@ export interface Gate {
|
|
|
17
18
|
}>;
|
|
18
19
|
readonly checks: readonly Check[];
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
/** createGate accepts explicit checks or a JSON policy (see spec/schema/policy.schema.json). */
|
|
22
|
+
export interface PolicyGateOptions {
|
|
23
|
+
policy: Policy;
|
|
24
|
+
store?: Store;
|
|
25
|
+
onDecision?: (decision: Decision) => void;
|
|
26
|
+
hooks?: GateHooks;
|
|
27
|
+
}
|
|
28
|
+
export declare function createGate(options: GateOptions | PolicyGateOptions): Gate;
|
package/dist/src/gate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { budgetKey, dismissalKey,
|
|
1
|
+
import { budgetKey, dismissalKey, DAY_SECONDS } from "./checks.js";
|
|
2
|
+
import { compilePolicy } from "./policy.js";
|
|
2
3
|
import { MemoryStore } from "./stores.js";
|
|
3
4
|
class PrefixedStore {
|
|
4
5
|
inner;
|
|
@@ -12,32 +13,59 @@ class PrefixedStore {
|
|
|
12
13
|
incr(key, ttl) { return this.inner.incr(this.prefix + key, ttl); }
|
|
13
14
|
del(key) { return this.inner.del(this.prefix + key); }
|
|
14
15
|
}
|
|
16
|
+
const COMMIT_TTL = 2 * DAY_SECONDS;
|
|
15
17
|
export function createGate(options) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
18
|
+
if ("policy" in options && "checks" in options)
|
|
19
|
+
throw new Error("createGate takes either checks or policy, not both");
|
|
20
|
+
const resolved = "policy" in options
|
|
21
|
+
? { ...compilePolicy(options.policy), ...(options.store ? { store: options.store } : {}), ...(options.onDecision ? { onDecision: options.onDecision } : {}), ...(options.hooks ? { hooks: options.hooks } : {}) }
|
|
22
|
+
: options;
|
|
23
|
+
const store = new PrefixedStore(resolved.store ?? new MemoryStore(), resolved.keyPrefix ?? "pg:");
|
|
24
|
+
const onStoreError = resolved.onStoreError ?? "open";
|
|
25
|
+
const hooks = resolved.hooks ?? {};
|
|
26
|
+
const checks = [...resolved.checks];
|
|
27
|
+
let sequence = 0;
|
|
28
|
+
const consumers = checks.filter((c) => typeof c.consume === "function");
|
|
29
|
+
const callHook = async (name, ctx, check, ...rest) => {
|
|
30
|
+
const hook = hooks[name];
|
|
31
|
+
if (!hook)
|
|
32
|
+
return;
|
|
33
|
+
try {
|
|
34
|
+
await hook(...(ctx ? [ctx, check, ...rest] : rest));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (name !== "error" && ctx && check)
|
|
38
|
+
await callHook("error", ctx, check, error);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
20
41
|
const evaluate = async (input) => {
|
|
21
42
|
const now = input.now ?? new Date();
|
|
22
43
|
const priority = input.candidate.priority ?? "normal";
|
|
23
44
|
const trace = [];
|
|
45
|
+
const shadowed = [];
|
|
46
|
+
const nearLimit = [];
|
|
24
47
|
let surfaces = pickSurfaces(input.user, input.candidate);
|
|
25
48
|
let deliverAt;
|
|
26
|
-
const finish = (partial) => {
|
|
49
|
+
const finish = async (partial) => {
|
|
27
50
|
const decision = {
|
|
51
|
+
id: `${input.user.id}:${input.candidate.id}:${now.toISOString()}#${++sequence}`,
|
|
28
52
|
allowed: false,
|
|
29
53
|
userId: input.user.id,
|
|
30
54
|
candidateId: input.candidate.id,
|
|
31
55
|
surfaces: [],
|
|
56
|
+
shadowed,
|
|
57
|
+
nearLimit,
|
|
32
58
|
trace,
|
|
33
59
|
evaluatedAt: now,
|
|
34
60
|
...partial,
|
|
35
61
|
};
|
|
36
|
-
|
|
62
|
+
resolved.onDecision?.(decision);
|
|
63
|
+
await callHook("finally", null, null, decision);
|
|
37
64
|
return decision;
|
|
38
65
|
};
|
|
39
66
|
for (const check of checks) {
|
|
40
67
|
const ctx = { user: input.user, candidate: input.candidate, now, priority, store, surfaces };
|
|
68
|
+
await callHook("before", ctx, check);
|
|
41
69
|
const started = performance.now();
|
|
42
70
|
let outcome;
|
|
43
71
|
try {
|
|
@@ -45,6 +73,7 @@ export function createGate(options) {
|
|
|
45
73
|
}
|
|
46
74
|
catch (error) {
|
|
47
75
|
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
await callHook("error", ctx, check, error);
|
|
48
77
|
if (onStoreError === "closed") {
|
|
49
78
|
trace.push({ id: check.id, outcome: "reject", reason: `check threw (${message}); failing closed`, ms: elapsed(started) });
|
|
50
79
|
return finish({ rejectedBy: check.id, reason: `check "${check.id}" failed and the gate fails closed: ${message}` });
|
|
@@ -52,15 +81,30 @@ export function createGate(options) {
|
|
|
52
81
|
trace.push({ id: check.id, outcome: "skip", reason: `check threw (${message}); failing open`, ms: elapsed(started) });
|
|
53
82
|
continue;
|
|
54
83
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
84
|
+
const ms = elapsed(started);
|
|
85
|
+
await callHook("after", ctx, check, outcome, ms);
|
|
86
|
+
if (check.nonRejecting && (outcome.kind === "reject" || outcome.kind === "defer")) {
|
|
87
|
+
// A non-rejecting check that tries to stop evaluation is a bug in the check, not a decision about the user.
|
|
88
|
+
trace.push({ id: check.id, outcome: "skip", reason: `non-rejecting check returned ${outcome.kind} (${outcome.reason}); ignored`, ms });
|
|
58
89
|
continue;
|
|
59
90
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
91
|
+
const stops = outcome.kind === "reject" || outcome.kind === "defer";
|
|
92
|
+
const entry = { id: check.id, outcome: outcome.kind, ms };
|
|
93
|
+
if ("reason" in outcome && outcome.reason)
|
|
94
|
+
entry.reason = outcome.reason;
|
|
95
|
+
if (stops && check.shadow)
|
|
96
|
+
entry.shadow = true;
|
|
97
|
+
trace.push(entry);
|
|
98
|
+
if (outcome.kind === "pass" && outcome.nearLimit)
|
|
99
|
+
nearLimit.push({ check: check.id, ...outcome.nearLimit });
|
|
100
|
+
if (stops && check.shadow) {
|
|
101
|
+
shadowed.push(check.id);
|
|
102
|
+
continue;
|
|
63
103
|
}
|
|
104
|
+
if (outcome.kind === "reject")
|
|
105
|
+
return finish({ rejectedBy: check.id, reason: outcome.reason });
|
|
106
|
+
if (outcome.kind === "defer")
|
|
107
|
+
return finish({ deferredBy: check.id, retryAt: outcome.retryAt, reason: outcome.reason });
|
|
64
108
|
if (outcome.kind === "adjust") {
|
|
65
109
|
if (outcome.deliverAt)
|
|
66
110
|
deliverAt = outcome.deliverAt;
|
|
@@ -73,20 +117,25 @@ export function createGate(options) {
|
|
|
73
117
|
const commit = async (decision, input) => {
|
|
74
118
|
if (!decision.allowed)
|
|
75
119
|
return false;
|
|
76
|
-
if (!
|
|
120
|
+
if (!consumers.length)
|
|
77
121
|
return true;
|
|
78
|
-
const now = input.now ??
|
|
122
|
+
const now = input.now ?? decision.evaluatedAt;
|
|
123
|
+
const priority = input.candidate.priority ?? "normal";
|
|
124
|
+
const marker = `commit:${decision.id}`;
|
|
79
125
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
|
|
126
|
+
const seen = await store.get(marker);
|
|
127
|
+
if (seen !== null)
|
|
128
|
+
return seen === "1";
|
|
129
|
+
let ok = true;
|
|
130
|
+
for (const check of consumers) {
|
|
131
|
+
const ctx = { user: input.user, candidate: input.candidate, now, priority, store, surfaces: decision.surfaces };
|
|
132
|
+
if (!(await check.consume(ctx))) {
|
|
133
|
+
ok = false;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
88
136
|
}
|
|
89
|
-
|
|
137
|
+
await store.set(marker, ok ? "1" : "0", COMMIT_TTL);
|
|
138
|
+
return ok;
|
|
90
139
|
}
|
|
91
140
|
catch {
|
|
92
141
|
return onStoreError === "open";
|
|
@@ -121,7 +170,3 @@ function pickSurfaces(user, candidate) {
|
|
|
121
170
|
return wanted.filter((s) => allowed.has(s));
|
|
122
171
|
}
|
|
123
172
|
const elapsed = (started) => Math.round((performance.now() - started) * 1000) / 1000;
|
|
124
|
-
/** dailyBudget() closes over its limit; expose it through a well-known property for commit(). */
|
|
125
|
-
function readLimit(check) {
|
|
126
|
-
return typeof check.limit === "number" ? check.limit : undefined;
|
|
127
|
-
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
export { createGate } from "./gate.js";
|
|
2
|
-
export type { Gate } from "./gate.js";
|
|
2
|
+
export type { Gate, PolicyGateOptions } from "./gate.js";
|
|
3
|
+
export { compilePolicy, KNOWN_CHECKS } from "./policy.js";
|
|
4
|
+
export { presets } from "./presets.js";
|
|
5
|
+
export type { Preset } from "./presets.js";
|
|
3
6
|
export { MemoryStore, RedisStore, SqliteStore } from "./stores.js";
|
|
4
7
|
export type { RedisLike } from "./stores.js";
|
|
5
8
|
export * as checks from "./checks.js";
|
|
6
|
-
export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, dismissalKey } from "./checks.js";
|
|
9
|
+
export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, monthlyBudgetKey, dismissalKey } from "./checks.js";
|
|
7
10
|
export { PRIORITY_RANK } from "./types.js";
|
|
8
|
-
export type { Candidate, Check, CheckContext, CheckOutcome, Decision, EvaluateInput, GateOptions, OutcomeEvent, Priority, Store, Surface, TraceEntry, UserState, } from "./types.js";
|
|
11
|
+
export type { Candidate, Check, CheckContext, CheckOutcome, Decision, EvaluateInput, GateHooks, GateOptions, OutcomeEvent, Policy, PolicyEntry, Priority, Store, Surface, TraceEntry, UserState, } from "./types.js";
|
package/dist/src/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createGate } from "./gate.js";
|
|
2
|
+
export { compilePolicy, KNOWN_CHECKS } from "./policy.js";
|
|
3
|
+
export { presets } from "./presets.js";
|
|
2
4
|
export { MemoryStore, RedisStore, SqliteStore } from "./stores.js";
|
|
3
5
|
export * as checks from "./checks.js";
|
|
4
|
-
export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, dismissalKey } from "./checks.js";
|
|
6
|
+
export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, monthlyBudgetKey, dismissalKey } from "./checks.js";
|
|
5
7
|
export { PRIORITY_RANK } from "./types.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const FRAMEWORKS: readonly ["ai-sdk", "mastra", "langchain", "openai-agents", "none"];
|
|
2
|
+
export type Framework = (typeof FRAMEWORKS)[number];
|
|
3
|
+
/** The order LILA runs, as a policy document. A preset is appended when one is named. */
|
|
4
|
+
export declare function buildPolicy(preset?: string): Record<string, unknown>;
|
|
5
|
+
export declare function snippetFor(framework: Framework, file: string): string;
|
|
6
|
+
export declare function presetLines(): string;
|
|
7
|
+
export declare function listText(): string;
|
|
8
|
+
/** Everything init writes and prints, as data, so the test does not need a filesystem. */
|
|
9
|
+
export declare function plan(options: {
|
|
10
|
+
preset?: string;
|
|
11
|
+
framework?: Framework;
|
|
12
|
+
out: string;
|
|
13
|
+
}): {
|
|
14
|
+
policy: string;
|
|
15
|
+
message: string;
|
|
16
|
+
};
|