usertrust 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/canonical.d.ts.map +1 -1
- package/dist/audit/canonical.js +11 -0
- package/dist/audit/canonical.js.map +1 -1
- package/dist/audit/chain.d.ts +1 -1
- package/dist/audit/chain.d.ts.map +1 -1
- package/dist/audit/chain.js +104 -57
- package/dist/audit/chain.js.map +1 -1
- package/dist/audit/rotation.d.ts +1 -1
- package/dist/audit/rotation.js +1 -1
- package/dist/audit/verify.d.ts +1 -0
- package/dist/audit/verify.d.ts.map +1 -1
- package/dist/audit/verify.js +13 -3
- package/dist/audit/verify.js.map +1 -1
- package/dist/board/board.d.ts +1 -1
- package/dist/board/board.js +1 -1
- package/dist/detect.d.ts +19 -2
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js +7 -2
- package/dist/detect.js.map +1 -1
- package/dist/govern.d.ts +2 -0
- package/dist/govern.d.ts.map +1 -1
- package/dist/govern.js +378 -260
- package/dist/govern.js.map +1 -1
- package/dist/ledger/engine.d.ts +1 -1
- package/dist/ledger/engine.d.ts.map +1 -1
- package/dist/ledger/engine.js +35 -12
- package/dist/ledger/engine.js.map +1 -1
- package/dist/memory/patterns.d.ts +4 -1
- package/dist/memory/patterns.d.ts.map +1 -1
- package/dist/memory/patterns.js +46 -14
- package/dist/memory/patterns.js.map +1 -1
- package/dist/policy/gate.d.ts.map +1 -1
- package/dist/policy/gate.js +38 -6
- package/dist/policy/gate.js.map +1 -1
- package/dist/proxy.d.ts +3 -0
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +14 -0
- package/dist/proxy.js.map +1 -1
- package/dist/resilience/scope.d.ts +32 -4
- package/dist/resilience/scope.d.ts.map +1 -1
- package/dist/resilience/scope.js +83 -35
- package/dist/resilience/scope.js.map +1 -1
- package/package.json +2 -4
package/dist/govern.js
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
29
|
import { createHash } from "node:crypto";
|
|
30
|
-
import { existsSync } from "node:fs";
|
|
31
|
-
import { readFile } from "node:fs/promises";
|
|
30
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
31
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
32
32
|
import { join } from "node:path";
|
|
33
33
|
import { createAuditWriter } from "./audit/chain.js";
|
|
34
34
|
import { writeReceipt } from "./audit/rotation.js";
|
|
@@ -36,16 +36,71 @@ 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 { DecayRateCalculator } from "./policy/decay.js";
|
|
40
39
|
import { evaluatePolicy, loadPolicies } from "./policy/gate.js";
|
|
41
40
|
import { detectPII } from "./policy/pii.js";
|
|
42
41
|
import { connectProxy } from "./proxy.js";
|
|
43
42
|
import { CircuitBreakerRegistry } from "./resilience/circuit.js";
|
|
44
43
|
import { DEFAULT_BUDGET, VAULT_DIR } from "./shared/constants.js";
|
|
44
|
+
/** Base URL for receipt verification links (used in proxy mode). */
|
|
45
|
+
const VERIFY_URL_BASE = "https://verify.usertrust.dev";
|
|
45
46
|
import { LedgerUnavailableError, PolicyDeniedError } from "./shared/errors.js";
|
|
46
47
|
import { trustId } from "./shared/ids.js";
|
|
47
48
|
import { TrustConfigSchema } from "./shared/types.js";
|
|
48
49
|
import { createGovernedStream } from "./streaming.js";
|
|
50
|
+
// ── AUD-453: Async mutex for budget atomicity ──
|
|
51
|
+
// Prevents concurrent interceptCall invocations from racing through
|
|
52
|
+
// the budget-check + PENDING hold sequence.
|
|
53
|
+
class AsyncMutex {
|
|
54
|
+
queue = Promise.resolve();
|
|
55
|
+
async acquire() {
|
|
56
|
+
let release;
|
|
57
|
+
const next = new Promise((resolve) => {
|
|
58
|
+
release = resolve;
|
|
59
|
+
});
|
|
60
|
+
const prev = this.queue;
|
|
61
|
+
this.queue = next;
|
|
62
|
+
await prev;
|
|
63
|
+
return release;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function loadSpendLedger(vaultBase) {
|
|
67
|
+
const ledgerPath = join(vaultBase, VAULT_DIR, "spend-ledger.json");
|
|
68
|
+
try {
|
|
69
|
+
const raw = await readFile(ledgerPath, "utf-8");
|
|
70
|
+
const parsed = JSON.parse(raw);
|
|
71
|
+
if (parsed != null &&
|
|
72
|
+
typeof parsed === "object" &&
|
|
73
|
+
"budgetSpent" in parsed &&
|
|
74
|
+
typeof parsed.budgetSpent === "number") {
|
|
75
|
+
return parsed.budgetSpent;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// No ledger file or corrupt — start from zero
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
async function persistSpendLedger(vaultBase, budgetSpent) {
|
|
84
|
+
const dir = join(vaultBase, VAULT_DIR);
|
|
85
|
+
const ledgerPath = join(dir, "spend-ledger.json");
|
|
86
|
+
const tmpPath = join(dir, "spend-ledger.json.tmp");
|
|
87
|
+
const data = {
|
|
88
|
+
budgetSpent,
|
|
89
|
+
updatedAt: new Date().toISOString(),
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
// Ensure vault dir exists
|
|
93
|
+
if (!existsSync(dir)) {
|
|
94
|
+
mkdirSync(dir, { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
// Atomic write: write tmp then rename
|
|
97
|
+
await writeFile(tmpPath, JSON.stringify(data), "utf-8");
|
|
98
|
+
await rename(tmpPath, ledgerPath);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Best-effort — do not fail the LLM call over ledger persistence
|
|
102
|
+
}
|
|
103
|
+
}
|
|
49
104
|
// ── trust() ──
|
|
50
105
|
export async function trust(client, opts) {
|
|
51
106
|
// 1. Load config
|
|
@@ -65,26 +120,27 @@ export async function trust(client, opts) {
|
|
|
65
120
|
});
|
|
66
121
|
}
|
|
67
122
|
const isDryRun = opts?.dryRun ?? process.env.USERTRUST_DRY_RUN === "true";
|
|
123
|
+
// AUD-470: Only accept injected _engine/_audit in test environments.
|
|
124
|
+
// In production, silently ignore them to prevent governance bypass.
|
|
125
|
+
const isTestEnv = process.env.USERTRUST_TEST === "1" || process.env.NODE_ENV === "test";
|
|
68
126
|
// 2. Initialise subsystems
|
|
69
127
|
const vaultPath = vaultBase;
|
|
70
|
-
const audit = opts?._audit ?? createAuditWriter(vaultPath);
|
|
128
|
+
const audit = (isTestEnv ? opts?._audit : undefined) ?? createAuditWriter(vaultPath);
|
|
71
129
|
const policiesPath = join(vaultPath, VAULT_DIR, config.policies);
|
|
72
130
|
const policyRules = existsSync(policiesPath) ? loadPolicies(policiesPath) : [];
|
|
73
131
|
const breaker = new CircuitBreakerRegistry({
|
|
74
132
|
failureThreshold: config.circuitBreaker.failureThreshold,
|
|
75
133
|
resetTimeoutMs: config.circuitBreaker.resetTimeout,
|
|
76
134
|
});
|
|
77
|
-
// Decay-weighted budget calculator (1-hour half-life)
|
|
78
|
-
const decayCalc = new DecayRateCalculator({ halfLifeMs: 3_600_000 });
|
|
79
|
-
const spendHistory = [];
|
|
80
135
|
// 3. Proxy connection (if proxy mode)
|
|
81
136
|
let proxyConn = null;
|
|
82
137
|
if (opts?.proxy) {
|
|
83
138
|
proxyConn = connectProxy(opts.proxy, opts.key);
|
|
84
139
|
}
|
|
85
140
|
// 4. Engine (injected for tests, real TB client in production, null in dry-run/proxy)
|
|
141
|
+
// AUD-470: _engine injection only accepted in test environments
|
|
86
142
|
let engine;
|
|
87
|
-
if (opts?._engine !== undefined) {
|
|
143
|
+
if (isTestEnv && opts?._engine !== undefined) {
|
|
88
144
|
engine = opts._engine;
|
|
89
145
|
}
|
|
90
146
|
else if (!isDryRun && proxyConn == null) {
|
|
@@ -102,306 +158,342 @@ export async function trust(client, opts) {
|
|
|
102
158
|
const kind = detectClientKind(client);
|
|
103
159
|
// 6. Track state
|
|
104
160
|
let destroyed = false;
|
|
105
|
-
let budgetSpent =
|
|
161
|
+
let budgetSpent = await loadSpendLedger(vaultBase); // AUD-457: restore from disk
|
|
162
|
+
const budgetMutex = new AsyncMutex(); // AUD-453: serialise budget-check + hold
|
|
163
|
+
let inFlightCount = 0; // AUD-462: track in-flight calls for graceful destroy
|
|
106
164
|
// 7. Two-phase intercept
|
|
107
165
|
async function interceptCall(originalFn, thisArg, args) {
|
|
108
166
|
if (destroyed) {
|
|
109
167
|
throw new Error("TrustedClient has been destroyed");
|
|
110
168
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
model,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const reason = policyResult.reasons.length > 0 ? policyResult.reasons.join("; ") : "Policy denied";
|
|
134
|
-
throw new PolicyDeniedError(reason);
|
|
135
|
-
}
|
|
136
|
-
// d. PII check
|
|
137
|
-
if (config.pii !== "off") {
|
|
138
|
-
const piiResult = detectPII(messages);
|
|
139
|
-
if (piiResult.found && config.pii === "block") {
|
|
140
|
-
throw new PolicyDeniedError(`PII detected: ${piiResult.types.join(", ")}`);
|
|
141
|
-
}
|
|
142
|
-
// "warn" and "redact" modes: continue (redact is not implemented at SDK level)
|
|
143
|
-
}
|
|
144
|
-
// d2. Failure mode 15.4: TigerBeetle / engine unreachable — PENDING hold
|
|
145
|
-
if (proxyConn != null && !isDryRun) {
|
|
169
|
+
// AUD-462: Track in-flight calls so destroy() can wait for them
|
|
170
|
+
inFlightCount++;
|
|
171
|
+
try {
|
|
172
|
+
const params = (args[0] ?? {});
|
|
173
|
+
const model = params.model ?? "unknown";
|
|
174
|
+
const messages = params.messages ?? [];
|
|
175
|
+
// Per-call audit degradation flag (not sticky across calls)
|
|
176
|
+
let callAuditDegraded = false;
|
|
177
|
+
// a. Circuit breaker check
|
|
178
|
+
const cb = breaker.get(kind);
|
|
179
|
+
cb.allowRequest();
|
|
180
|
+
// b. Estimate cost (before policy, so cost fields are available in context)
|
|
181
|
+
const transferId = trustId("tx");
|
|
182
|
+
const estimatedInputTokens = estimateInputTokens(messages);
|
|
183
|
+
const maxOutputTokens = params.max_tokens ?? 4096;
|
|
184
|
+
const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens);
|
|
185
|
+
// AUD-453: Acquire mutex to serialise budget-check + PENDING hold.
|
|
186
|
+
// This prevents concurrent calls from both passing the budget check
|
|
187
|
+
// and overshooting the budget.
|
|
188
|
+
const releaseBudgetLock = await budgetMutex.acquire();
|
|
189
|
+
// AUD-460: Track the proxy's transferId separately for settle/void
|
|
190
|
+
let proxyTransferId;
|
|
146
191
|
try {
|
|
147
|
-
|
|
192
|
+
// c. Policy gate
|
|
193
|
+
const policyResult = evaluatePolicy(policyRules, {
|
|
148
194
|
model,
|
|
149
|
-
|
|
150
|
-
|
|
195
|
+
tier: config.tier,
|
|
196
|
+
estimated_cost: estimatedCost,
|
|
197
|
+
budget_remaining: config.budget - budgetSpent,
|
|
198
|
+
...params,
|
|
151
199
|
});
|
|
200
|
+
if (policyResult.decision === "deny") {
|
|
201
|
+
const reason = policyResult.reasons.length > 0 ? policyResult.reasons.join("; ") : "Policy denied";
|
|
202
|
+
throw new PolicyDeniedError(reason);
|
|
203
|
+
}
|
|
204
|
+
// d. PII check
|
|
205
|
+
if (config.pii !== "off") {
|
|
206
|
+
const piiResult = detectPII(messages);
|
|
207
|
+
if (piiResult.found && config.pii === "block") {
|
|
208
|
+
throw new PolicyDeniedError(`PII detected: ${piiResult.types.join(", ")}`);
|
|
209
|
+
}
|
|
210
|
+
// "warn" and "redact" modes: continue (redact is not implemented at SDK level)
|
|
211
|
+
}
|
|
212
|
+
// d2. Failure mode 15.4: TigerBeetle / engine unreachable — PENDING hold
|
|
213
|
+
if (proxyConn != null && !isDryRun) {
|
|
214
|
+
try {
|
|
215
|
+
// AUD-460: Capture the proxy's returned transferId
|
|
216
|
+
const proxyResult = await proxyConn.spend({
|
|
217
|
+
model,
|
|
218
|
+
estimatedCost,
|
|
219
|
+
actor: "local",
|
|
220
|
+
});
|
|
221
|
+
proxyTransferId = proxyResult.transferId;
|
|
222
|
+
}
|
|
223
|
+
catch (holdErr) {
|
|
224
|
+
throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else if (engine != null && !isDryRun) {
|
|
228
|
+
try {
|
|
229
|
+
await engine.spendPending({
|
|
230
|
+
transferId,
|
|
231
|
+
amount: estimatedCost,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (holdErr) {
|
|
235
|
+
// Ledger unreachable — do NOT forward to provider
|
|
236
|
+
throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
152
239
|
}
|
|
153
|
-
|
|
154
|
-
|
|
240
|
+
finally {
|
|
241
|
+
// AUD-453: Release lock after budget check + hold are complete
|
|
242
|
+
releaseBudgetLock();
|
|
155
243
|
}
|
|
156
|
-
|
|
157
|
-
|
|
244
|
+
// e. Forward to original SDK
|
|
245
|
+
let settled = true;
|
|
158
246
|
try {
|
|
159
|
-
await
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const streamCost = estimateCost(model, usage.inputTokens, usage.outputTokens);
|
|
181
|
-
budgetSpent += streamCost;
|
|
182
|
-
cb.recordSuccess();
|
|
183
|
-
if (proxyConn != null && !isDryRun) {
|
|
184
|
-
try {
|
|
185
|
-
await proxyConn.settle(transferId, streamCost);
|
|
247
|
+
const response = await originalFn.apply(thisArg, args);
|
|
248
|
+
// e2. Streaming detection: if response is an async iterable, wrap with
|
|
249
|
+
// token accumulation. Settlement and audit happen when the stream ends.
|
|
250
|
+
if (response != null &&
|
|
251
|
+
typeof response === "object" &&
|
|
252
|
+
Symbol.asyncIterator in response) {
|
|
253
|
+
const stream = response;
|
|
254
|
+
const governedStream = createGovernedStream(stream, kind, async (usage) => {
|
|
255
|
+
const streamCost = estimateCost(model, usage.inputTokens, usage.outputTokens);
|
|
256
|
+
budgetSpent += streamCost;
|
|
257
|
+
// AUD-457: Persist cumulative spend to disk
|
|
258
|
+
await persistSpendLedger(vaultBase, budgetSpent);
|
|
259
|
+
cb.recordSuccess();
|
|
260
|
+
if (proxyConn != null && !isDryRun) {
|
|
261
|
+
try {
|
|
262
|
+
// AUD-460: Use the proxy's transferId for settlement
|
|
263
|
+
await proxyConn.settle(proxyTransferId ?? transferId, streamCost);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
settled = false;
|
|
267
|
+
}
|
|
186
268
|
}
|
|
187
|
-
|
|
188
|
-
|
|
269
|
+
else if (engine != null && !isDryRun) {
|
|
270
|
+
try {
|
|
271
|
+
await engine.postPendingSpend(transferId);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
settled = false;
|
|
275
|
+
}
|
|
189
276
|
}
|
|
190
|
-
|
|
191
|
-
|
|
277
|
+
const syntheticHash = createHash("sha256").update(transferId).digest("hex");
|
|
278
|
+
let auditHash = syntheticHash;
|
|
192
279
|
try {
|
|
193
|
-
await
|
|
280
|
+
const auditEvent = await audit.appendEvent({
|
|
281
|
+
kind: "llm_call",
|
|
282
|
+
actor: "local",
|
|
283
|
+
data: { model, cost: streamCost, settled, transferId },
|
|
284
|
+
});
|
|
285
|
+
auditHash = auditEvent.hash;
|
|
194
286
|
}
|
|
195
287
|
catch {
|
|
196
|
-
|
|
288
|
+
callAuditDegraded = true;
|
|
197
289
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
290
|
+
return {
|
|
291
|
+
transferId,
|
|
292
|
+
cost: streamCost,
|
|
293
|
+
budgetRemaining: config.budget - budgetSpent,
|
|
294
|
+
auditHash,
|
|
295
|
+
chainPath: join(VAULT_DIR, "audit"),
|
|
296
|
+
receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
|
|
297
|
+
settled,
|
|
298
|
+
model,
|
|
299
|
+
provider: kind,
|
|
300
|
+
timestamp: new Date().toISOString(),
|
|
301
|
+
...(callAuditDegraded ? { auditDegraded: true } : {}),
|
|
302
|
+
// AUD-456: Flag proxy stub receipts
|
|
303
|
+
...(proxyConn != null ? { proxyStub: true } : {}),
|
|
304
|
+
};
|
|
305
|
+
}, (error) => {
|
|
306
|
+
cb.recordFailure();
|
|
307
|
+
if (proxyConn != null && !isDryRun) {
|
|
308
|
+
// AUD-460: Use the proxy's transferId for void
|
|
309
|
+
proxyConn.void(proxyTransferId ?? transferId).catch(() => { });
|
|
310
|
+
}
|
|
311
|
+
else if (engine != null && !isDryRun) {
|
|
312
|
+
engine.voidPendingSpend(transferId).catch(() => { });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
// AUD-454: For streaming responses, settlement has NOT happened yet.
|
|
316
|
+
// Set settled: false — the real settlement status will be on
|
|
317
|
+
// governedStream.receipt after the stream is fully consumed.
|
|
318
|
+
const auditHash = createHash("sha256").update(transferId).digest("hex");
|
|
319
|
+
const estimatedReceipt = {
|
|
213
320
|
transferId,
|
|
214
|
-
cost:
|
|
321
|
+
cost: estimatedCost,
|
|
215
322
|
budgetRemaining: config.budget - budgetSpent,
|
|
216
323
|
auditHash,
|
|
217
324
|
chainPath: join(VAULT_DIR, "audit"),
|
|
218
|
-
receiptUrl: opts?.proxy != null ?
|
|
219
|
-
settled,
|
|
325
|
+
receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
|
|
326
|
+
settled: false, // AUD-454: not settled yet — stream hasn't been consumed
|
|
220
327
|
model,
|
|
221
328
|
provider: kind,
|
|
222
329
|
timestamp: new Date().toISOString(),
|
|
223
330
|
...(callAuditDegraded ? { auditDegraded: true } : {}),
|
|
331
|
+
// AUD-456: Flag proxy stub receipts
|
|
332
|
+
...(proxyConn != null ? { proxyStub: true } : {}),
|
|
224
333
|
};
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
334
|
+
return { response: governedStream, receipt: estimatedReceipt };
|
|
335
|
+
}
|
|
336
|
+
// f. Compute actual cost from response usage
|
|
337
|
+
let actualCost = estimatedCost;
|
|
338
|
+
if (response != null && typeof response === "object" && "usage" in response) {
|
|
339
|
+
const usage = response.usage;
|
|
340
|
+
if (usage != null) {
|
|
341
|
+
const inputTokens = usage.input_tokens ??
|
|
342
|
+
usage.prompt_tokens ??
|
|
343
|
+
estimatedInputTokens;
|
|
344
|
+
const outputTokens = usage.output_tokens ??
|
|
345
|
+
usage.completion_tokens ??
|
|
346
|
+
0;
|
|
347
|
+
actualCost = estimateCost(model, inputTokens, outputTokens);
|
|
229
348
|
}
|
|
230
|
-
|
|
231
|
-
|
|
349
|
+
}
|
|
350
|
+
// Track budget (cumulative)
|
|
351
|
+
budgetSpent += actualCost;
|
|
352
|
+
// AUD-457: Persist cumulative spend to disk
|
|
353
|
+
await persistSpendLedger(vaultBase, budgetSpent);
|
|
354
|
+
// g. Circuit breaker: record success
|
|
355
|
+
cb.recordSuccess();
|
|
356
|
+
// g2. Failure mode 15.1: POST fails after LLM success
|
|
357
|
+
if (engine != null && !isDryRun) {
|
|
358
|
+
try {
|
|
359
|
+
await engine.postPendingSpend(transferId);
|
|
360
|
+
}
|
|
361
|
+
catch (postErr) {
|
|
362
|
+
// POST failed — LLM call succeeded but settlement is ambiguous
|
|
363
|
+
settled = false;
|
|
364
|
+
await audit
|
|
365
|
+
.appendEvent({
|
|
366
|
+
kind: "settlement_ambiguous",
|
|
367
|
+
actor: "local",
|
|
368
|
+
data: {
|
|
369
|
+
model,
|
|
370
|
+
cost: actualCost,
|
|
371
|
+
transferId,
|
|
372
|
+
error: postErr instanceof Error ? postErr.message : String(postErr),
|
|
373
|
+
},
|
|
374
|
+
})
|
|
375
|
+
.catch(() => {
|
|
376
|
+
// Audit also degraded — nothing more we can do
|
|
377
|
+
callAuditDegraded = true;
|
|
378
|
+
});
|
|
232
379
|
}
|
|
233
|
-
});
|
|
234
|
-
// For streaming responses, return the wrapped stream with an
|
|
235
|
-
// estimated trust receipt. The actual receipt (with real
|
|
236
|
-
// token counts) is available via governedStream.receipt
|
|
237
|
-
// after the stream is fully consumed.
|
|
238
|
-
const auditHash = createHash("sha256").update(transferId).digest("hex");
|
|
239
|
-
const estimatedReceipt = {
|
|
240
|
-
transferId,
|
|
241
|
-
cost: estimatedCost,
|
|
242
|
-
budgetRemaining: config.budget - budgetSpent,
|
|
243
|
-
auditHash,
|
|
244
|
-
chainPath: join(VAULT_DIR, "audit"),
|
|
245
|
-
receiptUrl: opts?.proxy != null ? `https://verify.usertools.dev/${transferId}` : null,
|
|
246
|
-
settled,
|
|
247
|
-
model,
|
|
248
|
-
provider: kind,
|
|
249
|
-
timestamp: new Date().toISOString(),
|
|
250
|
-
...(callAuditDegraded ? { auditDegraded: true } : {}),
|
|
251
|
-
};
|
|
252
|
-
return { response: governedStream, receipt: estimatedReceipt };
|
|
253
|
-
}
|
|
254
|
-
// f. Compute actual cost from response usage
|
|
255
|
-
let actualCost = estimatedCost;
|
|
256
|
-
if (response != null && typeof response === "object" && "usage" in response) {
|
|
257
|
-
const usage = response.usage;
|
|
258
|
-
if (usage != null) {
|
|
259
|
-
const inputTokens = usage.input_tokens ??
|
|
260
|
-
usage.prompt_tokens ??
|
|
261
|
-
estimatedInputTokens;
|
|
262
|
-
const outputTokens = usage.output_tokens ??
|
|
263
|
-
usage.completion_tokens ??
|
|
264
|
-
0;
|
|
265
|
-
actualCost = estimateCost(model, inputTokens, outputTokens);
|
|
266
380
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
await engine.postPendingSpend(transferId);
|
|
381
|
+
// g3. Proxy settlement
|
|
382
|
+
if (proxyConn != null && !isDryRun) {
|
|
383
|
+
try {
|
|
384
|
+
// AUD-460: Use the proxy's transferId for settlement
|
|
385
|
+
await proxyConn.settle(proxyTransferId ?? transferId, actualCost);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
settled = false;
|
|
389
|
+
}
|
|
277
390
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
kind: "
|
|
391
|
+
// h. Audit event — failure mode 15.3: audit write failure
|
|
392
|
+
const syntheticHash = createHash("sha256").update(transferId).digest("hex");
|
|
393
|
+
let auditHash = syntheticHash;
|
|
394
|
+
try {
|
|
395
|
+
const auditEvent = await audit.appendEvent({
|
|
396
|
+
kind: "llm_call",
|
|
284
397
|
actor: "local",
|
|
285
398
|
data: {
|
|
286
399
|
model,
|
|
287
400
|
cost: actualCost,
|
|
401
|
+
settled,
|
|
288
402
|
transferId,
|
|
289
|
-
error: postErr instanceof Error ? postErr.message : String(postErr),
|
|
290
403
|
},
|
|
291
|
-
})
|
|
292
|
-
.catch(() => {
|
|
293
|
-
// Audit also degraded — nothing more we can do
|
|
294
|
-
callAuditDegraded = true;
|
|
295
404
|
});
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
// g3. Proxy settlement
|
|
299
|
-
if (proxyConn != null && !isDryRun) {
|
|
300
|
-
try {
|
|
301
|
-
await proxyConn.settle(transferId, actualCost);
|
|
405
|
+
auditHash = auditEvent.hash;
|
|
302
406
|
}
|
|
303
407
|
catch {
|
|
304
|
-
|
|
408
|
+
// Failure mode 15.3: Audit degraded — do not fail the response
|
|
409
|
+
callAuditDegraded = true;
|
|
410
|
+
process.stderr.write(`[usertrust] audit degraded: failed to write llm_call event for ${transferId}\n`);
|
|
305
411
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
412
|
+
// i. Daily-rotated audit receipt (non-blocking)
|
|
413
|
+
if (config.audit.rotation !== "none") {
|
|
414
|
+
writeReceipt(vaultPath, {
|
|
415
|
+
kind: "llm_call",
|
|
416
|
+
subsystem: "trust",
|
|
417
|
+
actor: "local",
|
|
418
|
+
data: { model, cost: actualCost, settled, transferId },
|
|
419
|
+
}, config.audit.indexLimit);
|
|
420
|
+
}
|
|
421
|
+
// i2. Pattern memory
|
|
422
|
+
if (config.patterns.enabled) {
|
|
423
|
+
const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
|
|
424
|
+
await recordPattern({
|
|
425
|
+
promptHash,
|
|
315
426
|
model,
|
|
316
427
|
cost: actualCost,
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
catch {
|
|
324
|
-
// Failure mode 15.3: Audit degraded — do not fail the response
|
|
325
|
-
callAuditDegraded = true;
|
|
326
|
-
process.stderr.write(`[usertrust] audit degraded: failed to write llm_call event for ${transferId}\n`);
|
|
327
|
-
}
|
|
328
|
-
// i. Daily-rotated audit receipt (non-blocking)
|
|
329
|
-
if (config.audit.rotation !== "none") {
|
|
330
|
-
writeReceipt(vaultPath, {
|
|
331
|
-
kind: "llm_call",
|
|
332
|
-
subsystem: "trust",
|
|
333
|
-
actor: "local",
|
|
334
|
-
data: { model, cost: actualCost, settled, transferId },
|
|
335
|
-
}, config.audit.indexLimit);
|
|
336
|
-
}
|
|
337
|
-
// i2. Pattern memory
|
|
338
|
-
if (config.patterns.enabled) {
|
|
339
|
-
const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
|
|
340
|
-
await recordPattern({
|
|
341
|
-
promptHash,
|
|
342
|
-
model,
|
|
428
|
+
success: true,
|
|
429
|
+
}).catch(() => { });
|
|
430
|
+
}
|
|
431
|
+
const budgetRemaining = config.budget - budgetSpent;
|
|
432
|
+
const receipt = {
|
|
433
|
+
transferId,
|
|
343
434
|
cost: actualCost,
|
|
344
|
-
|
|
345
|
-
|
|
435
|
+
budgetRemaining,
|
|
436
|
+
auditHash,
|
|
437
|
+
chainPath: join(VAULT_DIR, "audit"),
|
|
438
|
+
receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
|
|
439
|
+
settled,
|
|
440
|
+
model,
|
|
441
|
+
provider: kind,
|
|
442
|
+
timestamp: new Date().toISOString(),
|
|
443
|
+
...(callAuditDegraded ? { auditDegraded: true } : {}),
|
|
444
|
+
// AUD-456: Flag proxy stub receipts
|
|
445
|
+
...(proxyConn != null ? { proxyStub: true } : {}),
|
|
446
|
+
};
|
|
447
|
+
return { response, receipt };
|
|
346
448
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
timestamp: new Date().toISOString(),
|
|
359
|
-
...(callAuditDegraded ? { auditDegraded: true } : {}),
|
|
360
|
-
};
|
|
361
|
-
return { response, receipt };
|
|
362
|
-
}
|
|
363
|
-
catch (err) {
|
|
364
|
-
// j. Circuit breaker: record failure
|
|
365
|
-
cb.recordFailure();
|
|
366
|
-
// j2. Failure mode 15.2: LLM fails — VOID the pending hold
|
|
367
|
-
if (engine != null && !isDryRun) {
|
|
368
|
-
try {
|
|
369
|
-
await engine.voidPendingSpend(transferId);
|
|
370
|
-
}
|
|
371
|
-
catch {
|
|
372
|
-
// Best-effort void — log and continue
|
|
449
|
+
catch (err) {
|
|
450
|
+
// j. Circuit breaker: record failure
|
|
451
|
+
cb.recordFailure();
|
|
452
|
+
// j2. Failure mode 15.2: LLM fails — VOID the pending hold
|
|
453
|
+
if (engine != null && !isDryRun) {
|
|
454
|
+
try {
|
|
455
|
+
await engine.voidPendingSpend(transferId);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// Best-effort void — log and continue
|
|
459
|
+
}
|
|
373
460
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
461
|
+
// j3. Proxy void
|
|
462
|
+
if (proxyConn != null && !isDryRun) {
|
|
463
|
+
try {
|
|
464
|
+
// AUD-460: Use the proxy's transferId for void
|
|
465
|
+
await proxyConn.void(proxyTransferId ?? transferId);
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
// Best-effort void
|
|
469
|
+
}
|
|
379
470
|
}
|
|
380
|
-
|
|
381
|
-
|
|
471
|
+
// k. Audit the failure
|
|
472
|
+
await audit
|
|
473
|
+
.appendEvent({
|
|
474
|
+
kind: "llm_call_failed",
|
|
475
|
+
actor: "local",
|
|
476
|
+
data: { model, error: String(err), transferId },
|
|
477
|
+
})
|
|
478
|
+
.catch(() => {
|
|
479
|
+
callAuditDegraded = true;
|
|
480
|
+
});
|
|
481
|
+
// l. Pattern memory: record failure
|
|
482
|
+
if (config.patterns.enabled) {
|
|
483
|
+
const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
|
|
484
|
+
await recordPattern({
|
|
485
|
+
promptHash,
|
|
486
|
+
model,
|
|
487
|
+
cost: 0,
|
|
488
|
+
success: false,
|
|
489
|
+
}).catch(() => { });
|
|
382
490
|
}
|
|
491
|
+
throw err;
|
|
383
492
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
actor: "local",
|
|
389
|
-
data: { model, error: String(err), transferId },
|
|
390
|
-
})
|
|
391
|
-
.catch(() => {
|
|
392
|
-
callAuditDegraded = true;
|
|
393
|
-
});
|
|
394
|
-
// l. Pattern memory: record failure
|
|
395
|
-
if (config.patterns.enabled) {
|
|
396
|
-
const promptHash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
|
|
397
|
-
await recordPattern({
|
|
398
|
-
promptHash,
|
|
399
|
-
model,
|
|
400
|
-
cost: 0,
|
|
401
|
-
success: false,
|
|
402
|
-
}).catch(() => { });
|
|
403
|
-
}
|
|
404
|
-
throw err;
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
// AUD-462: Decrement in-flight count so destroy() knows when it's safe
|
|
496
|
+
inFlightCount--;
|
|
405
497
|
}
|
|
406
498
|
}
|
|
407
499
|
// 8. Safety net: clean up on process exit if destroy() was never called
|
|
@@ -412,11 +504,23 @@ export async function trust(client, opts) {
|
|
|
412
504
|
if (destroyed)
|
|
413
505
|
return;
|
|
414
506
|
destroyed = true;
|
|
507
|
+
// AUD-462: Wait up to 5 seconds for in-flight calls to complete.
|
|
508
|
+
// After the deadline, proceed with teardown anyway.
|
|
509
|
+
const deadline = Date.now() + 5_000;
|
|
510
|
+
while (inFlightCount > 0 && Date.now() < deadline) {
|
|
511
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
512
|
+
}
|
|
415
513
|
// Remove beforeExit safety net
|
|
416
514
|
if (beforeExitHandler != null) {
|
|
417
515
|
process.removeListener("beforeExit", beforeExitHandler);
|
|
418
516
|
beforeExitHandler = null;
|
|
419
517
|
}
|
|
518
|
+
// AUD-461: Void any remaining pending transfers (best-effort).
|
|
519
|
+
// TigerBeetle auto-voids pending transfers after 300s, but
|
|
520
|
+
// explicit voiding releases holds immediately.
|
|
521
|
+
if (engine != null && typeof engine.voidAllPending === "function") {
|
|
522
|
+
await engine.voidAllPending();
|
|
523
|
+
}
|
|
420
524
|
// Flush audit writes
|
|
421
525
|
await audit.flush();
|
|
422
526
|
// Release audit lock
|
|
@@ -496,6 +600,20 @@ async function createTBEngine(config) {
|
|
|
496
600
|
await tbClient.voidTransfer(tbId);
|
|
497
601
|
pendingMap.delete(transferId);
|
|
498
602
|
},
|
|
603
|
+
// AUD-461: Void all remaining pending transfers on destroy.
|
|
604
|
+
// Best-effort — TigerBeetle auto-voids after 300s regardless.
|
|
605
|
+
async voidAllPending() {
|
|
606
|
+
const entries = [...pendingMap.entries()];
|
|
607
|
+
for (const [trustIdKey, tbTransferId] of entries) {
|
|
608
|
+
try {
|
|
609
|
+
await tbClient.voidTransfer(tbTransferId);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
// Best-effort — ignore individual void failures
|
|
613
|
+
}
|
|
614
|
+
pendingMap.delete(trustIdKey);
|
|
615
|
+
}
|
|
616
|
+
},
|
|
499
617
|
destroy() {
|
|
500
618
|
tbClient.destroy();
|
|
501
619
|
},
|