usertrust 0.2.6 → 1.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.
Files changed (84) hide show
  1. package/dist/cli/init.d.ts +2 -0
  2. package/dist/cli/init.d.ts.map +1 -1
  3. package/dist/cli/init.js +231 -37
  4. package/dist/cli/init.js.map +1 -1
  5. package/dist/cli/main.d.ts +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/main.js +18 -3
  8. package/dist/cli/main.js.map +1 -1
  9. package/dist/cli/pricing.d.ts +5 -0
  10. package/dist/cli/pricing.d.ts.map +1 -0
  11. package/dist/cli/pricing.js +61 -0
  12. package/dist/cli/pricing.js.map +1 -0
  13. package/dist/cli/validate-key.d.ts +17 -0
  14. package/dist/cli/validate-key.d.ts.map +1 -0
  15. package/dist/cli/validate-key.js +58 -0
  16. package/dist/cli/validate-key.js.map +1 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +31 -0
  19. package/dist/config.js.map +1 -1
  20. package/dist/govern.d.ts +3 -1
  21. package/dist/govern.d.ts.map +1 -1
  22. package/dist/govern.js +495 -39
  23. package/dist/govern.js.map +1 -1
  24. package/dist/headless.d.ts +77 -0
  25. package/dist/headless.d.ts.map +1 -0
  26. package/dist/headless.js +566 -0
  27. package/dist/headless.js.map +1 -0
  28. package/dist/index.d.ts +12 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +13 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/ledger/pricing.d.ts +6 -2
  33. package/dist/ledger/pricing.d.ts.map +1 -1
  34. package/dist/ledger/pricing.js +31 -3
  35. package/dist/ledger/pricing.js.map +1 -1
  36. package/dist/policy/canary.d.ts +17 -0
  37. package/dist/policy/canary.d.ts.map +1 -0
  38. package/dist/policy/canary.js +44 -0
  39. package/dist/policy/canary.js.map +1 -0
  40. package/dist/policy/injection.d.ts +24 -0
  41. package/dist/policy/injection.d.ts.map +1 -0
  42. package/dist/policy/injection.js +178 -0
  43. package/dist/policy/injection.js.map +1 -0
  44. package/dist/policy/pii.d.ts +13 -1
  45. package/dist/policy/pii.d.ts.map +1 -1
  46. package/dist/policy/pii.js +88 -0
  47. package/dist/policy/pii.js.map +1 -1
  48. package/dist/shared/errors.d.ts +20 -0
  49. package/dist/shared/errors.d.ts.map +1 -1
  50. package/dist/shared/errors.js +46 -0
  51. package/dist/shared/errors.js.map +1 -1
  52. package/dist/shared/types.d.ts +268 -0
  53. package/dist/shared/types.d.ts.map +1 -1
  54. package/dist/shared/types.js +67 -0
  55. package/dist/shared/types.js.map +1 -1
  56. package/dist/streaming.d.ts +7 -2
  57. package/dist/streaming.d.ts.map +1 -1
  58. package/dist/streaming.js +15 -8
  59. package/dist/streaming.js.map +1 -1
  60. package/dist/supply-chain/manifest.d.ts +22 -0
  61. package/dist/supply-chain/manifest.d.ts.map +1 -0
  62. package/dist/supply-chain/manifest.js +46 -0
  63. package/dist/supply-chain/manifest.js.map +1 -0
  64. package/dist/supply-chain/permissions.d.ts +12 -0
  65. package/dist/supply-chain/permissions.d.ts.map +1 -0
  66. package/dist/supply-chain/permissions.js +89 -0
  67. package/dist/supply-chain/permissions.js.map +1 -0
  68. package/dist/supply-chain/sign.d.ts +17 -0
  69. package/dist/supply-chain/sign.d.ts.map +1 -0
  70. package/dist/supply-chain/sign.js +95 -0
  71. package/dist/supply-chain/sign.js.map +1 -0
  72. package/dist/vault/derive.d.ts +13 -0
  73. package/dist/vault/derive.d.ts.map +1 -0
  74. package/dist/vault/derive.js +32 -0
  75. package/dist/vault/derive.js.map +1 -0
  76. package/dist/vault/scope.d.ts +20 -0
  77. package/dist/vault/scope.d.ts.map +1 -0
  78. package/dist/vault/scope.js +35 -0
  79. package/dist/vault/scope.js.map +1 -0
  80. package/dist/vault/store.d.ts +31 -0
  81. package/dist/vault/store.d.ts.map +1 -0
  82. package/dist/vault/store.js +204 -0
  83. package/dist/vault/store.js.map +1 -0
  84. package/package.json +15 -4
package/dist/govern.js CHANGED
@@ -36,8 +36,10 @@ import { detectClientKind } from "./detect.js";
36
36
  import { TrustTBClient, XFER_SPEND } from "./ledger/client.js";
37
37
  import { estimateCost, estimateInputTokens } from "./ledger/pricing.js";
38
38
  import { recordPattern } from "./memory/patterns.js";
39
+ import { DEFAULT_RULES } from "./policy/default-rules.js";
39
40
  import { evaluatePolicy, loadPolicies } from "./policy/gate.js";
40
- import { detectPII } from "./policy/pii.js";
41
+ import { detectInjection } from "./policy/injection.js";
42
+ import { detectPII, redactPII } from "./policy/pii.js";
41
43
  import { connectProxy } from "./proxy.js";
42
44
  import { CircuitBreakerRegistry } from "./resilience/circuit.js";
43
45
  import { DEFAULT_BUDGET, VAULT_DIR } from "./shared/constants.js";
@@ -72,7 +74,10 @@ async function loadSpendLedger(vaultBase) {
72
74
  typeof parsed === "object" &&
73
75
  "budgetSpent" in parsed &&
74
76
  typeof parsed.budgetSpent === "number") {
75
- return parsed.budgetSpent;
77
+ const value = parsed.budgetSpent;
78
+ if (Number.isFinite(value) && value >= 0) {
79
+ return value;
80
+ }
76
81
  }
77
82
  }
78
83
  catch {
@@ -119,6 +124,7 @@ export async function trust(client, opts) {
119
124
  budget: opts?.budget ?? DEFAULT_BUDGET,
120
125
  });
121
126
  }
127
+ const customRates = config.pricing === "custom" ? config.customRates : undefined;
122
128
  const isDryRun = opts?.dryRun ?? process.env.USERTRUST_DRY_RUN === "true";
123
129
  // AUD-470: Only accept injected _engine/_audit in test environments.
124
130
  // In production, silently ignore them to prevent governance bypass.
@@ -127,7 +133,8 @@ export async function trust(client, opts) {
127
133
  const vaultPath = vaultBase;
128
134
  const audit = (isTestEnv ? opts?._audit : undefined) ?? createAuditWriter(vaultPath);
129
135
  const policiesPath = join(vaultPath, VAULT_DIR, config.policies);
130
- const policyRules = existsSync(policiesPath) ? loadPolicies(policiesPath) : [];
136
+ const loadedRules = existsSync(policiesPath) ? loadPolicies(policiesPath) : [];
137
+ const policyRules = loadedRules.length > 0 ? loadedRules : DEFAULT_RULES;
131
138
  const breaker = new CircuitBreakerRegistry({
132
139
  failureThreshold: config.circuitBreaker.failureThreshold,
133
140
  resetTimeoutMs: config.circuitBreaker.resetTimeout,
@@ -161,6 +168,8 @@ export async function trust(client, opts) {
161
168
  let budgetSpent = await loadSpendLedger(vaultBase); // AUD-457: restore from disk
162
169
  const budgetMutex = new AsyncMutex(); // AUD-453: serialise budget-check + hold
163
170
  let inFlightCount = 0; // AUD-462: track in-flight calls for graceful destroy
171
+ let inFlightStreamCount = 0; // AUD-462: track in-flight streams (consumed after interceptCall returns)
172
+ let inFlightHoldTotal = 0; // Track estimated cost of in-flight pending holds
164
173
  // 7. Two-phase intercept
165
174
  async function interceptCall(originalFn, thisArg, args) {
166
175
  if (destroyed) {
@@ -181,7 +190,7 @@ export async function trust(client, opts) {
181
190
  const transferId = trustId("tx");
182
191
  const estimatedInputTokens = estimateInputTokens(messages);
183
192
  const maxOutputTokens = params.max_tokens ?? 4096;
184
- const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens);
193
+ const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens, customRates);
185
194
  // AUD-453: Acquire mutex to serialise budget-check + PENDING hold.
186
195
  // This prevents concurrent calls from both passing the budget check
187
196
  // and overshooting the budget.
@@ -194,7 +203,7 @@ export async function trust(client, opts) {
194
203
  model,
195
204
  tier: config.tier,
196
205
  estimated_cost: estimatedCost,
197
- budget_remaining: config.budget - budgetSpent,
206
+ budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
198
207
  ...params,
199
208
  });
200
209
  if (policyResult.decision === "deny") {
@@ -209,7 +218,28 @@ export async function trust(client, opts) {
209
218
  }
210
219
  // "warn" and "redact" modes: continue (redact is not implemented at SDK level)
211
220
  }
212
- // d2. Failure mode 15.4: TigerBeetle / engine unreachable — PENDING hold
221
+ // d2. Injection detection
222
+ if (config.injection !== "off") {
223
+ const injectionResult = detectInjection(messages);
224
+ if (injectionResult.detected) {
225
+ if (config.injection === "block") {
226
+ throw new PolicyDeniedError(`Prompt injection detected: ${injectionResult.patterns.join(", ")}`);
227
+ }
228
+ // warn: log to audit trail (non-fatal)
229
+ await audit
230
+ .appendEvent({
231
+ kind: "injection_detected",
232
+ actor: "local",
233
+ data: {
234
+ patterns: injectionResult.patterns,
235
+ score: injectionResult.score,
236
+ model,
237
+ },
238
+ })
239
+ .catch(() => { });
240
+ }
241
+ }
242
+ // e. Failure mode 15.4: TigerBeetle / engine unreachable — PENDING hold
213
243
  if (proxyConn != null && !isDryRun) {
214
244
  try {
215
245
  // AUD-460: Capture the proxy's returned transferId
@@ -236,6 +266,8 @@ export async function trust(client, opts) {
236
266
  throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
237
267
  }
238
268
  }
269
+ // Track in-flight hold cost for accurate budget calculations
270
+ inFlightHoldTotal += estimatedCost;
239
271
  }
240
272
  finally {
241
273
  // AUD-453: Release lock after budget check + hold are complete
@@ -251,9 +283,25 @@ export async function trust(client, opts) {
251
283
  typeof response === "object" &&
252
284
  Symbol.asyncIterator in response) {
253
285
  const stream = response;
254
- const governedStream = createGovernedStream(stream, kind, async (usage) => {
255
- const streamCost = estimateCost(model, usage.inputTokens, usage.outputTokens);
256
- budgetSpent += streamCost;
286
+ const governedStream = createGovernedStream(stream, kind, async (completion) => {
287
+ // Determine cost: use provider usage if reported, else fall back to estimate
288
+ let streamCost;
289
+ let usageSource;
290
+ if (completion.usageReported) {
291
+ streamCost = estimateCost(model, completion.usage.inputTokens, completion.usage.outputTokens, customRates);
292
+ usageSource = "provider";
293
+ }
294
+ else {
295
+ streamCost = estimatedCost;
296
+ usageSource = "estimated";
297
+ }
298
+ // Release in-flight hold and commit budget under mutex
299
+ {
300
+ const releaseLock = await budgetMutex.acquire();
301
+ inFlightHoldTotal -= estimatedCost;
302
+ budgetSpent += streamCost;
303
+ releaseLock();
304
+ }
257
305
  // AUD-457: Persist cumulative spend to disk
258
306
  await persistSpendLedger(vaultBase, budgetSpent);
259
307
  cb.recordSuccess();
@@ -262,48 +310,126 @@ export async function trust(client, opts) {
262
310
  // AUD-460: Use the proxy's transferId for settlement
263
311
  await proxyConn.settle(proxyTransferId ?? transferId, streamCost);
264
312
  }
265
- catch {
313
+ catch (postErr) {
266
314
  settled = false;
315
+ await audit
316
+ .appendEvent({
317
+ kind: "settlement_ambiguous",
318
+ actor: "local",
319
+ data: {
320
+ model,
321
+ cost: streamCost,
322
+ transferId,
323
+ error: postErr instanceof Error
324
+ ? postErr.message.slice(0, 200)
325
+ : String(postErr).slice(0, 200),
326
+ },
327
+ })
328
+ .catch(() => {
329
+ callAuditDegraded = true;
330
+ });
267
331
  }
268
332
  }
269
333
  else if (engine != null && !isDryRun) {
270
334
  try {
271
335
  await engine.postPendingSpend(transferId);
272
336
  }
273
- catch {
337
+ catch (postErr) {
274
338
  settled = false;
339
+ await audit
340
+ .appendEvent({
341
+ kind: "settlement_ambiguous",
342
+ actor: "local",
343
+ data: {
344
+ model,
345
+ cost: streamCost,
346
+ transferId,
347
+ error: postErr instanceof Error
348
+ ? postErr.message.slice(0, 200)
349
+ : String(postErr).slice(0, 200),
350
+ },
351
+ })
352
+ .catch(() => {
353
+ callAuditDegraded = true;
354
+ });
275
355
  }
276
356
  }
277
357
  const syntheticHash = createHash("sha256").update(transferId).digest("hex");
278
358
  let auditHash = syntheticHash;
279
359
  try {
360
+ const auditEventData = {
361
+ model,
362
+ cost: streamCost,
363
+ settled,
364
+ transferId,
365
+ usageSource,
366
+ chunksDelivered: completion.chunksDelivered,
367
+ };
368
+ if (config.pii === "warn" || config.pii === "redact") {
369
+ const piiResult = redactPII(messages);
370
+ if (piiResult.detection.found) {
371
+ auditEventData.piiDetected = piiResult.detection.types;
372
+ auditEventData.piiPaths = piiResult.detection.paths;
373
+ }
374
+ }
280
375
  const auditEvent = await audit.appendEvent({
281
376
  kind: "llm_call",
282
377
  actor: "local",
283
- data: { model, cost: streamCost, settled, transferId },
378
+ data: auditEventData,
284
379
  });
285
380
  auditHash = auditEvent.hash;
286
381
  }
287
382
  catch {
288
383
  callAuditDegraded = true;
289
384
  }
290
- return {
385
+ const streamReceipt = {
291
386
  transferId,
292
387
  cost: streamCost,
293
- budgetRemaining: config.budget - budgetSpent,
294
- auditHash,
388
+ budgetRemaining: config.budget - budgetSpent - inFlightHoldTotal,
389
+ auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : auditHash,
295
390
  chainPath: join(VAULT_DIR, "audit"),
296
391
  receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
297
392
  settled,
298
393
  model,
299
394
  provider: kind,
300
395
  timestamp: new Date().toISOString(),
396
+ usageSource,
397
+ chunksDelivered: completion.chunksDelivered,
301
398
  ...(callAuditDegraded ? { auditDegraded: true } : {}),
302
399
  // AUD-456: Flag proxy stub receipts
303
400
  ...(proxyConn != null ? { proxyStub: true } : {}),
304
401
  };
305
- }, (error) => {
402
+ inFlightStreamCount--;
403
+ return streamReceipt;
404
+ }, async (error, partial) => {
405
+ // Release in-flight hold under mutex
406
+ {
407
+ const releaseLock = await budgetMutex.acquire();
408
+ inFlightHoldTotal -= estimatedCost;
409
+ releaseLock();
410
+ }
306
411
  cb.recordFailure();
412
+ // Best-effort audit of partial delivery
413
+ audit
414
+ .appendEvent({
415
+ kind: "stream_partial_delivery",
416
+ actor: "local",
417
+ data: {
418
+ transferId,
419
+ model,
420
+ chunksDelivered: partial.chunksDelivered,
421
+ partialInputTokens: partial.usage.inputTokens,
422
+ partialOutputTokens: partial.usage.outputTokens,
423
+ usageReported: partial.usageReported,
424
+ error: (() => {
425
+ const raw = error instanceof Error ? error.message : String(error);
426
+ return config.pii === "warn" || config.pii === "redact"
427
+ ? redactPII(raw).data.slice(0, 200)
428
+ : raw.slice(0, 200);
429
+ })(),
430
+ },
431
+ })
432
+ .catch(() => { });
307
433
  if (proxyConn != null && !isDryRun) {
308
434
  // AUD-460: Use the proxy's transferId for void
309
435
  proxyConn.void(proxyTransferId ?? transferId).catch(() => { });
@@ -311,16 +437,17 @@ export async function trust(client, opts) {
311
437
  else if (engine != null && !isDryRun) {
312
438
  engine.voidPendingSpend(transferId).catch(() => { });
313
439
  }
440
+ inFlightStreamCount--;
314
441
  });
315
442
  // AUD-454: For streaming responses, settlement has NOT happened yet.
316
443
  // Set settled: false — the real settlement status will be on
317
444
  // governedStream.receipt after the stream is fully consumed.
318
- const auditHash = createHash("sha256").update(transferId).digest("hex");
445
+ const streamEstimateHash = createHash("sha256").update(transferId).digest("hex");
319
446
  const estimatedReceipt = {
320
447
  transferId,
321
448
  cost: estimatedCost,
322
- budgetRemaining: config.budget - budgetSpent,
323
- auditHash,
449
+ budgetRemaining: config.budget - budgetSpent - inFlightHoldTotal,
450
+ auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : streamEstimateHash,
324
451
  chainPath: join(VAULT_DIR, "audit"),
325
452
  receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
326
453
  settled: false, // AUD-454: not settled yet — stream hasn't been consumed
@@ -331,6 +458,7 @@ export async function trust(client, opts) {
331
458
  // AUD-456: Flag proxy stub receipts
332
459
  ...(proxyConn != null ? { proxyStub: true } : {}),
333
460
  };
461
+ inFlightStreamCount++;
334
462
  return { response: governedStream, receipt: estimatedReceipt };
335
463
  }
336
464
  // f. Compute actual cost from response usage
@@ -344,11 +472,16 @@ export async function trust(client, opts) {
344
472
  const outputTokens = usage.output_tokens ??
345
473
  usage.completion_tokens ??
346
474
  0;
347
- actualCost = estimateCost(model, inputTokens, outputTokens);
475
+ actualCost = estimateCost(model, inputTokens, outputTokens, customRates);
348
476
  }
349
477
  }
350
- // Track budget (cumulative)
351
- budgetSpent += actualCost;
478
+ // Release in-flight hold and commit budget under mutex
479
+ {
480
+ const releaseLock = await budgetMutex.acquire();
481
+ inFlightHoldTotal -= estimatedCost;
482
+ budgetSpent += actualCost;
483
+ releaseLock();
484
+ }
352
485
  // AUD-457: Persist cumulative spend to disk
353
486
  await persistSpendLedger(vaultBase, budgetSpent);
354
487
  // g. Circuit breaker: record success
@@ -384,23 +517,47 @@ export async function trust(client, opts) {
384
517
  // AUD-460: Use the proxy's transferId for settlement
385
518
  await proxyConn.settle(proxyTransferId ?? transferId, actualCost);
386
519
  }
387
- catch {
520
+ catch (postErr) {
388
521
  settled = false;
522
+ await audit
523
+ .appendEvent({
524
+ kind: "settlement_ambiguous",
525
+ actor: "local",
526
+ data: {
527
+ model,
528
+ cost: actualCost,
529
+ transferId,
530
+ error: postErr instanceof Error
531
+ ? postErr.message.slice(0, 200)
532
+ : String(postErr).slice(0, 200),
533
+ },
534
+ })
535
+ .catch(() => {
536
+ callAuditDegraded = true;
537
+ });
389
538
  }
390
539
  }
391
540
  // h. Audit event — failure mode 15.3: audit write failure
392
541
  const syntheticHash = createHash("sha256").update(transferId).digest("hex");
393
542
  let auditHash = syntheticHash;
394
543
  try {
544
+ const auditData = {
545
+ model,
546
+ cost: actualCost,
547
+ settled,
548
+ transferId,
549
+ };
550
+ if (config.pii === "warn" || config.pii === "redact") {
551
+ const piiResult = redactPII(messages);
552
+ if (piiResult.detection.found) {
553
+ auditData.piiDetected = piiResult.detection.types;
554
+ auditData.piiPaths = piiResult.detection.paths;
555
+ }
556
+ }
395
557
  const auditEvent = await audit.appendEvent({
396
558
  kind: "llm_call",
397
559
  actor: "local",
398
- data: {
399
- model,
400
- cost: actualCost,
401
- settled,
402
- transferId,
403
- },
560
+ data: auditData,
404
561
  });
405
562
  auditHash = auditEvent.hash;
406
563
  }
@@ -428,12 +585,12 @@ export async function trust(client, opts) {
428
585
  success: true,
429
586
  }).catch(() => { });
430
587
  }
431
- const budgetRemaining = config.budget - budgetSpent;
588
+ const budgetRemaining = config.budget - budgetSpent - inFlightHoldTotal;
432
589
  const receipt = {
433
590
  transferId,
434
591
  cost: actualCost,
435
592
  budgetRemaining,
436
- auditHash,
593
+ auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : auditHash,
437
594
  chainPath: join(VAULT_DIR, "audit"),
438
595
  receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
439
596
  settled,
@@ -447,6 +604,12 @@ export async function trust(client, opts) {
447
604
  return { response, receipt };
448
605
  }
449
606
  catch (err) {
607
+ // Release in-flight hold under mutex (non-streaming failure)
608
+ {
609
+ const releaseLock = await budgetMutex.acquire();
610
+ inFlightHoldTotal -= estimatedCost;
611
+ releaseLock();
612
+ }
450
613
  // j. Circuit breaker: record failure
451
614
  cb.recordFailure();
452
615
  // j2. Failure mode 15.2: LLM fails — VOID the pending hold
@@ -473,7 +636,13 @@ export async function trust(client, opts) {
473
636
  .appendEvent({
474
637
  kind: "llm_call_failed",
475
638
  actor: "local",
476
- data: { model, error: String(err), transferId },
639
+ data: {
640
+ model,
641
+ error: config.pii === "warn" || config.pii === "redact"
642
+ ? redactPII(String(err)).data.slice(0, 200)
643
+ : String(err),
644
+ transferId,
645
+ },
477
646
  })
478
647
  .catch(() => {
479
648
  callAuditDegraded = true;
@@ -496,6 +665,287 @@ export async function trust(client, opts) {
496
665
  inFlightCount--;
497
666
  }
498
667
  }
668
+ // 7b. Action governance — simplified pipeline for non-LLM actions
669
+ async function governActionImpl(action, execute) {
670
+ if (destroyed) {
671
+ throw new Error("TrustedClient has been destroyed");
672
+ }
673
+ // AUD-466: Validate cost to prevent budget inflation via negative values
674
+ if (!Number.isFinite(action.cost) || action.cost < 0) {
675
+ throw new Error(`action.cost must be a non-negative finite number, got ${String(action.cost)}`);
676
+ }
677
+ inFlightCount++;
678
+ let callAuditDegraded = false;
679
+ try {
680
+ const actor = action.actor ?? "local";
681
+ const transferId = trustId("tx");
682
+ // a. Circuit breaker check (keyed by action kind)
683
+ const cb = breaker.get(action.kind);
684
+ cb.allowRequest();
685
+ // b. Acquire mutex for budget atomicity
686
+ const releaseBudgetLock = await budgetMutex.acquire();
687
+ let proxyTransferId;
688
+ try {
689
+ // c. Policy gate — action fields available in context
690
+ // AUD-467: Caller params spread FIRST so governance fields cannot be shadowed
691
+ const policyResult = evaluatePolicy(policyRules, {
692
+ ...(action.params ?? {}),
693
+ action_kind: action.kind,
694
+ action_name: action.name,
695
+ estimated_cost: action.cost,
696
+ budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
697
+ tier: config.tier,
698
+ });
699
+ if (policyResult.decision === "deny") {
700
+ const reason = policyResult.reasons.length > 0 ? policyResult.reasons.join("; ") : "Policy denied";
701
+ throw new PolicyDeniedError(reason);
702
+ }
703
+ // d. PII check on action params
704
+ if (config.pii !== "off" && action.params != null) {
705
+ const piiResult = detectPII(action.params);
706
+ if (piiResult.found && config.pii === "block") {
707
+ throw new PolicyDeniedError(`PII detected in action params: ${piiResult.types.join(", ")}`);
708
+ }
709
+ }
710
+ // d2. Injection detection on action params
711
+ if (config.injection !== "off" && action.params != null) {
712
+ const injectionResult = detectInjection(action.params);
713
+ if (injectionResult.detected) {
714
+ if (config.injection === "block") {
715
+ throw new PolicyDeniedError(`Injection detected in action params: ${injectionResult.patterns.join(", ")}`);
716
+ }
717
+ // warn: log to audit trail (non-fatal)
718
+ await audit
719
+ .appendEvent({
720
+ kind: "injection_detected",
721
+ actor: action.actor ?? "local",
722
+ data: {
723
+ patterns: injectionResult.patterns,
724
+ score: injectionResult.score,
725
+ actionName: action.name,
726
+ actionKind: action.kind,
727
+ },
728
+ })
729
+ .catch(() => { });
730
+ }
731
+ }
732
+ // e. PENDING hold
733
+ if (proxyConn != null && !isDryRun) {
734
+ try {
735
+ const proxyResult = await proxyConn.spend({
736
+ model: action.name,
737
+ estimatedCost: action.cost,
738
+ actor,
739
+ });
740
+ proxyTransferId = proxyResult.transferId;
741
+ }
742
+ catch (holdErr) {
743
+ throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
744
+ }
745
+ }
746
+ else if (engine != null && !isDryRun) {
747
+ try {
748
+ await engine.spendPending({
749
+ transferId,
750
+ amount: action.cost,
751
+ });
752
+ }
753
+ catch (holdErr) {
754
+ throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
755
+ }
756
+ }
757
+ inFlightHoldTotal += action.cost;
758
+ }
759
+ finally {
760
+ releaseBudgetLock();
761
+ }
762
+ // f. Execute the action
763
+ // Guard against double-decrement of inFlightHoldTotal (AUD-465)
764
+ let holdReleased = false;
765
+ async function releaseHoldAndCommit(cost) {
766
+ if (!holdReleased) {
767
+ holdReleased = true;
768
+ const releaseLock = await budgetMutex.acquire();
769
+ inFlightHoldTotal -= action.cost;
770
+ if (cost !== undefined) {
771
+ budgetSpent += cost;
772
+ }
773
+ releaseLock();
774
+ }
775
+ }
776
+ try {
777
+ const result = await execute();
778
+ // Release in-flight hold and commit budget under mutex
779
+ await releaseHoldAndCommit(action.cost);
780
+ await persistSpendLedger(vaultBase, budgetSpent);
781
+ // g. Circuit breaker: record success
782
+ cb.recordSuccess();
783
+ // h. POST settlement
784
+ let settled = true;
785
+ if (engine != null && !isDryRun) {
786
+ try {
787
+ await engine.postPendingSpend(transferId);
788
+ }
789
+ catch (postErr) {
790
+ settled = false;
791
+ await audit
792
+ .appendEvent({
793
+ kind: "settlement_ambiguous",
794
+ actor,
795
+ data: {
796
+ actionKind: action.kind,
797
+ actionName: action.name,
798
+ cost: action.cost,
799
+ transferId,
800
+ error: postErr instanceof Error
801
+ ? postErr.message.slice(0, 200)
802
+ : String(postErr).slice(0, 200),
803
+ },
804
+ })
805
+ .catch(() => {
806
+ callAuditDegraded = true;
807
+ });
808
+ }
809
+ }
810
+ if (proxyConn != null && !isDryRun) {
811
+ try {
812
+ await proxyConn.settle(proxyTransferId ?? transferId, action.cost);
813
+ }
814
+ catch (postErr) {
815
+ settled = false;
816
+ await audit
817
+ .appendEvent({
818
+ kind: "settlement_ambiguous",
819
+ actor,
820
+ data: {
821
+ actionKind: action.kind,
822
+ actionName: action.name,
823
+ cost: action.cost,
824
+ transferId,
825
+ error: postErr instanceof Error
826
+ ? postErr.message.slice(0, 200)
827
+ : String(postErr).slice(0, 200),
828
+ },
829
+ })
830
+ .catch(() => {
831
+ callAuditDegraded = true;
832
+ });
833
+ }
834
+ }
835
+ // i. Prepare params for audit — redact PII if configured
836
+ let auditParams;
837
+ if (action.params != null) {
838
+ if (config.pii === "warn" || config.pii === "redact") {
839
+ const redacted = redactPII(action.params);
840
+ auditParams = redacted.data;
841
+ }
842
+ else {
843
+ auditParams = action.params;
844
+ }
845
+ }
846
+ // i2. Audit event
847
+ const syntheticHash = createHash("sha256").update(transferId).digest("hex");
848
+ let auditHash = syntheticHash;
849
+ try {
850
+ const auditEvent = await audit.appendEvent({
851
+ kind: action.kind,
852
+ actor,
853
+ data: {
854
+ actionName: action.name,
855
+ cost: action.cost,
856
+ settled,
857
+ transferId,
858
+ ...(auditParams != null ? { params: auditParams } : {}),
859
+ },
860
+ });
861
+ auditHash = auditEvent.hash;
862
+ }
863
+ catch {
864
+ // Failure mode 15.3: Audit degraded — do not fail the response
865
+ callAuditDegraded = true;
866
+ process.stderr.write(`[usertrust] audit degraded: failed to write ${action.kind} event for ${transferId}\n`);
867
+ }
868
+ // j. Daily-rotated receipt
869
+ if (config.audit.rotation !== "none") {
870
+ writeReceipt(vaultPath, {
871
+ kind: action.kind,
872
+ subsystem: "trust",
873
+ actor,
874
+ data: {
875
+ actionName: action.name,
876
+ cost: action.cost,
877
+ settled,
878
+ transferId,
879
+ ...(auditParams != null ? { params: auditParams } : {}),
880
+ },
881
+ }, config.audit.indexLimit);
882
+ }
883
+ const budgetRemaining = config.budget - budgetSpent - inFlightHoldTotal;
884
+ const receipt = {
885
+ transferId,
886
+ cost: action.cost,
887
+ budgetRemaining,
888
+ auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : auditHash,
889
+ chainPath: join(VAULT_DIR, "audit"),
890
+ receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
891
+ settled,
892
+ model: action.name,
893
+ provider: action.kind,
894
+ timestamp: new Date().toISOString(),
895
+ actionKind: action.kind,
896
+ ...(callAuditDegraded ? { auditDegraded: true } : {}),
897
+ ...(proxyConn != null ? { proxyStub: true } : {}),
898
+ };
899
+ return { result, receipt };
900
+ }
901
+ catch (err) {
902
+ // Release in-flight hold (AUD-465: guard prevents double-decrement)
903
+ await releaseHoldAndCommit();
904
+ // Circuit breaker: record failure
905
+ cb.recordFailure();
906
+ // VOID the pending hold
907
+ if (engine != null && !isDryRun) {
908
+ try {
909
+ await engine.voidPendingSpend(transferId);
910
+ }
911
+ catch {
912
+ // Best-effort void
913
+ }
914
+ }
915
+ if (proxyConn != null && !isDryRun) {
916
+ try {
917
+ await proxyConn.void(proxyTransferId ?? transferId);
918
+ }
919
+ catch {
920
+ // Best-effort void
921
+ }
922
+ }
923
+ // Audit the failure
924
+ await audit
925
+ .appendEvent({
926
+ kind: `${action.kind}_failed`,
927
+ actor,
928
+ data: {
929
+ actionName: action.name,
930
+ error: (() => {
931
+ const raw = err instanceof Error ? err.message : String(err);
932
+ return config.pii === "warn" || config.pii === "redact"
933
+ ? redactPII(raw).data.slice(0, 200)
934
+ : raw.slice(0, 200);
935
+ })(),
936
+ transferId,
937
+ },
938
+ })
939
+ .catch(() => {
940
+ callAuditDegraded = true;
941
+ });
942
+ throw err;
943
+ }
944
+ }
945
+ finally {
946
+ inFlightCount--;
947
+ }
948
+ }
499
949
  // 8. Safety net: clean up on process exit if destroy() was never called
500
950
  let beforeExitHandler = null;
501
951
  // 9. Build Proxy based on client kind
@@ -507,7 +957,7 @@ export async function trust(client, opts) {
507
957
  // AUD-462: Wait up to 5 seconds for in-flight calls to complete.
508
958
  // After the deadline, proceed with teardown anyway.
509
959
  const deadline = Date.now() + 5_000;
510
- while (inFlightCount > 0 && Date.now() < deadline) {
960
+ while ((inFlightCount > 0 || inFlightStreamCount > 0) && Date.now() < deadline) {
511
961
  await new Promise((r) => setTimeout(r, 50));
512
962
  }
513
963
  // Remove beforeExit safety net
@@ -535,13 +985,13 @@ export async function trust(client, opts) {
535
985
  }
536
986
  };
537
987
  if (kind === "anthropic") {
538
- return buildAnthropicProxy(client, interceptCall, destroyFn);
988
+ return buildAnthropicProxy(client, interceptCall, destroyFn, governActionImpl);
539
989
  }
540
990
  if (kind === "openai") {
541
- return buildOpenAIProxy(client, interceptCall, destroyFn);
991
+ return buildOpenAIProxy(client, interceptCall, destroyFn, governActionImpl);
542
992
  }
543
993
  // google
544
- return buildGoogleProxy(client, interceptCall, destroyFn);
994
+ return buildGoogleProxy(client, interceptCall, destroyFn, governActionImpl);
545
995
  }
546
996
  const governedClient = createClientProxy();
547
997
  beforeExitHandler = () => {
@@ -619,7 +1069,7 @@ async function createTBEngine(config) {
619
1069
  },
620
1070
  };
621
1071
  }
622
- function buildAnthropicProxy(client, intercept, destroy) {
1072
+ function buildAnthropicProxy(client, intercept, destroy, governAction) {
623
1073
  const original = client;
624
1074
  const messages = original.messages;
625
1075
  const originalCreate = messages.create;
@@ -639,12 +1089,14 @@ function buildAnthropicProxy(client, intercept, destroy) {
639
1089
  return messagesProxy;
640
1090
  if (prop === "destroy")
641
1091
  return destroy;
1092
+ if (prop === "governAction")
1093
+ return governAction;
642
1094
  return Reflect.get(target, prop, receiver);
643
1095
  },
644
1096
  });
645
1097
  return clientProxy;
646
1098
  }
647
- function buildOpenAIProxy(client, intercept, destroy) {
1099
+ function buildOpenAIProxy(client, intercept, destroy, governAction) {
648
1100
  const original = client;
649
1101
  const chat = original.chat;
650
1102
  const completions = chat.completions;
@@ -670,12 +1122,14 @@ function buildOpenAIProxy(client, intercept, destroy) {
670
1122
  return chatProxy;
671
1123
  if (prop === "destroy")
672
1124
  return destroy;
1125
+ if (prop === "governAction")
1126
+ return governAction;
673
1127
  return Reflect.get(target, prop, receiver);
674
1128
  },
675
1129
  });
676
1130
  return clientProxy;
677
1131
  }
678
- function buildGoogleProxy(client, intercept, destroy) {
1132
+ function buildGoogleProxy(client, intercept, destroy, governAction) {
679
1133
  const original = client;
680
1134
  const models = original.models;
681
1135
  const originalGenerate = models.generateContent;
@@ -693,6 +1147,8 @@ function buildGoogleProxy(client, intercept, destroy) {
693
1147
  return modelsProxy;
694
1148
  if (prop === "destroy")
695
1149
  return destroy;
1150
+ if (prop === "governAction")
1151
+ return governAction;
696
1152
  return Reflect.get(target, prop, receiver);
697
1153
  },
698
1154
  });