From 612da797c7256cf387e54baebc2fd8f995a218b7 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:27:44 +0300 Subject: [PATCH] test(core): make the privacy guards prove what they claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four guards passed without demonstrating the property they exist for. **import-smoke stubbed seven modules.** It imports every public entry point with I/O primitives replaced by throwing stubs, to show that importing core touches nothing. Global `fetch`, `node:os` (homedir), `node:process` and env, `node:sqlite`, tls, dgram, http2, worker_threads and `createRequire` — which bypasses ESM loader hooks entirely — were all open. Environment variables were emptied rather than made to throw, so a read succeeded silently. It also only ever proved import purity: decoder bodies were never exercised, so a decoder that opened a file when called would have passed. Blocklist extended, env made to throw on ambient keys, and parser, decoder and detector bodies now run under the same stubs. **The schema string-field gate walked only part of the schema.** It descended into properties, items and definitions, so anything under anyOf, oneOf, allOf, patternProperties, additionalProperties or a union type escaped both the enumeration and the bound check — and any maxLength at all counted as "bounded", including one large enough to hold a paragraph. **The diagnostic sanitiser blocked path separators and nothing called it.** Its only caller was unused by every decoder. **The userMessage gate greps one identifier**, so a decoder copying user text into a differently-named field was invisible to it, and eight allowlisted providers had no hostile-decode test at all. They have one now. None of the four original guards failed against current code — but review of the hardened surface found two live holes, both closed in this commit (below). The green tests now say something true. **Review, round one — two live holes the hardening missed, now closed.** - isolateRecords forwarded caller-supplied diagnostics unchanged, and RecordOutcome permitted a `detail`, so a caller returning a diagnostic could emit an unkeyed fingerprint through a public entry point. RecordOutcome now carries `{ index, code }` only, and the runtime strips any detail a caller smuggles in (a loose cast cannot smuggle one past the runtime). The fingerprint is derived in exactly one place: isolateRecords itself, from a thrown error, under the key it owns. - the copilot JetBrains dedup key was an unkeyed sha256 of the assistant reply text, truncated to 12 hex — 48 bits, dictionary-attackable on short replies like "OK" or "Done." — and it crosses into the emitted envelope (observations.dedupKey) and the CLI ledger. It is now an HMAC keyed with the host's privacy key. The key IS available: decodeCopilot receives `context`, and the digest stays deterministic across re-parses, which is what the durable-store dedup needs. One caveat, documented at the site: the CLI's bridged rich-decode path passes an empty privacy key, so on that path the digest is only as strong as an empty key — closing it fully needs the CLI to hand the rich decode a real privacy key (getHostPrivacyKey() exists on its sync path), which is the adjacent CLI change. The CLI bridge goldens that pinned the unkeyed digests were recomputed against the keyed construction. The value of EVERY cached dedup key changed, not just hostile ones, so the copilot entry in PROVIDER_PARSE_VERSIONS is bumped in lockstep (cli-shutdown-cost-v1-skills -> ...-dedup-key-hmac-v1): copilot is the sole durable provider, whose union-merge appends any turn whose keys are not already cached and never deletes, and the env-fingerprint parse-version bump is the only mechanism that drops old-shape keys. Without it, the first JetBrains transcript re-parse after this lands would append the session's entire history under the new keys while the old copies remain — both coexist, and the daily cache re-derives double-counted copilot totals. The bump makes the section rebuild once, dropping the old keys instead of merging. **Review, round one — corrections to the guards themselves.** - The open-design and lingtai-tui smuggling fixtures planted secrets in fields their decoders never read, proving only that ignored fields stay ignored; they now plant in the token fields those decoders genuinely read and assert the hostile payload was coerced away by the read itself, never echoed. - The zed error path reads context defensively (it is the only place the function touches context; an untyped caller gets a diagnostic, not a TypeError). - The import-smoke env probe sets a guaranteed-present sentinel key instead of relying on HOME, so the guard tests what it means even in a HOME-less CI container. - The architecture-gate rationale no longer claims a 256 cap rules out short tokens (a GitHub PAT is ~93 chars); it is an anti-free-text bound, with the anti-credential guarantee resting on the content-smuggling tests. - The dead `byteOffset` field was removed from RecordDiagnostic — nothing emitted it. One thing this change nearly got wrong, worth recording. Wiring the sanitiser into the decoders introduced a `detail` fingerprint where diagnostics had been bare — and the sanitiser's key was optional, silently degrading to an unkeyed digest. The bridge decodes with an empty privacy key, so on the real path that digest would have been unkeyed, derived from an error message, and Node embeds a fragment of the offending input in JSON.parse failures. That is precisely the dictionary-attackable construction D1 exists to prevent, added by the change meant to close it. The key is now required and throws like fingerprint.ts does; decoders route through a helper that omits the field entirely when no key is available, because a bare throw there would break poison-isolation and take the whole batch down with one malformed record. Four regression tests fail if the keyless path returns. The copilot JetBrains dedup key was a second instance of the same construction that survived the first pass — an unkeyed digest of reply text crossing the boundary — and is keyed now. --- packages/cli/src/providers/bridge.ts | 3 +- packages/cli/src/session-cache.ts | 13 +- .../tests/copilot-cache-invalidation.test.ts | 240 +++++++ .../tests/providers/copilot-bridge.test.ts | 40 +- packages/core/src/diagnostics.ts | 84 ++- packages/core/src/providers/copilot/decode.ts | 22 +- .../src/providers/opencode-session/decode.ts | 13 +- .../core/src/providers/vscode-cline/decode.ts | 10 +- packages/core/src/providers/zed/decode.ts | 15 +- packages/core/tests/architecture-gate.test.ts | 122 +++- packages/core/tests/content-smuggling.test.ts | 604 +++++++++++++++++- packages/core/tests/diagnostics.test.ts | 139 +++- .../core/tests/harness/block-io-hooks.mjs | 23 +- .../core/tests/harness/block-io-register.mjs | 52 +- .../core/tests/harness/import-smoke-child.mjs | 90 ++- packages/core/tests/import-smoke.test.ts | 71 +- .../providers/opencode-session-decode.test.ts | 7 +- .../providers/vscode-cline-decode.test.ts | 19 + 18 files changed, 1452 insertions(+), 115 deletions(-) create mode 100644 packages/cli/tests/copilot-cache-invalidation.test.ts diff --git a/packages/cli/src/providers/bridge.ts b/packages/cli/src/providers/bridge.ts index b03b353f..28f4655f 100644 --- a/packages/cli/src/providers/bridge.ts +++ b/packages/cli/src/providers/bridge.ts @@ -83,7 +83,8 @@ export function createBridgedProvider(spec: BridgedProviderSpec): if (records === null) return // The CLI holds the rich decode only; minimization / fingerprinting // happens on the sync path, so an empty privacy key is correct here - // (the rich decoder never consumes it), matching claude/codex. + // (the rich decoder treats it as "no detail": diagnostics carry the + // code + index only, never an unkeyed digest), matching claude/codex. const context: DecodeContext = { privacyKey: '', providerId: spec.name, sourceRef: source.path } const { calls } = spec.decode({ records, context, seenKeys }) for (const rich of calls) { diff --git a/packages/cli/src/session-cache.ts b/packages/cli/src/session-cache.ts index 461dd461..e83ef227 100644 --- a/packages/cli/src/session-cache.ts +++ b/packages/cli/src/session-cache.ts @@ -216,7 +216,18 @@ export const PROVIDER_PARSE_VERSIONS: Record = { codex: 'mcp-attribution-v2-est-cost-rich-capture-v1-cross-provider-pr-v1', cursor: 'composer-anchored-crediting-v1-est-cost', 'cursor-agent': 'workspaceless-transcript-v1', - copilot: 'cli-shutdown-cost-v1-skills', + // JetBrains dedup-key hardening (D1): the per-turn digest changed from an + // unkeyed sha256 to an HMAC keyed with the host's privacy key, so EVERY + // cached copilot dedup key's value changed, not just hostile ones. The + // session cache seeds its dedup sets from the CACHED keys, and copilot is + // the sole DURABLE_PROVIDER_NAMES member — its union-merge never deletes + // cached turns, it appends any turn whose keys are not already cached. A + // pre-fix cache would therefore keep the old unkeyed-digest keys and + // re-ingest the same records under the new keyed shape on the next re-parse, + // and both would coexist (double-count). Bump so the env fingerprint + // changes, the section rebuilds once, and the old-shape keys are dropped + // instead of merged. + copilot: 'cli-shutdown-cost-v1-skills-dedup-key-hmac-v1', grok: 'estimated-cost-v1', hermes: 'reasoning-output-accounting-v1-est-cost', 'lingtai-tui': 'token-ledger-registry-activity-v3', diff --git a/packages/cli/tests/copilot-cache-invalidation.test.ts b/packages/cli/tests/copilot-cache-invalidation.test.ts new file mode 100644 index 00000000..9006ef55 --- /dev/null +++ b/packages/cli/tests/copilot-cache-invalidation.test.ts @@ -0,0 +1,240 @@ +// Regression test for the copilot JetBrains dedup-key shape change (D1). +// +// The JetBrains dedup key was an unkeyed sha256 of the assistant reply text +// (12 hex chars — 48 bits, dictionary-attackable on short replies); it is now +// an HMAC keyed with the host's privacy key. That changes the VALUE of the +// dedup key for EVERY record, not just hostile ones. copilot is the SOLE +// member of DURABLE_PROVIDER_NAMES: the durable union-merge never deletes +// cached turns — it appends any turn whose dedup keys are not already cached. +// So without a PROVIDER_PARSE_VERSIONS bump, the first time a JetBrains +// transcript re-parses after the change, the session's ENTIRE history would +// re-ingest under the new keyed digests while the old unkeyed-digest copies +// remain in the cache — both coexist, and the daily cache re-derives +// double-counted totals. +// +// The fix registers a new copilot parse version, which changes the provider +// envFingerprint and makes `parseAllSessions` DISCARD the stale section +// (rather than merging into it) on first run, so the old-shape keys are +// dropped and the double-append cannot happen. +// +// This test exercises the full `parseAllSessions` pipeline against a seeded +// session-cache.json, in both directions: +// - a cache seeded with the CURRENT fingerprint is honored (the old-shape +// keys stay, proving the seed is structurally valid and actually trusted) +// - a cache seeded with the PRE-BUMP fingerprint is discarded and the .db +// re-parses under the new key shape — the old keys are gone, and exactly +// one (keyed) copy of each record remains + +import { describe, it, expect, beforeEach, afterAll } from 'vitest' +import { mkdir, readFile, rm, writeFile } from 'fs/promises' +import { createHash, createHmac } from 'crypto' +import { join } from 'path' + +import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { + CACHE_VERSION, + computeEnvFingerprint, + fingerprintFile, + sessionCachePath, + type SessionCache, +} from '../src/session-cache.js' + +const TEST_ROOT = `${process.env['TMPDIR'] || '/tmp'}/copilot-cache-inv-${process.pid}-${Date.now()}` +const CACHE_DIR = join(TEST_ROOT, 'cache') +const JB_ROOT = join(TEST_ROOT, 'jetbrains') + +// The JetBrains reply text used throughout. The digest in the dedup key is +// derived from EXACTLY this string, so the seeded old-shape key and the +// expected new-shape key are both computed from it. +const REPLY_TEXT = 'Hello! How can I help you today?' +const STORE_ID = 'conv-1' + +// What computeEnvFingerprint('copilot') returned under the PRE-BUMP parse +// version ('cli-shutdown-cost-v1-skills'): copilot has no PROVIDER_ENV_VARS, +// so the fingerprint is a hash of the single `parser=` part. This is the +// fingerprint sitting in every cache written before the dedup-key change. +function preBumpFingerprint(): string { + return createHash('sha256').update('parser=cli-shutdown-cost-v1-skills').digest('hex').slice(0, 16) +} + +// The dedup key the pre-fix decoder wrote: an UNKEYED sha256 of the reply text. +function oldShapeKey(): string { + const digest = createHash('sha256').update(REPLY_TEXT).digest('hex').slice(0, 12) + return `copilot:jb:${STORE_ID}:${digest}:1` +} + +// The dedup key the hardened decoder writes on the CLI path: an HMAC keyed +// with the bridge's privacy key, which is EMPTY there (bridge.ts — the rich +// decode is what feeds this cache, and minimization happens on the sync path). +function newShapeKey(): string { + const digest = createHmac('sha256', '').update(REPLY_TEXT).digest('hex').slice(0, 12) + return `copilot:jb:${STORE_ID}:${digest}:1` +} + +// ---- Nitrite-.db fixture helpers (same on-disk shape as the real JetBrains +// Copilot plugin store: MVStore header + entity-class anchor + nested-escaped +// assistant blobs). See copilot.test.ts for the full family. ---- + +function jbAssistantBlob(text: string): string { + const innerMd = { type: 'Markdown', data: JSON.stringify({ text, annotations: [] }) } + const valueMap: Record = { + 'a1b2c3d4-0000-0000-0000-000000000001': { type: 'Value', value: JSON.stringify(innerMd) }, + } + const outer: Record = { + __first__: { type: 'Subgraph', value: JSON.stringify(valueMap) }, + } + return JSON.stringify(outer) +} + +function jbDbContent(blobs: string[]): string { + return ( + 'H:2,block:9,blockSize:1000,format:3\n' + + 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + + blobs.join('\nt\x00\x00model\n') + + '\n' + ) +} + +async function createJetBrainsDb(): Promise { + const dir = join(JB_ROOT, 'iu', 'chat-agent-sessions', STORE_ID) + await mkdir(dir, { recursive: true }) + const dbPath = join(dir, 'copilot-agent-sessions-nitrite.db') + await writeFile(dbPath, jbDbContent([jbAssistantBlob(REPLY_TEXT)])) + return dbPath +} + +// Seeds a session cache holding ONE old-shape copilot turn for the .db, under +// the given envFingerprint. The cached file fingerprint is the REAL .db's, so +// a cache at the current fingerprint is served verbatim (proving the seed is +// trusted), while a pre-bump fingerprint forces the section rebuild. +async function seedCache(dbPath: string, envFingerprint: string): Promise { + const fp = await fingerprintFile(dbPath) + if (!fp) throw new Error('failed to fingerprint seeded JetBrains .db') + const now = new Date().toISOString() + const cache: SessionCache = { + version: CACHE_VERSION, + providers: { + copilot: { + envFingerprint, + files: { + [dbPath]: { + fingerprint: fp, + mcpInventory: [], + turns: [{ + timestamp: now, + sessionId: STORE_ID, + userMessage: '', + calls: [{ + provider: 'copilot', + model: 'gpt-4o', + usage: { + inputTokens: 0, + outputTokens: 10, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + cacheCreationOneHourTokens: 0, + }, + speed: 'standard', + timestamp: now, + tools: [], + bashCommands: [], + skills: [], + subagentTypes: [], + deduplicationKey: oldShapeKey(), + }], + }], + }, + }, + }, + }, + } + await mkdir(CACHE_DIR, { recursive: true }) + await writeFile(sessionCachePath(), JSON.stringify(cache)) +} + +async function cachedCopilotKeys(): Promise<{ envFingerprint: string; keys: string[] }> { + const raw = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) as { + providers: Record }> }> + }> + } + const section = raw.providers['copilot'] + const keys: string[] = [] + for (const file of Object.values(section.files)) { + for (const turn of file.turns) { + for (const call of turn.calls) keys.push(call.deduplicationKey) + } + } + return { envFingerprint: section.envFingerprint, keys } +} + +async function parsedCopilotCalls() { + const projects = await parseAllSessions(undefined, 'copilot') + return projects + .flatMap(p => p.sessions) + .flatMap(s => s.turns) + .flatMap(t => t.assistantCalls) +} + +beforeEach(async () => { + // Runs after env-isolation's global beforeEach, which cleared these vars. + process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR + process.env['CODEBURN_COPILOT_JETBRAINS_DIR'] = JB_ROOT + process.env['CODEBURN_COPILOT_DISABLE_OTEL'] = '1' + clearSessionCache() + await rm(TEST_ROOT, { recursive: true, force: true }) +}) + +afterAll(async () => { + clearSessionCache() + await rm(TEST_ROOT, { recursive: true, force: true }) +}) + +describe('copilot session cache invalidation', () => { + it('registers a copilot parse-version bump in the env fingerprint', () => { + // The pre-bump fingerprint is what every cache written before the + // dedup-key change holds. It must NOT match the current one, or the + // durable union-merge would keep the old unkeyed-digest keys and append + // the same records under the new keyed shape on the next re-parse. + expect(computeEnvFingerprint('copilot')).not.toBe(preBumpFingerprint()) + }) + + it('control: a cache at the CURRENT fingerprint is honored (old-shape keys stay)', async () => { + const dbPath = await createJetBrainsDb() + await seedCache(dbPath, computeEnvFingerprint('copilot')) + + const calls = await parsedCopilotCalls() + + // The seeded cache is structurally valid and trusted: the unchanged .db is + // not re-parsed, so the old-shape key survives verbatim. This proves the + // seed is real (not silently ignored) — and that WITHOUT a fingerprint + // bump, the pre-fix keys would be served forever. + expect(calls).toHaveLength(1) + const { envFingerprint, keys } = await cachedCopilotKeys() + expect(envFingerprint).toBe(computeEnvFingerprint('copilot')) + expect(keys).toEqual([oldShapeKey()]) + }) + + it('regression: a pre-bump fingerprint discards the section instead of merging', async () => { + const dbPath = await createJetBrainsDb() + await seedCache(dbPath, preBumpFingerprint()) + + const calls = await parsedCopilotCalls() + + // The pre-bump fingerprint no longer matches, so the section is REBUILT: + // the old-shape key is dropped and the .db re-parses under the new keyed + // shape. Exactly one copy of the record remains — had the section been + // merged instead of discarded, the durable union-merge would have kept the + // old key AND appended the new one (the double-append this bump prevents). + expect(calls).toHaveLength(1) + expect(calls[0]!.deduplicationKey).toBe(newShapeKey()) + const { envFingerprint, keys } = await cachedCopilotKeys() + expect(envFingerprint).toBe(computeEnvFingerprint('copilot')) + expect(keys).toEqual([newShapeKey()]) + expect(keys).not.toContain(oldShapeKey()) + }) +}) diff --git a/packages/cli/tests/providers/copilot-bridge.test.ts b/packages/cli/tests/providers/copilot-bridge.test.ts index e304b0cf..590da5c2 100644 --- a/packages/cli/tests/providers/copilot-bridge.test.ts +++ b/packages/cli/tests/providers/copilot-bridge.test.ts @@ -724,7 +724,7 @@ const G5_GOLDEN: ParsedProviderCall[] = [ "bashCommands": [], "timestamp": "2026-07-03T12:00:00.000Z", "speed": "standard", - "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:29c75429dae1:1", + "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:d81513544dac:1", "userMessage": "Conversation B" }, { @@ -745,7 +745,7 @@ const G5_GOLDEN: ParsedProviderCall[] = [ "bashCommands": [], "timestamp": "2026-07-03T12:00:00.000Z", "speed": "standard", - "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:55e5aea23f97:1", + "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:77ecc7a691b9:1", "userMessage": "Conversation B" }, { @@ -766,7 +766,7 @@ const G5_GOLDEN: ParsedProviderCall[] = [ "bashCommands": [], "timestamp": "2026-07-03T12:00:00.000Z", "speed": "standard", - "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:e3b0c44298fc:1", + "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:b613679a0814:1", "userMessage": "Conversation B" }, { @@ -787,16 +787,16 @@ const G5_GOLDEN: ParsedProviderCall[] = [ "bashCommands": [], "timestamp": "2026-07-03T12:00:00.000Z", "speed": "standard", - "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:eff208336025:1", + "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:12086ad693b4:1", "userMessage": "Conversation B" } ] const G5_KEYS = [ - "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:29c75429dae1:1", - "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:55e5aea23f97:1", - "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:e3b0c44298fc:1", - "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:eff208336025:1" + "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:12086ad693b4:1", + "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:77ecc7a691b9:1", + "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:b613679a0814:1", + "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:d81513544dac:1" ] const G6_GOLDEN: ParsedProviderCall[] = [ @@ -818,13 +818,13 @@ const G6_GOLDEN: ParsedProviderCall[] = [ "bashCommands": [], "timestamp": "2026-07-03T12:00:00.000Z", "speed": "standard", - "deduplicationKey": "copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:a4d4d9a6916b:1", + "deduplicationKey": "copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:1950183ecfb1:1", "userMessage": "Understanding HBase Architecture" } ] const G6_KEYS = [ - "copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:a4d4d9a6916b:1" + "copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:1950183ecfb1:1" ] const G7_GOLDEN: ParsedProviderCall[] = [ @@ -1167,7 +1167,7 @@ const G13_GOLDEN: ParsedProviderCall[] = [ "bashCommands": [], "timestamp": "2026-07-03T12:00:00.000Z", "speed": "standard", - "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:eff208336025:1", + "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:12086ad693b4:1", "userMessage": "Conversation X7" }, { @@ -1188,14 +1188,14 @@ const G13_GOLDEN: ParsedProviderCall[] = [ "bashCommands": [], "timestamp": "2026-07-03T12:00:00.000Z", "speed": "standard", - "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:e0110dcd5a4e:1", + "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:5b8342db2b62:1", "userMessage": "Conversation X7" } ] const G13_KEYS = [ - "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:e0110dcd5a4e:1", - "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:eff208336025:1" + "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:12086ad693b4:1", + "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:5b8342db2b62:1" ] const G14_GOLDEN: ParsedProviderCall[] = [ @@ -1217,13 +1217,13 @@ const G14_GOLDEN: ParsedProviderCall[] = [ "bashCommands": [], "timestamp": "2026-07-03T12:00:00.000Z", "speed": "standard", - "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:03e1bde7d7c0:1", + "deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:f4ee234a9585:1", "userMessage": "Conversation X8" } ] const G14_KEYS = [ - "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:03e1bde7d7c0:1" + "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:f4ee234a9585:1" ] const G15_GOLDEN: ParsedProviderCall[] = [ @@ -1245,7 +1245,7 @@ const G15_GOLDEN: ParsedProviderCall[] = [ "bashCommands": [], "timestamp": "2026-07-03T12:00:00.000Z", "speed": "standard", - "deduplicationKey": "copilot:jb:x17-store:9829e901954d:1", + "deduplicationKey": "copilot:jb:x17-store:84b5586a1750:1", "userMessage": "" }, { @@ -1266,14 +1266,14 @@ const G15_GOLDEN: ParsedProviderCall[] = [ "bashCommands": [], "timestamp": "2026-07-03T12:00:00.000Z", "speed": "standard", - "deduplicationKey": "copilot:jb:x17-store:95b22f4dffb0:1", + "deduplicationKey": "copilot:jb:x17-store:6e05b7ba9bf8:1", "userMessage": "" } ] const G15_KEYS = [ - "copilot:jb:x17-store:95b22f4dffb0:1", - "copilot:jb:x17-store:9829e901954d:1" + "copilot:jb:x17-store:6e05b7ba9bf8:1", + "copilot:jb:x17-store:84b5586a1750:1" ] const G16_GOLDEN: ParsedProviderCall[] = [ diff --git a/packages/core/src/diagnostics.ts b/packages/core/src/diagnostics.ts index 0034a3a5..f3addc75 100644 --- a/packages/core/src/diagnostics.ts +++ b/packages/core/src/diagnostics.ts @@ -1,24 +1,26 @@ +import { createHmac } from 'node:crypto' + import { z } from 'zod' import type { SessionObservation } from './observations.js' -/** Maximum length of a diagnostic detail message. */ -export const DIAGNOSTIC_DETAIL_MAX = 200 - /** - * A bounded, sanitized diagnostic message. + * A bounded, content-free diagnostic detail: the first 16 hex chars of an + * HMAC-SHA-256 of the offending input, keyed by the host's privacy key (D1). + * A keyed fingerprint is REQUIRED — a keyless path must omit the field + * entirely (see {@link keyedDetail}), never degrade to an unkeyed digest. * - * The rule is deliberately crude but *structural*: reject any string containing - * a path separator ('/' or '\\'), and cap the length at 200 chars. A decoder - * cannot smuggle an absolute path (or most of a command line) through a - * diagnostic detail, because a path without separators is not a path. + * The contract for a diagnostic is that it carries a record index, a + * controlled error code, and a keyed fingerprint — NEVER content. + * A detail that echoed the error would leak a path, a command fragment, a + * prompt line, or an API key; the old rule ("no path separators, max 200 + * chars") let all of those through as long as they were slash-free. A digest + * cannot: identical failures dedupe to the same fingerprint, distinct + * failures differ, and no substring of the input survives in the output. */ export const DiagnosticDetail = z .string() - .max(DIAGNOSTIC_DETAIL_MAX) - .refine((s) => !s.includes('/') && !s.includes('\\'), { - message: 'diagnostic detail must not contain path separators ("/" or "\\\\")', - }) + .regex(/^[0-9a-f]{16}$/, 'diagnostic detail must be a 16-hex fingerprint, never content') /** Classification of why a record could not be turned into an observation. */ export const DiagnosticCode = z.enum([ @@ -52,30 +54,57 @@ export interface DecodeResult { } /** - * Coerce an arbitrary caught value into a detail string that satisfies - * {@link DiagnosticDetail}: strip path separators and cap the length. Used so a - * thrown error whose message embeds a path cannot leak that path verbatim. + * Coerce an arbitrary caught value into a content-free detail: the 16-hex + * HMAC-SHA-256 fingerprint of the error message, keyed by `key`. The key is + * REQUIRED (D1): with no key this throws rather than degrade to an unkeyed + * digest that a host could dictionary-attack. Callers with no key available + * must omit the detail field entirely — {@link keyedDetail} is the helper + * for that path. The raw message — and therefore any path, command fragment, + * prompt line, or API key inside it — never survives in the output. */ -export function sanitizeDetail(value: unknown): string { +export function sanitizeDetail(value: unknown, key: string): string { + if (!key) throw new Error('privacyKey is required') const raw = value instanceof Error ? value.message : String(value) - return raw.replace(/[\\/]+/g, ' ').slice(0, DIAGNOSTIC_DETAIL_MAX) + return createHmac('sha256', key).update(raw).digest('hex').slice(0, 16) } -/** The per-record outcome a caller's `decodeOne` may return. */ +/** + * The detail for a diagnostic when a privacy key is available, or `undefined` + * when it is not. A keyless diagnostic carries NO detail — the pre-fingerprint + * shape `{ index, code }` — because D1 forbids an unkeyed digest: a host with + * no key must not emit a fingerprint that could be dictionary-attacked. + */ +export function keyedDetail(value: unknown, key: string | undefined): string | undefined { + return key ? sanitizeDetail(value, key) : undefined +} + +/** + * The per-record outcome a caller's `decodeOne` may return. Callers report + * `{ index, code }` diagnostics only: `isolateRecords` is the sole place a + * `detail` fingerprint is derived (from a thrown error, keyed with + * `privacyKey`), so an unkeyed or caller-invented digest can never cross this + * boundary. The type enforces it for typed callers and the runtime strips it + * for untyped ones (see {@link isolateRecords}). + */ export interface RecordOutcome { observations?: SessionObservation[] - diagnostics?: RecordDiagnostic[] + diagnostics?: Array> } /** * Generic poison-isolation loop. Runs `decodeOne` against each record; a record - * that throws becomes an 'other' diagnostic (with a sanitized message) and the - * loop continues, so one bad record never drops its siblings. This is the - * pattern every concrete decoder is expected to use. + * that throws becomes an 'other' diagnostic and the loop continues, so one bad + * record never drops its siblings. When `privacyKey` is supplied the diagnostic + * carries the keyed fingerprint of the error (never its content); without one + * it carries no detail at all — D1 forbids an unkeyed digest. Diagnostics a + * caller RETURNS are trusted for `index`/`code` only: any `detail` they carry + * is stripped, because the only legitimate fingerprint is the one derived here + * from a thrown error under the key this function owns. */ export function isolateRecords( records: readonly unknown[], decodeOne: (record: unknown, index: number) => RecordOutcome, + privacyKey?: string, ): { observations: SessionObservation[]; diagnostics: RecordDiagnostic[] } { const observations: SessionObservation[] = [] const diagnostics: RecordDiagnostic[] = [] @@ -84,9 +113,16 @@ export function isolateRecords( try { const outcome = decodeOne(record, index) if (outcome.observations) observations.push(...outcome.observations) - if (outcome.diagnostics) diagnostics.push(...outcome.diagnostics) + if (outcome.diagnostics) { + for (const d of outcome.diagnostics) { + // Strip any detail a caller smuggled in (e.g. via a loose cast): it + // could be an unkeyed digest. Fingerprints are derived here only. + diagnostics.push({ index: d.index, code: d.code }) + } + } } catch (err) { - diagnostics.push({ index, code: 'other', detail: sanitizeDetail(err) }) + const detail = keyedDetail(err, privacyKey) + diagnostics.push(detail ? { index, code: 'other', detail } : { index, code: 'other' }) } }) diff --git a/packages/core/src/providers/copilot/decode.ts b/packages/core/src/providers/copilot/decode.ts index be889de0..4cf9219b 100644 --- a/packages/core/src/providers/copilot/decode.ts +++ b/packages/core/src/providers/copilot/decode.ts @@ -1,4 +1,4 @@ -import { createHash } from 'crypto' +import { createHmac } from 'crypto' import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { @@ -1081,7 +1081,7 @@ function decodeChatSession(envelope: Extract, seen: Set, calls: CopilotDecodedCall[]): void { +function decodeJetBrains(envelope: Extract, seen: Set, calls: CopilotDecodedCall[], privacyKey: string): void { const raw = envelope.raw const sessionId = envelope.sessionId const mtime = envelope.mtime @@ -1103,7 +1103,21 @@ function decodeJetBrains(envelope: Extract, providerName: string, seenKeys: Set, + privacyKey: string, ): OpenCodeSessionDecodeResult { const calls: OpenCodeSessionDecodedCall[] = [] const diagnostics: RecordDiagnostic[] = [] @@ -179,8 +180,12 @@ function decodeSqlite( let data: MessageData try { data = JSON.parse(msg.data) as MessageData - } catch { - diagnostics.push({ index: 0, code: 'malformed-json' }) + } catch (err) { + // Keyed fingerprint of the error, never its message: a hostile msg.data + // could otherwise smuggle content through the parse failure. Without a + // privacy key the detail is omitted entirely (D1: no unkeyed digest). + const detail = keyedDetail(err, privacyKey) + diagnostics.push(detail ? { index: 0, code: 'malformed-json', detail } : { index: 0, code: 'malformed-json' }) continue } @@ -332,7 +337,7 @@ export function decodeOpenCodeSession(input: OpenCodeSessionDecodeInput): OpenCo switch (envelope.kind) { case 'sqlite': - return decodeSqlite(envelope, providerName, seenKeys) + return decodeSqlite(envelope, providerName, seenKeys, input.context.privacyKey) case 'file': return decodeFile(envelope, providerName, seenKeys) default: diff --git a/packages/core/src/providers/vscode-cline/decode.ts b/packages/core/src/providers/vscode-cline/decode.ts index adacbfd1..c9fe2e45 100644 --- a/packages/core/src/providers/vscode-cline/decode.ts +++ b/packages/core/src/providers/vscode-cline/decode.ts @@ -5,7 +5,7 @@ import { basename } from 'node:path' import type { DecodeContext } from '../../contracts.js' -import type { RecordDiagnostic } from '../../diagnostics.js' +import { keyedDetail, type RecordDiagnostic } from '../../diagnostics.js' import type { ClineHistoryMessage, ClineRecordEnvelope, ClineUiMessage, VscodeClineDecodedCall } from './types.js' const MODEL_TAG_RE = /([^<]+)<\/model>/ @@ -82,8 +82,12 @@ export function decodeVscodeCline(input: VscodeClineDecodeInput): VscodeClineDec let uiMessages: ClineUiMessage[] try { uiMessages = JSON.parse(envelope.uiRaw) - } catch { - diagnostics.push({ index, code: 'malformed-json' }) + } catch (err) { + // Keyed fingerprint of the error, never its message: a hostile uiRaw + // could otherwise smuggle content through the parse failure. Without a + // privacy key the detail is omitted entirely (D1: no unkeyed digest). + const detail = keyedDetail(err, input.context.privacyKey) + diagnostics.push(detail ? { index, code: 'malformed-json', detail } : { index, code: 'malformed-json' }) continue } diff --git a/packages/core/src/providers/zed/decode.ts b/packages/core/src/providers/zed/decode.ts index d8355f30..014ed4ba 100644 --- a/packages/core/src/providers/zed/decode.ts +++ b/packages/core/src/providers/zed/decode.ts @@ -10,7 +10,7 @@ import zlib from 'node:zlib' import type { DecodeContext } from '../../contracts.js' -import type { RecordDiagnostic } from '../../diagnostics.js' +import { keyedDetail, type RecordDiagnostic } from '../../diagnostics.js' import type { ZedDecodedCall, ZedThreadJson, ZedThreadRow, ZedTokenUsage } from './types.js' const zstdDecompressSync = (zlib as { zstdDecompressSync?: (buf: Buffer) => Buffer }).zstdDecompressSync @@ -79,7 +79,7 @@ export type ZedDecodeResult = { * cumulative call. Dedup is keyed on `zed::` against the * live `seenKeys` set (host-owned). */ -export function decodeZed({ records, seenKeys: liveSeen }: ZedDecodeInput): ZedDecodeResult { +export function decodeZed({ records, seenKeys: liveSeen, context }: ZedDecodeInput): ZedDecodeResult { const seen = liveSeen ?? new Set() const calls: ZedDecodedCall[] = [] const diagnostics: RecordDiagnostic[] = [] @@ -134,8 +134,15 @@ export function decodeZed({ records, seenKeys: liveSeen }: ZedDecodeInput): ZedD seen.add(call.deduplicationKey) calls.push(call) } - } catch { - diagnostics.push({ index, code: 'malformed-json' }) + } catch (err) { + // Keyed fingerprint of the error, never its message: a hostile blob could + // otherwise smuggle content through a JSON.parse failure. Without a + // privacy key the detail is omitted entirely (D1: no unkeyed digest). + // `context?` is defensive: this error path is the only place the decoder + // touches context, so an untyped caller that supplies records only must + // get a diagnostic here, not a TypeError. + const detail = keyedDetail(err, context?.privacyKey) + diagnostics.push(detail ? { index, code: 'malformed-json', detail } : { index, code: 'malformed-json' }) } }) diff --git a/packages/core/tests/architecture-gate.test.ts b/packages/core/tests/architecture-gate.test.ts index b4016eb3..7482a29a 100644 --- a/packages/core/tests/architecture-gate.test.ts +++ b/packages/core/tests/architecture-gate.test.ts @@ -244,6 +244,16 @@ const SCHEMA_FILES = ['observation-0.1.0', 'observation-0.2.0', 'finding-0.1.0'] type StringField = { path: string; kind: string } +/** + * A `type` may be a single string or a union array (e.g. `["string","null"]`). + * Either form that includes "string" is a string field; the union form must not + * escape the enumeration just because `type !== 'string'`. + */ +function isStringType(node: Record): boolean { + if (node.type === 'string') return true + return Array.isArray(node.type) && node.type.includes('string') +} + /** Classify a `{type:'string'}` subschema by the constraint that bounds it. */ function classifyStringNode(node: Record): string { if ('const' in node) return `const:${JSON.stringify(node.const)}` @@ -255,10 +265,19 @@ function classifyStringNode(node: Record): string { return 'UNCONSTRAINED' } +/** + * Descend every place a string subschema can hide. Beyond `properties` / + * `items` / `definitions`, JSON Schema puts alternatives under the combinator + * keys (`anyOf` / `oneOf` / `allOf` arrays), regex-keyed subschemas under + * `patternProperties`, and catch-all subschemas under `additionalProperties` + * (the boolean form `true`/`false` is not a subschema and is skipped). A + * `type: ["string","null"]` union is a string field and must be enumerated + * with the same bound checks as a bare `type: "string"`. + */ function walkStringFields(node: unknown, path: string, out: StringField[]): void { if (!node || typeof node !== 'object') return const n = node as Record - if (n.type === 'string') { + if (isStringType(n)) { out.push({ path, kind: classifyStringNode(n) }) return } @@ -273,6 +292,21 @@ function walkStringFields(node: unknown, path: string, out: StringField[]): void walkStringFields(v, `${path}#${k}`, out) } } + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + if (Array.isArray(n[key])) { + for (const [i, sub] of (n[key] as unknown[]).entries()) { + walkStringFields(sub, `${path}/${key}[${i}]`, out) + } + } + } + if (n.patternProperties && typeof n.patternProperties === 'object') { + for (const [k, v] of Object.entries(n.patternProperties as Record)) { + walkStringFields(v, `${path}/${k}`, out) + } + } + if (n.additionalProperties && typeof n.additionalProperties === 'object') { + walkStringFields(n.additionalProperties, `${path}/additionalProperties`, out) + } } function enumerateSchema(name: string): StringField[] { @@ -284,17 +318,46 @@ function enumerateSchema(name: string): StringField[] { const allStringFields = SCHEMA_FILES.flatMap(enumerateSchema) +/** + * A maxLength only counts as "bounded" when the cap is tight enough to rule + * out free text — an anti-free-text measure, not an anti-credential one. A + * GitHub PAT is ~93 chars and an sk- key ~51, both well under 256, so a + * 256-capped field could still carry a short token if a decoder were ever + * wired to put one there. What the cap does rule out is bulk free text: + * prompt lines, shell command fragments, and file dumps run to hundreds or + * thousands of characters, so they cannot hide in an identifier-shaped + * field. The guarantee that no credential reaches the envelope is not this + * cap — it is the content-smuggling guardrails (no user text is captured + * into these fields at all) plus the MACHINE_ID_ALLOWLIST entries below, + * which are host-emitted controlled vocabularies (model slugs, provider ids, + * generator version, hash-derived dedup keys). A cap of 100000, which merely + * exists, is not bounded at all. + */ +const MAX_BOUNDED_STRING_LENGTH = 256 + +/** + * A format only counts as "bounded" when the runtime actually enforces it. + * draft-07 treats `format` as an annotation unless the validator opts in, and + * the zod runtime (src/schema.ts IsoTimestamp) validates exactly one format: + * `date-time`. Any other format string is decorative and does not bound the + * field, so the gate refuses to trust it. + */ +const ENFORCED_FORMATS = new Set(['date-time']) + // A string field is "bounded" (cannot hold arbitrary free text) when it is a // fixed literal, a closed enum, pattern-constrained (16-hex fingerprint, -// canonical tool-name charset, semver), a date-time, or length-capped. +// canonical tool-name charset, semver), an actually-enforced date-time, or +// length-capped within MAX_BOUNDED_STRING_LENGTH. function isBoundedKind(kind: string): boolean { - return ( - kind.startsWith('const:') || - kind.startsWith('enum[') || - kind.startsWith('pattern:') || - kind.startsWith('format:') || - kind.startsWith('maxLength:') - ) + if (kind.startsWith('const:') || kind.startsWith('enum[') || kind.startsWith('pattern:')) return true + if (kind.startsWith('format:')) { + return ENFORCED_FORMATS.has(kind.slice('format:'.length)) + } + if (kind.startsWith('maxLength:')) { + const n = Number(kind.slice('maxLength:'.length)) + return Number.isFinite(n) && n <= MAX_BOUNDED_STRING_LENGTH + } + return false } // The only string fields NOT length/charset-capped: machine-generated @@ -387,6 +450,47 @@ describe('architecture gate: envelope schemas carry no free-text-capable field', expect(bare, `bare z.string() field(s) found:\n${bare.join('\n')}`).toEqual([]) }) + it('the walker descends into every combinator form and union type (self-test)', () => { + // A synthetic schema proving the walker itself cannot be gamed by moving a + // string field under anyOf/oneOf/allOf/patternProperties/additionalProperties + // or into a ["string","null"] union. If a future schema version uses one of + // these forms, the field is enumerated and bound-checked like any other. + const synthetic = { + type: 'object', + properties: { + a: { anyOf: [{ type: 'string' }, { type: 'integer' }] }, + b: { oneOf: [{ type: 'string', enum: ['x', 'y'] }, { type: 'null' }] }, + c: { allOf: [{ type: 'string', maxLength: 300 }, { type: 'string', pattern: '^x' }] }, + d: { type: ['string', 'null'] }, + }, + patternProperties: { '^tag_': { type: 'string', maxLength: 10 } }, + additionalProperties: { type: 'string' }, + } + const out: StringField[] = [] + walkStringFields(synthetic, 'synthetic', out) + const byPath = new Map(out.map(f => [f.path, f.kind])) + expect(byPath.get('synthetic/a/anyOf[0]')).toBe('UNCONSTRAINED') + expect(byPath.get('synthetic/b/oneOf[0]')).toBe('enum[2]') + expect(byPath.get('synthetic/c/allOf[0]')).toBe('maxLength:300') + expect(byPath.get('synthetic/c/allOf[1]')).toBe('pattern:^x') + expect(byPath.get('synthetic/d')).toBe('UNCONSTRAINED') + expect(byPath.get('synthetic/^tag_')).toBe('maxLength:10') + expect(byPath.get('synthetic/additionalProperties')).toBe('UNCONSTRAINED') + // The union type node is a string field even though type !== 'string'. + expect(out.some(f => f.path === 'synthetic/d')).toBe(true) + }) + + it('"bounded" rejects a merely-present cap and a decorative format (self-test)', () => { + // A 100000-char cap is free text wearing a cap; a maxLength of 256 (the + // identifier ceiling) is bounded. `format: date-time` is enforced by the + // runtime; any other format string is an unenforced annotation. + expect(isBoundedKind('maxLength:100000')).toBe(false) + expect(isBoundedKind('maxLength:256')).toBe(true) + expect(isBoundedKind('maxLength:64')).toBe(true) + expect(isBoundedKind('format:uuid')).toBe(false) + expect(isBoundedKind('format:date-time')).toBe(true) + }) + it('the string-field surface matches the frozen enumeration', () => { const key = (f: StringField) => `${f.path} => ${f.kind}` expect(allStringFields.map(key).sort()).toEqual(EXPECTED_STRING_FIELDS.map(key).sort()) diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index ebfd145f..a28ef96b 100644 --- a/packages/core/tests/content-smuggling.test.ts +++ b/packages/core/tests/content-smuggling.test.ts @@ -5,7 +5,7 @@ import zlib from 'node:zlib' import { describe, expect, it } from 'vitest' -import { DiagnosticDetail } from '../src/diagnostics.js' +import { DiagnosticDetail, sanitizeDetail } from '../src/diagnostics.js' import { ObservationEnvelope } from '../src/observations.js' import { OBSERVATION_SCHEMA_VERSION } from '../src/schema.js' import { @@ -54,6 +54,14 @@ import { toObservations as toKiroObservations, } from '../src/providers/kiro/index.js' import { decodeVercelGateway, toObservations as toVercelGatewayObservations } from '../src/providers/vercel-gateway/index.js' +import { decodeZerostack, toObservations as toZerostackObservations } from '../src/providers/zerostack/index.js' +import { decodeDroid, toObservations as toDroidObservations } from '../src/providers/droid/index.js' +import { decodeMux, toObservations as toMuxObservations } from '../src/providers/mux/index.js' +import { decodeOpenDesign, toObservations as toOpenDesignObservations } from '../src/providers/open-design/index.js' +import { decodeLingTaiTui, toObservations as toLingTaiTuiObservations } from '../src/providers/lingtai-tui/index.js' +import { decodeGemini, toObservations as toGeminiObservations } from '../src/providers/gemini/index.js' +import { decodeKimicode, toObservations as toKimicodeObservations } from '../src/providers/kimicode/index.js' +import { decodePi, toObservations as toPiObservations } from '../src/providers/pi/index.js' import type { DecodeContext } from '../src/contracts.js' import type { ZedThreadRow } from '../src/providers/zed/index.js' import type { @@ -1052,7 +1060,7 @@ describe('content-smuggling guardrail: real copilot decode -> toObservations is }) }) -describe('content-smuggling guardrail: diagnostic detail rejects paths', () => { +describe('content-smuggling guardrail: diagnostic detail is a fingerprint, never content', () => { it('rejects an absolute path', () => { expect(DiagnosticDetail.safeParse(SECRETS.absPath).success).toBe(false) }) @@ -1060,6 +1068,22 @@ describe('content-smuggling guardrail: diagnostic detail rejects paths', () => { it('rejects a command line (contains a slash)', () => { expect(DiagnosticDetail.safeParse(SECRETS.commandLine).success).toBe(false) }) + + it('rejects slash-free content: a prompt line', () => { + expect(DiagnosticDetail.safeParse(SECRETS.prompt).success).toBe(false) + }) + + it('rejects slash-free content: an API key', () => { + expect(DiagnosticDetail.safeParse(SECRETS.apiKey).success).toBe(false) + }) + + it('sanitizeDetail turns every secret into a content-free fingerprint', () => { + for (const secret of ALL_SECRETS) { + const out = sanitizeDetail(secret, 'test-privacy-key') + expect(DiagnosticDetail.safeParse(out).success).toBe(true) + expect(out).not.toContain(secret) + } + }) }) describe('content-smuggling guardrail: real hermes decode -> toObservations is secret-free', () => { @@ -2150,3 +2174,579 @@ describe('content-smuggling guardrail: real vercel-gateway decode -> toObservati expect(parsed.success).toBe(false) }) }) + +// ── allowlisted providers with no hostile-decode coverage until now ───────── +// The architecture gate confines `userMessage` to these files; these tests are +// the behavioral half of that gate: a hostile record whose user-content fields +// carry a planted secret must never surface that secret in the emitted +// observations or diagnostics. Every fixture plants secrets in fields the +// decoder genuinely reads, and each proves the decode actually consumed the +// record — either the rich decode received the secret verbatim (free-text +// fields that are later minimized away) or, for decoders with no free-text +// capture, a hostile value planted in a consumed field was coerced/dropped by +// the read itself (see the open-design and lingtai-tui sections). A fixture +// that silently stops decoding can therefore never make the no-secret +// assertions vacuous. + +describe('content-smuggling guardrail: real zerostack decode -> toObservations is secret-free', () => { + // A hostile Zerostack session planting every secret in the free-text fields + // the decode captures: the first user message and the session working_dir. + const zerostackContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'zerostack', sourceRef: 'ref' } + + function decodeAndMinimize() { + const records = [{ + id: 'sess-hostile', + model: 'claude-opus-4-8', + total_input_tokens: 500, + total_output_tokens: 200, + updated_at: '2026-07-17T10:00:00.000Z', + working_dir: SECRETS.absPath, + messages: [ + { role: 'user', content: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}` }, + { role: 'assistant', content: [{ text: 'ok' }] }, + ], + }] + const { calls, diagnostics } = decodeZerostack({ records, context: zerostackContext }) + const { sessions } = toZerostackObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'zerostack' }, + ) + return { + envelope: { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + }, + calls, + diagnostics, + } + } + + it('produces a schema-valid envelope from the hostile session (non-vacuous)', () => { + const { envelope, calls } = decodeAndMinimize() + expect(calls).toHaveLength(1) + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + }) + + it('the planted secrets really entered the rich decode', () => { + const rich = allStrings(decodeAndMinimize().calls).join('\n') + expect(rich).toContain(SECRETS.prompt) + expect(rich).toContain(SECRETS.absPath) // working_dir -> projectPath + }) + + it('neither the observations nor the diagnostics contain any planted secret', () => { + const { envelope, diagnostics } = decodeAndMinimize() + const haystack = JSON.stringify(envelope) + '\n' + JSON.stringify(diagnostics) + for (const secret of ALL_SECRETS) { + expect(haystack).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real droid decode -> toObservations is secret-free', () => { + // A hostile Droid JSONL planting every secret in the free-text fields the + // decode captures: the user message, an Execute command, and a tool NAME + // carrying a command line (must fail the canonical-name filter). + const droidContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'droid', sourceRef: 'ref' } + + function decodeAndMinimize() { + const settings = { + model: 'custom:GLM-5.1-[Proxy]-0', + tokenUsage: { inputTokens: 100, outputTokens: 50, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 }, + } + const records = [ + JSON.stringify({ type: 'session_start', id: 'sess-hostile' }), + JSON.stringify({ + type: 'message', id: 'u1', timestamp: '2026-07-17T10:00:00.000Z', + message: { role: 'user', content: [{ type: 'text', text: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}` }] }, + }), + JSON.stringify({ + type: 'message', id: 'a1', timestamp: '2026-07-17T10:00:05.000Z', + message: { + role: 'assistant', + content: [ + // A hostile tool NAME carrying a command line: unmapped, so it + // reaches `tools` and must be dropped by the canonical filter. + { type: 'tool_use', id: 't1', name: SECRETS.commandLine, input: {} }, + { type: 'tool_use', id: 't2', name: 'Execute', input: { command: SECRETS.commandLine } }, + ], + }, + }), + ] + const { calls, diagnostics } = decodeDroid({ records, settings, context: droidContext }) + const { sessions } = toDroidObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'droid' }, + ) + return { + envelope: { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + }, + calls, + diagnostics, + } + } + + it('produces a schema-valid envelope from the hostile JSONL (non-vacuous)', () => { + const { envelope, calls } = decodeAndMinimize() + expect(calls).toHaveLength(1) + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + }) + + it('the planted secrets really entered the rich decode', () => { + const rich = allStrings(decodeAndMinimize().calls).join('\n') + expect(rich).toContain(SECRETS.prompt) + expect(rich).toContain(SECRETS.commandLine) // rawBashCommands + hostile tool name + }) + + it('keeps canonical tool names (Bash) and drops the argument-carrying name', () => { + const { envelope } = decodeAndMinimize() + const allToolNames = envelope.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).toContain('Bash') + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) + + it('neither the observations nor the diagnostics contain any planted secret', () => { + const { envelope, diagnostics } = decodeAndMinimize() + const haystack = JSON.stringify(envelope) + '\n' + JSON.stringify(diagnostics) + for (const secret of ALL_SECRETS) { + expect(haystack).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real mux decode -> toObservations is secret-free', () => { + // A hostile Mux JSONL planting every secret in the free-text fields the + // decode captures: the user message, a bash script, and a tool NAME carrying + // a command line (must fail the canonical-name filter). + const muxContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'mux', sourceRef: 'ref' } + + function decodeAndMinimize() { + const records = [ + { + role: 'user', id: 'u1', createdAt: '2026-07-17T10:00:00.000Z', + parts: [{ type: 'text', text: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}` }], + }, + { + role: 'assistant', id: 'a1', createdAt: '2026-07-17T10:00:05.000Z', + metadata: { + model: 'anthropic:claude-opus-4-6', + timestamp: 1784354405000, + usage: { inputTokens: 100, outputTokens: 50, cachedInputTokens: 10, reasoningTokens: 5 }, + }, + parts: [ + // A hostile tool NAME carrying a command line: unmapped, so it + // reaches `tools` and must be dropped by the canonical filter. + { type: 'dynamic-tool', toolName: SECRETS.commandLine, input: {} }, + { type: 'dynamic-tool', toolName: 'bash', input: { script: SECRETS.commandLine } }, + ], + }, + ] + const { calls, diagnostics } = decodeMux({ records, workspaceId: 'sess-hostile', context: muxContext }) + const { sessions } = toMuxObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'mux' }, + ) + return { + envelope: { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + }, + calls, + diagnostics, + } + } + + it('produces a schema-valid envelope from the hostile JSONL (non-vacuous)', () => { + const { envelope, calls } = decodeAndMinimize() + expect(calls).toHaveLength(1) + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + }) + + it('the planted secrets really entered the rich decode', () => { + const rich = allStrings(decodeAndMinimize().calls).join('\n') + expect(rich).toContain(SECRETS.prompt) + expect(rich).toContain(SECRETS.commandLine) // rawBashCommands + hostile tool name + }) + + it('keeps canonical tool names (Bash) and drops the argument-carrying name', () => { + const { envelope } = decodeAndMinimize() + const allToolNames = envelope.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).toContain('Bash') + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) + + it('neither the observations nor the diagnostics contain any planted secret', () => { + const { envelope, diagnostics } = decodeAndMinimize() + const haystack = JSON.stringify(envelope) + '\n' + JSON.stringify(diagnostics) + for (const secret of ALL_SECRETS) { + expect(haystack).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real open-design decode -> toObservations is secret-free', () => { + // A hostile Open Design event log. The decode captures NO free-text user + // content by design (userMessage is always ''); the only user-controlled + // values it consumes are machine identifiers (event / id / timestamp / + // model) and the four numeric usage-token fields. The vector here is + // hostile free text planted IN those token fields — fields the decoder + // genuinely reads via tokenValue(), which must coerce the non-numeric + // payload away rather than let it ride the call into the envelope. + const openDesignContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'open-design', sourceRef: 'ref' } + + function decodeAndMinimize() { + const records = [ + JSON.stringify({ event: 'start', id: 'e0', timestamp: '2026-07-17T10:00:00.000Z', data: { model: 'gpt-5.3' } }), + JSON.stringify({ + event: 'agent', id: 'e1', timestamp: '2026-07-17T10:00:01.000Z', + data: { + type: 'usage', + usage: { + input_tokens: 100, + // Hostile free text in the token fields the decoder reads. + output_tokens: `${SECRETS.prompt}`, + cached_read_tokens: `${SECRETS.apiKey}`, + thought_tokens: `${SECRETS.commandLine}`, + }, + }, + }), + ] + const { calls, diagnostics } = decodeOpenDesign({ records, sessionId: 'sess-hostile', project: 'hostile-project', context: openDesignContext }) + const { sessions } = toOpenDesignObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'open-design' }, + ) + return { + envelope: { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + }, + calls, + diagnostics, + } + } + + it('produces a schema-valid envelope from the hostile event log (non-vacuous)', () => { + const { envelope, calls } = decodeAndMinimize() + // One usage event -> one call; a dropped record would make the no-secret + // assertions below vacuous. + expect(calls).toHaveLength(1) + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + }) + + it('the token fields were genuinely read and the hostile payloads coerced, not echoed', () => { + const { calls } = decodeAndMinimize() + // The numeric field survived the read, proving the decode consumed this + // usage event... + expect(calls[0]!.inputTokens).toBe(100) + // ...while the free text planted in the sibling token fields it also + // reads was coerced to zero — containment inside the read, not a + // skipped record. + expect(calls[0]!.outputTokens).toBe(0) + expect(calls[0]!.cacheReadInputTokens).toBe(0) + expect(calls[0]!.reasoningTokens).toBe(0) + }) + + it('neither the observations nor the diagnostics contain any planted secret', () => { + const { envelope, diagnostics } = decodeAndMinimize() + const haystack = JSON.stringify(envelope) + '\n' + JSON.stringify(diagnostics) + for (const secret of ALL_SECRETS) { + expect(haystack).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real lingtai-tui decode -> toObservations is secret-free', () => { + // A hostile LingTai ledger. The decode reads NO free-text user content: the + // userMessage is synthesized from the machine-generated activity `source` + // label. The only user-controlled values it consumes are the ledger's own + // machine identifiers (source / em_id / run_id / ts / model / endpoint, + // which flow into the dedup key by design, exactly like every provider's + // model/dedupKey — they are not planted here) and the four numeric token + // fields. The vector here is hostile free text planted IN those token + // fields — fields the decoder genuinely reads via numericField(), which + // must coerce the non-numeric payload away rather than let it ride the + // call into the envelope. + const lingtaiContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'lingtai-tui', sourceRef: 'ref' } + + function decodeAndMinimize() { + const records = [{ + source: 'main', + em_id: 'em-1', + run_id: 'run-1', + ts: '2026-07-17T10:00:00.000Z', + // input stays numeric so the entry bills a call; the sibling token + // fields carry hostile free text the decoder genuinely reads. + input: 100, + output: `${SECRETS.prompt}`, + thinking: `${SECRETS.apiKey}`, + cached: `${SECRETS.commandLine}`, + model: 'claude-sonnet-4-6', + endpoint: 'anthropic', + }] + const { calls, diagnostics } = decodeLingTaiTui({ + records, + context: lingtaiContext, + agentId: 'agent-hostile', + fallbackModel: 'fallback-model', + fallbackEndpoint: 'fallback-endpoint', + projectPath: SECRETS.absPath, + project: 'hostile-project', + }) + const { sessions } = toLingTaiTuiObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'lingtai-tui' }, + ) + return { + envelope: { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + }, + calls, + diagnostics, + } + } + + it('produces a schema-valid envelope from the hostile ledger (non-vacuous)', () => { + const { envelope, calls } = decodeAndMinimize() + expect(calls).toHaveLength(1) + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + }) + + it('the token fields were genuinely read and the hostile payloads coerced, not echoed', () => { + const { calls } = decodeAndMinimize() + // The numeric field survived the read, proving the decode consumed this + // ledger entry (totalTokens > 0)... + expect(calls[0]!.inputTokens).toBe(100) + // ...while the free text planted in the sibling token fields it also + // reads was coerced to zero — containment inside the read, not a + // skipped record. + expect(calls[0]!.outputTokens).toBe(0) + expect(calls[0]!.cachedInputTokens).toBe(0) + expect(calls[0]!.reasoningTokens).toBe(0) + }) + + it('the synthesized userMessage never echoes the hostile source label', () => { + const { calls } = decodeAndMinimize() + // A hostile `source` becomes the synthesized label 'LingTai ... conversation' + // in the rich decode, never the raw value. + expect(calls[0]!.userMessage).toBe('LingTai main conversation') + expect(calls[0]!.userMessage).not.toContain(SECRETS.prompt) + }) + + it('neither the observations nor the diagnostics contain any planted secret', () => { + const { envelope, diagnostics } = decodeAndMinimize() + const haystack = JSON.stringify(envelope) + '\n' + JSON.stringify(diagnostics) + for (const secret of ALL_SECRETS) { + expect(haystack).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real gemini decode -> toObservations is secret-free', () => { + // A hostile Gemini session planting every secret in the free-text fields the + // decode captures: the user message, a run_command shell line, and a tool + // NAME carrying a command line (must fail the canonical-name filter). + const geminiContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'gemini', sourceRef: 'ref' } + + function decodeAndMinimize() { + const session = { + sessionId: 'sess-hostile', + startTime: '2026-07-17T10:00:00.000Z', + messages: [ + { id: 'u1', timestamp: '2026-07-17T10:00:00.000Z', type: 'user', content: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}` }, + { + id: 'a1', timestamp: '2026-07-17T10:00:05.000Z', type: 'gemini', model: 'gemini-3-pro', + tokens: { input: 100, output: 50, cached: 10, thoughts: 5 }, + toolCalls: [ + // A hostile tool NAME carrying a command line: unmapped, so it + // reaches `tools` and must be dropped by the canonical filter. + { id: 't1', name: SECRETS.commandLine, args: {}, displayName: SECRETS.commandLine }, + { id: 't2', name: 'run_command', args: { command: SECRETS.commandLine } }, + ], + }, + ], + } + const { calls, diagnostics } = decodeGemini({ records: [JSON.stringify(session)], context: geminiContext }) + const { sessions } = toGeminiObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'gemini' }, + ) + return { + envelope: { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + }, + calls, + diagnostics, + } + } + + it('produces a schema-valid envelope from the hostile session (non-vacuous)', () => { + const { envelope, calls } = decodeAndMinimize() + expect(calls).toHaveLength(1) + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + }) + + it('the planted secrets really entered the rich decode', () => { + const rich = allStrings(decodeAndMinimize().calls).join('\n') + expect(rich).toContain(SECRETS.prompt) + expect(rich).toContain(SECRETS.commandLine) // rawBashCommands + hostile tool name + }) + + it('keeps canonical tool names (Bash) and drops the argument-carrying name', () => { + const { envelope } = decodeAndMinimize() + const allToolNames = envelope.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).toContain('Bash') + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) + + it('neither the observations nor the diagnostics contain any planted secret', () => { + const { envelope, diagnostics } = decodeAndMinimize() + const haystack = JSON.stringify(envelope) + '\n' + JSON.stringify(diagnostics) + for (const secret of ALL_SECRETS) { + expect(haystack).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real kimicode decode -> toObservations is secret-free', () => { + // A hostile Kimicode wire log planting every secret in the free-text fields + // the decode captures: the prompt input, a Bash command, and a tool NAME + // carrying a command line (must fail the canonical-name filter). + const kimicodeContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'kimicode', sourceRef: 'ref' } + + function decodeAndMinimize() { + const records = [ + JSON.stringify({ type: 'turn.prompt', input: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}` }), + JSON.stringify({ type: 'llm.request', model: 'kimi-k2', modelAlias: 'k2', turnStep: '1.0', time: '2026-07-17T10:00:00.000Z' }), + JSON.stringify({ type: 'context.append_loop_event', event: { type: 'tool.call', name: SECRETS.commandLine, args: {} } }), + JSON.stringify({ type: 'context.append_loop_event', event: { type: 'tool.call', name: 'Bash', args: { command: SECRETS.commandLine } } }), + JSON.stringify({ type: 'usage.record', model: 'k2', time: '2026-07-17T10:00:05.000Z', usage: { inputOther: 100, output: 50 } }), + ] + const { calls, diagnostics } = decodeKimicode({ + records, + context: kimicodeContext, + sessionId: 'sess-hostile', + agentId: 'agent-hostile', + }) + const { sessions } = toKimicodeObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'kimicode' }, + ) + return { + envelope: { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + }, + calls, + diagnostics, + } + } + + it('produces a schema-valid envelope from the hostile wire log (non-vacuous)', () => { + const { envelope, calls } = decodeAndMinimize() + expect(calls).toHaveLength(1) + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + }) + + it('the planted secrets really entered the rich decode', () => { + const rich = allStrings(decodeAndMinimize().calls).join('\n') + expect(rich).toContain(SECRETS.prompt) + expect(rich).toContain(SECRETS.commandLine) // rawBashCommands + hostile tool name + }) + + it('keeps canonical tool names (Bash) and drops the argument-carrying name', () => { + const { envelope } = decodeAndMinimize() + const allToolNames = envelope.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).toContain('Bash') + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) + + it('neither the observations nor the diagnostics contain any planted secret', () => { + const { envelope, diagnostics } = decodeAndMinimize() + const haystack = JSON.stringify(envelope) + '\n' + JSON.stringify(diagnostics) + for (const secret of ALL_SECRETS) { + expect(haystack).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real pi decode -> toObservations is secret-free', () => { + // A hostile Pi session planting every secret in the free-text fields the + // decode captures: the user message, a bash command, and a tool NAME carrying + // a command line (must fail the canonical-name filter). + const piContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'pi', sourceRef: 'ref' } + + function decodeAndMinimize() { + const records = [ + JSON.stringify({ type: 'session', id: 'sess-hostile' }), + JSON.stringify({ + type: 'message', id: 'u1', timestamp: '2026-07-17T10:00:00.000Z', + message: { role: 'user', content: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}` }, + }), + JSON.stringify({ + type: 'message', id: 'a1', timestamp: '2026-07-17T10:00:05.000Z', + message: { + role: 'assistant', model: 'gpt-5', responseId: 'r1', + usage: { input: 100, output: 50 }, + content: [ + // A hostile tool NAME carrying a command line: unmapped, so it + // reaches `tools` and must be dropped by the canonical filter. + { type: 'toolCall', name: SECRETS.commandLine, arguments: {} }, + { type: 'toolCall', name: 'bash', arguments: { command: SECRETS.commandLine } }, + ], + }, + }), + ] + const { calls, diagnostics } = decodePi({ records, context: piContext }) + const { sessions } = toPiObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'pi' }, + ) + return { + envelope: { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + }, + calls, + diagnostics, + } + } + + it('produces a schema-valid envelope from the hostile session (non-vacuous)', () => { + const { envelope, calls } = decodeAndMinimize() + expect(calls).toHaveLength(1) + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + }) + + it('the planted secrets really entered the rich decode', () => { + const rich = allStrings(decodeAndMinimize().calls).join('\n') + expect(rich).toContain(SECRETS.prompt) + expect(rich).toContain(SECRETS.commandLine) // rawBashCommands + hostile tool name + }) + + it('keeps canonical tool names (Bash) and drops the argument-carrying name', () => { + const { envelope } = decodeAndMinimize() + const allToolNames = envelope.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).toContain('Bash') + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) + + it('neither the observations nor the diagnostics contain any planted secret', () => { + const { envelope, diagnostics } = decodeAndMinimize() + const haystack = JSON.stringify(envelope) + '\n' + JSON.stringify(diagnostics) + for (const secret of ALL_SECRETS) { + expect(haystack).not.toContain(secret) + } + }) +}) diff --git a/packages/core/tests/diagnostics.test.ts b/packages/core/tests/diagnostics.test.ts index a3920cbc..01906e19 100644 --- a/packages/core/tests/diagnostics.test.ts +++ b/packages/core/tests/diagnostics.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { - DIAGNOSTIC_DETAIL_MAX, DiagnosticDetail, RecordDiagnostic, isolateRecords, + keyedDetail, sanitizeDetail, } from '../src/diagnostics.js' import type { SessionObservation } from '../src/observations.js' @@ -19,65 +19,162 @@ const fakeSession = (ref: string): SessionObservation => ({ }) describe('DiagnosticDetail validator', () => { - it('accepts a bounded, path-free message', () => { - expect(DiagnosticDetail.safeParse('unexpected token at position 4').success).toBe(true) + it('accepts a 16-hex fingerprint (the only legal detail)', () => { + expect(DiagnosticDetail.safeParse('0123456789abcdef').success).toBe(true) }) - it('rejects forward-slash paths', () => { - expect(DiagnosticDetail.safeParse('failed on /home/u/secret.json').success).toBe(false) + it('rejects free text — even slash-free content like a prompt line', () => { + expect(DiagnosticDetail.safeParse('unexpected token at position 4').success).toBe(false) + expect(DiagnosticDetail.safeParse('reset the production database').success).toBe(false) }) - it('rejects backslash paths', () => { - expect(DiagnosticDetail.safeParse('failed on C:\\Users\\me\\x').success).toBe(false) + it('rejects an API key (identifier-charset content, no slashes)', () => { + expect(DiagnosticDetail.safeParse('sk-live-abc123DEF456').success).toBe(false) }) - it('rejects strings over the max length', () => { - expect(DiagnosticDetail.safeParse('x'.repeat(DIAGNOSTIC_DETAIL_MAX + 1)).success).toBe(false) + it('rejects path-bearing strings', () => { + expect(DiagnosticDetail.safeParse('/home/u/secret.json').success).toBe(false) + expect(DiagnosticDetail.safeParse('C:\\Users\\me\\x').success).toBe(false) + }) + + it('rejects anything that is not exactly 16 lowercase hex chars', () => { + expect(DiagnosticDetail.safeParse('0123456789abcdef0').success).toBe(false) // 17 chars + expect(DiagnosticDetail.safeParse('0123456789ABCDEF').success).toBe(false) // uppercase + expect(DiagnosticDetail.safeParse('0123456789abcde').success).toBe(false) // 15 chars }) it('RecordDiagnostic is strict (rejects unknown fields)', () => { expect( - RecordDiagnostic.safeParse({ code: 'other', detail: 'ok', extra: 'nope' }).success, + RecordDiagnostic.safeParse({ code: 'other', detail: '0123456789abcdef', extra: 'nope' }).success, ).toBe(false) }) + + it('RecordDiagnostic accepts the contract fields: index, code, detail', () => { + expect( + RecordDiagnostic.safeParse({ + index: 3, + code: 'malformed-json', + detail: '0123456789abcdef', + }).success, + ).toBe(true) + }) }) describe('sanitizeDetail', () => { - it('strips path separators so the result passes DiagnosticDetail', () => { - const out = sanitizeDetail(new Error('cannot read /etc/passwd or C:\\secret')) - expect(out).not.toMatch(/[\\/]/) + it('never echoes content: an absolute path becomes a 16-hex fingerprint', () => { + const out = sanitizeDetail(new Error('cannot read /etc/passwd or C:\\secret'), 'host-key') + expect(out).toMatch(/^[0-9a-f]{16}$/) + expect(out).not.toContain('etc') expect(DiagnosticDetail.safeParse(out).success).toBe(true) }) - it('caps length at the max', () => { - expect(sanitizeDetail('y'.repeat(1000)).length).toBe(DIAGNOSTIC_DETAIL_MAX) + it('never echoes slash-free content: a prompt line and an API key cannot survive', () => { + const prompt = 'reset the production database and email me the dump' + const apiKey = '«redacted:sk-…»' + for (const input of [prompt, apiKey]) { + const out = sanitizeDetail(input, 'host-key') + expect(out).toMatch(/^[0-9a-f]{16}$/) + expect(out).not.toContain('reset') + expect(out).not.toContain('sk-') + expect(DiagnosticDetail.safeParse(out).success).toBe(true) + } + }) + + it('is deterministic: identical inputs produce identical fingerprints, distinct inputs differ', () => { + expect(sanitizeDetail('boom', 'k')).toBe(sanitizeDetail('boom', 'k')) + expect(sanitizeDetail('boom', 'k')).not.toBe(sanitizeDetail('boom!', 'k')) + }) + + it('is keyed when a privacy key is supplied (D1)', () => { + const a = sanitizeDetail('boom', 'key-1') + const b = sanitizeDetail('boom', 'key-2') + expect(a).toMatch(/^[0-9a-f]{16}$/) + expect(b).toMatch(/^[0-9a-f]{16}$/) + expect(a).not.toBe(b) + }) + + it('always yields exactly 16 hex chars regardless of input size', () => { + expect(sanitizeDetail('y'.repeat(100_000), 'k')).toMatch(/^[0-9a-f]{16}$/) + expect(sanitizeDetail(42, 'k')).toMatch(/^[0-9a-f]{16}$/) + }) + + it('D1 regression: refuses to run without a key — no silent unkeyed digest', () => { + // A keyless call must fail loudly, never degrade to an unkeyed SHA-256 of + // a JSON.parse error message that can embed user content. The unkeyed + // call goes through a loose cast: the compile-time contract already + // requires the key, and this asserts the runtime guard holds too. + const unkeyed = sanitizeDetail as (value: unknown) => string + expect(() => unkeyed('boom')).toThrow(/privacyKey/) + expect(() => sanitizeDetail('boom', '')).toThrow(/privacyKey/) + }) +}) + +describe('keyedDetail', () => { + it('emits no detail at all when no key is available (D1)', () => { + expect(keyedDetail('boom', undefined)).toBeUndefined() + expect(keyedDetail('boom', '')).toBeUndefined() + }) + + it('emits the keyed fingerprint when a key is available', () => { + expect(keyedDetail('boom', 'host-key')).toBe(sanitizeDetail('boom', 'host-key')) + expect(keyedDetail('boom', 'host-key')).toMatch(/^[0-9a-f]{16}$/) }) }) describe('isolateRecords poison isolation', () => { - it('a throwing record becomes a diagnostic and never drops its siblings', () => { + it('a throwing record becomes a diagnostic with a content-free fingerprint and never drops its siblings', () => { const records = ['good-1', 'POISON', 'good-2'] const { observations, diagnostics } = isolateRecords(records, (record, index) => { if (record === 'POISON') throw new Error('kaboom at /secret/path') return { observations: [fakeSession(`ref-${index}`)] } - }) + }, 'host-key') expect(observations.map((o) => o.sessionRef)).toEqual(['ref-0', 'ref-2']) expect(diagnostics).toHaveLength(1) expect(diagnostics[0].index).toBe(1) expect(diagnostics[0].code).toBe('other') - // The thrown message's path must have been sanitized out. - expect(diagnostics[0].detail).not.toMatch(/[\\/]/) + // The thrown message's path must never survive — only its fingerprint. + expect(diagnostics[0].detail).toMatch(/^[0-9a-f]{16}$/) + expect(diagnostics[0].detail).not.toContain('secret') expect(RecordDiagnostic.safeParse(diagnostics[0]).success).toBe(true) }) - it('aggregates observations and diagnostics returned by decodeOne', () => { + it('D1 regression: keyless isolation emits no detail — never an unkeyed digest', () => { + const { diagnostics } = isolateRecords(['POISON'], () => { + throw new Error('boom at /x/y') + }) + expect(diagnostics[0]!.detail).toBeUndefined() + expect(diagnostics[0]).toEqual({ index: 0, code: 'other' }) + }) + + it('fingerprints the thrown error with the supplied privacy key', () => { + const { diagnostics } = isolateRecords(['POISON'], () => { + throw new Error('boom at /x/y') + }, 'host-key') + expect(diagnostics[0]!.detail).toBe(sanitizeDetail(new Error('boom at /x/y'), 'host-key')) + }) + + it('aggregates caller diagnostics but strips any detail — only thrown-error fingerprints survive', () => { const { observations, diagnostics } = isolateRecords([1, 2], (_r, index) => ({ observations: [fakeSession(`s-${index}`)], - diagnostics: [{ index, code: 'invalid-value' as const, detail: 'clamped a value' }], + // The type-level contract forbids a caller diagnostic from carrying + // `detail`; the loose cast simulates an untyped caller smuggling one in. + // The runtime must strip it rather than trust it — without the privacy + // key this fingerprint is unkeyed, and with a key isolateRecords cannot + // verify a caller-derived digest was actually keyed with it. + diagnostics: [ + { index, code: 'invalid-value' as const, detail: '0123456789abcdef' }, + ] as unknown as Array>, })) expect(observations).toHaveLength(2) expect(diagnostics).toHaveLength(2) + expect(diagnostics).toEqual([ + { index: 0, code: 'invalid-value' }, + { index: 1, code: 'invalid-value' }, + ]) + for (const d of diagnostics) { + expect(d.detail).toBeUndefined() + } }) it('never throws even when every record is poison', () => { diff --git a/packages/core/tests/harness/block-io-hooks.mjs b/packages/core/tests/harness/block-io-hooks.mjs index 6fa50acc..b9af20d8 100644 --- a/packages/core/tests/harness/block-io-hooks.mjs +++ b/packages/core/tests/harness/block-io-hooks.mjs @@ -1,7 +1,17 @@ // ESM loader hook (registered by block-io-register.mjs). Throws on resolution of // any I/O-capable core module, so if @codeburn/core touches the filesystem, a -// child process, or the network at import time — or during a trivial call — the -// import fails and the import-smoke guardrail catches it. +// child process, or the network at import time — or during any exercised call — +// the import fails and the import-smoke guardrail catches it. +// +// The list is deliberately a superset of the classic fs/child_process/net trio: +// every remaining module Node ships that can reach the ambient machine or its +// network must also be banned, or a decoder could quietly switch escape routes +// (os.homedir(), node:sqlite, a TLS socket, a worker thread, createRequire +// bypassing loader hooks entirely, ...). 'module' is banned because +// createRequire() rebuilds a CJS require that bypasses this resolve hook, and +// module.register() could install a competing hook. 'process' is banned because +// importing it by name is how a module grabs a live handle to the ambient +// environment after the preload has already emptied it. const BANNED = new Set([ 'fs', 'fs/promises', @@ -11,6 +21,15 @@ const BANNED = new Set([ 'https', 'dns', 'dns/promises', + // --- ambient machine / network escape routes --- + 'os', // homedir(), tmpdir(), userInfo(), platform() + 'tls', // raw TLS sockets + 'dgram', // UDP sockets + 'http2', // HTTP/2 sockets + 'worker_threads', // a worker can do anything its parent can + 'sqlite', // node:sqlite = direct file access + 'module', // createRequire bypasses loader hooks; register installs hooks + 'process', // live handle to the ambient process/env after they are emptied ]) export async function resolve(specifier, context, nextResolve) { diff --git a/packages/core/tests/harness/block-io-register.mjs b/packages/core/tests/harness/block-io-register.mjs index 5297dd90..44753696 100644 --- a/packages/core/tests/harness/block-io-register.mjs +++ b/packages/core/tests/harness/block-io-register.mjs @@ -1,11 +1,57 @@ // Preload (`node --import`) for the import-smoke child. Registers the I/O -// blocking loader hook and empties process.env, so the child runs with no -// ambient environment and no ability to reach fs / child_process / net / http / -// https / dns. +// blocking loader hook, removes the network globals, and neutralizes the +// ambient environment so the child runs with no way to reach fs / child_process +// / net / http / https / dns / os / tls / dgram / http2 / worker_threads / +// sqlite / createRequire — and no ambient env to read. import { register } from 'node:module' register('./block-io-hooks.mjs', import.meta.url) +// The ambient environment is emptied, and env reads of keys that HAD a value +// throw. Why throw at all, instead of plain emptying? An emptied env makes +// `process.env.HOME` return undefined silently — a read succeeds and the guard +// never notices. We verified a fully-throwing proxy is NOT viable: Node's own +// ESM loader reads process.env lazily during module linking (e.g. +// WATCH_REPORT_DEPENDENCIES on node 26), so a blanket `get` trap breaks Node +// itself, not just dependencies. The narrow variant below keeps Node's +// internal reads of *unset* keys harmless (undefined) while making any read of +// a key that genuinely had an ambient value (HOME, PATH, NODE_ENV, tokens...) +// fail loudly. Core's entire runtime graph is zod + node:crypto, neither of +// which reads ambient env at import, so this is safe today and catches the +// first accidental `process.env.*` read a future decoder adds. +const ambientKeys = new Set(Object.keys(process.env)) for (const key of Object.keys(process.env)) { delete process.env[key] } +process.env = new Proxy( + {}, + { + get(_target, prop) { + if (typeof prop === 'symbol') return undefined + if (ambientKeys.has(String(prop))) { + throw new Error(`import-smoke: blocked ambient env read "${String(prop)}"`) + } + return undefined + }, + has(_target, prop) { + return ambientKeys.has(String(prop)) + }, + set() { + return true + }, + deleteProperty() { + return true + }, + ownKeys() { + return [] + }, + getOwnPropertyDescriptor() { + return undefined + }, + }, +) + +// Network globals: undici's fetch (and the WebSocket global) are how a module +// reaches the network without importing any `node:` module at all. +delete globalThis.fetch +delete globalThis.WebSocket diff --git a/packages/core/tests/harness/import-smoke-child.mjs b/packages/core/tests/harness/import-smoke-child.mjs index 5a0fbed1..c4c73c8e 100644 --- a/packages/core/tests/harness/import-smoke-child.mjs +++ b/packages/core/tests/harness/import-smoke-child.mjs @@ -1,8 +1,14 @@ // Runs under block-io-register.mjs. argv[2..] are absolute paths to every // exports-map dist target (computed by the parent test from package.json, since -// this child cannot read files). It imports each one, then calls a trivial -// fingerprint + schema parse via the barrel. Any I/O import inside core makes -// one of these dynamic imports throw, failing the guardrail. +// this child cannot read files). It imports each one, then proves: +// 1. imports touch nothing (any I/O import inside core throws), +// 2. a trivial fingerprint + schema parse work, +// 3. a representative PARSER body (claude parseJsonlLine), +// 4. a representative DECODER body (codex decodeCodex), and +// 5. a representative DETECTOR body (junkReadsDetector) +// all execute cleanly under the same stubs. A decoder or detector that opened a +// file, hit the network, or read ambient env when CALLED now fails the guard — +// previously only import purity was proven, so an I/O-bearing body passed. import { pathToFileURL } from 'node:url' const targets = process.argv.slice(2) @@ -45,4 +51,82 @@ if (env.schemaVersion !== '0.2.0') { process.exit(5) } +// ── body coverage under the stubs ────────────────────────────────────────── + +// 3. Parser body: claude parseJsonlLine must parse a real user line. A parser +// whose body reached for fs/env/network would throw here. +const claude = Object.values(loaded).find((m) => typeof m.parseJsonlLine === 'function') +if (!claude) { + console.error('import-smoke: claude parser module not found across targets') + process.exit(6) +} +const entry = claude.parseJsonlLine( + JSON.stringify({ type: 'user', timestamp: '2026-07-17T10:00:00.000Z', sessionId: 'sess-smoke', message: { role: 'user', content: 'hello' } }), +) +if (!entry || entry.type !== 'user') { + console.error('import-smoke: parseJsonlLine returned unexpected result') + process.exit(7) +} + +// 4. Decoder body: codex decodeCodex over a minimal rollout must emit a call. +const codex = Object.values(loaded).find((m) => typeof m.decodeCodex === 'function') +if (!codex) { + console.error('import-smoke: codex decoder module not found across targets') + process.exit(8) +} +const { calls } = codex.decodeCodex({ + records: [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-07-17T10:00:00.000Z', payload: { cwd: '/smoke', originator: 'codex-cli', session_id: 'sess-smoke', model: 'gpt-5.3-codex' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-07-17T10:00:01.000Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-17T10:00:05.000Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, total_token_usage: { total_tokens: 8 } } } }), + ], + context: { privacyKey: 'smoke-key', providerId: 'codex', sourceRef: 'smoke-ref' }, +}) +if (!Array.isArray(calls) || calls.length === 0) { + console.error('import-smoke: decodeCodex produced no calls') + process.exit(9) +} + +// 5. Detector body: junkReadsDetector over an envelope with 3 dependency reads +// must emit exactly one finding (the boundary case from detectors.test.ts). A +// detector whose body touched I/O would throw here instead. +const detectorsMod = Object.values(loaded).find((m) => typeof m.junkReadsDetector === 'function') +if (!detectorsMod) { + console.error('import-smoke: detectors module not found across targets') + process.exit(10) +} +const junkEnv = { + schemaVersion: '0.2.0', + generator: { name: '@codeburn/core', version: '0.0.0-smoke' }, + sessions: [{ + sessionRef: '0000000000000000', + projectRef: '0000000000000000', + providerId: 'claude', + startedAt: '2026-07-17T10:00:00.000Z', + calls: [{ + provider: 'claude', + model: 'claude-opus-4-8', + tokens: { input: 1, output: 1, reasoning: 0, cacheRead: 0, cacheCreate: 0 }, + webSearchRequests: 0, + speed: 'standard', + costBasis: 'estimated', + timestamp: '2026-07-17T10:00:00.000Z', + dedupKey: 'smoke-dedup', + toolNames: ['Read'], + turnIndex: 0, + resourceReads: [ + { resourceId: '0000000000000000', resourceClass: 'dependency' }, + { resourceId: '1111111111111111', resourceClass: 'dependency' }, + { resourceId: '2222222222222222', resourceClass: 'dependency' }, + ], + }], + turnCount: 1, + }], +} +const junkFindings = detectorsMod.junkReadsDetector(junkEnv) +if (!Array.isArray(junkFindings) || junkFindings.length !== 1) { + console.error(`import-smoke: junkReadsDetector expected 1 finding, got ${junkFindings?.length}`) + process.exit(11) +} + console.log('IMPORT_SMOKE_OK') diff --git a/packages/core/tests/import-smoke.test.ts b/packages/core/tests/import-smoke.test.ts index 5db308f8..71b60f21 100644 --- a/packages/core/tests/import-smoke.test.ts +++ b/packages/core/tests/import-smoke.test.ts @@ -9,12 +9,15 @@ import { beforeAll, describe, expect, it } from 'vitest' * IMPORT-SMOKE GUARDRAIL. * * Proves @codeburn/core performs no filesystem / child-process / network I/O at - * import time or during a trivial fingerprint + schema parse. We run against the - * BUILT dist (pure ESM whose only deps are `zod` and `node:crypto`), so the - * child needs no TS loader — just plain node plus a resolve hook that throws on - * any I/O module. Resolving the exports-map targets to file paths (rather than - * importing `@codeburn/core` by name) avoids a self-symlink dance in the - * worktree while still exercising every declared subpath. + * import time or during exercised parser, decoder, and detector bodies. We run + * against the BUILT dist (pure ESM whose only deps are `zod` and `node:crypto`), + * so the child needs no TS loader — just plain node plus a resolve hook that + * throws on any I/O module (fs, child_process, net, http(s), dns, os, tls, + * dgram, http2, worker_threads, sqlite, module/createRequire, process). The + * preload also deletes the network globals (fetch, WebSocket) and makes reads + * of ambient env keys throw. Resolving the exports-map targets to file paths + * (rather than importing `@codeburn/core` by name) avoids a self-symlink dance + * in the worktree while still exercising every declared subpath. */ const here = dirname(fileURLToPath(import.meta.url)) const pkgRoot = resolve(here, '..') @@ -60,15 +63,57 @@ describe('import-smoke guardrail', () => { expect(result.stdout).toContain('IMPORT_SMOKE_OK') }) - it('confirms the block hook actually throws on a banned module (harness sanity)', () => { - // A tiny inline module that imports fs must fail under the preload, proving - // the guardrail can detect I/O — otherwise the passing test above is vacuous. - const result = spawnSync( + it('confirms the block hook actually throws on every banned module (harness sanity)', () => { + // Tiny inline modules that import each banned module must fail under the + // preload, proving the guardrail can detect I/O across the whole blocklist — + // otherwise the passing test above is vacuous for the newer entries + // (os / tls / dgram / http2 / worker_threads / sqlite / module / process). + const banned = [ + 'node:fs', + 'node:os', + 'node:tls', + 'node:dgram', + 'node:http2', + 'node:worker_threads', + 'node:sqlite', + 'node:module', + 'node:process', + ] + for (const specifier of banned) { + const result = spawnSync( + process.execPath, + ['--import', registerPreload, '--input-type=module', '--eval', `await import('${specifier}')`], + { cwd: pkgRoot, encoding: 'utf8' }, + ) + expect(result.status, `import of ${specifier} must fail under the preload`).not.toBe(0) + expect(result.stderr).toContain('blocked I/O module import') + } + }) + + it('confirms the preload removes the network globals and ambient env (harness sanity)', () => { + // fetch/WebSocket are globals, so the loader hook cannot see them — the + // preload must delete them. And a read of a key that had an ambient value + // must throw, not silently return undefined. Both are otherwise silent + // escape routes. The env probe uses a sentinel we set explicitly in the + // child's environment rather than HOME: the guard's contract is "reads of + // keys that HAD a value throw", so the test must not depend on a key being + // present in the ambient environment (env -i / minimal CI containers have + // no HOME — there the old probe failed for the wrong reason). + const SENTINEL = 'IMPORT_SMOKE_AMBIENT_SENTINEL' + const fetchResult = spawnSync( process.execPath, - ['--import', registerPreload, '--input-type=module', '--eval', "await import('node:fs')"], + ['--import', registerPreload, '--input-type=module', '--eval', 'await fetch("http://127.0.0.1")'], { cwd: pkgRoot, encoding: 'utf8' }, ) - expect(result.status).not.toBe(0) - expect(result.stderr).toContain('blocked I/O module import') + expect(fetchResult.status).not.toBe(0) + expect(fetchResult.stderr).toContain('fetch is not defined') + + const envResult = spawnSync( + process.execPath, + ['--import', registerPreload, '--input-type=module', '--eval', `process.env.${SENTINEL}`], + { cwd: pkgRoot, encoding: 'utf8', env: { ...process.env, [SENTINEL]: 'present' } }, + ) + expect(envResult.status).not.toBe(0) + expect(envResult.stderr).toContain(`blocked ambient env read "${SENTINEL}"`) }) }) diff --git a/packages/core/tests/providers/opencode-session-decode.test.ts b/packages/core/tests/providers/opencode-session-decode.test.ts index 84e2c3d0..1497e997 100644 --- a/packages/core/tests/providers/opencode-session-decode.test.ts +++ b/packages/core/tests/providers/opencode-session-decode.test.ts @@ -338,7 +338,12 @@ describe('opencode-session rich decode: SQLite arm', () => { context, }) expect(calls).toHaveLength(1) - expect(diagnostics).toEqual([{ index: 0, code: 'malformed-json' }]) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]!.code).toBe('malformed-json') + expect(diagnostics[0]!.index).toBe(0) + // The sanitiser is wired into the parse-failure path: detail is a keyed + // fingerprint of the error, never its message. + expect(diagnostics[0]!.detail).toMatch(/^[0-9a-f]{16}$/) }) it('S4: corrupt part data is skipped silently', () => { diff --git a/packages/core/tests/providers/vscode-cline-decode.test.ts b/packages/core/tests/providers/vscode-cline-decode.test.ts index 5654f1ef..bd335007 100644 --- a/packages/core/tests/providers/vscode-cline-decode.test.ts +++ b/packages/core/tests/providers/vscode-cline-decode.test.ts @@ -194,6 +194,25 @@ describe('vscode-cline rich decode (moved to @codeburn/core)', () => { it('returns no calls and a malformed-json diagnostic when uiRaw is invalid JSON', () => { const { calls, diagnostics } = decodeVscodeCline({ records: [envelope({ uiRaw: 'not json' })], context }) expect(calls).toEqual([]) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]!.code).toBe('malformed-json') + expect(diagnostics[0]!.index).toBe(0) + // The sanitiser is wired into the parse-failure path: detail is a keyed + // fingerprint of the error, never its message. + expect(diagnostics[0]!.detail).toMatch(/^[0-9a-f]{16}$/) + }) + + it('D1 regression: with the bridge\'s empty privacy key, malformed JSON emits no detail — never an unkeyed digest', () => { + // The CLI bridge (packages/cli/src/providers/bridge.ts) decodes every + // bridged provider with `privacyKey: ''`. A JSON.parse error message + // embeds a fragment of the offending input, so an unkeyed digest of it + // would be dictionary-attackable. The keyless path must omit detail + // entirely, matching the pre-fingerprint diagnostic shape. + const { calls, diagnostics } = decodeVscodeCline({ + records: [envelope({ uiRaw: 'not json' })], + context: { ...context, privacyKey: '' }, + }) + expect(calls).toEqual([]) expect(diagnostics).toEqual([{ index: 0, code: 'malformed-json' }]) })