usertrust 1.2.5 → 1.4.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 (106) hide show
  1. package/README.md +9 -9
  2. package/dist/anomaly/detector.d.ts +17 -0
  3. package/dist/anomaly/detector.d.ts.map +1 -0
  4. package/dist/anomaly/detector.js +174 -0
  5. package/dist/anomaly/detector.js.map +1 -0
  6. package/dist/anomaly/index.d.ts +8 -0
  7. package/dist/anomaly/index.d.ts.map +1 -0
  8. package/dist/anomaly/index.js +8 -0
  9. package/dist/anomaly/index.js.map +1 -0
  10. package/dist/anomaly/signals/injection-cascade.d.ts +31 -0
  11. package/dist/anomaly/signals/injection-cascade.d.ts.map +1 -0
  12. package/dist/anomaly/signals/injection-cascade.js +46 -0
  13. package/dist/anomaly/signals/injection-cascade.js.map +1 -0
  14. package/dist/anomaly/signals/spend-velocity.d.ts +41 -0
  15. package/dist/anomaly/signals/spend-velocity.d.ts.map +1 -0
  16. package/dist/anomaly/signals/spend-velocity.js +90 -0
  17. package/dist/anomaly/signals/spend-velocity.js.map +1 -0
  18. package/dist/anomaly/signals/token-rate.d.ts +46 -0
  19. package/dist/anomaly/signals/token-rate.d.ts.map +1 -0
  20. package/dist/anomaly/signals/token-rate.js +90 -0
  21. package/dist/anomaly/signals/token-rate.js.map +1 -0
  22. package/dist/anomaly/types.d.ts +103 -0
  23. package/dist/anomaly/types.d.ts.map +1 -0
  24. package/dist/anomaly/types.js +4 -0
  25. package/dist/anomaly/types.js.map +1 -0
  26. package/dist/audit/chain.d.ts +9 -0
  27. package/dist/audit/chain.d.ts.map +1 -1
  28. package/dist/audit/chain.js +65 -9
  29. package/dist/audit/chain.js.map +1 -1
  30. package/dist/audit/verify.d.ts +19 -0
  31. package/dist/audit/verify.d.ts.map +1 -1
  32. package/dist/audit/verify.js +216 -14
  33. package/dist/audit/verify.js.map +1 -1
  34. package/dist/cli/init.d.ts.map +1 -1
  35. package/dist/cli/init.js +9 -1
  36. package/dist/cli/init.js.map +1 -1
  37. package/dist/cli/skill.d.ts.map +1 -1
  38. package/dist/cli/skill.js +35 -5
  39. package/dist/cli/skill.js.map +1 -1
  40. package/dist/cli/snapshot.d.ts.map +1 -1
  41. package/dist/cli/snapshot.js +20 -2
  42. package/dist/cli/snapshot.js.map +1 -1
  43. package/dist/cli/verify.d.ts.map +1 -1
  44. package/dist/cli/verify.js +20 -11
  45. package/dist/cli/verify.js.map +1 -1
  46. package/dist/govern.d.ts +14 -3
  47. package/dist/govern.d.ts.map +1 -1
  48. package/dist/govern.js +367 -129
  49. package/dist/govern.js.map +1 -1
  50. package/dist/headless.d.ts.map +1 -1
  51. package/dist/headless.js +155 -36
  52. package/dist/headless.js.map +1 -1
  53. package/dist/index.d.ts +4 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +3 -1
  56. package/dist/index.js.map +1 -1
  57. package/dist/ledger/client.d.ts +14 -0
  58. package/dist/ledger/client.d.ts.map +1 -1
  59. package/dist/ledger/client.js +56 -0
  60. package/dist/ledger/client.js.map +1 -1
  61. package/dist/ledger/pricing.d.ts.map +1 -1
  62. package/dist/ledger/pricing.js +8 -2
  63. package/dist/ledger/pricing.js.map +1 -1
  64. package/dist/policy/default-rules.d.ts +18 -3
  65. package/dist/policy/default-rules.d.ts.map +1 -1
  66. package/dist/policy/default-rules.js +33 -15
  67. package/dist/policy/default-rules.js.map +1 -1
  68. package/dist/policy/gate.d.ts.map +1 -1
  69. package/dist/policy/gate.js +114 -16
  70. package/dist/policy/gate.js.map +1 -1
  71. package/dist/policy/injection.d.ts.map +1 -1
  72. package/dist/policy/injection.js +25 -16
  73. package/dist/policy/injection.js.map +1 -1
  74. package/dist/policy/pii.d.ts +20 -0
  75. package/dist/policy/pii.d.ts.map +1 -1
  76. package/dist/policy/pii.js +45 -2
  77. package/dist/policy/pii.js.map +1 -1
  78. package/dist/proxy.d.ts +17 -9
  79. package/dist/proxy.d.ts.map +1 -1
  80. package/dist/proxy.js +8 -36
  81. package/dist/proxy.js.map +1 -1
  82. package/dist/shared/errors.d.ts +8 -0
  83. package/dist/shared/errors.d.ts.map +1 -1
  84. package/dist/shared/errors.js +18 -0
  85. package/dist/shared/errors.js.map +1 -1
  86. package/dist/shared/types.d.ts +119 -0
  87. package/dist/shared/types.d.ts.map +1 -1
  88. package/dist/shared/types.js +37 -1
  89. package/dist/shared/types.js.map +1 -1
  90. package/dist/snapshot/checkpoint.d.ts +18 -2
  91. package/dist/snapshot/checkpoint.d.ts.map +1 -1
  92. package/dist/snapshot/checkpoint.js +101 -17
  93. package/dist/snapshot/checkpoint.js.map +1 -1
  94. package/dist/streaming.d.ts +21 -3
  95. package/dist/streaming.d.ts.map +1 -1
  96. package/dist/streaming.js +35 -8
  97. package/dist/streaming.js.map +1 -1
  98. package/dist/supply-chain/permissions.d.ts +16 -3
  99. package/dist/supply-chain/permissions.d.ts.map +1 -1
  100. package/dist/supply-chain/permissions.js +59 -9
  101. package/dist/supply-chain/permissions.js.map +1 -1
  102. package/dist/supply-chain/sign.d.ts +5 -2
  103. package/dist/supply-chain/sign.d.ts.map +1 -1
  104. package/dist/supply-chain/sign.js +10 -2
  105. package/dist/supply-chain/sign.js.map +1 -1
  106. package/package.json +2 -4
package/dist/govern.js CHANGED
@@ -26,26 +26,27 @@
26
26
  * await client.destroy();
27
27
  * ```
28
28
  */
29
- import { createHash } from "node:crypto";
29
+ import { createHash, randomUUID } from "node:crypto";
30
30
  import { existsSync, mkdirSync } from "node:fs";
31
- import { readFile, rename, writeFile } from "node:fs/promises";
31
+ import { readFile, rename, unlink, writeFile } from "node:fs/promises";
32
32
  import { join } from "node:path";
33
+ import { CreateTransferError } from "tigerbeetle-node";
33
34
  import { createAuditWriter } from "./audit/chain.js";
34
35
  import { writeReceipt } from "./audit/rotation.js";
35
36
  import { detectClientKind } from "./detect.js";
36
- import { TrustTBClient, XFER_SPEND } from "./ledger/client.js";
37
+ import { TBTransferError, TrustTBClient, XFER_SPEND } from "./ledger/client.js";
37
38
  import { estimateCost, estimateInputTokens } from "./ledger/pricing.js";
38
39
  import { recordPattern } from "./memory/patterns.js";
39
- import { DEFAULT_RULES } from "./policy/default-rules.js";
40
+ import { DEFAULT_RULES, mergePolicies } from "./policy/default-rules.js";
40
41
  import { evaluatePolicy, loadPolicies } from "./policy/gate.js";
41
42
  import { detectInjection } from "./policy/injection.js";
42
43
  import { detectPII, redactPII } from "./policy/pii.js";
43
- import { connectProxy } from "./proxy.js";
44
44
  import { CircuitBreakerRegistry } from "./resilience/circuit.js";
45
45
  import { DEFAULT_BUDGET, VAULT_DIR } from "./shared/constants.js";
46
46
  /** Base URL for receipt verification links (used in proxy mode). */
47
47
  const VERIFY_URL_BASE = "https://verify.usertrust.dev";
48
- import { LedgerUnavailableError, PolicyDeniedError } from "./shared/errors.js";
48
+ import { createAnomalyDetector } from "./anomaly/detector.js";
49
+ import { AnomalyError, AuditDegradedError, InsufficientBalanceError, LedgerUnavailableError, PolicyDeniedError, } from "./shared/errors.js";
49
50
  import { trustId } from "./shared/ids.js";
50
51
  import { TrustConfigSchema } from "./shared/types.js";
51
52
  import { createGovernedStream } from "./streaming.js";
@@ -88,22 +89,36 @@ async function loadSpendLedger(vaultBase) {
88
89
  async function persistSpendLedger(vaultBase, budgetSpent) {
89
90
  const dir = join(vaultBase, VAULT_DIR);
90
91
  const ledgerPath = join(dir, "spend-ledger.json");
91
- const tmpPath = join(dir, "spend-ledger.json.tmp");
92
- const data = {
93
- budgetSpent,
94
- updatedAt: new Date().toISOString(),
95
- };
92
+ // AUD-457 hardening (RECON #4): UNIQUE tmp path per write. A fixed
93
+ // `spend-ledger.json.tmp` lets two concurrent writers clobber each other's
94
+ // staging file, so a half-written record can be renamed into place. A pid +
95
+ // uuid suffix isolates every writer's staging file.
96
+ const tmpPath = join(dir, `spend-ledger.json.${process.pid}.${randomUUID()}.tmp`);
96
97
  try {
97
98
  // Ensure vault dir exists
98
99
  if (!existsSync(dir)) {
99
100
  mkdirSync(dir, { recursive: true });
100
101
  }
101
- // Atomic write: write tmp then rename
102
+ // MONOTONIC guard (RECON #4): cumulative spend must never regress on disk.
103
+ // A stale/racing writer carrying a lower budgetSpent must not "un-spend"
104
+ // money that another writer already recorded. Skip the write if the
105
+ // persisted value is already >= ours.
106
+ const existing = await loadSpendLedger(vaultBase);
107
+ if (existing > budgetSpent) {
108
+ return;
109
+ }
110
+ const data = {
111
+ budgetSpent,
112
+ updatedAt: new Date().toISOString(),
113
+ };
114
+ // Atomic write: write UNIQUE tmp then rename over the target.
102
115
  await writeFile(tmpPath, JSON.stringify(data), "utf-8");
103
116
  await rename(tmpPath, ledgerPath);
104
117
  }
105
118
  catch {
106
- // Best-effort — do not fail the LLM call over ledger persistence
119
+ // Best-effort — do not fail the LLM call over ledger persistence. Clean up
120
+ // our unique staging file if the rename never happened.
121
+ await unlink(tmpPath).catch(() => { });
107
122
  }
108
123
  }
109
124
  // ── trust() ──
@@ -134,16 +149,27 @@ export async function trust(client, opts) {
134
149
  const audit = (isTestEnv ? opts?._audit : undefined) ?? createAuditWriter(vaultPath);
135
150
  const policiesPath = join(vaultPath, VAULT_DIR, config.policies);
136
151
  const loadedRules = existsSync(policiesPath) ? loadPolicies(policiesPath) : [];
137
- const policyRules = loadedRules.length > 0 ? loadedRules : DEFAULT_RULES;
152
+ // P1-CUSTOM-POLICY-REPLACES (RECON #2): platform DEFAULT_RULES are ALWAYS
153
+ // enforced. mergePolicies is a safe concat — a custom policy file can only ADD
154
+ // deny/warn rules, never remove the budget/overshoot/exhausted guarantees. The
155
+ // gate is deny-wins with no "allow" effect, so appended user rules cannot weaken
156
+ // a default deny.
157
+ const policyRules = mergePolicies(DEFAULT_RULES, loadedRules);
138
158
  const breaker = new CircuitBreakerRegistry({
139
159
  failureThreshold: config.circuitBreaker.failureThreshold,
140
160
  resetTimeoutMs: config.circuitBreaker.resetTimeout,
141
161
  });
142
- // 3. Proxy connection (if proxy mode)
143
- let proxyConn = null;
162
+ // 3. AUD-456: Proxy mode removed throw early with clear error
144
163
  if (opts?.proxy) {
145
- proxyConn = connectProxy(opts.proxy, opts.key);
164
+ throw new Error("usertrust: proxy mode is not yet implemented (AUD-456). " +
165
+ "Use dryRun mode for testing, or connect a real TigerBeetle instance for production.");
146
166
  }
167
+ // AUD-456: proxyConn is always null now — proxy mode throws above.
168
+ // Cast keeps dead code paths type-safe for future re-enablement.
169
+ const proxyConn = null;
170
+ // AUD-457: restore cumulative spend from disk BEFORE building the engine so the
171
+ // enforcing holding account can be seeded with the REMAINING budget.
172
+ let budgetSpent = await loadSpendLedger(vaultBase);
147
173
  // 4. Engine (injected for tests, real TB client in production, null in dry-run/proxy)
148
174
  // AUD-470: _engine injection only accepted in test environments
149
175
  let engine;
@@ -152,7 +178,9 @@ export async function trust(client, opts) {
152
178
  }
153
179
  else if (!isDryRun && proxyConn == null) {
154
180
  try {
155
- engine = await createTBEngine(config);
181
+ // P1-LEDGER-ENFORCE (RECON #3): seed the enforcing holding account with the
182
+ // remaining budget so TigerBeetle atomically REJECTS an over-budget hold.
183
+ engine = await createTBEngine(config, Math.max(0, config.budget - budgetSpent));
156
184
  }
157
185
  catch (err) {
158
186
  throw new LedgerUnavailableError(err instanceof Error ? err.message : String(err));
@@ -165,11 +193,13 @@ export async function trust(client, opts) {
165
193
  const kind = detectClientKind(client);
166
194
  // 6. Track state
167
195
  let destroyed = false;
168
- let budgetSpent = await loadSpendLedger(vaultBase); // AUD-457: restore from disk
169
196
  const budgetMutex = new AsyncMutex(); // AUD-453: serialise budget-check + hold
170
197
  let inFlightCount = 0; // AUD-462: track in-flight calls for graceful destroy
171
198
  let inFlightStreamCount = 0; // AUD-462: track in-flight streams (consumed after interceptCall returns)
172
199
  let inFlightHoldTotal = 0; // Track estimated cost of in-flight pending holds
200
+ // Streaming anomaly detector — shared across calls so injection-cascade
201
+ // can track signals across the conversation. Disabled when config.anomaly.enabled=false.
202
+ const anomalyDetector = createAnomalyDetector(config.anomaly, { provider: kind });
173
203
  // 7. Two-phase intercept
174
204
  async function interceptCall(originalFn, thisArg, args) {
175
205
  if (destroyed) {
@@ -180,15 +210,23 @@ export async function trust(client, opts) {
180
210
  try {
181
211
  const params = (args[0] ?? {});
182
212
  const model = params.model ?? "unknown";
183
- const messages = params.messages ?? [];
213
+ // P3-PROVIDER-BLINDSPOT: normalize the prompt-bearing payload across
214
+ // providers (Anthropic/OpenAI `messages` + `system`, Google `contents`) so
215
+ // PII/injection scanning, token estimation, redaction, and pattern hashing
216
+ // all see the actual prompt — not an empty `messages` array on Google calls.
217
+ const promptParts = extractPromptParts(params, kind);
184
218
  // Per-call audit degradation flag (not sticky across calls)
185
219
  let callAuditDegraded = false;
220
+ // P3-PII-REDACT-EGRESS: `forwardArgs` is what we actually send to the
221
+ // provider. In redact mode it becomes a redacted deep clone so PII never
222
+ // egresses; block mode throws before any egress. Default: forward verbatim.
223
+ let forwardArgs = args;
186
224
  // a. Circuit breaker check
187
225
  const cb = breaker.get(kind);
188
226
  cb.allowRequest();
189
227
  // b. Estimate cost (before policy, so cost fields are available in context)
190
228
  const transferId = trustId("tx");
191
- const estimatedInputTokens = estimateInputTokens(messages);
229
+ const estimatedInputTokens = estimateInputTokens(promptParts);
192
230
  const maxOutputTokens = params.max_tokens ?? 4096;
193
231
  const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens, customRates);
194
232
  // AUD-453: Acquire mutex to serialise budget-check + PENDING hold.
@@ -197,30 +235,56 @@ export async function trust(client, opts) {
197
235
  const releaseBudgetLock = await budgetMutex.acquire();
198
236
  // AUD-460: Track the proxy's transferId separately for settle/void
199
237
  let proxyTransferId;
238
+ // AUD-468: Track whether the in-flight hold has been released, so
239
+ // the catch handlers below can't double-decrement inFlightHoldTotal
240
+ // (mirrors the AUD-465 guard used in governActionImpl).
241
+ let holdActive = false;
242
+ async function releaseInFlightHold() {
243
+ if (!holdActive)
244
+ return;
245
+ holdActive = false;
246
+ const releaseLock = await budgetMutex.acquire();
247
+ inFlightHoldTotal -= estimatedCost;
248
+ releaseLock();
249
+ }
200
250
  try {
201
251
  // c. Policy gate
252
+ // P1-PARAM-SHADOW: caller `params` are spread FIRST so trusted
253
+ // governance fields (tier/estimated_cost/budget_remaining/
254
+ // budget_remaining_after) CANNOT be shadowed by request-supplied keys.
255
+ // P1-BUDGET-PREFLIGHT: budget_remaining_after is the derived field the
256
+ // single-field gate compares against zero to deny a single overshooting
257
+ // call (the `block-budget-overshoot` default rule).
202
258
  const policyResult = evaluatePolicy(policyRules, {
259
+ ...params,
203
260
  model,
204
261
  tier: config.tier,
205
262
  estimated_cost: estimatedCost,
206
263
  budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
207
- ...params,
264
+ budget_remaining_after: config.budget - budgetSpent - inFlightHoldTotal - estimatedCost,
208
265
  });
209
266
  if (policyResult.decision === "deny") {
210
267
  const reason = policyResult.reasons.length > 0 ? policyResult.reasons.join("; ") : "Policy denied";
211
268
  throw new PolicyDeniedError(reason);
212
269
  }
213
- // d. PII check
270
+ // d. PII check + redact-egress
214
271
  if (config.pii !== "off") {
215
- const piiResult = detectPII(messages);
272
+ const piiResult = detectPII(promptParts);
216
273
  if (piiResult.found && config.pii === "block") {
274
+ // block mode: throw BEFORE any egress.
217
275
  throw new PolicyDeniedError(`PII detected: ${piiResult.types.join(", ")}`);
218
276
  }
219
- // "warn" and "redact" modes: continue (redact is not implemented at SDK level)
277
+ if (config.pii === "redact" && piiResult.found) {
278
+ // redact mode: forward a redacted DEEP CLONE so PII never egresses.
279
+ // redactPII is pure — the caller's original object is never mutated.
280
+ const redactedBody = redactPII(params).data;
281
+ forwardArgs = [redactedBody, ...args.slice(1)];
282
+ }
283
+ // "warn" mode: continue, no transform (audit copy is redacted later).
220
284
  }
221
285
  // d2. Injection detection
222
286
  if (config.injection !== "off") {
223
- const injectionResult = detectInjection(messages);
287
+ const injectionResult = detectInjection(promptParts);
224
288
  if (injectionResult.detected) {
225
289
  if (config.injection === "block") {
226
290
  throw new PolicyDeniedError(`Prompt injection detected: ${injectionResult.patterns.join(", ")}`);
@@ -237,6 +301,11 @@ export async function trust(client, opts) {
237
301
  },
238
302
  })
239
303
  .catch(() => { });
304
+ // Feed the anomaly detector so cascading injections trip the breaker.
305
+ anomalyDetector.observe({
306
+ kind: "injection",
307
+ patterns: injectionResult.patterns,
308
+ });
240
309
  }
241
310
  }
242
311
  // e. Failure mode 15.4: TigerBeetle / engine unreachable — PENDING hold
@@ -262,21 +331,32 @@ export async function trust(client, opts) {
262
331
  });
263
332
  }
264
333
  catch (holdErr) {
265
- // Ledger unreachable do NOT forward to provider
334
+ // P1-LEDGER-ENFORCE: an over-budget reservation is rejected
335
+ // atomically by the ledger. Surface it as a hard budget DENY —
336
+ // NOT as "ledger unavailable" (which would misreport a budget cap
337
+ // as an outage). This throws out of the budget-section try, runs
338
+ // its finally (releases the budget lock), and propagates without
339
+ // a hold to void — exactly mirroring the policy-deny control flow.
340
+ if (holdErr instanceof InsufficientBalanceError) {
341
+ throw holdErr;
342
+ }
343
+ // Genuine ledger outage — do NOT forward to provider.
266
344
  throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
267
345
  }
268
346
  }
269
347
  // Track in-flight hold cost for accurate budget calculations
270
348
  inFlightHoldTotal += estimatedCost;
349
+ holdActive = true; // AUD-468: arm the guard
271
350
  }
272
351
  finally {
273
352
  // AUD-453: Release lock after budget check + hold are complete
274
353
  releaseBudgetLock();
275
354
  }
276
- // e. Forward to original SDK
355
+ // e. Forward to original SDK. P3-PII-REDACT-EGRESS: forwardArgs is the
356
+ // redacted clone in redact mode, or the original args otherwise.
277
357
  let settled = true;
278
358
  try {
279
- const response = await originalFn.apply(thisArg, args);
359
+ const response = await originalFn.apply(thisArg, forwardArgs);
280
360
  // e2. Streaming detection: if response is an async iterable, wrap with
281
361
  // token accumulation. Settlement and audit happen when the stream ends.
282
362
  if (response != null &&
@@ -296,12 +376,22 @@ export async function trust(client, opts) {
296
376
  usageSource = "estimated";
297
377
  }
298
378
  // Release in-flight hold and commit budget under mutex
299
- {
379
+ // AUD-468: holdActive guard prevents double-release if the
380
+ // outer catch also fires (e.g. from a synchronous exception
381
+ // during stream construction).
382
+ if (holdActive) {
383
+ holdActive = false;
300
384
  const releaseLock = await budgetMutex.acquire();
301
385
  inFlightHoldTotal -= estimatedCost;
302
386
  budgetSpent += streamCost;
303
387
  releaseLock();
304
388
  }
389
+ else {
390
+ // Hold already released — still need to record the spend.
391
+ const releaseLock = await budgetMutex.acquire();
392
+ budgetSpent += streamCost;
393
+ releaseLock();
394
+ }
305
395
  // AUD-457: Persist cumulative spend to disk
306
396
  await persistSpendLedger(vaultBase, budgetSpent);
307
397
  cb.recordSuccess();
@@ -332,7 +422,8 @@ export async function trust(client, opts) {
332
422
  }
333
423
  else if (engine != null && !isDryRun) {
334
424
  try {
335
- await engine.postPendingSpend(transferId);
425
+ // Post the ACTUAL consumed cost (RECON #3).
426
+ await engine.postPendingSpend(transferId, streamCost);
336
427
  }
337
428
  catch (postErr) {
338
429
  settled = false;
@@ -366,7 +457,7 @@ export async function trust(client, opts) {
366
457
  chunksDelivered: completion.chunksDelivered,
367
458
  };
368
459
  if (config.pii === "warn" || config.pii === "redact") {
369
- const piiResult = redactPII(messages);
460
+ const piiResult = redactPII(promptParts);
370
461
  if (piiResult.detection.found) {
371
462
  auditEventData.piiDetected = piiResult.detection.types;
372
463
  auditEventData.piiPaths = piiResult.detection.paths;
@@ -382,6 +473,13 @@ export async function trust(client, opts) {
382
473
  catch {
383
474
  callAuditDegraded = true;
384
475
  }
476
+ // P3-AUDIT-FAILCLOSED (streaming): chunks were already delivered,
477
+ // so the strongest post-delivery signal is to REJECT `.receipt`.
478
+ // Decrement the stream counter first so destroy() never blocks.
479
+ if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
480
+ inFlightStreamCount--;
481
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
482
+ }
385
483
  const streamReceipt = {
386
484
  transferId,
387
485
  cost: streamCost,
@@ -403,11 +501,8 @@ export async function trust(client, opts) {
403
501
  return streamReceipt;
404
502
  }, async (error, partial) => {
405
503
  // Release in-flight hold under mutex
406
- {
407
- const releaseLock = await budgetMutex.acquire();
408
- inFlightHoldTotal -= estimatedCost;
409
- releaseLock();
410
- }
504
+ // AUD-468: holdActive guard prevents double-release.
505
+ await releaseInFlightHold();
411
506
  cb.recordFailure();
412
507
  // Best-effort audit of partial delivery
413
508
  audit
@@ -438,6 +533,41 @@ export async function trust(client, opts) {
438
533
  engine.voidPendingSpend(transferId).catch(() => { });
439
534
  }
440
535
  inFlightStreamCount--;
536
+ },
537
+ // onChunk: streaming anomaly detector hook. Observe each chunk;
538
+ // if a signal trips, throw AnomalyError to abort the stream.
539
+ // The thrown error propagates through wrapStream's onError above
540
+ // → existing VOID flow runs → upstream caller sees AnomalyError.
541
+ (obs) => {
542
+ if (!config.anomaly.enabled)
543
+ return;
544
+ anomalyDetector.observe({
545
+ kind: "chunk",
546
+ deltaTokens: obs.deltaTokens,
547
+ cumulativeInputTokens: obs.cumulativeInputTokens,
548
+ cumulativeOutputTokens: obs.cumulativeOutputTokens,
549
+ });
550
+ const verdict = anomalyDetector.check();
551
+ if (verdict.tripped) {
552
+ // Append hash-chained anomaly_detected audit event.
553
+ // Best-effort: do not block the abort if audit fails.
554
+ audit
555
+ .appendEvent({
556
+ kind: "anomaly_detected",
557
+ actor: "local",
558
+ data: {
559
+ anomalyKind: verdict.kind,
560
+ message: verdict.message,
561
+ metric: verdict.metric,
562
+ threshold: verdict.threshold,
563
+ model,
564
+ transferId,
565
+ provider: kind,
566
+ },
567
+ })
568
+ .catch(() => { });
569
+ throw new AnomalyError(verdict.kind, verdict.message, verdict.metric, verdict.threshold);
570
+ }
441
571
  });
442
572
  // AUD-454: For streaming responses, settlement has NOT happened yet.
443
573
  // Set settled: false — the real settlement status will be on
@@ -475,8 +605,53 @@ export async function trust(client, opts) {
475
605
  actualCost = estimateCost(model, inputTokens, outputTokens, customRates);
476
606
  }
477
607
  }
478
- // Release in-flight hold and commit budget under mutex
479
- {
608
+ // h. Audit the llm_call FIRST (P3-AUDIT-FAILCLOSED). The settlement-
609
+ // defining event is written BEFORE the irreversible budget commit and
610
+ // POST, so a fail-closed deployment never settles an unaudited spend.
611
+ // The event carries settled:true optimistically; a later POST failure
612
+ // appends the settlement_ambiguous correction and flips receipt.settled.
613
+ const syntheticHash = createHash("sha256").update(transferId).digest("hex");
614
+ let auditHash = syntheticHash;
615
+ let llmAuditFailed = false;
616
+ try {
617
+ const auditData = {
618
+ model,
619
+ cost: actualCost,
620
+ settled: true,
621
+ transferId,
622
+ };
623
+ if (config.pii === "warn" || config.pii === "redact") {
624
+ const piiResult = redactPII(promptParts);
625
+ if (piiResult.detection.found) {
626
+ auditData.piiDetected = piiResult.detection.types;
627
+ auditData.piiPaths = piiResult.detection.paths;
628
+ }
629
+ }
630
+ const auditEvent = await audit.appendEvent({
631
+ kind: "llm_call",
632
+ actor: "local",
633
+ data: auditData,
634
+ });
635
+ auditHash = auditEvent.hash;
636
+ }
637
+ catch {
638
+ // Failure mode 15.3: Audit degraded — mark + warn.
639
+ llmAuditFailed = true;
640
+ callAuditDegraded = true;
641
+ process.stderr.write(`[usertrust] audit degraded: failed to write llm_call event for ${transferId}\n`);
642
+ }
643
+ // P3-AUDIT-FAILCLOSED: under failClosed a failed llm_call audit ABORTS the
644
+ // call before any money moves. Throwing here routes to the outer catch,
645
+ // which VOIDs the hold (once) and never POSTs — the internal ledger holds
646
+ // no unaudited spend, and the caller is told the call failed.
647
+ if (config.audit.failClosed && llmAuditFailed) {
648
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
649
+ }
650
+ // Release in-flight hold and commit budget under mutex — money moves only
651
+ // AFTER the spend is audited. AUD-468: mark hold released before the commit
652
+ // so a later throw cannot double-decrement inFlightHoldTotal via the catch.
653
+ if (holdActive) {
654
+ holdActive = false;
480
655
  const releaseLock = await budgetMutex.acquire();
481
656
  inFlightHoldTotal -= estimatedCost;
482
657
  budgetSpent += actualCost;
@@ -489,7 +664,8 @@ export async function trust(client, opts) {
489
664
  // g2. Failure mode 15.1: POST fails after LLM success
490
665
  if (engine != null && !isDryRun) {
491
666
  try {
492
- await engine.postPendingSpend(transferId);
667
+ // Post the ACTUAL consumed cost (RECON #3).
668
+ await engine.postPendingSpend(transferId, actualCost);
493
669
  }
494
670
  catch (postErr) {
495
671
  // POST failed — LLM call succeeded but settlement is ambiguous
@@ -537,34 +713,11 @@ export async function trust(client, opts) {
537
713
  });
538
714
  }
539
715
  }
540
- // h. Audit event failure mode 15.3: audit write failure
541
- const syntheticHash = createHash("sha256").update(transferId).digest("hex");
542
- let auditHash = syntheticHash;
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
- }
557
- const auditEvent = await audit.appendEvent({
558
- kind: "llm_call",
559
- actor: "local",
560
- data: auditData,
561
- });
562
- auditHash = auditEvent.hash;
563
- }
564
- catch {
565
- // Failure mode 15.3: Audit degraded — do not fail the response
566
- callAuditDegraded = true;
567
- process.stderr.write(`[usertrust] audit degraded: failed to write llm_call event for ${transferId}\n`);
716
+ // P3-AUDIT-FAILCLOSED belt-and-suspenders: if ANY audit write during this
717
+ // call degraded the writer (including best-effort advisory writes), refuse
718
+ // to report success under failClosed so the caller cannot silently proceed.
719
+ if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
720
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
568
721
  }
569
722
  // i. Daily-rotated audit receipt (non-blocking)
570
723
  if (config.audit.rotation !== "none") {
@@ -577,7 +730,7 @@ export async function trust(client, opts) {
577
730
  }
578
731
  // i2. Pattern memory
579
732
  if (config.patterns.enabled) {
580
- const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
733
+ const promptHash = createHash("sha256").update(JSON.stringify(promptParts)).digest("hex");
581
734
  await recordPattern({
582
735
  promptHash,
583
736
  model,
@@ -605,11 +758,10 @@ export async function trust(client, opts) {
605
758
  }
606
759
  catch (err) {
607
760
  // Release in-flight hold under mutex (non-streaming failure)
608
- {
609
- const releaseLock = await budgetMutex.acquire();
610
- inFlightHoldTotal -= estimatedCost;
611
- releaseLock();
612
- }
761
+ // AUD-468: Use the guarded release — this is a no-op if the
762
+ // success path already released the hold, preventing
763
+ // inFlightHoldTotal from drifting negative on post-commit throws.
764
+ await releaseInFlightHold();
613
765
  // j. Circuit breaker: record failure
614
766
  cb.recordFailure();
615
767
  // j2. Failure mode 15.2: LLM fails — VOID the pending hold
@@ -649,7 +801,7 @@ export async function trust(client, opts) {
649
801
  });
650
802
  // l. Pattern memory: record failure
651
803
  if (config.patterns.enabled) {
652
- const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
804
+ const promptHash = createHash("sha256").update(JSON.stringify(promptParts)).digest("hex");
653
805
  await recordPattern({
654
806
  promptHash,
655
807
  model,
@@ -687,13 +839,17 @@ export async function trust(client, opts) {
687
839
  let proxyTransferId;
688
840
  try {
689
841
  // c. Policy gate — action fields available in context
690
- // AUD-467: Caller params spread FIRST so governance fields cannot be shadowed
842
+ // AUD-467: Caller params spread FIRST so governance fields cannot be shadowed.
843
+ // P1-BUDGET-PREFLIGHT: budget_remaining_after is the derived field the
844
+ // block-budget-overshoot default rule compares against zero (a HARD rule
845
+ // that fails CLOSED if the governor omits it — so it MUST be supplied).
691
846
  const policyResult = evaluatePolicy(policyRules, {
692
847
  ...(action.params ?? {}),
693
848
  action_kind: action.kind,
694
849
  action_name: action.name,
695
850
  estimated_cost: action.cost,
696
851
  budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
852
+ budget_remaining_after: config.budget - budgetSpent - inFlightHoldTotal - action.cost,
697
853
  tier: config.tier,
698
854
  });
699
855
  if (policyResult.decision === "deny") {
@@ -751,6 +907,11 @@ export async function trust(client, opts) {
751
907
  });
752
908
  }
753
909
  catch (holdErr) {
910
+ // P1-LEDGER-ENFORCE: over-budget reservation → hard budget DENY,
911
+ // not a ledger-outage misreport.
912
+ if (holdErr instanceof InsufficientBalanceError) {
913
+ throw holdErr;
914
+ }
754
915
  throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
755
916
  }
756
917
  }
@@ -775,7 +936,52 @@ export async function trust(client, opts) {
775
936
  }
776
937
  try {
777
938
  const result = await execute();
778
- // Release in-flight hold and commit budget under mutex
939
+ // i. Prepare params for audit redact PII if configured.
940
+ let auditParams;
941
+ if (action.params != null) {
942
+ if (config.pii === "warn" || config.pii === "redact") {
943
+ const redacted = redactPII(action.params);
944
+ auditParams = redacted.data;
945
+ }
946
+ else {
947
+ auditParams = action.params;
948
+ }
949
+ }
950
+ // i2. Audit the action FIRST (P3-AUDIT-FAILCLOSED). The settlement-
951
+ // defining event precedes the budget commit + POST, so a fail-closed
952
+ // deployment never settles an unaudited action. settled:true is
953
+ // optimistic; a later POST failure appends settlement_ambiguous.
954
+ const syntheticHash = createHash("sha256").update(transferId).digest("hex");
955
+ let auditHash = syntheticHash;
956
+ let actionAuditFailed = false;
957
+ try {
958
+ const auditEvent = await audit.appendEvent({
959
+ kind: action.kind,
960
+ actor,
961
+ data: {
962
+ actionName: action.name,
963
+ cost: action.cost,
964
+ settled: true,
965
+ transferId,
966
+ ...(auditParams != null ? { params: auditParams } : {}),
967
+ },
968
+ });
969
+ auditHash = auditEvent.hash;
970
+ }
971
+ catch {
972
+ // Failure mode 15.3: Audit degraded — mark + warn.
973
+ actionAuditFailed = true;
974
+ callAuditDegraded = true;
975
+ process.stderr.write(`[usertrust] audit degraded: failed to write ${action.kind} event for ${transferId}\n`);
976
+ }
977
+ // P3-AUDIT-FAILCLOSED: under failClosed a failed action audit ABORTS the
978
+ // call before any money moves — the outer catch VOIDs the hold (once) and
979
+ // never POSTs.
980
+ if (config.audit.failClosed && actionAuditFailed) {
981
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
982
+ }
983
+ // Release in-flight hold and commit budget under mutex — money moves only
984
+ // AFTER the action is audited.
779
985
  await releaseHoldAndCommit(action.cost);
780
986
  await persistSpendLedger(vaultBase, budgetSpent);
781
987
  // g. Circuit breaker: record success
@@ -784,7 +990,8 @@ export async function trust(client, opts) {
784
990
  let settled = true;
785
991
  if (engine != null && !isDryRun) {
786
992
  try {
787
- await engine.postPendingSpend(transferId);
993
+ // Post the ACTUAL cost (RECON #3).
994
+ await engine.postPendingSpend(transferId, action.cost);
788
995
  }
789
996
  catch (postErr) {
790
997
  settled = false;
@@ -832,38 +1039,10 @@ export async function trust(client, opts) {
832
1039
  });
833
1040
  }
834
1041
  }
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`);
1042
+ // P3-AUDIT-FAILCLOSED belt-and-suspenders: refuse to report success if any
1043
+ // audit write during this call degraded the writer under failClosed.
1044
+ if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
1045
+ throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
867
1046
  }
868
1047
  // j. Daily-rotated receipt
869
1048
  if (config.audit.rotation !== "none") {
@@ -1002,44 +1181,103 @@ export async function trust(client, opts) {
1002
1181
  process.on("beforeExit", beforeExitHandler);
1003
1182
  return governedClient;
1004
1183
  }
1184
+ // ── Provider-aware prompt extraction (P3-PROVIDER-BLINDSPOT) ──
1185
+ /**
1186
+ * Normalize the prompt-bearing payload across providers so PII/injection scanning
1187
+ * and token estimation cover ALL shapes, not just Anthropic/OpenAI `messages`:
1188
+ * - Anthropic/OpenAI: `params.messages` (+ Anthropic top-level `system` string)
1189
+ * - Google: `params.contents` (the prompt lives here, not `messages`)
1190
+ *
1191
+ * Without this, a Google `generateContent({ model, contents })` call has an empty
1192
+ * `messages` array, so every PII/injection scan sees nothing and PII egresses.
1193
+ */
1194
+ function extractPromptParts(params, kind) {
1195
+ if (kind === "google") {
1196
+ const contents = params.contents;
1197
+ if (Array.isArray(contents))
1198
+ return contents;
1199
+ return contents != null ? [contents] : [];
1200
+ }
1201
+ const messages = params.messages;
1202
+ const parts = Array.isArray(messages) ? [...messages] : [];
1203
+ if (typeof params.system === "string") {
1204
+ parts.push({ role: "system", content: params.system });
1205
+ }
1206
+ return parts;
1207
+ }
1005
1208
  // ── TigerBeetle engine factory ──
1209
+ /** TigerBeetle codes that mean "this debit would exceed the account's credits". */
1210
+ function isTBInsufficientBalance(err) {
1211
+ if (!(err instanceof TBTransferError))
1212
+ return false;
1213
+ return (err.code === CreateTransferError.exceeds_credits ||
1214
+ err.code === CreateTransferError.overflows_debits ||
1215
+ err.code === CreateTransferError.overflows_debits_pending);
1216
+ }
1006
1217
  /**
1007
- * Create a TrustEngine backed by a real TigerBeetle client.
1008
- * Uses a simplified two-phase interface: pending transfers are created
1009
- * directly against the TB client using escrow-style debit/credit accounts.
1218
+ * Create a balance-enforcing TrustEngine backed by a real TigerBeetle client.
1219
+ *
1220
+ * P1-LEDGER-ENFORCE: the holding account is created with
1221
+ * `debits_must_not_exceed_credits` and FUNDED with `seedBudget` usertokens, so a
1222
+ * pending debit (hold) whose cumulative amount would exceed the remaining budget
1223
+ * is REJECTED atomically by TigerBeetle. That rejection is surfaced as an
1224
+ * {@link InsufficientBalanceError}, which the governor re-throws as a hard budget
1225
+ * DENY (never as a ledger outage). The prior escrow account had no enforcing flag
1226
+ * and no funding, so `spendPending` could never reject an over-budget hold.
1227
+ *
1228
+ * NOTE (cross-domain): RECON #3 designates `createLedgerEngine` /
1229
+ * `createFundedBudgetWallet` (LEDGER-owned, in `ledger/engine.ts`) as the eventual
1230
+ * home for this factory. Those symbols do not yet exist on disk, so this
1231
+ * GOVERN-local factory implements the same funded-enforcing contract using the
1232
+ * existing `TrustTBClient` primitives. When LEDGER ships `createLedgerEngine`,
1233
+ * this factory is a drop-in replacement (identical `TrustEngine` shape).
1010
1234
  */
1011
- async function createTBEngine(config) {
1235
+ async function createTBEngine(config, seedBudget) {
1012
1236
  const tbAddresses = config.tigerbeetle.addresses;
1013
1237
  const tbClusterId = BigInt(config.tigerbeetle.clusterId);
1014
1238
  const tbClient = new TrustTBClient({
1015
1239
  addresses: tbAddresses,
1016
1240
  clusterId: tbClusterId,
1017
1241
  });
1018
- // Initialize treasury and escrow accounts
1242
+ // Treasury (unconstrained) funds a per-session enforcing holding wallet.
1019
1243
  await tbClient.createTreasury();
1020
- await tbClient.ensureEscrowAccount("trust:escrow");
1244
+ const treasury = tbClient.getTreasuryId();
1245
+ // Enforcing holding wallet (debits_must_not_exceed_credits), funded with the
1246
+ // remaining session budget so cumulative pending debits cannot exceed it.
1247
+ // A FRESH account id per session prevents double-funding a deterministic
1248
+ // account across restarts (which would inflate the TB-enforced budget).
1249
+ const holdingId = await tbClient.createFundedBudgetWallet(seedBudget);
1021
1250
  // Pending transfer ID mapping (trustId string -> TB bigint)
1022
1251
  const pendingMap = new Map();
1023
1252
  return {
1024
1253
  async spendPending(params) {
1025
- const treasury = tbClient.getTreasuryId();
1026
- // Use a deterministic escrow account for SDK-local holds
1027
- const escrowId = TrustTBClient.deriveAccountId("trust:escrow");
1028
- const tbTransferId = await tbClient.createPendingTransfer({
1029
- debitAccountId: escrowId,
1030
- creditAccountId: treasury,
1031
- amount: params.amount,
1032
- code: XFER_SPEND,
1033
- });
1034
- pendingMap.set(params.transferId, tbTransferId);
1035
- return { transferId: params.transferId };
1254
+ try {
1255
+ const tbTransferId = await tbClient.createPendingTransfer({
1256
+ debitAccountId: holdingId,
1257
+ creditAccountId: treasury,
1258
+ amount: params.amount,
1259
+ code: XFER_SPEND,
1260
+ });
1261
+ pendingMap.set(params.transferId, tbTransferId);
1262
+ return { transferId: params.transferId };
1263
+ }
1264
+ catch (err) {
1265
+ // Over-budget reservation → TB rejects the pending debit. Surface as a
1266
+ // budget error so the governor reports a hard DENY, not an outage.
1267
+ if (isTBInsufficientBalance(err)) {
1268
+ throw new InsufficientBalanceError("trust:hold", params.amount, seedBudget);
1269
+ }
1270
+ throw err;
1271
+ }
1036
1272
  },
1037
- async postPendingSpend(transferId) {
1273
+ async postPendingSpend(transferId, actualAmount) {
1038
1274
  const tbId = pendingMap.get(transferId);
1039
1275
  if (tbId === undefined) {
1040
1276
  throw new Error(`No pending transfer found for ${transferId}`);
1041
1277
  }
1042
- await tbClient.postTransfer(tbId);
1278
+ // Post the ACTUAL consumed amount (≤ the reserved estimate); omitting it
1279
+ // posts the full pending amount.
1280
+ await tbClient.postTransfer(tbId, actualAmount);
1043
1281
  pendingMap.delete(transferId);
1044
1282
  },
1045
1283
  async voidPendingSpend(transferId) {