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' }]) })