@verax-ai/proxy 0.1.1 → 0.1.3

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,8 +1,10 @@
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";
7
+ import { markEnded, markStarted, startedWithoutEnd } from "./in-flight-log.js";
6
8
  import { effectDescriptor, sha256Canonical } from "./hash.js";
7
9
  import { inputsLogFor } from "./inputs.js";
8
10
  import { countedWork, hasPrimaryEffect, lookupDecisionByRef, lookupReauthByHash, lookupResolvedBy, noteResolution, noteTenantRef, } from "./ledger.js";
@@ -129,6 +131,13 @@ function toolCallOf(call) {
129
131
  export function createProxy(deps) {
130
132
  const inputsLog = deps.inputsLog ?? inputsLogFor(deps.ledger);
131
133
  const approvals = approvalsLogFor(deps.ledger);
134
+ const admission = new SerialQueue();
135
+ const inFlight = new Map();
136
+ const budgetGuard = createApprovalBudgetGuard({
137
+ policy: deps.policy,
138
+ approvals,
139
+ now: deps.now,
140
+ });
132
141
  deps.ledger.effectSigner = deps.effectSigner;
133
142
  const ledgerDir = deps.ledger.dir;
134
143
  const stateDir = typeof ledgerDir === "string" ? ledgerDir : null;
@@ -192,30 +201,6 @@ export function createProxy(deps) {
192
201
  return "daily-limited";
193
202
  return null;
194
203
  }
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
204
  async function spendReauth(requestHash, inputs, timestampMs, subject) {
220
205
  const existingReauth = await lookupReauthByHash(deps.ledger, requestHash);
221
206
  if (existingReauth)
@@ -309,6 +294,43 @@ export function createProxy(deps) {
309
294
  reasonCode: null,
310
295
  };
311
296
  }
297
+ function launchInner(dispatchedCall, principal, allowRef, flightKey) {
298
+ // Written before the tool runs, removed after the effect is recorded. A
299
+ // mark that survives the process is what tells a restarted body that this
300
+ // ref was already started (in-flight-log.ts).
301
+ if (stateDir !== null)
302
+ markStarted(stateDir, flightKey, dispatchedCall.name);
303
+ const work = runInner(dispatchedCall, principal, allowRef);
304
+ inFlight.set(flightKey, work);
305
+ return { kind: "run", work, key: flightKey, replayRef: allowRef };
306
+ }
307
+ async function retryPolicyDeny(dispatchedCall, principal, requestHash, inputs, timestampMs, subject, scopedRef) {
308
+ let spendCtx;
309
+ if (dispatchedCall.name === "spend") {
310
+ const approvalRows = await approvals.listAll();
311
+ spendCtx = {
312
+ spentTodayMinor: (currency) => spentTodayMinorOf(approvalRows, timestampMs, currency, deps.policy.approvalTtlMs),
313
+ };
314
+ }
315
+ const verdict = deps.policy.evaluate(dispatchedCall, principal, spendCtx);
316
+ if (verdict.decision !== "deny")
317
+ return null;
318
+ const denyRef = deps.nonce();
319
+ await writeRecord({
320
+ decision: "deny",
321
+ reasonCode: verdict.reasonCode,
322
+ ref: denyRef,
323
+ requestHash,
324
+ inputs: {
325
+ ...inputs,
326
+ approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
327
+ },
328
+ effectHash: null,
329
+ subject,
330
+ timestampMs,
331
+ });
332
+ return denied(verdict.reasonCode, denyRef);
333
+ }
312
334
  return {
313
335
  approvals,
314
336
  inputsLog,
@@ -330,6 +352,7 @@ export function createProxy(deps) {
330
352
  policyHash: defer.policyHash,
331
353
  approvals,
332
354
  inputsLog,
355
+ budgetGuard,
333
356
  });
334
357
  });
335
358
  }
@@ -366,168 +389,245 @@ export function createProxy(deps) {
366
389
  return denied("ledger-disk-low", ref);
367
390
  }
368
391
  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();
392
+ const plan = await admission.enqueue(async () => {
393
+ if (given === "invalid") {
394
+ const ref = deps.nonce();
395
+ await writeRecord({
396
+ decision: "deny",
397
+ reasonCode: "ref-invalid",
398
+ ref,
399
+ requestHash,
400
+ inputs: resolved.inputs,
401
+ effectHash: null,
402
+ subject: call.name,
403
+ timestampMs,
404
+ });
405
+ return { kind: "done", result: denied("ref-invalid", ref) };
406
+ }
407
+ if (typeof given === "string") {
408
+ // The ledger, the approvals snapshot and the resolution index all key on the
409
+ // scoped ref. The brain only ever sees and resends the raw `_ref` it chose.
410
+ const scopedRef = scopedClaimsRef(principal, given);
411
+ const existing = await lookupDecisionByRef(deps.ledger, given, tenantKey(principal));
412
+ if (existing) {
413
+ if (existing.requestHash !== requestHash) {
414
+ const ref = deps.nonce();
413
415
  await writeRecord({
414
416
  decision: "deny",
415
- reasonCode: "expired",
416
- ref: expireRef,
417
+ reasonCode: "ref-reuse",
418
+ ref,
417
419
  requestHash,
418
- inputs: {
419
- ...resolved.inputs,
420
- approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
421
- },
420
+ inputs: resolved.inputs,
422
421
  effectHash: null,
423
422
  subject: call.name,
424
423
  timestampMs,
425
424
  });
426
- await approvals.updateStatus(scopedRef, "expired");
427
- return denied("expired", expireRef);
425
+ return { kind: "done", result: denied("ref-reuse", ref) };
428
426
  }
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);
427
+ if (existing.decision === "defer") {
428
+ const snap = await approvals.get(scopedRef);
429
+ const boundHit = lookupResolvedBy(deps.ledger, scopedRef);
430
+ if (boundHit?.kind === "expired") {
431
+ if (snap?.status === "pending")
432
+ await approvals.updateStatus(scopedRef, "expired");
433
+ return { kind: "done", result: denied("expired", boundHit.ref) };
435
434
  }
436
- if (!snap?.allowRef) {
437
- await approvals.updateStatus(scopedRef, "approved", { allowRef: allow.ref });
435
+ if (snap && timestampMs > snap.expiresAtMs) {
436
+ const expireRef = deps.nonce();
437
+ await writeRecord({
438
+ decision: "deny",
439
+ reasonCode: "expired",
440
+ ref: expireRef,
441
+ requestHash,
442
+ inputs: {
443
+ ...resolved.inputs,
444
+ approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
445
+ },
446
+ effectHash: null,
447
+ subject: call.name,
448
+ timestampMs,
449
+ });
450
+ await approvals.updateStatus(scopedRef, "expired");
451
+ return { kind: "done", result: denied("expired", expireRef) };
438
452
  }
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);
453
+ const allowRef = snap?.allowRef ?? (boundHit?.kind === "allow" ? boundHit.ref : undefined);
454
+ const allow = allowRef ? await lookupDecisionByRef(deps.ledger, allowRef) : null;
455
+ if (allow?.ref && allow.decision === "allow" && allow.reasonCode === "approved-by-operator") {
456
+ const bound = await inputsLog.get(allow.ref);
457
+ if (bound?.approver?.resolves && bound.approver.resolves !== scopedRef) {
458
+ return { kind: "done", result: deferred(given) };
459
+ }
460
+ if (!snap?.allowRef) {
461
+ await approvals.updateStatus(scopedRef, "approved", { allowRef: allow.ref });
462
+ }
463
+ if (await hasPrimaryEffect(deps.ledger, allow.ref)) {
464
+ return { kind: "done", result: allowedReplay(allow.ref) };
465
+ }
466
+ if (allow.subject === "spend") {
467
+ return {
468
+ kind: "done",
469
+ result: await spendReauth(requestHash, resolved.inputs, timestampMs, call.name),
470
+ };
471
+ }
472
+ const policyDeny = await retryPolicyDeny(dispatched, principal, requestHash, resolved.inputs, timestampMs, call.name, scopedRef);
473
+ if (policyDeny)
474
+ return { kind: "done", result: policyDeny };
475
+ const flying = inFlight.get(scopedRef);
476
+ if (flying)
477
+ return { kind: "wait", work: flying, replayRef: allow.ref };
478
+ // An approved allow means "the operator said yes", not "it ran",
479
+ // so the first retry after an approval is the first run. Only a
480
+ // mark left on disk says a run was already started and never
481
+ // finished — that one cannot be run again.
482
+ if (stateDir !== null && startedWithoutEnd(stateDir, scopedRef)) {
483
+ const unknownRef = deps.nonce();
484
+ await writeRecord({
485
+ decision: "deny",
486
+ reasonCode: "outcome-unknown",
487
+ ref: unknownRef,
488
+ requestHash,
489
+ inputs: {
490
+ ...resolved.inputs,
491
+ approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
492
+ },
493
+ effectHash: null,
494
+ subject: call.name,
495
+ timestampMs,
496
+ });
497
+ return { kind: "done", result: denied("outcome-unknown", unknownRef) };
498
+ }
499
+ return launchInner(dispatched, principal, allow.ref, scopedRef);
443
500
  }
444
- return runInner(dispatched, principal, allow.ref);
501
+ return { kind: "done", result: deferred(given) };
445
502
  }
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);
503
+ if (existing.decision === "deny") {
504
+ return { kind: "done", result: denied(existing.reasonCode, given, call) };
454
505
  }
455
- if (existing.subject === "spend") {
456
- return spendReauth(requestHash, resolved.inputs, timestampMs, call.name);
506
+ if (existing.decision === "allow" && existing.ref) {
507
+ if (await hasPrimaryEffect(deps.ledger, existing.ref)) {
508
+ return { kind: "done", result: allowedReplay(existing.ref) };
509
+ }
510
+ if (existing.subject === "spend") {
511
+ return {
512
+ kind: "done",
513
+ result: await spendReauth(requestHash, resolved.inputs, timestampMs, call.name),
514
+ };
515
+ }
516
+ const policyDeny = await retryPolicyDeny(dispatched, principal, requestHash, resolved.inputs, timestampMs, call.name, scopedRef);
517
+ if (policyDeny)
518
+ return { kind: "done", result: policyDeny };
519
+ const flying = inFlight.get(scopedRef);
520
+ if (flying)
521
+ return { kind: "wait", work: flying, replayRef: existing.ref };
522
+ const denyRef = deps.nonce();
523
+ await writeRecord({
524
+ decision: "deny",
525
+ reasonCode: "outcome-unknown",
526
+ ref: denyRef,
527
+ requestHash,
528
+ inputs: {
529
+ ...resolved.inputs,
530
+ approver: { id: "verax-proxy", via: "proxy", resolves: scopedRef },
531
+ },
532
+ effectHash: null,
533
+ subject: call.name,
534
+ timestampMs,
535
+ });
536
+ return { kind: "done", result: denied("outcome-unknown", denyRef) };
457
537
  }
458
- return runInner(dispatched, principal, existing.ref);
459
538
  }
460
539
  }
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({
540
+ let spendCtx;
541
+ if (dispatched.name === "spend") {
542
+ const approvalRows = await approvals.listAll();
543
+ spendCtx = {
544
+ spentTodayMinor: (currency) => spentTodayMinorOf(approvalRows, timestampMs, currency, deps.policy.approvalTtlMs),
545
+ };
546
+ }
547
+ const verdict = deps.policy.evaluate(dispatched, principal, spendCtx);
548
+ let reasonCode = resolved.reasonCode ?? verdict.reasonCode;
549
+ let decision = resolved.reasonCode ? "deny" : verdict.decision;
550
+ const bound = rateBound(timestampMs, decision === "allow" || decision === "defer");
551
+ if (bound) {
552
+ decision = "deny";
553
+ reasonCode = bound;
554
+ }
555
+ if (!bound &&
556
+ decision === "allow" &&
557
+ deps.checkTenantMismatch &&
558
+ (await deps.checkTenantMismatch(dispatched, principal))) {
559
+ decision = "deny";
560
+ reasonCode = "tenant-mismatch";
561
+ }
562
+ const ref = typeof given === "string" ? scopedClaimsRef(principal, given) : deps.nonce();
563
+ // What the brain is told: its own `_ref`, so a retry carries the same key back.
564
+ const shown = typeof given === "string" ? given : ref;
565
+ const allow = decision === "allow";
566
+ await writeRecord({
567
+ decision,
568
+ reasonCode,
507
569
  ref,
508
570
  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,
571
+ inputs: resolved.inputs,
572
+ effectHash: allow ? sha256Canonical(effectDescriptor(dispatched.name, dispatched.arguments)) : null,
573
+ subject: call.name,
574
+ timestampMs,
524
575
  });
525
- return deferred(shown);
576
+ if (typeof given === "string") {
577
+ noteTenantRef(deps.ledger, tenantKey(principal), given);
578
+ }
579
+ if (decision === "defer") {
580
+ const rule = deps.policy.rule(verdict.rule);
581
+ const amount = dispatched.name === "spend" ? dispatched.arguments.amountMinor : dispatched.arguments.amount;
582
+ const payee = dispatched.arguments.payee;
583
+ const currency = dispatched.arguments.currency;
584
+ await approvals.append({
585
+ ref,
586
+ requestHash,
587
+ subject: dispatched.name,
588
+ args: dispatched.arguments,
589
+ ruleId: verdict.rule,
590
+ ruleText: rule?.text ?? null,
591
+ inputsSummary: {
592
+ count: resolved.inputs.inputs.length,
593
+ ids: resolved.inputs.inputs.map((i) => i.id),
594
+ },
595
+ ...(amount !== undefined ? { amount } : {}),
596
+ ...(payee !== undefined ? { payee } : {}),
597
+ ...(currency !== undefined ? { currency } : {}),
598
+ createdAtMs: timestampMs,
599
+ expiresAtMs: timestampMs + deps.policy.approvalTtlMs,
600
+ status: "pending",
601
+ brain: principal.brain,
602
+ });
603
+ return { kind: "done", result: deferred(shown) };
604
+ }
605
+ if (!allow) {
606
+ return { kind: "done", result: denied(reasonCode, shown, call) };
607
+ }
608
+ return launchInner(dispatched, principal, ref, ref);
609
+ });
610
+ if (plan.kind === "done")
611
+ return plan.result;
612
+ if (plan.kind === "wait") {
613
+ try {
614
+ await plan.work;
615
+ }
616
+ catch {
617
+ // The first call recorded the throw on its effect row.
618
+ }
619
+ return allowedReplay(plan.replayRef);
620
+ }
621
+ try {
622
+ return await plan.work;
526
623
  }
527
- if (!allow) {
528
- return denied(reasonCode, shown, call);
624
+ finally {
625
+ inFlight.delete(plan.key);
626
+ // runInner has written its effect row by now — on the throw path too,
627
+ // as `<name>:threw`. The outcome is on the ledger, so the mark goes.
628
+ if (stateDir !== null)
629
+ markEnded(stateDir, plan.key);
529
630
  }
530
- return runInner(dispatched, principal, ref);
531
631
  },
532
632
  };
533
633
  }
@@ -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.3",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {