usertrust 1.3.0 → 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.
- package/README.md +9 -9
- package/dist/audit/chain.d.ts +9 -0
- package/dist/audit/chain.d.ts.map +1 -1
- package/dist/audit/chain.js +65 -9
- package/dist/audit/chain.js.map +1 -1
- package/dist/audit/verify.d.ts +19 -0
- package/dist/audit/verify.d.ts.map +1 -1
- package/dist/audit/verify.js +216 -14
- package/dist/audit/verify.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +9 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/skill.d.ts.map +1 -1
- package/dist/cli/skill.js +35 -5
- package/dist/cli/skill.js.map +1 -1
- package/dist/cli/snapshot.d.ts.map +1 -1
- package/dist/cli/snapshot.js +20 -2
- package/dist/cli/snapshot.js.map +1 -1
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +20 -11
- package/dist/cli/verify.js.map +1 -1
- package/dist/govern.d.ts +5 -1
- package/dist/govern.d.ts.map +1 -1
- package/dist/govern.js +285 -116
- package/dist/govern.js.map +1 -1
- package/dist/headless.d.ts.map +1 -1
- package/dist/headless.js +149 -32
- package/dist/headless.js.map +1 -1
- package/dist/ledger/client.d.ts +14 -0
- package/dist/ledger/client.d.ts.map +1 -1
- package/dist/ledger/client.js +56 -0
- package/dist/ledger/client.js.map +1 -1
- package/dist/ledger/pricing.d.ts.map +1 -1
- package/dist/ledger/pricing.js +8 -2
- package/dist/ledger/pricing.js.map +1 -1
- package/dist/policy/default-rules.d.ts +18 -3
- package/dist/policy/default-rules.d.ts.map +1 -1
- package/dist/policy/default-rules.js +33 -15
- package/dist/policy/default-rules.js.map +1 -1
- package/dist/policy/gate.d.ts.map +1 -1
- package/dist/policy/gate.js +114 -16
- package/dist/policy/gate.js.map +1 -1
- package/dist/policy/injection.d.ts.map +1 -1
- package/dist/policy/injection.js +25 -16
- package/dist/policy/injection.js.map +1 -1
- package/dist/policy/pii.d.ts +20 -0
- package/dist/policy/pii.d.ts.map +1 -1
- package/dist/policy/pii.js +45 -2
- package/dist/policy/pii.js.map +1 -1
- package/dist/shared/types.d.ts +16 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +12 -1
- package/dist/shared/types.js.map +1 -1
- package/dist/snapshot/checkpoint.d.ts +18 -2
- package/dist/snapshot/checkpoint.d.ts.map +1 -1
- package/dist/snapshot/checkpoint.js +101 -17
- package/dist/snapshot/checkpoint.js.map +1 -1
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +14 -1
- package/dist/streaming.js.map +1 -1
- package/dist/supply-chain/permissions.d.ts +16 -3
- package/dist/supply-chain/permissions.d.ts.map +1 -1
- package/dist/supply-chain/permissions.js +59 -9
- package/dist/supply-chain/permissions.js.map +1 -1
- package/dist/supply-chain/sign.d.ts +5 -2
- package/dist/supply-chain/sign.d.ts.map +1 -1
- package/dist/supply-chain/sign.js +10 -2
- package/dist/supply-chain/sign.js.map +1 -1
- package/package.json +2 -4
package/dist/govern.js
CHANGED
|
@@ -26,17 +26,18 @@
|
|
|
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";
|
|
@@ -45,7 +46,7 @@ import { DEFAULT_BUDGET, VAULT_DIR } from "./shared/constants.js";
|
|
|
45
46
|
/** Base URL for receipt verification links (used in proxy mode). */
|
|
46
47
|
const VERIFY_URL_BASE = "https://verify.usertrust.dev";
|
|
47
48
|
import { createAnomalyDetector } from "./anomaly/detector.js";
|
|
48
|
-
import { AnomalyError, LedgerUnavailableError, PolicyDeniedError } from "./shared/errors.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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
//
|
|
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,7 +149,12 @@ 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
|
-
|
|
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,
|
|
@@ -147,6 +167,9 @@ export async function trust(client, opts) {
|
|
|
147
167
|
// AUD-456: proxyConn is always null now — proxy mode throws above.
|
|
148
168
|
// Cast keeps dead code paths type-safe for future re-enablement.
|
|
149
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);
|
|
150
173
|
// 4. Engine (injected for tests, real TB client in production, null in dry-run/proxy)
|
|
151
174
|
// AUD-470: _engine injection only accepted in test environments
|
|
152
175
|
let engine;
|
|
@@ -155,7 +178,9 @@ export async function trust(client, opts) {
|
|
|
155
178
|
}
|
|
156
179
|
else if (!isDryRun && proxyConn == null) {
|
|
157
180
|
try {
|
|
158
|
-
|
|
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));
|
|
159
184
|
}
|
|
160
185
|
catch (err) {
|
|
161
186
|
throw new LedgerUnavailableError(err instanceof Error ? err.message : String(err));
|
|
@@ -168,7 +193,6 @@ export async function trust(client, opts) {
|
|
|
168
193
|
const kind = detectClientKind(client);
|
|
169
194
|
// 6. Track state
|
|
170
195
|
let destroyed = false;
|
|
171
|
-
let budgetSpent = await loadSpendLedger(vaultBase); // AUD-457: restore from disk
|
|
172
196
|
const budgetMutex = new AsyncMutex(); // AUD-453: serialise budget-check + hold
|
|
173
197
|
let inFlightCount = 0; // AUD-462: track in-flight calls for graceful destroy
|
|
174
198
|
let inFlightStreamCount = 0; // AUD-462: track in-flight streams (consumed after interceptCall returns)
|
|
@@ -186,15 +210,23 @@ export async function trust(client, opts) {
|
|
|
186
210
|
try {
|
|
187
211
|
const params = (args[0] ?? {});
|
|
188
212
|
const model = params.model ?? "unknown";
|
|
189
|
-
|
|
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);
|
|
190
218
|
// Per-call audit degradation flag (not sticky across calls)
|
|
191
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;
|
|
192
224
|
// a. Circuit breaker check
|
|
193
225
|
const cb = breaker.get(kind);
|
|
194
226
|
cb.allowRequest();
|
|
195
227
|
// b. Estimate cost (before policy, so cost fields are available in context)
|
|
196
228
|
const transferId = trustId("tx");
|
|
197
|
-
const estimatedInputTokens = estimateInputTokens(
|
|
229
|
+
const estimatedInputTokens = estimateInputTokens(promptParts);
|
|
198
230
|
const maxOutputTokens = params.max_tokens ?? 4096;
|
|
199
231
|
const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens, customRates);
|
|
200
232
|
// AUD-453: Acquire mutex to serialise budget-check + PENDING hold.
|
|
@@ -217,28 +249,42 @@ export async function trust(client, opts) {
|
|
|
217
249
|
}
|
|
218
250
|
try {
|
|
219
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).
|
|
220
258
|
const policyResult = evaluatePolicy(policyRules, {
|
|
259
|
+
...params,
|
|
221
260
|
model,
|
|
222
261
|
tier: config.tier,
|
|
223
262
|
estimated_cost: estimatedCost,
|
|
224
263
|
budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
|
|
225
|
-
|
|
264
|
+
budget_remaining_after: config.budget - budgetSpent - inFlightHoldTotal - estimatedCost,
|
|
226
265
|
});
|
|
227
266
|
if (policyResult.decision === "deny") {
|
|
228
267
|
const reason = policyResult.reasons.length > 0 ? policyResult.reasons.join("; ") : "Policy denied";
|
|
229
268
|
throw new PolicyDeniedError(reason);
|
|
230
269
|
}
|
|
231
|
-
// d. PII check
|
|
270
|
+
// d. PII check + redact-egress
|
|
232
271
|
if (config.pii !== "off") {
|
|
233
|
-
const piiResult = detectPII(
|
|
272
|
+
const piiResult = detectPII(promptParts);
|
|
234
273
|
if (piiResult.found && config.pii === "block") {
|
|
274
|
+
// block mode: throw BEFORE any egress.
|
|
235
275
|
throw new PolicyDeniedError(`PII detected: ${piiResult.types.join(", ")}`);
|
|
236
276
|
}
|
|
237
|
-
|
|
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).
|
|
238
284
|
}
|
|
239
285
|
// d2. Injection detection
|
|
240
286
|
if (config.injection !== "off") {
|
|
241
|
-
const injectionResult = detectInjection(
|
|
287
|
+
const injectionResult = detectInjection(promptParts);
|
|
242
288
|
if (injectionResult.detected) {
|
|
243
289
|
if (config.injection === "block") {
|
|
244
290
|
throw new PolicyDeniedError(`Prompt injection detected: ${injectionResult.patterns.join(", ")}`);
|
|
@@ -285,7 +331,16 @@ export async function trust(client, opts) {
|
|
|
285
331
|
});
|
|
286
332
|
}
|
|
287
333
|
catch (holdErr) {
|
|
288
|
-
//
|
|
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.
|
|
289
344
|
throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
|
|
290
345
|
}
|
|
291
346
|
}
|
|
@@ -297,10 +352,11 @@ export async function trust(client, opts) {
|
|
|
297
352
|
// AUD-453: Release lock after budget check + hold are complete
|
|
298
353
|
releaseBudgetLock();
|
|
299
354
|
}
|
|
300
|
-
// 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.
|
|
301
357
|
let settled = true;
|
|
302
358
|
try {
|
|
303
|
-
const response = await originalFn.apply(thisArg,
|
|
359
|
+
const response = await originalFn.apply(thisArg, forwardArgs);
|
|
304
360
|
// e2. Streaming detection: if response is an async iterable, wrap with
|
|
305
361
|
// token accumulation. Settlement and audit happen when the stream ends.
|
|
306
362
|
if (response != null &&
|
|
@@ -366,7 +422,8 @@ export async function trust(client, opts) {
|
|
|
366
422
|
}
|
|
367
423
|
else if (engine != null && !isDryRun) {
|
|
368
424
|
try {
|
|
369
|
-
|
|
425
|
+
// Post the ACTUAL consumed cost (RECON #3).
|
|
426
|
+
await engine.postPendingSpend(transferId, streamCost);
|
|
370
427
|
}
|
|
371
428
|
catch (postErr) {
|
|
372
429
|
settled = false;
|
|
@@ -400,7 +457,7 @@ export async function trust(client, opts) {
|
|
|
400
457
|
chunksDelivered: completion.chunksDelivered,
|
|
401
458
|
};
|
|
402
459
|
if (config.pii === "warn" || config.pii === "redact") {
|
|
403
|
-
const piiResult = redactPII(
|
|
460
|
+
const piiResult = redactPII(promptParts);
|
|
404
461
|
if (piiResult.detection.found) {
|
|
405
462
|
auditEventData.piiDetected = piiResult.detection.types;
|
|
406
463
|
auditEventData.piiPaths = piiResult.detection.paths;
|
|
@@ -416,6 +473,13 @@ export async function trust(client, opts) {
|
|
|
416
473
|
catch {
|
|
417
474
|
callAuditDegraded = true;
|
|
418
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
|
+
}
|
|
419
483
|
const streamReceipt = {
|
|
420
484
|
transferId,
|
|
421
485
|
cost: streamCost,
|
|
@@ -541,10 +605,51 @@ export async function trust(client, opts) {
|
|
|
541
605
|
actualCost = estimateCost(model, inputTokens, outputTokens, customRates);
|
|
542
606
|
}
|
|
543
607
|
}
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
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.
|
|
548
653
|
if (holdActive) {
|
|
549
654
|
holdActive = false;
|
|
550
655
|
const releaseLock = await budgetMutex.acquire();
|
|
@@ -559,7 +664,8 @@ export async function trust(client, opts) {
|
|
|
559
664
|
// g2. Failure mode 15.1: POST fails after LLM success
|
|
560
665
|
if (engine != null && !isDryRun) {
|
|
561
666
|
try {
|
|
562
|
-
|
|
667
|
+
// Post the ACTUAL consumed cost (RECON #3).
|
|
668
|
+
await engine.postPendingSpend(transferId, actualCost);
|
|
563
669
|
}
|
|
564
670
|
catch (postErr) {
|
|
565
671
|
// POST failed — LLM call succeeded but settlement is ambiguous
|
|
@@ -607,34 +713,11 @@ export async function trust(client, opts) {
|
|
|
607
713
|
});
|
|
608
714
|
}
|
|
609
715
|
}
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
model,
|
|
616
|
-
cost: actualCost,
|
|
617
|
-
settled,
|
|
618
|
-
transferId,
|
|
619
|
-
};
|
|
620
|
-
if (config.pii === "warn" || config.pii === "redact") {
|
|
621
|
-
const piiResult = redactPII(messages);
|
|
622
|
-
if (piiResult.detection.found) {
|
|
623
|
-
auditData.piiDetected = piiResult.detection.types;
|
|
624
|
-
auditData.piiPaths = piiResult.detection.paths;
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
const auditEvent = await audit.appendEvent({
|
|
628
|
-
kind: "llm_call",
|
|
629
|
-
actor: "local",
|
|
630
|
-
data: auditData,
|
|
631
|
-
});
|
|
632
|
-
auditHash = auditEvent.hash;
|
|
633
|
-
}
|
|
634
|
-
catch {
|
|
635
|
-
// Failure mode 15.3: Audit degraded — do not fail the response
|
|
636
|
-
callAuditDegraded = true;
|
|
637
|
-
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}`);
|
|
638
721
|
}
|
|
639
722
|
// i. Daily-rotated audit receipt (non-blocking)
|
|
640
723
|
if (config.audit.rotation !== "none") {
|
|
@@ -647,7 +730,7 @@ export async function trust(client, opts) {
|
|
|
647
730
|
}
|
|
648
731
|
// i2. Pattern memory
|
|
649
732
|
if (config.patterns.enabled) {
|
|
650
|
-
const promptHash = createHash("sha256").update(JSON.stringify(
|
|
733
|
+
const promptHash = createHash("sha256").update(JSON.stringify(promptParts)).digest("hex");
|
|
651
734
|
await recordPattern({
|
|
652
735
|
promptHash,
|
|
653
736
|
model,
|
|
@@ -718,7 +801,7 @@ export async function trust(client, opts) {
|
|
|
718
801
|
});
|
|
719
802
|
// l. Pattern memory: record failure
|
|
720
803
|
if (config.patterns.enabled) {
|
|
721
|
-
const promptHash = createHash("sha256").update(JSON.stringify(
|
|
804
|
+
const promptHash = createHash("sha256").update(JSON.stringify(promptParts)).digest("hex");
|
|
722
805
|
await recordPattern({
|
|
723
806
|
promptHash,
|
|
724
807
|
model,
|
|
@@ -756,13 +839,17 @@ export async function trust(client, opts) {
|
|
|
756
839
|
let proxyTransferId;
|
|
757
840
|
try {
|
|
758
841
|
// c. Policy gate — action fields available in context
|
|
759
|
-
// 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).
|
|
760
846
|
const policyResult = evaluatePolicy(policyRules, {
|
|
761
847
|
...(action.params ?? {}),
|
|
762
848
|
action_kind: action.kind,
|
|
763
849
|
action_name: action.name,
|
|
764
850
|
estimated_cost: action.cost,
|
|
765
851
|
budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
|
|
852
|
+
budget_remaining_after: config.budget - budgetSpent - inFlightHoldTotal - action.cost,
|
|
766
853
|
tier: config.tier,
|
|
767
854
|
});
|
|
768
855
|
if (policyResult.decision === "deny") {
|
|
@@ -820,6 +907,11 @@ export async function trust(client, opts) {
|
|
|
820
907
|
});
|
|
821
908
|
}
|
|
822
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
|
+
}
|
|
823
915
|
throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
|
|
824
916
|
}
|
|
825
917
|
}
|
|
@@ -844,7 +936,52 @@ export async function trust(client, opts) {
|
|
|
844
936
|
}
|
|
845
937
|
try {
|
|
846
938
|
const result = await execute();
|
|
847
|
-
//
|
|
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.
|
|
848
985
|
await releaseHoldAndCommit(action.cost);
|
|
849
986
|
await persistSpendLedger(vaultBase, budgetSpent);
|
|
850
987
|
// g. Circuit breaker: record success
|
|
@@ -853,7 +990,8 @@ export async function trust(client, opts) {
|
|
|
853
990
|
let settled = true;
|
|
854
991
|
if (engine != null && !isDryRun) {
|
|
855
992
|
try {
|
|
856
|
-
|
|
993
|
+
// Post the ACTUAL cost (RECON #3).
|
|
994
|
+
await engine.postPendingSpend(transferId, action.cost);
|
|
857
995
|
}
|
|
858
996
|
catch (postErr) {
|
|
859
997
|
settled = false;
|
|
@@ -901,38 +1039,10 @@ export async function trust(client, opts) {
|
|
|
901
1039
|
});
|
|
902
1040
|
}
|
|
903
1041
|
}
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
if (
|
|
907
|
-
|
|
908
|
-
const redacted = redactPII(action.params);
|
|
909
|
-
auditParams = redacted.data;
|
|
910
|
-
}
|
|
911
|
-
else {
|
|
912
|
-
auditParams = action.params;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
// i2. Audit event
|
|
916
|
-
const syntheticHash = createHash("sha256").update(transferId).digest("hex");
|
|
917
|
-
let auditHash = syntheticHash;
|
|
918
|
-
try {
|
|
919
|
-
const auditEvent = await audit.appendEvent({
|
|
920
|
-
kind: action.kind,
|
|
921
|
-
actor,
|
|
922
|
-
data: {
|
|
923
|
-
actionName: action.name,
|
|
924
|
-
cost: action.cost,
|
|
925
|
-
settled,
|
|
926
|
-
transferId,
|
|
927
|
-
...(auditParams != null ? { params: auditParams } : {}),
|
|
928
|
-
},
|
|
929
|
-
});
|
|
930
|
-
auditHash = auditEvent.hash;
|
|
931
|
-
}
|
|
932
|
-
catch {
|
|
933
|
-
// Failure mode 15.3: Audit degraded — do not fail the response
|
|
934
|
-
callAuditDegraded = true;
|
|
935
|
-
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}`);
|
|
936
1046
|
}
|
|
937
1047
|
// j. Daily-rotated receipt
|
|
938
1048
|
if (config.audit.rotation !== "none") {
|
|
@@ -1071,44 +1181,103 @@ export async function trust(client, opts) {
|
|
|
1071
1181
|
process.on("beforeExit", beforeExitHandler);
|
|
1072
1182
|
return governedClient;
|
|
1073
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
|
+
}
|
|
1074
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
|
+
}
|
|
1075
1217
|
/**
|
|
1076
|
-
* Create a TrustEngine backed by a real TigerBeetle client.
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
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).
|
|
1079
1234
|
*/
|
|
1080
|
-
async function createTBEngine(config) {
|
|
1235
|
+
async function createTBEngine(config, seedBudget) {
|
|
1081
1236
|
const tbAddresses = config.tigerbeetle.addresses;
|
|
1082
1237
|
const tbClusterId = BigInt(config.tigerbeetle.clusterId);
|
|
1083
1238
|
const tbClient = new TrustTBClient({
|
|
1084
1239
|
addresses: tbAddresses,
|
|
1085
1240
|
clusterId: tbClusterId,
|
|
1086
1241
|
});
|
|
1087
|
-
//
|
|
1242
|
+
// Treasury (unconstrained) funds a per-session enforcing holding wallet.
|
|
1088
1243
|
await tbClient.createTreasury();
|
|
1089
|
-
|
|
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);
|
|
1090
1250
|
// Pending transfer ID mapping (trustId string -> TB bigint)
|
|
1091
1251
|
const pendingMap = new Map();
|
|
1092
1252
|
return {
|
|
1093
1253
|
async spendPending(params) {
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
+
}
|
|
1105
1272
|
},
|
|
1106
|
-
async postPendingSpend(transferId) {
|
|
1273
|
+
async postPendingSpend(transferId, actualAmount) {
|
|
1107
1274
|
const tbId = pendingMap.get(transferId);
|
|
1108
1275
|
if (tbId === undefined) {
|
|
1109
1276
|
throw new Error(`No pending transfer found for ${transferId}`);
|
|
1110
1277
|
}
|
|
1111
|
-
|
|
1278
|
+
// Post the ACTUAL consumed amount (≤ the reserved estimate); omitting it
|
|
1279
|
+
// posts the full pending amount.
|
|
1280
|
+
await tbClient.postTransfer(tbId, actualAmount);
|
|
1112
1281
|
pendingMap.delete(transferId);
|
|
1113
1282
|
},
|
|
1114
1283
|
async voidPendingSpend(transferId) {
|