usertrust 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/dist/anomaly/detector.d.ts.map +1 -1
- package/dist/anomaly/detector.js +36 -11
- package/dist/anomaly/detector.js.map +1 -1
- package/dist/anomaly/signals/spend-velocity.d.ts +39 -12
- package/dist/anomaly/signals/spend-velocity.d.ts.map +1 -1
- package/dist/anomaly/signals/spend-velocity.js +66 -43
- package/dist/anomaly/signals/spend-velocity.js.map +1 -1
- package/dist/anomaly/signals/token-rate.d.ts +33 -13
- package/dist/anomaly/signals/token-rate.d.ts.map +1 -1
- package/dist/anomaly/signals/token-rate.js +124 -38
- package/dist/anomaly/signals/token-rate.js.map +1 -1
- package/dist/anomaly/types.d.ts +28 -6
- package/dist/anomaly/types.d.ts.map +1 -1
- package/dist/audit/chain.d.ts +9 -0
- package/dist/audit/chain.d.ts.map +1 -1
- package/dist/audit/chain.js +65 -9
- package/dist/audit/chain.js.map +1 -1
- package/dist/audit/verify.d.ts +19 -0
- package/dist/audit/verify.d.ts.map +1 -1
- package/dist/audit/verify.js +216 -14
- package/dist/audit/verify.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +107 -2
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/main.js +0 -0
- package/dist/cli/skill.d.ts.map +1 -1
- package/dist/cli/skill.js +35 -5
- package/dist/cli/skill.js.map +1 -1
- package/dist/cli/snapshot.d.ts.map +1 -1
- package/dist/cli/snapshot.js +20 -2
- package/dist/cli/snapshot.js.map +1 -1
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +20 -11
- package/dist/cli/verify.js.map +1 -1
- package/dist/detect.d.ts +34 -1
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js +124 -0
- package/dist/detect.js.map +1 -1
- package/dist/govern.d.ts +13 -2
- package/dist/govern.d.ts.map +1 -1
- package/dist/govern.js +475 -127
- package/dist/govern.js.map +1 -1
- package/dist/headless.d.ts +41 -2
- package/dist/headless.d.ts.map +1 -1
- package/dist/headless.js +203 -35
- package/dist/headless.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/ledger/client.d.ts +14 -0
- package/dist/ledger/client.d.ts.map +1 -1
- package/dist/ledger/client.js +56 -0
- package/dist/ledger/client.js.map +1 -1
- package/dist/ledger/pricing.d.ts +54 -0
- package/dist/ledger/pricing.d.ts.map +1 -1
- package/dist/ledger/pricing.js +134 -8
- package/dist/ledger/pricing.js.map +1 -1
- package/dist/policy/default-rules.d.ts +18 -3
- package/dist/policy/default-rules.d.ts.map +1 -1
- package/dist/policy/default-rules.js +33 -15
- package/dist/policy/default-rules.js.map +1 -1
- package/dist/policy/gate.d.ts.map +1 -1
- package/dist/policy/gate.js +114 -16
- package/dist/policy/gate.js.map +1 -1
- package/dist/policy/injection.d.ts.map +1 -1
- package/dist/policy/injection.js +25 -16
- package/dist/policy/injection.js.map +1 -1
- package/dist/policy/pii.d.ts +20 -0
- package/dist/policy/pii.d.ts.map +1 -1
- package/dist/policy/pii.js +45 -2
- package/dist/policy/pii.js.map +1 -1
- package/dist/shared/types.d.ts +191 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +67 -6
- package/dist/shared/types.js.map +1 -1
- package/dist/snapshot/checkpoint.d.ts +18 -2
- package/dist/snapshot/checkpoint.d.ts.map +1 -1
- package/dist/snapshot/checkpoint.js +101 -17
- package/dist/snapshot/checkpoint.js.map +1 -1
- package/dist/streaming.d.ts +8 -3
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +75 -31
- package/dist/streaming.js.map +1 -1
- package/dist/supply-chain/permissions.d.ts +16 -3
- package/dist/supply-chain/permissions.d.ts.map +1 -1
- package/dist/supply-chain/permissions.js +59 -9
- package/dist/supply-chain/permissions.js.map +1 -1
- package/dist/supply-chain/sign.d.ts +5 -2
- package/dist/supply-chain/sign.d.ts.map +1 -1
- package/dist/supply-chain/sign.js +10 -2
- package/dist/supply-chain/sign.js.map +1 -1
- package/package.json +2 -4
package/dist/govern.js
CHANGED
|
@@ -26,17 +26,18 @@
|
|
|
26
26
|
* await client.destroy();
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
-
import { createHash } from "node:crypto";
|
|
29
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
30
30
|
import { existsSync, mkdirSync } from "node:fs";
|
|
31
|
-
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
31
|
+
import { readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
32
32
|
import { join } from "node:path";
|
|
33
|
+
import { CreateTransferError } from "tigerbeetle-node";
|
|
33
34
|
import { createAuditWriter } from "./audit/chain.js";
|
|
34
35
|
import { writeReceipt } from "./audit/rotation.js";
|
|
35
|
-
import { detectClientKind } from "./detect.js";
|
|
36
|
-
import { TrustTBClient, XFER_SPEND } from "./ledger/client.js";
|
|
37
|
-
import {
|
|
36
|
+
import { classifyEndpoint, detectClientKind } from "./detect.js";
|
|
37
|
+
import { TBTransferError, TrustTBClient, XFER_SPEND } from "./ledger/client.js";
|
|
38
|
+
import { costFromRates, estimateInputTokens, resolveRates, warnUnknownModel, } from "./ledger/pricing.js";
|
|
38
39
|
import { recordPattern } from "./memory/patterns.js";
|
|
39
|
-
import { DEFAULT_RULES } from "./policy/default-rules.js";
|
|
40
|
+
import { DEFAULT_RULES, mergePolicies } from "./policy/default-rules.js";
|
|
40
41
|
import { evaluatePolicy, loadPolicies } from "./policy/gate.js";
|
|
41
42
|
import { detectInjection } from "./policy/injection.js";
|
|
42
43
|
import { detectPII, redactPII } from "./policy/pii.js";
|
|
@@ -45,7 +46,7 @@ import { DEFAULT_BUDGET, VAULT_DIR } from "./shared/constants.js";
|
|
|
45
46
|
/** Base URL for receipt verification links (used in proxy mode). */
|
|
46
47
|
const VERIFY_URL_BASE = "https://verify.usertrust.dev";
|
|
47
48
|
import { createAnomalyDetector } from "./anomaly/detector.js";
|
|
48
|
-
import { AnomalyError, LedgerUnavailableError, PolicyDeniedError } from "./shared/errors.js";
|
|
49
|
+
import { AnomalyError, AuditDegradedError, InsufficientBalanceError, LedgerUnavailableError, PolicyDeniedError, } from "./shared/errors.js";
|
|
49
50
|
import { trustId } from "./shared/ids.js";
|
|
50
51
|
import { TrustConfigSchema } from "./shared/types.js";
|
|
51
52
|
import { createGovernedStream } from "./streaming.js";
|
|
@@ -65,6 +66,86 @@ class AsyncMutex {
|
|
|
65
66
|
return release;
|
|
66
67
|
}
|
|
67
68
|
}
|
|
69
|
+
// ── M2 unknown-model policy (A5) ──
|
|
70
|
+
/** USD value of one usertoken: 1 usertoken = $0.0001 (one basis point of a cent). */
|
|
71
|
+
const USERTOKENS_PER_DOLLAR = 10_000;
|
|
72
|
+
/**
|
|
73
|
+
* Enforce config.unknownModelPolicy at AUTHORIZE time (A5). Only cloud-scope
|
|
74
|
+
* resolutions can be unknown — local scope always resolves to local rates.
|
|
75
|
+
* "deny" throws before any PENDING hold; "warn" logs once per model string per
|
|
76
|
+
* process via the shared warnUnknownModel helper (receipts carry
|
|
77
|
+
* meter.rateSource "fallback" regardless of warn dedup); "fallback" is silent.
|
|
78
|
+
*/
|
|
79
|
+
function enforceUnknownModelPolicy(model, resolution, config) {
|
|
80
|
+
if (!resolution.unknown)
|
|
81
|
+
return;
|
|
82
|
+
if (config.unknownModelPolicy === "deny") {
|
|
83
|
+
throw new PolicyDeniedError(`unknown_model: ${model} not in pricing table`);
|
|
84
|
+
}
|
|
85
|
+
if (config.unknownModelPolicy === "warn") {
|
|
86
|
+
warnUnknownModel(model);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A4: build forward-args for a local openai stream call with
|
|
91
|
+
* stream_options.include_usage merged in. The caller's other stream_options
|
|
92
|
+
* fields survive; an explicit include_usage (true OR false) is respected —
|
|
93
|
+
* only a missing/null value injects. Returns null when no injection applies.
|
|
94
|
+
*/
|
|
95
|
+
function withInjectedUsageOptions(args) {
|
|
96
|
+
const params = (args[0] ?? {});
|
|
97
|
+
if (params.stream !== true)
|
|
98
|
+
return null;
|
|
99
|
+
const rawOptions = params.stream_options;
|
|
100
|
+
const streamOptions = rawOptions != null && typeof rawOptions === "object"
|
|
101
|
+
? rawOptions
|
|
102
|
+
: undefined;
|
|
103
|
+
if (streamOptions?.include_usage != null)
|
|
104
|
+
return null;
|
|
105
|
+
return [
|
|
106
|
+
{ ...params, stream_options: { ...streamOptions, include_usage: true } },
|
|
107
|
+
...args.slice(1),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
/** Message shapes an OpenAI-compat server emits when it rejects an unknown field. */
|
|
111
|
+
const STREAM_OPTIONS_REJECTION_RE = /stream_options|include_usage|unrecognized|unknown.{0,20}(field|argument|parameter|option)/i;
|
|
112
|
+
/**
|
|
113
|
+
* A4 retry heuristic: does `err` plausibly indicate that the server rejected the
|
|
114
|
+
* injected `stream_options.include_usage`? We only retry-without-injection for
|
|
115
|
+
* these — a blanket retry on ANY error would double the provider call on
|
|
116
|
+
* transient failures (ECONNRESET, timeouts, 5xx) and mask the real root cause,
|
|
117
|
+
* so everything else rethrows the ORIGINAL error immediately. Matches the
|
|
118
|
+
* rejection message on the error, its nested `error.message`, or a cheaply
|
|
119
|
+
* reachable `response` body; or an HTTP-shaped 400/422 status on the error
|
|
120
|
+
* object. Tradeoff: a server that rejects the field with an opaque 500 and no
|
|
121
|
+
* telltale text is NOT retried — acceptable, since include_usage is widely
|
|
122
|
+
* supported and the caller still receives the original error.
|
|
123
|
+
*/
|
|
124
|
+
function looksLikeStreamOptionsRejection(err) {
|
|
125
|
+
if (err == null || typeof err !== "object") {
|
|
126
|
+
return err instanceof Error && STREAM_OPTIONS_REJECTION_RE.test(err.message);
|
|
127
|
+
}
|
|
128
|
+
const e = err;
|
|
129
|
+
const status = e.status ?? e.statusCode;
|
|
130
|
+
if (status === 400 || status === 422)
|
|
131
|
+
return true;
|
|
132
|
+
const candidates = [e.message, e.body];
|
|
133
|
+
if (err instanceof Error)
|
|
134
|
+
candidates.push(err.message);
|
|
135
|
+
const nested = e.error;
|
|
136
|
+
if (nested != null && typeof nested === "object") {
|
|
137
|
+
candidates.push(nested.message);
|
|
138
|
+
}
|
|
139
|
+
const response = e.response;
|
|
140
|
+
if (typeof response === "string") {
|
|
141
|
+
candidates.push(response);
|
|
142
|
+
}
|
|
143
|
+
else if (response != null && typeof response === "object") {
|
|
144
|
+
const r = response;
|
|
145
|
+
candidates.push(r.data, r.body);
|
|
146
|
+
}
|
|
147
|
+
return candidates.some((c) => typeof c === "string" && STREAM_OPTIONS_REJECTION_RE.test(c));
|
|
148
|
+
}
|
|
68
149
|
async function loadSpendLedger(vaultBase) {
|
|
69
150
|
const ledgerPath = join(vaultBase, VAULT_DIR, "spend-ledger.json");
|
|
70
151
|
try {
|
|
@@ -88,22 +169,36 @@ async function loadSpendLedger(vaultBase) {
|
|
|
88
169
|
async function persistSpendLedger(vaultBase, budgetSpent) {
|
|
89
170
|
const dir = join(vaultBase, VAULT_DIR);
|
|
90
171
|
const ledgerPath = join(dir, "spend-ledger.json");
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
};
|
|
172
|
+
// AUD-457 hardening (RECON #4): UNIQUE tmp path per write. A fixed
|
|
173
|
+
// `spend-ledger.json.tmp` lets two concurrent writers clobber each other's
|
|
174
|
+
// staging file, so a half-written record can be renamed into place. A pid +
|
|
175
|
+
// uuid suffix isolates every writer's staging file.
|
|
176
|
+
const tmpPath = join(dir, `spend-ledger.json.${process.pid}.${randomUUID()}.tmp`);
|
|
96
177
|
try {
|
|
97
178
|
// Ensure vault dir exists
|
|
98
179
|
if (!existsSync(dir)) {
|
|
99
180
|
mkdirSync(dir, { recursive: true });
|
|
100
181
|
}
|
|
101
|
-
//
|
|
182
|
+
// MONOTONIC guard (RECON #4): cumulative spend must never regress on disk.
|
|
183
|
+
// A stale/racing writer carrying a lower budgetSpent must not "un-spend"
|
|
184
|
+
// money that another writer already recorded. Skip the write if the
|
|
185
|
+
// persisted value is already >= ours.
|
|
186
|
+
const existing = await loadSpendLedger(vaultBase);
|
|
187
|
+
if (existing > budgetSpent) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const data = {
|
|
191
|
+
budgetSpent,
|
|
192
|
+
updatedAt: new Date().toISOString(),
|
|
193
|
+
};
|
|
194
|
+
// Atomic write: write UNIQUE tmp then rename over the target.
|
|
102
195
|
await writeFile(tmpPath, JSON.stringify(data), "utf-8");
|
|
103
196
|
await rename(tmpPath, ledgerPath);
|
|
104
197
|
}
|
|
105
198
|
catch {
|
|
106
|
-
// Best-effort — do not fail the LLM call over ledger persistence
|
|
199
|
+
// Best-effort — do not fail the LLM call over ledger persistence. Clean up
|
|
200
|
+
// our unique staging file if the rename never happened.
|
|
201
|
+
await unlink(tmpPath).catch(() => { });
|
|
107
202
|
}
|
|
108
203
|
}
|
|
109
204
|
// ── trust() ──
|
|
@@ -124,7 +219,6 @@ export async function trust(client, opts) {
|
|
|
124
219
|
budget: opts?.budget ?? DEFAULT_BUDGET,
|
|
125
220
|
});
|
|
126
221
|
}
|
|
127
|
-
const customRates = config.pricing === "custom" ? config.customRates : undefined;
|
|
128
222
|
const isDryRun = opts?.dryRun ?? process.env.USERTRUST_DRY_RUN === "true";
|
|
129
223
|
// AUD-470: Only accept injected _engine/_audit in test environments.
|
|
130
224
|
// In production, silently ignore them to prevent governance bypass.
|
|
@@ -134,7 +228,12 @@ export async function trust(client, opts) {
|
|
|
134
228
|
const audit = (isTestEnv ? opts?._audit : undefined) ?? createAuditWriter(vaultPath);
|
|
135
229
|
const policiesPath = join(vaultPath, VAULT_DIR, config.policies);
|
|
136
230
|
const loadedRules = existsSync(policiesPath) ? loadPolicies(policiesPath) : [];
|
|
137
|
-
|
|
231
|
+
// P1-CUSTOM-POLICY-REPLACES (RECON #2): platform DEFAULT_RULES are ALWAYS
|
|
232
|
+
// enforced. mergePolicies is a safe concat — a custom policy file can only ADD
|
|
233
|
+
// deny/warn rules, never remove the budget/overshoot/exhausted guarantees. The
|
|
234
|
+
// gate is deny-wins with no "allow" effect, so appended user rules cannot weaken
|
|
235
|
+
// a default deny.
|
|
236
|
+
const policyRules = mergePolicies(DEFAULT_RULES, loadedRules);
|
|
138
237
|
const breaker = new CircuitBreakerRegistry({
|
|
139
238
|
failureThreshold: config.circuitBreaker.failureThreshold,
|
|
140
239
|
resetTimeoutMs: config.circuitBreaker.resetTimeout,
|
|
@@ -147,6 +246,9 @@ export async function trust(client, opts) {
|
|
|
147
246
|
// AUD-456: proxyConn is always null now — proxy mode throws above.
|
|
148
247
|
// Cast keeps dead code paths type-safe for future re-enablement.
|
|
149
248
|
const proxyConn = null;
|
|
249
|
+
// AUD-457: restore cumulative spend from disk BEFORE building the engine so the
|
|
250
|
+
// enforcing holding account can be seeded with the REMAINING budget.
|
|
251
|
+
let budgetSpent = await loadSpendLedger(vaultBase);
|
|
150
252
|
// 4. Engine (injected for tests, real TB client in production, null in dry-run/proxy)
|
|
151
253
|
// AUD-470: _engine injection only accepted in test environments
|
|
152
254
|
let engine;
|
|
@@ -155,7 +257,9 @@ export async function trust(client, opts) {
|
|
|
155
257
|
}
|
|
156
258
|
else if (!isDryRun && proxyConn == null) {
|
|
157
259
|
try {
|
|
158
|
-
|
|
260
|
+
// P1-LEDGER-ENFORCE (RECON #3): seed the enforcing holding account with the
|
|
261
|
+
// remaining budget so TigerBeetle atomically REJECTS an over-budget hold.
|
|
262
|
+
engine = await createTBEngine(config, Math.max(0, config.budget - budgetSpent));
|
|
159
263
|
}
|
|
160
264
|
catch (err) {
|
|
161
265
|
throw new LedgerUnavailableError(err instanceof Error ? err.message : String(err));
|
|
@@ -164,18 +268,43 @@ export async function trust(client, opts) {
|
|
|
164
268
|
else {
|
|
165
269
|
engine = null;
|
|
166
270
|
}
|
|
167
|
-
// 5. Detect client kind
|
|
271
|
+
// 5. Detect client kind (transport) + classify the endpoint (settlement
|
|
272
|
+
// regime, M2). classifyEndpoint runs BESIDE detectClientKind — the endpoint
|
|
273
|
+
// class, not the model string, picks local vs cloud metering (A3: this scope
|
|
274
|
+
// is captured once here and used verbatim at every authorize/settle below).
|
|
168
275
|
const kind = detectClientKind(client);
|
|
276
|
+
const endpoint = classifyEndpoint(client, config, opts?.endpoint);
|
|
169
277
|
// 6. Track state
|
|
170
278
|
let destroyed = false;
|
|
171
|
-
let budgetSpent = await loadSpendLedger(vaultBase); // AUD-457: restore from disk
|
|
172
279
|
const budgetMutex = new AsyncMutex(); // AUD-453: serialise budget-check + hold
|
|
173
280
|
let inFlightCount = 0; // AUD-462: track in-flight calls for graceful destroy
|
|
174
281
|
let inFlightStreamCount = 0; // AUD-462: track in-flight streams (consumed after interceptCall returns)
|
|
175
282
|
let inFlightHoldTotal = 0; // Track estimated cost of in-flight pending holds
|
|
176
283
|
// Streaming anomaly detector — shared across calls so injection-cascade
|
|
177
284
|
// can track signals across the conversation. Disabled when config.anomaly.enabled=false.
|
|
178
|
-
|
|
285
|
+
// M2 seam fix: the injected costCalculator prices spend-velocity with the
|
|
286
|
+
// SAME scoped rates as settlement, using the observed event's
|
|
287
|
+
// model/endpointClass (the detector is shared while the model varies per
|
|
288
|
+
// call). Denominations differ by design: cloud events → DOLLARS against
|
|
289
|
+
// thresholdDollarsPerMin; local events → nominal usertokens against
|
|
290
|
+
// localThresholdUsertokensPerMin, WITHOUT the per-call >=1 settlement floor.
|
|
291
|
+
// Settlement floors per call; the anomaly signal measures flow — so a
|
|
292
|
+
// default {0,0}-rate local stream contributes 0 and can never false-trip
|
|
293
|
+
// spend_velocity (rejected-merge design note, pinned by Task 3 tests).
|
|
294
|
+
const anomalyDetector = createAnomalyDetector(config.anomaly, {
|
|
295
|
+
provider: kind,
|
|
296
|
+
costCalculator: (calcModel, inputTokens, outputTokens, event) => {
|
|
297
|
+
const scope = event?.endpointClass ?? "cloud";
|
|
298
|
+
const resolution = resolveRates(event?.model ?? calcModel, scope, config);
|
|
299
|
+
if (scope === "local") {
|
|
300
|
+
const inTok = Number.isFinite(inputTokens) && inputTokens > 0 ? inputTokens : 0;
|
|
301
|
+
const outTok = Number.isFinite(outputTokens) && outputTokens > 0 ? outputTokens : 0;
|
|
302
|
+
return ((inTok / 1000) * resolution.rates.inputPer1k +
|
|
303
|
+
(outTok / 1000) * resolution.rates.outputPer1k);
|
|
304
|
+
}
|
|
305
|
+
return costFromRates(resolution.rates, inputTokens, outputTokens) / USERTOKENS_PER_DOLLAR;
|
|
306
|
+
},
|
|
307
|
+
});
|
|
179
308
|
// 7. Two-phase intercept
|
|
180
309
|
async function interceptCall(originalFn, thisArg, args) {
|
|
181
310
|
if (destroyed) {
|
|
@@ -186,17 +315,31 @@ export async function trust(client, opts) {
|
|
|
186
315
|
try {
|
|
187
316
|
const params = (args[0] ?? {});
|
|
188
317
|
const model = params.model ?? "unknown";
|
|
189
|
-
|
|
318
|
+
// P3-PROVIDER-BLINDSPOT: normalize the prompt-bearing payload across
|
|
319
|
+
// providers (Anthropic/OpenAI `messages` + `system`, Google `contents`) so
|
|
320
|
+
// PII/injection scanning, token estimation, redaction, and pattern hashing
|
|
321
|
+
// all see the actual prompt — not an empty `messages` array on Google calls.
|
|
322
|
+
const promptParts = extractPromptParts(params, kind);
|
|
190
323
|
// Per-call audit degradation flag (not sticky across calls)
|
|
191
324
|
let callAuditDegraded = false;
|
|
325
|
+
// P3-PII-REDACT-EGRESS: `forwardArgs` is what we actually send to the
|
|
326
|
+
// provider. In redact mode it becomes a redacted deep clone so PII never
|
|
327
|
+
// egresses; block mode throws before any egress. Default: forward verbatim.
|
|
328
|
+
let forwardArgs = args;
|
|
192
329
|
// a. Circuit breaker check
|
|
193
330
|
const cb = breaker.get(kind);
|
|
194
331
|
cb.allowRequest();
|
|
195
|
-
// b. Estimate cost (before policy, so cost fields are available in context)
|
|
332
|
+
// b. Estimate cost (before policy, so cost fields are available in context).
|
|
333
|
+
// M2: rates resolve within the endpoint scope CAPTURED AT AUTHORIZE (A3) —
|
|
334
|
+
// this one resolution prices the hold, both settlement paths, and the
|
|
335
|
+
// receipt's meter provenance. unknownModelPolicy is enforced here, at
|
|
336
|
+
// authorize time, before any PENDING hold (A5).
|
|
196
337
|
const transferId = trustId("tx");
|
|
197
|
-
const estimatedInputTokens = estimateInputTokens(
|
|
338
|
+
const estimatedInputTokens = estimateInputTokens(promptParts);
|
|
198
339
|
const maxOutputTokens = params.max_tokens ?? 4096;
|
|
199
|
-
const
|
|
340
|
+
const rateResolution = resolveRates(model, endpoint.class, config);
|
|
341
|
+
enforceUnknownModelPolicy(model, rateResolution, config);
|
|
342
|
+
const estimatedCost = costFromRates(rateResolution.rates, estimatedInputTokens, maxOutputTokens);
|
|
200
343
|
// AUD-453: Acquire mutex to serialise budget-check + PENDING hold.
|
|
201
344
|
// This prevents concurrent calls from both passing the budget check
|
|
202
345
|
// and overshooting the budget.
|
|
@@ -217,28 +360,42 @@ export async function trust(client, opts) {
|
|
|
217
360
|
}
|
|
218
361
|
try {
|
|
219
362
|
// c. Policy gate
|
|
363
|
+
// P1-PARAM-SHADOW: caller `params` are spread FIRST so trusted
|
|
364
|
+
// governance fields (tier/estimated_cost/budget_remaining/
|
|
365
|
+
// budget_remaining_after) CANNOT be shadowed by request-supplied keys.
|
|
366
|
+
// P1-BUDGET-PREFLIGHT: budget_remaining_after is the derived field the
|
|
367
|
+
// single-field gate compares against zero to deny a single overshooting
|
|
368
|
+
// call (the `block-budget-overshoot` default rule).
|
|
220
369
|
const policyResult = evaluatePolicy(policyRules, {
|
|
370
|
+
...params,
|
|
221
371
|
model,
|
|
222
372
|
tier: config.tier,
|
|
223
373
|
estimated_cost: estimatedCost,
|
|
224
374
|
budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
|
|
225
|
-
|
|
375
|
+
budget_remaining_after: config.budget - budgetSpent - inFlightHoldTotal - estimatedCost,
|
|
226
376
|
});
|
|
227
377
|
if (policyResult.decision === "deny") {
|
|
228
378
|
const reason = policyResult.reasons.length > 0 ? policyResult.reasons.join("; ") : "Policy denied";
|
|
229
379
|
throw new PolicyDeniedError(reason);
|
|
230
380
|
}
|
|
231
|
-
// d. PII check
|
|
381
|
+
// d. PII check + redact-egress
|
|
232
382
|
if (config.pii !== "off") {
|
|
233
|
-
const piiResult = detectPII(
|
|
383
|
+
const piiResult = detectPII(promptParts);
|
|
234
384
|
if (piiResult.found && config.pii === "block") {
|
|
385
|
+
// block mode: throw BEFORE any egress.
|
|
235
386
|
throw new PolicyDeniedError(`PII detected: ${piiResult.types.join(", ")}`);
|
|
236
387
|
}
|
|
237
|
-
|
|
388
|
+
if (config.pii === "redact" && piiResult.found) {
|
|
389
|
+
// redact mode: forward a redacted DEEP CLONE so PII never egresses.
|
|
390
|
+
// redactPII is pure — the caller's original object is never mutated.
|
|
391
|
+
const redactedBody = redactPII(params).data;
|
|
392
|
+
forwardArgs = [redactedBody, ...args.slice(1)];
|
|
393
|
+
}
|
|
394
|
+
// "warn" mode: continue, no transform (audit copy is redacted later).
|
|
238
395
|
}
|
|
239
396
|
// d2. Injection detection
|
|
240
397
|
if (config.injection !== "off") {
|
|
241
|
-
const injectionResult = detectInjection(
|
|
398
|
+
const injectionResult = detectInjection(promptParts);
|
|
242
399
|
if (injectionResult.detected) {
|
|
243
400
|
if (config.injection === "block") {
|
|
244
401
|
throw new PolicyDeniedError(`Prompt injection detected: ${injectionResult.patterns.join(", ")}`);
|
|
@@ -285,7 +442,16 @@ export async function trust(client, opts) {
|
|
|
285
442
|
});
|
|
286
443
|
}
|
|
287
444
|
catch (holdErr) {
|
|
288
|
-
//
|
|
445
|
+
// P1-LEDGER-ENFORCE: an over-budget reservation is rejected
|
|
446
|
+
// atomically by the ledger. Surface it as a hard budget DENY —
|
|
447
|
+
// NOT as "ledger unavailable" (which would misreport a budget cap
|
|
448
|
+
// as an outage). This throws out of the budget-section try, runs
|
|
449
|
+
// its finally (releases the budget lock), and propagates without
|
|
450
|
+
// a hold to void — exactly mirroring the policy-deny control flow.
|
|
451
|
+
if (holdErr instanceof InsufficientBalanceError) {
|
|
452
|
+
throw holdErr;
|
|
453
|
+
}
|
|
454
|
+
// Genuine ledger outage — do NOT forward to provider.
|
|
289
455
|
throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
|
|
290
456
|
}
|
|
291
457
|
}
|
|
@@ -297,10 +463,41 @@ export async function trust(client, opts) {
|
|
|
297
463
|
// AUD-453: Release lock after budget check + hold are complete
|
|
298
464
|
releaseBudgetLock();
|
|
299
465
|
}
|
|
300
|
-
//
|
|
466
|
+
// e0. M2 usage injection (A4): for local openai streams, opt in to the
|
|
467
|
+
// server's final usage chunk (Ollama emits /v1 streaming usage ONLY when
|
|
468
|
+
// stream_options.include_usage is set). The merge preserves the caller's
|
|
469
|
+
// other stream_options fields and respects an explicit include_usage.
|
|
470
|
+
// The resulting usage chunk is FORWARDED to the consumer unmodified
|
|
471
|
+
// (transparent middleware).
|
|
472
|
+
let preInjectionArgs = null;
|
|
473
|
+
if (kind === "openai" && endpoint.class === "local" && config.local.injectUsageOptions) {
|
|
474
|
+
const injected = withInjectedUsageOptions(forwardArgs);
|
|
475
|
+
if (injected != null) {
|
|
476
|
+
preInjectionArgs = forwardArgs;
|
|
477
|
+
forwardArgs = injected;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// e. Forward to original SDK. P3-PII-REDACT-EGRESS: forwardArgs is the
|
|
481
|
+
// redacted clone in redact mode, or the original args otherwise.
|
|
301
482
|
let settled = true;
|
|
302
483
|
try {
|
|
303
|
-
|
|
484
|
+
let response;
|
|
485
|
+
try {
|
|
486
|
+
response = await originalFn.apply(thisArg, forwardArgs);
|
|
487
|
+
}
|
|
488
|
+
catch (callErr) {
|
|
489
|
+
// A4: some OpenAI-compat servers reject unknown stream_options. When
|
|
490
|
+
// WE injected them AND the error plausibly says so (message/HTTP-status
|
|
491
|
+
// heuristic), retry ONCE without the injection; the retried stream
|
|
492
|
+
// simply settles on the estimate if no usage tail arrives (A7). Any
|
|
493
|
+
// other error — transient network failures, unrelated 5xx — rethrows
|
|
494
|
+
// the ORIGINAL immediately rather than duplicating compute and masking
|
|
495
|
+
// the root cause.
|
|
496
|
+
if (preInjectionArgs == null || !looksLikeStreamOptionsRejection(callErr)) {
|
|
497
|
+
throw callErr;
|
|
498
|
+
}
|
|
499
|
+
response = await originalFn.apply(thisArg, preInjectionArgs);
|
|
500
|
+
}
|
|
304
501
|
// e2. Streaming detection: if response is an async iterable, wrap with
|
|
305
502
|
// token accumulation. Settlement and audit happen when the stream ends.
|
|
306
503
|
if (response != null &&
|
|
@@ -308,11 +505,13 @@ export async function trust(client, opts) {
|
|
|
308
505
|
Symbol.asyncIterator in response) {
|
|
309
506
|
const stream = response;
|
|
310
507
|
const governedStream = createGovernedStream(stream, kind, async (completion) => {
|
|
311
|
-
// Determine cost: use provider usage if reported, else fall back to
|
|
508
|
+
// Determine cost: use provider usage if reported, else fall back to
|
|
509
|
+
// estimate. A3: priced with the rate resolution captured at
|
|
510
|
+
// authorize; A11: costFromRates floors at 1 even for 0/0 usage.
|
|
312
511
|
let streamCost;
|
|
313
512
|
let usageSource;
|
|
314
513
|
if (completion.usageReported) {
|
|
315
|
-
streamCost =
|
|
514
|
+
streamCost = costFromRates(rateResolution.rates, completion.usage.inputTokens, completion.usage.outputTokens);
|
|
316
515
|
usageSource = "provider";
|
|
317
516
|
}
|
|
318
517
|
else {
|
|
@@ -366,7 +565,8 @@ export async function trust(client, opts) {
|
|
|
366
565
|
}
|
|
367
566
|
else if (engine != null && !isDryRun) {
|
|
368
567
|
try {
|
|
369
|
-
|
|
568
|
+
// Post the ACTUAL consumed cost (RECON #3).
|
|
569
|
+
await engine.postPendingSpend(transferId, streamCost);
|
|
370
570
|
}
|
|
371
571
|
catch (postErr) {
|
|
372
572
|
settled = false;
|
|
@@ -398,9 +598,13 @@ export async function trust(client, opts) {
|
|
|
398
598
|
transferId,
|
|
399
599
|
usageSource,
|
|
400
600
|
chunksDelivered: completion.chunksDelivered,
|
|
601
|
+
// M2: metering provenance mirrors the receipt (A3 authorize-time scope).
|
|
602
|
+
endpointClass: endpoint.class,
|
|
603
|
+
costBasis: rateResolution.costBasis,
|
|
604
|
+
rateSource: rateResolution.rateSource,
|
|
401
605
|
};
|
|
402
606
|
if (config.pii === "warn" || config.pii === "redact") {
|
|
403
|
-
const piiResult = redactPII(
|
|
607
|
+
const piiResult = redactPII(promptParts);
|
|
404
608
|
if (piiResult.detection.found) {
|
|
405
609
|
auditEventData.piiDetected = piiResult.detection.types;
|
|
406
610
|
auditEventData.piiPaths = piiResult.detection.paths;
|
|
@@ -416,6 +620,13 @@ export async function trust(client, opts) {
|
|
|
416
620
|
catch {
|
|
417
621
|
callAuditDegraded = true;
|
|
418
622
|
}
|
|
623
|
+
// P3-AUDIT-FAILCLOSED (streaming): chunks were already delivered,
|
|
624
|
+
// so the strongest post-delivery signal is to REJECT `.receipt`.
|
|
625
|
+
// Decrement the stream counter first so destroy() never blocks.
|
|
626
|
+
if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
|
|
627
|
+
inFlightStreamCount--;
|
|
628
|
+
throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
|
|
629
|
+
}
|
|
419
630
|
const streamReceipt = {
|
|
420
631
|
transferId,
|
|
421
632
|
cost: streamCost,
|
|
@@ -429,6 +640,12 @@ export async function trust(client, opts) {
|
|
|
429
640
|
timestamp: new Date().toISOString(),
|
|
430
641
|
usageSource,
|
|
431
642
|
chunksDelivered: completion.chunksDelivered,
|
|
643
|
+
// M2 provenance (A6: computeMs omitted — no compute-time source here).
|
|
644
|
+
endpoint: { class: endpoint.class, runtime: endpoint.runtime },
|
|
645
|
+
meter: {
|
|
646
|
+
costBasis: rateResolution.costBasis,
|
|
647
|
+
rateSource: rateResolution.rateSource,
|
|
648
|
+
},
|
|
432
649
|
...(callAuditDegraded ? { auditDegraded: true } : {}),
|
|
433
650
|
// AUD-456: Flag proxy stub receipts
|
|
434
651
|
...(proxyConn != null ? { proxyStub: true } : {}),
|
|
@@ -482,6 +699,10 @@ export async function trust(client, opts) {
|
|
|
482
699
|
deltaTokens: obs.deltaTokens,
|
|
483
700
|
cumulativeInputTokens: obs.cumulativeInputTokens,
|
|
484
701
|
cumulativeOutputTokens: obs.cumulativeOutputTokens,
|
|
702
|
+
// M2: stamp the per-call scope so the SHARED detector prices
|
|
703
|
+
// this event with the same scoped rates as settlement.
|
|
704
|
+
model,
|
|
705
|
+
endpointClass: endpoint.class,
|
|
485
706
|
});
|
|
486
707
|
const verdict = anomalyDetector.check();
|
|
487
708
|
if (verdict.tripped) {
|
|
@@ -520,6 +741,12 @@ export async function trust(client, opts) {
|
|
|
520
741
|
model,
|
|
521
742
|
provider: kind,
|
|
522
743
|
timestamp: new Date().toISOString(),
|
|
744
|
+
// M2: authorize-time scope, already fixed for the eventual settle (A3).
|
|
745
|
+
endpoint: { class: endpoint.class, runtime: endpoint.runtime },
|
|
746
|
+
meter: {
|
|
747
|
+
costBasis: rateResolution.costBasis,
|
|
748
|
+
rateSource: rateResolution.rateSource,
|
|
749
|
+
},
|
|
523
750
|
...(callAuditDegraded ? { auditDegraded: true } : {}),
|
|
524
751
|
// AUD-456: Flag proxy stub receipts
|
|
525
752
|
...(proxyConn != null ? { proxyStub: true } : {}),
|
|
@@ -527,8 +754,11 @@ export async function trust(client, opts) {
|
|
|
527
754
|
inFlightStreamCount++;
|
|
528
755
|
return { response: governedStream, receipt: estimatedReceipt };
|
|
529
756
|
}
|
|
530
|
-
// f. Compute actual cost from response usage
|
|
757
|
+
// f. Compute actual cost from response usage. A3: priced with the rate
|
|
758
|
+
// resolution captured at authorize. costFromRates clamps NaN/negative
|
|
759
|
+
// counts (A7) and floors at 1 even for 0/0 provider usage (A11).
|
|
531
760
|
let actualCost = estimatedCost;
|
|
761
|
+
let usageSource = "estimated";
|
|
532
762
|
if (response != null && typeof response === "object" && "usage" in response) {
|
|
533
763
|
const usage = response.usage;
|
|
534
764
|
if (usage != null) {
|
|
@@ -538,13 +768,60 @@ export async function trust(client, opts) {
|
|
|
538
768
|
const outputTokens = usage.output_tokens ??
|
|
539
769
|
usage.completion_tokens ??
|
|
540
770
|
0;
|
|
541
|
-
actualCost =
|
|
771
|
+
actualCost = costFromRates(rateResolution.rates, inputTokens, outputTokens);
|
|
772
|
+
usageSource = "provider";
|
|
542
773
|
}
|
|
543
774
|
}
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
775
|
+
// h. Audit the llm_call FIRST (P3-AUDIT-FAILCLOSED). The settlement-
|
|
776
|
+
// defining event is written BEFORE the irreversible budget commit and
|
|
777
|
+
// POST, so a fail-closed deployment never settles an unaudited spend.
|
|
778
|
+
// The event carries settled:true optimistically; a later POST failure
|
|
779
|
+
// appends the settlement_ambiguous correction and flips receipt.settled.
|
|
780
|
+
const syntheticHash = createHash("sha256").update(transferId).digest("hex");
|
|
781
|
+
let auditHash = syntheticHash;
|
|
782
|
+
let llmAuditFailed = false;
|
|
783
|
+
try {
|
|
784
|
+
const auditData = {
|
|
785
|
+
model,
|
|
786
|
+
cost: actualCost,
|
|
787
|
+
settled: true,
|
|
788
|
+
transferId,
|
|
789
|
+
usageSource,
|
|
790
|
+
// M2: metering provenance mirrors the receipt (A3 authorize-time scope).
|
|
791
|
+
endpointClass: endpoint.class,
|
|
792
|
+
costBasis: rateResolution.costBasis,
|
|
793
|
+
rateSource: rateResolution.rateSource,
|
|
794
|
+
};
|
|
795
|
+
if (config.pii === "warn" || config.pii === "redact") {
|
|
796
|
+
const piiResult = redactPII(promptParts);
|
|
797
|
+
if (piiResult.detection.found) {
|
|
798
|
+
auditData.piiDetected = piiResult.detection.types;
|
|
799
|
+
auditData.piiPaths = piiResult.detection.paths;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const auditEvent = await audit.appendEvent({
|
|
803
|
+
kind: "llm_call",
|
|
804
|
+
actor: "local",
|
|
805
|
+
data: auditData,
|
|
806
|
+
});
|
|
807
|
+
auditHash = auditEvent.hash;
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
// Failure mode 15.3: Audit degraded — mark + warn.
|
|
811
|
+
llmAuditFailed = true;
|
|
812
|
+
callAuditDegraded = true;
|
|
813
|
+
process.stderr.write(`[usertrust] audit degraded: failed to write llm_call event for ${transferId}\n`);
|
|
814
|
+
}
|
|
815
|
+
// P3-AUDIT-FAILCLOSED: under failClosed a failed llm_call audit ABORTS the
|
|
816
|
+
// call before any money moves. Throwing here routes to the outer catch,
|
|
817
|
+
// which VOIDs the hold (once) and never POSTs — the internal ledger holds
|
|
818
|
+
// no unaudited spend, and the caller is told the call failed.
|
|
819
|
+
if (config.audit.failClosed && llmAuditFailed) {
|
|
820
|
+
throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
|
|
821
|
+
}
|
|
822
|
+
// Release in-flight hold and commit budget under mutex — money moves only
|
|
823
|
+
// AFTER the spend is audited. AUD-468: mark hold released before the commit
|
|
824
|
+
// so a later throw cannot double-decrement inFlightHoldTotal via the catch.
|
|
548
825
|
if (holdActive) {
|
|
549
826
|
holdActive = false;
|
|
550
827
|
const releaseLock = await budgetMutex.acquire();
|
|
@@ -559,7 +836,8 @@ export async function trust(client, opts) {
|
|
|
559
836
|
// g2. Failure mode 15.1: POST fails after LLM success
|
|
560
837
|
if (engine != null && !isDryRun) {
|
|
561
838
|
try {
|
|
562
|
-
|
|
839
|
+
// Post the ACTUAL consumed cost (RECON #3).
|
|
840
|
+
await engine.postPendingSpend(transferId, actualCost);
|
|
563
841
|
}
|
|
564
842
|
catch (postErr) {
|
|
565
843
|
// POST failed — LLM call succeeded but settlement is ambiguous
|
|
@@ -607,34 +885,11 @@ export async function trust(client, opts) {
|
|
|
607
885
|
});
|
|
608
886
|
}
|
|
609
887
|
}
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
model,
|
|
616
|
-
cost: actualCost,
|
|
617
|
-
settled,
|
|
618
|
-
transferId,
|
|
619
|
-
};
|
|
620
|
-
if (config.pii === "warn" || config.pii === "redact") {
|
|
621
|
-
const piiResult = redactPII(messages);
|
|
622
|
-
if (piiResult.detection.found) {
|
|
623
|
-
auditData.piiDetected = piiResult.detection.types;
|
|
624
|
-
auditData.piiPaths = piiResult.detection.paths;
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
const auditEvent = await audit.appendEvent({
|
|
628
|
-
kind: "llm_call",
|
|
629
|
-
actor: "local",
|
|
630
|
-
data: auditData,
|
|
631
|
-
});
|
|
632
|
-
auditHash = auditEvent.hash;
|
|
633
|
-
}
|
|
634
|
-
catch {
|
|
635
|
-
// Failure mode 15.3: Audit degraded — do not fail the response
|
|
636
|
-
callAuditDegraded = true;
|
|
637
|
-
process.stderr.write(`[usertrust] audit degraded: failed to write llm_call event for ${transferId}\n`);
|
|
888
|
+
// P3-AUDIT-FAILCLOSED belt-and-suspenders: if ANY audit write during this
|
|
889
|
+
// call degraded the writer (including best-effort advisory writes), refuse
|
|
890
|
+
// to report success under failClosed so the caller cannot silently proceed.
|
|
891
|
+
if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
|
|
892
|
+
throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
|
|
638
893
|
}
|
|
639
894
|
// i. Daily-rotated audit receipt (non-blocking)
|
|
640
895
|
if (config.audit.rotation !== "none") {
|
|
@@ -647,7 +902,7 @@ export async function trust(client, opts) {
|
|
|
647
902
|
}
|
|
648
903
|
// i2. Pattern memory
|
|
649
904
|
if (config.patterns.enabled) {
|
|
650
|
-
const promptHash = createHash("sha256").update(JSON.stringify(
|
|
905
|
+
const promptHash = createHash("sha256").update(JSON.stringify(promptParts)).digest("hex");
|
|
651
906
|
await recordPattern({
|
|
652
907
|
promptHash,
|
|
653
908
|
model,
|
|
@@ -667,6 +922,13 @@ export async function trust(client, opts) {
|
|
|
667
922
|
model,
|
|
668
923
|
provider: kind,
|
|
669
924
|
timestamp: new Date().toISOString(),
|
|
925
|
+
usageSource,
|
|
926
|
+
// M2 provenance (A6: computeMs omitted — no compute-time source here).
|
|
927
|
+
endpoint: { class: endpoint.class, runtime: endpoint.runtime },
|
|
928
|
+
meter: {
|
|
929
|
+
costBasis: rateResolution.costBasis,
|
|
930
|
+
rateSource: rateResolution.rateSource,
|
|
931
|
+
},
|
|
670
932
|
...(callAuditDegraded ? { auditDegraded: true } : {}),
|
|
671
933
|
// AUD-456: Flag proxy stub receipts
|
|
672
934
|
...(proxyConn != null ? { proxyStub: true } : {}),
|
|
@@ -718,7 +980,7 @@ export async function trust(client, opts) {
|
|
|
718
980
|
});
|
|
719
981
|
// l. Pattern memory: record failure
|
|
720
982
|
if (config.patterns.enabled) {
|
|
721
|
-
const promptHash = createHash("sha256").update(JSON.stringify(
|
|
983
|
+
const promptHash = createHash("sha256").update(JSON.stringify(promptParts)).digest("hex");
|
|
722
984
|
await recordPattern({
|
|
723
985
|
promptHash,
|
|
724
986
|
model,
|
|
@@ -756,13 +1018,17 @@ export async function trust(client, opts) {
|
|
|
756
1018
|
let proxyTransferId;
|
|
757
1019
|
try {
|
|
758
1020
|
// c. Policy gate — action fields available in context
|
|
759
|
-
// AUD-467: Caller params spread FIRST so governance fields cannot be shadowed
|
|
1021
|
+
// AUD-467: Caller params spread FIRST so governance fields cannot be shadowed.
|
|
1022
|
+
// P1-BUDGET-PREFLIGHT: budget_remaining_after is the derived field the
|
|
1023
|
+
// block-budget-overshoot default rule compares against zero (a HARD rule
|
|
1024
|
+
// that fails CLOSED if the governor omits it — so it MUST be supplied).
|
|
760
1025
|
const policyResult = evaluatePolicy(policyRules, {
|
|
761
1026
|
...(action.params ?? {}),
|
|
762
1027
|
action_kind: action.kind,
|
|
763
1028
|
action_name: action.name,
|
|
764
1029
|
estimated_cost: action.cost,
|
|
765
1030
|
budget_remaining: config.budget - budgetSpent - inFlightHoldTotal,
|
|
1031
|
+
budget_remaining_after: config.budget - budgetSpent - inFlightHoldTotal - action.cost,
|
|
766
1032
|
tier: config.tier,
|
|
767
1033
|
});
|
|
768
1034
|
if (policyResult.decision === "deny") {
|
|
@@ -820,6 +1086,11 @@ export async function trust(client, opts) {
|
|
|
820
1086
|
});
|
|
821
1087
|
}
|
|
822
1088
|
catch (holdErr) {
|
|
1089
|
+
// P1-LEDGER-ENFORCE: over-budget reservation → hard budget DENY,
|
|
1090
|
+
// not a ledger-outage misreport.
|
|
1091
|
+
if (holdErr instanceof InsufficientBalanceError) {
|
|
1092
|
+
throw holdErr;
|
|
1093
|
+
}
|
|
823
1094
|
throw new LedgerUnavailableError(holdErr instanceof Error ? holdErr.message : String(holdErr));
|
|
824
1095
|
}
|
|
825
1096
|
}
|
|
@@ -844,7 +1115,52 @@ export async function trust(client, opts) {
|
|
|
844
1115
|
}
|
|
845
1116
|
try {
|
|
846
1117
|
const result = await execute();
|
|
847
|
-
//
|
|
1118
|
+
// i. Prepare params for audit — redact PII if configured.
|
|
1119
|
+
let auditParams;
|
|
1120
|
+
if (action.params != null) {
|
|
1121
|
+
if (config.pii === "warn" || config.pii === "redact") {
|
|
1122
|
+
const redacted = redactPII(action.params);
|
|
1123
|
+
auditParams = redacted.data;
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
auditParams = action.params;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
// i2. Audit the action FIRST (P3-AUDIT-FAILCLOSED). The settlement-
|
|
1130
|
+
// defining event precedes the budget commit + POST, so a fail-closed
|
|
1131
|
+
// deployment never settles an unaudited action. settled:true is
|
|
1132
|
+
// optimistic; a later POST failure appends settlement_ambiguous.
|
|
1133
|
+
const syntheticHash = createHash("sha256").update(transferId).digest("hex");
|
|
1134
|
+
let auditHash = syntheticHash;
|
|
1135
|
+
let actionAuditFailed = false;
|
|
1136
|
+
try {
|
|
1137
|
+
const auditEvent = await audit.appendEvent({
|
|
1138
|
+
kind: action.kind,
|
|
1139
|
+
actor,
|
|
1140
|
+
data: {
|
|
1141
|
+
actionName: action.name,
|
|
1142
|
+
cost: action.cost,
|
|
1143
|
+
settled: true,
|
|
1144
|
+
transferId,
|
|
1145
|
+
...(auditParams != null ? { params: auditParams } : {}),
|
|
1146
|
+
},
|
|
1147
|
+
});
|
|
1148
|
+
auditHash = auditEvent.hash;
|
|
1149
|
+
}
|
|
1150
|
+
catch {
|
|
1151
|
+
// Failure mode 15.3: Audit degraded — mark + warn.
|
|
1152
|
+
actionAuditFailed = true;
|
|
1153
|
+
callAuditDegraded = true;
|
|
1154
|
+
process.stderr.write(`[usertrust] audit degraded: failed to write ${action.kind} event for ${transferId}\n`);
|
|
1155
|
+
}
|
|
1156
|
+
// P3-AUDIT-FAILCLOSED: under failClosed a failed action audit ABORTS the
|
|
1157
|
+
// call before any money moves — the outer catch VOIDs the hold (once) and
|
|
1158
|
+
// never POSTs.
|
|
1159
|
+
if (config.audit.failClosed && actionAuditFailed) {
|
|
1160
|
+
throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
|
|
1161
|
+
}
|
|
1162
|
+
// Release in-flight hold and commit budget under mutex — money moves only
|
|
1163
|
+
// AFTER the action is audited.
|
|
848
1164
|
await releaseHoldAndCommit(action.cost);
|
|
849
1165
|
await persistSpendLedger(vaultBase, budgetSpent);
|
|
850
1166
|
// g. Circuit breaker: record success
|
|
@@ -853,7 +1169,8 @@ export async function trust(client, opts) {
|
|
|
853
1169
|
let settled = true;
|
|
854
1170
|
if (engine != null && !isDryRun) {
|
|
855
1171
|
try {
|
|
856
|
-
|
|
1172
|
+
// Post the ACTUAL cost (RECON #3).
|
|
1173
|
+
await engine.postPendingSpend(transferId, action.cost);
|
|
857
1174
|
}
|
|
858
1175
|
catch (postErr) {
|
|
859
1176
|
settled = false;
|
|
@@ -901,38 +1218,10 @@ export async function trust(client, opts) {
|
|
|
901
1218
|
});
|
|
902
1219
|
}
|
|
903
1220
|
}
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
if (
|
|
907
|
-
|
|
908
|
-
const redacted = redactPII(action.params);
|
|
909
|
-
auditParams = redacted.data;
|
|
910
|
-
}
|
|
911
|
-
else {
|
|
912
|
-
auditParams = action.params;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
// i2. Audit event
|
|
916
|
-
const syntheticHash = createHash("sha256").update(transferId).digest("hex");
|
|
917
|
-
let auditHash = syntheticHash;
|
|
918
|
-
try {
|
|
919
|
-
const auditEvent = await audit.appendEvent({
|
|
920
|
-
kind: action.kind,
|
|
921
|
-
actor,
|
|
922
|
-
data: {
|
|
923
|
-
actionName: action.name,
|
|
924
|
-
cost: action.cost,
|
|
925
|
-
settled,
|
|
926
|
-
transferId,
|
|
927
|
-
...(auditParams != null ? { params: auditParams } : {}),
|
|
928
|
-
},
|
|
929
|
-
});
|
|
930
|
-
auditHash = auditEvent.hash;
|
|
931
|
-
}
|
|
932
|
-
catch {
|
|
933
|
-
// Failure mode 15.3: Audit degraded — do not fail the response
|
|
934
|
-
callAuditDegraded = true;
|
|
935
|
-
process.stderr.write(`[usertrust] audit degraded: failed to write ${action.kind} event for ${transferId}\n`);
|
|
1221
|
+
// P3-AUDIT-FAILCLOSED belt-and-suspenders: refuse to report success if any
|
|
1222
|
+
// audit write during this call degraded the writer under failClosed.
|
|
1223
|
+
if (config.audit.failClosed && (callAuditDegraded || audit.isDegraded())) {
|
|
1224
|
+
throw new AuditDegradedError(`audit unavailable (writeFailures=${audit.getWriteFailures()}) for ${transferId}`);
|
|
936
1225
|
}
|
|
937
1226
|
// j. Daily-rotated receipt
|
|
938
1227
|
if (config.audit.rotation !== "none") {
|
|
@@ -1071,44 +1360,103 @@ export async function trust(client, opts) {
|
|
|
1071
1360
|
process.on("beforeExit", beforeExitHandler);
|
|
1072
1361
|
return governedClient;
|
|
1073
1362
|
}
|
|
1363
|
+
// ── Provider-aware prompt extraction (P3-PROVIDER-BLINDSPOT) ──
|
|
1364
|
+
/**
|
|
1365
|
+
* Normalize the prompt-bearing payload across providers so PII/injection scanning
|
|
1366
|
+
* and token estimation cover ALL shapes, not just Anthropic/OpenAI `messages`:
|
|
1367
|
+
* - Anthropic/OpenAI: `params.messages` (+ Anthropic top-level `system` string)
|
|
1368
|
+
* - Google: `params.contents` (the prompt lives here, not `messages`)
|
|
1369
|
+
*
|
|
1370
|
+
* Without this, a Google `generateContent({ model, contents })` call has an empty
|
|
1371
|
+
* `messages` array, so every PII/injection scan sees nothing and PII egresses.
|
|
1372
|
+
*/
|
|
1373
|
+
function extractPromptParts(params, kind) {
|
|
1374
|
+
if (kind === "google") {
|
|
1375
|
+
const contents = params.contents;
|
|
1376
|
+
if (Array.isArray(contents))
|
|
1377
|
+
return contents;
|
|
1378
|
+
return contents != null ? [contents] : [];
|
|
1379
|
+
}
|
|
1380
|
+
const messages = params.messages;
|
|
1381
|
+
const parts = Array.isArray(messages) ? [...messages] : [];
|
|
1382
|
+
if (typeof params.system === "string") {
|
|
1383
|
+
parts.push({ role: "system", content: params.system });
|
|
1384
|
+
}
|
|
1385
|
+
return parts;
|
|
1386
|
+
}
|
|
1074
1387
|
// ── TigerBeetle engine factory ──
|
|
1388
|
+
/** TigerBeetle codes that mean "this debit would exceed the account's credits". */
|
|
1389
|
+
function isTBInsufficientBalance(err) {
|
|
1390
|
+
if (!(err instanceof TBTransferError))
|
|
1391
|
+
return false;
|
|
1392
|
+
return (err.code === CreateTransferError.exceeds_credits ||
|
|
1393
|
+
err.code === CreateTransferError.overflows_debits ||
|
|
1394
|
+
err.code === CreateTransferError.overflows_debits_pending);
|
|
1395
|
+
}
|
|
1075
1396
|
/**
|
|
1076
|
-
* Create a TrustEngine backed by a real TigerBeetle client.
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1397
|
+
* Create a balance-enforcing TrustEngine backed by a real TigerBeetle client.
|
|
1398
|
+
*
|
|
1399
|
+
* P1-LEDGER-ENFORCE: the holding account is created with
|
|
1400
|
+
* `debits_must_not_exceed_credits` and FUNDED with `seedBudget` usertokens, so a
|
|
1401
|
+
* pending debit (hold) whose cumulative amount would exceed the remaining budget
|
|
1402
|
+
* is REJECTED atomically by TigerBeetle. That rejection is surfaced as an
|
|
1403
|
+
* {@link InsufficientBalanceError}, which the governor re-throws as a hard budget
|
|
1404
|
+
* DENY (never as a ledger outage). The prior escrow account had no enforcing flag
|
|
1405
|
+
* and no funding, so `spendPending` could never reject an over-budget hold.
|
|
1406
|
+
*
|
|
1407
|
+
* NOTE (cross-domain): RECON #3 designates `createLedgerEngine` /
|
|
1408
|
+
* `createFundedBudgetWallet` (LEDGER-owned, in `ledger/engine.ts`) as the eventual
|
|
1409
|
+
* home for this factory. Those symbols do not yet exist on disk, so this
|
|
1410
|
+
* GOVERN-local factory implements the same funded-enforcing contract using the
|
|
1411
|
+
* existing `TrustTBClient` primitives. When LEDGER ships `createLedgerEngine`,
|
|
1412
|
+
* this factory is a drop-in replacement (identical `TrustEngine` shape).
|
|
1079
1413
|
*/
|
|
1080
|
-
async function createTBEngine(config) {
|
|
1414
|
+
async function createTBEngine(config, seedBudget) {
|
|
1081
1415
|
const tbAddresses = config.tigerbeetle.addresses;
|
|
1082
1416
|
const tbClusterId = BigInt(config.tigerbeetle.clusterId);
|
|
1083
1417
|
const tbClient = new TrustTBClient({
|
|
1084
1418
|
addresses: tbAddresses,
|
|
1085
1419
|
clusterId: tbClusterId,
|
|
1086
1420
|
});
|
|
1087
|
-
//
|
|
1421
|
+
// Treasury (unconstrained) funds a per-session enforcing holding wallet.
|
|
1088
1422
|
await tbClient.createTreasury();
|
|
1089
|
-
|
|
1423
|
+
const treasury = tbClient.getTreasuryId();
|
|
1424
|
+
// Enforcing holding wallet (debits_must_not_exceed_credits), funded with the
|
|
1425
|
+
// remaining session budget so cumulative pending debits cannot exceed it.
|
|
1426
|
+
// A FRESH account id per session prevents double-funding a deterministic
|
|
1427
|
+
// account across restarts (which would inflate the TB-enforced budget).
|
|
1428
|
+
const holdingId = await tbClient.createFundedBudgetWallet(seedBudget);
|
|
1090
1429
|
// Pending transfer ID mapping (trustId string -> TB bigint)
|
|
1091
1430
|
const pendingMap = new Map();
|
|
1092
1431
|
return {
|
|
1093
1432
|
async spendPending(params) {
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1433
|
+
try {
|
|
1434
|
+
const tbTransferId = await tbClient.createPendingTransfer({
|
|
1435
|
+
debitAccountId: holdingId,
|
|
1436
|
+
creditAccountId: treasury,
|
|
1437
|
+
amount: params.amount,
|
|
1438
|
+
code: XFER_SPEND,
|
|
1439
|
+
});
|
|
1440
|
+
pendingMap.set(params.transferId, tbTransferId);
|
|
1441
|
+
return { transferId: params.transferId };
|
|
1442
|
+
}
|
|
1443
|
+
catch (err) {
|
|
1444
|
+
// Over-budget reservation → TB rejects the pending debit. Surface as a
|
|
1445
|
+
// budget error so the governor reports a hard DENY, not an outage.
|
|
1446
|
+
if (isTBInsufficientBalance(err)) {
|
|
1447
|
+
throw new InsufficientBalanceError("trust:hold", params.amount, seedBudget);
|
|
1448
|
+
}
|
|
1449
|
+
throw err;
|
|
1450
|
+
}
|
|
1105
1451
|
},
|
|
1106
|
-
async postPendingSpend(transferId) {
|
|
1452
|
+
async postPendingSpend(transferId, actualAmount) {
|
|
1107
1453
|
const tbId = pendingMap.get(transferId);
|
|
1108
1454
|
if (tbId === undefined) {
|
|
1109
1455
|
throw new Error(`No pending transfer found for ${transferId}`);
|
|
1110
1456
|
}
|
|
1111
|
-
|
|
1457
|
+
// Post the ACTUAL consumed amount (≤ the reserved estimate); omitting it
|
|
1458
|
+
// posts the full pending amount.
|
|
1459
|
+
await tbClient.postTransfer(tbId, actualAmount);
|
|
1112
1460
|
pendingMap.delete(transferId);
|
|
1113
1461
|
},
|
|
1114
1462
|
async voidPendingSpend(transferId) {
|