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