@axtary/proxy 0.0.1 → 0.2.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.
package/dist/index.js CHANGED
@@ -1,13 +1,196 @@
1
- import { APPROVAL_ARTIFACT_VERSION, DEFAULT_EXPIRES_IN_SECONDS, issueActionPass, parseNormalizedAction, } from "@axtary/actionpass";
2
- import { evaluatePolicy } from "@axtary/policy";
1
+ import { APPROVAL_ARTIFACT_VERSION, ActionProvenanceSchema, actionPassDpopTarget, BudgetAmountSchema, createDpopProof, DEFAULT_EXPIRES_IN_SECONDS, issueActionPass, issueActionPassV1, issueActionPassV2, parseNormalizedAction, } from "@axtary/actionpass";
2
+ import { randomUUID } from "node:crypto";
3
+ import { evaluatePolicy, PolicyConfigSchema, } from "@axtary/policy";
3
4
  import { performance } from "node:perf_hooks";
4
5
  export const PROXY_SCHEMA_VERSION = "axtary.proxy.v0";
5
6
  export const PROXY_POLICY_VERSION = "2026-05-30";
6
7
  export const DEFAULT_HANDLER_TIMEOUT_MS = 10_000;
8
+ export const LOCAL_AUTHORIZATION_DECISION_BUDGET_MS = 200;
9
+ export class InMemoryBudgetMeter {
10
+ committed = new Map();
11
+ reservations = new Map();
12
+ reserve(input) {
13
+ const cost = normalizeBudgetAmount(input.cost);
14
+ const limit = normalizeBudgetAmount(input.limit);
15
+ const committedBefore = this.committed.get(input.scope) ?? {};
16
+ const pendingBefore = this.pendingForScope(input.scope);
17
+ const dimensions = new Set([
18
+ ...Object.keys(cost),
19
+ ...Object.keys(limit),
20
+ ...Object.keys(committedBefore),
21
+ ...Object.keys(pendingBefore),
22
+ ]);
23
+ const exceeded = [...dimensions].some((dimension) => {
24
+ const requested = cost[dimension] ?? 0;
25
+ if (requested === 0)
26
+ return false;
27
+ const ceiling = limit[dimension];
28
+ return (ceiling === undefined ||
29
+ (committedBefore[dimension] ?? 0) +
30
+ (pendingBefore[dimension] ?? 0) +
31
+ requested >
32
+ ceiling);
33
+ });
34
+ if (exceeded) {
35
+ return {
36
+ accepted: false,
37
+ event: {
38
+ reservationId: null,
39
+ scope: input.scope,
40
+ status: "denied",
41
+ expiresAt: null,
42
+ cost,
43
+ limit,
44
+ committedBefore,
45
+ committedAfter: committedBefore,
46
+ reservedAfter: pendingBefore,
47
+ },
48
+ };
49
+ }
50
+ const reservation = {
51
+ reservationId: `budget_${randomUUID()}`,
52
+ scope: input.scope,
53
+ cost,
54
+ limit,
55
+ status: "pending",
56
+ expiresAt: input.expiresAt,
57
+ };
58
+ this.reservations.set(reservation.reservationId, reservation);
59
+ return {
60
+ accepted: true,
61
+ reservation,
62
+ event: {
63
+ reservationId: reservation.reservationId,
64
+ scope: input.scope,
65
+ status: "pending",
66
+ expiresAt: input.expiresAt.toISOString(),
67
+ cost,
68
+ limit,
69
+ committedBefore,
70
+ committedAfter: committedBefore,
71
+ reservedAfter: addBudgetAmounts(pendingBefore, cost),
72
+ },
73
+ };
74
+ }
75
+ commit(reservationId) {
76
+ const reservation = this.pendingReservation(reservationId);
77
+ const committedBefore = this.committed.get(reservation.scope) ?? {};
78
+ const committedAfter = addBudgetAmounts(committedBefore, reservation.cost);
79
+ reservation.status = "committed";
80
+ this.committed.set(reservation.scope, committedAfter);
81
+ return {
82
+ reservationId,
83
+ scope: reservation.scope,
84
+ status: "committed",
85
+ expiresAt: reservation.expiresAt.toISOString(),
86
+ cost: reservation.cost,
87
+ limit: reservation.limit,
88
+ committedBefore,
89
+ committedAfter,
90
+ reservedAfter: this.pendingForScope(reservation.scope),
91
+ };
92
+ }
93
+ rollback(reservationId) {
94
+ const reservation = this.pendingReservation(reservationId);
95
+ const committed = this.committed.get(reservation.scope) ?? {};
96
+ reservation.status = "rolled_back";
97
+ return {
98
+ reservationId,
99
+ scope: reservation.scope,
100
+ status: "rolled_back",
101
+ expiresAt: reservation.expiresAt.toISOString(),
102
+ cost: reservation.cost,
103
+ limit: reservation.limit,
104
+ committedBefore: committed,
105
+ committedAfter: committed,
106
+ reservedAfter: this.pendingForScope(reservation.scope),
107
+ };
108
+ }
109
+ hydrate(events, now = new Date()) {
110
+ for (const event of events) {
111
+ if (!event.reservationId || !event.expiresAt)
112
+ continue;
113
+ const existing = this.reservations.get(event.reservationId);
114
+ if (event.status === "pending") {
115
+ this.reservations.set(event.reservationId, {
116
+ reservationId: event.reservationId,
117
+ scope: event.scope,
118
+ cost: event.cost,
119
+ limit: event.limit,
120
+ status: new Date(event.expiresAt).getTime() <= now.getTime()
121
+ ? "rolled_back"
122
+ : "pending",
123
+ expiresAt: new Date(event.expiresAt),
124
+ });
125
+ }
126
+ else if (event.status === "committed") {
127
+ if (!existing || existing.status !== "committed") {
128
+ this.committed.set(event.scope, addBudgetAmounts(this.committed.get(event.scope) ?? {}, event.cost));
129
+ }
130
+ if (existing)
131
+ existing.status = "committed";
132
+ }
133
+ else if (event.status === "rolled_back" && existing) {
134
+ existing.status = "rolled_back";
135
+ }
136
+ }
137
+ }
138
+ pendingReservation(reservationId) {
139
+ const reservation = this.reservations.get(reservationId);
140
+ if (!reservation) {
141
+ throw new Error("budget_reservation_not_found");
142
+ }
143
+ if (reservation.status !== "pending") {
144
+ throw new Error("budget_reservation_already_settled");
145
+ }
146
+ return reservation;
147
+ }
148
+ pendingForScope(scope) {
149
+ let pending = {};
150
+ for (const reservation of this.reservations.values()) {
151
+ if (reservation.scope === scope && reservation.status === "pending") {
152
+ pending = addBudgetAmounts(pending, reservation.cost);
153
+ }
154
+ }
155
+ return pending;
156
+ }
157
+ }
158
+ export class LedgerBackedBudgetMeter {
159
+ ledger;
160
+ initialization = null;
161
+ meter = new InMemoryBudgetMeter();
162
+ constructor(ledger) {
163
+ this.ledger = ledger;
164
+ }
165
+ async reserve(input) {
166
+ await this.initialize(input.now);
167
+ return this.meter.reserve(input);
168
+ }
169
+ async commit(reservationId) {
170
+ await this.initialize(new Date());
171
+ return this.meter.commit(reservationId);
172
+ }
173
+ async rollback(reservationId) {
174
+ await this.initialize(new Date());
175
+ return this.meter.rollback(reservationId);
176
+ }
177
+ initialize(now) {
178
+ this.initialization ??= this.ledger.readRecords().then((records) => {
179
+ this.meter.hydrate(records.flatMap((record) => (record.budget ? [record.budget] : [])), now);
180
+ });
181
+ return this.initialization;
182
+ }
183
+ }
7
184
  export class AxtaryProxyRuntime {
8
185
  config;
186
+ rateLimitEvents = new Map();
187
+ budgetMeter;
188
+ budgetConfig;
9
189
  constructor(config) {
10
190
  this.config = config;
191
+ this.budgetConfig = normalizeProxyBudgetConfig(config.budget);
192
+ this.budgetMeter =
193
+ config.budgetMeter ?? new LedgerBackedBudgetMeter(config.ledger);
11
194
  }
12
195
  async handle(actionInput) {
13
196
  const startedAt = performance.now();
@@ -16,6 +199,15 @@ export class AxtaryProxyRuntime {
16
199
  try {
17
200
  const parseStartedAt = performance.now();
18
201
  action = parseNormalizedAction(actionInput);
202
+ if (action.budget) {
203
+ throw new Error("budget_authority_fields_forbidden");
204
+ }
205
+ if (action.provenance) {
206
+ throw new Error("provenance_authority_fields_forbidden");
207
+ }
208
+ if (this.config.provenanceResolver) {
209
+ action = actionWithProvenance(action, await this.config.provenanceResolver(action));
210
+ }
19
211
  timings.parseMs = elapsed(parseStartedAt);
20
212
  }
21
213
  catch (error) {
@@ -33,7 +225,9 @@ export class AxtaryProxyRuntime {
33
225
  const resolvedPolicy = typeof this.config.policy === "function"
34
226
  ? await this.config.policy()
35
227
  : this.config.policy;
36
- policyDecision = evaluatePolicy(action, resolvedPolicy);
228
+ const policy = PolicyConfigSchema.parse(resolvedPolicy ?? {});
229
+ const behaviorSignals = await this.behaviorSignals(action);
230
+ policyDecision = evaluatePolicy(action, policy, this.policyEvaluationContext(action, policy, behaviorSignals));
37
231
  timings.policyMs = elapsed(policyStartedAt);
38
232
  }
39
233
  catch (error) {
@@ -42,7 +236,7 @@ export class AxtaryProxyRuntime {
42
236
  const ledgerStartedAt = performance.now();
43
237
  const ledger = await this.append(action, decision, null);
44
238
  timings.ledgerMs = elapsed(ledgerStartedAt);
45
- timings.totalMs = elapsed(startedAt);
239
+ completeDecisionTiming(timings, startedAt);
46
240
  return {
47
241
  schemaVersion: PROXY_SCHEMA_VERSION,
48
242
  status: "blocked",
@@ -63,7 +257,7 @@ export class AxtaryProxyRuntime {
63
257
  }));
64
258
  timings.approvalMs = elapsed(approvalStartedAt);
65
259
  if (approvalEvidence.approval || approvalEvidence.approvalArtifact) {
66
- policyDecision = approveStepUp(policyDecision);
260
+ policyDecision = approveStepUp(policyDecision, approvalEvidence);
67
261
  }
68
262
  }
69
263
  catch (error) {
@@ -72,7 +266,7 @@ export class AxtaryProxyRuntime {
72
266
  const ledgerStartedAt = performance.now();
73
267
  const ledger = await this.append(action, decision, null);
74
268
  timings.ledgerMs = elapsed(ledgerStartedAt);
75
- timings.totalMs = elapsed(startedAt);
269
+ completeDecisionTiming(timings, startedAt);
76
270
  return {
77
271
  schemaVersion: PROXY_SCHEMA_VERSION,
78
272
  status: "blocked",
@@ -88,7 +282,7 @@ export class AxtaryProxyRuntime {
88
282
  const ledgerStartedAt = performance.now();
89
283
  const ledger = await this.append(action, policyDecision, null);
90
284
  timings.ledgerMs = elapsed(ledgerStartedAt);
91
- timings.totalMs = elapsed(startedAt);
285
+ completeDecisionTiming(timings, startedAt);
92
286
  return {
93
287
  schemaVersion: PROXY_SCHEMA_VERSION,
94
288
  status: "blocked",
@@ -99,13 +293,14 @@ export class AxtaryProxyRuntime {
99
293
  timings,
100
294
  };
101
295
  }
296
+ this.consumeRateLimit(action, policyDecision);
102
297
  const handler = this.config.handlers?.[action.capability.tool];
103
298
  if (!handler) {
104
299
  const decision = proxyDeny([`proxy_handler_missing:${action.capability.tool}`], policyDecision);
105
300
  const ledgerStartedAt = performance.now();
106
301
  const ledger = await this.append(action, decision, null);
107
302
  timings.ledgerMs = elapsed(ledgerStartedAt);
108
- timings.totalMs = elapsed(startedAt);
303
+ completeDecisionTiming(timings, startedAt);
109
304
  return {
110
305
  schemaVersion: PROXY_SCHEMA_VERSION,
111
306
  status: "blocked",
@@ -121,7 +316,26 @@ export class AxtaryProxyRuntime {
121
316
  const ledgerStartedAt = performance.now();
122
317
  const ledger = await this.append(action, decision, null);
123
318
  timings.ledgerMs = elapsed(ledgerStartedAt);
124
- timings.totalMs = elapsed(startedAt);
319
+ completeDecisionTiming(timings, startedAt);
320
+ return {
321
+ schemaVersion: PROXY_SCHEMA_VERSION,
322
+ status: "blocked",
323
+ action,
324
+ decision,
325
+ actionPass: null,
326
+ ledger,
327
+ timings,
328
+ };
329
+ }
330
+ const budgetStartedAt = performance.now();
331
+ const budgetReservation = await this.reserveBudget(action, policyDecision.constraints.expiresInSeconds);
332
+ timings.budgetMs = elapsed(budgetStartedAt);
333
+ if (budgetReservation && !budgetReservation.accepted) {
334
+ const decision = proxyDeny(["budget_exceeded"], policyDecision);
335
+ const ledgerStartedAt = performance.now();
336
+ const ledger = await this.append(action, decision, null, budgetReservation.event);
337
+ timings.ledgerMs = elapsed(ledgerStartedAt);
338
+ completeDecisionTiming(timings, startedAt);
125
339
  return {
126
340
  schemaVersion: PROXY_SCHEMA_VERSION,
127
341
  status: "blocked",
@@ -132,11 +346,14 @@ export class AxtaryProxyRuntime {
132
346
  timings,
133
347
  };
134
348
  }
349
+ if (budgetReservation?.accepted) {
350
+ action = actionWithBudget(action, budgetReservation.reservation);
351
+ }
135
352
  let actionPass = null;
136
353
  if (this.config.signingKey) {
354
+ const actionPassStartedAt = performance.now();
137
355
  try {
138
- const actionPassStartedAt = performance.now();
139
- actionPass = await issueActionPass({
356
+ const issueInput = {
140
357
  action,
141
358
  decision: policyDecision,
142
359
  issuer: this.config.issuer,
@@ -147,15 +364,42 @@ export class AxtaryProxyRuntime {
147
364
  now: this.now(),
148
365
  approval: approvalEvidence.approval,
149
366
  approvalArtifact: approvalEvidence.approvalArtifact,
150
- });
367
+ };
368
+ if (this.config.holderPublicJwk && this.config.statusReference) {
369
+ const passId = `ap_${randomUUID()}`;
370
+ actionPass = await issueActionPassV2({
371
+ ...issueInput,
372
+ passId,
373
+ holderPublicJwk: this.config.holderPublicJwk,
374
+ status: {
375
+ status_list: await this.config.statusReference(passId),
376
+ },
377
+ });
378
+ }
379
+ else if (this.config.holderPublicJwk) {
380
+ actionPass = await issueActionPassV1({
381
+ ...issueInput,
382
+ holderPublicJwk: this.config.holderPublicJwk,
383
+ });
384
+ }
385
+ else {
386
+ actionPass = await issueActionPass(issueInput);
387
+ }
151
388
  timings.actionPassMs = elapsed(actionPassStartedAt);
152
389
  }
153
- catch {
154
- const decision = proxyDeny(["proxy_actionpass_issue_failed"], policyDecision);
390
+ catch (error) {
391
+ timings.actionPassMs = elapsed(actionPassStartedAt);
392
+ let budgetEvent;
393
+ if (budgetReservation?.accepted) {
394
+ const rollbackStartedAt = performance.now();
395
+ budgetEvent = await this.budgetMeter.rollback(budgetReservation.reservation.reservationId);
396
+ timings.budgetMs += elapsed(rollbackStartedAt);
397
+ }
398
+ const decision = proxyDeny([`proxy_actionpass_issue_failed:${errorToReason(error)}`], policyDecision);
155
399
  const ledgerStartedAt = performance.now();
156
- const ledger = await this.append(action, decision, null);
400
+ const ledger = await this.append(action, decision, null, budgetEvent);
157
401
  timings.ledgerMs = elapsed(ledgerStartedAt);
158
- timings.totalMs = elapsed(startedAt);
402
+ completeDecisionTiming(timings, startedAt);
159
403
  return {
160
404
  schemaVersion: PROXY_SCHEMA_VERSION,
161
405
  status: "blocked",
@@ -168,16 +412,50 @@ export class AxtaryProxyRuntime {
168
412
  }
169
413
  }
170
414
  const ledgerStartedAt = performance.now();
171
- const ledger = await this.append(action, policyDecision, actionPass?.claims.jti ?? null);
415
+ let ledger;
416
+ try {
417
+ ledger = await this.append(action, policyDecision, actionPass?.claims.jti ?? null, budgetReservation?.accepted ? budgetReservation.event : undefined);
418
+ }
419
+ catch (error) {
420
+ if (budgetReservation?.accepted) {
421
+ await this.budgetMeter.rollback(budgetReservation.reservation.reservationId);
422
+ }
423
+ throw error;
424
+ }
172
425
  timings.ledgerMs = elapsed(ledgerStartedAt);
426
+ timings.decisionMs = elapsed(startedAt);
173
427
  const handlerStartedAt = performance.now();
428
+ let budgetSettled = false;
429
+ let settledBudgetEvent;
174
430
  try {
431
+ const dpopProof = actionPass?.claims.apv === "axtary.actionpass.v1" ||
432
+ actionPass?.claims.apv === "axtary.actionpass.v2"
433
+ ? await this.createProof(action, actionPass)
434
+ : null;
175
435
  const result = await withTimeout(handler({
176
436
  action,
177
437
  decision: policyDecision,
178
438
  actionPass,
439
+ dpopProof,
179
440
  ledgerRecord: ledger.record,
180
441
  }), this.config.handlerTimeoutMs ?? DEFAULT_HANDLER_TIMEOUT_MS);
442
+ const budgetEvent = budgetReservation?.accepted
443
+ ? await this.budgetMeter.commit(budgetReservation.reservation.reservationId)
444
+ : undefined;
445
+ budgetSettled = budgetReservation?.accepted ?? false;
446
+ settledBudgetEvent = budgetEvent;
447
+ const executionLedger = await this.appendExecutionOutcome({
448
+ action,
449
+ decision: policyDecision,
450
+ actionPassId: actionPass?.claims.jti ?? null,
451
+ correlationId: ledger.record.id,
452
+ traceId: ledger.record.traceId,
453
+ outcome: outcomeFromResult(action, result),
454
+ budget: budgetEvent,
455
+ });
456
+ // The action actually executed (allowed, or approved step-up), so it is
457
+ // now "known" — record it for future novelty/sequence evaluation.
458
+ await this.observeBehavior(action);
181
459
  timings.handlerMs = elapsed(handlerStartedAt);
182
460
  timings.totalMs = elapsed(startedAt);
183
461
  return {
@@ -187,11 +465,25 @@ export class AxtaryProxyRuntime {
187
465
  decision: policyDecision,
188
466
  actionPass,
189
467
  ledger,
468
+ executionLedger,
190
469
  result,
191
470
  timings,
192
471
  };
193
472
  }
194
473
  catch (error) {
474
+ const reason = errorToReason(error);
475
+ const budgetEvent = budgetReservation?.accepted && !budgetSettled
476
+ ? await this.budgetMeter.rollback(budgetReservation.reservation.reservationId)
477
+ : settledBudgetEvent;
478
+ const executionLedger = await this.appendExecutionOutcome({
479
+ action,
480
+ decision: policyDecision,
481
+ actionPassId: actionPass?.claims.jti ?? null,
482
+ correlationId: ledger.record.id,
483
+ traceId: ledger.record.traceId,
484
+ outcome: outcomeFromError(action, reason),
485
+ budget: budgetEvent,
486
+ });
195
487
  timings.handlerMs = elapsed(handlerStartedAt);
196
488
  timings.totalMs = elapsed(startedAt);
197
489
  return {
@@ -201,24 +493,250 @@ export class AxtaryProxyRuntime {
201
493
  decision: policyDecision,
202
494
  actionPass,
203
495
  ledger,
496
+ executionLedger,
204
497
  error: {
205
- reason: errorToReason(error),
498
+ reason,
206
499
  },
207
500
  timings,
208
501
  };
209
502
  }
210
503
  }
211
- append(action, decision, actionPassId) {
504
+ async authorize(actionInput) {
505
+ const startedAt = performance.now();
506
+ const timings = createTimings();
507
+ let action;
508
+ try {
509
+ const parseStartedAt = performance.now();
510
+ action = parseNormalizedAction(actionInput);
511
+ if (action.budget) {
512
+ throw new Error("budget_authority_fields_forbidden");
513
+ }
514
+ if (action.provenance) {
515
+ throw new Error("provenance_authority_fields_forbidden");
516
+ }
517
+ if (this.config.provenanceResolver) {
518
+ action = actionWithProvenance(action, await this.config.provenanceResolver(action));
519
+ }
520
+ timings.parseMs = elapsed(parseStartedAt);
521
+ }
522
+ catch (error) {
523
+ return {
524
+ schemaVersion: PROXY_SCHEMA_VERSION,
525
+ status: "malformed",
526
+ error: {
527
+ reason: errorToReason(error),
528
+ },
529
+ };
530
+ }
531
+ const policyStartedAt = performance.now();
532
+ let policyDecision;
533
+ try {
534
+ const resolvedPolicy = typeof this.config.policy === "function"
535
+ ? await this.config.policy()
536
+ : this.config.policy;
537
+ const policy = PolicyConfigSchema.parse(resolvedPolicy ?? {});
538
+ const behaviorSignals = await this.behaviorSignals(action);
539
+ policyDecision = evaluatePolicy(action, policy, this.policyEvaluationContext(action, policy, behaviorSignals));
540
+ timings.policyMs = elapsed(policyStartedAt);
541
+ }
542
+ catch (error) {
543
+ timings.policyMs = elapsed(policyStartedAt);
544
+ policyDecision = proxyDeny([
545
+ `proxy_policy_resolution_failed:${errorToReason(error)}`,
546
+ ]);
547
+ }
548
+ if (policyDecision.decision === "allow" &&
549
+ !this.config.signingKey &&
550
+ this.config.allowUnsignedExecution !== true) {
551
+ policyDecision = proxyDeny(["proxy_signing_key_required"], policyDecision);
552
+ }
553
+ const budgetStartedAt = performance.now();
554
+ const budgetReservation = policyDecision.decision === "allow"
555
+ ? await this.reserveBudget(action, policyDecision.constraints.expiresInSeconds)
556
+ : null;
557
+ timings.budgetMs = elapsed(budgetStartedAt);
558
+ if (budgetReservation && !budgetReservation.accepted) {
559
+ policyDecision = proxyDeny(["budget_exceeded"], policyDecision);
560
+ }
561
+ if (budgetReservation?.accepted) {
562
+ action = actionWithBudget(action, budgetReservation.reservation);
563
+ }
564
+ let actionPass = null;
565
+ let budgetEvent = budgetReservation?.event;
566
+ if (policyDecision.decision === "allow" && this.config.signingKey) {
567
+ const actionPassStartedAt = performance.now();
568
+ try {
569
+ const issueInput = {
570
+ action,
571
+ decision: policyDecision,
572
+ issuer: this.config.issuer,
573
+ tenant: this.config.tenant,
574
+ signingKey: this.config.signingKey,
575
+ keyId: this.config.keyId,
576
+ algorithm: this.config.algorithm,
577
+ now: this.now(),
578
+ };
579
+ if (this.config.holderPublicJwk && this.config.statusReference) {
580
+ const passId = `ap_${randomUUID()}`;
581
+ actionPass = await issueActionPassV2({
582
+ ...issueInput,
583
+ passId,
584
+ holderPublicJwk: this.config.holderPublicJwk,
585
+ status: {
586
+ status_list: await this.config.statusReference(passId),
587
+ },
588
+ });
589
+ }
590
+ else if (this.config.holderPublicJwk) {
591
+ actionPass = await issueActionPassV1({
592
+ ...issueInput,
593
+ holderPublicJwk: this.config.holderPublicJwk,
594
+ });
595
+ }
596
+ else {
597
+ actionPass = await issueActionPass(issueInput);
598
+ }
599
+ timings.actionPassMs = elapsed(actionPassStartedAt);
600
+ }
601
+ catch (error) {
602
+ timings.actionPassMs = elapsed(actionPassStartedAt);
603
+ if (budgetReservation?.accepted) {
604
+ const rollbackStartedAt = performance.now();
605
+ budgetEvent = await this.budgetMeter.rollback(budgetReservation.reservation.reservationId);
606
+ timings.budgetMs += elapsed(rollbackStartedAt);
607
+ }
608
+ policyDecision = proxyDeny([`proxy_actionpass_issue_failed:${errorToReason(error)}`], policyDecision);
609
+ }
610
+ }
611
+ if (policyDecision.decision === "allow") {
612
+ this.consumeRateLimit(action, policyDecision);
613
+ if (budgetReservation?.accepted) {
614
+ const commitStartedAt = performance.now();
615
+ budgetEvent = await this.budgetMeter.commit(budgetReservation.reservation.reservationId);
616
+ timings.budgetMs += elapsed(commitStartedAt);
617
+ }
618
+ }
619
+ const ledgerStartedAt = performance.now();
620
+ const ledger = await this.append(action, policyDecision, actionPass?.claims.jti ?? null, budgetEvent);
621
+ timings.ledgerMs = elapsed(ledgerStartedAt);
622
+ completeDecisionTiming(timings, startedAt);
623
+ const status = policyDecision.decision === "allow"
624
+ ? "authorized"
625
+ : policyDecision.decision === "step_up"
626
+ ? "step_up"
627
+ : "blocked";
628
+ return {
629
+ schemaVersion: PROXY_SCHEMA_VERSION,
630
+ status,
631
+ action,
632
+ decision: policyDecision,
633
+ actionPass,
634
+ ledger,
635
+ timings,
636
+ };
637
+ }
638
+ append(action, decision, actionPassId, budget) {
212
639
  return this.config.ledger.appendDecision({
213
640
  action,
214
641
  decision,
215
642
  actionPassId,
643
+ budget,
216
644
  occurredAt: this.now(),
217
645
  });
218
646
  }
647
+ appendExecutionOutcome(input) {
648
+ return this.config.ledger.appendExecutionOutcome({
649
+ action: input.action,
650
+ decision: input.decision,
651
+ actionPassId: input.actionPassId,
652
+ occurredAt: this.now(),
653
+ traceId: input.traceId,
654
+ executionOutcome: input.outcome,
655
+ budget: input.budget,
656
+ correlationId: input.correlationId,
657
+ });
658
+ }
219
659
  now() {
220
660
  return this.config.now?.() ?? new Date();
221
661
  }
662
+ async reserveBudget(action, expiresInSeconds) {
663
+ const budget = this.budgetConfig;
664
+ if (!budget.enabled) {
665
+ return null;
666
+ }
667
+ const cost = addBudgetAmounts(budget.costs["*"], budget.costs[action.capability.tool] ?? {});
668
+ const scopeValue = budget.scope === "actor"
669
+ ? action.actor.agentId
670
+ : budget.scope === "task"
671
+ ? action.intent.taskId
672
+ : this.config.tenant;
673
+ const now = this.now();
674
+ return this.budgetMeter.reserve({
675
+ scope: `${budget.scope}:${scopeValue}`,
676
+ cost,
677
+ limit: budget.limits,
678
+ now,
679
+ expiresAt: new Date(now.getTime() + expiresInSeconds * 1000),
680
+ });
681
+ }
682
+ async createProof(action, actionPass) {
683
+ if (!this.config.holderSigningKey || !this.config.holderPublicJwk) {
684
+ throw new Error("proxy_dpop_holder_key_required");
685
+ }
686
+ const target = actionPassDpopTarget(action);
687
+ return (await createDpopProof({
688
+ actionPass: actionPass.token,
689
+ method: target.method,
690
+ uri: target.uri,
691
+ signingKey: this.config.holderSigningKey,
692
+ publicJwk: this.config.holderPublicJwk,
693
+ algorithm: this.config.holderAlgorithm,
694
+ nonce: this.config.dpopNonce,
695
+ now: this.now(),
696
+ })).proof;
697
+ }
698
+ policyEvaluationContext(action, policy, behaviorSignals) {
699
+ const now = this.now();
700
+ const rateLimitCounts = Object.fromEntries(policy.rules.flatMap((rule) => {
701
+ const limit = rule.obligations.rateLimit;
702
+ if (!limit) {
703
+ return [];
704
+ }
705
+ const key = rateLimitKey(rule.id, limit.scope, action);
706
+ const cutoff = now.getTime() - limit.windowSeconds * 1000;
707
+ const active = (this.rateLimitEvents.get(key) ?? []).filter((timestamp) => timestamp > cutoff);
708
+ this.rateLimitEvents.set(key, active);
709
+ return [[rule.id, active.length]];
710
+ }));
711
+ return { now, rateLimitCounts, behaviorSignals };
712
+ }
713
+ /**
714
+ * Read novelty/sequence signals from the durable store for this action. Pure
715
+ * read — no mutation; the action only becomes "known" after it executes (see
716
+ * `observeBehavior`). Returns undefined when no store is configured, leaving
717
+ * the behavior guard inert.
718
+ */
719
+ async behaviorSignals(action) {
720
+ if (!this.config.behaviorStore)
721
+ return undefined;
722
+ return this.config.behaviorStore.evaluate(action);
723
+ }
724
+ /** Record an executed action so its combo/transition becomes known. */
725
+ async observeBehavior(action) {
726
+ if (!this.config.behaviorStore)
727
+ return;
728
+ await this.config.behaviorStore.observe(action);
729
+ }
730
+ consumeRateLimit(action, decision) {
731
+ const limit = decision.obligations?.rateLimit;
732
+ if (!limit) {
733
+ return;
734
+ }
735
+ const key = rateLimitKey(limit.ruleId, limit.scope, action);
736
+ const events = this.rateLimitEvents.get(key) ?? [];
737
+ events.push(this.now().getTime());
738
+ this.rateLimitEvents.set(key, events);
739
+ }
222
740
  }
223
741
  export function createProxyRuntime(config) {
224
742
  return new AxtaryProxyRuntime(config);
@@ -238,6 +756,8 @@ function proxyDeny(reasons, previousDecision) {
238
756
  maxFilesChanged: previousDecision?.constraints.maxFilesChanged ?? 0,
239
757
  blockedPaths: previousDecision?.constraints.blockedPaths ?? [],
240
758
  },
759
+ rule: previousDecision?.rule,
760
+ obligations: previousDecision?.obligations,
241
761
  };
242
762
  }
243
763
  function normalizeApprovalResult(result) {
@@ -259,27 +779,275 @@ function normalizeApprovalResult(result) {
259
779
  approvalArtifact: result.approvalArtifact,
260
780
  };
261
781
  }
262
- function approveStepUp(decision) {
782
+ function approveStepUp(decision, evidence) {
783
+ const requiredRoles = decision.obligations?.requiredApproverRoles ?? [];
784
+ const actualRoles = evidence.approval?.approverRoles ??
785
+ evidence.approvalArtifact?.approverRoles ??
786
+ [];
787
+ const missingRole = requiredRoles.find((role) => !actualRoles.includes(role));
788
+ if (missingRole) {
789
+ return proxyDeny([`required_approver_role_missing:${missingRole}`], decision);
790
+ }
263
791
  return {
264
792
  ...decision,
265
793
  decision: "allow",
266
794
  reasons: [...decision.reasons, "step_up_approved"],
267
795
  };
268
796
  }
797
+ function rateLimitKey(ruleId, scope, action) {
798
+ const value = scope === "actor"
799
+ ? action.actor.agentId
800
+ : scope === "tool"
801
+ ? action.capability.tool
802
+ : scope === "resource"
803
+ ? action.capability.resource
804
+ : action.actor.tenant ?? "tenant:unknown";
805
+ return `${ruleId}:${scope}:${value}`;
806
+ }
807
+ function normalizeProxyBudgetConfig(input) {
808
+ const config = {
809
+ enabled: input?.enabled ?? false,
810
+ scope: input?.scope ?? "tenant",
811
+ limits: normalizeBudgetAmount(input?.limits ?? {}),
812
+ costs: Object.fromEntries(Object.entries(input?.costs ?? {}).map(([tool, cost]) => [
813
+ tool,
814
+ normalizeBudgetAmount(cost),
815
+ ])),
816
+ };
817
+ if (config.enabled && !config.costs["*"]) {
818
+ throw new Error("budget_wildcard_cost_required");
819
+ }
820
+ if (config.enabled) {
821
+ for (const cost of Object.values(config.costs)) {
822
+ for (const dimension of Object.keys(cost)) {
823
+ if (config.limits[dimension] === undefined) {
824
+ throw new Error(`budget_limit_missing:${dimension}`);
825
+ }
826
+ }
827
+ }
828
+ }
829
+ return config;
830
+ }
831
+ function normalizeBudgetAmount(input) {
832
+ const amount = BudgetAmountSchema.parse(input);
833
+ return Object.fromEntries(Object.entries(amount).sort(([left], [right]) => left.localeCompare(right)));
834
+ }
835
+ function addBudgetAmounts(left, right) {
836
+ const dimensions = new Set([...Object.keys(left), ...Object.keys(right)]);
837
+ return Object.fromEntries([...dimensions]
838
+ .sort((a, b) => a.localeCompare(b))
839
+ .map((dimension) => [
840
+ dimension,
841
+ (left[dimension] ?? 0) + (right[dimension] ?? 0),
842
+ ])
843
+ .filter(([, value]) => value !== 0));
844
+ }
845
+ function actionWithBudget(action, reservation) {
846
+ const budget = {
847
+ reservationId: reservation.reservationId,
848
+ cost: reservation.cost,
849
+ limit: reservation.limit,
850
+ commitStatus: "pending",
851
+ };
852
+ return parseNormalizedAction({
853
+ ...action,
854
+ budget,
855
+ });
856
+ }
857
+ function actionWithProvenance(action, provenanceInput) {
858
+ return parseNormalizedAction({
859
+ ...action,
860
+ provenance: ActionProvenanceSchema.parse(provenanceInput),
861
+ });
862
+ }
863
+ function outcomeFromResult(action, result) {
864
+ const resultObject = isJsonObject(result) ? result : {};
865
+ const provider = providerFromValue(resultObject.provider) ?? providerFromTool(action);
866
+ const resultId = getString(resultObject.id) ??
867
+ getString(resultObject.commitSha) ??
868
+ getString(resultObject.sha) ??
869
+ (typeof resultObject.number === "number" ? String(resultObject.number) : undefined);
870
+ const resultUrl = getUrlString(resultObject.url);
871
+ const provenance = actionProvenanceProof(action);
872
+ const resourceId = getString(resultObject.path) ??
873
+ getString(resultObject.issueKey) ??
874
+ getString(resultObject.channel) ??
875
+ getString(resultObject.bucket) ??
876
+ getString(resultObject.projectId) ??
877
+ getString(resultObject.fileId) ??
878
+ getString(resultObject.branch) ??
879
+ getString(provenance.toolName) ??
880
+ getString(resultObject.workspace);
881
+ return {
882
+ schemaVersion: "axtary.ledger_execution_outcome.v0",
883
+ status: "succeeded",
884
+ provider,
885
+ resultId,
886
+ resultUrl,
887
+ resourceId,
888
+ resourceLabel: getString(resultObject.title) ?? resourceId,
889
+ sideEffectProof: compactJson({
890
+ ...provenance,
891
+ provider,
892
+ kind: getString(resultObject.kind),
893
+ id: resultId,
894
+ url: resultUrl,
895
+ status: getString(resultObject.status) ?? getString(resultObject.state),
896
+ resourceId,
897
+ actionPassId: getString(resultObject.actionPassId),
898
+ number: typeof resultObject.number === "number" ? resultObject.number : undefined,
899
+ branch: getString(resultObject.branch),
900
+ commitSha: getString(resultObject.commitSha),
901
+ channel: getString(resultObject.channel),
902
+ ts: getString(resultObject.ts),
903
+ issueKey: getString(resultObject.issueKey),
904
+ path: getString(resultObject.path),
905
+ acceptedPermissions: Array.isArray(resultObject.acceptedPermissions)
906
+ ? resultObject.acceptedPermissions
907
+ : undefined,
908
+ rateLimit: isJsonObject(resultObject.rateLimit)
909
+ ? resultObject.rateLimit
910
+ : undefined,
911
+ workspace: getString(resultObject.workspace),
912
+ fileId: getString(resultObject.fileId),
913
+ mimeType: getString(resultObject.mimeType),
914
+ scope: getString(resultObject.scope),
915
+ appAuthorized: typeof resultObject.appAuthorized === "boolean"
916
+ ? resultObject.appAuthorized
917
+ : undefined,
918
+ database: getString(resultObject.database),
919
+ role: getString(resultObject.role),
920
+ transactionReadOnly: typeof resultObject.transactionReadOnly === "boolean"
921
+ ? resultObject.transactionReadOnly
922
+ : undefined,
923
+ roleLeastPrivilege: typeof resultObject.roleLeastPrivilege === "boolean"
924
+ ? resultObject.roleLeastPrivilege
925
+ : undefined,
926
+ statementHash: getString(resultObject.statementHash),
927
+ predicateHash: getString(resultObject.predicateHash),
928
+ tables: Array.isArray(resultObject.tables)
929
+ ? resultObject.tables
930
+ : undefined,
931
+ rowCount: typeof resultObject.rowCount === "number"
932
+ ? resultObject.rowCount
933
+ : undefined,
934
+ fields: Array.isArray(resultObject.fields)
935
+ ? resultObject.fields
936
+ : undefined,
937
+ rls: Array.isArray(resultObject.rls) ? resultObject.rls : undefined,
938
+ root: getString(resultObject.root),
939
+ bytes: typeof resultObject.bytes === "number" ? resultObject.bytes : undefined,
940
+ truncated: typeof resultObject.truncated === "boolean"
941
+ ? resultObject.truncated
942
+ : undefined,
943
+ resultCount: Array.isArray(resultObject.results)
944
+ ? resultObject.results.length
945
+ : undefined,
946
+ objectCount: Array.isArray(resultObject.objects)
947
+ ? resultObject.objects.length
948
+ : undefined,
949
+ }),
950
+ };
951
+ }
952
+ function outcomeFromError(action, reason) {
953
+ return {
954
+ schemaVersion: "axtary.ledger_execution_outcome.v0",
955
+ status: "failed",
956
+ provider: providerFromTool(action),
957
+ failureReason: reason,
958
+ errorClass: reason.split(":", 1)[0] || "handler_failed",
959
+ sideEffectProof: actionProvenanceProof(action),
960
+ };
961
+ }
962
+ function actionProvenanceProof(action) {
963
+ const payload = action.capability.payload;
964
+ if (action.capability.tool === "mcp.tool.call") {
965
+ return compactJson({
966
+ serverIdentity: action.toolDefinition?.serverIdentity ?? getString(payload.serverIdentity),
967
+ toolName: getString(payload.toolName),
968
+ toolSchemaVersion: action.toolDefinition?.schemaVersion,
969
+ definitionHash: action.toolDefinition?.definitionHash,
970
+ });
971
+ }
972
+ if (action.capability.tool.startsWith("docs.")) {
973
+ return compactJson({
974
+ workspace: getString(payload.workspace),
975
+ query: getString(payload.query),
976
+ path: getString(payload.path),
977
+ });
978
+ }
979
+ if (action.capability.tool === "drive.files.read") {
980
+ return compactJson({
981
+ fileId: getString(payload.fileId),
982
+ scope: "https://www.googleapis.com/auth/drive.file",
983
+ });
984
+ }
985
+ return {};
986
+ }
987
+ function providerFromTool(action) {
988
+ const prefix = action.capability.tool.split(".", 1)[0];
989
+ return providerFromValue(prefix);
990
+ }
991
+ function providerFromValue(value) {
992
+ const provider = typeof value === "string" && value.startsWith("fake-")
993
+ ? value.slice("fake-".length)
994
+ : value;
995
+ switch (provider) {
996
+ case "github":
997
+ case "slack":
998
+ case "linear":
999
+ case "docs":
1000
+ case "drive":
1001
+ case "mcp":
1002
+ case "aws":
1003
+ case "gcp":
1004
+ case "jira":
1005
+ case "postgres":
1006
+ return provider;
1007
+ default:
1008
+ return undefined;
1009
+ }
1010
+ }
1011
+ function compactJson(input) {
1012
+ return Object.fromEntries(Object.entries(input).filter((entry) => entry[1] !== undefined));
1013
+ }
1014
+ function getString(value) {
1015
+ return typeof value === "string" && value.length > 0 ? value : undefined;
1016
+ }
1017
+ function getUrlString(value) {
1018
+ const url = getString(value);
1019
+ if (!url)
1020
+ return undefined;
1021
+ try {
1022
+ return new URL(url).toString();
1023
+ }
1024
+ catch {
1025
+ return undefined;
1026
+ }
1027
+ }
1028
+ function isJsonObject(value) {
1029
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1030
+ }
269
1031
  function errorToReason(error) {
270
1032
  return error instanceof Error ? error.message : "unknown_error";
271
1033
  }
272
1034
  function createTimings() {
273
1035
  return {
274
1036
  totalMs: 0,
1037
+ decisionMs: 0,
275
1038
  parseMs: 0,
276
1039
  policyMs: 0,
1040
+ budgetMs: 0,
277
1041
  actionPassMs: 0,
278
1042
  ledgerMs: 0,
279
1043
  approvalMs: 0,
280
1044
  handlerMs: 0,
281
1045
  };
282
1046
  }
1047
+ function completeDecisionTiming(timings, startedAt) {
1048
+ timings.decisionMs = elapsed(startedAt);
1049
+ timings.totalMs = timings.decisionMs;
1050
+ }
283
1051
  function elapsed(startedAt) {
284
1052
  return Math.round((performance.now() - startedAt) * 1000) / 1000;
285
1053
  }