@verax-ai/proxy 0.1.1 → 0.1.2

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/dist/policy.js CHANGED
@@ -197,7 +197,15 @@ export function loadPolicy(json) {
197
197
  if (id === null)
198
198
  return null;
199
199
  const found = document.rules.find((r) => r.id === id);
200
- return found ? { id: found.id, text: found.text } : null;
200
+ return found
201
+ ? {
202
+ id: found.id,
203
+ text: found.text,
204
+ ...(found.spend?.dailyMaxMinor !== undefined
205
+ ? { spend: { dailyMaxMinor: found.spend.dailyMaxMinor } }
206
+ : {}),
207
+ }
208
+ : null;
201
209
  },
202
210
  evaluate(call, principal, ctx) {
203
211
  if (call.name === "pay") {
package/dist/proxy.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { signDecisionRecord } from "@cedulon/core";
4
- import { approvePending, approvalsLogFor, drainApprovalCommands } from "./approvals.js";
4
+ import { approvePending, approvalsLogFor, createApprovalBudgetGuard, drainApprovalCommands, spentTodayMinorOf, } from "./approvals.js";
5
+ import { SerialQueue } from "./serial-queue.js";
5
6
  import { diskProbe } from "./disk.js";
6
7
  import { effectDescriptor, sha256Canonical } from "./hash.js";
7
8
  import { inputsLogFor } from "./inputs.js";
@@ -129,6 +130,13 @@ function toolCallOf(call) {
129
130
  export function createProxy(deps) {
130
131
  const inputsLog = deps.inputsLog ?? inputsLogFor(deps.ledger);
131
132
  const approvals = approvalsLogFor(deps.ledger);
133
+ const admission = new SerialQueue();
134
+ const inFlight = new Map();
135
+ const budgetGuard = createApprovalBudgetGuard({
136
+ policy: deps.policy,
137
+ approvals,
138
+ now: deps.now,
139
+ });
132
140
  deps.ledger.effectSigner = deps.effectSigner;
133
141
  const ledgerDir = deps.ledger.dir;
134
142
  const stateDir = typeof ledgerDir === "string" ? ledgerDir : null;
@@ -192,30 +200,6 @@ export function createProxy(deps) {
192
200
  return "daily-limited";
193
201
  return null;
194
202
  }
195
- function utcDayStart(ms) {
196
- const d = new Date(ms);
197
- return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
198
- }
199
- function spentTodayMinorOf(rows, nowMs, currency) {
200
- const start = utcDayStart(nowMs);
201
- const end = start + 86_400_000;
202
- let sum = 0;
203
- for (const row of rows) {
204
- if (row.subject !== "spend")
205
- continue;
206
- if (row.status !== "pending" && row.status !== "approved")
207
- continue;
208
- const created = row.createdAtMs ?? row.expiresAtMs - deps.policy.approvalTtlMs;
209
- if (created < start || created >= end)
210
- continue;
211
- if (row.args.currency !== currency)
212
- continue;
213
- const amt = row.args.amountMinor;
214
- if (typeof amt === "number")
215
- sum += amt;
216
- }
217
- return sum;
218
- }
219
203
  async function spendReauth(requestHash, inputs, timestampMs, subject) {
220
204
  const existingReauth = await lookupReauthByHash(deps.ledger, requestHash);
221
205
  if (existingReauth)
@@ -309,6 +293,38 @@ export function createProxy(deps) {
309
293
  reasonCode: null,
310
294
  };
311
295
  }
296
+ function launchInner(dispatchedCall, principal, allowRef, flightKey) {
297
+ const work = runInner(dispatchedCall, principal, allowRef);
298
+ inFlight.set(flightKey, work);
299
+ return { kind: "run", work, key: flightKey, replayRef: allowRef };
300
+ }
301
+ async function retryPolicyDeny(dispatchedCall, principal, requestHash, inputs, timestampMs, subject, scopedRef) {
302
+ let spendCtx;
303
+ if (dispatchedCall.name === "spend") {
304
+ const approvalRows = await approvals.listAll();
305
+ spendCtx = {
306
+ spentTodayMinor: (currency) => spentTodayMinorOf(approvalRows, timestampMs, currency, deps.policy.approvalTtlMs),
307
+ };
308
+ }
309
+ const verdict = deps.policy.evaluate(dispatchedCall, principal, spendCtx);
310
+ if (verdict.decision !== "deny")
311
+ return null;
312
+ const denyRef = deps.nonce();
313
+ await writeRecord({
314
+ decision: "deny",
315
+ reasonCode: verdict.reasonCode,
316
+ ref: denyRef,
317
+ requestHash,
318
+ inputs: {
319
+ ...inputs,
320
+ approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
321
+ },
322
+ effectHash: null,
323
+ subject,
324
+ timestampMs,
325
+ });
326
+ return denied(verdict.reasonCode, denyRef);
327
+ }
312
328
  return {
313
329
  approvals,
314
330
  inputsLog,
@@ -330,6 +346,7 @@ export function createProxy(deps) {
330
346
  policyHash: defer.policyHash,
331
347
  approvals,
332
348
  inputsLog,
349
+ budgetGuard,
333
350
  });
334
351
  });
335
352
  }
@@ -366,168 +383,220 @@ export function createProxy(deps) {
366
383
  return denied("ledger-disk-low", ref);
367
384
  }
368
385
  const given = readRef(call.arguments);
369
- if (given === "invalid") {
370
- const ref = deps.nonce();
371
- await writeRecord({
372
- decision: "deny",
373
- reasonCode: "ref-invalid",
374
- ref,
375
- requestHash,
376
- inputs: resolved.inputs,
377
- effectHash: null,
378
- subject: call.name,
379
- timestampMs,
380
- });
381
- return denied("ref-invalid", ref);
382
- }
383
- if (typeof given === "string") {
384
- // The ledger, the approvals snapshot and the resolution index all key on the
385
- // scoped ref. The brain only ever sees and resends the raw `_ref` it chose.
386
- const scopedRef = scopedClaimsRef(principal, given);
387
- const existing = await lookupDecisionByRef(deps.ledger, given, tenantKey(principal));
388
- if (existing) {
389
- if (existing.requestHash !== requestHash) {
390
- const ref = deps.nonce();
391
- await writeRecord({
392
- decision: "deny",
393
- reasonCode: "ref-reuse",
394
- ref,
395
- requestHash,
396
- inputs: resolved.inputs,
397
- effectHash: null,
398
- subject: call.name,
399
- timestampMs,
400
- });
401
- return denied("ref-reuse", ref);
402
- }
403
- if (existing.decision === "defer") {
404
- const snap = await approvals.get(scopedRef);
405
- const boundHit = lookupResolvedBy(deps.ledger, scopedRef);
406
- if (boundHit?.kind === "expired") {
407
- if (snap?.status === "pending")
408
- await approvals.updateStatus(scopedRef, "expired");
409
- return denied("expired", boundHit.ref);
410
- }
411
- if (snap && timestampMs > snap.expiresAtMs) {
412
- const expireRef = deps.nonce();
386
+ const plan = await admission.enqueue(async () => {
387
+ if (given === "invalid") {
388
+ const ref = deps.nonce();
389
+ await writeRecord({
390
+ decision: "deny",
391
+ reasonCode: "ref-invalid",
392
+ ref,
393
+ requestHash,
394
+ inputs: resolved.inputs,
395
+ effectHash: null,
396
+ subject: call.name,
397
+ timestampMs,
398
+ });
399
+ return { kind: "done", result: denied("ref-invalid", ref) };
400
+ }
401
+ if (typeof given === "string") {
402
+ // The ledger, the approvals snapshot and the resolution index all key on the
403
+ // scoped ref. The brain only ever sees and resends the raw `_ref` it chose.
404
+ const scopedRef = scopedClaimsRef(principal, given);
405
+ const existing = await lookupDecisionByRef(deps.ledger, given, tenantKey(principal));
406
+ if (existing) {
407
+ if (existing.requestHash !== requestHash) {
408
+ const ref = deps.nonce();
413
409
  await writeRecord({
414
410
  decision: "deny",
415
- reasonCode: "expired",
416
- ref: expireRef,
411
+ reasonCode: "ref-reuse",
412
+ ref,
417
413
  requestHash,
418
- inputs: {
419
- ...resolved.inputs,
420
- approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
421
- },
414
+ inputs: resolved.inputs,
422
415
  effectHash: null,
423
416
  subject: call.name,
424
417
  timestampMs,
425
418
  });
426
- await approvals.updateStatus(scopedRef, "expired");
427
- return denied("expired", expireRef);
419
+ return { kind: "done", result: denied("ref-reuse", ref) };
428
420
  }
429
- const allowRef = snap?.allowRef ?? (boundHit?.kind === "allow" ? boundHit.ref : undefined);
430
- const allow = allowRef ? await lookupDecisionByRef(deps.ledger, allowRef) : null;
431
- if (allow?.ref && allow.decision === "allow" && allow.reasonCode === "approved-by-operator") {
432
- const bound = await inputsLog.get(allow.ref);
433
- if (bound?.approver?.resolves && bound.approver.resolves !== scopedRef) {
434
- return deferred(given);
421
+ if (existing.decision === "defer") {
422
+ const snap = await approvals.get(scopedRef);
423
+ const boundHit = lookupResolvedBy(deps.ledger, scopedRef);
424
+ if (boundHit?.kind === "expired") {
425
+ if (snap?.status === "pending")
426
+ await approvals.updateStatus(scopedRef, "expired");
427
+ return { kind: "done", result: denied("expired", boundHit.ref) };
435
428
  }
436
- if (!snap?.allowRef) {
437
- await approvals.updateStatus(scopedRef, "approved", { allowRef: allow.ref });
429
+ if (snap && timestampMs > snap.expiresAtMs) {
430
+ const expireRef = deps.nonce();
431
+ await writeRecord({
432
+ decision: "deny",
433
+ reasonCode: "expired",
434
+ ref: expireRef,
435
+ requestHash,
436
+ inputs: {
437
+ ...resolved.inputs,
438
+ approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
439
+ },
440
+ effectHash: null,
441
+ subject: call.name,
442
+ timestampMs,
443
+ });
444
+ await approvals.updateStatus(scopedRef, "expired");
445
+ return { kind: "done", result: denied("expired", expireRef) };
438
446
  }
439
- if (await hasPrimaryEffect(deps.ledger, allow.ref))
440
- return allowedReplay(allow.ref);
441
- if (allow.subject === "spend") {
442
- return spendReauth(requestHash, resolved.inputs, timestampMs, call.name);
447
+ const allowRef = snap?.allowRef ?? (boundHit?.kind === "allow" ? boundHit.ref : undefined);
448
+ const allow = allowRef ? await lookupDecisionByRef(deps.ledger, allowRef) : null;
449
+ if (allow?.ref && allow.decision === "allow" && allow.reasonCode === "approved-by-operator") {
450
+ const bound = await inputsLog.get(allow.ref);
451
+ if (bound?.approver?.resolves && bound.approver.resolves !== scopedRef) {
452
+ return { kind: "done", result: deferred(given) };
453
+ }
454
+ if (!snap?.allowRef) {
455
+ await approvals.updateStatus(scopedRef, "approved", { allowRef: allow.ref });
456
+ }
457
+ if (await hasPrimaryEffect(deps.ledger, allow.ref)) {
458
+ return { kind: "done", result: allowedReplay(allow.ref) };
459
+ }
460
+ if (allow.subject === "spend") {
461
+ return {
462
+ kind: "done",
463
+ result: await spendReauth(requestHash, resolved.inputs, timestampMs, call.name),
464
+ };
465
+ }
466
+ const policyDeny = await retryPolicyDeny(dispatched, principal, requestHash, resolved.inputs, timestampMs, call.name, scopedRef);
467
+ if (policyDeny)
468
+ return { kind: "done", result: policyDeny };
469
+ const flying = inFlight.get(scopedRef);
470
+ if (flying)
471
+ return { kind: "wait", work: flying, replayRef: allow.ref };
472
+ return launchInner(dispatched, principal, allow.ref, scopedRef);
443
473
  }
444
- return runInner(dispatched, principal, allow.ref);
474
+ return { kind: "done", result: deferred(given) };
445
475
  }
446
- return deferred(given);
447
- }
448
- if (existing.decision === "deny") {
449
- return denied(existing.reasonCode, given, call);
450
- }
451
- if (existing.decision === "allow" && existing.ref) {
452
- if (await hasPrimaryEffect(deps.ledger, existing.ref)) {
453
- return allowedReplay(existing.ref);
476
+ if (existing.decision === "deny") {
477
+ return { kind: "done", result: denied(existing.reasonCode, given, call) };
454
478
  }
455
- if (existing.subject === "spend") {
456
- return spendReauth(requestHash, resolved.inputs, timestampMs, call.name);
479
+ if (existing.decision === "allow" && existing.ref) {
480
+ if (await hasPrimaryEffect(deps.ledger, existing.ref)) {
481
+ return { kind: "done", result: allowedReplay(existing.ref) };
482
+ }
483
+ if (existing.subject === "spend") {
484
+ return {
485
+ kind: "done",
486
+ result: await spendReauth(requestHash, resolved.inputs, timestampMs, call.name),
487
+ };
488
+ }
489
+ const policyDeny = await retryPolicyDeny(dispatched, principal, requestHash, resolved.inputs, timestampMs, call.name, scopedRef);
490
+ if (policyDeny)
491
+ return { kind: "done", result: policyDeny };
492
+ const flying = inFlight.get(scopedRef);
493
+ if (flying)
494
+ return { kind: "wait", work: flying, replayRef: existing.ref };
495
+ const denyRef = deps.nonce();
496
+ await writeRecord({
497
+ decision: "deny",
498
+ reasonCode: "outcome-unknown",
499
+ ref: denyRef,
500
+ requestHash,
501
+ inputs: {
502
+ ...resolved.inputs,
503
+ approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
504
+ },
505
+ effectHash: null,
506
+ subject: call.name,
507
+ timestampMs,
508
+ });
509
+ return { kind: "done", result: denied("outcome-unknown", denyRef) };
457
510
  }
458
- return runInner(dispatched, principal, existing.ref);
459
511
  }
460
512
  }
461
- }
462
- let spendCtx;
463
- if (dispatched.name === "spend") {
464
- const approvalRows = await approvals.listAll();
465
- spendCtx = {
466
- spentTodayMinor: (currency) => spentTodayMinorOf(approvalRows, timestampMs, currency),
467
- };
468
- }
469
- const verdict = deps.policy.evaluate(dispatched, principal, spendCtx);
470
- let reasonCode = resolved.reasonCode ?? verdict.reasonCode;
471
- let decision = resolved.reasonCode ? "deny" : verdict.decision;
472
- const bound = rateBound(timestampMs, decision === "allow" || decision === "defer");
473
- if (bound) {
474
- decision = "deny";
475
- reasonCode = bound;
476
- }
477
- if (!bound &&
478
- decision === "allow" &&
479
- deps.checkTenantMismatch &&
480
- (await deps.checkTenantMismatch(dispatched, principal))) {
481
- decision = "deny";
482
- reasonCode = "tenant-mismatch";
483
- }
484
- const ref = typeof given === "string" ? scopedClaimsRef(principal, given) : deps.nonce();
485
- // What the brain is told: its own `_ref`, so a retry carries the same key back.
486
- const shown = typeof given === "string" ? given : ref;
487
- const allow = decision === "allow";
488
- await writeRecord({
489
- decision,
490
- reasonCode,
491
- ref,
492
- requestHash,
493
- inputs: resolved.inputs,
494
- effectHash: allow ? sha256Canonical(effectDescriptor(dispatched.name, dispatched.arguments)) : null,
495
- subject: call.name,
496
- timestampMs,
497
- });
498
- if (typeof given === "string") {
499
- noteTenantRef(deps.ledger, tenantKey(principal), given);
500
- }
501
- if (decision === "defer") {
502
- const rule = deps.policy.rule(verdict.rule);
503
- const amount = dispatched.name === "spend" ? dispatched.arguments.amountMinor : dispatched.arguments.amount;
504
- const payee = dispatched.arguments.payee;
505
- const currency = dispatched.arguments.currency;
506
- await approvals.append({
513
+ let spendCtx;
514
+ if (dispatched.name === "spend") {
515
+ const approvalRows = await approvals.listAll();
516
+ spendCtx = {
517
+ spentTodayMinor: (currency) => spentTodayMinorOf(approvalRows, timestampMs, currency, deps.policy.approvalTtlMs),
518
+ };
519
+ }
520
+ const verdict = deps.policy.evaluate(dispatched, principal, spendCtx);
521
+ let reasonCode = resolved.reasonCode ?? verdict.reasonCode;
522
+ let decision = resolved.reasonCode ? "deny" : verdict.decision;
523
+ const bound = rateBound(timestampMs, decision === "allow" || decision === "defer");
524
+ if (bound) {
525
+ decision = "deny";
526
+ reasonCode = bound;
527
+ }
528
+ if (!bound &&
529
+ decision === "allow" &&
530
+ deps.checkTenantMismatch &&
531
+ (await deps.checkTenantMismatch(dispatched, principal))) {
532
+ decision = "deny";
533
+ reasonCode = "tenant-mismatch";
534
+ }
535
+ const ref = typeof given === "string" ? scopedClaimsRef(principal, given) : deps.nonce();
536
+ // What the brain is told: its own `_ref`, so a retry carries the same key back.
537
+ const shown = typeof given === "string" ? given : ref;
538
+ const allow = decision === "allow";
539
+ await writeRecord({
540
+ decision,
541
+ reasonCode,
507
542
  ref,
508
543
  requestHash,
509
- subject: dispatched.name,
510
- args: dispatched.arguments,
511
- ruleId: verdict.rule,
512
- ruleText: rule?.text ?? null,
513
- inputsSummary: {
514
- count: resolved.inputs.inputs.length,
515
- ids: resolved.inputs.inputs.map((i) => i.id),
516
- },
517
- ...(amount !== undefined ? { amount } : {}),
518
- ...(payee !== undefined ? { payee } : {}),
519
- ...(currency !== undefined ? { currency } : {}),
520
- createdAtMs: timestampMs,
521
- expiresAtMs: timestampMs + deps.policy.approvalTtlMs,
522
- status: "pending",
523
- brain: principal.brain,
544
+ inputs: resolved.inputs,
545
+ effectHash: allow ? sha256Canonical(effectDescriptor(dispatched.name, dispatched.arguments)) : null,
546
+ subject: call.name,
547
+ timestampMs,
524
548
  });
525
- return deferred(shown);
549
+ if (typeof given === "string") {
550
+ noteTenantRef(deps.ledger, tenantKey(principal), given);
551
+ }
552
+ if (decision === "defer") {
553
+ const rule = deps.policy.rule(verdict.rule);
554
+ const amount = dispatched.name === "spend" ? dispatched.arguments.amountMinor : dispatched.arguments.amount;
555
+ const payee = dispatched.arguments.payee;
556
+ const currency = dispatched.arguments.currency;
557
+ await approvals.append({
558
+ ref,
559
+ requestHash,
560
+ subject: dispatched.name,
561
+ args: dispatched.arguments,
562
+ ruleId: verdict.rule,
563
+ ruleText: rule?.text ?? null,
564
+ inputsSummary: {
565
+ count: resolved.inputs.inputs.length,
566
+ ids: resolved.inputs.inputs.map((i) => i.id),
567
+ },
568
+ ...(amount !== undefined ? { amount } : {}),
569
+ ...(payee !== undefined ? { payee } : {}),
570
+ ...(currency !== undefined ? { currency } : {}),
571
+ createdAtMs: timestampMs,
572
+ expiresAtMs: timestampMs + deps.policy.approvalTtlMs,
573
+ status: "pending",
574
+ brain: principal.brain,
575
+ });
576
+ return { kind: "done", result: deferred(shown) };
577
+ }
578
+ if (!allow) {
579
+ return { kind: "done", result: denied(reasonCode, shown, call) };
580
+ }
581
+ return launchInner(dispatched, principal, ref, ref);
582
+ });
583
+ if (plan.kind === "done")
584
+ return plan.result;
585
+ if (plan.kind === "wait") {
586
+ try {
587
+ await plan.work;
588
+ }
589
+ catch {
590
+ // The first call recorded the throw on its effect row.
591
+ }
592
+ return allowedReplay(plan.replayRef);
593
+ }
594
+ try {
595
+ return await plan.work;
526
596
  }
527
- if (!allow) {
528
- return denied(reasonCode, shown, call);
597
+ finally {
598
+ inFlight.delete(plan.key);
529
599
  }
530
- return runInner(dispatched, principal, ref);
531
600
  },
532
601
  };
533
602
  }
@@ -0,0 +1,5 @@
1
+ /** One async tail so a later turn cannot start until the earlier one settles. */
2
+ export declare class SerialQueue {
3
+ private tail;
4
+ enqueue<T>(fn: () => Promise<T>): Promise<T>;
5
+ }
@@ -0,0 +1,9 @@
1
+ /** One async tail so a later turn cannot start until the earlier one settles. */
2
+ export class SerialQueue {
3
+ tail = Promise.resolve();
4
+ enqueue(fn) {
5
+ const run = this.tail.then(fn, fn);
6
+ this.tail = run.then(() => undefined, () => undefined);
7
+ return run;
8
+ }
9
+ }
package/dist/types.d.ts CHANGED
@@ -48,6 +48,9 @@ export type Policy = {
48
48
  rule(id: string | null): {
49
49
  id: string;
50
50
  text: string;
51
+ spend?: {
52
+ dailyMaxMinor?: number;
53
+ };
51
54
  } | null;
52
55
  };
53
56
  export type WitnessClass = "self" | "same-org" | "third-party" | "regulated";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verax-ai/proxy",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {