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/init.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `proactive-gate init` writes a policy you can read and edit, and prints the
|
|
3
|
+
* few lines that wire it into the framework you named. The goal is that the
|
|
4
|
+
* distance between "npm i" and a gate that actually runs is one command.
|
|
5
|
+
*/
|
|
6
|
+
import { presets } from "./presets.js";
|
|
7
|
+
export const FRAMEWORKS = ["ai-sdk", "mastra", "langchain", "openai-agents", "none"];
|
|
8
|
+
/** The order LILA runs, as a policy document. A preset is appended when one is named. */
|
|
9
|
+
export function buildPolicy(preset) {
|
|
10
|
+
const checks = [
|
|
11
|
+
{ id: "consent" },
|
|
12
|
+
{ id: "enabled" },
|
|
13
|
+
{ id: "mode", allow: ["normal"] },
|
|
14
|
+
{ id: "snooze", defer: true },
|
|
15
|
+
{ id: "mute" },
|
|
16
|
+
{ id: "intensity" },
|
|
17
|
+
{ id: "quietHours", priorityFloor: "critical" },
|
|
18
|
+
{ id: "trustRamp", days: 7, minPriority: "high" },
|
|
19
|
+
{ id: "dismissalCooldown", dismissals: 3, withinDays: 30, silenceDays: 7 },
|
|
20
|
+
{ id: "dailyBudget", limit: 5, bypassPriority: "critical" },
|
|
21
|
+
];
|
|
22
|
+
if (preset)
|
|
23
|
+
checks.splice(checks.length - 1, 0, { preset });
|
|
24
|
+
return { specVersion: "1.0.0", onStoreError: "open", checks };
|
|
25
|
+
}
|
|
26
|
+
const SNIPPETS = {
|
|
27
|
+
"ai-sdk": (file) => `import { readFile } from "node:fs/promises";
|
|
28
|
+
import { createGate } from "proactive-gate";
|
|
29
|
+
import { gateToolApproval } from "proactive-gate/ai-sdk";
|
|
30
|
+
|
|
31
|
+
const gate = createGate({ policy: JSON.parse(await readFile("${file}", "utf8")) });
|
|
32
|
+
const approve = gateToolApproval({ gate, toInput: (call) => call.input.gate });
|
|
33
|
+
|
|
34
|
+
// Give the send tool needsApproval: true, then answer each request:
|
|
35
|
+
const { approved, reason } = await approve(call);
|
|
36
|
+
// addToolApprovalResponse({ id: call.approvalId, approved, reason })`,
|
|
37
|
+
mastra: (file) => `import { readFile } from "node:fs/promises";
|
|
38
|
+
import { createGate } from "proactive-gate";
|
|
39
|
+
import { gateProcessor } from "proactive-gate/mastra";
|
|
40
|
+
|
|
41
|
+
const gate = createGate({ policy: JSON.parse(await readFile("${file}", "utf8")) });
|
|
42
|
+
|
|
43
|
+
// In the agent definition:
|
|
44
|
+
// outputProcessors: [gateProcessor({ gate, toInput: ({ messages }) => ({ user, candidate }) })]
|
|
45
|
+
// A rejection calls abort(reason) before the result reaches the user.`,
|
|
46
|
+
langchain: (file) => `import { readFile } from "node:fs/promises";
|
|
47
|
+
import { createGate } from "proactive-gate";
|
|
48
|
+
import { gateMiddleware } from "proactive-gate/langchain";
|
|
49
|
+
|
|
50
|
+
const gate = createGate({ policy: JSON.parse(await readFile("${file}", "utf8")) });
|
|
51
|
+
|
|
52
|
+
// createAgent({
|
|
53
|
+
// tools: [sendMessage],
|
|
54
|
+
// middleware: [gateMiddleware({ gate, tools: ["send_message"], toInput: (call) => call.args.gate })],
|
|
55
|
+
// })
|
|
56
|
+
// A rejection returns a tool message carrying the reason instead of running the tool.`,
|
|
57
|
+
"openai-agents": (file) => `import { readFile } from "node:fs/promises";
|
|
58
|
+
import { createGate } from "proactive-gate";
|
|
59
|
+
import { gateToolInputGuardrail } from "proactive-gate/openai-agents";
|
|
60
|
+
|
|
61
|
+
const gate = createGate({ policy: JSON.parse(await readFile("${file}", "utf8")) });
|
|
62
|
+
|
|
63
|
+
// tool({
|
|
64
|
+
// name: "send_message",
|
|
65
|
+
// inputGuardrails: [gateToolInputGuardrail({ gate, toInput: (input) => input.gate })],
|
|
66
|
+
// })
|
|
67
|
+
// A rejection trips the wire with the reason in outputInfo.`,
|
|
68
|
+
none: (file) => `import { readFile } from "node:fs/promises";
|
|
69
|
+
import { createGate } from "proactive-gate";
|
|
70
|
+
|
|
71
|
+
const gate = createGate({ policy: JSON.parse(await readFile("${file}", "utf8")) });
|
|
72
|
+
|
|
73
|
+
const decision = await gate.evaluate({ user, candidate });
|
|
74
|
+
if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
|
|
75
|
+
await send(candidate);
|
|
76
|
+
} else {
|
|
77
|
+
log.info({ reason: decision.reason, rejectedBy: decision.rejectedBy }, "not sent");
|
|
78
|
+
}`,
|
|
79
|
+
};
|
|
80
|
+
export function snippetFor(framework, file) {
|
|
81
|
+
return SNIPPETS[framework](file);
|
|
82
|
+
}
|
|
83
|
+
export function presetLines() {
|
|
84
|
+
return Object.entries(presets)
|
|
85
|
+
.map(([name, preset]) => ` ${name.padEnd(28)}${preset.sources[0] ?? ""}`)
|
|
86
|
+
.join("\n");
|
|
87
|
+
}
|
|
88
|
+
export function listText() {
|
|
89
|
+
return `presets (append one to the policy, or leave it out):\n${presetLines()}\n\nframeworks: ${FRAMEWORKS.join(", ")}`;
|
|
90
|
+
}
|
|
91
|
+
/** Everything init writes and prints, as data, so the test does not need a filesystem. */
|
|
92
|
+
export function plan(options) {
|
|
93
|
+
if (options.preset && !presets[options.preset]) {
|
|
94
|
+
throw new Error(`unknown preset "${options.preset}"; known presets: ${Object.keys(presets).join(", ")}`);
|
|
95
|
+
}
|
|
96
|
+
const framework = options.framework ?? "none";
|
|
97
|
+
if (!FRAMEWORKS.includes(framework)) {
|
|
98
|
+
throw new Error(`unknown framework "${framework}"; known frameworks: ${FRAMEWORKS.join(", ")}`);
|
|
99
|
+
}
|
|
100
|
+
const preset = options.preset ? presets[options.preset] : undefined;
|
|
101
|
+
const lines = [
|
|
102
|
+
`wrote ${options.out}`,
|
|
103
|
+
"",
|
|
104
|
+
preset
|
|
105
|
+
? `preset ${options.preset}: ${preset.note}\nsources:\n${preset.sources.map((s) => ` ${s}`).join("\n")}\n`
|
|
106
|
+
: "no preset: the ten checks above are the default order. `proactive-gate init --list` shows the platform and legal presets.\n",
|
|
107
|
+
`wire it in (${framework}):`,
|
|
108
|
+
"",
|
|
109
|
+
snippetFor(framework, options.out),
|
|
110
|
+
"",
|
|
111
|
+
`then replay a day against it before you ship:`,
|
|
112
|
+
` npx proactive-gate replay day.jsonl --policy ${options.out} --commit`,
|
|
113
|
+
];
|
|
114
|
+
return { policy: `${JSON.stringify(buildPolicy(options.preset), null, 2)}\n`, message: lines.join("\n") };
|
|
115
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Check, GateOptions, Policy } from "./types.js";
|
|
2
|
+
type Options = Record<string, unknown>;
|
|
3
|
+
type Factory = (options: Options) => Check;
|
|
4
|
+
/** Every check a JSON policy may name, with the options it reads. */
|
|
5
|
+
export declare const KNOWN_CHECKS: Record<string, Factory>;
|
|
6
|
+
/** Compile a JSON policy into gate options. Throws on unknown ids, unknown presets, or an unsupported specVersion. */
|
|
7
|
+
export declare function compilePolicy(policy: Policy): GateOptions;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as checks from "./checks.js";
|
|
2
|
+
import { presets } from "./presets.js";
|
|
3
|
+
const num = (o, key) => (typeof o[key] === "number" ? o[key] : undefined);
|
|
4
|
+
const str = (o, key) => (typeof o[key] === "string" ? o[key] : undefined);
|
|
5
|
+
const bool = (o, key) => (typeof o[key] === "boolean" ? o[key] : undefined);
|
|
6
|
+
const prio = (o, key) => str(o, key);
|
|
7
|
+
const strs = (o, key) => (Array.isArray(o[key]) ? o[key] : undefined);
|
|
8
|
+
const opt = (value, key) => (value === undefined ? {} : { [key]: value });
|
|
9
|
+
const budgetOptions = (o) => ({ ...opt(num(o, "limit"), "limit"), ...opt(prio(o, "bypassPriority"), "bypassPriority"), ...opt(num(o, "nearLimit"), "nearLimit") });
|
|
10
|
+
/** Every check a JSON policy may name, with the options it reads. */
|
|
11
|
+
export const KNOWN_CHECKS = {
|
|
12
|
+
killSwitch: (o) => checks.killSwitch(() => bool(o, "on") === true),
|
|
13
|
+
consent: () => checks.consent(),
|
|
14
|
+
enabled: () => checks.enabled(),
|
|
15
|
+
mode: (o) => checks.mode({ allow: strs(o, "allow") ?? ["normal"] }),
|
|
16
|
+
snooze: (o) => checks.snooze({ ...opt(bool(o, "defer"), "defer") }),
|
|
17
|
+
mute: () => checks.mute(),
|
|
18
|
+
intensity: (o) => (o.floors ? checks.intensity(o.floors) : checks.intensity()),
|
|
19
|
+
quietHours: (o) => checks.quietHours({ ...opt(prio(o, "priorityFloor"), "priorityFloor") }),
|
|
20
|
+
trustRamp: (o) => checks.trustRamp({ ...opt(num(o, "days"), "days"), ...opt(prio(o, "minPriority"), "minPriority") }),
|
|
21
|
+
dismissalCooldown: (o) => checks.dismissalCooldown({ ...opt(num(o, "dismissals"), "dismissals"), ...opt(num(o, "withinDays"), "withinDays"), ...opt(num(o, "silenceDays"), "silenceDays") }),
|
|
22
|
+
adaptiveTiming: () => checks.adaptiveTiming(),
|
|
23
|
+
dailyBudget: (o) => checks.dailyBudget(budgetOptions(o)),
|
|
24
|
+
weeklyBudget: (o) => checks.weeklyBudget(budgetOptions(o)),
|
|
25
|
+
monthlyBudget: (o) => checks.monthlyBudget(budgetOptions(o)),
|
|
26
|
+
utilityFloor: (o) => checks.utilityFloor({ costFalseAlarm: num(o, "costFalseAlarm") ?? 1, costMissedHelp: num(o, "costMissedHelp") ?? 1 }),
|
|
27
|
+
boundedDeferral: (o) => checks.boundedDeferral({ ...opt(num(o, "lambda"), "lambda"), ...opt(num(o, "interruptCost"), "interruptCost"), ...opt(num(o, "staleness"), "staleness"), ...opt(num(o, "boundSeconds"), "boundSeconds") }),
|
|
28
|
+
allowedWindow: (o) => checks.allowedWindow({ start: str(o, "start") ?? "08:00", end: str(o, "end") ?? "21:00", timezone: str(o, "timezone") ?? "user", ...opt(prio(o, "priorityFloor"), "priorityFloor"), ...opt(str(o, "id"), "id") }),
|
|
29
|
+
requiresConsent: (o) => checks.requiresConsent({ name: str(o, "name") ?? "consent", ...(o.when ? { when: o.when } : {}), ...opt(str(o, "id"), "id") }),
|
|
30
|
+
rateLimit: (o) => checks.rateLimit({ limit: num(o, "limit") ?? 1, perSeconds: num(o, "perSeconds") ?? 1, ...opt(str(o, "keyBy"), "keyBy"), ...opt(str(o, "id"), "id") }),
|
|
31
|
+
recentInteraction: (o) => checks.recentInteraction({ withinHours: num(o, "withinHours") ?? 48 }),
|
|
32
|
+
windowBudget: (o) => checks.windowBudget({ limit: num(o, "limit") ?? 1, withinHours: num(o, "withinHours") ?? 48 }),
|
|
33
|
+
};
|
|
34
|
+
const SUPPORTED_MAJOR = 1;
|
|
35
|
+
/** Compile a JSON policy into gate options. Throws on unknown ids, unknown presets, or an unsupported specVersion. */
|
|
36
|
+
export function compilePolicy(policy) {
|
|
37
|
+
const major = Number(String(policy.specVersion).split(".")[0]);
|
|
38
|
+
if (!Number.isInteger(major) || major !== SUPPORTED_MAJOR) {
|
|
39
|
+
throw new Error(`policy specVersion ${policy.specVersion} is not supported; this package implements spec ${SUPPORTED_MAJOR}.x`);
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(policy.checks) || !policy.checks.length)
|
|
42
|
+
throw new Error("policy.checks must be a non-empty array");
|
|
43
|
+
const compiled = [];
|
|
44
|
+
for (const entry of policy.checks) {
|
|
45
|
+
const { shadow, ...rest } = entry;
|
|
46
|
+
let built;
|
|
47
|
+
if (typeof rest.preset === "string") {
|
|
48
|
+
const preset = presets[rest.preset];
|
|
49
|
+
if (!preset)
|
|
50
|
+
throw new Error(`unknown preset "${rest.preset}"; known presets: ${Object.keys(presets).join(", ")}`);
|
|
51
|
+
const { preset: _name, ...options } = rest;
|
|
52
|
+
built = preset(options);
|
|
53
|
+
}
|
|
54
|
+
else if (typeof rest.id === "string") {
|
|
55
|
+
const factory = KNOWN_CHECKS[rest.id];
|
|
56
|
+
if (!factory)
|
|
57
|
+
throw new Error(`unknown check "${rest.id}"; known checks: ${Object.keys(KNOWN_CHECKS).join(", ")}`);
|
|
58
|
+
const { id: _id, ...options } = rest;
|
|
59
|
+
built = [factory(options)];
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
throw new Error("each policy entry needs an id or a preset");
|
|
63
|
+
}
|
|
64
|
+
if (shadow)
|
|
65
|
+
for (const c of built)
|
|
66
|
+
c.shadow = true;
|
|
67
|
+
compiled.push(...built);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
checks: compiled,
|
|
71
|
+
...(policy.onStoreError ? { onStoreError: policy.onStoreError } : {}),
|
|
72
|
+
...(policy.keyPrefix !== undefined ? { keyPrefix: policy.keyPrefix } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Check } from "./types.js";
|
|
2
|
+
export interface Preset {
|
|
3
|
+
(options?: Record<string, unknown>): Check[];
|
|
4
|
+
/** Primary sources the numbers come from. */
|
|
5
|
+
sources: string[];
|
|
6
|
+
/** What the preset encodes and what it leaves out. */
|
|
7
|
+
note: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const presets: Record<string, Preset>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presets: the platform quotas and legal limits people ship against, as ordered
|
|
3
|
+
* check lists. Reviewable defaults, not legal advice. Every number sits next to
|
|
4
|
+
* its source; several official sources disagree with each other (the Kakao
|
|
5
|
+
* evening boundary is quoted as 20:00, 20:50 and 20:55), so read the note and
|
|
6
|
+
* decide for your own deployment.
|
|
7
|
+
*/
|
|
8
|
+
import * as c from "./checks.js";
|
|
9
|
+
const define = (build, sources, note) => {
|
|
10
|
+
const preset = ((options = {}) => build(options));
|
|
11
|
+
preset.sources = sources;
|
|
12
|
+
preset.note = note;
|
|
13
|
+
return preset;
|
|
14
|
+
};
|
|
15
|
+
const LINE_PLANS = { communication: 200, light: 5000, standard: 30000 };
|
|
16
|
+
export const presets = {
|
|
17
|
+
lineMessagingApi: define((o) => {
|
|
18
|
+
const plan = typeof o.plan === "string" ? o.plan : "communication";
|
|
19
|
+
const limit = LINE_PLANS[plan];
|
|
20
|
+
if (limit === undefined)
|
|
21
|
+
throw new Error(`lineMessagingApi: unknown plan "${plan}", known: ${Object.keys(LINE_PLANS).join(", ")}`);
|
|
22
|
+
return [c.consent(), c.monthlyBudget({ limit, nearLimit: 0.9 })];
|
|
23
|
+
}, ["https://developers.line.biz/en/docs/messaging-api/pricing/", "https://developers.line.biz/en/reference/messaging-api/"], "Monthly push messages per plan for Japan: communication 200, light 5,000, standard 30,000; replies are free and not counted. Multicast and broadcast request rates are not encoded."),
|
|
24
|
+
wechatSubscriptionMessage: define(() => [c.requiresConsent({ name: "subscription" }), c.windowBudget({ limit: 1, withinHours: 24 * 365 })], ["https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/subscribe-message-overview.html"], "One-time subscription: exactly one message per opt-in; set user.lastInboundAt to the opt-in instant. Long-term subscriptions for government, medical, transport, finance and education categories are not encoded."),
|
|
25
|
+
wechatCustomerService: define(() => [c.recentInteraction({ withinHours: 48 }), c.windowBudget({ limit: 5, withinHours: 48 })], ["https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/customer-message/send.html"], "Mini program customer-service messages: within 48 hours of the user's last message, at most 5 in that window."),
|
|
26
|
+
wechatTemplateMessage: define(() => [c.requiresConsent({ name: "templateTrigger" }), c.rateLimit({ limit: 3, perSeconds: 24 * 3600, keyBy: "user", id: "rate:template" })], ["https://developers.weixin.qq.com/doc/service/guide/product/template_message/Template_Message_Operation_Specifications.html"], "Template messages only after a user action (consents.templateTrigger) and no more than three repeated templates a day; marketing templates are not allowed at all."),
|
|
27
|
+
wecomAppMessage: define(() => [c.rateLimit({ limit: 30, perSeconds: 60, id: "rate:30/min" }), c.rateLimit({ limit: 1000, perSeconds: 3600, id: "rate:1000/h" })], ["https://developer.work.weixin.qq.com/document/path/96212"], "WeCom application messages per app per member: 30 a minute and 1,000 an hour; the platform drops the excess silently, this preset refuses it with a reason."),
|
|
28
|
+
kakaoAlimtalk: define(() => [c.consent()], ["https://kakaobusiness.gitbook.io/main/ad/infotalk"], "AlimTalk is informational and carries no time-of-day limit; consent is the only gate."),
|
|
29
|
+
kakaoBrandMessage: define(() => [c.requiresConsent({ name: "ad" }), c.allowedWindow({ start: "08:00", end: "20:50", timezone: "Asia/Seoul", id: "window:kakao" })], ["https://kakaobusiness.gitbook.io/main/ad/moment/messagead/channelmessage/new/send"], "Brand messages need advertising consent and go out 08:00 to 20:50 Korea time regardless of the recipient's location. Official sources also quote 20:00 and 20:55; 20:50 is the stricter documented value."),
|
|
30
|
+
krNetworkAct50: define(() => [c.requiresConsent({ name: "ad" }), c.requiresConsent({ name: "night", when: { start: "21:00", end: "08:00", timezone: "user" } })], ["https://www.law.go.kr", "https://developers.fingerpush.com/biz-message/console/ads-guide"], "Network Act article 50: prior consent for advertising, and a separate consent for 21:00 to 08:00 (email is exempt). The two-year re-confirmation is not encoded."),
|
|
31
|
+
jpAntiSpamLaw: define(() => [c.requiresConsent({ name: "optIn" })], ["https://www.soumu.go.jp/main_sosiki/cybersecurity/kokumin/basic/legal/08/"], "Opt-in since 2008 with sender identity and an opt-out route. There is no time-of-day rule in the law; a Japanese quiet-hours window would be etiquette, so none is encoded."),
|
|
32
|
+
cnMinorMode: define(() => {
|
|
33
|
+
const window = c.allowedWindow({ start: "06:00", end: "22:00", timezone: "Asia/Shanghai", id: "window:minor" });
|
|
34
|
+
const budget = c.dailyBudget({ limit: 1 });
|
|
35
|
+
const adult = () => ({ kind: "pass", reason: "not a minor" });
|
|
36
|
+
return [
|
|
37
|
+
{ id: window.id, run: (ctx) => (ctx.user.minor ? window.run(ctx) : adult(ctx)) },
|
|
38
|
+
{ id: budget.id, limit: budget.limit, run: (ctx) => (ctx.user.minor ? budget.run(ctx) : adult(ctx)), consume: (ctx) => (ctx.user.minor ? budget.consume(ctx) : Promise.resolve(true)) },
|
|
39
|
+
];
|
|
40
|
+
}, ["https://www.cac.gov.cn/2024-11/15/c_1733364304749288.htm", "https://www.cac.gov.cn/2022-01/04/c_1642894606364259.htm"], "Minor mode: no service 22:00 to 06:00 China time and a daily budget of one when user.minor is true; adults pass both checks. Per-age daily durations are not encoded."),
|
|
41
|
+
usTcpa: define(() => [c.allowedWindow({ start: "08:00", end: "21:00", timezone: "user", id: "window:tcpa" })], ["https://www.law.cornell.edu/cfr/text/47/64.1200"], "47 CFR 64.1200: no solicitation before 8 a.m. or after 9 p.m. at the called party's local time."),
|
|
42
|
+
euEprivacy: define(() => [{ ...c.requiresConsent({ name: "marketing" }), run: (ctx) => (ctx.user.existingCustomer ? { kind: "pass", reason: "existing customer, soft opt-in" } : c.requiresConsent({ name: "marketing" }).run(ctx)) }], ["https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32002L0058"], "Directive 2002/58/EC article 13: prior consent for direct marketing, with the soft opt-in for existing customers (user.existingCustomer)."),
|
|
43
|
+
telegramBot: define(() => [c.rateLimit({ limit: 1, perSeconds: 1, keyBy: "channel", id: "rate:1/s" }), c.rateLimit({ limit: 20, perSeconds: 60, keyBy: "channel", id: "rate:20/min" })], ["https://core.telegram.org/bots/faq"], "One message a second per chat and twenty a minute per group, keyed by candidate.channel. The broadcast rate of roughly thirty a second is not encoded."),
|
|
44
|
+
slackApp: define(() => [c.rateLimit({ limit: 1, perSeconds: 1, keyBy: "channel", id: "rate:1/s" })], ["https://docs.slack.dev/apis/web-api/rate-limits/"], "chat.postMessage: one message a second per channel, keyed by candidate.channel."),
|
|
45
|
+
};
|
package/dist/src/stores.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
const require = createRequire(import.meta.url);
|
|
3
1
|
/** In-process store. Correct for one instance, wrong the moment you scale out. */
|
|
4
2
|
export class MemoryStore {
|
|
5
3
|
clock;
|
|
@@ -71,13 +69,12 @@ export class SqliteStore {
|
|
|
71
69
|
database;
|
|
72
70
|
clock;
|
|
73
71
|
constructor(path, clock = () => Date.now()) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
// Resolved lazily so the module also loads where node:sqlite does not exist (older Node, a browser bundle).
|
|
73
|
+
const loader = globalThis.process?.getBuiltinModule;
|
|
74
|
+
const mod = loader ? loader("node:sqlite") : undefined;
|
|
75
|
+
const DatabaseSync = mod?.DatabaseSync;
|
|
76
|
+
if (!DatabaseSync)
|
|
79
77
|
throw new Error("SqliteStore requires Node.js 22.5 or newer.");
|
|
80
|
-
}
|
|
81
78
|
this.database = new DatabaseSync(path);
|
|
82
79
|
this.clock = clock;
|
|
83
80
|
this.database.exec("CREATE TABLE IF NOT EXISTS proactive_gate_store (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL, expires_at INTEGER)");
|
package/dist/src/types.d.ts
CHANGED
|
@@ -29,6 +29,14 @@ export interface UserState {
|
|
|
29
29
|
createdAt?: Date | string;
|
|
30
30
|
/** Surfaces the user allows, in preference order. Defaults to the candidate's surfaces. */
|
|
31
31
|
surfaces?: Surface[];
|
|
32
|
+
/** Named consents a preset can require, e.g. { ad: true, night: false }. */
|
|
33
|
+
consents?: Record<string, boolean>;
|
|
34
|
+
/** Last message the user sent to the assistant; drives inbound-window presets. */
|
|
35
|
+
lastInboundAt?: Date | string | null;
|
|
36
|
+
/** True when the user is a minor under the applicable rules. */
|
|
37
|
+
minor?: boolean;
|
|
38
|
+
/** True when a soft opt-in for existing customers applies. */
|
|
39
|
+
existingCustomer?: boolean;
|
|
32
40
|
}
|
|
33
41
|
/** The thing the agent wants to say. */
|
|
34
42
|
export interface Candidate {
|
|
@@ -38,6 +46,14 @@ export interface Candidate {
|
|
|
38
46
|
priority?: Priority;
|
|
39
47
|
/** Surfaces this candidate can be delivered on, in preference order. */
|
|
40
48
|
surfaces?: Surface[];
|
|
49
|
+
/** Channel or chat the message goes to; rate limits keyed by channel read it. */
|
|
50
|
+
channel?: string;
|
|
51
|
+
/** The caller's own signal that the user is busy right now; boundedDeferral reads it. */
|
|
52
|
+
busy?: boolean;
|
|
53
|
+
/** Caller-estimated probability the user accepts this message; utilityFloor reads it. */
|
|
54
|
+
pAccept?: number;
|
|
55
|
+
/** Caller-estimated probability the user needs it; utilityFloor reads it, default 1. */
|
|
56
|
+
pNeed?: number;
|
|
41
57
|
/** Free-form payload; the gate never reads it. */
|
|
42
58
|
payload?: unknown;
|
|
43
59
|
}
|
|
@@ -50,6 +66,11 @@ export interface EvaluateInput {
|
|
|
50
66
|
/** What a single check may say. */
|
|
51
67
|
export type CheckOutcome = {
|
|
52
68
|
kind: "pass";
|
|
69
|
+
reason?: string;
|
|
70
|
+
nearLimit?: {
|
|
71
|
+
used: number;
|
|
72
|
+
limit: number;
|
|
73
|
+
};
|
|
53
74
|
} | {
|
|
54
75
|
kind: "reject";
|
|
55
76
|
reason: string;
|
|
@@ -61,6 +82,10 @@ export type CheckOutcome = {
|
|
|
61
82
|
} | {
|
|
62
83
|
kind: "skip";
|
|
63
84
|
reason: string;
|
|
85
|
+
} | {
|
|
86
|
+
kind: "defer";
|
|
87
|
+
reason: string;
|
|
88
|
+
retryAt: Date;
|
|
64
89
|
};
|
|
65
90
|
export interface CheckContext {
|
|
66
91
|
user: UserState;
|
|
@@ -75,26 +100,50 @@ export interface Check {
|
|
|
75
100
|
id: string;
|
|
76
101
|
/** True when the check can never reject; it only adjusts timing or surfaces. */
|
|
77
102
|
nonRejecting?: boolean;
|
|
103
|
+
/** True to record what the check would have done without letting it stop evaluation. */
|
|
104
|
+
shadow?: boolean;
|
|
78
105
|
run(ctx: CheckContext): Promise<CheckOutcome> | CheckOutcome;
|
|
106
|
+
/**
|
|
107
|
+
* Budget-like checks consume one unit at commit time. Return false when the
|
|
108
|
+
* unit was not available (a concurrent delivery took it). The gate calls
|
|
109
|
+
* consume() in check order, once per decision.
|
|
110
|
+
*/
|
|
111
|
+
consume?(ctx: CheckContext): Promise<boolean>;
|
|
79
112
|
}
|
|
80
113
|
export interface TraceEntry {
|
|
81
114
|
id: string;
|
|
82
115
|
outcome: CheckOutcome["kind"];
|
|
83
116
|
reason?: string;
|
|
84
117
|
ms: number;
|
|
118
|
+
/** Present when the check ran in shadow mode and would have stopped evaluation. */
|
|
119
|
+
shadow?: boolean;
|
|
85
120
|
}
|
|
86
121
|
export interface Decision {
|
|
122
|
+
/** Unique per evaluation: userId, candidateId, the instant and a sequence number. commit() is idempotent on it. */
|
|
123
|
+
id: string;
|
|
87
124
|
allowed: boolean;
|
|
88
125
|
userId: string;
|
|
89
126
|
candidateId: string;
|
|
90
|
-
/** Surfaces to route to when allowed. Empty when rejected. */
|
|
127
|
+
/** Surfaces to route to when allowed. Empty when rejected or deferred. */
|
|
91
128
|
surfaces: Surface[];
|
|
92
129
|
/** Set when a non-rejecting check asked for a later delivery. */
|
|
93
130
|
deliverAt?: Date;
|
|
94
131
|
/** The check that rejected, when rejected. */
|
|
95
132
|
rejectedBy?: string;
|
|
96
|
-
/**
|
|
133
|
+
/** The check that deferred, when deferred. */
|
|
134
|
+
deferredBy?: string;
|
|
135
|
+
/** When to evaluate again, when deferred. */
|
|
136
|
+
retryAt?: Date;
|
|
137
|
+
/** Human-readable reason, when rejected or deferred. */
|
|
97
138
|
reason?: string;
|
|
139
|
+
/** Checks in shadow mode that would have rejected or deferred. */
|
|
140
|
+
shadowed: string[];
|
|
141
|
+
/** Budget checks that passed close to their limit. */
|
|
142
|
+
nearLimit: Array<{
|
|
143
|
+
check: string;
|
|
144
|
+
used: number;
|
|
145
|
+
limit: number;
|
|
146
|
+
}>;
|
|
98
147
|
/** Every check that ran, in order, with what it said. */
|
|
99
148
|
trace: TraceEntry[];
|
|
100
149
|
evaluatedAt: Date;
|
|
@@ -113,6 +162,13 @@ export interface Store {
|
|
|
113
162
|
incr(key: string, ttlSeconds?: number): Promise<number>;
|
|
114
163
|
del(key: string): Promise<void>;
|
|
115
164
|
}
|
|
165
|
+
/** Observation points. Hooks never change a decision; a throwing hook is reported to `error` and ignored. */
|
|
166
|
+
export interface GateHooks {
|
|
167
|
+
before?(ctx: CheckContext, check: Check): void | Promise<void>;
|
|
168
|
+
after?(ctx: CheckContext, check: Check, outcome: CheckOutcome, ms: number): void | Promise<void>;
|
|
169
|
+
error?(ctx: CheckContext, check: Check, error: unknown): void | Promise<void>;
|
|
170
|
+
finally?(decision: Decision): void | Promise<void>;
|
|
171
|
+
}
|
|
116
172
|
export interface GateOptions {
|
|
117
173
|
checks: Check[];
|
|
118
174
|
store?: Store;
|
|
@@ -126,4 +182,20 @@ export interface GateOptions {
|
|
|
126
182
|
onDecision?: (decision: Decision) => void;
|
|
127
183
|
/** Key prefix for everything the gate writes to the store. */
|
|
128
184
|
keyPrefix?: string;
|
|
185
|
+
/** Observation hooks, e.g. one OpenTelemetry span per check. */
|
|
186
|
+
hooks?: GateHooks;
|
|
129
187
|
}
|
|
188
|
+
/** A policy document: the same checks as data. See spec/schema/policy.schema.json. */
|
|
189
|
+
export interface Policy {
|
|
190
|
+
specVersion: string;
|
|
191
|
+
onStoreError?: "open" | "closed";
|
|
192
|
+
keyPrefix?: string;
|
|
193
|
+
checks: PolicyEntry[];
|
|
194
|
+
}
|
|
195
|
+
export type PolicyEntry = ({
|
|
196
|
+
id: string;
|
|
197
|
+
shadow?: boolean;
|
|
198
|
+
} & Record<string, unknown>) | ({
|
|
199
|
+
preset: string;
|
|
200
|
+
shadow?: boolean;
|
|
201
|
+
} & Record<string, unknown>);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proactive-gate",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Decide whether a proactive AI agent may reach a user right now, and log why not. Ordered checks
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Decide whether a proactive AI agent may reach a user right now, and log why not. Ordered checks as code or JSON, a conformance spec, presets for platform and legal limits, adapters for AI SDK, Mastra, LangChain and OpenAI Agents, and a Python sibling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
7
7
|
"types": "./dist/src/index.d.ts",
|
|
@@ -9,23 +9,51 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/src/index.d.ts",
|
|
11
11
|
"import": "./dist/src/index.js"
|
|
12
|
-
}
|
|
12
|
+
},
|
|
13
|
+
"./presets": {
|
|
14
|
+
"types": "./dist/src/presets.d.ts",
|
|
15
|
+
"import": "./dist/src/presets.js"
|
|
16
|
+
},
|
|
17
|
+
"./ai-sdk": {
|
|
18
|
+
"types": "./dist/src/adapters/ai-sdk.d.ts",
|
|
19
|
+
"import": "./dist/src/adapters/ai-sdk.js"
|
|
20
|
+
},
|
|
21
|
+
"./mastra": {
|
|
22
|
+
"types": "./dist/src/adapters/mastra.d.ts",
|
|
23
|
+
"import": "./dist/src/adapters/mastra.js"
|
|
24
|
+
},
|
|
25
|
+
"./langchain": {
|
|
26
|
+
"types": "./dist/src/adapters/langchain.d.ts",
|
|
27
|
+
"import": "./dist/src/adapters/langchain.js"
|
|
28
|
+
},
|
|
29
|
+
"./openai-agents": {
|
|
30
|
+
"types": "./dist/src/adapters/openai-agents.d.ts",
|
|
31
|
+
"import": "./dist/src/adapters/openai-agents.js"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
13
34
|
},
|
|
14
35
|
"bin": {
|
|
15
36
|
"proactive-gate": "dist/src/cli.js"
|
|
16
37
|
},
|
|
17
38
|
"files": [
|
|
18
|
-
"dist",
|
|
39
|
+
"dist/src",
|
|
19
40
|
"README.md",
|
|
20
41
|
"LICENSE"
|
|
21
42
|
],
|
|
43
|
+
"sideEffects": false,
|
|
22
44
|
"scripts": {
|
|
23
45
|
"build": "tsc -p tsconfig.json",
|
|
24
|
-
"test": "npm run build && node --test dist/test/gate.test.js",
|
|
46
|
+
"test": "npm run build && node test/spec-lint.mjs && node --test dist/test/gate.test.js dist/test/conformance.test.js dist/test/presets.test.js dist/test/adapters.test.js dist/test/init.test.js dist/test/properties.test.js test/release.test.mjs test/examples.test.mjs test/naive.test.mjs",
|
|
25
47
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
48
|
+
"spec-lint": "node test/spec-lint.mjs",
|
|
49
|
+
"conformance": "npm run build && node dist/src/cli.js replay --fixtures spec/fixtures",
|
|
26
50
|
"prepublishOnly": "npm test",
|
|
27
|
-
"examples": "npm run build && node dist/src/cli.js replay examples/day.jsonl --policy examples/policy.js --commit",
|
|
28
|
-
"bench": "npm run build && node bench/evaluate.mjs"
|
|
51
|
+
"examples": "npm run build && node dist/src/cli.js replay examples/day.jsonl --policy examples/policy.js --commit && node dist/src/cli.js replay examples/day.jsonl --policy examples/policy.json --commit && node examples/mastra/run.mjs && node examples/ai-sdk/run.mjs",
|
|
52
|
+
"bench": "npm run build && node bench/evaluate.mjs",
|
|
53
|
+
"release": "node scripts/release.mjs",
|
|
54
|
+
"release-gate": "npm run build && node scripts/release-gate.mjs",
|
|
55
|
+
"trace-svg": "npm run build && node scripts/trace-svg.mjs",
|
|
56
|
+
"bench:compare": "npm run build && node bench/compare.mjs"
|
|
29
57
|
},
|
|
30
58
|
"engines": {
|
|
31
59
|
"node": ">=20"
|
|
@@ -39,7 +67,20 @@
|
|
|
39
67
|
"quiet-hours",
|
|
40
68
|
"consent",
|
|
41
69
|
"budget",
|
|
42
|
-
"llm"
|
|
70
|
+
"llm",
|
|
71
|
+
"policy",
|
|
72
|
+
"presets",
|
|
73
|
+
"langchain",
|
|
74
|
+
"mastra",
|
|
75
|
+
"ai-sdk",
|
|
76
|
+
"openai-agents",
|
|
77
|
+
"claude-code",
|
|
78
|
+
"guardrails",
|
|
79
|
+
"agent-guardrails",
|
|
80
|
+
"notification-budget",
|
|
81
|
+
"proactive-ai",
|
|
82
|
+
"python",
|
|
83
|
+
"conformance"
|
|
43
84
|
],
|
|
44
85
|
"author": "Efe Genc",
|
|
45
86
|
"license": "MIT",
|
|
@@ -47,7 +88,14 @@
|
|
|
47
88
|
"type": "git",
|
|
48
89
|
"url": "git+https://github.com/Bubblegunn/proactive-gate.git"
|
|
49
90
|
},
|
|
50
|
-
"homepage": "https://github.
|
|
91
|
+
"homepage": "https://bubblegunn.github.io/proactive-gate/",
|
|
92
|
+
"bugs": {
|
|
93
|
+
"url": "https://github.com/Bubblegunn/proactive-gate/issues"
|
|
94
|
+
},
|
|
95
|
+
"publishConfig": {
|
|
96
|
+
"access": "public",
|
|
97
|
+
"provenance": true
|
|
98
|
+
},
|
|
51
99
|
"devDependencies": {
|
|
52
100
|
"@arethetypeswrong/cli": "^0.18.5",
|
|
53
101
|
"@types/node": "^26.4.1",
|
package/dist/test/gate.test.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|