@verax-ai/proxy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ import type { Policy } from "./types.ts";
2
+ export type PolicySpend = {
3
+ maxAmountMinor: number;
4
+ currency: string;
5
+ payees: readonly string[];
6
+ dailyMaxMinor?: number;
7
+ /** Statement stamps for this payee. Optional; never guessed. */
8
+ descriptors?: readonly string[];
9
+ };
10
+ export type PolicyRule = {
11
+ id: string;
12
+ tool: string;
13
+ requires: readonly string[];
14
+ text: string;
15
+ mode?: "allow" | "approve";
16
+ spend?: PolicySpend;
17
+ egress?: true;
18
+ };
19
+ export type PolicyDocument = {
20
+ version: 1;
21
+ default: "deny";
22
+ approvalTtlMs?: number;
23
+ egress?: readonly string[];
24
+ /** Body-wide: a call with no `_inputs` key is deny inputs-required. */
25
+ requireInputs?: true;
26
+ limits?: {
27
+ ratePerMinute?: number;
28
+ dailyMax?: number;
29
+ diskFreeBytes?: number;
30
+ };
31
+ rules: PolicyRule[];
32
+ };
33
+ /** Named per tool. Do not sniff `host` / `url` on arbitrary arguments. */
34
+ export declare const HOST_EXTRACTORS: Record<string, (args: Record<string, unknown>) => string | undefined>;
35
+ export declare function parsePolicyDocument(json: unknown): PolicyDocument;
36
+ export declare function ruleTextHash(text: string): string;
37
+ export declare function loadPolicy(json: unknown): Policy;
package/dist/policy.js ADDED
@@ -0,0 +1,265 @@
1
+ import { parseIJson } from "@cedulon/core";
2
+ import { defaultDiskFreeBytes } from "./disk.js";
3
+ import { sha256Canonical } from "./hash.js";
4
+ /** Named per tool. Do not sniff `host` / `url` on arbitrary arguments. */
5
+ export const HOST_EXTRACTORS = {
6
+ "message.send": (args) => {
7
+ const to = args.to;
8
+ if (typeof to !== "string" || to === "")
9
+ return undefined;
10
+ const at = to.lastIndexOf("@");
11
+ if (at < 0)
12
+ return undefined;
13
+ const host = to.slice(at + 1).trim().toLowerCase();
14
+ if (host === "" || host.includes("/") || host.includes(" "))
15
+ return undefined;
16
+ return host;
17
+ },
18
+ };
19
+ const DEFAULT_APPROVAL_TTL_MS = 86_400_000;
20
+ function asRule(raw, index) {
21
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
22
+ throw new Error(`policy-rule-invalid:${index}`);
23
+ }
24
+ const rec = raw;
25
+ if (typeof rec.id !== "string" || rec.id === "") {
26
+ throw new Error(`policy-rule-id:${index}`);
27
+ }
28
+ if (typeof rec.tool !== "string" || rec.tool === "") {
29
+ throw new Error(`policy-rule-tool:${rec.id}`);
30
+ }
31
+ if (!Array.isArray(rec.requires) || rec.requires.some((s) => typeof s !== "string" || s === "")) {
32
+ throw new Error(`policy-rule-requires:${rec.id}`);
33
+ }
34
+ if (typeof rec.text !== "string" || rec.text.trim() === "") {
35
+ throw new Error(`policy-rule-text:${rec.id}`);
36
+ }
37
+ const rule = {
38
+ id: rec.id,
39
+ tool: rec.tool,
40
+ requires: rec.requires,
41
+ text: rec.text,
42
+ };
43
+ if (rec.mode !== undefined) {
44
+ if (rec.mode !== "allow" && rec.mode !== "approve") {
45
+ throw new Error(`policy-rule-mode:${rec.id}`);
46
+ }
47
+ rule.mode = rec.mode;
48
+ }
49
+ if (rec.tool === "spend") {
50
+ if (rule.mode !== "approve") {
51
+ throw new Error(`policy-rule-spend-mode:${rec.id}`);
52
+ }
53
+ if (rec.spend === undefined) {
54
+ throw new Error(`policy-rule-spend-missing:${rec.id}`);
55
+ }
56
+ rule.spend = asSpend(rec.spend, rec.id);
57
+ }
58
+ if (rec.egress !== undefined) {
59
+ if (rec.egress !== true) {
60
+ throw new Error(`policy-rule-egress:${rec.id}`);
61
+ }
62
+ rule.egress = true;
63
+ }
64
+ return rule;
65
+ }
66
+ function asSpend(raw, id) {
67
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
68
+ throw new Error(`policy-rule-spend-missing:${id}`);
69
+ }
70
+ const rec = raw;
71
+ if (typeof rec.maxAmountMinor !== "number" ||
72
+ !Number.isInteger(rec.maxAmountMinor) ||
73
+ rec.maxAmountMinor <= 0) {
74
+ throw new Error(`policy-rule-spend-missing:${id}`);
75
+ }
76
+ if (typeof rec.currency !== "string" || !/^[A-Z]{3}$/.test(rec.currency)) {
77
+ throw new Error(`policy-rule-spend-missing:${id}`);
78
+ }
79
+ if (!Array.isArray(rec.payees) || rec.payees.length === 0 || rec.payees.some((p) => typeof p !== "string" || p === "")) {
80
+ throw new Error(`policy-rule-spend-missing:${id}`);
81
+ }
82
+ const spend = {
83
+ maxAmountMinor: rec.maxAmountMinor,
84
+ currency: rec.currency,
85
+ payees: rec.payees,
86
+ };
87
+ if (rec.dailyMaxMinor !== undefined) {
88
+ if (typeof rec.dailyMaxMinor !== "number" || !Number.isInteger(rec.dailyMaxMinor) || rec.dailyMaxMinor <= 0) {
89
+ throw new Error(`policy-rule-spend-missing:${id}`);
90
+ }
91
+ spend.dailyMaxMinor = rec.dailyMaxMinor;
92
+ }
93
+ if (rec.descriptors !== undefined) {
94
+ if (!Array.isArray(rec.descriptors) ||
95
+ rec.descriptors.length === 0 ||
96
+ rec.descriptors.some((d) => typeof d !== "string" || d === "")) {
97
+ throw new Error(`policy-rule-spend-descriptors:${id}`);
98
+ }
99
+ spend.descriptors = rec.descriptors;
100
+ }
101
+ return spend;
102
+ }
103
+ function spendArgsInvalid(args) {
104
+ const keys = Object.keys(args);
105
+ const allowed = new Set(["amountMinor", "currency", "payee", "reference"]);
106
+ if (keys.length !== 4 || keys.some((k) => !allowed.has(k)))
107
+ return true;
108
+ const amt = args.amountMinor;
109
+ if (typeof amt !== "number" || !Number.isInteger(amt) || amt <= 0)
110
+ return true;
111
+ if (typeof args.currency !== "string" || !/^[A-Z]{3}$/.test(args.currency))
112
+ return true;
113
+ if (typeof args.payee !== "string" || args.payee === "")
114
+ return true;
115
+ if (typeof args.reference !== "string" || args.reference.length > 140)
116
+ return true;
117
+ return false;
118
+ }
119
+ export function parsePolicyDocument(json) {
120
+ const raw = typeof json === "string" ? parseIJson(json) : json;
121
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
122
+ throw new Error("policy-not-object");
123
+ }
124
+ const rec = raw;
125
+ if (rec.version !== 1) {
126
+ throw new Error("policy-version");
127
+ }
128
+ if (rec.default !== "deny") {
129
+ throw new Error("policy-default-not-deny");
130
+ }
131
+ if (!Array.isArray(rec.rules)) {
132
+ throw new Error("policy-rules");
133
+ }
134
+ const document = {
135
+ version: 1,
136
+ default: "deny",
137
+ rules: rec.rules.map(asRule),
138
+ };
139
+ if (rec.egress !== undefined) {
140
+ if (!Array.isArray(rec.egress) ||
141
+ rec.egress.some((h) => typeof h !== "string" || h.trim() === "")) {
142
+ throw new Error("policy-egress");
143
+ }
144
+ document.egress = rec.egress;
145
+ }
146
+ if (rec.approvalTtlMs !== undefined) {
147
+ if (typeof rec.approvalTtlMs !== "number" || !Number.isFinite(rec.approvalTtlMs) || rec.approvalTtlMs < 0) {
148
+ throw new Error("policy-approval-ttl");
149
+ }
150
+ document.approvalTtlMs = rec.approvalTtlMs;
151
+ }
152
+ if (rec.limits !== undefined) {
153
+ document.limits = asLimits(rec.limits);
154
+ }
155
+ if (rec.requireInputs !== undefined) {
156
+ if (rec.requireInputs !== true) {
157
+ throw new Error("policy-require-inputs");
158
+ }
159
+ document.requireInputs = true;
160
+ }
161
+ return document;
162
+ }
163
+ function asLimits(raw) {
164
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
165
+ throw new Error("policy-limits");
166
+ }
167
+ const rec = raw;
168
+ const limits = {};
169
+ for (const key of ["ratePerMinute", "dailyMax", "diskFreeBytes"]) {
170
+ if (rec[key] === undefined)
171
+ continue;
172
+ const n = rec[key];
173
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
174
+ throw new Error("policy-limits");
175
+ }
176
+ limits[key] = n;
177
+ }
178
+ return limits;
179
+ }
180
+ export function ruleTextHash(text) {
181
+ return sha256Canonical(text);
182
+ }
183
+ export function loadPolicy(json) {
184
+ const document = parsePolicyDocument(json);
185
+ const hash = sha256Canonical(document);
186
+ const limits = {
187
+ diskFreeBytes: document.limits?.diskFreeBytes ?? defaultDiskFreeBytes(),
188
+ ...(document.limits?.ratePerMinute !== undefined ? { ratePerMinute: document.limits.ratePerMinute } : {}),
189
+ ...(document.limits?.dailyMax !== undefined ? { dailyMax: document.limits.dailyMax } : {}),
190
+ };
191
+ return {
192
+ hash,
193
+ approvalTtlMs: document.approvalTtlMs ?? DEFAULT_APPROVAL_TTL_MS,
194
+ limits,
195
+ ...(document.requireInputs ? { requireInputs: true } : {}),
196
+ rule(id) {
197
+ if (id === null)
198
+ return null;
199
+ const found = document.rules.find((r) => r.id === id);
200
+ return found ? { id: found.id, text: found.text } : null;
201
+ },
202
+ evaluate(call, principal, ctx) {
203
+ if (call.name === "pay") {
204
+ return { decision: "deny", reasonCode: "spend-not-wired", rule: null };
205
+ }
206
+ if (call.name === "spend") {
207
+ const rule = document.rules.find((r) => r.tool === "spend");
208
+ if (!rule || !rule.spend) {
209
+ return { decision: "deny", reasonCode: "spend-not-wired", rule: null };
210
+ }
211
+ for (const scope of rule.requires) {
212
+ if (!principal.scopes.has(scope)) {
213
+ return { decision: "deny", reasonCode: "scope-missing", rule: rule.id };
214
+ }
215
+ }
216
+ if (spendArgsInvalid(call.arguments)) {
217
+ return { decision: "deny", reasonCode: "spend-args-invalid", rule: rule.id };
218
+ }
219
+ const amountMinor = call.arguments.amountMinor;
220
+ const currency = call.arguments.currency;
221
+ const payee = call.arguments.payee;
222
+ if (currency !== rule.spend.currency) {
223
+ return { decision: "deny", reasonCode: "spend-currency", rule: rule.id };
224
+ }
225
+ if (amountMinor > rule.spend.maxAmountMinor) {
226
+ return { decision: "deny", reasonCode: "spend-cap", rule: rule.id };
227
+ }
228
+ if (!rule.spend.payees.includes(payee)) {
229
+ return { decision: "deny", reasonCode: "spend-payee", rule: rule.id };
230
+ }
231
+ if (rule.spend.dailyMaxMinor !== undefined) {
232
+ const spent = ctx?.spentTodayMinor?.(currency) ?? 0;
233
+ if (spent + amountMinor > rule.spend.dailyMaxMinor) {
234
+ return { decision: "deny", reasonCode: "spend-daily", rule: rule.id };
235
+ }
236
+ }
237
+ return { decision: "defer", reasonCode: "approval-required", rule: rule.id };
238
+ }
239
+ const rule = document.rules.find((r) => r.tool === call.name);
240
+ if (!rule) {
241
+ return { decision: "deny", reasonCode: "no-rule", rule: null };
242
+ }
243
+ for (const scope of rule.requires) {
244
+ if (!principal.scopes.has(scope)) {
245
+ return { decision: "deny", reasonCode: "scope-missing", rule: rule.id };
246
+ }
247
+ }
248
+ if (rule.egress === true) {
249
+ const extract = HOST_EXTRACTORS[call.name];
250
+ const host = extract ? extract(call.arguments) : undefined;
251
+ if (!extract || host === undefined) {
252
+ return { decision: "deny", reasonCode: "egress-host-missing", rule: rule.id };
253
+ }
254
+ const allowed = document.egress ?? [];
255
+ if (!allowed.includes(host)) {
256
+ return { decision: "deny", reasonCode: "egress-blocked", rule: rule.id };
257
+ }
258
+ }
259
+ if (rule.mode === "approve") {
260
+ return { decision: "defer", reasonCode: "approval-required", rule: rule.id };
261
+ }
262
+ return { decision: "allow", reasonCode: "allow", rule: rule.id };
263
+ },
264
+ };
265
+ }
@@ -0,0 +1,11 @@
1
+ import type { Principal, ProxyDeps, ToolCall, ToolResult } from "./types.ts";
2
+ export declare class LedgerDenyUnrecorded extends Error {
3
+ readonly reasonCode: string;
4
+ constructor(reasonCode: string, cause?: unknown);
5
+ }
6
+ export declare const REF_RE: RegExp;
7
+ export declare function createProxy(deps: ProxyDeps): {
8
+ approvals: import("./approvals.ts").ApprovalsLog;
9
+ inputsLog: import("./types.ts").InputsLog;
10
+ call(call: ToolCall, principal: Principal): Promise<ToolResult>;
11
+ };