@copilotkit/aimock 1.44.0 → 1.45.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +31 -0
- package/README.md +6 -0
- package/dist/agui-types.d.ts.map +1 -1
- package/dist/aimock-cli.cjs +31 -5
- package/dist/aimock-cli.cjs.map +1 -1
- package/dist/aimock-cli.d.cts.map +1 -1
- package/dist/aimock-cli.d.ts.map +1 -1
- package/dist/aimock-cli.js +31 -5
- package/dist/aimock-cli.js.map +1 -1
- package/dist/bedrock-converse.cjs +312 -39
- package/dist/bedrock-converse.cjs.map +1 -1
- package/dist/bedrock-converse.d.cts.map +1 -1
- package/dist/bedrock-converse.d.ts.map +1 -1
- package/dist/bedrock-converse.js +313 -40
- package/dist/bedrock-converse.js.map +1 -1
- package/dist/bedrock.cjs +195 -42
- package/dist/bedrock.cjs.map +1 -1
- package/dist/bedrock.d.cts.map +1 -1
- package/dist/bedrock.d.ts.map +1 -1
- package/dist/bedrock.js +196 -43
- package/dist/bedrock.js.map +1 -1
- package/dist/chaos.cjs +1 -0
- package/dist/chaos.js +1 -1
- package/dist/cli.cjs +152 -5
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +155 -8
- package/dist/cli.js.map +1 -1
- package/dist/cohere.cjs +132 -71
- package/dist/cohere.cjs.map +1 -1
- package/dist/cohere.d.cts.map +1 -1
- package/dist/cohere.d.ts.map +1 -1
- package/dist/cohere.js +133 -72
- package/dist/cohere.js.map +1 -1
- package/dist/config-loader.cjs +62 -4
- package/dist/config-loader.cjs.map +1 -1
- package/dist/config-loader.d.cts +37 -4
- package/dist/config-loader.d.cts.map +1 -1
- package/dist/config-loader.d.ts +37 -4
- package/dist/config-loader.d.ts.map +1 -1
- package/dist/config-loader.js +62 -4
- package/dist/config-loader.js.map +1 -1
- package/dist/fakes-plugin.cjs +131 -0
- package/dist/fakes-plugin.cjs.map +1 -0
- package/dist/fakes-plugin.d.cts +23 -0
- package/dist/fakes-plugin.d.cts.map +1 -0
- package/dist/fakes-plugin.d.ts +23 -0
- package/dist/fakes-plugin.d.ts.map +1 -0
- package/dist/fakes-plugin.js +127 -0
- package/dist/fakes-plugin.js.map +1 -0
- package/dist/fixture-loader-services.cjs +1 -1
- package/dist/fixture-loader-services.cjs.map +1 -1
- package/dist/fixture-loader-services.js +1 -1
- package/dist/fixture-loader-services.js.map +1 -1
- package/dist/fixture-loader.cjs +389 -27
- package/dist/fixture-loader.cjs.map +1 -1
- package/dist/fixture-loader.d.cts +47 -3
- package/dist/fixture-loader.d.cts.map +1 -1
- package/dist/fixture-loader.d.ts +47 -3
- package/dist/fixture-loader.d.ts.map +1 -1
- package/dist/fixture-loader.js +384 -29
- package/dist/fixture-loader.js.map +1 -1
- package/dist/gemini-interactions.cjs +250 -52
- package/dist/gemini-interactions.cjs.map +1 -1
- package/dist/gemini-interactions.d.cts.map +1 -1
- package/dist/gemini-interactions.d.ts.map +1 -1
- package/dist/gemini-interactions.js +251 -53
- package/dist/gemini-interactions.js.map +1 -1
- package/dist/gemini.cjs +226 -37
- package/dist/gemini.cjs.map +1 -1
- package/dist/gemini.d.cts.map +1 -1
- package/dist/gemini.d.ts.map +1 -1
- package/dist/gemini.js +227 -38
- package/dist/gemini.js.map +1 -1
- package/dist/helpers.cjs +881 -7
- package/dist/helpers.cjs.map +1 -1
- package/dist/helpers.d.cts +85 -4
- package/dist/helpers.d.cts.map +1 -1
- package/dist/helpers.d.ts +85 -4
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +839 -8
- package/dist/helpers.js.map +1 -1
- package/dist/index.cjs +9 -0
- package/dist/index.d.cts +7 -5
- package/dist/index.d.ts +7 -5
- package/dist/index.js +4 -3
- package/dist/jest.cjs +31 -7
- package/dist/jest.cjs.map +1 -1
- package/dist/jest.d.cts +43 -3
- package/dist/jest.d.cts.map +1 -1
- package/dist/jest.d.ts +43 -3
- package/dist/jest.d.ts.map +1 -1
- package/dist/jest.js +33 -9
- package/dist/jest.js.map +1 -1
- package/dist/journal.cjs +135 -13
- package/dist/journal.cjs.map +1 -1
- package/dist/journal.d.cts +38 -4
- package/dist/journal.d.cts.map +1 -1
- package/dist/journal.d.ts +38 -4
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +135 -13
- package/dist/journal.js.map +1 -1
- package/dist/live-sanitize.cjs +4 -32
- package/dist/live-sanitize.cjs.map +1 -1
- package/dist/live-sanitize.js +4 -32
- package/dist/live-sanitize.js.map +1 -1
- package/dist/llmock.cjs +123 -30
- package/dist/llmock.cjs.map +1 -1
- package/dist/llmock.d.cts +16 -1
- package/dist/llmock.d.cts.map +1 -1
- package/dist/llmock.d.ts +16 -1
- package/dist/llmock.d.ts.map +1 -1
- package/dist/llmock.js +125 -32
- package/dist/llmock.js.map +1 -1
- package/dist/mcp-fakes-mount.cjs +1 -0
- package/dist/mcp-fakes-mount.cjs.map +1 -1
- package/dist/mcp-fakes-mount.js +1 -0
- package/dist/mcp-fakes-mount.js.map +1 -1
- package/dist/mcp-fakes-report.cjs +111 -0
- package/dist/mcp-fakes-report.cjs.map +1 -0
- package/dist/mcp-fakes-report.d.cts +40 -0
- package/dist/mcp-fakes-report.d.cts.map +1 -0
- package/dist/mcp-fakes-report.d.ts +40 -0
- package/dist/mcp-fakes-report.d.ts.map +1 -0
- package/dist/mcp-fakes-report.js +107 -0
- package/dist/mcp-fakes-report.js.map +1 -0
- package/dist/mcp-fakes.cjs +331 -8
- package/dist/mcp-fakes.cjs.map +1 -1
- package/dist/mcp-fakes.d.cts +406 -3
- package/dist/mcp-fakes.d.cts.map +1 -1
- package/dist/mcp-fakes.d.ts +406 -3
- package/dist/mcp-fakes.d.ts.map +1 -1
- package/dist/mcp-fakes.js +331 -8
- package/dist/mcp-fakes.js.map +1 -1
- package/dist/mcp-handler.cjs +209 -16
- package/dist/mcp-handler.cjs.map +1 -1
- package/dist/mcp-handler.d.cts +49 -0
- package/dist/mcp-handler.d.cts.map +1 -0
- package/dist/mcp-handler.d.ts +49 -0
- package/dist/mcp-handler.d.ts.map +1 -0
- package/dist/mcp-handler.js +207 -17
- package/dist/mcp-handler.js.map +1 -1
- package/dist/mcp-mock.cjs +112 -1
- package/dist/mcp-mock.cjs.map +1 -1
- package/dist/mcp-mock.d.cts +33 -2
- package/dist/mcp-mock.d.cts.map +1 -1
- package/dist/mcp-mock.d.ts +33 -2
- package/dist/mcp-mock.d.ts.map +1 -1
- package/dist/mcp-mock.js +112 -1
- package/dist/mcp-mock.js.map +1 -1
- package/dist/mcp-recorder.cjs +1224 -0
- package/dist/mcp-recorder.cjs.map +1 -0
- package/dist/mcp-recorder.d.cts +108 -0
- package/dist/mcp-recorder.d.cts.map +1 -0
- package/dist/mcp-recorder.d.ts +108 -0
- package/dist/mcp-recorder.d.ts.map +1 -0
- package/dist/mcp-recorder.js +1215 -0
- package/dist/mcp-recorder.js.map +1 -0
- package/dist/messages.cjs +362 -56
- package/dist/messages.cjs.map +1 -1
- package/dist/messages.d.cts.map +1 -1
- package/dist/messages.d.ts.map +1 -1
- package/dist/messages.js +363 -58
- package/dist/messages.js.map +1 -1
- package/dist/misbehavior.cjs +1523 -0
- package/dist/misbehavior.cjs.map +1 -0
- package/dist/misbehavior.d.cts +43 -0
- package/dist/misbehavior.d.cts.map +1 -0
- package/dist/misbehavior.d.ts +43 -0
- package/dist/misbehavior.d.ts.map +1 -0
- package/dist/misbehavior.js +1511 -0
- package/dist/misbehavior.js.map +1 -0
- package/dist/ollama.cjs +310 -112
- package/dist/ollama.cjs.map +1 -1
- package/dist/ollama.d.cts.map +1 -1
- package/dist/ollama.d.ts.map +1 -1
- package/dist/ollama.js +311 -113
- package/dist/ollama.js.map +1 -1
- package/dist/record-sanitize.cjs +231 -0
- package/dist/record-sanitize.cjs.map +1 -0
- package/dist/record-sanitize.js +224 -0
- package/dist/record-sanitize.js.map +1 -0
- package/dist/recorder.cjs +106 -5
- package/dist/recorder.cjs.map +1 -1
- package/dist/recorder.d.cts +2 -1
- package/dist/recorder.d.cts.map +1 -1
- package/dist/recorder.d.ts +2 -1
- package/dist/recorder.d.ts.map +1 -1
- package/dist/recorder.js +107 -7
- package/dist/recorder.js.map +1 -1
- package/dist/responses.cjs +667 -171
- package/dist/responses.cjs.map +1 -1
- package/dist/responses.d.cts +5 -13
- package/dist/responses.d.cts.map +1 -1
- package/dist/responses.d.ts +5 -13
- package/dist/responses.d.ts.map +1 -1
- package/dist/responses.js +665 -174
- package/dist/responses.js.map +1 -1
- package/dist/router.cjs +24 -2
- package/dist/router.cjs.map +1 -1
- package/dist/router.d.cts.map +1 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +25 -4
- package/dist/router.js.map +1 -1
- package/dist/server.cjs +572 -76
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +574 -78
- package/dist/server.js.map +1 -1
- package/dist/stream-collapse.cjs +236 -34
- package/dist/stream-collapse.cjs.map +1 -1
- package/dist/stream-collapse.d.cts +39 -4
- package/dist/stream-collapse.d.cts.map +1 -1
- package/dist/stream-collapse.d.ts +39 -4
- package/dist/stream-collapse.d.ts.map +1 -1
- package/dist/stream-collapse.js +235 -35
- package/dist/stream-collapse.js.map +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +315 -4
- package/dist/types.d.cts.map +1 -1
- package/dist/types.d.ts +315 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validate-cli.cjs +36 -4
- package/dist/validate-cli.cjs.map +1 -1
- package/dist/validate-cli.js +37 -5
- package/dist/validate-cli.js.map +1 -1
- package/dist/vector-types.d.ts.map +1 -1
- package/dist/version.cjs +45 -0
- package/dist/version.cjs.map +1 -0
- package/dist/version.js +44 -0
- package/dist/version.js.map +1 -0
- package/dist/vitest.cjs +66 -10
- package/dist/vitest.cjs.map +1 -1
- package/dist/vitest.d.cts +35 -4
- package/dist/vitest.d.cts.map +1 -1
- package/dist/vitest.d.ts +35 -4
- package/dist/vitest.d.ts.map +1 -1
- package/dist/vitest.js +67 -11
- package/dist/vitest.js.map +1 -1
- package/dist/watcher.cjs +6 -2
- package/dist/watcher.cjs.map +1 -1
- package/dist/watcher.js +6 -2
- package/dist/watcher.js.map +1 -1
- package/dist/ws-gemini-live.cjs +210 -101
- package/dist/ws-gemini-live.cjs.map +1 -1
- package/dist/ws-gemini-live.d.cts +9 -3
- package/dist/ws-gemini-live.d.cts.map +1 -1
- package/dist/ws-gemini-live.d.ts +9 -3
- package/dist/ws-gemini-live.d.ts.map +1 -1
- package/dist/ws-gemini-live.js +211 -102
- package/dist/ws-gemini-live.js.map +1 -1
- package/dist/ws-realtime.cjs +175 -62
- package/dist/ws-realtime.cjs.map +1 -1
- package/dist/ws-realtime.d.cts +8 -3
- package/dist/ws-realtime.d.cts.map +1 -1
- package/dist/ws-realtime.d.ts +8 -3
- package/dist/ws-realtime.d.ts.map +1 -1
- package/dist/ws-realtime.js +176 -63
- package/dist/ws-realtime.js.map +1 -1
- package/dist/ws-responses.cjs +109 -16
- package/dist/ws-responses.cjs.map +1 -1
- package/dist/ws-responses.d.cts +8 -12
- package/dist/ws-responses.d.cts.map +1 -1
- package/dist/ws-responses.d.ts +8 -12
- package/dist/ws-responses.d.ts.map +1 -1
- package/dist/ws-responses.js +111 -18
- package/dist/ws-responses.js.map +1 -1
- package/package.json +10 -2
- package/skills/write-fixtures/SKILL.md +179 -21
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"record-sanitize.cjs","names":[],"sources":["../src/record-sanitize.ts"],"sourcesContent":["/**\n * Shared record sanitizer (D10, S1-S7).\n *\n * The credential-key and URL rules were moved here from `live-sanitize.ts`,\n * which imports them back unchanged. `sanitizeRecording` applies the generic\n * record rules to any JSON value (for example an `mcpFakes` recording).\n */\n\nexport const CREDENTIAL_KEY =\n /^(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|secret|token|sec-websocket-protocol)$/i;\nexport const REDACTED = \"[REDACTED]\";\nexport const MIN_SECRET_LENGTH = 8;\n\n/** Thrown when a recording cannot be made safe (S6). Never carries a value. */\nexport class RecordUnsafeError extends Error {\n constructor(readonly reason: \"secret-in-path\" | \"secret-remains\") {\n super(`unsafe recording: ${reason}`);\n this.name = \"RecordUnsafeError\";\n }\n}\n\nexport function decodeUrlComponent(value: string): string {\n // Tolerate malformed escapes while exposing encoded secrets. Protect literal\n // form separators so the entire component remains one value.\n return new URLSearchParams(`value=${value.replace(/\\+/g, \"%2B\").replace(/&/g, \"%26\")}`).get(\n \"value\",\n )!;\n}\n\n/**\n * S3: scrub one absolute URL string; non-URLs unchanged. `onPathSecret` is\n * called (and must throw) when the decoded path contains a secret.\n */\nexport function scrubUrl(\n value: string,\n hasSecret: (s: string) => boolean,\n onPathSecret: () => never,\n): string {\n // Leave non-URLs and clean URLs byte-for-byte intact.\n if (!/^[a-z][a-z0-9+.-]*:\\/\\//i.test(value)) return value;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return value;\n }\n // A path identifies the resource; changing it could change replay semantics.\n if (hasSecret(decodeUrlComponent(url.pathname))) onPathSecret();\n let changed = false;\n if (url.username || url.password) {\n url.username = \"\";\n url.password = \"\";\n changed = true;\n }\n for (const [key, val] of [...url.searchParams]) {\n if (CREDENTIAL_KEY.test(key) || hasSecret(key) || hasSecret(val)) {\n url.searchParams.delete(key);\n changed = true;\n }\n }\n const decodedHash = decodeUrlComponent(url.hash);\n if (\n url.hash &&\n (hasSecret(decodedHash) || /(?:token|secret|password|api[_-]?key)=/i.test(decodedHash))\n ) {\n url.hash = \"\";\n changed = true;\n }\n return changed ? url.href : value;\n}\n\n/** S2 (d) start check: throws a plain Error naming the rule, never the value. */\nexport function validateSecretValues(values: readonly string[]): void {\n for (const value of values) {\n if (typeof value !== \"string\") throw new Error(\"record.secretValues entries must be strings\");\n if (value.length < MIN_SECRET_LENGTH) {\n throw new Error(`record.secretValues entry shorter than ${MIN_SECRET_LENGTH} characters`);\n }\n }\n}\n\n// Recognized API-key header names beyond the credential-key regex\n// (for example `x-goog-api-key`).\nconst API_KEY_HEADER = /(?:^|[-_])api[-_]?key$/i;\n\n/** The value, the token after the scheme, and for `Basic` the decoded user and password. */\nfunction credentialParts(value: string): string[] {\n const parts = [value];\n const match = /^(\\S+)\\s+(\\S.*)$/.exec(value.trim());\n if (match) {\n const token = match[2].trim();\n parts.push(token);\n if (match[1].toLowerCase() === \"basic\") {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf8\");\n const colon = decoded.indexOf(\":\");\n if (colon >= 0) parts.push(decoded.slice(0, colon), decoded.slice(colon + 1));\n }\n }\n return parts;\n}\n\nfunction urlParts(value: string): string[] {\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return [];\n }\n const parts: string[] = [];\n if (url.username) parts.push(url.username, decodeUrlComponent(url.username));\n if (url.password) parts.push(url.password, decodeUrlComponent(url.password));\n // Every query value; path segments are names, so they are not included (S2 c).\n for (const val of url.searchParams.values()) parts.push(val);\n return parts;\n}\n\n/** S2 (a)-(c): secrets from forwarded headers, upstreamAuth and the upstream URL; min length 8. */\nexport function knownSecrets(input: {\n headers: Record<string, string>;\n upstreamAuth?: { name: string; value: string };\n upstreamUrl: string;\n secretValues: readonly string[];\n}): string[] {\n const derived: string[] = [];\n for (const [name, value] of Object.entries(input.headers)) {\n if (typeof value !== \"string\") continue;\n if (CREDENTIAL_KEY.test(name) || API_KEY_HEADER.test(name)) {\n derived.push(...credentialParts(value));\n }\n }\n if (input.upstreamAuth) derived.push(...credentialParts(input.upstreamAuth.value));\n derived.push(...urlParts(input.upstreamUrl));\n const secrets = new Set(derived.filter((s) => s.length >= MIN_SECRET_LENGTH));\n // (d) is validated at start (`validateSecretValues`); keep every non-empty entry.\n for (const value of input.secretValues) if (value.length) secrets.add(value);\n return [...secrets].sort((a, b) => b.length - a.length);\n}\n\n/** `%2b` for `%2B`: some encoders write lowercase hex. */\nfunction lowerHex(value: string): string {\n return value.replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase());\n}\n\n/** `f(value)`, or nothing when it throws (a lone surrogate, a bad escape). */\nfunction attempt(f: (value: string) => string, value: string): string[] {\n try {\n return [f(value)];\n } catch {\n return [];\n }\n}\n\n/**\n * Every textual form a known secret can take in a recording (G2b B1-r2): the\n * value as registered, its percent-decoded forms (it may have been\n * registered encoded, as a URL query value is), and for each of those the\n * `encodeURIComponent`, form (`URLSearchParams`, `+` for space) and\n * `encodeURI` encodings, with uppercase or lowercase hex. A derived form\n * shorter than `MIN_SECRET_LENGTH` is dropped; the value itself never is.\n */\nexport function secretForms(secret: string): string[] {\n const bases = [\n secret,\n ...attempt(decodeURIComponent, secret),\n ...attempt((v) => decodeURIComponent(v.replace(/\\+/g, \" \")), secret),\n ];\n const forms = new Set<string>();\n for (const base of bases) {\n forms.add(base);\n const encoded = [\n ...attempt(encodeURIComponent, base),\n ...attempt((v) => new URLSearchParams([[\"v\", v]]).toString().slice(2), base),\n ...attempt(encodeURI, base),\n ...attempt((v) => encodeURIComponent(v).replace(/%20/g, \"+\"), base),\n ];\n for (const form of encoded) {\n forms.add(form);\n forms.add(lowerHex(form));\n }\n }\n return [...forms].filter((form) => form === secret || form.length >= MIN_SECRET_LENGTH);\n}\n\nexport interface SanitizeResult<T> {\n value: T;\n /** `_warnings` text: \"<pointer>: redacted a known secret\" / \"<pointer>: looks like a <kind> token\" */\n warnings: string[];\n}\n\n// S7: token-shaped strings are reported, never redacted. Order matters:\n// `sk-ant-` is checked before the generic `sk-`.\nconst TOKEN_PATTERNS: readonly { kind: string; pattern: RegExp }[] = [\n { kind: \"Anthropic API key\", pattern: /\\bsk-ant-[A-Za-z0-9_-]{8,}/ },\n { kind: \"OpenAI API key\", pattern: /\\bsk-(?!ant-)[A-Za-z0-9_-]{16,}/ },\n { kind: \"GitHub token\", pattern: /\\b(?:ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/ },\n { kind: \"Slack token\", pattern: /\\bxox[abp]-[A-Za-z0-9-]{10,}/ },\n { kind: \"AWS access key\", pattern: /\\bAKIA[0-9A-Z]{16}\\b/ },\n { kind: \"JWT\", pattern: /\\beyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+/ },\n];\n\nfunction escapePointer(key: string): string {\n return key.replace(/~/g, \"~0\").replace(/\\//g, \"~1\");\n}\n\n/**\n * S2-S7 over one JSON value. `isCredentialBranch(pointer)` is called with the\n * pointer of each object and says whether credential-shaped keys directly\n * under it are removed (S4: `/recorded…`, any `…/_meta…`).\n * Throws RecordUnsafeError (S6). The input is never mutated.\n */\nexport function sanitizeRecording<T>(\n value: T,\n secrets: readonly string[],\n isCredentialBranch: (pointer: string) => boolean,\n): SanitizeResult<T> {\n // G2b B1-r2: every form a secret can take (encoded, decoded) is matched.\n const ordered = [...new Set(secrets.filter((s) => s.length).flatMap(secretForms))].sort(\n (a, b) => b.length - a.length,\n );\n const hasSecret = (s: string) => ordered.some((secret) => s.includes(secret));\n // S6 runs over JSON text, where `\"`, `\\` and control characters are escaped.\n const jsonForms = ordered.map((secret) => JSON.stringify(secret).slice(1, -1));\n const hasSecretInJson = (s: string) =>\n hasSecret(s) || jsonForms.some((secret) => s.includes(secret));\n const onPathSecret = (): never => {\n throw new RecordUnsafeError(\"secret-in-path\");\n };\n const warnings: string[] = [];\n\n function visitString(input: string, pointer: string): string {\n const url = scrubUrl(input, hasSecret, onPathSecret);\n if (url !== input) warnings.push(`${pointer}: removed credentials from a URL`);\n let out = url;\n for (const secret of ordered) out = out.split(secret).join(REDACTED);\n if (out !== url) warnings.push(`${pointer}: redacted a known secret`);\n for (const { kind, pattern } of TOKEN_PATTERNS) {\n if (pattern.test(out)) warnings.push(`${pointer}: looks like a ${kind} token`);\n }\n return out;\n }\n\n function visit(node: unknown, pointer: string): unknown {\n if (typeof node === \"string\") return visitString(node, pointer);\n if (Array.isArray(node)) return node.map((item, i) => visit(item, `${pointer}/${i}`));\n if (!node || typeof node !== \"object\") return node;\n const removeCredentials = isCredentialBranch(pointer);\n const output: Record<string, unknown> = {};\n for (const [key, item] of Object.entries(node)) {\n if (removeCredentials && CREDENTIAL_KEY.test(key)) continue;\n // Own data property, so a `__proto__` key never rewires the prototype.\n Object.defineProperty(output, key, {\n value: visit(item, `${pointer}/${escapePointer(key)}`),\n enumerable: true,\n writable: true,\n configurable: true,\n });\n }\n return output;\n }\n\n const cleaned = visit(value, \"\") as T;\n // S6: fail closed if any known secret remains (for example in a key name).\n if (hasSecretInJson(JSON.stringify(cleaned) ?? \"\")) {\n throw new RecordUnsafeError(\"secret-remains\");\n }\n return { value: cleaned, warnings };\n}\n"],"mappings":";;;;;;;;;AAQA,MAAa,iBACX;AACF,MAAa,WAAW;AACxB,MAAa,oBAAoB;;AAGjC,IAAa,oBAAb,cAAuC,MAAM;CAC3C,YAAY,AAAS,QAA6C;AAChE,QAAM,qBAAqB,SAAS;EADjB;AAEnB,OAAK,OAAO;;;AAIhB,SAAgB,mBAAmB,OAAuB;AAGxD,QAAO,IAAI,gBAAgB,SAAS,MAAM,QAAQ,OAAO,MAAM,CAAC,QAAQ,MAAM,MAAM,GAAG,CAAC,IACtF,QACD;;;;;;AAOH,SAAgB,SACd,OACA,WACA,cACQ;AAER,KAAI,CAAC,2BAA2B,KAAK,MAAM,CAAE,QAAO;CACpD,IAAI;AACJ,KAAI;AACF,QAAM,IAAI,IAAI,MAAM;SACd;AACN,SAAO;;AAGT,KAAI,UAAU,mBAAmB,IAAI,SAAS,CAAC,CAAE,eAAc;CAC/D,IAAI,UAAU;AACd,KAAI,IAAI,YAAY,IAAI,UAAU;AAChC,MAAI,WAAW;AACf,MAAI,WAAW;AACf,YAAU;;AAEZ,MAAK,MAAM,CAAC,KAAK,QAAQ,CAAC,GAAG,IAAI,aAAa,CAC5C,KAAI,eAAe,KAAK,IAAI,IAAI,UAAU,IAAI,IAAI,UAAU,IAAI,EAAE;AAChE,MAAI,aAAa,OAAO,IAAI;AAC5B,YAAU;;CAGd,MAAM,cAAc,mBAAmB,IAAI,KAAK;AAChD,KACE,IAAI,SACH,UAAU,YAAY,IAAI,0CAA0C,KAAK,YAAY,GACtF;AACA,MAAI,OAAO;AACX,YAAU;;AAEZ,QAAO,UAAU,IAAI,OAAO;;;AAI9B,SAAgB,qBAAqB,QAAiC;AACpE,MAAK,MAAM,SAAS,QAAQ;AAC1B,MAAI,OAAO,UAAU,SAAU,OAAM,IAAI,MAAM,8CAA8C;AAC7F,MAAI,MAAM,SAAS,kBACjB,OAAM,IAAI,MAAM,0CAA0C,kBAAkB,aAAa;;;AAO/F,MAAM,iBAAiB;;AAGvB,SAAS,gBAAgB,OAAyB;CAChD,MAAM,QAAQ,CAAC,MAAM;CACrB,MAAM,QAAQ,mBAAmB,KAAK,MAAM,MAAM,CAAC;AACnD,KAAI,OAAO;EACT,MAAM,QAAQ,MAAM,GAAG,MAAM;AAC7B,QAAM,KAAK,MAAM;AACjB,MAAI,MAAM,GAAG,aAAa,KAAK,SAAS;GACtC,MAAM,UAAU,OAAO,KAAK,OAAO,SAAS,CAAC,SAAS,OAAO;GAC7D,MAAM,QAAQ,QAAQ,QAAQ,IAAI;AAClC,OAAI,SAAS,EAAG,OAAM,KAAK,QAAQ,MAAM,GAAG,MAAM,EAAE,QAAQ,MAAM,QAAQ,EAAE,CAAC;;;AAGjF,QAAO;;AAGT,SAAS,SAAS,OAAyB;CACzC,IAAI;AACJ,KAAI;AACF,QAAM,IAAI,IAAI,MAAM;SACd;AACN,SAAO,EAAE;;CAEX,MAAM,QAAkB,EAAE;AAC1B,KAAI,IAAI,SAAU,OAAM,KAAK,IAAI,UAAU,mBAAmB,IAAI,SAAS,CAAC;AAC5E,KAAI,IAAI,SAAU,OAAM,KAAK,IAAI,UAAU,mBAAmB,IAAI,SAAS,CAAC;AAE5E,MAAK,MAAM,OAAO,IAAI,aAAa,QAAQ,CAAE,OAAM,KAAK,IAAI;AAC5D,QAAO;;;AAIT,SAAgB,aAAa,OAKhB;CACX,MAAM,UAAoB,EAAE;AAC5B,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,MAAM,QAAQ,EAAE;AACzD,MAAI,OAAO,UAAU,SAAU;AAC/B,MAAI,eAAe,KAAK,KAAK,IAAI,eAAe,KAAK,KAAK,CACxD,SAAQ,KAAK,GAAG,gBAAgB,MAAM,CAAC;;AAG3C,KAAI,MAAM,aAAc,SAAQ,KAAK,GAAG,gBAAgB,MAAM,aAAa,MAAM,CAAC;AAClF,SAAQ,KAAK,GAAG,SAAS,MAAM,YAAY,CAAC;CAC5C,MAAM,UAAU,IAAI,IAAI,QAAQ,QAAQ,MAAM,EAAE,UAAU,kBAAkB,CAAC;AAE7E,MAAK,MAAM,SAAS,MAAM,aAAc,KAAI,MAAM,OAAQ,SAAQ,IAAI,MAAM;AAC5E,QAAO,CAAC,GAAG,QAAQ,CAAC,MAAM,GAAG,MAAM,EAAE,SAAS,EAAE,OAAO;;;AAIzD,SAAS,SAAS,OAAuB;AACvC,QAAO,MAAM,QAAQ,kBAAkB,MAAM,EAAE,aAAa,CAAC;;;AAI/D,SAAS,QAAQ,GAA8B,OAAyB;AACtE,KAAI;AACF,SAAO,CAAC,EAAE,MAAM,CAAC;SACX;AACN,SAAO,EAAE;;;;;;;;;;;AAYb,SAAgB,YAAY,QAA0B;CACpD,MAAM,QAAQ;EACZ;EACA,GAAG,QAAQ,oBAAoB,OAAO;EACtC,GAAG,SAAS,MAAM,mBAAmB,EAAE,QAAQ,OAAO,IAAI,CAAC,EAAE,OAAO;EACrE;CACD,MAAM,wBAAQ,IAAI,KAAa;AAC/B,MAAK,MAAM,QAAQ,OAAO;AACxB,QAAM,IAAI,KAAK;EACf,MAAM,UAAU;GACd,GAAG,QAAQ,oBAAoB,KAAK;GACpC,GAAG,SAAS,MAAM,IAAI,gBAAgB,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,EAAE,EAAE,KAAK;GAC5E,GAAG,QAAQ,WAAW,KAAK;GAC3B,GAAG,SAAS,MAAM,mBAAmB,EAAE,CAAC,QAAQ,QAAQ,IAAI,EAAE,KAAK;GACpE;AACD,OAAK,MAAM,QAAQ,SAAS;AAC1B,SAAM,IAAI,KAAK;AACf,SAAM,IAAI,SAAS,KAAK,CAAC;;;AAG7B,QAAO,CAAC,GAAG,MAAM,CAAC,QAAQ,SAAS,SAAS,UAAU,KAAK,UAAU,kBAAkB;;AAWzF,MAAM,iBAA+D;CACnE;EAAE,MAAM;EAAqB,SAAS;EAA8B;CACpE;EAAE,MAAM;EAAkB,SAAS;EAAmC;CACtE;EAAE,MAAM;EAAgB,SAAS;EAA2D;CAC5F;EAAE,MAAM;EAAe,SAAS;EAAgC;CAChE;EAAE,MAAM;EAAkB,SAAS;EAAwB;CAC3D;EAAE,MAAM;EAAO,SAAS;EAAuD;CAChF;AAED,SAAS,cAAc,KAAqB;AAC1C,QAAO,IAAI,QAAQ,MAAM,KAAK,CAAC,QAAQ,OAAO,KAAK;;;;;;;;AASrD,SAAgB,kBACd,OACA,SACA,oBACmB;CAEnB,MAAM,UAAU,CAAC,GAAG,IAAI,IAAI,QAAQ,QAAQ,MAAM,EAAE,OAAO,CAAC,QAAQ,YAAY,CAAC,CAAC,CAAC,MAChF,GAAG,MAAM,EAAE,SAAS,EAAE,OACxB;CACD,MAAM,aAAa,MAAc,QAAQ,MAAM,WAAW,EAAE,SAAS,OAAO,CAAC;CAE7E,MAAM,YAAY,QAAQ,KAAK,WAAW,KAAK,UAAU,OAAO,CAAC,MAAM,GAAG,GAAG,CAAC;CAC9E,MAAM,mBAAmB,MACvB,UAAU,EAAE,IAAI,UAAU,MAAM,WAAW,EAAE,SAAS,OAAO,CAAC;CAChE,MAAM,qBAA4B;AAChC,QAAM,IAAI,kBAAkB,iBAAiB;;CAE/C,MAAM,WAAqB,EAAE;CAE7B,SAAS,YAAY,OAAe,SAAyB;EAC3D,MAAM,MAAM,SAAS,OAAO,WAAW,aAAa;AACpD,MAAI,QAAQ,MAAO,UAAS,KAAK,GAAG,QAAQ,kCAAkC;EAC9E,IAAI,MAAM;AACV,OAAK,MAAM,UAAU,QAAS,OAAM,IAAI,MAAM,OAAO,CAAC,KAAK,SAAS;AACpE,MAAI,QAAQ,IAAK,UAAS,KAAK,GAAG,QAAQ,2BAA2B;AACrE,OAAK,MAAM,EAAE,MAAM,aAAa,eAC9B,KAAI,QAAQ,KAAK,IAAI,CAAE,UAAS,KAAK,GAAG,QAAQ,iBAAiB,KAAK,QAAQ;AAEhF,SAAO;;CAGT,SAAS,MAAM,MAAe,SAA0B;AACtD,MAAI,OAAO,SAAS,SAAU,QAAO,YAAY,MAAM,QAAQ;AAC/D,MAAI,MAAM,QAAQ,KAAK,CAAE,QAAO,KAAK,KAAK,MAAM,MAAM,MAAM,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;AACrF,MAAI,CAAC,QAAQ,OAAO,SAAS,SAAU,QAAO;EAC9C,MAAM,oBAAoB,mBAAmB,QAAQ;EACrD,MAAM,SAAkC,EAAE;AAC1C,OAAK,MAAM,CAAC,KAAK,SAAS,OAAO,QAAQ,KAAK,EAAE;AAC9C,OAAI,qBAAqB,eAAe,KAAK,IAAI,CAAE;AAEnD,UAAO,eAAe,QAAQ,KAAK;IACjC,OAAO,MAAM,MAAM,GAAG,QAAQ,GAAG,cAAc,IAAI,GAAG;IACtD,YAAY;IACZ,UAAU;IACV,cAAc;IACf,CAAC;;AAEJ,SAAO;;CAGT,MAAM,UAAU,MAAM,OAAO,GAAG;AAEhC,KAAI,gBAAgB,KAAK,UAAU,QAAQ,IAAI,GAAG,CAChD,OAAM,IAAI,kBAAkB,iBAAiB;AAE/C,QAAO;EAAE,OAAO;EAAS;EAAU"}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
//#region src/record-sanitize.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shared record sanitizer (D10, S1-S7).
|
|
4
|
+
*
|
|
5
|
+
* The credential-key and URL rules were moved here from `live-sanitize.ts`,
|
|
6
|
+
* which imports them back unchanged. `sanitizeRecording` applies the generic
|
|
7
|
+
* record rules to any JSON value (for example an `mcpFakes` recording).
|
|
8
|
+
*/
|
|
9
|
+
const CREDENTIAL_KEY = /^(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|secret|token|sec-websocket-protocol)$/i;
|
|
10
|
+
const REDACTED = "[REDACTED]";
|
|
11
|
+
const MIN_SECRET_LENGTH = 8;
|
|
12
|
+
/** Thrown when a recording cannot be made safe (S6). Never carries a value. */
|
|
13
|
+
var RecordUnsafeError = class extends Error {
|
|
14
|
+
constructor(reason) {
|
|
15
|
+
super(`unsafe recording: ${reason}`);
|
|
16
|
+
this.reason = reason;
|
|
17
|
+
this.name = "RecordUnsafeError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function decodeUrlComponent(value) {
|
|
21
|
+
return new URLSearchParams(`value=${value.replace(/\+/g, "%2B").replace(/&/g, "%26")}`).get("value");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* S3: scrub one absolute URL string; non-URLs unchanged. `onPathSecret` is
|
|
25
|
+
* called (and must throw) when the decoded path contains a secret.
|
|
26
|
+
*/
|
|
27
|
+
function scrubUrl(value, hasSecret, onPathSecret) {
|
|
28
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return value;
|
|
29
|
+
let url;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(value);
|
|
32
|
+
} catch {
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
if (hasSecret(decodeUrlComponent(url.pathname))) onPathSecret();
|
|
36
|
+
let changed = false;
|
|
37
|
+
if (url.username || url.password) {
|
|
38
|
+
url.username = "";
|
|
39
|
+
url.password = "";
|
|
40
|
+
changed = true;
|
|
41
|
+
}
|
|
42
|
+
for (const [key, val] of [...url.searchParams]) if (CREDENTIAL_KEY.test(key) || hasSecret(key) || hasSecret(val)) {
|
|
43
|
+
url.searchParams.delete(key);
|
|
44
|
+
changed = true;
|
|
45
|
+
}
|
|
46
|
+
const decodedHash = decodeUrlComponent(url.hash);
|
|
47
|
+
if (url.hash && (hasSecret(decodedHash) || /(?:token|secret|password|api[_-]?key)=/i.test(decodedHash))) {
|
|
48
|
+
url.hash = "";
|
|
49
|
+
changed = true;
|
|
50
|
+
}
|
|
51
|
+
return changed ? url.href : value;
|
|
52
|
+
}
|
|
53
|
+
/** S2 (d) start check: throws a plain Error naming the rule, never the value. */
|
|
54
|
+
function validateSecretValues(values) {
|
|
55
|
+
for (const value of values) {
|
|
56
|
+
if (typeof value !== "string") throw new Error("record.secretValues entries must be strings");
|
|
57
|
+
if (value.length < MIN_SECRET_LENGTH) throw new Error(`record.secretValues entry shorter than ${MIN_SECRET_LENGTH} characters`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const API_KEY_HEADER = /(?:^|[-_])api[-_]?key$/i;
|
|
61
|
+
/** The value, the token after the scheme, and for `Basic` the decoded user and password. */
|
|
62
|
+
function credentialParts(value) {
|
|
63
|
+
const parts = [value];
|
|
64
|
+
const match = /^(\S+)\s+(\S.*)$/.exec(value.trim());
|
|
65
|
+
if (match) {
|
|
66
|
+
const token = match[2].trim();
|
|
67
|
+
parts.push(token);
|
|
68
|
+
if (match[1].toLowerCase() === "basic") {
|
|
69
|
+
const decoded = Buffer.from(token, "base64").toString("utf8");
|
|
70
|
+
const colon = decoded.indexOf(":");
|
|
71
|
+
if (colon >= 0) parts.push(decoded.slice(0, colon), decoded.slice(colon + 1));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return parts;
|
|
75
|
+
}
|
|
76
|
+
function urlParts(value) {
|
|
77
|
+
let url;
|
|
78
|
+
try {
|
|
79
|
+
url = new URL(value);
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const parts = [];
|
|
84
|
+
if (url.username) parts.push(url.username, decodeUrlComponent(url.username));
|
|
85
|
+
if (url.password) parts.push(url.password, decodeUrlComponent(url.password));
|
|
86
|
+
for (const val of url.searchParams.values()) parts.push(val);
|
|
87
|
+
return parts;
|
|
88
|
+
}
|
|
89
|
+
/** S2 (a)-(c): secrets from forwarded headers, upstreamAuth and the upstream URL; min length 8. */
|
|
90
|
+
function knownSecrets(input) {
|
|
91
|
+
const derived = [];
|
|
92
|
+
for (const [name, value] of Object.entries(input.headers)) {
|
|
93
|
+
if (typeof value !== "string") continue;
|
|
94
|
+
if (CREDENTIAL_KEY.test(name) || API_KEY_HEADER.test(name)) derived.push(...credentialParts(value));
|
|
95
|
+
}
|
|
96
|
+
if (input.upstreamAuth) derived.push(...credentialParts(input.upstreamAuth.value));
|
|
97
|
+
derived.push(...urlParts(input.upstreamUrl));
|
|
98
|
+
const secrets = new Set(derived.filter((s) => s.length >= MIN_SECRET_LENGTH));
|
|
99
|
+
for (const value of input.secretValues) if (value.length) secrets.add(value);
|
|
100
|
+
return [...secrets].sort((a, b) => b.length - a.length);
|
|
101
|
+
}
|
|
102
|
+
/** `%2b` for `%2B`: some encoders write lowercase hex. */
|
|
103
|
+
function lowerHex(value) {
|
|
104
|
+
return value.replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase());
|
|
105
|
+
}
|
|
106
|
+
/** `f(value)`, or nothing when it throws (a lone surrogate, a bad escape). */
|
|
107
|
+
function attempt(f, value) {
|
|
108
|
+
try {
|
|
109
|
+
return [f(value)];
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Every textual form a known secret can take in a recording (G2b B1-r2): the
|
|
116
|
+
* value as registered, its percent-decoded forms (it may have been
|
|
117
|
+
* registered encoded, as a URL query value is), and for each of those the
|
|
118
|
+
* `encodeURIComponent`, form (`URLSearchParams`, `+` for space) and
|
|
119
|
+
* `encodeURI` encodings, with uppercase or lowercase hex. A derived form
|
|
120
|
+
* shorter than `MIN_SECRET_LENGTH` is dropped; the value itself never is.
|
|
121
|
+
*/
|
|
122
|
+
function secretForms(secret) {
|
|
123
|
+
const bases = [
|
|
124
|
+
secret,
|
|
125
|
+
...attempt(decodeURIComponent, secret),
|
|
126
|
+
...attempt((v) => decodeURIComponent(v.replace(/\+/g, " ")), secret)
|
|
127
|
+
];
|
|
128
|
+
const forms = /* @__PURE__ */ new Set();
|
|
129
|
+
for (const base of bases) {
|
|
130
|
+
forms.add(base);
|
|
131
|
+
const encoded = [
|
|
132
|
+
...attempt(encodeURIComponent, base),
|
|
133
|
+
...attempt((v) => new URLSearchParams([["v", v]]).toString().slice(2), base),
|
|
134
|
+
...attempt(encodeURI, base),
|
|
135
|
+
...attempt((v) => encodeURIComponent(v).replace(/%20/g, "+"), base)
|
|
136
|
+
];
|
|
137
|
+
for (const form of encoded) {
|
|
138
|
+
forms.add(form);
|
|
139
|
+
forms.add(lowerHex(form));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return [...forms].filter((form) => form === secret || form.length >= MIN_SECRET_LENGTH);
|
|
143
|
+
}
|
|
144
|
+
const TOKEN_PATTERNS = [
|
|
145
|
+
{
|
|
146
|
+
kind: "Anthropic API key",
|
|
147
|
+
pattern: /\bsk-ant-[A-Za-z0-9_-]{8,}/
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
kind: "OpenAI API key",
|
|
151
|
+
pattern: /\bsk-(?!ant-)[A-Za-z0-9_-]{16,}/
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
kind: "GitHub token",
|
|
155
|
+
pattern: /\b(?:ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
kind: "Slack token",
|
|
159
|
+
pattern: /\bxox[abp]-[A-Za-z0-9-]{10,}/
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
kind: "AWS access key",
|
|
163
|
+
pattern: /\bAKIA[0-9A-Z]{16}\b/
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
kind: "JWT",
|
|
167
|
+
pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/
|
|
168
|
+
}
|
|
169
|
+
];
|
|
170
|
+
function escapePointer(key) {
|
|
171
|
+
return key.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* S2-S7 over one JSON value. `isCredentialBranch(pointer)` is called with the
|
|
175
|
+
* pointer of each object and says whether credential-shaped keys directly
|
|
176
|
+
* under it are removed (S4: `/recorded…`, any `…/_meta…`).
|
|
177
|
+
* Throws RecordUnsafeError (S6). The input is never mutated.
|
|
178
|
+
*/
|
|
179
|
+
function sanitizeRecording(value, secrets, isCredentialBranch) {
|
|
180
|
+
const ordered = [...new Set(secrets.filter((s) => s.length).flatMap(secretForms))].sort((a, b) => b.length - a.length);
|
|
181
|
+
const hasSecret = (s) => ordered.some((secret) => s.includes(secret));
|
|
182
|
+
const jsonForms = ordered.map((secret) => JSON.stringify(secret).slice(1, -1));
|
|
183
|
+
const hasSecretInJson = (s) => hasSecret(s) || jsonForms.some((secret) => s.includes(secret));
|
|
184
|
+
const onPathSecret = () => {
|
|
185
|
+
throw new RecordUnsafeError("secret-in-path");
|
|
186
|
+
};
|
|
187
|
+
const warnings = [];
|
|
188
|
+
function visitString(input, pointer) {
|
|
189
|
+
const url = scrubUrl(input, hasSecret, onPathSecret);
|
|
190
|
+
if (url !== input) warnings.push(`${pointer}: removed credentials from a URL`);
|
|
191
|
+
let out = url;
|
|
192
|
+
for (const secret of ordered) out = out.split(secret).join(REDACTED);
|
|
193
|
+
if (out !== url) warnings.push(`${pointer}: redacted a known secret`);
|
|
194
|
+
for (const { kind, pattern } of TOKEN_PATTERNS) if (pattern.test(out)) warnings.push(`${pointer}: looks like a ${kind} token`);
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
function visit(node, pointer) {
|
|
198
|
+
if (typeof node === "string") return visitString(node, pointer);
|
|
199
|
+
if (Array.isArray(node)) return node.map((item, i) => visit(item, `${pointer}/${i}`));
|
|
200
|
+
if (!node || typeof node !== "object") return node;
|
|
201
|
+
const removeCredentials = isCredentialBranch(pointer);
|
|
202
|
+
const output = {};
|
|
203
|
+
for (const [key, item] of Object.entries(node)) {
|
|
204
|
+
if (removeCredentials && CREDENTIAL_KEY.test(key)) continue;
|
|
205
|
+
Object.defineProperty(output, key, {
|
|
206
|
+
value: visit(item, `${pointer}/${escapePointer(key)}`),
|
|
207
|
+
enumerable: true,
|
|
208
|
+
writable: true,
|
|
209
|
+
configurable: true
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return output;
|
|
213
|
+
}
|
|
214
|
+
const cleaned = visit(value, "");
|
|
215
|
+
if (hasSecretInJson(JSON.stringify(cleaned) ?? "")) throw new RecordUnsafeError("secret-remains");
|
|
216
|
+
return {
|
|
217
|
+
value: cleaned,
|
|
218
|
+
warnings
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
//#endregion
|
|
223
|
+
export { CREDENTIAL_KEY, REDACTED, RecordUnsafeError, knownSecrets, sanitizeRecording, scrubUrl, validateSecretValues };
|
|
224
|
+
//# sourceMappingURL=record-sanitize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"record-sanitize.js","names":[],"sources":["../src/record-sanitize.ts"],"sourcesContent":["/**\n * Shared record sanitizer (D10, S1-S7).\n *\n * The credential-key and URL rules were moved here from `live-sanitize.ts`,\n * which imports them back unchanged. `sanitizeRecording` applies the generic\n * record rules to any JSON value (for example an `mcpFakes` recording).\n */\n\nexport const CREDENTIAL_KEY =\n /^(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|secret|token|sec-websocket-protocol)$/i;\nexport const REDACTED = \"[REDACTED]\";\nexport const MIN_SECRET_LENGTH = 8;\n\n/** Thrown when a recording cannot be made safe (S6). Never carries a value. */\nexport class RecordUnsafeError extends Error {\n constructor(readonly reason: \"secret-in-path\" | \"secret-remains\") {\n super(`unsafe recording: ${reason}`);\n this.name = \"RecordUnsafeError\";\n }\n}\n\nexport function decodeUrlComponent(value: string): string {\n // Tolerate malformed escapes while exposing encoded secrets. Protect literal\n // form separators so the entire component remains one value.\n return new URLSearchParams(`value=${value.replace(/\\+/g, \"%2B\").replace(/&/g, \"%26\")}`).get(\n \"value\",\n )!;\n}\n\n/**\n * S3: scrub one absolute URL string; non-URLs unchanged. `onPathSecret` is\n * called (and must throw) when the decoded path contains a secret.\n */\nexport function scrubUrl(\n value: string,\n hasSecret: (s: string) => boolean,\n onPathSecret: () => never,\n): string {\n // Leave non-URLs and clean URLs byte-for-byte intact.\n if (!/^[a-z][a-z0-9+.-]*:\\/\\//i.test(value)) return value;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return value;\n }\n // A path identifies the resource; changing it could change replay semantics.\n if (hasSecret(decodeUrlComponent(url.pathname))) onPathSecret();\n let changed = false;\n if (url.username || url.password) {\n url.username = \"\";\n url.password = \"\";\n changed = true;\n }\n for (const [key, val] of [...url.searchParams]) {\n if (CREDENTIAL_KEY.test(key) || hasSecret(key) || hasSecret(val)) {\n url.searchParams.delete(key);\n changed = true;\n }\n }\n const decodedHash = decodeUrlComponent(url.hash);\n if (\n url.hash &&\n (hasSecret(decodedHash) || /(?:token|secret|password|api[_-]?key)=/i.test(decodedHash))\n ) {\n url.hash = \"\";\n changed = true;\n }\n return changed ? url.href : value;\n}\n\n/** S2 (d) start check: throws a plain Error naming the rule, never the value. */\nexport function validateSecretValues(values: readonly string[]): void {\n for (const value of values) {\n if (typeof value !== \"string\") throw new Error(\"record.secretValues entries must be strings\");\n if (value.length < MIN_SECRET_LENGTH) {\n throw new Error(`record.secretValues entry shorter than ${MIN_SECRET_LENGTH} characters`);\n }\n }\n}\n\n// Recognized API-key header names beyond the credential-key regex\n// (for example `x-goog-api-key`).\nconst API_KEY_HEADER = /(?:^|[-_])api[-_]?key$/i;\n\n/** The value, the token after the scheme, and for `Basic` the decoded user and password. */\nfunction credentialParts(value: string): string[] {\n const parts = [value];\n const match = /^(\\S+)\\s+(\\S.*)$/.exec(value.trim());\n if (match) {\n const token = match[2].trim();\n parts.push(token);\n if (match[1].toLowerCase() === \"basic\") {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf8\");\n const colon = decoded.indexOf(\":\");\n if (colon >= 0) parts.push(decoded.slice(0, colon), decoded.slice(colon + 1));\n }\n }\n return parts;\n}\n\nfunction urlParts(value: string): string[] {\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return [];\n }\n const parts: string[] = [];\n if (url.username) parts.push(url.username, decodeUrlComponent(url.username));\n if (url.password) parts.push(url.password, decodeUrlComponent(url.password));\n // Every query value; path segments are names, so they are not included (S2 c).\n for (const val of url.searchParams.values()) parts.push(val);\n return parts;\n}\n\n/** S2 (a)-(c): secrets from forwarded headers, upstreamAuth and the upstream URL; min length 8. */\nexport function knownSecrets(input: {\n headers: Record<string, string>;\n upstreamAuth?: { name: string; value: string };\n upstreamUrl: string;\n secretValues: readonly string[];\n}): string[] {\n const derived: string[] = [];\n for (const [name, value] of Object.entries(input.headers)) {\n if (typeof value !== \"string\") continue;\n if (CREDENTIAL_KEY.test(name) || API_KEY_HEADER.test(name)) {\n derived.push(...credentialParts(value));\n }\n }\n if (input.upstreamAuth) derived.push(...credentialParts(input.upstreamAuth.value));\n derived.push(...urlParts(input.upstreamUrl));\n const secrets = new Set(derived.filter((s) => s.length >= MIN_SECRET_LENGTH));\n // (d) is validated at start (`validateSecretValues`); keep every non-empty entry.\n for (const value of input.secretValues) if (value.length) secrets.add(value);\n return [...secrets].sort((a, b) => b.length - a.length);\n}\n\n/** `%2b` for `%2B`: some encoders write lowercase hex. */\nfunction lowerHex(value: string): string {\n return value.replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase());\n}\n\n/** `f(value)`, or nothing when it throws (a lone surrogate, a bad escape). */\nfunction attempt(f: (value: string) => string, value: string): string[] {\n try {\n return [f(value)];\n } catch {\n return [];\n }\n}\n\n/**\n * Every textual form a known secret can take in a recording (G2b B1-r2): the\n * value as registered, its percent-decoded forms (it may have been\n * registered encoded, as a URL query value is), and for each of those the\n * `encodeURIComponent`, form (`URLSearchParams`, `+` for space) and\n * `encodeURI` encodings, with uppercase or lowercase hex. A derived form\n * shorter than `MIN_SECRET_LENGTH` is dropped; the value itself never is.\n */\nexport function secretForms(secret: string): string[] {\n const bases = [\n secret,\n ...attempt(decodeURIComponent, secret),\n ...attempt((v) => decodeURIComponent(v.replace(/\\+/g, \" \")), secret),\n ];\n const forms = new Set<string>();\n for (const base of bases) {\n forms.add(base);\n const encoded = [\n ...attempt(encodeURIComponent, base),\n ...attempt((v) => new URLSearchParams([[\"v\", v]]).toString().slice(2), base),\n ...attempt(encodeURI, base),\n ...attempt((v) => encodeURIComponent(v).replace(/%20/g, \"+\"), base),\n ];\n for (const form of encoded) {\n forms.add(form);\n forms.add(lowerHex(form));\n }\n }\n return [...forms].filter((form) => form === secret || form.length >= MIN_SECRET_LENGTH);\n}\n\nexport interface SanitizeResult<T> {\n value: T;\n /** `_warnings` text: \"<pointer>: redacted a known secret\" / \"<pointer>: looks like a <kind> token\" */\n warnings: string[];\n}\n\n// S7: token-shaped strings are reported, never redacted. Order matters:\n// `sk-ant-` is checked before the generic `sk-`.\nconst TOKEN_PATTERNS: readonly { kind: string; pattern: RegExp }[] = [\n { kind: \"Anthropic API key\", pattern: /\\bsk-ant-[A-Za-z0-9_-]{8,}/ },\n { kind: \"OpenAI API key\", pattern: /\\bsk-(?!ant-)[A-Za-z0-9_-]{16,}/ },\n { kind: \"GitHub token\", pattern: /\\b(?:ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/ },\n { kind: \"Slack token\", pattern: /\\bxox[abp]-[A-Za-z0-9-]{10,}/ },\n { kind: \"AWS access key\", pattern: /\\bAKIA[0-9A-Z]{16}\\b/ },\n { kind: \"JWT\", pattern: /\\beyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+/ },\n];\n\nfunction escapePointer(key: string): string {\n return key.replace(/~/g, \"~0\").replace(/\\//g, \"~1\");\n}\n\n/**\n * S2-S7 over one JSON value. `isCredentialBranch(pointer)` is called with the\n * pointer of each object and says whether credential-shaped keys directly\n * under it are removed (S4: `/recorded…`, any `…/_meta…`).\n * Throws RecordUnsafeError (S6). The input is never mutated.\n */\nexport function sanitizeRecording<T>(\n value: T,\n secrets: readonly string[],\n isCredentialBranch: (pointer: string) => boolean,\n): SanitizeResult<T> {\n // G2b B1-r2: every form a secret can take (encoded, decoded) is matched.\n const ordered = [...new Set(secrets.filter((s) => s.length).flatMap(secretForms))].sort(\n (a, b) => b.length - a.length,\n );\n const hasSecret = (s: string) => ordered.some((secret) => s.includes(secret));\n // S6 runs over JSON text, where `\"`, `\\` and control characters are escaped.\n const jsonForms = ordered.map((secret) => JSON.stringify(secret).slice(1, -1));\n const hasSecretInJson = (s: string) =>\n hasSecret(s) || jsonForms.some((secret) => s.includes(secret));\n const onPathSecret = (): never => {\n throw new RecordUnsafeError(\"secret-in-path\");\n };\n const warnings: string[] = [];\n\n function visitString(input: string, pointer: string): string {\n const url = scrubUrl(input, hasSecret, onPathSecret);\n if (url !== input) warnings.push(`${pointer}: removed credentials from a URL`);\n let out = url;\n for (const secret of ordered) out = out.split(secret).join(REDACTED);\n if (out !== url) warnings.push(`${pointer}: redacted a known secret`);\n for (const { kind, pattern } of TOKEN_PATTERNS) {\n if (pattern.test(out)) warnings.push(`${pointer}: looks like a ${kind} token`);\n }\n return out;\n }\n\n function visit(node: unknown, pointer: string): unknown {\n if (typeof node === \"string\") return visitString(node, pointer);\n if (Array.isArray(node)) return node.map((item, i) => visit(item, `${pointer}/${i}`));\n if (!node || typeof node !== \"object\") return node;\n const removeCredentials = isCredentialBranch(pointer);\n const output: Record<string, unknown> = {};\n for (const [key, item] of Object.entries(node)) {\n if (removeCredentials && CREDENTIAL_KEY.test(key)) continue;\n // Own data property, so a `__proto__` key never rewires the prototype.\n Object.defineProperty(output, key, {\n value: visit(item, `${pointer}/${escapePointer(key)}`),\n enumerable: true,\n writable: true,\n configurable: true,\n });\n }\n return output;\n }\n\n const cleaned = visit(value, \"\") as T;\n // S6: fail closed if any known secret remains (for example in a key name).\n if (hasSecretInJson(JSON.stringify(cleaned) ?? \"\")) {\n throw new RecordUnsafeError(\"secret-remains\");\n }\n return { value: cleaned, warnings };\n}\n"],"mappings":"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"}
|
package/dist/recorder.cjs
CHANGED
|
@@ -386,7 +386,8 @@ async function proxyAndRecord(req, res, request, providerKey, pathname, fixtures
|
|
|
386
386
|
const contentType = upstreamHeaders["content-type"];
|
|
387
387
|
const ctString = pickContentType(contentType);
|
|
388
388
|
const isBinaryStream = ctString.toLowerCase().includes("application/vnd.amazon.eventstream");
|
|
389
|
-
const
|
|
389
|
+
const collapsedRaw = require_stream_collapse.collapseStreamingResponseWithResponsesTools(ctString, providerKey, isBinaryStream ? rawBuffer : upstreamBody, defaults.logger);
|
|
390
|
+
const collapsed = collapsedRaw && defaults.responsesTools !== "extended" ? require_stream_collapse.withoutResponsesToolFields(collapsedRaw) : collapsedRaw;
|
|
390
391
|
let fixtureResponse;
|
|
391
392
|
if (ctString.toLowerCase().startsWith("audio/") && upstreamStatus >= 200 && upstreamStatus < 300) {
|
|
392
393
|
const audioFormat = ctString.toLowerCase().replace("audio/", "").replace("mpeg", "mp3").split(";")[0].trim();
|
|
@@ -420,7 +421,7 @@ async function proxyAndRecord(req, res, request, providerKey, pathname, fixtures
|
|
|
420
421
|
...audioContentSpread,
|
|
421
422
|
...audioReasoningSpread
|
|
422
423
|
};
|
|
423
|
-
} else if (collapsed.content === "" && (!collapsed.toolCalls || collapsed.toolCalls.length === 0)) {
|
|
424
|
+
} else if (collapsed.content === "" && (!collapsed.toolCalls || collapsed.toolCalls.length === 0) && !collapsed.customToolCalls?.length) {
|
|
424
425
|
defaults.logger.warn("Stream collapse produced empty content — fixture may be incomplete");
|
|
425
426
|
const reasoningSpread = collapsed.reasoning ? { reasoning: collapsed.reasoning } : {};
|
|
426
427
|
const reasoningSignatureSpread = collapsed.reasoning && collapsed.reasoningSignature ? { reasoningSignature: collapsed.reasoningSignature } : {};
|
|
@@ -441,12 +442,27 @@ async function proxyAndRecord(req, res, request, providerKey, pathname, fixtures
|
|
|
441
442
|
logDroppedReasoningSignature(defaults.logger, collapsed.reasoning, collapsed.reasoningSignature);
|
|
442
443
|
const redactedThinkingSpread = collapsed.redactedThinking?.length ? { redactedThinking: collapsed.redactedThinking } : {};
|
|
443
444
|
const webSearchesSpread = collapsed.webSearches?.length ? { webSearches: collapsed.webSearches } : {};
|
|
444
|
-
if (collapsed.
|
|
445
|
-
const
|
|
445
|
+
if (collapsed.customToolCalls?.length) {
|
|
446
|
+
const toolCalls = (collapsed.toolCalls ?? []).map(sanitizeRecordedToolCall);
|
|
447
|
+
const customToolCalls = collapsed.customToolCalls.map((tc) => ({
|
|
446
448
|
...tc,
|
|
447
449
|
name: tc.name ?? "",
|
|
448
|
-
|
|
450
|
+
input: tc.input ?? ""
|
|
449
451
|
}));
|
|
452
|
+
const orderSpread = collapsed.responsesBlocks?.length ? { responsesBlocks: collapsed.responsesBlocks } : collapsed.blocks?.length ? { blocks: collapsed.blocks } : {};
|
|
453
|
+
fixtureResponse = {
|
|
454
|
+
...collapsed.content ? { content: collapsed.content } : {},
|
|
455
|
+
toolCalls,
|
|
456
|
+
customToolCalls,
|
|
457
|
+
...orderSpread,
|
|
458
|
+
...reasoningSpread,
|
|
459
|
+
...reasoningSignatureSpread,
|
|
460
|
+
...redactedThinkingSpread,
|
|
461
|
+
...webSearchesSpread,
|
|
462
|
+
...usageSpread
|
|
463
|
+
};
|
|
464
|
+
} else if (collapsed.toolCalls && collapsed.toolCalls.length > 0) {
|
|
465
|
+
const sanitizedToolCalls = collapsed.toolCalls.map(sanitizeRecordedToolCall);
|
|
450
466
|
if (collapsed.content) {
|
|
451
467
|
const blocksSpread = collapsed.blocks?.length ? { blocks: collapsed.blocks } : {};
|
|
452
468
|
fixtureResponse = {
|
|
@@ -809,6 +825,14 @@ function makeUpstreamRequest(target, headers, body, clientRes, method = "POST",
|
|
|
809
825
|
function logDroppedReasoningSignature(logger, reasoning, reasoningSignature) {
|
|
810
826
|
if (reasoningSignature && !reasoning) logger?.warn("Dropping captured reasoningSignature — no plaintext reasoning to attach it to");
|
|
811
827
|
}
|
|
828
|
+
/** Sanitize a collapsed function call before it is persisted: string `name` and `arguments`. */
|
|
829
|
+
function sanitizeRecordedToolCall(tc) {
|
|
830
|
+
return {
|
|
831
|
+
...tc,
|
|
832
|
+
name: tc.name ?? "",
|
|
833
|
+
arguments: tc.arguments ?? "{}"
|
|
834
|
+
};
|
|
835
|
+
}
|
|
812
836
|
/**
|
|
813
837
|
* Coerce a tool call's `arguments` field into the string the fixture contract
|
|
814
838
|
* requires (types.ts `ToolCall.arguments: string`). When the upstream omits the
|
|
@@ -1273,6 +1297,82 @@ function buildFixtureMetadata(request) {
|
|
|
1273
1297
|
if (request.tools && request.tools.length > 0) meta.toolsHash = node_crypto.createHash("sha256").update(JSON.stringify(request.tools)).digest("hex").slice(0, 8);
|
|
1274
1298
|
return Object.keys(meta).length > 0 ? meta : void 0;
|
|
1275
1299
|
}
|
|
1300
|
+
/**
|
|
1301
|
+
* MR11: read-merge-validate-write one service fakes file atomically (temp +
|
|
1302
|
+
* rename, 0o600). Synchronous, so writes to one path never interleave in this
|
|
1303
|
+
* process. Keeps unknown top-level keys (fakes:F9). `validate` returns a
|
|
1304
|
+
* problem text, or null when the whole merged file is valid. Logs RL4 on a
|
|
1305
|
+
* write; a failure is returned, never logged here (the caller logs RL1 with
|
|
1306
|
+
* its own context).
|
|
1307
|
+
*/
|
|
1308
|
+
function persistServiceFakes(opts) {
|
|
1309
|
+
const { filepath, merge, validate, logger } = opts;
|
|
1310
|
+
let existing = null;
|
|
1311
|
+
let text = null;
|
|
1312
|
+
try {
|
|
1313
|
+
text = node_fs.readFileSync(filepath, "utf-8");
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
if (err.code !== "ENOENT") return {
|
|
1316
|
+
kind: "failed",
|
|
1317
|
+
stage: "write",
|
|
1318
|
+
error: err instanceof Error ? err.message : "Unknown filesystem error"
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
if (text !== null) {
|
|
1322
|
+
let parsed;
|
|
1323
|
+
try {
|
|
1324
|
+
parsed = JSON.parse(text);
|
|
1325
|
+
} catch {
|
|
1326
|
+
return {
|
|
1327
|
+
kind: "failed",
|
|
1328
|
+
stage: "read",
|
|
1329
|
+
error: `${filepath} is not valid JSON`
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {
|
|
1333
|
+
kind: "failed",
|
|
1334
|
+
stage: "read",
|
|
1335
|
+
error: `${filepath} is not a JSON object`
|
|
1336
|
+
};
|
|
1337
|
+
existing = parsed;
|
|
1338
|
+
}
|
|
1339
|
+
const content = merge(existing);
|
|
1340
|
+
const problem = validate(content);
|
|
1341
|
+
if (problem !== null) return {
|
|
1342
|
+
kind: "failed",
|
|
1343
|
+
stage: "validate",
|
|
1344
|
+
error: problem
|
|
1345
|
+
};
|
|
1346
|
+
let tmpPath;
|
|
1347
|
+
try {
|
|
1348
|
+
node_fs.mkdirSync(node_path.dirname(filepath), { recursive: true });
|
|
1349
|
+
const bytes = Buffer.from(JSON.stringify(content, null, 2), "utf-8");
|
|
1350
|
+
tmpPath = filepath + ".tmp." + node_crypto.randomUUID();
|
|
1351
|
+
node_fs.writeFileSync(tmpPath, bytes, {
|
|
1352
|
+
flag: "wx",
|
|
1353
|
+
mode: 384
|
|
1354
|
+
});
|
|
1355
|
+
node_fs.renameSync(tmpPath, filepath);
|
|
1356
|
+
tmpPath = void 0;
|
|
1357
|
+
logger.warn(`Response recorded → ${filepath}`);
|
|
1358
|
+
return {
|
|
1359
|
+
kind: "written",
|
|
1360
|
+
filepath,
|
|
1361
|
+
sha256: node_crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
1362
|
+
content
|
|
1363
|
+
};
|
|
1364
|
+
} catch (err) {
|
|
1365
|
+
return {
|
|
1366
|
+
kind: "failed",
|
|
1367
|
+
stage: "write",
|
|
1368
|
+
error: err instanceof Error ? err.message : "Unknown filesystem error"
|
|
1369
|
+
};
|
|
1370
|
+
} finally {
|
|
1371
|
+
if (tmpPath) try {
|
|
1372
|
+
node_fs.unlinkSync(tmpPath);
|
|
1373
|
+
} catch {}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1276
1376
|
|
|
1277
1377
|
//#endregion
|
|
1278
1378
|
exports.DEFAULT_MAX_PROXY_BUFFER_BYTES = DEFAULT_MAX_PROXY_BUFFER_BYTES;
|
|
@@ -1282,6 +1382,7 @@ exports.buildFixtureMatch = buildFixtureMatch;
|
|
|
1282
1382
|
exports.buildForwardHeaders = buildForwardHeaders;
|
|
1283
1383
|
exports.clampTimeout = clampTimeout;
|
|
1284
1384
|
exports.persistFixture = persistFixture;
|
|
1385
|
+
exports.persistServiceFakes = persistServiceFakes;
|
|
1285
1386
|
exports.prepareEgressHeaders = prepareEgressHeaders;
|
|
1286
1387
|
exports.proxyAndRecord = proxyAndRecord;
|
|
1287
1388
|
exports.removeForwardHeader = removeForwardHeader;
|