@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.
Files changed (272) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +31 -0
  4. package/README.md +6 -0
  5. package/dist/agui-types.d.ts.map +1 -1
  6. package/dist/aimock-cli.cjs +31 -5
  7. package/dist/aimock-cli.cjs.map +1 -1
  8. package/dist/aimock-cli.d.cts.map +1 -1
  9. package/dist/aimock-cli.d.ts.map +1 -1
  10. package/dist/aimock-cli.js +31 -5
  11. package/dist/aimock-cli.js.map +1 -1
  12. package/dist/bedrock-converse.cjs +312 -39
  13. package/dist/bedrock-converse.cjs.map +1 -1
  14. package/dist/bedrock-converse.d.cts.map +1 -1
  15. package/dist/bedrock-converse.d.ts.map +1 -1
  16. package/dist/bedrock-converse.js +313 -40
  17. package/dist/bedrock-converse.js.map +1 -1
  18. package/dist/bedrock.cjs +195 -42
  19. package/dist/bedrock.cjs.map +1 -1
  20. package/dist/bedrock.d.cts.map +1 -1
  21. package/dist/bedrock.d.ts.map +1 -1
  22. package/dist/bedrock.js +196 -43
  23. package/dist/bedrock.js.map +1 -1
  24. package/dist/chaos.cjs +1 -0
  25. package/dist/chaos.js +1 -1
  26. package/dist/cli.cjs +152 -5
  27. package/dist/cli.cjs.map +1 -1
  28. package/dist/cli.js +155 -8
  29. package/dist/cli.js.map +1 -1
  30. package/dist/cohere.cjs +132 -71
  31. package/dist/cohere.cjs.map +1 -1
  32. package/dist/cohere.d.cts.map +1 -1
  33. package/dist/cohere.d.ts.map +1 -1
  34. package/dist/cohere.js +133 -72
  35. package/dist/cohere.js.map +1 -1
  36. package/dist/config-loader.cjs +62 -4
  37. package/dist/config-loader.cjs.map +1 -1
  38. package/dist/config-loader.d.cts +37 -4
  39. package/dist/config-loader.d.cts.map +1 -1
  40. package/dist/config-loader.d.ts +37 -4
  41. package/dist/config-loader.d.ts.map +1 -1
  42. package/dist/config-loader.js +62 -4
  43. package/dist/config-loader.js.map +1 -1
  44. package/dist/fakes-plugin.cjs +131 -0
  45. package/dist/fakes-plugin.cjs.map +1 -0
  46. package/dist/fakes-plugin.d.cts +23 -0
  47. package/dist/fakes-plugin.d.cts.map +1 -0
  48. package/dist/fakes-plugin.d.ts +23 -0
  49. package/dist/fakes-plugin.d.ts.map +1 -0
  50. package/dist/fakes-plugin.js +127 -0
  51. package/dist/fakes-plugin.js.map +1 -0
  52. package/dist/fixture-loader-services.cjs +1 -1
  53. package/dist/fixture-loader-services.cjs.map +1 -1
  54. package/dist/fixture-loader-services.js +1 -1
  55. package/dist/fixture-loader-services.js.map +1 -1
  56. package/dist/fixture-loader.cjs +389 -27
  57. package/dist/fixture-loader.cjs.map +1 -1
  58. package/dist/fixture-loader.d.cts +47 -3
  59. package/dist/fixture-loader.d.cts.map +1 -1
  60. package/dist/fixture-loader.d.ts +47 -3
  61. package/dist/fixture-loader.d.ts.map +1 -1
  62. package/dist/fixture-loader.js +384 -29
  63. package/dist/fixture-loader.js.map +1 -1
  64. package/dist/gemini-interactions.cjs +250 -52
  65. package/dist/gemini-interactions.cjs.map +1 -1
  66. package/dist/gemini-interactions.d.cts.map +1 -1
  67. package/dist/gemini-interactions.d.ts.map +1 -1
  68. package/dist/gemini-interactions.js +251 -53
  69. package/dist/gemini-interactions.js.map +1 -1
  70. package/dist/gemini.cjs +226 -37
  71. package/dist/gemini.cjs.map +1 -1
  72. package/dist/gemini.d.cts.map +1 -1
  73. package/dist/gemini.d.ts.map +1 -1
  74. package/dist/gemini.js +227 -38
  75. package/dist/gemini.js.map +1 -1
  76. package/dist/helpers.cjs +881 -7
  77. package/dist/helpers.cjs.map +1 -1
  78. package/dist/helpers.d.cts +85 -4
  79. package/dist/helpers.d.cts.map +1 -1
  80. package/dist/helpers.d.ts +85 -4
  81. package/dist/helpers.d.ts.map +1 -1
  82. package/dist/helpers.js +839 -8
  83. package/dist/helpers.js.map +1 -1
  84. package/dist/index.cjs +9 -0
  85. package/dist/index.d.cts +7 -5
  86. package/dist/index.d.ts +7 -5
  87. package/dist/index.js +4 -3
  88. package/dist/jest.cjs +31 -7
  89. package/dist/jest.cjs.map +1 -1
  90. package/dist/jest.d.cts +43 -3
  91. package/dist/jest.d.cts.map +1 -1
  92. package/dist/jest.d.ts +43 -3
  93. package/dist/jest.d.ts.map +1 -1
  94. package/dist/jest.js +33 -9
  95. package/dist/jest.js.map +1 -1
  96. package/dist/journal.cjs +135 -13
  97. package/dist/journal.cjs.map +1 -1
  98. package/dist/journal.d.cts +38 -4
  99. package/dist/journal.d.cts.map +1 -1
  100. package/dist/journal.d.ts +38 -4
  101. package/dist/journal.d.ts.map +1 -1
  102. package/dist/journal.js +135 -13
  103. package/dist/journal.js.map +1 -1
  104. package/dist/live-sanitize.cjs +4 -32
  105. package/dist/live-sanitize.cjs.map +1 -1
  106. package/dist/live-sanitize.js +4 -32
  107. package/dist/live-sanitize.js.map +1 -1
  108. package/dist/llmock.cjs +123 -30
  109. package/dist/llmock.cjs.map +1 -1
  110. package/dist/llmock.d.cts +16 -1
  111. package/dist/llmock.d.cts.map +1 -1
  112. package/dist/llmock.d.ts +16 -1
  113. package/dist/llmock.d.ts.map +1 -1
  114. package/dist/llmock.js +125 -32
  115. package/dist/llmock.js.map +1 -1
  116. package/dist/mcp-fakes-mount.cjs +1 -0
  117. package/dist/mcp-fakes-mount.cjs.map +1 -1
  118. package/dist/mcp-fakes-mount.js +1 -0
  119. package/dist/mcp-fakes-mount.js.map +1 -1
  120. package/dist/mcp-fakes-report.cjs +111 -0
  121. package/dist/mcp-fakes-report.cjs.map +1 -0
  122. package/dist/mcp-fakes-report.d.cts +40 -0
  123. package/dist/mcp-fakes-report.d.cts.map +1 -0
  124. package/dist/mcp-fakes-report.d.ts +40 -0
  125. package/dist/mcp-fakes-report.d.ts.map +1 -0
  126. package/dist/mcp-fakes-report.js +107 -0
  127. package/dist/mcp-fakes-report.js.map +1 -0
  128. package/dist/mcp-fakes.cjs +331 -8
  129. package/dist/mcp-fakes.cjs.map +1 -1
  130. package/dist/mcp-fakes.d.cts +406 -3
  131. package/dist/mcp-fakes.d.cts.map +1 -1
  132. package/dist/mcp-fakes.d.ts +406 -3
  133. package/dist/mcp-fakes.d.ts.map +1 -1
  134. package/dist/mcp-fakes.js +331 -8
  135. package/dist/mcp-fakes.js.map +1 -1
  136. package/dist/mcp-handler.cjs +209 -16
  137. package/dist/mcp-handler.cjs.map +1 -1
  138. package/dist/mcp-handler.d.cts +49 -0
  139. package/dist/mcp-handler.d.cts.map +1 -0
  140. package/dist/mcp-handler.d.ts +49 -0
  141. package/dist/mcp-handler.d.ts.map +1 -0
  142. package/dist/mcp-handler.js +207 -17
  143. package/dist/mcp-handler.js.map +1 -1
  144. package/dist/mcp-mock.cjs +112 -1
  145. package/dist/mcp-mock.cjs.map +1 -1
  146. package/dist/mcp-mock.d.cts +33 -2
  147. package/dist/mcp-mock.d.cts.map +1 -1
  148. package/dist/mcp-mock.d.ts +33 -2
  149. package/dist/mcp-mock.d.ts.map +1 -1
  150. package/dist/mcp-mock.js +112 -1
  151. package/dist/mcp-mock.js.map +1 -1
  152. package/dist/mcp-recorder.cjs +1224 -0
  153. package/dist/mcp-recorder.cjs.map +1 -0
  154. package/dist/mcp-recorder.d.cts +108 -0
  155. package/dist/mcp-recorder.d.cts.map +1 -0
  156. package/dist/mcp-recorder.d.ts +108 -0
  157. package/dist/mcp-recorder.d.ts.map +1 -0
  158. package/dist/mcp-recorder.js +1215 -0
  159. package/dist/mcp-recorder.js.map +1 -0
  160. package/dist/messages.cjs +362 -56
  161. package/dist/messages.cjs.map +1 -1
  162. package/dist/messages.d.cts.map +1 -1
  163. package/dist/messages.d.ts.map +1 -1
  164. package/dist/messages.js +363 -58
  165. package/dist/messages.js.map +1 -1
  166. package/dist/misbehavior.cjs +1523 -0
  167. package/dist/misbehavior.cjs.map +1 -0
  168. package/dist/misbehavior.d.cts +43 -0
  169. package/dist/misbehavior.d.cts.map +1 -0
  170. package/dist/misbehavior.d.ts +43 -0
  171. package/dist/misbehavior.d.ts.map +1 -0
  172. package/dist/misbehavior.js +1511 -0
  173. package/dist/misbehavior.js.map +1 -0
  174. package/dist/ollama.cjs +310 -112
  175. package/dist/ollama.cjs.map +1 -1
  176. package/dist/ollama.d.cts.map +1 -1
  177. package/dist/ollama.d.ts.map +1 -1
  178. package/dist/ollama.js +311 -113
  179. package/dist/ollama.js.map +1 -1
  180. package/dist/record-sanitize.cjs +231 -0
  181. package/dist/record-sanitize.cjs.map +1 -0
  182. package/dist/record-sanitize.js +224 -0
  183. package/dist/record-sanitize.js.map +1 -0
  184. package/dist/recorder.cjs +106 -5
  185. package/dist/recorder.cjs.map +1 -1
  186. package/dist/recorder.d.cts +2 -1
  187. package/dist/recorder.d.cts.map +1 -1
  188. package/dist/recorder.d.ts +2 -1
  189. package/dist/recorder.d.ts.map +1 -1
  190. package/dist/recorder.js +107 -7
  191. package/dist/recorder.js.map +1 -1
  192. package/dist/responses.cjs +667 -171
  193. package/dist/responses.cjs.map +1 -1
  194. package/dist/responses.d.cts +5 -13
  195. package/dist/responses.d.cts.map +1 -1
  196. package/dist/responses.d.ts +5 -13
  197. package/dist/responses.d.ts.map +1 -1
  198. package/dist/responses.js +665 -174
  199. package/dist/responses.js.map +1 -1
  200. package/dist/router.cjs +24 -2
  201. package/dist/router.cjs.map +1 -1
  202. package/dist/router.d.cts.map +1 -1
  203. package/dist/router.d.ts.map +1 -1
  204. package/dist/router.js +25 -4
  205. package/dist/router.js.map +1 -1
  206. package/dist/server.cjs +572 -76
  207. package/dist/server.cjs.map +1 -1
  208. package/dist/server.d.cts.map +1 -1
  209. package/dist/server.d.ts.map +1 -1
  210. package/dist/server.js +574 -78
  211. package/dist/server.js.map +1 -1
  212. package/dist/stream-collapse.cjs +236 -34
  213. package/dist/stream-collapse.cjs.map +1 -1
  214. package/dist/stream-collapse.d.cts +39 -4
  215. package/dist/stream-collapse.d.cts.map +1 -1
  216. package/dist/stream-collapse.d.ts +39 -4
  217. package/dist/stream-collapse.d.ts.map +1 -1
  218. package/dist/stream-collapse.js +235 -35
  219. package/dist/stream-collapse.js.map +1 -1
  220. package/dist/types.cjs.map +1 -1
  221. package/dist/types.d.cts +315 -4
  222. package/dist/types.d.cts.map +1 -1
  223. package/dist/types.d.ts +315 -4
  224. package/dist/types.d.ts.map +1 -1
  225. package/dist/types.js.map +1 -1
  226. package/dist/validate-cli.cjs +36 -4
  227. package/dist/validate-cli.cjs.map +1 -1
  228. package/dist/validate-cli.js +37 -5
  229. package/dist/validate-cli.js.map +1 -1
  230. package/dist/vector-types.d.ts.map +1 -1
  231. package/dist/version.cjs +45 -0
  232. package/dist/version.cjs.map +1 -0
  233. package/dist/version.js +44 -0
  234. package/dist/version.js.map +1 -0
  235. package/dist/vitest.cjs +66 -10
  236. package/dist/vitest.cjs.map +1 -1
  237. package/dist/vitest.d.cts +35 -4
  238. package/dist/vitest.d.cts.map +1 -1
  239. package/dist/vitest.d.ts +35 -4
  240. package/dist/vitest.d.ts.map +1 -1
  241. package/dist/vitest.js +67 -11
  242. package/dist/vitest.js.map +1 -1
  243. package/dist/watcher.cjs +6 -2
  244. package/dist/watcher.cjs.map +1 -1
  245. package/dist/watcher.js +6 -2
  246. package/dist/watcher.js.map +1 -1
  247. package/dist/ws-gemini-live.cjs +210 -101
  248. package/dist/ws-gemini-live.cjs.map +1 -1
  249. package/dist/ws-gemini-live.d.cts +9 -3
  250. package/dist/ws-gemini-live.d.cts.map +1 -1
  251. package/dist/ws-gemini-live.d.ts +9 -3
  252. package/dist/ws-gemini-live.d.ts.map +1 -1
  253. package/dist/ws-gemini-live.js +211 -102
  254. package/dist/ws-gemini-live.js.map +1 -1
  255. package/dist/ws-realtime.cjs +175 -62
  256. package/dist/ws-realtime.cjs.map +1 -1
  257. package/dist/ws-realtime.d.cts +8 -3
  258. package/dist/ws-realtime.d.cts.map +1 -1
  259. package/dist/ws-realtime.d.ts +8 -3
  260. package/dist/ws-realtime.d.ts.map +1 -1
  261. package/dist/ws-realtime.js +176 -63
  262. package/dist/ws-realtime.js.map +1 -1
  263. package/dist/ws-responses.cjs +109 -16
  264. package/dist/ws-responses.cjs.map +1 -1
  265. package/dist/ws-responses.d.cts +8 -12
  266. package/dist/ws-responses.d.cts.map +1 -1
  267. package/dist/ws-responses.d.ts +8 -12
  268. package/dist/ws-responses.d.ts.map +1 -1
  269. package/dist/ws-responses.js +111 -18
  270. package/dist/ws-responses.js.map +1 -1
  271. package/package.json +10 -2
  272. 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":"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"}
@@ -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":";;;;;;;;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"}
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 collapsed = require_stream_collapse.collapseStreamingResponse(ctString, providerKey, isBinaryStream ? rawBuffer : upstreamBody, defaults.logger);
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.toolCalls && collapsed.toolCalls.length > 0) {
445
- const sanitizedToolCalls = collapsed.toolCalls.map((tc) => ({
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
- arguments: tc.arguments ?? "{}"
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;