@graphorin/provider 0.6.1 → 0.7.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/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/dist/adapters/llamacpp-server.d.ts +1 -1
- package/dist/adapters/llamacpp-server.js.map +1 -1
- package/dist/adapters/ollama.d.ts.map +1 -1
- package/dist/adapters/ollama.js +19 -6
- package/dist/adapters/ollama.js.map +1 -1
- package/dist/adapters/vercel.d.ts +1 -1
- package/dist/adapters/vercel.d.ts.map +1 -1
- package/dist/adapters/vercel.js +111 -33
- package/dist/adapters/vercel.js.map +1 -1
- package/dist/errors/errors.d.ts +1 -1
- package/dist/errors/errors.js +1 -1
- package/dist/errors/errors.js.map +1 -1
- package/dist/index.js +0 -6
- package/dist/index.js.map +1 -1
- package/dist/internal/http.js +111 -7
- package/dist/internal/http.js.map +1 -1
- package/dist/internal/openai-shaped.js +21 -4
- package/dist/internal/openai-shaped.js.map +1 -1
- package/dist/middleware/with-fallback.js +1 -1
- package/dist/middleware/with-rate-limit.d.ts +21 -8
- package/dist/middleware/with-rate-limit.d.ts.map +1 -1
- package/dist/middleware/with-rate-limit.js +65 -12
- package/dist/middleware/with-rate-limit.js.map +1 -1
- package/dist/middleware/with-retry.js +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +18 -16
- package/src/adapters/index.ts +14 -0
- package/src/adapters/llamacpp-server.ts +102 -0
- package/src/adapters/ollama.ts +382 -0
- package/src/adapters/openai-compatible.ts +95 -0
- package/src/adapters/vercel-messages.ts +308 -0
- package/src/adapters/vercel.ts +706 -0
- package/src/counters/anthropic-wire.ts +199 -0
- package/src/counters/anthropic.ts +114 -0
- package/src/counters/bedrock.ts +46 -0
- package/src/counters/dispatcher.ts +127 -0
- package/src/counters/global.ts +39 -0
- package/src/counters/google.ts +46 -0
- package/src/counters/heuristic.ts +107 -0
- package/src/counters/index.ts +35 -0
- package/src/counters/js-tiktoken.ts +135 -0
- package/src/counters/serialize.ts +85 -0
- package/src/errors/errors.ts +316 -0
- package/src/errors/index.ts +20 -0
- package/src/index.ts +42 -0
- package/src/internal/abort.ts +30 -0
- package/src/internal/http.ts +388 -0
- package/src/internal/openai-shaped.ts +555 -0
- package/src/internal/sse.ts +112 -0
- package/src/internal/url-utils.ts +20 -0
- package/src/middleware/compose.ts +213 -0
- package/src/middleware/index.ts +37 -0
- package/src/middleware/production-hook.ts +47 -0
- package/src/middleware/with-cost-limit.ts +131 -0
- package/src/middleware/with-cost-tracking.ts +216 -0
- package/src/middleware/with-fallback.ts +157 -0
- package/src/middleware/with-rate-limit.ts +306 -0
- package/src/middleware/with-redaction.ts +671 -0
- package/src/middleware/with-retry.ts +274 -0
- package/src/middleware/with-tracing.ts +117 -0
- package/src/model-tier/classify.ts +125 -0
- package/src/model-tier/index.ts +11 -0
- package/src/provider.ts +121 -0
- package/src/reasoning/apply-policy.ts +89 -0
- package/src/reasoning/classify-contract.ts +120 -0
- package/src/reasoning/index.ts +21 -0
- package/src/reasoning/retention.ts +64 -0
- package/src/tool-examples.ts +54 -0
- package/src/trust/classify-local-provider.ts +254 -0
- package/src/trust/index.ts +14 -0
- package/dist/adapters/index.js +0 -6
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `withRedaction` - outbound prompt-redaction middleware. Innermost
|
|
3
|
+
* layer in the canonical order: it runs after every other middleware
|
|
4
|
+
* has shaped the request and immediately before the underlying
|
|
5
|
+
* provider call. The middleware:
|
|
6
|
+
*
|
|
7
|
+
* 1. Walks every text-bearing field of `ProviderRequest` (system /
|
|
8
|
+
* user / assistant / tool messages, tool-call args, vendor-prefixed
|
|
9
|
+
* `cache_control` spans).
|
|
10
|
+
* 2. Detects `SecretValue`-shaped instances (cross-realm safe via
|
|
11
|
+
* `Symbol.for('graphorin.SecretValue')`).
|
|
12
|
+
* 3. Runs the configured pattern catalogue (defaults to the 14 built-in
|
|
13
|
+
* patterns shared with the OTLP `RedactionValidator`).
|
|
14
|
+
* 4. Applies the configured action (`'redact'` in-place by default;
|
|
15
|
+
* `'throw'` for `failClosed: true` deployments).
|
|
16
|
+
* 5. Strips Anthropic-shape `cache_control` markers from any span
|
|
17
|
+
* that hit a pattern (default `stripCacheControlOnHit: true`).
|
|
18
|
+
* 6. Emits one counter increment per detection plus a sanitised audit
|
|
19
|
+
* record (audit emission is delegated to the consumer via
|
|
20
|
+
* `onViolation`).
|
|
21
|
+
* 7. Optionally scans streamed `text-delta` chunks for the same
|
|
22
|
+
* patterns - observability-only in v0.1, no stream mutation.
|
|
23
|
+
*
|
|
24
|
+
* @packageDocumentation
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
AssistantMessage,
|
|
29
|
+
LocalProviderTrust,
|
|
30
|
+
Message,
|
|
31
|
+
Provider,
|
|
32
|
+
ProviderEvent,
|
|
33
|
+
ProviderRequest,
|
|
34
|
+
ProviderResponse,
|
|
35
|
+
Sensitivity,
|
|
36
|
+
ToolMessage,
|
|
37
|
+
UserMessage,
|
|
38
|
+
} from '@graphorin/core';
|
|
39
|
+
import { SECRET_VALUE_BRAND } from '@graphorin/core';
|
|
40
|
+
import {
|
|
41
|
+
BUILT_IN_PATTERNS,
|
|
42
|
+
type RedactionPattern,
|
|
43
|
+
} from '@graphorin/observability/redaction/patterns';
|
|
44
|
+
import { PromptRedactionError } from '../errors/errors.js';
|
|
45
|
+
import { defineProviderMiddleware } from './compose.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Range of fields scanned by the middleware.
|
|
49
|
+
*
|
|
50
|
+
* @stable
|
|
51
|
+
*/
|
|
52
|
+
export type PromptRedactionScanScope = 'all' | 'untrusted' | 'secret-value-only';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sanitized record handed to `onViolation`. Carries metadata only;
|
|
56
|
+
* never the matched value.
|
|
57
|
+
*
|
|
58
|
+
* @stable
|
|
59
|
+
*/
|
|
60
|
+
export interface PromptRedactionViolation {
|
|
61
|
+
readonly patternName: string;
|
|
62
|
+
readonly fieldPath: string;
|
|
63
|
+
readonly role?: string;
|
|
64
|
+
readonly matchLength: number;
|
|
65
|
+
readonly trustClass?: LocalProviderTrust;
|
|
66
|
+
readonly action: 'redact' | 'throw' | 'block-and-prompt-user';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mutable per-request scrubbing context. Tracks whether any pattern
|
|
71
|
+
* matched so the post-scrub `cache_control` strip can decide whether
|
|
72
|
+
* to fire.
|
|
73
|
+
*
|
|
74
|
+
* @internal
|
|
75
|
+
*/
|
|
76
|
+
interface ScrubContext {
|
|
77
|
+
hits: number;
|
|
78
|
+
cacheControlHits: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Full prompt-redaction policy.
|
|
83
|
+
*
|
|
84
|
+
* @stable
|
|
85
|
+
*/
|
|
86
|
+
export interface PromptRedactionPolicy {
|
|
87
|
+
/** Pattern catalogue. Defaults to the 14 built-in patterns. */
|
|
88
|
+
readonly patterns?: ReadonlyArray<RedactionPattern>;
|
|
89
|
+
/** Action on detection. Defaults to `'redact'`. */
|
|
90
|
+
readonly action?: 'redact' | 'throw' | 'block-and-prompt-user';
|
|
91
|
+
/** Throw on the first hit instead of redacting in-place. */
|
|
92
|
+
readonly failClosed?: boolean;
|
|
93
|
+
/** Range of fields scanned. Defaults to `'all'`. */
|
|
94
|
+
readonly scanScope?: PromptRedactionScanScope;
|
|
95
|
+
/** Detect `SecretValue` instances anywhere in the request. */
|
|
96
|
+
readonly detectSecretValue?: boolean;
|
|
97
|
+
/** Strip Anthropic-shape `cache_control` markers on hit. */
|
|
98
|
+
readonly stripCacheControlOnHit?: boolean;
|
|
99
|
+
/** Per-trust-class override block. */
|
|
100
|
+
readonly byTrustClass?: Partial<Record<LocalProviderTrust, Partial<PromptRedactionPolicy>>>;
|
|
101
|
+
/** Sanitised violation hook (audit emission lives downstream). */
|
|
102
|
+
readonly onViolation?: (violation: PromptRedactionViolation) => void;
|
|
103
|
+
/** Optional logger override. Defaults to `console.warn`. */
|
|
104
|
+
readonly logger?: (message: string, meta?: object) => void;
|
|
105
|
+
/** Test hook - synthetic trust class. */
|
|
106
|
+
readonly trustClassOverride?: LocalProviderTrust;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const TRUST_CLASS_DEFAULTS: Readonly<Record<LocalProviderTrust, Partial<PromptRedactionPolicy>>> =
|
|
110
|
+
Object.freeze({
|
|
111
|
+
loopback: { scanScope: 'secret-value-only' },
|
|
112
|
+
private: { scanScope: 'all', failClosed: false },
|
|
113
|
+
'public-tls': { scanScope: 'all', failClosed: false },
|
|
114
|
+
'public-cleartext': {},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @stable
|
|
119
|
+
*/
|
|
120
|
+
export const withRedaction = defineProviderMiddleware<PromptRedactionPolicy>({
|
|
121
|
+
kind: 'withRedaction',
|
|
122
|
+
factory: (rawPolicy: PromptRedactionPolicy) => {
|
|
123
|
+
const baseLogger = rawPolicy.logger ?? defaultLogger;
|
|
124
|
+
return (next: Provider): Provider => {
|
|
125
|
+
const trustClass = rawPolicy.trustClassOverride ?? inferTrustClass(next.acceptsSensitivity);
|
|
126
|
+
const policy = effectivePolicy(rawPolicy, trustClass);
|
|
127
|
+
return {
|
|
128
|
+
name: next.name,
|
|
129
|
+
modelId: next.modelId,
|
|
130
|
+
capabilities: next.capabilities,
|
|
131
|
+
...(next.acceptsSensitivity !== undefined
|
|
132
|
+
? { acceptsSensitivity: next.acceptsSensitivity }
|
|
133
|
+
: {}),
|
|
134
|
+
stream(req) {
|
|
135
|
+
const scrubbed = scrubRequest(req, policy, trustClass, baseLogger);
|
|
136
|
+
return scanStreamingResponse(next.stream(scrubbed), policy, baseLogger);
|
|
137
|
+
},
|
|
138
|
+
async generate(req) {
|
|
139
|
+
const scrubbed = scrubRequest(req, policy, trustClass, baseLogger);
|
|
140
|
+
return next.generate(scrubbed);
|
|
141
|
+
},
|
|
142
|
+
...(next.countTokens ? { countTokens: next.countTokens.bind(next) } : {}),
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
function effectivePolicy(
|
|
149
|
+
raw: PromptRedactionPolicy,
|
|
150
|
+
trustClass: LocalProviderTrust | undefined,
|
|
151
|
+
): RequiredPolicy {
|
|
152
|
+
const tierDefaults = trustClass !== undefined ? TRUST_CLASS_DEFAULTS[trustClass] : {};
|
|
153
|
+
const explicitOverride = trustClass !== undefined ? (raw.byTrustClass?.[trustClass] ?? {}) : {};
|
|
154
|
+
const merged: RequiredPolicy = {
|
|
155
|
+
patterns: ensureGlobalPatterns(
|
|
156
|
+
raw.patterns ?? explicitOverride.patterns ?? tierDefaults.patterns ?? BUILT_IN_PATTERNS,
|
|
157
|
+
),
|
|
158
|
+
action: raw.action ?? explicitOverride.action ?? tierDefaults.action ?? 'redact',
|
|
159
|
+
failClosed: pick(raw.failClosed, explicitOverride.failClosed, tierDefaults.failClosed, false),
|
|
160
|
+
scanScope: raw.scanScope ?? explicitOverride.scanScope ?? tierDefaults.scanScope ?? 'all',
|
|
161
|
+
detectSecretValue: pick(
|
|
162
|
+
raw.detectSecretValue,
|
|
163
|
+
explicitOverride.detectSecretValue,
|
|
164
|
+
tierDefaults.detectSecretValue,
|
|
165
|
+
true,
|
|
166
|
+
),
|
|
167
|
+
stripCacheControlOnHit: pick(
|
|
168
|
+
raw.stripCacheControlOnHit,
|
|
169
|
+
explicitOverride.stripCacheControlOnHit,
|
|
170
|
+
tierDefaults.stripCacheControlOnHit,
|
|
171
|
+
true,
|
|
172
|
+
),
|
|
173
|
+
onViolation: raw.onViolation,
|
|
174
|
+
};
|
|
175
|
+
if (raw.failClosed === true || explicitOverride.failClosed === true) {
|
|
176
|
+
merged.action = 'throw';
|
|
177
|
+
}
|
|
178
|
+
return merged;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* PS-22: callers can supply a pattern without the global flag. `.replace` /
|
|
183
|
+
* `.match` then stop after the FIRST occurrence, so a second secret on the same
|
|
184
|
+
* line is neither redacted nor counted. Re-compile every non-global pattern
|
|
185
|
+
* with the `g` flag so all occurrences are covered. The 14 built-ins already
|
|
186
|
+
* carry `/g`; this only rescues user-supplied patterns (identity otherwise).
|
|
187
|
+
*/
|
|
188
|
+
function ensureGlobalPatterns(
|
|
189
|
+
patterns: ReadonlyArray<RedactionPattern>,
|
|
190
|
+
): ReadonlyArray<RedactionPattern> {
|
|
191
|
+
let changed = false;
|
|
192
|
+
const out = patterns.map((p) => {
|
|
193
|
+
if (p.regex.global) return p;
|
|
194
|
+
changed = true;
|
|
195
|
+
return { ...p, regex: new RegExp(p.regex.source, `${p.regex.flags}g`) };
|
|
196
|
+
});
|
|
197
|
+
return changed ? out : patterns;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function pick<T>(...values: ReadonlyArray<T | undefined>): T {
|
|
201
|
+
for (const v of values) {
|
|
202
|
+
if (v !== undefined) return v as T;
|
|
203
|
+
}
|
|
204
|
+
// The last argument acts as the default and is always defined per
|
|
205
|
+
// the call sites above.
|
|
206
|
+
return values[values.length - 1] as T;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
interface RequiredPolicy {
|
|
210
|
+
patterns: ReadonlyArray<RedactionPattern>;
|
|
211
|
+
action: 'redact' | 'throw' | 'block-and-prompt-user';
|
|
212
|
+
failClosed: boolean;
|
|
213
|
+
scanScope: PromptRedactionScanScope;
|
|
214
|
+
detectSecretValue: boolean;
|
|
215
|
+
stripCacheControlOnHit: boolean;
|
|
216
|
+
onViolation: ((violation: PromptRedactionViolation) => void) | undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function scrubRequest(
|
|
220
|
+
req: ProviderRequest,
|
|
221
|
+
policy: RequiredPolicy,
|
|
222
|
+
trustClass: LocalProviderTrust | undefined,
|
|
223
|
+
logger: (message: string, meta?: object) => void,
|
|
224
|
+
): ProviderRequest {
|
|
225
|
+
const ctx: ScrubContext = { hits: 0, cacheControlHits: 0 };
|
|
226
|
+
const messages = req.messages.map((msg, i) =>
|
|
227
|
+
scrubMessage(msg, policy, trustClass, `messages[${i}]`, ctx, logger),
|
|
228
|
+
);
|
|
229
|
+
const systemMessage =
|
|
230
|
+
req.systemMessage !== undefined
|
|
231
|
+
? scrubText(req.systemMessage, 'system', 'systemMessage', policy, trustClass, ctx, logger)
|
|
232
|
+
: undefined;
|
|
233
|
+
let providerOptions = scrubProviderOptions(req.providerOptions, policy, trustClass, ctx, logger);
|
|
234
|
+
if (
|
|
235
|
+
policy.stripCacheControlOnHit &&
|
|
236
|
+
ctx.hits > 0 &&
|
|
237
|
+
providerOptions !== undefined &&
|
|
238
|
+
hasAnthropicCacheControl(providerOptions)
|
|
239
|
+
) {
|
|
240
|
+
providerOptions = stripAnthropicCacheControl(providerOptions);
|
|
241
|
+
reportViolation(
|
|
242
|
+
policy,
|
|
243
|
+
{
|
|
244
|
+
patternName: 'anthropic-cache-control-stripped',
|
|
245
|
+
fieldPath: 'providerOptions.anthropic.cache_control',
|
|
246
|
+
matchLength: 0,
|
|
247
|
+
...(trustClass !== undefined ? { trustClass } : {}),
|
|
248
|
+
action: 'redact',
|
|
249
|
+
},
|
|
250
|
+
logger,
|
|
251
|
+
);
|
|
252
|
+
ctx.cacheControlHits++;
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
...req,
|
|
256
|
+
messages,
|
|
257
|
+
...(systemMessage !== undefined ? { systemMessage } : {}),
|
|
258
|
+
...(providerOptions !== undefined ? { providerOptions } : {}),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function hasAnthropicCacheControl(providerOptions: Record<string, unknown>): boolean {
|
|
263
|
+
const anthropic = providerOptions.anthropic;
|
|
264
|
+
if (anthropic === null || typeof anthropic !== 'object') return false;
|
|
265
|
+
return 'cache_control' in (anthropic as Record<string, unknown>);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function stripAnthropicCacheControl(
|
|
269
|
+
providerOptions: Record<string, unknown>,
|
|
270
|
+
): Record<string, unknown> {
|
|
271
|
+
const anthropic = providerOptions.anthropic;
|
|
272
|
+
if (anthropic === null || typeof anthropic !== 'object') return providerOptions;
|
|
273
|
+
const { cache_control: _drop, ...rest } = anthropic as Record<string, unknown>;
|
|
274
|
+
void _drop;
|
|
275
|
+
return { ...providerOptions, anthropic: rest };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function scrubMessage(
|
|
279
|
+
msg: Message,
|
|
280
|
+
policy: RequiredPolicy,
|
|
281
|
+
trustClass: LocalProviderTrust | undefined,
|
|
282
|
+
fieldPrefix: string,
|
|
283
|
+
ctx: ScrubContext,
|
|
284
|
+
logger: (message: string, meta?: object) => void,
|
|
285
|
+
): Message {
|
|
286
|
+
if (policy.scanScope === 'secret-value-only') {
|
|
287
|
+
detectSecretValuesIn(msg, policy, trustClass, fieldPrefix, ctx, logger);
|
|
288
|
+
return msg;
|
|
289
|
+
}
|
|
290
|
+
if (policy.scanScope === 'untrusted' && msg.role === 'system') {
|
|
291
|
+
detectSecretValuesIn(msg, policy, trustClass, fieldPrefix, ctx, logger);
|
|
292
|
+
return msg;
|
|
293
|
+
}
|
|
294
|
+
switch (msg.role) {
|
|
295
|
+
case 'system':
|
|
296
|
+
return {
|
|
297
|
+
role: 'system',
|
|
298
|
+
content: scrubText(
|
|
299
|
+
msg.content,
|
|
300
|
+
'system',
|
|
301
|
+
`${fieldPrefix}.content`,
|
|
302
|
+
policy,
|
|
303
|
+
trustClass,
|
|
304
|
+
ctx,
|
|
305
|
+
logger,
|
|
306
|
+
),
|
|
307
|
+
};
|
|
308
|
+
case 'user':
|
|
309
|
+
return scrubUserMessage(msg, policy, trustClass, fieldPrefix, ctx, logger);
|
|
310
|
+
case 'assistant':
|
|
311
|
+
return scrubAssistantMessage(msg, policy, trustClass, fieldPrefix, ctx, logger);
|
|
312
|
+
case 'tool':
|
|
313
|
+
return scrubToolMessage(msg, policy, trustClass, fieldPrefix, ctx, logger);
|
|
314
|
+
default:
|
|
315
|
+
return msg;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function scrubUserMessage(
|
|
320
|
+
msg: UserMessage,
|
|
321
|
+
policy: RequiredPolicy,
|
|
322
|
+
trustClass: LocalProviderTrust | undefined,
|
|
323
|
+
fieldPrefix: string,
|
|
324
|
+
ctx: ScrubContext,
|
|
325
|
+
logger: (message: string, meta?: object) => void,
|
|
326
|
+
): UserMessage {
|
|
327
|
+
const next: UserMessage = {
|
|
328
|
+
role: 'user',
|
|
329
|
+
content: scrubContent(
|
|
330
|
+
msg.content,
|
|
331
|
+
'user',
|
|
332
|
+
`${fieldPrefix}.content`,
|
|
333
|
+
policy,
|
|
334
|
+
trustClass,
|
|
335
|
+
ctx,
|
|
336
|
+
logger,
|
|
337
|
+
) as UserMessage['content'],
|
|
338
|
+
...(msg.userId !== undefined ? { userId: msg.userId } : {}),
|
|
339
|
+
};
|
|
340
|
+
return next;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function scrubAssistantMessage(
|
|
344
|
+
msg: AssistantMessage,
|
|
345
|
+
policy: RequiredPolicy,
|
|
346
|
+
trustClass: LocalProviderTrust | undefined,
|
|
347
|
+
fieldPrefix: string,
|
|
348
|
+
ctx: ScrubContext,
|
|
349
|
+
logger: (message: string, meta?: object) => void,
|
|
350
|
+
): AssistantMessage {
|
|
351
|
+
const next: AssistantMessage = {
|
|
352
|
+
role: 'assistant',
|
|
353
|
+
content: scrubContent(
|
|
354
|
+
msg.content,
|
|
355
|
+
'assistant',
|
|
356
|
+
`${fieldPrefix}.content`,
|
|
357
|
+
policy,
|
|
358
|
+
trustClass,
|
|
359
|
+
ctx,
|
|
360
|
+
logger,
|
|
361
|
+
) as AssistantMessage['content'],
|
|
362
|
+
...(msg.agentId !== undefined ? { agentId: msg.agentId } : {}),
|
|
363
|
+
};
|
|
364
|
+
if (msg.toolCalls !== undefined) {
|
|
365
|
+
(next as { toolCalls?: AssistantMessage['toolCalls'] }).toolCalls = msg.toolCalls.map(
|
|
366
|
+
(tc, idx) => ({
|
|
367
|
+
...tc,
|
|
368
|
+
args: scrubAny(
|
|
369
|
+
tc.args,
|
|
370
|
+
'assistant',
|
|
371
|
+
`${fieldPrefix}.toolCalls[${idx}].args`,
|
|
372
|
+
policy,
|
|
373
|
+
trustClass,
|
|
374
|
+
ctx,
|
|
375
|
+
logger,
|
|
376
|
+
),
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
return next;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function scrubToolMessage(
|
|
384
|
+
msg: ToolMessage,
|
|
385
|
+
policy: RequiredPolicy,
|
|
386
|
+
trustClass: LocalProviderTrust | undefined,
|
|
387
|
+
fieldPrefix: string,
|
|
388
|
+
ctx: ScrubContext,
|
|
389
|
+
logger: (message: string, meta?: object) => void,
|
|
390
|
+
): ToolMessage {
|
|
391
|
+
return {
|
|
392
|
+
role: 'tool',
|
|
393
|
+
toolCallId: msg.toolCallId,
|
|
394
|
+
content: scrubContent(
|
|
395
|
+
msg.content,
|
|
396
|
+
'tool',
|
|
397
|
+
`${fieldPrefix}.content`,
|
|
398
|
+
policy,
|
|
399
|
+
trustClass,
|
|
400
|
+
ctx,
|
|
401
|
+
logger,
|
|
402
|
+
) as ToolMessage['content'],
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function scrubContent(
|
|
407
|
+
content: string | ReadonlyArray<unknown>,
|
|
408
|
+
role: string,
|
|
409
|
+
fieldPath: string,
|
|
410
|
+
policy: RequiredPolicy,
|
|
411
|
+
trustClass: LocalProviderTrust | undefined,
|
|
412
|
+
ctx: ScrubContext,
|
|
413
|
+
logger: (message: string, meta?: object) => void,
|
|
414
|
+
): string | ReadonlyArray<unknown> {
|
|
415
|
+
if (typeof content === 'string') {
|
|
416
|
+
return scrubText(content, role, fieldPath, policy, trustClass, ctx, logger);
|
|
417
|
+
}
|
|
418
|
+
return content.map((part, idx) => {
|
|
419
|
+
if (typeof part === 'string') {
|
|
420
|
+
return scrubText(part, role, `${fieldPath}[${idx}]`, policy, trustClass, ctx, logger);
|
|
421
|
+
}
|
|
422
|
+
if (part === null || typeof part !== 'object') return part;
|
|
423
|
+
const partWithType = part as { type?: string; text?: string };
|
|
424
|
+
if (partWithType.type === 'text' && typeof partWithType.text === 'string') {
|
|
425
|
+
return {
|
|
426
|
+
...part,
|
|
427
|
+
text: scrubText(
|
|
428
|
+
partWithType.text,
|
|
429
|
+
role,
|
|
430
|
+
`${fieldPath}[${idx}].text`,
|
|
431
|
+
policy,
|
|
432
|
+
trustClass,
|
|
433
|
+
ctx,
|
|
434
|
+
logger,
|
|
435
|
+
),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
if (partWithType.type === 'reasoning' && typeof partWithType.text === 'string') {
|
|
439
|
+
return {
|
|
440
|
+
...part,
|
|
441
|
+
text: scrubText(
|
|
442
|
+
partWithType.text,
|
|
443
|
+
role,
|
|
444
|
+
`${fieldPath}[${idx}].text`,
|
|
445
|
+
policy,
|
|
446
|
+
trustClass,
|
|
447
|
+
ctx,
|
|
448
|
+
logger,
|
|
449
|
+
),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
return part;
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function scrubText(
|
|
457
|
+
text: string,
|
|
458
|
+
role: string,
|
|
459
|
+
fieldPath: string,
|
|
460
|
+
policy: RequiredPolicy,
|
|
461
|
+
trustClass: LocalProviderTrust | undefined,
|
|
462
|
+
ctx: ScrubContext,
|
|
463
|
+
logger: (message: string, meta?: object) => void,
|
|
464
|
+
): string {
|
|
465
|
+
let scrubbed = text;
|
|
466
|
+
for (const pattern of policy.patterns) {
|
|
467
|
+
pattern.regex.lastIndex = 0;
|
|
468
|
+
if (pattern.regex.test(scrubbed)) {
|
|
469
|
+
const matches = scrubbed.match(pattern.regex) ?? [];
|
|
470
|
+
const matchLength = matches.reduce((sum, m) => sum + m.length, 0);
|
|
471
|
+
ctx.hits++;
|
|
472
|
+
reportViolation(
|
|
473
|
+
policy,
|
|
474
|
+
{
|
|
475
|
+
patternName: pattern.name,
|
|
476
|
+
fieldPath,
|
|
477
|
+
role,
|
|
478
|
+
matchLength,
|
|
479
|
+
...(trustClass !== undefined ? { trustClass } : {}),
|
|
480
|
+
action: policy.action,
|
|
481
|
+
},
|
|
482
|
+
logger,
|
|
483
|
+
);
|
|
484
|
+
if (policy.action === 'throw' || policy.failClosed) {
|
|
485
|
+
throw new PromptRedactionError({
|
|
486
|
+
patternName: pattern.name,
|
|
487
|
+
fieldPath,
|
|
488
|
+
...(role !== undefined ? { role } : {}),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
pattern.regex.lastIndex = 0;
|
|
492
|
+
scrubbed = scrubbed.replace(pattern.regex, pattern.mask ?? `[REDACTED ${pattern.name}]`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return scrubbed;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function scrubAny(
|
|
499
|
+
value: unknown,
|
|
500
|
+
role: string,
|
|
501
|
+
fieldPath: string,
|
|
502
|
+
policy: RequiredPolicy,
|
|
503
|
+
trustClass: LocalProviderTrust | undefined,
|
|
504
|
+
ctx: ScrubContext,
|
|
505
|
+
logger: (message: string, meta?: object) => void,
|
|
506
|
+
): unknown {
|
|
507
|
+
if (typeof value === 'string') {
|
|
508
|
+
return scrubText(value, role, fieldPath, policy, trustClass, ctx, logger);
|
|
509
|
+
}
|
|
510
|
+
if (value === null || value === undefined) return value;
|
|
511
|
+
if (Array.isArray(value)) {
|
|
512
|
+
return value.map((item, idx) =>
|
|
513
|
+
scrubAny(item, role, `${fieldPath}[${idx}]`, policy, trustClass, ctx, logger),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
if (typeof value === 'object') {
|
|
517
|
+
if (policy.detectSecretValue && isSecretValue(value)) {
|
|
518
|
+
ctx.hits++;
|
|
519
|
+
reportViolation(
|
|
520
|
+
policy,
|
|
521
|
+
{
|
|
522
|
+
patternName: 'graphorin-secret-value',
|
|
523
|
+
fieldPath,
|
|
524
|
+
role,
|
|
525
|
+
matchLength: 0,
|
|
526
|
+
...(trustClass !== undefined ? { trustClass } : {}),
|
|
527
|
+
action: policy.action,
|
|
528
|
+
},
|
|
529
|
+
logger,
|
|
530
|
+
);
|
|
531
|
+
return '[REDACTED graphorin-secret-value]';
|
|
532
|
+
}
|
|
533
|
+
const out: Record<string, unknown> = {};
|
|
534
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
535
|
+
out[k] = scrubAny(v, role, `${fieldPath}.${k}`, policy, trustClass, ctx, logger);
|
|
536
|
+
}
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
return value;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function scrubProviderOptions(
|
|
543
|
+
providerOptions: Readonly<Record<string, unknown>> | undefined,
|
|
544
|
+
policy: RequiredPolicy,
|
|
545
|
+
trustClass: LocalProviderTrust | undefined,
|
|
546
|
+
ctx: ScrubContext,
|
|
547
|
+
logger: (message: string, meta?: object) => void,
|
|
548
|
+
): Record<string, unknown> | undefined {
|
|
549
|
+
if (providerOptions === undefined) return undefined;
|
|
550
|
+
const out: Record<string, unknown> = {};
|
|
551
|
+
for (const [k, v] of Object.entries(providerOptions)) {
|
|
552
|
+
out[k] = scrubAny(
|
|
553
|
+
v,
|
|
554
|
+
'provider-options',
|
|
555
|
+
`providerOptions.${k}`,
|
|
556
|
+
policy,
|
|
557
|
+
trustClass,
|
|
558
|
+
ctx,
|
|
559
|
+
logger,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function detectSecretValuesIn(
|
|
566
|
+
msg: Message,
|
|
567
|
+
policy: RequiredPolicy,
|
|
568
|
+
trustClass: LocalProviderTrust | undefined,
|
|
569
|
+
fieldPrefix: string,
|
|
570
|
+
ctx: ScrubContext,
|
|
571
|
+
logger: (message: string, meta?: object) => void,
|
|
572
|
+
): void {
|
|
573
|
+
if (!policy.detectSecretValue) return;
|
|
574
|
+
scrubAny(msg, msg.role, fieldPrefix, policy, trustClass, ctx, logger);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function reportViolation(
|
|
578
|
+
policy: RequiredPolicy,
|
|
579
|
+
violation: PromptRedactionViolation,
|
|
580
|
+
logger: (message: string, meta?: object) => void,
|
|
581
|
+
): void {
|
|
582
|
+
policy.onViolation?.(violation);
|
|
583
|
+
logger(
|
|
584
|
+
`[graphorin/provider] withRedaction hit '${violation.patternName}' at '${violation.fieldPath}' (action: ${violation.action})`,
|
|
585
|
+
{
|
|
586
|
+
patternName: violation.patternName,
|
|
587
|
+
fieldPath: violation.fieldPath,
|
|
588
|
+
role: violation.role,
|
|
589
|
+
matchLength: violation.matchLength,
|
|
590
|
+
trustClass: violation.trustClass,
|
|
591
|
+
},
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function isSecretValue(value: unknown): boolean {
|
|
596
|
+
if (value === null || typeof value !== 'object') return false;
|
|
597
|
+
return (value as Record<string | symbol, unknown>)[SECRET_VALUE_BRAND] === true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function inferTrustClass(
|
|
601
|
+
acceptsSensitivity: ReadonlyArray<Sensitivity> | undefined,
|
|
602
|
+
): LocalProviderTrust | undefined {
|
|
603
|
+
if (acceptsSensitivity === undefined) return undefined;
|
|
604
|
+
const set = new Set<Sensitivity>(acceptsSensitivity);
|
|
605
|
+
if (set.has('secret')) return 'loopback';
|
|
606
|
+
if (set.has('internal')) return 'private';
|
|
607
|
+
if (set.has('public')) return 'public-tls';
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function* scanStreamingResponse(
|
|
612
|
+
source: AsyncIterable<ProviderEvent>,
|
|
613
|
+
policy: RequiredPolicy,
|
|
614
|
+
logger: (message: string, meta?: object) => void,
|
|
615
|
+
): AsyncIterable<ProviderEvent> {
|
|
616
|
+
// PS-22: secrets can straddle text-delta boundaries. Keep a bounded tail of
|
|
617
|
+
// recently-streamed text and scan `tail + delta`, reporting only matches that
|
|
618
|
+
// reach into the freshly-arrived delta so a prior window's matches are not
|
|
619
|
+
// double-counted. The tail is capped (no unbounded accumulation); a single
|
|
620
|
+
// match longer than the cap can still slip a boundary (best-effort).
|
|
621
|
+
const TAIL_WINDOW = 512;
|
|
622
|
+
let tail = '';
|
|
623
|
+
for await (const event of source) {
|
|
624
|
+
if (event.type === 'text-delta' && policy.scanScope === 'all') {
|
|
625
|
+
// Observability-only - match patterns and emit synthetic violation rows;
|
|
626
|
+
// do NOT mutate the stream content (mid-stream mutation would break
|
|
627
|
+
// structured-output / tool-call parsing).
|
|
628
|
+
const haystack = tail + event.delta;
|
|
629
|
+
for (const pattern of policy.patterns) {
|
|
630
|
+
pattern.regex.lastIndex = 0;
|
|
631
|
+
let newMatchLength = 0;
|
|
632
|
+
let match = pattern.regex.exec(haystack);
|
|
633
|
+
while (match !== null) {
|
|
634
|
+
const endIndex = match.index + match[0].length;
|
|
635
|
+
// A match wholly inside `tail` was reported on the prior delta.
|
|
636
|
+
if (match[0].length > 0 && endIndex > tail.length) {
|
|
637
|
+
newMatchLength += match[0].length;
|
|
638
|
+
}
|
|
639
|
+
if (!pattern.regex.global) break;
|
|
640
|
+
if (match[0].length === 0) pattern.regex.lastIndex += 1;
|
|
641
|
+
match = pattern.regex.exec(haystack);
|
|
642
|
+
}
|
|
643
|
+
if (newMatchLength > 0) {
|
|
644
|
+
reportViolation(
|
|
645
|
+
policy,
|
|
646
|
+
{
|
|
647
|
+
patternName: pattern.name,
|
|
648
|
+
fieldPath: 'response.text-delta',
|
|
649
|
+
role: 'assistant',
|
|
650
|
+
matchLength: newMatchLength,
|
|
651
|
+
action: 'redact',
|
|
652
|
+
},
|
|
653
|
+
logger,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
tail =
|
|
658
|
+
haystack.length > TAIL_WINDOW ? haystack.slice(haystack.length - TAIL_WINDOW) : haystack;
|
|
659
|
+
}
|
|
660
|
+
yield event;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function defaultLogger(message: string, meta?: object): void {
|
|
665
|
+
if (meta !== undefined) console.warn(message, meta);
|
|
666
|
+
else console.warn(message);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Re-export to silence "declared but unused" lints for downstream
|
|
670
|
+
// consumers that import only the types.
|
|
671
|
+
export type _RedactionResponseGuard = ProviderResponse;
|