usertrust 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +415 -47
- 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 +264 -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/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 +13 -2
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";
|
|
@@ -122,6 +124,7 @@ export async function trust(client, opts) {
|
|
|
122
124
|
budget: opts?.budget ?? DEFAULT_BUDGET,
|
|
123
125
|
});
|
|
124
126
|
}
|
|
127
|
+
const customRates = config.pricing === "custom" ? config.customRates : undefined;
|
|
125
128
|
const isDryRun = opts?.dryRun ?? process.env.USERTRUST_DRY_RUN === "true";
|
|
126
129
|
// AUD-470: Only accept injected _engine/_audit in test environments.
|
|
127
130
|
// In production, silently ignore them to prevent governance bypass.
|
|
@@ -130,7 +133,8 @@ export async function trust(client, opts) {
|
|
|
130
133
|
const vaultPath = vaultBase;
|
|
131
134
|
const audit = (isTestEnv ? opts?._audit : undefined) ?? createAuditWriter(vaultPath);
|
|
132
135
|
const policiesPath = join(vaultPath, VAULT_DIR, config.policies);
|
|
133
|
-
const
|
|
136
|
+
const loadedRules = existsSync(policiesPath) ? loadPolicies(policiesPath) : [];
|
|
137
|
+
const policyRules = loadedRules.length > 0 ? loadedRules : DEFAULT_RULES;
|
|
134
138
|
const breaker = new CircuitBreakerRegistry({
|
|
135
139
|
failureThreshold: config.circuitBreaker.failureThreshold,
|
|
136
140
|
resetTimeoutMs: config.circuitBreaker.resetTimeout,
|
|
@@ -186,7 +190,7 @@ export async function trust(client, opts) {
|
|
|
186
190
|
const transferId = trustId("tx");
|
|
187
191
|
const estimatedInputTokens = estimateInputTokens(messages);
|
|
188
192
|
const maxOutputTokens = params.max_tokens ?? 4096;
|
|
189
|
-
const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens);
|
|
193
|
+
const estimatedCost = estimateCost(model, estimatedInputTokens, maxOutputTokens, customRates);
|
|
190
194
|
// AUD-453: Acquire mutex to serialise budget-check + PENDING hold.
|
|
191
195
|
// This prevents concurrent calls from both passing the budget check
|
|
192
196
|
// and overshooting the budget.
|
|
@@ -214,7 +218,28 @@ export async function trust(client, opts) {
|
|
|
214
218
|
}
|
|
215
219
|
// "warn" and "redact" modes: continue (redact is not implemented at SDK level)
|
|
216
220
|
}
|
|
217
|
-
// 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
|
|
218
243
|
if (proxyConn != null && !isDryRun) {
|
|
219
244
|
try {
|
|
220
245
|
// AUD-460: Capture the proxy's returned transferId
|
|
@@ -263,16 +288,20 @@ export async function trust(client, opts) {
|
|
|
263
288
|
let streamCost;
|
|
264
289
|
let usageSource;
|
|
265
290
|
if (completion.usageReported) {
|
|
266
|
-
streamCost = estimateCost(model, completion.usage.inputTokens, completion.usage.outputTokens);
|
|
291
|
+
streamCost = estimateCost(model, completion.usage.inputTokens, completion.usage.outputTokens, customRates);
|
|
267
292
|
usageSource = "provider";
|
|
268
293
|
}
|
|
269
294
|
else {
|
|
270
295
|
streamCost = estimatedCost;
|
|
271
296
|
usageSource = "estimated";
|
|
272
297
|
}
|
|
273
|
-
// Release in-flight hold
|
|
274
|
-
|
|
275
|
-
|
|
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
|
+
}
|
|
276
305
|
// AUD-457: Persist cumulative spend to disk
|
|
277
306
|
await persistSpendLedger(vaultBase, budgetSpent);
|
|
278
307
|
cb.recordSuccess();
|
|
@@ -328,17 +357,25 @@ export async function trust(client, opts) {
|
|
|
328
357
|
const syntheticHash = createHash("sha256").update(transferId).digest("hex");
|
|
329
358
|
let auditHash = syntheticHash;
|
|
330
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
|
+
}
|
|
331
375
|
const auditEvent = await audit.appendEvent({
|
|
332
376
|
kind: "llm_call",
|
|
333
377
|
actor: "local",
|
|
334
|
-
data:
|
|
335
|
-
model,
|
|
336
|
-
cost: streamCost,
|
|
337
|
-
settled,
|
|
338
|
-
transferId,
|
|
339
|
-
usageSource,
|
|
340
|
-
chunksDelivered: completion.chunksDelivered,
|
|
341
|
-
},
|
|
378
|
+
data: auditEventData,
|
|
342
379
|
});
|
|
343
380
|
auditHash = auditEvent.hash;
|
|
344
381
|
}
|
|
@@ -349,7 +386,7 @@ export async function trust(client, opts) {
|
|
|
349
386
|
transferId,
|
|
350
387
|
cost: streamCost,
|
|
351
388
|
budgetRemaining: config.budget - budgetSpent - inFlightHoldTotal,
|
|
352
|
-
auditHash,
|
|
389
|
+
auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : auditHash,
|
|
353
390
|
chainPath: join(VAULT_DIR, "audit"),
|
|
354
391
|
receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
|
|
355
392
|
settled,
|
|
@@ -364,9 +401,13 @@ export async function trust(client, opts) {
|
|
|
364
401
|
};
|
|
365
402
|
inFlightStreamCount--;
|
|
366
403
|
return streamReceipt;
|
|
367
|
-
}, (error, partial) => {
|
|
368
|
-
// Release in-flight hold
|
|
369
|
-
|
|
404
|
+
}, async (error, partial) => {
|
|
405
|
+
// Release in-flight hold under mutex
|
|
406
|
+
{
|
|
407
|
+
const releaseLock = await budgetMutex.acquire();
|
|
408
|
+
inFlightHoldTotal -= estimatedCost;
|
|
409
|
+
releaseLock();
|
|
410
|
+
}
|
|
370
411
|
cb.recordFailure();
|
|
371
412
|
// Best-effort audit of partial delivery
|
|
372
413
|
audit
|
|
@@ -380,9 +421,12 @@ export async function trust(client, opts) {
|
|
|
380
421
|
partialInputTokens: partial.usage.inputTokens,
|
|
381
422
|
partialOutputTokens: partial.usage.outputTokens,
|
|
382
423
|
usageReported: partial.usageReported,
|
|
383
|
-
error:
|
|
384
|
-
? error.message
|
|
385
|
-
|
|
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
|
+
})(),
|
|
386
430
|
},
|
|
387
431
|
})
|
|
388
432
|
.catch(() => { });
|
|
@@ -398,12 +442,12 @@ export async function trust(client, opts) {
|
|
|
398
442
|
// AUD-454: For streaming responses, settlement has NOT happened yet.
|
|
399
443
|
// Set settled: false — the real settlement status will be on
|
|
400
444
|
// governedStream.receipt after the stream is fully consumed.
|
|
401
|
-
const
|
|
445
|
+
const streamEstimateHash = createHash("sha256").update(transferId).digest("hex");
|
|
402
446
|
const estimatedReceipt = {
|
|
403
447
|
transferId,
|
|
404
448
|
cost: estimatedCost,
|
|
405
449
|
budgetRemaining: config.budget - budgetSpent - inFlightHoldTotal,
|
|
406
|
-
auditHash,
|
|
450
|
+
auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : streamEstimateHash,
|
|
407
451
|
chainPath: join(VAULT_DIR, "audit"),
|
|
408
452
|
receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
|
|
409
453
|
settled: false, // AUD-454: not settled yet — stream hasn't been consumed
|
|
@@ -428,13 +472,16 @@ export async function trust(client, opts) {
|
|
|
428
472
|
const outputTokens = usage.output_tokens ??
|
|
429
473
|
usage.completion_tokens ??
|
|
430
474
|
0;
|
|
431
|
-
actualCost = estimateCost(model, inputTokens, outputTokens);
|
|
475
|
+
actualCost = estimateCost(model, inputTokens, outputTokens, customRates);
|
|
432
476
|
}
|
|
433
477
|
}
|
|
434
|
-
// Release in-flight hold
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
+
}
|
|
438
485
|
// AUD-457: Persist cumulative spend to disk
|
|
439
486
|
await persistSpendLedger(vaultBase, budgetSpent);
|
|
440
487
|
// g. Circuit breaker: record success
|
|
@@ -470,23 +517,47 @@ export async function trust(client, opts) {
|
|
|
470
517
|
// AUD-460: Use the proxy's transferId for settlement
|
|
471
518
|
await proxyConn.settle(proxyTransferId ?? transferId, actualCost);
|
|
472
519
|
}
|
|
473
|
-
catch {
|
|
520
|
+
catch (postErr) {
|
|
474
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
|
+
});
|
|
475
538
|
}
|
|
476
539
|
}
|
|
477
540
|
// h. Audit event — failure mode 15.3: audit write failure
|
|
478
541
|
const syntheticHash = createHash("sha256").update(transferId).digest("hex");
|
|
479
542
|
let auditHash = syntheticHash;
|
|
480
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
|
+
}
|
|
481
557
|
const auditEvent = await audit.appendEvent({
|
|
482
558
|
kind: "llm_call",
|
|
483
559
|
actor: "local",
|
|
484
|
-
data:
|
|
485
|
-
model,
|
|
486
|
-
cost: actualCost,
|
|
487
|
-
settled,
|
|
488
|
-
transferId,
|
|
489
|
-
},
|
|
560
|
+
data: auditData,
|
|
490
561
|
});
|
|
491
562
|
auditHash = auditEvent.hash;
|
|
492
563
|
}
|
|
@@ -519,7 +590,7 @@ export async function trust(client, opts) {
|
|
|
519
590
|
transferId,
|
|
520
591
|
cost: actualCost,
|
|
521
592
|
budgetRemaining,
|
|
522
|
-
auditHash,
|
|
593
|
+
auditHash: callAuditDegraded ? "AUDIT_DEGRADED" : auditHash,
|
|
523
594
|
chainPath: join(VAULT_DIR, "audit"),
|
|
524
595
|
receiptUrl: opts?.proxy != null ? `${VERIFY_URL_BASE}/${transferId}` : null,
|
|
525
596
|
settled,
|
|
@@ -533,8 +604,12 @@ export async function trust(client, opts) {
|
|
|
533
604
|
return { response, receipt };
|
|
534
605
|
}
|
|
535
606
|
catch (err) {
|
|
536
|
-
// Release in-flight hold (non-streaming failure)
|
|
537
|
-
|
|
607
|
+
// Release in-flight hold under mutex (non-streaming failure)
|
|
608
|
+
{
|
|
609
|
+
const releaseLock = await budgetMutex.acquire();
|
|
610
|
+
inFlightHoldTotal -= estimatedCost;
|
|
611
|
+
releaseLock();
|
|
612
|
+
}
|
|
538
613
|
// j. Circuit breaker: record failure
|
|
539
614
|
cb.recordFailure();
|
|
540
615
|
// j2. Failure mode 15.2: LLM fails — VOID the pending hold
|
|
@@ -561,7 +636,13 @@ export async function trust(client, opts) {
|
|
|
561
636
|
.appendEvent({
|
|
562
637
|
kind: "llm_call_failed",
|
|
563
638
|
actor: "local",
|
|
564
|
-
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
|
+
},
|
|
565
646
|
})
|
|
566
647
|
.catch(() => {
|
|
567
648
|
callAuditDegraded = true;
|
|
@@ -584,6 +665,287 @@ export async function trust(client, opts) {
|
|
|
584
665
|
inFlightCount--;
|
|
585
666
|
}
|
|
586
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
|
+
}
|
|
587
949
|
// 8. Safety net: clean up on process exit if destroy() was never called
|
|
588
950
|
let beforeExitHandler = null;
|
|
589
951
|
// 9. Build Proxy based on client kind
|
|
@@ -623,13 +985,13 @@ export async function trust(client, opts) {
|
|
|
623
985
|
}
|
|
624
986
|
};
|
|
625
987
|
if (kind === "anthropic") {
|
|
626
|
-
return buildAnthropicProxy(client, interceptCall, destroyFn);
|
|
988
|
+
return buildAnthropicProxy(client, interceptCall, destroyFn, governActionImpl);
|
|
627
989
|
}
|
|
628
990
|
if (kind === "openai") {
|
|
629
|
-
return buildOpenAIProxy(client, interceptCall, destroyFn);
|
|
991
|
+
return buildOpenAIProxy(client, interceptCall, destroyFn, governActionImpl);
|
|
630
992
|
}
|
|
631
993
|
// google
|
|
632
|
-
return buildGoogleProxy(client, interceptCall, destroyFn);
|
|
994
|
+
return buildGoogleProxy(client, interceptCall, destroyFn, governActionImpl);
|
|
633
995
|
}
|
|
634
996
|
const governedClient = createClientProxy();
|
|
635
997
|
beforeExitHandler = () => {
|
|
@@ -707,7 +1069,7 @@ async function createTBEngine(config) {
|
|
|
707
1069
|
},
|
|
708
1070
|
};
|
|
709
1071
|
}
|
|
710
|
-
function buildAnthropicProxy(client, intercept, destroy) {
|
|
1072
|
+
function buildAnthropicProxy(client, intercept, destroy, governAction) {
|
|
711
1073
|
const original = client;
|
|
712
1074
|
const messages = original.messages;
|
|
713
1075
|
const originalCreate = messages.create;
|
|
@@ -727,12 +1089,14 @@ function buildAnthropicProxy(client, intercept, destroy) {
|
|
|
727
1089
|
return messagesProxy;
|
|
728
1090
|
if (prop === "destroy")
|
|
729
1091
|
return destroy;
|
|
1092
|
+
if (prop === "governAction")
|
|
1093
|
+
return governAction;
|
|
730
1094
|
return Reflect.get(target, prop, receiver);
|
|
731
1095
|
},
|
|
732
1096
|
});
|
|
733
1097
|
return clientProxy;
|
|
734
1098
|
}
|
|
735
|
-
function buildOpenAIProxy(client, intercept, destroy) {
|
|
1099
|
+
function buildOpenAIProxy(client, intercept, destroy, governAction) {
|
|
736
1100
|
const original = client;
|
|
737
1101
|
const chat = original.chat;
|
|
738
1102
|
const completions = chat.completions;
|
|
@@ -758,12 +1122,14 @@ function buildOpenAIProxy(client, intercept, destroy) {
|
|
|
758
1122
|
return chatProxy;
|
|
759
1123
|
if (prop === "destroy")
|
|
760
1124
|
return destroy;
|
|
1125
|
+
if (prop === "governAction")
|
|
1126
|
+
return governAction;
|
|
761
1127
|
return Reflect.get(target, prop, receiver);
|
|
762
1128
|
},
|
|
763
1129
|
});
|
|
764
1130
|
return clientProxy;
|
|
765
1131
|
}
|
|
766
|
-
function buildGoogleProxy(client, intercept, destroy) {
|
|
1132
|
+
function buildGoogleProxy(client, intercept, destroy, governAction) {
|
|
767
1133
|
const original = client;
|
|
768
1134
|
const models = original.models;
|
|
769
1135
|
const originalGenerate = models.generateContent;
|
|
@@ -781,6 +1147,8 @@ function buildGoogleProxy(client, intercept, destroy) {
|
|
|
781
1147
|
return modelsProxy;
|
|
782
1148
|
if (prop === "destroy")
|
|
783
1149
|
return destroy;
|
|
1150
|
+
if (prop === "governAction")
|
|
1151
|
+
return governAction;
|
|
784
1152
|
return Reflect.get(target, prop, receiver);
|
|
785
1153
|
},
|
|
786
1154
|
});
|