From cff80ccdb1582ffb4b4769791002db1de86eb2d4 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 10:18:01 -0700 Subject: [PATCH 01/11] fix(core): bound the model field at the observation boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `model` on a CallObservation was `z.string().min(1)` — unbounded free text on a field that ships in the envelope. Bound it (and the optional `pricingModel`) to the identifier charset real model slugs use, `[A-Za-z0-9._:/@-]`, max 128, in both the zod schema and the published schemas/observation-0.2.0.json. Providers report display names ("Gemini 3.5 Flash (High)"), so a hard reject would fail a whole multi-session batch over one call. Every provider's toObservations now normalizes through `normalizeModelIdentifier`: conforming values pass through untouched, everything else collapses to 'unknown' — the same fallback the decoders already use when no model resolves. BOUNDARY ONLY. The normalizer is never called while building a dedup key: a key is an identity, not an observation field, and collapsing two distinct raw models to 'unknown' inside one would merge two real calls and re-key every cached key for a partial win. The key's free-text surface is schema-wide (every provider's key carries raw components) and belongs to the dedup-key-hashing follow-up. CLI output is unaffected — normalization happens at the observation boundary only, so no terminal, dashboard, menubar or desktop number or label moves. --- packages/core/schemas/observation-0.2.0.json | 8 +- packages/core/src/observations.ts | 5 +- .../src/providers/antigravity/observations.ts | 3 +- .../core/src/providers/claude/observations.ts | 3 +- .../src/providers/codebuff/observations.ts | 3 +- .../src/providers/codewhale/observations.ts | 3 +- .../core/src/providers/codex/observations.ts | 3 +- .../src/providers/copilot/observations.ts | 3 +- .../core/src/providers/crush/observations.ts | 3 +- .../providers/cursor-agent/observations.ts | 3 +- .../core/src/providers/cursor/observations.ts | 3 +- .../core/src/providers/devin/observations.ts | 3 +- .../core/src/providers/droid/observations.ts | 3 +- .../core/src/providers/forge/observations.ts | 3 +- .../core/src/providers/gemini/observations.ts | 3 +- .../core/src/providers/goose/observations.ts | 3 +- .../core/src/providers/grok/observations.ts | 3 +- .../core/src/providers/hermes/observations.ts | 3 +- .../core/src/providers/kimi/observations.ts | 3 +- .../src/providers/kimicode/observations.ts | 3 +- .../core/src/providers/kiro/observations.ts | 3 +- .../src/providers/lingtai-tui/observations.ts | 3 +- .../providers/mistral-vibe/observations.ts | 3 +- .../core/src/providers/mux/observations.ts | 3 +- .../src/providers/open-design/observations.ts | 3 +- .../src/providers/openclaw/observations.ts | 3 +- .../opencode-session/observations.ts | 3 +- .../core/src/providers/pi/observations.ts | 3 +- .../src/providers/quickdesk/observations.ts | 3 +- .../core/src/providers/qwen/observations.ts | 3 +- .../providers/vercel-gateway/observations.ts | 13 +- .../providers/vscode-cline/observations.ts | 3 +- .../core/src/providers/warp/observations.ts | 3 +- .../core/src/providers/zcode/observations.ts | 3 +- .../core/src/providers/zed/observations.ts | 3 +- .../src/providers/zerostack/observations.ts | 3 +- packages/core/src/schema.ts | 69 +++++++++ packages/core/tests/architecture-gate.test.ts | 140 +++++++++++++++--- .../providers/antigravity-decode.test.ts | 42 ++++++ .../core/tests/providers/devin-decode.test.ts | 39 +++++ .../providers/vercel-gateway-decode.test.ts | 53 +++++-- .../core/tests/providers/warp-decode.test.ts | 23 +++ 42 files changed, 421 insertions(+), 70 deletions(-) diff --git a/packages/core/schemas/observation-0.2.0.json b/packages/core/schemas/observation-0.2.0.json index e8d328759..92c6b640f 100644 --- a/packages/core/schemas/observation-0.2.0.json +++ b/packages/core/schemas/observation-0.2.0.json @@ -69,11 +69,15 @@ }, "model": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:/@-]+$" }, "pricingModel": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:/@-]+$" }, "tokens": { "type": "object", diff --git a/packages/core/src/observations.ts b/packages/core/src/observations.ts index 7124c044b..72102905b 100644 --- a/packages/core/src/observations.ts +++ b/packages/core/src/observations.ts @@ -5,6 +5,7 @@ import { CostBasis, FingerprintHex, IsoTimestamp, + ModelIdentifier, NonNegInt, NonNegUSD, OBSERVATION_SCHEMA_VERSION, @@ -26,8 +27,8 @@ import { export const CallObservation = z .object({ provider: z.string().min(1), - model: z.string().min(1), - pricingModel: z.string().min(1).optional(), + model: ModelIdentifier, + pricingModel: ModelIdentifier.optional(), tokens: TokenBuckets, webSearchRequests: NonNegInt, diff --git a/packages/core/src/providers/antigravity/observations.ts b/packages/core/src/providers/antigravity/observations.ts index 91d9fcb2d..9a21eeefe 100644 --- a/packages/core/src/providers/antigravity/observations.ts +++ b/packages/core/src/providers/antigravity/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { AntigravityDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Antigravity cascade's rich decode, as the host holds it before minimization. */ export interface RichAntigravitySessionDecode { @@ -27,7 +28,7 @@ export interface AntigravityToObservationsContext { function toCallObservation(call: AntigravityDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/claude/observations.ts b/packages/core/src/providers/claude/observations.ts index 582af21dd..49df19f28 100644 --- a/packages/core/src/providers/claude/observations.ts +++ b/packages/core/src/providers/claude/observations.ts @@ -10,6 +10,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { DecodedCall, DecodedTurn } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One session's rich decode, as the host holds it before minimization. */ export interface RichSessionDecode { @@ -38,7 +39,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: DecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.usage.inputTokens, output: call.usage.outputTokens, diff --git a/packages/core/src/providers/codebuff/observations.ts b/packages/core/src/providers/codebuff/observations.ts index 71ad60d05..7f03953a9 100644 --- a/packages/core/src/providers/codebuff/observations.ts +++ b/packages/core/src/providers/codebuff/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CodebuffDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Codebuff session's rich decode, as the host holds it before minimization. */ export interface RichCodebuffSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CodebuffDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/codewhale/observations.ts b/packages/core/src/providers/codewhale/observations.ts index b726ef38d..8d1303d9f 100644 --- a/packages/core/src/providers/codewhale/observations.ts +++ b/packages/core/src/providers/codewhale/observations.ts @@ -9,6 +9,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { CodeWhaleDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One CodeWhale session's rich decode, as the host holds it before minimization. */ export interface RichCodeWhaleSessionDecode { @@ -31,7 +32,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CodeWhaleDecodedCall, turnIndex: number, privacyKey: string): CallObservation { const obs: CallObservation = { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/codex/observations.ts b/packages/core/src/providers/codex/observations.ts index 5beacbbb4..4ecc4c0b9 100644 --- a/packages/core/src/providers/codex/observations.ts +++ b/packages/core/src/providers/codex/observations.ts @@ -10,6 +10,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { CodexDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Codex session's rich decode, as the host holds it before minimization. */ export interface RichCodexSessionDecode { @@ -35,7 +36,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CodexDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/copilot/observations.ts b/packages/core/src/providers/copilot/observations.ts index 92ed1a49f..7061073ce 100644 --- a/packages/core/src/providers/copilot/observations.ts +++ b/packages/core/src/providers/copilot/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CopilotDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Copilot session's rich decode, as the host holds it before minimization. */ export interface RichCopilotSessionDecode { @@ -29,7 +30,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CopilotDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/crush/observations.ts b/packages/core/src/providers/crush/observations.ts index e89342fd8..7205660f3 100644 --- a/packages/core/src/providers/crush/observations.ts +++ b/packages/core/src/providers/crush/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CrushDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Crush session's rich decode, as the host holds it before minimization. */ export interface RichCrushSessionDecode { @@ -30,7 +31,7 @@ function toCallObservation(call: CrushDecodedCall, turnIndex: number): CallObser const measured = call.measuredCostUSD !== undefined return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/cursor-agent/observations.ts b/packages/core/src/providers/cursor-agent/observations.ts index 06e944e0e..5a9d90c13 100644 --- a/packages/core/src/providers/cursor-agent/observations.ts +++ b/packages/core/src/providers/cursor-agent/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CursorAgentDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Cursor Agent session's rich decode, as the host holds it before minimization. */ export interface RichCursorAgentSessionDecode { @@ -32,7 +33,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CursorAgentDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/cursor/observations.ts b/packages/core/src/providers/cursor/observations.ts index ddbda94d3..720e00eea 100644 --- a/packages/core/src/providers/cursor/observations.ts +++ b/packages/core/src/providers/cursor/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { CursorDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Cursor session's rich decode, as the host holds it before minimization. */ export interface RichCursorSessionDecode { @@ -32,7 +33,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: CursorDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/devin/observations.ts b/packages/core/src/providers/devin/observations.ts index 1bb71a643..ad68c66de 100644 --- a/packages/core/src/providers/devin/observations.ts +++ b/packages/core/src/providers/devin/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { DevinDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Devin session's rich decode, as the host holds it before minimization. */ export interface RichDevinSessionDecode { @@ -34,7 +35,7 @@ function toCallObservation(call: DevinDecodedCall, turnIndex: number, privacyKey provider: call.provider, // The raw model id, not the host's display name: the envelope is keyed by // provider ids, and display formatting lives CLI-side. - model: call.generationModel ?? call.modelName, + model: normalizeModelIdentifier(call.generationModel ?? call.modelName), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/droid/observations.ts b/packages/core/src/providers/droid/observations.ts index db24be903..f67847738 100644 --- a/packages/core/src/providers/droid/observations.ts +++ b/packages/core/src/providers/droid/observations.ts @@ -4,6 +4,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { DroidDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export interface RichDroidSessionDecode { sessionId: string @@ -21,7 +22,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: DroidDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/forge/observations.ts b/packages/core/src/providers/forge/observations.ts index bb4c97935..1b6472f27 100644 --- a/packages/core/src/providers/forge/observations.ts +++ b/packages/core/src/providers/forge/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { ForgeDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Forge conversation's rich decode, as the host holds it before minimization. */ export interface RichForgeSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: ForgeDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/gemini/observations.ts b/packages/core/src/providers/gemini/observations.ts index 744fa11db..52f056dc4 100644 --- a/packages/core/src/providers/gemini/observations.ts +++ b/packages/core/src/providers/gemini/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { GeminiDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Gemini session's rich decode, as the host holds it before minimization. */ export interface RichGeminiSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: GeminiDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/goose/observations.ts b/packages/core/src/providers/goose/observations.ts index ba90005b6..c46bdabb6 100644 --- a/packages/core/src/providers/goose/observations.ts +++ b/packages/core/src/providers/goose/observations.ts @@ -8,6 +8,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { GooseDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Goose session's rich decode, as the host holds it before minimization. */ export interface RichGooseSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: GooseDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/grok/observations.ts b/packages/core/src/providers/grok/observations.ts index 86318aeed..a89b25eaa 100644 --- a/packages/core/src/providers/grok/observations.ts +++ b/packages/core/src/providers/grok/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { GrokDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Grok session's rich decode, as the host holds it before minimization. */ export interface RichGrokSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: GrokDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/hermes/observations.ts b/packages/core/src/providers/hermes/observations.ts index 112a11ccc..4ccd4ae67 100644 --- a/packages/core/src/providers/hermes/observations.ts +++ b/packages/core/src/providers/hermes/observations.ts @@ -8,6 +8,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { HermesDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Hermes session's rich decode, as the host holds it before minimization. */ export interface RichHermesSessionDecode { @@ -34,7 +35,7 @@ function toCallObservation(call: HermesDecodedCall, turnIndex: number, privacyKe const measured = call.recordedCost !== undefined return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/kimi/observations.ts b/packages/core/src/providers/kimi/observations.ts index 93d26f3a4..51f1d6ab8 100644 --- a/packages/core/src/providers/kimi/observations.ts +++ b/packages/core/src/providers/kimi/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { KimiDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Kimi session's rich decode, as the host holds it before minimization. */ export interface RichKimiSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: KimiDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/kimicode/observations.ts b/packages/core/src/providers/kimicode/observations.ts index 510c88b9c..e6d2aefc2 100644 --- a/packages/core/src/providers/kimicode/observations.ts +++ b/packages/core/src/providers/kimicode/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { KimicodeDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Kimicode session's rich decode, as the host holds it before minimization. */ export interface RichKimicodeSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: KimicodeDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/kiro/observations.ts b/packages/core/src/providers/kiro/observations.ts index 08842feca..11206cf24 100644 --- a/packages/core/src/providers/kiro/observations.ts +++ b/packages/core/src/providers/kiro/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { KiroDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Kiro session's rich decode, as the host holds it before minimization. */ export interface RichKiroSessionDecode { @@ -31,7 +32,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: KiroDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/lingtai-tui/observations.ts b/packages/core/src/providers/lingtai-tui/observations.ts index 89364810e..e650348ea 100644 --- a/packages/core/src/providers/lingtai-tui/observations.ts +++ b/packages/core/src/providers/lingtai-tui/observations.ts @@ -2,6 +2,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { LingTaiTuiDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export interface RichLingTaiTuiSessionDecode { sessionId: string @@ -19,7 +20,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: LingTaiTuiDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/mistral-vibe/observations.ts b/packages/core/src/providers/mistral-vibe/observations.ts index f5e51ae96..e3be2a808 100644 --- a/packages/core/src/providers/mistral-vibe/observations.ts +++ b/packages/core/src/providers/mistral-vibe/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { MistralVibeDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Mistral Vibe session's rich decode, as the host holds it before minimization. */ export interface RichMistralVibeSessionDecode { @@ -33,7 +34,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: MistralVibeDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/mux/observations.ts b/packages/core/src/providers/mux/observations.ts index 584c7330c..3e03dfb96 100644 --- a/packages/core/src/providers/mux/observations.ts +++ b/packages/core/src/providers/mux/observations.ts @@ -2,6 +2,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { MuxDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export interface RichMuxSessionDecode { sessionId: string @@ -19,7 +20,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: MuxDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/open-design/observations.ts b/packages/core/src/providers/open-design/observations.ts index ad7f14807..535df0f85 100644 --- a/packages/core/src/providers/open-design/observations.ts +++ b/packages/core/src/providers/open-design/observations.ts @@ -2,6 +2,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { OpenDesignDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' export interface RichOpenDesignSessionDecode { sessionId: string @@ -19,7 +20,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: OpenDesignDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/openclaw/observations.ts b/packages/core/src/providers/openclaw/observations.ts index d9e88b7ac..fc8e851c6 100644 --- a/packages/core/src/providers/openclaw/observations.ts +++ b/packages/core/src/providers/openclaw/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { OpenClawDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One OpenClaw session's rich decode, as the host holds it before minimization. */ export interface RichOpenClawSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: OpenClawDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/opencode-session/observations.ts b/packages/core/src/providers/opencode-session/observations.ts index 60d662918..e026c5d6d 100644 --- a/packages/core/src/providers/opencode-session/observations.ts +++ b/packages/core/src/providers/opencode-session/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { OpenCodeSessionDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One OpenCode-session decode, as the host holds it before minimization. */ export interface RichOpenCodeSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: OpenCodeSessionDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/pi/observations.ts b/packages/core/src/providers/pi/observations.ts index 3c47062c4..cc3aa22dc 100644 --- a/packages/core/src/providers/pi/observations.ts +++ b/packages/core/src/providers/pi/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { PiDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Pi/OMP session's rich decode, as the host holds it before minimization. */ export interface RichPiSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: PiDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/quickdesk/observations.ts b/packages/core/src/providers/quickdesk/observations.ts index 353241471..b256ee6aa 100644 --- a/packages/core/src/providers/quickdesk/observations.ts +++ b/packages/core/src/providers/quickdesk/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { QuickdeskDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Quickdesk session's rich decode, as the host holds it before minimization. */ export interface RichQuickdeskSessionDecode { @@ -33,7 +34,7 @@ function toCallObservation(call: QuickdeskDecodedCall, turnIndex: number, privac const measured = call.recordedCost !== undefined return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/qwen/observations.ts b/packages/core/src/providers/qwen/observations.ts index 0eb673943..372575434 100644 --- a/packages/core/src/providers/qwen/observations.ts +++ b/packages/core/src/providers/qwen/observations.ts @@ -10,6 +10,7 @@ import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import { extractResourceRefs } from '../resource-refs.js' import type { QwenDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Qwen session's rich decode, as the host holds it before minimization. */ export interface RichQwenSessionDecode { @@ -35,7 +36,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: QwenDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/vercel-gateway/observations.ts b/packages/core/src/providers/vercel-gateway/observations.ts index 9db010538..438109855 100644 --- a/packages/core/src/providers/vercel-gateway/observations.ts +++ b/packages/core/src/providers/vercel-gateway/observations.ts @@ -3,10 +3,16 @@ // // Vercel Gateway reports contain no free-text user content. The only string // fields that cross into the envelope are machine identifiers: -// - `provider` and `model` are emitted by design under the identifier-exemption +// - `provider` is emitted by design under the identifier-exemption // convention (see architecture-gate.test.ts MACHINE_ID_ALLOWLIST). +// - `model` is externally supplied (the fetched report) and is normalized at +// this boundary: values inside the ModelIdentifier charset cross unchanged, +// anything else (a hostile prompt, a display name) collapses to 'unknown', +// so a bad model can never reject the whole envelope. // - `day` is an API-supplied calendar date. It IS emitted, verbatim, inside the -// synthesized timestamp and the dedup key. The envelope's `format: date-time` +// synthesized timestamp and the dedup key. (The dedup key also carries the +// RAW model — normalization is a boundary concern only, and the key's +// free-text surface is the schema-wide dedup-key hashing follow-up.) The envelope's `format: date-time` // constraint on every timestamp is what bounds it: a `day` that is not a real // date fails envelope validation, so a hostile value cannot ship. // No host-held user prompt, tools, bashCommands, file paths, or command lines @@ -16,6 +22,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { VercelGatewayDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Vercel Gateway report's rich decode, as the host holds it before minimization. */ export interface RichVercelGatewaySessionDecode { @@ -36,7 +43,7 @@ export interface VercelGatewayToObservationsContext { function toCallObservation(call: VercelGatewayDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/vscode-cline/observations.ts b/packages/core/src/providers/vscode-cline/observations.ts index 66667e2b1..6efc88f9a 100644 --- a/packages/core/src/providers/vscode-cline/observations.ts +++ b/packages/core/src/providers/vscode-cline/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { VscodeClineDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One vscode-cline session's rich decode, as the host holds it before minimization. */ export interface RichVscodeClineSessionDecode { @@ -30,7 +31,7 @@ function toCallObservation(call: VscodeClineDecodedCall, turnIndex: number): Cal const measured = call.measuredCostUSD !== undefined return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/warp/observations.ts b/packages/core/src/providers/warp/observations.ts index 906066831..ab6a274b5 100644 --- a/packages/core/src/providers/warp/observations.ts +++ b/packages/core/src/providers/warp/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { WarpDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Warp session's rich decode, as the host holds it before minimization. */ export interface RichWarpSessionDecode { @@ -32,7 +33,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: WarpDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/zcode/observations.ts b/packages/core/src/providers/zcode/observations.ts index 2f26dd185..5a67f4467 100644 --- a/packages/core/src/providers/zcode/observations.ts +++ b/packages/core/src/providers/zcode/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { ZcodeDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One ZCode session's rich decode, as the host holds it before minimization. */ export interface RichZcodeSessionDecode { @@ -29,7 +30,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: ZcodeDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/zed/observations.ts b/packages/core/src/providers/zed/observations.ts index b01ca7b0f..43ab3da9e 100644 --- a/packages/core/src/providers/zed/observations.ts +++ b/packages/core/src/providers/zed/observations.ts @@ -8,6 +8,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { ZedDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Zed thread's rich decode, as the host holds it before minimization. */ export interface RichZedSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: ZedDecodedCall, turnIndex: number): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/providers/zerostack/observations.ts b/packages/core/src/providers/zerostack/observations.ts index 4955f65ca..be3d346bf 100644 --- a/packages/core/src/providers/zerostack/observations.ts +++ b/packages/core/src/providers/zerostack/observations.ts @@ -7,6 +7,7 @@ import { projectRef, sessionRef } from '../../fingerprint.js' import type { RecordDiagnostic } from '../../diagnostics.js' import type { CallObservation, SessionObservation } from '../../observations.js' import type { ZerostackDecodedCall } from './types.js' +import { normalizeModelIdentifier } from '../../schema.js' /** One Zerostack session's rich decode, as the host holds it before minimization. */ export interface RichZerostackSessionDecode { @@ -30,7 +31,7 @@ const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ function toCallObservation(call: ZerostackDecodedCall, turnIndex: number, privacyKey: string): CallObservation { return { provider: call.provider, - model: call.model, + model: normalizeModelIdentifier(call.model), tokens: { input: call.inputTokens, output: call.outputTokens, diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 41d2a503b..0e5b907ad 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -7,6 +7,20 @@ import { z } from 'zod' * 0.2.0 adds the optional per-call `resourceReads` / `resourceEdits` arrays * (ResourceRef). Strictness rules are unchanged: every added field is either a * fingerprint or a coarse enum, so the anti-smuggling property still holds. + * + * MIGRATION NOTE (in-place hardening, not a version bump): during 0.2.0's + * lifetime the `model` / `pricingModel` validation was tightened in place from + * `minLength: 1` to the ModelIdentifier bound (maxLength 128 + identifier + * charset), and the published schemas/observation-0.2.0.json changed in lock- + * step. The envelope shape is unchanged — producers always normalize through + * `normalizeModelIdentifier` now, so no newly produced envelope can be + * rejected. The one hazard is ARCHIVED envelopes: a pre-hardening 0.2.0 + * envelope whose model held a display name (e.g. "Gemini 3.5 Flash (High)") + * now fails validation against the same version string. Such archives must be + * re-normalized (collapse the model to 'unknown' or an identifier) before + * re-validating. A version bump was considered and rejected: 0.x is already + * breaking-by-default, no field changed shape, and a new version would force + * consumers to carry a second schema for a validation tightening alone. */ export const OBSERVATION_SCHEMA_VERSION = '0.2.0' @@ -42,6 +56,61 @@ export const CanonicalToolName = z .max(64) .regex(/^[A-Za-z0-9_.-]+$/, 'canonical tool names only (no args, paths, or spaces)') +/** + * Model identifier, as reported by the provider. Bounded to the identifier + * charset real model slugs use — letters, digits, and the separators `._:/@-` + * (openai/gpt-4o, anthropic--claude-4.6-opus, us.anthropic.claude-3-5-sonnet- + * 20241022-v2:0, cloudflare/@cf/meta/llama-2-7b-chat-fp16). The bound is + * anti-free-text: whitespace, punctuation outside the separators, and prompt + * text cannot fit, so a planted prompt or command line fails validation. It is + * NOT path-proof — `/`, `.`, `-` and `:` are valid identifier characters, so a + * path-shaped string (e.g. /Users/victim/company/secret-plan.md) can still + * match; the anti-path guarantee lives in the fingerprint fields + * (FingerprintHex), not here. A provider value outside the charset (e.g. a + * display name like "Gemini 3.5 Flash (High)") is normalized to 'unknown' at + * the observation boundary by `normalizeModelIdentifier`, never rejected here. + * The cap is generous (the longest slug in the litellm pricing snapshot is 76 + * chars) but the charset is the binding constraint. + */ +const MODEL_IDENTIFIER_PATTERN = /^[A-Za-z0-9._:/@-]+$/ + +export const ModelIdentifier = z + .string() + .min(1) + .max(128) + .regex(MODEL_IDENTIFIER_PATTERN, 'model identifiers only (letters, digits, and . _ : / @ - separators)') + +/** + * Normalize a provider-supplied model string at the observation boundary (each + * provider's toObservations). Values already inside the ModelIdentifier + * charset pass through unchanged; anything else — provider display names with + * spaces ("Gemini 3.5 Flash (High)", "GPT-5.3 Codex (medium reasoning)"), + * unmapped aliases, empty strings — collapses to 'unknown', the same fallback + * the decoders already use when no model can be resolved. This mirrors how + * non-canonical tool names are dropped rather than failing: a hostile value + * must never be able to reject a whole envelope, because one bad model id in + * one call would otherwise fail an entire multi-session batch. + * + * BOUNDARY ONLY. Never call this while building a dedup key. A dedup key is an + * identity, not an observation field: collapsing two distinct raw models to + * 'unknown' inside a key merges two real calls into one, and normalizing a key + * component changes every cached key's value (a cache-invalidation event) for + * no privacy gain — the key's free-text surface is schema-wide (every + * provider's key carries raw components) and is closed by hashing keys + * wholesale, not by normalizing one component of a few. + * + * BOTH bounded fields go through this: `model` and `pricingModel` carry the + * same ModelIdentifier bound, so a producer that ever fills `pricingModel` + * (none does today — it is CLI-side only and is dropped at the boundary) must + * normalize it here too, or a display name in it would hard-reject the whole + * envelope. + */ +export function normalizeModelIdentifier(raw: string): string { + const trimmed = raw.trim() + if (trimmed.length === 0 || trimmed.length > 128) return 'unknown' + return MODEL_IDENTIFIER_PATTERN.test(trimmed) ? trimmed : 'unknown' +} + /** Per-call token buckets. All five are required, non-negative integers. */ export const TokenBuckets = z .object({ diff --git a/packages/core/tests/architecture-gate.test.ts b/packages/core/tests/architecture-gate.test.ts index b4016eb32..474618418 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,26 +318,57 @@ 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 // identifiers with `minLength:1` and no upper bound. Each is a controlled -// vocabulary emitted by the host (a generator version, a provider/model/pricing -// slug, or a hash-derived dedup key), never user free text — provider and model -// ids have no natural maximum, so no maxLength is asserted. content-smuggling -// tests prove no user text reaches these. Every entry is justified; a NEW -// minLength-only string field NOT listed here fails the gate. +// vocabulary emitted by the host (a generator version, a provider slug, or a +// hash-derived dedup key), never user free text — provider ids have no natural +// maximum, so no maxLength is asserted. In 0.2.0 the model/pricingModel slugs +// ARE capped (ModelIdentifier charset + maxLength); the 0.1.0 entries below +// stay allowlisted only because that schema is frozen as shipped. Every entry +// is justified; a NEW minLength-only string field NOT listed here fails the +// gate. const MACHINE_ID_ALLOWLIST = new Set([ 'observation-0.1.0#ObservationEnvelope/generator/version', 'observation-0.1.0#ObservationEnvelope/sessions/items/providerId', @@ -314,8 +379,6 @@ const MACHINE_ID_ALLOWLIST = new Set([ 'observation-0.2.0#ObservationEnvelope/generator/version', 'observation-0.2.0#ObservationEnvelope/sessions/items/providerId', 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/provider', - 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/model', - 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/pricingModel', 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/dedupKey', ]) @@ -351,8 +414,8 @@ const EXPECTED_STRING_FIELDS: StringField[] = [ { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/endedAt', kind: 'format:date-time' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/gitBranchRef', kind: 'pattern:^[0-9a-f]{16}$' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/provider', kind: 'minLength-only:1' }, - { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/model', kind: 'minLength-only:1' }, - { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/pricingModel', kind: 'minLength-only:1' }, + { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/model', kind: 'pattern:^[A-Za-z0-9._:/@-]+$' }, + { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/pricingModel', kind: 'pattern:^[A-Za-z0-9._:/@-]+$' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/speed', kind: 'enum[2]' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/costBasis', kind: 'enum[2]' }, { path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/timestamp', kind: 'format:date-time' }, @@ -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/providers/antigravity-decode.test.ts b/packages/core/tests/providers/antigravity-decode.test.ts index ca261c7dd..e73ed69a1 100644 --- a/packages/core/tests/providers/antigravity-decode.test.ts +++ b/packages/core/tests/providers/antigravity-decode.test.ts @@ -346,6 +346,48 @@ describe('antigravity rich decode (moved to @codeburn/core)', () => { expect(calls[1]).toMatchObject({ inputTokens: 100, outputTokens: 10, cacheReadInputTokens: 50 }) }) + it('acceptance: a display-name model read from payload.model.display_name is normalized at the observation boundary', () => { + // The status-line decoder reads payload.model.display_name verbatim when no + // id is present (e.g. "Gemini 3.5 Flash (High)"). The observation boundary + // must normalize it to 'unknown' instead of rejecting the whole envelope. + const payload = { + conversation_id: 'accept-1', + model: { display_name: 'Gemini 3.5 Flash (High)' }, + context_window: { + current_usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + } + const event = parseAntigravityStatusLinePayload(payload, '2026-05-05T05:05:05.005Z') + expect(event).not.toBeNull() + expect(event!.model).toBe('Gemini 3.5 Flash (High)') + + const { calls } = decodeAntigravityStatusLine({ + records: [JSON.stringify(event)], + context, + seenKeys: new Set(), + }) + expect(calls[0]!.model).toBe('Gemini 3.5 Flash (High)') + + const { sessions } = toObservations( + { sessionId: 'accept-1', projectPath: '/Users/t/project', calls }, + { privacyKey: 'test-privacy-key', provider: 'antigravity' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.model).toBe('unknown') + // Boundary only: the observation's model field is clean. The decoder's + // usageSignature (and therefore the dedup key) is deliberately built from + // the RAW display name — normalizing a key component would merge distinct + // calls and churn every cached key; the key's free-text surface is closed + // schema-wide by hashing dedup keys, not here. + expect(JSON.stringify(envelope, (k, v) => (k === 'dedupKey' ? '' : v))) + .not.toContain('Gemini 3.5 Flash') + }) + it('parseAntigravityStatusLinePayload uses the injected at value and never captures cwd', () => { const fixedAt = '2026-05-05T05:05:05.005Z' const payload = { diff --git a/packages/core/tests/providers/devin-decode.test.ts b/packages/core/tests/providers/devin-decode.test.ts index b653c87b4..5454f4040 100644 --- a/packages/core/tests/providers/devin-decode.test.ts +++ b/packages/core/tests/providers/devin-decode.test.ts @@ -286,6 +286,45 @@ describe('devin rich decode (moved to @codeburn/core)', () => { } }) + it('acceptance: a display-name model name (e.g. "Gemini 3 Flash") is normalized at the observation boundary', () => { + // When no generation_model is recorded, devin falls back to the agent's + // model_name, which real databases carry as a display name ("Gemini 3 + // Flash"). The observation boundary must normalize it to 'unknown' + // instead of rejecting the whole envelope. + const transcript: DevinAgentTrajectory = { + ...BASE_TRANSCRIPT, + agent: { name: 'devin', version: '2.0', model_name: 'Gemini 3 Flash' }, + steps: [ + { + step_id: 1, + source: 'assistant', + message: 'working', + metadata: { + created_at: '2027-01-15T08:00:01.000Z', + committed_acu_cost: 0.1, + metrics: { input_tokens: 100 }, + }, + }, + ], + } + const { calls } = decodeDevin({ records: [makeRecord(transcript)], context }) + expect(calls[0]!.generationModel).toBeUndefined() + expect(calls[0]!.modelName).toBe('Gemini 3 Flash') + + const { sessions } = toObservations( + { sessionId: 'sess-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'devin' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.model).toBe('unknown') + expect(JSON.stringify(envelope)).not.toContain('Gemini 3 Flash') + }) + it('extracts user message from ContentPart[] messages', () => { const transcript: DevinAgentTrajectory = { ...BASE_TRANSCRIPT, diff --git a/packages/core/tests/providers/vercel-gateway-decode.test.ts b/packages/core/tests/providers/vercel-gateway-decode.test.ts index 6cf6f2286..2e6d87974 100644 --- a/packages/core/tests/providers/vercel-gateway-decode.test.ts +++ b/packages/core/tests/providers/vercel-gateway-decode.test.ts @@ -109,8 +109,11 @@ describe('vercel-gateway observations', () => { } } - it('produces a schema-valid envelope', () => { - expect(ObservationEnvelope.safeParse(buildEnvelope()).success).toBe(true) + it('normalizes a hostile prompt in model to unknown; the envelope still parses (no whole-batch rejection)', () => { + const env = buildEnvelope() + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + const call = env.sessions[0]?.calls[0] + expect(call?.model).toBe('unknown') }) it('contains at least one call (non-vacuous)', () => { @@ -119,17 +122,43 @@ describe('vercel-gateway observations', () => { expect(callCount).toBeGreaterThan(0) }) - it('emits no free text except the model identifier (identifier-exemption convention)', () => { - const env = buildEnvelope() - const serialized = JSON.stringify(env) - - // The model field is an API identifier and is emitted by design; the planted - // secret in model is therefore expected to appear there and only there. - expect(serialized).toContain(SECRETS.prompt) + it('the hostile envelope serializes with none of the planted secrets outside the dedup key', () => { + // The prompt was planted in model, the abs path was passed as projectPath; + // neither may survive the observation boundary. The dedup key + // (`vercel-gateway::`) still carries the report's RAW model: + // normalization is boundary-only by design, and the key's free-text surface + // is closed schema-wide by hashing dedup keys, not by cleaning one + // component of one provider's key. + const serialized = JSON.stringify(buildEnvelope(), (k, v) => (k === 'dedupKey' ? '' : v)) + expect(serialized).not.toContain(SECRETS.prompt) expect(serialized).not.toContain(SECRETS.absPath) - expect(serialized).not.toContain(SECRETS.apiKey) - expect(serialized).not.toContain(SECRETS.commandLine) - expect(serialized).not.toContain(SECRETS.fileContent) + }) + + it('a legitimate identifier-shaped model crosses unchanged', () => { + const { calls } = decodeVercelGateway({ + records: [ + { + day: '2026-07-17', + model: 'anthropic/claude-sonnet-4.6', + total_cost: 1.23, + input_tokens: 100, + output_tokens: 50, + }, + ], + }) + const { sessions } = toObservations( + { sessionId: 'report-2026-07-17', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'vercel-gateway' }, + ) + const env = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + expect(env.sessions[0]?.calls[0]?.model).toBe('anthropic/claude-sonnet-4.6') + const serialized = JSON.stringify(env) + expect(serialized).toContain('anthropic/claude-sonnet-4.6') }) it('exposes the provider-reported cost as measured', () => { diff --git a/packages/core/tests/providers/warp-decode.test.ts b/packages/core/tests/providers/warp-decode.test.ts index 1dc7f9f7c..f91d9435e 100644 --- a/packages/core/tests/providers/warp-decode.test.ts +++ b/packages/core/tests/providers/warp-decode.test.ts @@ -170,6 +170,29 @@ describe('warp rich decode (moved to @codeburn/core)', () => { expect(calls[0]!.model).toBe('gpt-5.3-codex') }) + it('acceptance: a display-name model the alias map does not cover is normalized at the observation boundary', () => { + // Warp's alias map is closed, so any NEW model id arrives verbatim (spaces + // and all) — e.g. "GPT-5.4 Codex (medium reasoning)" or "Claude Sonnet + // 4.7". The decode passes it through; the observation boundary must + // normalize it to 'unknown' instead of rejecting the whole envelope. + const exchanges: WarpQueryRow[] = [makeExchange('ex-1', { model_id: 'GPT-5.4 Codex (medium reasoning)' })] + const { calls } = decodeWarp({ records: [makeComposite('conv-a', BASE_CONVERSATION, exchanges)], context }) + expect(calls[0]!.model).toBe('GPT-5.4 Codex (medium reasoning)') + + const { sessions } = toObservations( + { sessionId: 'conv-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'warp' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.model).toBe('unknown') + expect(JSON.stringify(envelope)).not.toContain('GPT-5.4 Codex') + }) + it('uses the fallback token budget when conversation usage is absent', () => { const conversation: WarpConversationRow = { ...BASE_CONVERSATION, From a4be04d926a21bf808ad9d07d6b4154321375388 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 10:18:16 -0700 Subject: [PATCH 02/11] fix(core,cli): fingerprint the source path out of dedup keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dedupKey is a CallObservation field: it ships on the envelope. Six decoders — codebuff, zerostack, pi, omp, grok and lingtai-tui — were folding the absolute source path (a chat directory, a session file) into it raw, so a host path rode every payload derived from those sessions. They now fold `sourceRefFingerprint`, a keyed HMAC-SHA256 under a new `source` domain. Like every other fingerprint in that module the key is required and an empty key throws, so a source ref can never degrade to an unkeyed, dictionary-attackable digest. That means the CLI bridge has to supply a real key. It passed `''`, which was correct when minimization happened only on the sync path; it now threads getHostPrivacyKey() — per-install stable, so dedup keys stay stable across runs. The keys change VALUE, so the six carry a `source-ref-fingerprint-v1` parse version that forces one re-parse and drops the cached raw-path keys instead of re-ingesting the same records under two shapes. A parse version cannot see the privacy key change, though, and a lost, rotated, or ephemeral (unwritable config dir) key would silently produce keys that never match the cached ones — so computeEnvFingerprint folds a digest of the key in for exactly those providers. The five bridge parity goldens used to compute their expected keys with the same function and key production uses, which pins nothing. They now re-derive the fingerprint longhand under a pinned test key, assert the key SHAPE, and assert the raw fixture path appears nowhere in it. --- packages/cli/src/providers/bridge.ts | 22 +++++-- packages/cli/src/session-cache.ts | 41 ++++++++++-- .../tests/providers/codebuff-bridge.test.ts | 31 ++++++++-- .../cli/tests/providers/grok-bridge.test.ts | 24 ++++++- .../providers/lingtai-tui-bridge.test.ts | 23 ++++++- .../cli/tests/providers/pi-bridge.test.ts | 40 ++++++++++-- .../tests/providers/zerostack-bridge.test.ts | 28 +++++++-- packages/cli/tests/session-cache.test.ts | 62 ++++++++++++++++++- packages/cli/tests/setup/fixed-privacy-key.ts | 48 ++++++++++++++ packages/core/src/contracts.ts | 16 ++++- packages/core/src/fingerprint.ts | 18 +++++- .../core/src/providers/codebuff/decode.ts | 8 ++- packages/core/src/providers/grok/decode.ts | 9 ++- packages/core/src/providers/grok/types.ts | 3 +- .../core/src/providers/lingtai-tui/decode.ts | 14 ++++- packages/core/src/providers/pi/decode.ts | 15 +++-- .../core/src/providers/zerostack/decode.ts | 8 ++- 17 files changed, 364 insertions(+), 46 deletions(-) create mode 100644 packages/cli/tests/setup/fixed-privacy-key.ts diff --git a/packages/cli/src/providers/bridge.ts b/packages/cli/src/providers/bridge.ts index b03b353f6..5580c6072 100644 --- a/packages/cli/src/providers/bridge.ts +++ b/packages/cli/src/providers/bridge.ts @@ -1,5 +1,6 @@ import type { DecodeContext } from '@codeburn/core' +import { getHostPrivacyKey } from '../privacy-key.js' import type { Provider, ProbeRoot, SessionSource, SessionParser, ParsedProviderCall } from './types.js' // ── The dual-registry bridge ──────────────────────────────────────────────── @@ -81,10 +82,23 @@ export function createBridgedProvider(spec: BridgedProviderSpec): async *parse(): AsyncGenerator { const records = await spec.readRecords(source) 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. - const context: DecodeContext = { privacyKey: '', providerId: spec.name, sourceRef: source.path } + // The host privacy key, threaded into the rich decode (D1). An empty + // key was correct when the bridge was written — minimization and + // fingerprinting happened later, on the sync path, so the rich + // decoder never consumed it. That is no longer true: the rich + // decoders now derive dedup keys (sourceRefFingerprint, copilot's + // JetBrains per-turn HMAC) and diagnostic details from the key, and + // dedupKey ships on the observation envelope. core's keyed + // primitives THROW on an empty key rather than degrade to an unkeyed + // digest, so the bridge has to supply the real one. + // getHostPrivacyKey() is per-install stable (persisted, like the + // optimize detectors use), so dedup keys stay stable across runs and + // the session-cache dedup semantics are unchanged; it falls back to a + // per-process key only when the config dir is unwritable, in which + // case the session cache cannot persist either. A lost or rotated key + // is caught by computeEnvFingerprint, which folds a digest of the key + // into the env fingerprint for the affected providers. + const context: DecodeContext = { privacyKey: getHostPrivacyKey(), providerId: spec.name, sourceRef: source.path } const { calls } = spec.decode({ records, context, seenKeys }) for (const rich of calls) { yield spec.toProviderCall(rich) diff --git a/packages/cli/src/session-cache.ts b/packages/cli/src/session-cache.ts index 5ca6b9a66..fb6e3051b 100644 --- a/packages/cli/src/session-cache.ts +++ b/packages/cli/src/session-cache.ts @@ -4,6 +4,7 @@ import { createHash, randomBytes } from 'crypto' import { join } from 'path' import { homedir } from 'os' +import { getHostPrivacyKey } from './privacy-key.js' import type { ToolCall } from './types.js' // ── Types ────────────────────────────────────────────────────────────── @@ -234,6 +235,22 @@ export const DURABLE_PROVIDER_NAMES: ReadonlySet = new Set(['copilot']) // needs no suffix: the cli-shutdown-cost-v1 bump below already forces its one // re-parse, which lands the flag too, and durable orphans now survive // fingerprint changes (the carry-forward in getOrCreateProviderSection). +// Dedup-key hygiene (#933/#935): six providers now thread a FINGERPRINT of the +// source path into their dedup keys instead of the raw path, and copilot's +// JetBrains per-turn digest went from an unkeyed sha256 to a keyed HMAC. The +// session cache seeds its dedup sets from the CACHED keys, so a pre-fix cache +// keeps the old-shape keys and the same records re-ingest under the new shape +// (double-count; and for the six, the raw path stays on disk forever). Each +// entry/suffix below changes the provider's env fingerprint, which forces the +// one-time re-parse that drops the old-shape keys. +// +// SOURCE_REF_KEYED_PARSE_VERSION is the marker token for the six: their keys +// are derived from the per-install privacy key, which the parse version alone +// cannot see. computeEnvFingerprint folds a digest of that key in for exactly +// these providers, so a lost or rotated key forces a clean re-parse instead of +// silently accumulating duplicates in a warm cache. +export const SOURCE_REF_KEYED_PARSE_VERSION = 'source-ref-fingerprint-v1' + export const PROVIDER_PARSE_VERSIONS: Record = { // rich-session-capture-v1: parse-time capture of per-turn gitBranch, per-call // LOC deltas / interruptions / userModified / toolErrors, and session-level @@ -252,10 +269,14 @@ 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', - grok: 'estimated-cost-v1', + copilot: 'cli-shutdown-cost-v1-skills-dedup-key-hmac-v1', + codebuff: 'source-ref-fingerprint-v1', + zerostack: 'source-ref-fingerprint-v1', + pi: 'source-ref-fingerprint-v1', + omp: 'source-ref-fingerprint-v1', + grok: 'estimated-cost-v1-source-ref-fingerprint-v1', hermes: 'reasoning-output-accounting-v1-est-cost', - 'lingtai-tui': 'token-ledger-registry-activity-v3', + 'lingtai-tui': 'token-ledger-registry-activity-v3-source-ref-fingerprint-v1', 'ibm-bob': 'worktree-project-grouping-v1', kiro: 'ide-parsing-v1-est-cost', opencode: 'session-model-v1', @@ -292,7 +313,19 @@ export function computeEnvFingerprint(provider: string): string { const vars = PROVIDER_ENV_VARS[provider] ?? [] const parts = vars.map(v => `${v}=${process.env[v] ?? ''}`) const parseVersion = PROVIDER_PARSE_VERSIONS[provider] - if (parseVersion) parts.push(`parser=${parseVersion}`) + if (parseVersion) { + parts.push(`parser=${parseVersion}`) + // Providers whose dedup keys are HMAC-derived from the per-install privacy + // key must re-parse when that key changes. The key file can be lost, + // rotated, or fall back to a per-process ephemeral key (unwritable config + // dir) with no parse-version change to notice it — and the cached keys + // would then never match the freshly derived ones, so every record would + // re-ingest as new. A digest of the key (never the key itself) rides the + // fingerprint for exactly those providers. + if (parseVersion.includes(SOURCE_REF_KEYED_PARSE_VERSION)) { + parts.push(`privacy-key=${createHash('sha256').update(getHostPrivacyKey()).digest('hex').slice(0, 16)}`) + } + } return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16) } diff --git a/packages/cli/tests/providers/codebuff-bridge.test.ts b/packages/cli/tests/providers/codebuff-bridge.test.ts index bdfafa913..cb8d7ddc9 100644 --- a/packages/cli/tests/providers/codebuff-bridge.test.ts +++ b/packages/cli/tests/providers/codebuff-bridge.test.ts @@ -1,3 +1,7 @@ +// FIRST import: pins the host privacy key before anything reads it, so the +// dedup-key fingerprint below is a constant rather than a per-run random. +import '../setup/fixed-privacy-key.js' + import { dirname, join, resolve } from 'path' import { fileURLToPath } from 'url' @@ -5,19 +9,24 @@ import { describe, it, expect } from 'vitest' import { createCodebuffProvider } from '../../src/providers/codebuff.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { expectedSourceRef } from '../setup/fixed-privacy-key.js' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the codebuff bridge migration. The GOLDEN below // was captured from the legacy in-CLI decode before the migration; the bridged // provider (discovery + I/O CLI-side, pure decode in @codeburn/core/providers/codebuff) -// must reproduce it exactly. Dedup keys contain absolute source paths, so they are -// computed from the discovered source at runtime rather than hard-coded. +// must reproduce it exactly, EXCEPT the dedup key, which deliberately changed: +// it used to embed the absolute chat-dir path (dedupKey ships on the observation +// envelope, so that was a raw host path riding a payload) and now embeds a keyed +// fingerprint of it. The expected fingerprint is re-derived longhand from a +// pinned key (expectedSourceRef) rather than by calling the production helper, +// so this golden pins the encoding instead of agreeing with itself. const here = dirname(fileURLToPath(import.meta.url)) const FIXTURE_DIR = resolve(here, '../fixtures/codebuff-parity/manicode') function expectedGolden(sourcePath: string): ParsedProviderCall[] { - const chatDir = sourcePath + const chatRef = expectedSourceRef(sourcePath) return [ { provider: 'codebuff', @@ -35,7 +44,7 @@ function expectedGolden(sourcePath: string): ParsedProviderCall[] { bashCommands: ['npm', 'npm'], timestamp: '2026-04-14T10:00:30.000Z', speed: 'standard', - deduplicationKey: `codebuff:${chatDir}:a1`, + deduplicationKey: `codebuff:${chatRef}:a1`, userMessage: 'implement the feature', sessionId: 'manicode/2026-04-14T10-00-00.000Z', }, @@ -55,7 +64,7 @@ function expectedGolden(sourcePath: string): ParsedProviderCall[] { bashCommands: [], timestamp: '2026-04-14T10:01:30.000Z', speed: 'standard', - deduplicationKey: `codebuff:${chatDir}:a2`, + deduplicationKey: `codebuff:${chatRef}:a2`, userMessage: 'fix the bug', sessionId: 'manicode/2026-04-14T10-00-00.000Z', }, @@ -75,7 +84,7 @@ function expectedGolden(sourcePath: string): ParsedProviderCall[] { bashCommands: [], timestamp: '2026-04-14T10:02:00.000Z', speed: 'standard', - deduplicationKey: `codebuff:${chatDir}:a3`, + deduplicationKey: `codebuff:${chatRef}:a3`, userMessage: '', sessionId: 'manicode/2026-04-14T10-00-00.000Z', }, @@ -101,6 +110,16 @@ describe('codebuff bridge — fixture parity', () => { expect(calls).toEqual(expectedGolden(sourcePath)) }) + it('the dedup key is an opaque fingerprint, never the chat-dir path', async () => { + const { calls, sourcePath } = await collect() + expect(calls.length).toBeGreaterThan(0) // non-vacuous + for (const call of calls) { + expect(call.deduplicationKey).toMatch(/^codebuff:[0-9a-f]{16}:a[0-9]+$/) + expect(call.deduplicationKey).not.toContain(sourcePath) + expect(call.deduplicationKey).not.toContain('codebuff-parity') + } + }) + it('the priced output survives the pricing pass; credit fallback is used when table price is zero', async () => { const { calls } = await collect() const priced = calls.map(priceProviderCall) diff --git a/packages/cli/tests/providers/grok-bridge.test.ts b/packages/cli/tests/providers/grok-bridge.test.ts index 65a298fc4..4efbed5a4 100644 --- a/packages/cli/tests/providers/grok-bridge.test.ts +++ b/packages/cli/tests/providers/grok-bridge.test.ts @@ -1,3 +1,7 @@ +// FIRST import: pins the host privacy key before anything reads it, so the +// dedup-key fingerprint below is a constant rather than a per-run random. +import '../setup/fixed-privacy-key.js' + import { dirname, resolve } from 'path' import { fileURLToPath } from 'url' @@ -5,6 +9,7 @@ import { describe, it, expect } from 'vitest' import { createGrokProvider } from '../../src/providers/grok.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { expectedSourceRef } from '../setup/fixed-privacy-key.js' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the grok bridge migration (phase 8). The @@ -12,6 +17,13 @@ import type { ParsedProviderCall, SessionSource } from '../../src/providers/type const here = dirname(fileURLToPath(import.meta.url)) const FIXTURE_DIR = resolve(here, '../fixtures/grok-parity') +// The dedup key used to embed the session dir's ABSOLUTE PATH; dedupKey ships on +// the observation envelope, so that was a raw host path riding a payload. It now +// embeds a keyed fingerprint of that path, re-derived here longhand from a +// pinned key (expectedSourceRef) instead of by calling the production helper, so +// the golden pins the encoding rather than agreeing with itself. +const SESSION_DIR = resolve(FIXTURE_DIR, '%2FUsers%2Ftest/019edf9c-0000-7000-8000-000000000001') +const SESSION_REF = expectedSourceRef(SESSION_DIR) const GOLDEN: ParsedProviderCall[] = [ { @@ -35,7 +47,7 @@ const GOLDEN: ParsedProviderCall[] = [ speed: 'standard', // The key embeds the session dir's absolute path — compute it from // FIXTURE_DIR so the golden is portable across checkouts. - deduplicationKey: `grok:${resolve(FIXTURE_DIR, '%2FUsers%2Ftest/019edf9c-0000-7000-8000-000000000001')}:2026-06-19T11:31:12.282793Z:019edf9c-0000-7000-8000-000000000001`, + deduplicationKey: `grok:${SESSION_REF}:2026-06-19T11:31:12.282793Z:019edf9c-0000-7000-8000-000000000001`, userMessage: 'User asks about the repo', sessionId: '019edf9c-0000-7000-8000-000000000001', project: 'myproject', @@ -62,6 +74,16 @@ describe('grok bridge — fixture parity', () => { expect(await collect()).toEqual(GOLDEN) }) + it('the dedup key is an opaque fingerprint, never the session-dir path', async () => { + const calls = await collect() + expect(calls.length).toBeGreaterThan(0) // non-vacuous + for (const call of calls) { + expect(call.deduplicationKey).toMatch(/^grok:[0-9a-f]{16}:/) + expect(call.deduplicationKey).not.toContain(SESSION_DIR) + expect(call.deduplicationKey).not.toContain('grok-parity') + } + }) + it('the priced output survives the pricing pass with only costUSD added', async () => { const raw = await collect() const priced = raw.map(priceProviderCall) diff --git a/packages/cli/tests/providers/lingtai-tui-bridge.test.ts b/packages/cli/tests/providers/lingtai-tui-bridge.test.ts index 2632ee3f7..72a1cb57a 100644 --- a/packages/cli/tests/providers/lingtai-tui-bridge.test.ts +++ b/packages/cli/tests/providers/lingtai-tui-bridge.test.ts @@ -1,9 +1,14 @@ +// FIRST import: pins the host privacy key before anything reads it, so the +// dedup-key fingerprint below is a constant rather than a per-run random. +import '../setup/fixed-privacy-key.js' + import { dirname, resolve } from 'path' import { fileURLToPath } from 'url' import { describe, it, expect } from 'vitest' import { createLingTaiTuiProvider } from '../../src/providers/lingtai-tui.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { expectedSourceRef } from '../setup/fixed-privacy-key.js' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the lingtai-tui bridge migration (phase 8). @@ -14,7 +19,11 @@ import type { ParsedProviderCall, SessionSource } from '../../src/providers/type // per-source-label activity synthesis (main / tc_wake / daemon => userMessage + // tools + subagentTypes), model/endpoint fallback from the manifest when a // ledger row omits them, run_id vs `${agentId}:${label}` session ids, the -// composite dedup key threaded on the SOURCE PATH (not the agent dir), turnId, +// composite dedup key threaded on a keyed FINGERPRINT of the source path (not +// the agent dir; the raw path used to ride the key, and dedupKey ships on the +// observation envelope). The expected fingerprint is re-derived longhand from a +// pinned key (expectedSourceRef) rather than by calling the production helper, +// so the golden pins the encoding instead of agreeing with itself. Also: turnId, // and the manifest-derived project / projectPath carried onto the call. const here = dirname(fileURLToPath(import.meta.url)) const FIXTURE_DIR = resolve(here, '../fixtures/lingtai-parity') @@ -33,7 +42,7 @@ function dedup( thinking: number, cached: number, ): string { - return ['lingtai-tui', sourcePath, lineNo, ts, model, endpoint, label, emId, runId, input, output, thinking, cached].join(':') + return ['lingtai-tui', expectedSourceRef(sourcePath), lineNo, ts, model, endpoint, label, emId, runId, input, output, thinking, cached].join(':') } function golden(sourcePath: string, agentDir: string): ParsedProviderCall[] { @@ -127,6 +136,16 @@ describe('lingtai-tui bridge — fixture parity', () => { expect(calls).toEqual(golden(source.path, agentDir)) }) + it('the dedup key is an opaque fingerprint, never the ledger path', async () => { + const { source, calls } = await sourceAndCalls() + expect(calls.length).toBeGreaterThan(0) // non-vacuous + for (const call of calls) { + expect(call.deduplicationKey).toMatch(/^lingtai-tui:[0-9a-f]{16}:/) + expect(call.deduplicationKey).not.toContain(source.path) + expect(call.deduplicationKey).not.toContain('lingtai-parity') + } + }) + it('the priced output survives the pricing pass with only costUSD added', async () => { const { calls } = await sourceAndCalls() calls.map(priceProviderCall).forEach((call, i) => { diff --git a/packages/cli/tests/providers/pi-bridge.test.ts b/packages/cli/tests/providers/pi-bridge.test.ts index af9329555..a1a78e0f4 100644 --- a/packages/cli/tests/providers/pi-bridge.test.ts +++ b/packages/cli/tests/providers/pi-bridge.test.ts @@ -1,3 +1,7 @@ +// FIRST import: pins the host privacy key before anything reads it, so the +// dedup-key fingerprint below is a constant rather than a per-run random. +import '../setup/fixed-privacy-key.js' + import { dirname, resolve } from 'path' import { fileURLToPath } from 'url' @@ -5,14 +9,18 @@ import { describe, it, expect } from 'vitest' import { createPiProvider, createOmpProvider } from '../../src/providers/pi.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { expectedSourceRef } from '../setup/fixed-privacy-key.js' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the pi/omp bridge migration (phase 8). One core // decode serves both providers; the GOLDENs were captured from the legacy in-CLI // decode (git show origin/feat/core-extraction:packages/cli/src/providers/pi.ts) -// run over the committed fixtures. Covers: the `::` dedup -// key — anchored to the SESSION FILE PATH, not the sessionId, and computed from -// FIXTURE_DIR so the golden is checkout-portable — plus its +// run over the committed fixtures. Covers: the +// `::` dedup key — anchored to the SESSION +// FILE PATH, not the sessionId. The key used to embed that path RAW, and +// dedupKey ships on the observation envelope, so it now embeds a keyed +// fingerprint, re-derived here longhand from a pinned key (expectedSourceRef) +// rather than by calling the production helper — plus its // responseId||entryId||timestamp||lineIdx fallback chain; sessionId from the // session entry `id` vs the basename-of-path fallback (omp entry omits id -> // 'ofile'); SKILL.md and skill:// reads reclassified as the `Skill` tool with @@ -26,6 +34,8 @@ const OMP_DIR = resolve(here, '../fixtures/pi-parity/omp-sessions') const PI_PATH = resolve(PI_DIR, 'proj1/sess-file.jsonl') const OMP_PATH = resolve(OMP_DIR, 'projO/ofile.jsonl') +const PI_REF = expectedSourceRef(PI_PATH) +const OMP_REF = expectedSourceRef(OMP_PATH) async function collect(provider: { discoverSessions: () => Promise @@ -58,7 +68,7 @@ const PI_GOLDEN: ParsedProviderCall[] = [ skills: ['my-skill', 'web-search'], timestamp: '2026-06-10T10:00:02.000Z', speed: 'standard', - deduplicationKey: `pi:${PI_PATH}:resp-1`, + deduplicationKey: `pi:${PI_REF}:resp-1`, userMessage: 'do stuff', sessionId: 'pi-sess-1', }, @@ -83,7 +93,7 @@ const OMP_GOLDEN: ParsedProviderCall[] = [ speed: 'standard', // responseId '' -> entry.id absent -> entry.timestamp; sessionId falls back // to basename-of-path because the session entry carries no id. - deduplicationKey: `omp:${OMP_PATH}:2026-06-11T10:00:00.000Z`, + deduplicationKey: `omp:${OMP_REF}:2026-06-11T10:00:00.000Z`, userMessage: '', sessionId: 'ofile', }, @@ -98,6 +108,26 @@ describe('pi/omp bridge — fixture parity', () => { expect(await collect(createOmpProvider(OMP_DIR))).toEqual(OMP_GOLDEN) }) + it('the dedup key is an opaque fingerprint, never the session-file path', async () => { + const cases: Array<[string, RegExp, string]> = [ + [PI_PATH, /^pi:[0-9a-f]{16}:/, 'pi'], + [OMP_PATH, /^omp:[0-9a-f]{16}:/, 'omp'], + ] + const collected = [ + await collect(createPiProvider(PI_DIR)), + await collect(createOmpProvider(OMP_DIR)), + ] + collected.forEach((calls, i) => { + const [rawPath, shape] = cases[i]! + expect(calls.length).toBeGreaterThan(0) // non-vacuous + for (const call of calls) { + expect(call.deduplicationKey).toMatch(shape) + expect(call.deduplicationKey).not.toContain(rawPath) + expect(call.deduplicationKey).not.toContain('pi-parity') + } + }) + }) + it('the priced output survives the pricing pass with only costUSD added', async () => { const raw = await collect(createPiProvider(PI_DIR)) const priced = raw.map(priceProviderCall) diff --git a/packages/cli/tests/providers/zerostack-bridge.test.ts b/packages/cli/tests/providers/zerostack-bridge.test.ts index 0c2a9c346..7dc6eea54 100644 --- a/packages/cli/tests/providers/zerostack-bridge.test.ts +++ b/packages/cli/tests/providers/zerostack-bridge.test.ts @@ -1,18 +1,26 @@ +// FIRST import: pins the host privacy key before anything reads it, so the +// dedup-key fingerprint below is a constant rather than a per-run random. +import '../setup/fixed-privacy-key.js' + import { dirname, join, resolve } from 'path' import { fileURLToPath } from 'url' import { describe, it, expect } from 'vitest' import { createZerostackProvider } from '../../src/providers/zerostack.js' import { priceProviderCall } from '../../src/pricing-pass.js' +import { expectedSourceRef } from '../setup/fixed-privacy-key.js' import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' // Byte-identical parity gate for the zerostack bridge migration (phase 8). // Zerostack is not in the frozen corpus, so a committed fixture golden is THE // parity gate: the bridged provider (discovery + JSON I/O CLI-side, pure decode // delegated to @codeburn/core/providers/zerostack) must reproduce exactly what -// the pre-migration in-CLI decode produced. The dedup key threads the absolute -// source path (`zerostack:::`), so it is built from -// the discovered source rather than hard-coded. Covers: cumulative session +// the pre-migration in-CLI decode produced, EXCEPT the dedup key, which +// deliberately changed: it used to embed the absolute source path +// (`zerostack:::`) and dedupKey ships on the observation envelope, +// so it now embeds a keyed fingerprint of that path — re-derived here longhand +// from a pinned key (expectedSourceRef) instead of by calling the production +// helper, so the golden pins the encoding rather than agreeing with itself. Covers: cumulative session // totals, the zero-token skip (elsewhere), the OpenRouter model passing through // raw, the empty-model + string-array userMessage + `basename(path)` sessionId // fallbacks, updated_at-then-created_at timestamp precedence, and the discovered @@ -39,7 +47,7 @@ function golden(dir: string): ParsedProviderCall[] { bashCommands: [], timestamp: '2026-06-19T11:34:14.140631+00:00', speed: 'standard', - deduplicationKey: `zerostack:${abcPath}:2026-06-19T11:34:14.140631+00:00:sess-abc`, + deduplicationKey: `zerostack:${expectedSourceRef(abcPath)}:2026-06-19T11:34:14.140631+00:00:sess-abc`, userMessage: 'hello, what is this repo about?', sessionId: 'sess-abc', project: 'myproject', @@ -60,7 +68,7 @@ function golden(dir: string): ParsedProviderCall[] { bashCommands: [], timestamp: '2026-06-20T09:00:00.000000+00:00', speed: 'standard', - deduplicationKey: `zerostack:${arrayPath}:2026-06-20T09:00:00.000000+00:00:sess-array`, + deduplicationKey: `zerostack:${expectedSourceRef(arrayPath)}:2026-06-20T09:00:00.000000+00:00:sess-array`, userMessage: 'part one part two', sessionId: 'sess-array', project: 'another', @@ -85,6 +93,16 @@ describe('zerostack bridge — fixture parity', () => { expect(await collect()).toEqual(golden(FIXTURE_DIR)) }) + it('the dedup key is an opaque fingerprint, never the source path', async () => { + const calls = await collect() + expect(calls.length).toBeGreaterThan(0) // non-vacuous + for (const call of calls) { + expect(call.deduplicationKey).toMatch(/^zerostack:[0-9a-f]{16}:/) + expect(call.deduplicationKey).not.toContain(FIXTURE_DIR) + expect(call.deduplicationKey).not.toContain('zerostack-parity') + } + }) + it('the priced output survives the pricing pass with only costUSD added', async () => { const raw = await collect() raw.map(priceProviderCall).forEach((call, i) => { diff --git a/packages/cli/tests/session-cache.test.ts b/packages/cli/tests/session-cache.test.ts index b10133246..7daa64d19 100644 --- a/packages/cli/tests/session-cache.test.ts +++ b/packages/cli/tests/session-cache.test.ts @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHash } from 'crypto' -import { readFile, rm, writeFile, mkdir } from 'fs/promises' +import { mkdtemp, readFile, rm, writeFile, mkdir } from 'fs/promises' import { existsSync } from 'fs' import { tmpdir } from 'os' import { basename, join } from 'path' @@ -296,6 +296,64 @@ describe('computeEnvFingerprint', () => { expect(computeEnvFingerprint('opencode')).not.toBe(preBumpFingerprint('opencode')) expect(computeEnvFingerprint('kilo-code')).not.toBe(preBumpFingerprint('kilo-code', 'worktree-project-grouping-v1')) }) + + it('every dedup-key-hygiene provider carries a parse version so cached old-shape keys re-derive', () => { + // The six sourceRef providers changed their dedup key shape (raw source + // path -> keyed fingerprint) and copilot's JetBrains digest changed value + // (sha256 -> HMAC). Without an entry here the env fingerprint would not + // change, a warm session cache would keep serving the old keys, and those + // keys seed the dedup sets — re-ingesting the same records under the new + // shape. The comparison baseline is a provider with no entry and no env + // vars, whose fingerprint omits the `parser=` component entirely. + for (const provider of ['codebuff', 'zerostack', 'pi', 'omp', 'grok', 'lingtai-tui', 'copilot']) { + expect(computeEnvFingerprint(provider), provider).not.toBe(computeEnvFingerprint('unknown-provider')) + } + }) +}) + +// ── computeEnvFingerprint: privacy-key binding ───────────────────────── + +// The six sourceRef providers derive their dedup keys from the per-install +// privacy key. A parse version cannot see that key change — a lost, rotated, or +// (unwritable config dir) ephemeral key would silently produce keys that never +// match the cached ones, so every record re-ingests as new. computeEnvFingerprint +// therefore folds a digest of the key in for exactly those providers. +describe('computeEnvFingerprint — privacy-key binding', () => { + async function fingerprintUnderKey(key: string, providers: string[]): Promise { + const home = await mkdtemp(join(tmpdir(), 'codeburn-envfp-')) + await mkdir(join(home, '.config', 'codeburn'), { recursive: true }) + await writeFile(join(home, '.config', 'codeburn', 'privacy-key'), key + '\n') + process.env.HOME = home + // Fresh module registry so privacy-key.ts re-reads the file instead of + // serving its memoized key — this is what a new process would do. + vi.resetModules() + const mod = await import('../src/session-cache.js') + const out = providers.map(p => mod.computeEnvFingerprint(p)) + await rm(home, { recursive: true, force: true }) + return out + } + + const KEY_A = 'a1'.repeat(32) + const KEY_B = 'b2'.repeat(32) + + it('changes the fingerprint of a sourceRef provider and leaves other providers alone', async () => { + const providers = ['codebuff', 'claude'] + const underA = await fingerprintUnderKey(KEY_A, providers) + const underB = await fingerprintUnderKey(KEY_B, providers) + + // codebuff: key-derived dedup keys, so a different key must re-parse. + expect(underB[0]).not.toBe(underA[0]) + // claude: dedup keys owe nothing to the privacy key, so a rotation must NOT + // churn its cache. Without this half the test would pass on a fingerprint + // that folded the key in for everyone. + expect(underB[1]).toBe(underA[1]) + }) + + it('is stable when the key is unchanged', async () => { + const first = await fingerprintUnderKey(KEY_A, ['codebuff']) + const again = await fingerprintUnderKey(KEY_A, ['codebuff']) + expect(again[0]).toBe(first[0]) + }) }) // ── provider env overrides invalidate the fingerprint (#920) ───────────── diff --git a/packages/cli/tests/setup/fixed-privacy-key.ts b/packages/cli/tests/setup/fixed-privacy-key.ts new file mode 100644 index 000000000..62c61f43f --- /dev/null +++ b/packages/cli/tests/setup/fixed-privacy-key.ts @@ -0,0 +1,48 @@ +// Pins the host privacy key for tests that assert HMAC-derived literals. +// +// The bridge threads getHostPrivacyKey() into every rich decode, so dedup keys +// (sourceRefFingerprint, copilot's JetBrains per-turn HMAC) are derived from a +// RANDOM per-install key. Under tests/setup/env-isolation.ts HOME points at a +// fresh sandbox, so that key is minted at random on first use — which makes any +// golden that embeds a digest non-reproducible from one run to the next, and +// machine-dependent if HOME ever escaped the sandbox. +// +// Importing this module (side effect, before the first getHostPrivacyKey call) +// writes a FIXED key into the sandbox config dir, so the digests are constants +// a golden can pin. Import it FIRST in such a test file: ESM evaluates imports +// in declaration order, and privacy-key.ts memoizes the key on first read. +// +// Deliberately NOT an env-var override in src/: production has no reason to +// accept an externally supplied privacy key, and adding that knob would be a +// far worse hole than the goldens are worth. +import { createHmac } from 'node:crypto' +import { mkdirSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +import { normalizePath } from '@codeburn/core' + +/** The pinned key every digest literal in the goldens is derived under. */ +export const FIXED_PRIVACY_KEY = 'c0de6b12'.repeat(8) + +const configDir = join(homedir(), '.config', 'codeburn') +mkdirSync(configDir, { recursive: true }) +writeFileSync(join(configDir, 'privacy-key'), FIXED_PRIVACY_KEY + '\n', { mode: 0o600 }) + +/** + * The source-ref fingerprint a dedup key must contain, re-derived INDEPENDENTLY + * of the function under test: HMAC-SHA256(FIXED_PRIVACY_KEY, "source:" + + * normalized path), first 16 hex chars — core's fingerprint encoding written + * out longhand. A golden that called `sourceRefFingerprint` instead would pin + * nothing: it would compute the expected value with the same function, under + * the same key, that production uses, so any change to either side would move + * both together. This version fails if the domain tag, the key, or the + * truncation ever change. `normalizePath` is reused deliberately — it is the + * input spelling, not the fingerprint encoding, that it owns. + */ +export function expectedSourceRef(sourcePath: string): string { + return createHmac('sha256', FIXED_PRIVACY_KEY) + .update(`source:${normalizePath(sourcePath)}`) + .digest('hex') + .slice(0, 16) +} diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts index 10a6190e0..f2de01bb9 100644 --- a/packages/core/src/contracts.ts +++ b/packages/core/src/contracts.ts @@ -20,7 +20,21 @@ export interface DecodeContext { privacyKey: string /** The provider whose records these are. */ providerId: string - /** An opaque fingerprint of the source (file/stream) being decoded. */ + /** + * The host's absolute filesystem path to the source being decoded — NOT an + * opaque fingerprint. Decoders may use it to derive session/chat identity + * (a chat directory name, a session file's basename), but the RAW value must + * never cross into an observation output — and dedupKey is an observation + * output: it is a field on CallObservation that ships on the envelope, so + * folding the raw path into a dedup key is a leak. A decoder that needs an + * opaque form of the source in a dedup key or identity must fingerprint it + * first via fingerprint.ts (`sourceRefFingerprint` — keyed HMAC-SHA256, + * decision D1; the key is required and an empty key throws, so the ref can + * never degrade to an unkeyed digest). Every fingerprint/ref field on the + * envelope (sessionRef, projectRef, gitBranchRef, resource refs, and the + * dedupKey's source component) is HMAC-derived via fingerprint.ts with the + * host privacyKey, which the CLI bridge threads from getHostPrivacyKey(). + */ sourceRef: string } diff --git a/packages/core/src/fingerprint.ts b/packages/core/src/fingerprint.ts index 9b2947dd5..ea9087532 100644 --- a/packages/core/src/fingerprint.ts +++ b/packages/core/src/fingerprint.ts @@ -13,7 +13,7 @@ import { createHmac } from 'node:crypto' const FINGERPRINT_LEN = 16 /** Domain-separation prefixes so the same string in different roles differs. */ -type Domain = 'session' | 'project' | 'branch' | 'resource' +type Domain = 'session' | 'project' | 'branch' | 'resource' | 'source' /** Field separator for composite HMAC inputs (ASCII Unit Separator). */ const SEP = String.fromCharCode(0x1f) @@ -182,6 +182,22 @@ export function branchRef(privacyKey: string, branch: string): string { return hmac(privacyKey, 'branch', branch) } +/** + * Fingerprint the source path for dedup-key / identity derivation. The raw + * absolute path must never cross into an observation output, but a decoder + * that needs a stable opaque form of it (e.g. inside a dedupKey, which ships + * on the envelope) may use this. Keyed HMAC-SHA256 (decision D1) under the + * caller-supplied privacyKey: the key is REQUIRED and an empty key throws + * (like every other fingerprint in this module), so a source ref can never + * silently degrade to an unkeyed, dictionary-attackable digest. With the host + * key the ref is host-scoped — not brute-forceable, and not comparable across + * hosts. The path is normalized first, so a Windows path and its POSIX + * spelling fingerprint identically. + */ +export function sourceRefFingerprint(privacyKey: string, sourceRef: string): string { + return hmac(privacyKey, 'source', normalizePath(sourceRef)) +} + export type CommandFamily = | 'git' | 'test' diff --git a/packages/core/src/providers/codebuff/decode.ts b/packages/core/src/providers/codebuff/decode.ts index 53c6e8300..4e09d9b4d 100644 --- a/packages/core/src/providers/codebuff/decode.ts +++ b/packages/core/src/providers/codebuff/decode.ts @@ -6,6 +6,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { CodebuffBlock, CodebuffChatMessage, @@ -162,7 +163,8 @@ export type CodebuffDecodeResult = { * Decode a Codebuff chat-messages.json array into rich, cost-free calls. A single * pass: user messages set the pending prompt for the next assistant call; assistant * messages that carry credits or token usage flush into a call. Dedup is keyed on - * `codebuff::` against the live `seenKeys` set (host-owned). + * `codebuff::` against the live `seenKeys` set (host-owned); + * the source path is fingerprinted, never emitted raw. */ export function decodeCodebuff({ records, context, seenKeys: liveSeen }: CodebuffDecodeInput): CodebuffDecodeResult { const seen = liveSeen ?? new Set() @@ -209,7 +211,9 @@ export function decodeCodebuff({ records, context, seenKeys: liveSeen }: Codebuf const timestamp = coerceTimestamp(msg.timestamp ?? msg.metadata?.timestamp) || fallbackTs const dedupId = msg.id ?? String(idx) - const dedupKey = `codebuff:${chatDir}:${dedupId}` + // The dedup key threads a FINGERPRINT of the chat directory (source path), + // never the raw path — dedupKey ships on the envelope. + const dedupKey = `codebuff:${sourceRefFingerprint(context.privacyKey, context.sourceRef)}:${dedupId}` if (seen.has(dedupKey)) continue seen.add(dedupKey) diff --git a/packages/core/src/providers/grok/decode.ts b/packages/core/src/providers/grok/decode.ts index 8ac68c71a..fdb116390 100644 --- a/packages/core/src/providers/grok/decode.ts +++ b/packages/core/src/providers/grok/decode.ts @@ -5,6 +5,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { GrokDecodedCall, GrokSessionRecords, GrokSignals, GrokSummary, GrokUpdate } from './types.js' // Grok Build tool ids mapped to the canonical vocabulary. Unknown ids pass @@ -124,7 +125,7 @@ export type GrokDecodeResult = { * The host owns file I/O and the live cross-file dedup set; this function is * pure over the supplied record. */ -export function decodeGrok({ records, seenKeys: liveSeen }: GrokDecodeInput): GrokDecodeResult { +export function decodeGrok({ records, context, seenKeys: liveSeen }: GrokDecodeInput): GrokDecodeResult { const seen = liveSeen ?? new Set() const session = records.find(isGrokSessionRecords) if (!session) return { calls: [], diagnostics: [] } @@ -142,7 +143,11 @@ export function decodeGrok({ records, seenKeys: liveSeen }: GrokDecodeInput): Gr const timestamp = summary.updated_at ?? summary.last_active_at ?? summary.created_at ?? '' const sessionId = summary.info?.id ?? sessionName - const dedupKey = `grok:${sourceDir}:${timestamp}:${sessionId}` + // The dedup key threads a FINGERPRINT of the session directory, never the raw + // path — dedupKey ships on the envelope, so the raw path must not cross into + // an observation output. (sessionName stays the basename-derived session + // identity; it is not a path.) + const dedupKey = `grok:${sourceRefFingerprint(context.privacyKey, sourceDir)}:${timestamp}:${sessionId}` if (seen.has(dedupKey)) return { calls: [], diagnostics: [] } seen.add(dedupKey) diff --git a/packages/core/src/providers/grok/types.ts b/packages/core/src/providers/grok/types.ts index 92d52ea81..5b6c0fbef 100644 --- a/packages/core/src/providers/grok/types.ts +++ b/packages/core/src/providers/grok/types.ts @@ -37,7 +37,8 @@ export type GrokSessionRecords = { summary: GrokSummary signals: GrokSignals | null updatesLines: string[] - /** Absolute session directory, used only for the host-side dedup key. */ + /** Absolute session directory; fingerprinted into the host-side dedup key, + * never emitted raw. */ sourceDir: string /** Basename of the session directory, used as a session id fallback. */ sessionName: string diff --git a/packages/core/src/providers/lingtai-tui/decode.ts b/packages/core/src/providers/lingtai-tui/decode.ts index fee074dce..164bef65e 100644 --- a/packages/core/src/providers/lingtai-tui/decode.ts +++ b/packages/core/src/providers/lingtai-tui/decode.ts @@ -2,6 +2,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { LingTaiTuiDecodedCall, LingTaiLedgerEntry, JsonObject } from './types.js' export type LingTaiTuiDecodeInput = { @@ -141,11 +142,18 @@ export function decodeLingTaiTui({ const runId = stringField(obj, 'run_id') ?? '' const sessionId = runId || `${agentId}:${sourceLabel}` const activity = activityForSource(sourceLabel) - // The dedup key threads the source ref (host ledger path) exactly as the - // pre-migration decode did — NOT the agent-dir projectPath. + // The dedup key threads a FINGERPRINT of the source ref (host ledger path), + // never the raw path — dedupKey ships on the envelope, so the raw path must + // not cross into an observation output. (The agent-dir projectPath is never + // used here.) The model component stays the RAW ledger value: normalization + // is an observation-boundary concern only (see schema.ts), and collapsing + // two distinct raw models to 'unknown' inside a dedup key would merge two + // real calls into one. The dedup key's own free-text surface is a + // schema-wide problem (every provider's key carries raw components) and is + // closed by hashing keys wholesale, not by normalizing one component. const dedupKey = [ 'lingtai-tui', - context.sourceRef, + sourceRefFingerprint(context.privacyKey, context.sourceRef), lineNo, timestamp, model, diff --git a/packages/core/src/providers/pi/decode.ts b/packages/core/src/providers/pi/decode.ts index a2b488d8d..597de5e4d 100644 --- a/packages/core/src/providers/pi/decode.ts +++ b/packages/core/src/providers/pi/decode.ts @@ -7,6 +7,7 @@ import { basename } from 'node:path' import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { PiDecodedCall, PiEntry } from './types.js' // Pi/OMP tool ids mapped to the canonical vocabulary. Unknown ids pass through. @@ -83,9 +84,11 @@ function normalizeContentBlocks( /** * Decode Pi/OMP session records into rich, cost-free calls. A single pass over * the entries: user messages set pending prompt; assistant messages with token - * usage flush into calls. Dedup is keyed on `::` - * against live seenKeys. `provider` ('pi' or 'omp') comes from - * `context.providerId`, since Pi and OMP share this exact decode. + * usage flush into calls. Dedup is keyed on + * `::` against live seenKeys — the + * source path is fingerprinted, never emitted raw (dedupKey ships on the + * envelope). `provider` ('pi' or 'omp') comes from `context.providerId`, since + * Pi and OMP share this exact decode. */ export function decodePi({ records, @@ -147,7 +150,11 @@ export function decodePi({ const model = msg.model ?? 'gpt-5' const responseId = msg.responseId ?? '' - const dedupKey = `${provider}:${sourcePath}:${responseId || entry.id || entry.timestamp || String(lineIdx)}` + // The dedup key threads a FINGERPRINT of the session file path, never the + // raw path — dedupKey ships on the envelope, so the raw path must not + // cross into an observation output. (The basename-derived sessionId below + // stays the host's session identity; it is not a path.) + const dedupKey = `${provider}:${sourceRefFingerprint(context.privacyKey, context.sourceRef)}:${responseId || entry.id || entry.timestamp || String(lineIdx)}` if (seen.has(dedupKey)) continue seen.add(dedupKey) diff --git a/packages/core/src/providers/zerostack/decode.ts b/packages/core/src/providers/zerostack/decode.ts index b7f051011..24f6cf3ed 100644 --- a/packages/core/src/providers/zerostack/decode.ts +++ b/packages/core/src/providers/zerostack/decode.ts @@ -5,6 +5,7 @@ import type { DecodeContext } from '../../contracts.js' import type { RecordDiagnostic } from '../../diagnostics.js' +import { sourceRefFingerprint } from '../../fingerprint.js' import type { ZerostackDecodedCall, ZerostackMessage, ZerostackSession } from './types.js' // Zerostack tool ids mapped to the canonical vocabulary. An id with @@ -48,8 +49,9 @@ export type ZerostackDecodeResult = { /** * Decode a Zerostack session file's record into a rich, cost-free call. * Zerostack has one record per session with cumulative token totals. The dedup - * key threads the source ref (host path) exactly as the pre-migration decode did: - * `zerostack:::`. + * key threads a FINGERPRINT of the source ref (host path), never the raw path: + * `zerostack:::`. The raw path + * must not cross into an observation output (dedupKey ships on the envelope). */ export function decodeZerostack({ records, @@ -72,7 +74,7 @@ export function decodeZerostack({ const timestamp = session.updated_at ?? session.created_at ?? '' const sessionId = session.id ?? sessionIdFallback ?? '' - const dedupKey = `zerostack:${context.sourceRef}:${timestamp}:${sessionId}` + const dedupKey = `zerostack:${sourceRefFingerprint(context.privacyKey, context.sourceRef)}:${timestamp}:${sessionId}` if (seen.has(dedupKey)) continue seen.add(dedupKey) From fb433df656494a14f34f4f1197f48330c843ca8d Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 10:18:42 -0700 Subject: [PATCH 03/11] fix(core): key the diagnostic detail and copilot's JetBrains digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A diagnostic detail was "any string without a path separator, capped at 200 chars". That let a path with no slashes, a command fragment, a prompt line, or an API key through verbatim — the rule was structural but it guarded the wrong structure. A detail is now the 16-hex HMAC-SHA256 fingerprint of the offending input under the host privacy key: identical failures dedupe, distinct failures differ, and no substring of the input survives. A caller with no key omits the field entirely rather than emitting an unkeyed digest, which is what `keyedDetail` is for. isolateRecords is the only place a detail is derived. Diagnostics a caller RETURNS are trusted for index and code only; any detail they carry is stripped, so an unkeyed or caller-invented digest cannot cross the boundary through a loose cast. The three decoders that reported malformed-json (opencode-session, vscode-cline, zed) are wired to it. Copilot's JetBrains dedup key embedded an unkeyed sha256 of the assistant's REPLY TEXT — dictionary-attackable for short replies ("OK", "Done.") — and it crosses into the envelope and the CLI ledger. It is now an HMAC under the host privacy key, with a `cli-shutdown-cost-v1-skills-dedup-key-hmac-v1` parse version: copilot is the sole durable provider, so its union-merge would otherwise keep the old-shape keys and append the new ones for the same records. Breaking for consumers: DIAGNOSTIC_DETAIL_MAX is gone, sanitizeDetail takes a key, DiagnosticDetail accepts only the fingerprint, and RecordOutcome.diagnostics may no longer carry a detail. --- .../tests/copilot-cache-invalidation.test.ts | 247 ++++++++++++++++++ .../tests/providers/copilot-bridge.test.ts | 45 ++-- packages/core/src/diagnostics.ts | 84 ++++-- packages/core/src/providers/copilot/decode.ts | 20 +- .../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/diagnostics.test.ts | 139 ++++++++-- .../tests/providers/copilot-decode.test.ts | 66 ++++- .../providers/opencode-session-decode.test.ts | 7 +- .../providers/vscode-cline-decode.test.ts | 19 ++ 11 files changed, 574 insertions(+), 91 deletions(-) create mode 100644 packages/cli/tests/copilot-cache-invalidation.test.ts 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 000000000..ef42b73cb --- /dev/null +++ b/packages/cli/tests/copilot-cache-invalidation.test.ts @@ -0,0 +1,247 @@ +// 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 + +// FIRST import: pins the host privacy key before anything reads it, so the +// keyed dedup key below is a constant rather than a per-run random. +import './setup/fixed-privacy-key.js' + +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 { FIXED_PRIVACY_KEY } from './setup/fixed-privacy-key.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 host privacy key, which the bridge threads from getHostPrivacyKey(). The +// key is pinned by the import at the top of this file, so the digest below is a +// constant — and it is re-derived here longhand rather than by calling the +// decoder, so this test pins the encoding instead of agreeing with itself. +function newShapeKey(): string { + const digest = createHmac('sha256', FIXED_PRIVACY_KEY).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 e304b0cf7..60afb5a57 100644 --- a/packages/cli/tests/providers/copilot-bridge.test.ts +++ b/packages/cli/tests/providers/copilot-bridge.test.ts @@ -1,3 +1,8 @@ +// FIRST import: pins the host privacy key before anything can read it. The +// JetBrains dedup keys below embed an HMAC keyed with it, so without the pin +// every `copilot:jb:...` literal here would be a random per-run value. +import '../setup/fixed-privacy-key.js' + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' import { join, basename, dirname } from 'path' @@ -724,7 +729,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:9907719707c3:1", "userMessage": "Conversation B" }, { @@ -745,7 +750,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:6b4d59c0e74e:1", "userMessage": "Conversation B" }, { @@ -766,7 +771,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:01e02c77fe38:1", "userMessage": "Conversation B" }, { @@ -787,16 +792,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:ada6313a0808: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:01e02c77fe38:1", + "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:6b4d59c0e74e:1", + "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:9907719707c3:1", + "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:ada6313a0808:1" ] const G6_GOLDEN: ParsedProviderCall[] = [ @@ -818,13 +823,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:31950016be99:1", "userMessage": "Understanding HBase Architecture" } ] const G6_KEYS = [ - "copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:a4d4d9a6916b:1" + "copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:31950016be99:1" ] const G7_GOLDEN: ParsedProviderCall[] = [ @@ -1167,7 +1172,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:ada6313a0808:1", "userMessage": "Conversation X7" }, { @@ -1188,14 +1193,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:a08d65507059: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:a08d65507059:1", + "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:ada6313a0808:1" ] const G14_GOLDEN: ParsedProviderCall[] = [ @@ -1217,13 +1222,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:5c217b7ca104:1", "userMessage": "Conversation X8" } ] const G14_KEYS = [ - "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:03e1bde7d7c0:1" + "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:5c217b7ca104:1" ] const G15_GOLDEN: ParsedProviderCall[] = [ @@ -1245,7 +1250,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:4db98cac6d89:1", "userMessage": "" }, { @@ -1266,14 +1271,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:95250cfdeede:1", "userMessage": "" } ] const G15_KEYS = [ - "copilot:jb:x17-store:95b22f4dffb0:1", - "copilot:jb:x17-store:9829e901954d:1" + "copilot:jb:x17-store:4db98cac6d89:1", + "copilot:jb:x17-store:95250cfdeede:1" ] const G16_GOLDEN: ParsedProviderCall[] = [ diff --git a/packages/core/src/diagnostics.ts b/packages/core/src/diagnostics.ts index 0034a3a51..f3addc750 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 be889de06..5b0e508b5 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,19 @@ function decodeJetBrains(envelope: Extract getHostPrivacyKey()), so the digest is keyed on every + // path; the key is per-install stable, so the dedup key stays stable + // across re-parses, which is what the durable copilot store needs. + const contentHash = createHmac('sha256', privacyKey).update(turn.replyText).digest('hex').slice(0, 12) const nth = (perContentIndex.get(`${convId}:${contentHash}`) ?? 0) + 1 perContentIndex.set(`${convId}:${contentHash}`, nth) const dedupKey = `copilot:jb:${convId}:${contentHash}:${nth}` @@ -1294,7 +1306,7 @@ export function decodeCopilot(input: CopilotDecodeInput): CopilotDecodeResult { switch (envelope.kind) { case 'jsonl': decodeJsonl(envelope, seen, calls); break case 'chatsession': decodeChatSession(envelope, seen, calls); break - case 'jetbrains': decodeJetBrains(envelope, seen, calls); break + case 'jetbrains': decodeJetBrains(envelope, seen, calls, input.context.privacyKey); break case 'otel': decodeOtel(envelope, seen, calls); break } return { calls, diagnostics: [] } diff --git a/packages/core/src/providers/opencode-session/decode.ts b/packages/core/src/providers/opencode-session/decode.ts index 95d2f2345..f7f962f22 100644 --- a/packages/core/src/providers/opencode-session/decode.ts +++ b/packages/core/src/providers/opencode-session/decode.ts @@ -3,7 +3,7 @@ // Pure record decode: no fs, no SQLite, no env, no process, no pricing, no // strip-ansi. The CLI hands core a tagged envelope; core returns rich calls. -import type { RecordDiagnostic } from '../../diagnostics.js' +import { keyedDetail, type RecordDiagnostic } from '../../diagnostics.js' import type { DecodeContext } from '../../contracts.js' import type { FileMessageData, @@ -157,6 +157,7 @@ function decodeSqlite( 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 adacbfd1f..c9fe2e455 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 d8355f300..014ed4ba5 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/diagnostics.test.ts b/packages/core/tests/diagnostics.test.ts index a3920cbc2..01906e19f 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/providers/copilot-decode.test.ts b/packages/core/tests/providers/copilot-decode.test.ts index 9eb54af5a..ca815254f 100644 --- a/packages/core/tests/providers/copilot-decode.test.ts +++ b/packages/core/tests/providers/copilot-decode.test.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto' + import { describe, it, expect } from 'vitest' import { collectJetBrainsRepoDirCandidates, decodeCopilot } from '../../src/providers/copilot/decode.js' import type { CopilotRecordEnvelope } from '../../src/providers/copilot/types.js' @@ -142,18 +144,23 @@ describe('decodeCopilot — chatsession arm', () => { }) }) +/** One JetBrains nitrite page holding a single ask-mode turn. */ +function jetBrainsRaw(replyText: string, userMessage: string): string { + const convGuid = '11111111-1111-1111-1111-111111111111' + const convRecord = `$${convGuid}t\x00\x04namesq\x00\x01?@\x00\x00w\x00\x00t\x00value t\x00${userMessage}t\x00\x06sourcet\x00copilotx` + const innerMd = { type: 'Markdown', data: JSON.stringify({ text: replyText, annotations: [] }) } + const valueMap: Record = { + 'a1b2c3d4-0000-0000-0000-000000000001': { type: 'Value', value: JSON.stringify(innerMd) }, + } + const blob = JSON.stringify({ __first__: { type: 'Subgraph', value: JSON.stringify(valueMap) } }) + return 'H:2,block:9,blockSize:1000,format:3\n' + + 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + + convRecord + '\n' + blob + '\n' + blob + '\n' +} + describe('decodeCopilot — jetbrains arm', () => { it('extracts an ask-mode turn and dedups by content hash', () => { - const convGuid = '11111111-1111-1111-1111-111111111111' - const convRecord = `$${convGuid}t\x00\x04namesq\x00\x01?@\x00\x00w\x00\x00t\x00value t\x00Hello Worldt\x00\x06sourcet\x00copilotx` - const innerMd = { type: 'Markdown', data: JSON.stringify({ text: 'Ask reply', annotations: [] }) } - const valueMap: Record = { - 'a1b2c3d4-0000-0000-0000-000000000001': { type: 'Value', value: JSON.stringify(innerMd) }, - } - const blob = JSON.stringify({ __first__: { type: 'Subgraph', value: JSON.stringify(valueMap) } }) - const raw = 'H:2,block:9,blockSize:1000,format:3\n' + - 'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' + - convRecord + '\n' + blob + '\n' + blob + '\n' + const raw = jetBrainsRaw('Ask reply', 'Hello World') const envelope: CopilotRecordEnvelope = { kind: 'jetbrains', sessionId: 'jb1', @@ -168,6 +175,45 @@ describe('decodeCopilot — jetbrains arm', () => { expect(calls[0]?.outputTokens).toBe(3) // ceil("Ask reply".length / 4) = 3 expect(calls[0]?.userMessage).toBe('Hello World') }) + + it('keys the per-turn content digest with the privacy key: same input, different keys => different dedup key', () => { + // The JetBrains dedup key embeds a digest of the assistant REPLY TEXT and + // ships on the observation envelope. An unkeyed sha256 of a short reply + // ("OK", "Done.") is dictionary-attackable, so the digest is an HMAC under + // the host privacy key. This test is the mutation gate: swap createHmac + // back to createHash in decode.ts and the two keys below become equal (and + // equal to the bare sha256 pinned at the end), so it fails. + const raw = jetBrainsRaw('Ask reply', 'Hello World') + const envelope = (): CopilotRecordEnvelope => ({ + kind: 'jetbrains', + sessionId: 'jb1', + mtime: '2026-07-17T10:00:00.000Z', + raw, + repoRootByDir: new Map(), + }) + const keyFor = (privacyKey: string): string => { + const { calls } = decodeCopilot({ + records: [envelope()], + context: { privacyKey, providerId: 'copilot', sourceRef: 'ref' }, + seenKeys: new Set(), + }) + expect(calls).toHaveLength(1) // non-vacuous: a decode that emits nothing proves nothing + return calls[0]!.deduplicationKey + } + + const keyA = keyFor('privacy-key-A') + const keyB = keyFor('privacy-key-B') + + expect(keyA).not.toBe(keyB) + expect(keyA).toBe(keyFor('privacy-key-A')) // deterministic under one key + expect(keyA).toMatch(/^copilot:jb:[^:]+:[0-9a-f]{12}:1$/) + + // Direct anti-createHash pin: the digest component must NOT be the unkeyed + // sha256 of the reply text. + const unkeyed = createHash('sha256').update('Ask reply').digest('hex').slice(0, 12) + expect(keyA).not.toContain(unkeyed) + expect(keyB).not.toContain(unkeyed) + }) }) describe('decodeCopilot — otel arm', () => { diff --git a/packages/core/tests/providers/opencode-session-decode.test.ts b/packages/core/tests/providers/opencode-session-decode.test.ts index 84e2c3d09..1497e9970 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 5654f1efb..bd3350077 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' }]) }) From 70b6bd9c9d76785cdf45a373b86ceb03d2f36fce Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 10:18:42 -0700 Subject: [PATCH 04/11] test(core): make the privacy guardrails prove what they claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smuggling guardrail asserted a planted prompt DID survive into the envelope ("identifier-exemption convention") — an inverted assertion certifying the hole as correct. It is gone. In its place: the model field is asserted clean, a positive control proves a legitimate identifier still crosses unchanged, and every hostile case asserts non-vacuously (the decode really produced calls) before asserting secret-freedom. Where a secret still ships, it says so instead of hiding it. `serializeWithoutDedupKeys` excludes exactly one field, names why (dedup-key hashing is the schema-wide follow-up), and is used only where a planted value rides a key component — so the exclusion is visible in the diff rather than buried in a passing assertion. New end-to-end coverage for the six source-ref providers: the raw path appears nowhere in the envelope and each key matches its fingerprint shape. import-smoke additionally calls one representative parser, decoder and detector body under the blocked-I/O preload, and the banned-module sanity check now covers every entry in the blocklist rather than fs alone. Its docstring states the scope honestly: import purity for every subpath, call-time purity for three representatives — a spot check, not a sweep. The architecture gate walks the published schemas structurally, so a loosened constraint fails the gate instead of passing unnoticed, and its synthetic self-test proves the walker can actually detect one. --- packages/core/tests/content-smuggling.test.ts | 1007 ++++++++++++++++- .../core/tests/harness/block-io-hooks.mjs | 23 +- .../core/tests/harness/block-io-register.mjs | 52 +- .../core/tests/harness/import-smoke-child.mjs | 91 +- packages/core/tests/import-smoke.test.ts | 75 +- .../tests/providers/codebuff-decode.test.ts | 9 +- .../core/tests/providers/grok-decode.test.ts | 5 + 7 files changed, 1222 insertions(+), 40 deletions(-) diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index ebfd145f2..55e76d6ec 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 { @@ -62,6 +70,23 @@ import type { CursorUserMessageRow, } from '../src/providers/cursor/index.js' +/** + * Serialize an envelope with every call's `dedupKey` blanked. + * + * dedupKey is the ONE envelope field this branch deliberately leaves carrying + * raw provider text. Model normalization is an observation-BOUNDARY rule only: + * normalizing a key component would merge two genuinely distinct calls into one + * and re-key every cached key, for a partial win — a dedup key is assembled + * from many raw components (paths, session ids, endpoints, ledger text), not + * just the model, so cleaning one of them proves nothing. Closing it properly + * means hashing dedup keys schema-wide, which is the follow-up this branch + * defers to. Until then the key is excluded EXPLICITLY, and named here, rather + * than the assertion being inverted to certify the hole as correct. + */ +function serializeWithoutDedupKeys(envelope: unknown): string { + return JSON.stringify(envelope, (key, value) => (key === 'dedupKey' ? '' : value)) +} + const here = dirname(fileURLToPath(import.meta.url)) const goldenEnvelope = JSON.parse( readFileSync(resolve(here, '..', 'tests/fixtures/golden-envelope.json'), 'utf8'), @@ -1052,7 +1077,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 +1085,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', () => { @@ -2081,11 +2122,13 @@ describe('content-smuggling guardrail: real kiro decode -> toObservations is sec describe('content-smuggling guardrail: real vercel-gateway decode -> toObservations is secret-free', () => { - // A hostile Vercel Gateway report planting every secret in the API fields the - // decode sees. The only free-text-capable API field is `model`; under the - // identifier-exemption convention model is an API identifier emitted by design, - // so the secret planted there is expected to remain. Every other secret must - // be absent from the envelope. + // A hostile Vercel Gateway report planting every secret the API fields can + // carry. `model` is externally supplied (the fetched report), so it is + // normalized at the observation boundary: a hostile prompt collapses to + // 'unknown' and the envelope still parses — a bad model in one call can + // never reject the whole batch. `day` is spliced into the synthesized + // timestamp, whose date-time constraint is the containment: a hostile day + // fails validation, and the error path names the timestamp field. function decodeAndMinimize() { const { calls } = decodeVercelGateway({ records: [ @@ -2109,8 +2152,20 @@ describe('content-smuggling guardrail: real vercel-gateway decode -> toObservati } } - it('produces a schema-valid envelope from the hostile report', () => { - expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + it('normalizes a hostile prompt in model to unknown; the envelope still parses (no whole-batch rejection)', () => { + const env = decodeAndMinimize() + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + const call = env.sessions[0]!.calls[0]! + expect(call.model).toBe('unknown') + }) + + it('the hostile envelope serializes with none of the planted secrets outside the dedup key', () => { + const serialized = serializeWithoutDedupKeys(decodeAndMinimize()) + // The prompt was planted in model, the abs path was passed as projectPath; + // neither may survive the observation boundary. The dedup key still carries + // the report's raw model — see serializeWithoutDedupKeys. + expect(serialized).not.toContain(SECRETS.prompt) + expect(serialized).not.toContain(SECRETS.absPath) }) it('is non-vacuous (at least one call)', () => { @@ -2119,20 +2174,40 @@ describe('content-smuggling guardrail: real vercel-gateway decode -> toObservati expect(callCount).toBeGreaterThan(0) }) - it('contains the model secret (identifier-exemption convention) and no other secrets', () => { - const serialized = JSON.stringify(decodeAndMinimize()) - expect(serialized).toContain(SECRETS.prompt) - expect(serialized).not.toContain(SECRETS.absPath) - expect(serialized).not.toContain(SECRETS.apiKey) - expect(serialized).not.toContain(SECRETS.commandLine) - expect(serialized).not.toContain(SECRETS.fileContent) + it('still lets a legitimate identifier-shaped model cross unchanged', () => { + const { calls } = decodeVercelGateway({ + records: [ + { + day: '2026-07-17', + model: 'openai/gpt-4o', + total_cost: 1.23, + input_tokens: 100, + output_tokens: 50, + }, + ], + }) + const { sessions } = toVercelGatewayObservations( + { sessionId: 'report-identifier', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'vercel-gateway' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(envelope.sessions[0]!.calls[0]!.model).toBe('openai/gpt-4o') + const serialized = JSON.stringify(envelope) + expect(serialized).toContain('openai/gpt-4o') }) // `day` is the report's only other string field, and it is NOT sanitized: the // decode splices it verbatim into the synthesized timestamp and the dedup key. // The envelope's date-time constraint is the containment, not the decode — a - // hostile `day` fails validation and therefore never ships. - it('rejects the envelope when a hostile day is spliced into the timestamp', () => { + // hostile `day` fails validation and therefore never ships. The error is + // asserted to name the timestamp field so this test cannot pass because an + // unrelated field broke. + it('rejects the envelope when a hostile day is spliced into the timestamp, and the error names the timestamp field', () => { const { calls } = decodeVercelGateway({ records: [{ day: SECRETS.apiKey, model: 'openai/gpt-4o', total_cost: 1, input_tokens: 1, output_tokens: 1 }], }) @@ -2148,5 +2223,901 @@ describe('content-smuggling guardrail: real vercel-gateway decode -> toObservati sessions, }) expect(parsed.success).toBe(false) + if (!parsed.success) { + const paths = parsed.error.issues.map(i => i.path.join('.')) + expect(paths.some(p => p.includes('timestamp'))).toBe(true) + } + }) +}) + +describe('content-smuggling guardrail: real zerostack decode -> toObservations is secret-free', () => { + // The zerostack dedup key threads the source ref. The raw host path must not + // cross into the envelope — dedupKey ships on the envelope — so the decoder + // fingerprints the source ref instead. A hostile sourceRef (the victim's + // absolute path) planted through the real decoder must appear nowhere in the + // serialized envelope. + const zerostackContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'zerostack', + sourceRef: SECRETS.absPath, + } + + function decodeAndMinimize() { + const records = [ + { + id: 'sess-hostile', + messages: [{ role: 'user', content: SECRETS.prompt }], + total_input_tokens: 100, + total_output_tokens: 50, + model: 'deepseek/deepseek-v4-pro', + created_at: '2026-07-17T10:00:00Z', + updated_at: '2026-07-17T10:01:00Z', + }, + ] + const { calls } = decodeZerostack({ records, context: zerostackContext }) + const { sessions } = toZerostackObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'zerostack' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('fingerprints the source ref into the dedup key; the raw path appears nowhere', () => { + const env = decodeAndMinimize() + const allDedupKeys = env.sessions.flatMap(s => s.calls.map(c => c.dedupKey)) + expect(allDedupKeys.length).toBeGreaterThan(0) + for (const key of allDedupKeys) { + expect(key).not.toContain(SECRETS.absPath) + expect(key).toMatch(/^zerostack:[0-9a-f]{16}:/) + } + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.absPath) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real lingtai-tui decode -> toObservations is secret-free', () => { + // The lingtai-tui dedup key threads the source ref (the ledger path). The raw + // host path must not cross into the envelope — dedupKey ships on the + // envelope — so the decoder fingerprints the source ref instead. + const lingTaiContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'lingtai-tui', + sourceRef: SECRETS.absPath, + } + + function decodeAndMinimize() { + const records = [ + JSON.stringify({ + ts: '2026-07-17T10:00:00.000Z', + input: 100, + output: 50, + model: 'gpt-5.5', + endpoint: 'example-endpoint', + source: 'main', + }), + ] + const { calls } = decodeLingTaiTui({ + records, + context: lingTaiContext, + agentId: 'agent-hostile', + fallbackModel: 'unknown', + fallbackEndpoint: 'unknown', + projectPath: SECRETS.absPath, + }) + const { sessions } = toLingTaiTuiObservations( + { sessionId: 'agent-hostile:main', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'lingtai-tui' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile ledger', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('fingerprints the source ref into the dedup key; the raw path appears nowhere', () => { + const env = decodeAndMinimize() + const allDedupKeys = env.sessions.flatMap(s => s.calls.map(c => c.dedupKey)) + expect(allDedupKeys.length).toBeGreaterThan(0) + for (const key of allDedupKeys) { + expect(key).not.toContain(SECRETS.absPath) + expect(key).toMatch(/^lingtai-tui:[0-9a-f]{16}:/) + } + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.absPath) + }) + + it('normalizes a hostile model at the observation boundary; only the dedup key still carries it', () => { + // The ledger's `model` field is provider free text. The observation + // boundary collapses it to 'unknown', so it cannot reach the envelope's + // model field nor reject the batch. The decoder's KEY is deliberately built + // from the RAW ledger value (see schema.ts: normalization is boundary-only), + // so the key is excluded from the secret-freedom assertion and named. + const records = [ + JSON.stringify({ + ts: '2026-07-17T10:00:00.000Z', + input: 100, + output: 50, + model: SECRETS.prompt, + endpoint: 'example-endpoint', + source: 'main', + }), + ] + const { calls } = decodeLingTaiTui({ + records, + context: lingTaiContext, + agentId: 'agent-hostile', + fallbackModel: 'unknown', + fallbackEndpoint: 'unknown', + projectPath: SECRETS.absPath, + }) + const { sessions } = toLingTaiTuiObservations( + { sessionId: 'agent-hostile:main', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'lingtai-tui' }, + ) + const env = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + expect(env.sessions[0]!.calls[0]!.model).toBe('unknown') + expect(serializeWithoutDedupKeys(env)).not.toContain(SECRETS.prompt) + // The real fix this provider carries: the source path is fingerprinted into + // the key, so the raw host path is gone even though the model is not. + expect(env.sessions[0]!.calls[0]!.dedupKey).toMatch(/^lingtai-tui:[0-9a-f]{16}:/) + expect(env.sessions[0]!.calls[0]!.dedupKey).not.toContain(SECRETS.absPath) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real pi/omp decode -> toObservations is secret-free', () => { + // The pi/omp dedup key threads the session file path. The raw host path must + // not cross into the envelope — dedupKey ships on the envelope — so the + // decoder fingerprints the source ref instead. A hostile sourceRef (the + // victim's absolute path) and a hostile display-name model planted through + // the real decoder must appear nowhere in the serialized envelope. + const piContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'pi', + sourceRef: SECRETS.absPath, + } + + function decodeAndMinimize() { + const records = [ + JSON.stringify({ + type: 'session', + id: 'sess-hostile', + timestamp: '2026-07-17T10:00:00.000Z', + }), + JSON.stringify({ + type: 'message', + id: 'msg-hostile-1', + timestamp: '2026-07-17T10:00:10.000Z', + message: { + role: 'assistant', + model: SECRETS.prompt, + responseId: 'resp-hostile-1', + content: [], + usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 }, + }, + }), + ] + const { calls } = decodePi({ records, context: piContext }) + const { sessions } = toPiObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'pi' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('fingerprints the source ref into the dedup key; the raw path appears nowhere', () => { + const env = decodeAndMinimize() + const allDedupKeys = env.sessions.flatMap(s => s.calls.map(c => c.dedupKey)) + expect(allDedupKeys.length).toBeGreaterThan(0) + for (const key of allDedupKeys) { + expect(key).not.toContain(SECRETS.absPath) + expect(key).toMatch(/^pi:[0-9a-f]{16}:/) + } + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.absPath) + }) + + it('normalizes a hostile display-name model out of the envelope', () => { + const env = decodeAndMinimize() + expect(env.sessions[0]!.calls[0]!.model).toBe('unknown') + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.prompt) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) +}) + +describe('content-smuggling guardrail: real grok decode -> toObservations is secret-free', () => { + // The grok dedup key threads the session directory. The raw host path must + // not cross into the envelope — dedupKey ships on the envelope — so the + // decoder fingerprints the session dir instead. A hostile sourceDir (the + // victim's absolute path) and a hostile display-name model planted through + // the real decoder must appear nowhere in the serialized envelope. + const grokContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'grok', + sourceRef: SECRETS.absPath, + } + + function decodeAndMinimize() { + const records = [ + { + summary: { + info: { id: 'sess-hostile', cwd: SECRETS.absPath }, + created_at: '2026-07-17T10:00:00.000Z', + updated_at: '2026-07-17T10:01:00.000Z', + current_model_id: SECRETS.prompt, + session_summary: 'hostile', + }, + signals: null, + updatesLines: [ + JSON.stringify({ params: { _meta: { totalTokens: 1000, promptId: 'p1' } } }), + JSON.stringify({ params: { _meta: { totalTokens: 1500, promptId: 'p1' } } }), + ], + sourceDir: SECRETS.absPath, + sessionName: 'sess-hostile', + project: 'hostile', + }, + ] + const { calls } = decodeGrok({ records, context: grokContext }) + const { sessions } = toGrokObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'grok' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('fingerprints the session dir into the dedup key; the raw path appears nowhere', () => { + const env = decodeAndMinimize() + const allDedupKeys = env.sessions.flatMap(s => s.calls.map(c => c.dedupKey)) + expect(allDedupKeys.length).toBeGreaterThan(0) + for (const key of allDedupKeys) { + expect(key).not.toContain(SECRETS.absPath) + expect(key).toMatch(/^grok:[0-9a-f]{16}:/) + } + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.absPath) + }) + + it('normalizes a hostile display-name model out of the envelope', () => { + const env = decodeAndMinimize() + expect(env.sessions[0]!.calls[0]!.model).toBe('unknown') + const serialized = JSON.stringify(env) + expect(serialized).not.toContain(SECRETS.prompt) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) +}) + +// ── 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/harness/block-io-hooks.mjs b/packages/core/tests/harness/block-io-hooks.mjs index 6fa50acca..b9af20d81 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 5297dd902..44753696c 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 5a0fbed14..888dd62ab 100644 --- a/packages/core/tests/harness/import-smoke-child.mjs +++ b/packages/core/tests/harness/import-smoke-child.mjs @@ -1,8 +1,15 @@ // 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. Scope: import purity is proven for +// EVERY exports subpath; call-time purity is proven for these THREE +// representatives only. It is a spot check — a different provider's decoder +// that opens a file when called is still not covered here. import { pathToFileURL } from 'node:url' const targets = process.argv.slice(2) @@ -45,4 +52,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 587658374..3da5bc8d2 100644 --- a/packages/core/tests/import-smoke.test.ts +++ b/packages/core/tests/import-smoke.test.ts @@ -9,12 +9,19 @@ 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 at any exports subpath, nor when ONE REPRESENTATIVE parser, + * decoder, and detector body is actually called (claude parseJsonlLine, codex + * decodeCodex, junkReadsDetector). Import purity is proven for every subpath; + * call-time purity is proven for those three only — it is a spot check, not a + * per-provider sweep. 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, '..') @@ -67,15 +74,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/codebuff-decode.test.ts b/packages/core/tests/providers/codebuff-decode.test.ts index 660827901..a5bb4305e 100644 --- a/packages/core/tests/providers/codebuff-decode.test.ts +++ b/packages/core/tests/providers/codebuff-decode.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { decodeCodebuff, toObservations, type CodebuffChatMessage } from '../../src/providers/codebuff/index.js' import { ObservationEnvelope } from '../../src/observations.js' import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import { sourceRefFingerprint } from '../../src/fingerprint.js' import type { DecodeContext } from '../../src/contracts.js' const context: DecodeContext = { privacyKey: 'k', providerId: 'codebuff', sourceRef: '/data/manicode/projects/alpha/chats/2026-04-14T10-00-00.000Z' } @@ -93,7 +94,13 @@ describe('codebuff rich decode (moved to @codeburn/core)', () => { expect(first!.rawBashCommands).toEqual(['npm test']) expect(first!.credits).toBe(42) expect(first!.userMessage).toBe('implement the feature') - expect(first!.deduplicationKey).toBe(`codebuff:${context.sourceRef}:a1`) + // The dedup key threads a FINGERPRINT of the chat directory (the source + // ref), never the raw absolute path: dedupKey ships on the envelope, so + // the raw-path form (`codebuff:${context.sourceRef}:a1`) was the defect — + // do not restore it. The expectation is DERIVED from the same fingerprint + // function the decoder uses, so the golden pins the contract, not a + // literal. + expect(first!.deduplicationKey).toBe(`codebuff:${sourceRefFingerprint(context.privacyKey, context.sourceRef)}:a1`) expect(second!.model).toBe('claude-haiku-4-5-20251001') expect(second!.inputTokens).toBe(5000) diff --git a/packages/core/tests/providers/grok-decode.test.ts b/packages/core/tests/providers/grok-decode.test.ts index 6fd3f37ad..2f589086d 100644 --- a/packages/core/tests/providers/grok-decode.test.ts +++ b/packages/core/tests/providers/grok-decode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { decodeGrok, toObservations } from '../../src/providers/grok/index.js' +import { sourceRefFingerprint } from '../../src/fingerprint.js' import { ObservationEnvelope } from '../../src/observations.js' import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' import type { DecodeContext } from '../../src/contracts.js' @@ -92,6 +93,10 @@ describe('grok rich decode (moved to @codeburn/core)', () => { expect(call.sessionId).toBe('sess-1') expect(call.project).toBe('project') expect(call.projectPath).toBe('/Users/test/project') + // The dedup key threads a FINGERPRINT of the session dir, never the raw + // path — dedupKey ships on the envelope. + expect(call.deduplicationKey).toBe(`grok:${sourceRefFingerprint('k', '/sessions/%2FUsers%2Ftest/sess-1')}:2026-06-19T11:31:12.282793Z:sess-1`) + expect(call.deduplicationKey).not.toContain('/sessions/') }) it('sums fresh input across compaction segments', () => { From e1234b0312f11d8a1e15de10b7d4ea716f858d29 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 10:18:52 -0700 Subject: [PATCH 05/11] chore: 0.10.0 and changelog for the observation-boundary hardening @codeburn/core takes a minor bump under 0.x semver: the diagnostics API is a breaking change (DIAGNOSTIC_DETAIL_MAX deleted, sanitizeDetail signature, DiagnosticDetail retightened) and observation-0.2.0.json is tightened in place, so an archived pre-hardening envelope whose model held a display name no longer validates against that same version string. check-workspace-versions requires root, cli and core to move together, so all three (and package-lock.json) go to 0.10.0. Publish order at the next publish is core before cli, as always. --- CHANGELOG.md | 9 +++++++++ package-lock.json | 10 +++++----- package.json | 2 +- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5c3c959..cc13f7330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ ### Added (CLI) - `codeburn sync push --attribution` (opt-in): sends git attribution spans — the session→commit correlation from `codeburn yield` (`codeburn.session.attribution` and `codeburn.commit` span types with normalized repo remote, commit SHAs, merged/reverted state, and PR links). Nothing new is sent without the flag; local-only repos and Windows filesystem paths are never emitted as repo identities, and sessions whose project path no longer resolves never inherit the push-time working directory's repo. See docs/sync/README.md "Git attribution". +### Changed (@codeburn/core — breaking, 0.9.20 → 0.10.0) +- **The `model` field is bounded at the observation boundary.** `model` (and the optional `pricingModel`) on a `CallObservation` must now match the identifier charset `[A-Za-z0-9._:/@-]`, max 128 chars, in both the zod schema and the published `schemas/observation-0.2.0.json`. Every provider's `toObservations` normalizes through `normalizeModelIdentifier`, so a provider display name — Antigravity's `"Gemini 3.5 Flash (High)"`, Warp's and Devin's display strings — surfaces as `unknown` in an emitted observation rather than rejecting the whole envelope. **CLI output is unaffected:** normalization happens only at the observation boundary, so terminal, dashboard, menubar, and desktop numbers and model labels are byte-identical to before. The bound is a tightening of `observation-0.2.0` in place; an ARCHIVED pre-hardening 0.2.0 envelope whose `model` held a display name now fails validation against that same version string and must be re-normalized before re-validating. +- **The diagnostics API changed shape.** `DIAGNOSTIC_DETAIL_MAX` is deleted, `sanitizeDetail(value)` becomes `sanitizeDetail(value, key)` and returns a 16-hex keyed fingerprint instead of a truncated message, `DiagnosticDetail` now accepts only that fingerprint, `RecordOutcome.diagnostics` may no longer carry a `detail`, and `isolateRecords` takes an optional `privacyKey`. A diagnostic now carries a record index, a controlled code, and a keyed fingerprint — never content. The previous rule (strip path separators, cap at 200 chars) let any slash-free path, command fragment, prompt line, or API key through verbatim. Callers with no key omit the field entirely; core never degrades to an unkeyed digest. + +### Fixed (privacy) +- **Dedup keys no longer carry raw host paths.** `dedupKey` ships on the observation envelope, and the codebuff, zerostack, pi, omp, grok and LingTai TUI decoders were folding the absolute source path (a chat directory, a session file) straight into it. They now fold a keyed HMAC fingerprint of that path (`sourceRefFingerprint`, new `source` domain), and the CLI bridge threads the real per-install privacy key into the rich decode instead of an empty one. (#933, thanks @ozymandiashh) +- **Copilot's JetBrains dedup key is keyed.** The per-turn digest was an unkeyed SHA-256 of the assistant's reply text — dictionary-attackable for short replies ("OK", "Done.") — and it crosses into the envelope and the CLI ledger. It is now an HMAC under the host privacy key. (#935, thanks @ozymandiashh) +- Both changes alter dedup-key VALUES, so the affected providers carry a parse-version bump (`source-ref-fingerprint-v1`; copilot `…-dedup-key-hmac-v1`) that forces one re-parse and drops the old-shape keys from the warm session cache instead of re-ingesting the same records under two shapes. The six fingerprint providers additionally fold a digest of the privacy key into their env fingerprint, so a lost or rotated key also forces a clean re-parse. No totals change: no call is added, dropped, or repriced, and the daily cache needs no bump. + ### Changed (CLI) - **`codeburn sync` re-keys device, span, and trace identity.** Device, span, and trace ids are now domain-separated HMAC digests under the per-install privacy key rather than bare hashes. This is a one-time identity re-key: a sync backend sees a new device identity after upgrade, and spans pushed before the upgrade won't correlate with spans pushed after it. `sync push` also now hard-aborts — instead of degrading to an ephemeral, unstable key — when the config dir is unwritable or the on-disk privacy-key file is corrupt. diff --git a/package-lock.json b/package-lock.json index 9087e8ac2..00404b53a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeburn-monorepo", - "version": "0.9.20", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeburn-monorepo", - "version": "0.9.20", + "version": "0.10.0", "license": "MIT", "workspaces": [ "packages/*" @@ -4116,10 +4116,10 @@ }, "packages/cli": { "name": "codeburn", - "version": "0.9.20", + "version": "0.10.0", "license": "MIT", "dependencies": { - "@codeburn/core": "0.9.20", + "@codeburn/core": "0.10.0", "@modelcontextprotocol/sdk": "^1.29.0", "bonjour-service": "^1.4.1", "chalk": "^5.4.1", @@ -4150,7 +4150,7 @@ }, "packages/core": { "name": "@codeburn/core", - "version": "0.9.20", + "version": "0.10.0", "license": "MIT", "dependencies": { "zod": "^3.25.76" diff --git a/package.json b/package.json index 738923e8a..c39ab0f6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeburn-monorepo", - "version": "0.9.20", + "version": "0.10.0", "description": "CodeBurn monorepo — CLI (packages/cli) + @codeburn/core (packages/core)", "private": true, "type": "module", diff --git a/packages/cli/package.json b/packages/cli/package.json index 16da0468b..5a8301db8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codeburn", - "version": "0.9.20", + "version": "0.10.0", "description": "See where your AI spend goes - by task, tool, model, and project", "type": "module", "main": "./dist/cli.js", @@ -51,7 +51,7 @@ }, "homepage": "https://github.com/getagentseal/codeburn#readme", "dependencies": { - "@codeburn/core": "0.9.20", + "@codeburn/core": "0.10.0", "@modelcontextprotocol/sdk": "^1.29.0", "bonjour-service": "^1.4.1", "chalk": "^5.4.1", diff --git a/packages/core/package.json b/packages/core/package.json index 6260aa8c5..2cfc0256a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@codeburn/core", - "version": "0.9.20", + "version": "0.10.0", "description": "Pure decode/detect engine for CodeBurn: provider session-log decoding, content-minimized observations, and detector contracts. No I/O, no pricing \u2014 hosts supply both.", "type": "module", "sideEffects": false, From 5c4a8a5299987c77fa5b77ebb5e1b7c1584c2d6a Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 10:38:17 -0700 Subject: [PATCH 06/11] chore: hold workspace versions at 0.9.20, defer the bump to release Maintainer call: the diagnostics/observation breaking changes are documented in the CHANGELOG now, but the version number moves at the next actual publish (core before cli), not on the branch. The 0.x bump owed for the core API break is recorded in the CHANGELOG heading. --- CHANGELOG.md | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc13f7330..499044b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added (CLI) - `codeburn sync push --attribution` (opt-in): sends git attribution spans — the session→commit correlation from `codeburn yield` (`codeburn.session.attribution` and `codeburn.commit` span types with normalized repo remote, commit SHAs, merged/reverted state, and PR links). Nothing new is sent without the flag; local-only repos and Windows filesystem paths are never emitted as repo identities, and sessions whose project path no longer resolves never inherit the push-time working directory's repo. See docs/sync/README.md "Git attribution". -### Changed (@codeburn/core — breaking, 0.9.20 → 0.10.0) +### Changed (@codeburn/core — breaking; version bump deferred to the next release, which must take at least a minor under 0.x) - **The `model` field is bounded at the observation boundary.** `model` (and the optional `pricingModel`) on a `CallObservation` must now match the identifier charset `[A-Za-z0-9._:/@-]`, max 128 chars, in both the zod schema and the published `schemas/observation-0.2.0.json`. Every provider's `toObservations` normalizes through `normalizeModelIdentifier`, so a provider display name — Antigravity's `"Gemini 3.5 Flash (High)"`, Warp's and Devin's display strings — surfaces as `unknown` in an emitted observation rather than rejecting the whole envelope. **CLI output is unaffected:** normalization happens only at the observation boundary, so terminal, dashboard, menubar, and desktop numbers and model labels are byte-identical to before. The bound is a tightening of `observation-0.2.0` in place; an ARCHIVED pre-hardening 0.2.0 envelope whose `model` held a display name now fails validation against that same version string and must be re-normalized before re-validating. - **The diagnostics API changed shape.** `DIAGNOSTIC_DETAIL_MAX` is deleted, `sanitizeDetail(value)` becomes `sanitizeDetail(value, key)` and returns a 16-hex keyed fingerprint instead of a truncated message, `DiagnosticDetail` now accepts only that fingerprint, `RecordOutcome.diagnostics` may no longer carry a `detail`, and `isolateRecords` takes an optional `privacyKey`. A diagnostic now carries a record index, a controlled code, and a keyed fingerprint — never content. The previous rule (strip path separators, cap at 200 chars) let any slash-free path, command fragment, prompt line, or API key through verbatim. Callers with no key omit the field entirely; core never degrades to an unkeyed digest. diff --git a/package-lock.json b/package-lock.json index 00404b53a..9087e8ac2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeburn-monorepo", - "version": "0.10.0", + "version": "0.9.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeburn-monorepo", - "version": "0.10.0", + "version": "0.9.20", "license": "MIT", "workspaces": [ "packages/*" @@ -4116,10 +4116,10 @@ }, "packages/cli": { "name": "codeburn", - "version": "0.10.0", + "version": "0.9.20", "license": "MIT", "dependencies": { - "@codeburn/core": "0.10.0", + "@codeburn/core": "0.9.20", "@modelcontextprotocol/sdk": "^1.29.0", "bonjour-service": "^1.4.1", "chalk": "^5.4.1", @@ -4150,7 +4150,7 @@ }, "packages/core": { "name": "@codeburn/core", - "version": "0.10.0", + "version": "0.9.20", "license": "MIT", "dependencies": { "zod": "^3.25.76" diff --git a/package.json b/package.json index c39ab0f6d..738923e8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeburn-monorepo", - "version": "0.10.0", + "version": "0.9.20", "description": "CodeBurn monorepo — CLI (packages/cli) + @codeburn/core (packages/core)", "private": true, "type": "module", diff --git a/packages/cli/package.json b/packages/cli/package.json index 5a8301db8..16da0468b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codeburn", - "version": "0.10.0", + "version": "0.9.20", "description": "See where your AI spend goes - by task, tool, model, and project", "type": "module", "main": "./dist/cli.js", @@ -51,7 +51,7 @@ }, "homepage": "https://github.com/getagentseal/codeburn#readme", "dependencies": { - "@codeburn/core": "0.10.0", + "@codeburn/core": "0.9.20", "@modelcontextprotocol/sdk": "^1.29.0", "bonjour-service": "^1.4.1", "chalk": "^5.4.1", diff --git a/packages/core/package.json b/packages/core/package.json index 2cfc0256a..6260aa8c5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@codeburn/core", - "version": "0.10.0", + "version": "0.9.20", "description": "Pure decode/detect engine for CodeBurn: provider session-log decoding, content-minimized observations, and detector contracts. No I/O, no pricing \u2014 hosts supply both.", "type": "module", "sideEffects": false, From 52e6678cd3c5f99139320c3a53b2f73763ae4e8f Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 10:48:21 -0700 Subject: [PATCH 07/11] fix(cli): select the privacy-key fold set explicitly, not by substring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeEnvFingerprint decided which providers re-parse on a privacy-key change by sniffing 'source-ref-fingerprint-v1' out of the parse-version string. That silently missed copilot. Copilot's dedup keys are just as key-derived — createHmac(privacyKey) over the JetBrains reply text — but its parse version reads '…-dedup-key-hmac-v1', so its fingerprint held still across a key rotation. Copilot is the sole DURABLE provider: its union-merge never deletes cached turns, it appends any turn whose key is not already cached. A fingerprint that does not move therefore does not merely re-parse — it keeps the old key AND appends the new one for the same turn. Two real turns became three calls. And a corrupt or unreadable key file yields a fresh ephemeral key in EVERY process, so that inflation compounded on every run. KEY_DERIVED_PROVIDERS lists the seven explicitly. A provider is in the set because of what its decoder does, which no substring of a version label can know. The fold also moves out from under `if (parseVersion)`: the key dependency comes from the decoder, not from having a parse version entry. The bridge comment claimed the fold covered "the affected providers" and that the ephemeral-key fallback only happens when the cache cannot persist. Both were false — the config dir (~/.config/codeburn) and the cache dir (~/.cache/codeburn, or CODEBURN_CACHE_DIR) are different, separately overridable directories, so a writable cache plus an unreadable key file means a new key every process. Corrected. Tests: the rotation pair now covers all seven key-derived providers and five unaffected ones, plus a dedicated copilot case that pins the exact regression (parse version has no source-ref token, provider is durable, fingerprint must still move). --- packages/cli/src/providers/bridge.ts | 18 ++++++-- packages/cli/src/session-cache.ts | 52 +++++++++++++-------- packages/cli/tests/session-cache.test.ts | 59 ++++++++++++++++++------ 3 files changed, 93 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/providers/bridge.ts b/packages/cli/src/providers/bridge.ts index 5580c6072..504fa55a5 100644 --- a/packages/cli/src/providers/bridge.ts +++ b/packages/cli/src/providers/bridge.ts @@ -93,11 +93,19 @@ export function createBridgedProvider(spec: BridgedProviderSpec): // digest, so the bridge has to supply the real one. // getHostPrivacyKey() is per-install stable (persisted, like the // optimize detectors use), so dedup keys stay stable across runs and - // the session-cache dedup semantics are unchanged; it falls back to a - // per-process key only when the config dir is unwritable, in which - // case the session cache cannot persist either. A lost or rotated key - // is caught by computeEnvFingerprint, which folds a digest of the key - // into the env fingerprint for the affected providers. + // the session-cache dedup semantics are unchanged. + // + // It degrades to a FRESH per-process key whenever the config dir + // (~/.config/codeburn) is unwritable OR the key file exists but is + // corrupt/unreadable — and that says nothing about whether the cache + // can persist: the cache lives in ~/.cache/codeburn (or + // CODEBURN_CACHE_DIR), a different directory, separately overridable. + // A writable cache plus an unreadable key file therefore means a new + // key every process. computeEnvFingerprint is what stops that from + // inflating totals: it folds a digest of the key into the fingerprint + // for every provider in KEY_DERIVED_PROVIDERS, so a rotated, lost, or + // per-process key rebuilds the section instead of re-ingesting the + // same records under keys the cache has never seen. const context: DecodeContext = { privacyKey: getHostPrivacyKey(), 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 fb6e3051b..01e8036c1 100644 --- a/packages/cli/src/session-cache.ts +++ b/packages/cli/src/session-cache.ts @@ -244,12 +244,31 @@ export const DURABLE_PROVIDER_NAMES: ReadonlySet = new Set(['copilot']) // entry/suffix below changes the provider's env fingerprint, which forces the // one-time re-parse that drops the old-shape keys. // -// SOURCE_REF_KEYED_PARSE_VERSION is the marker token for the six: their keys -// are derived from the per-install privacy key, which the parse version alone -// cannot see. computeEnvFingerprint folds a digest of that key in for exactly -// these providers, so a lost or rotated key forces a clean re-parse instead of -// silently accumulating duplicates in a warm cache. -export const SOURCE_REF_KEYED_PARSE_VERSION = 'source-ref-fingerprint-v1' +// A parse version cannot see the OTHER input those keys depend on: the random +// per-install privacy key. Rotate it, lose the file, or land on the ephemeral +// fallback and the freshly derived keys stop matching the cached ones, so every +// record re-ingests as new — silently, and for copilot (the sole durable +// provider, whose union-merge never deletes) permanently. Every provider whose +// dedup keys are derived from that key is listed HERE, explicitly, and +// computeEnvFingerprint folds a digest of the key in for exactly this set. +// +// Listed explicitly rather than sniffed out of the parse-version string: the +// first version of this used `parseVersion.includes('source-ref-fingerprint-v1')` +// and silently missed copilot, whose keys are just as key-derived but whose +// parse version says `dedup-key-hmac-v1`. A provider is in this set because of +// what its decoder DOES, which no substring of a version label can know. +// Add a provider here whenever its dedup key starts depending on the privacy +// key — the rotation test in session-cache.test.ts covers one member of each +// kind (source-ref and durable/copilot). +export const KEY_DERIVED_PROVIDERS: ReadonlySet = new Set([ + 'codebuff', + 'zerostack', + 'pi', + 'omp', + 'grok', + 'lingtai-tui', + 'copilot', +]) export const PROVIDER_PARSE_VERSIONS: Record = { // rich-session-capture-v1: parse-time capture of per-turn gitBranch, per-call @@ -313,18 +332,15 @@ export function computeEnvFingerprint(provider: string): string { const vars = PROVIDER_ENV_VARS[provider] ?? [] const parts = vars.map(v => `${v}=${process.env[v] ?? ''}`) const parseVersion = PROVIDER_PARSE_VERSIONS[provider] - if (parseVersion) { - parts.push(`parser=${parseVersion}`) - // Providers whose dedup keys are HMAC-derived from the per-install privacy - // key must re-parse when that key changes. The key file can be lost, - // rotated, or fall back to a per-process ephemeral key (unwritable config - // dir) with no parse-version change to notice it — and the cached keys - // would then never match the freshly derived ones, so every record would - // re-ingest as new. A digest of the key (never the key itself) rides the - // fingerprint for exactly those providers. - if (parseVersion.includes(SOURCE_REF_KEYED_PARSE_VERSION)) { - parts.push(`privacy-key=${createHash('sha256').update(getHostPrivacyKey()).digest('hex').slice(0, 16)}`) - } + if (parseVersion) parts.push(`parser=${parseVersion}`) + // Providers whose dedup keys are HMAC-derived from the per-install privacy + // key must re-parse when that key changes; a digest of the key (never the key + // itself) rides the fingerprint for exactly those. Deliberately NOT nested + // under `if (parseVersion)`: the key dependency comes from the decoder, not + // from having a parse-version entry, and a key-derived provider that ever + // loses its entry must still re-parse on rotation. + if (KEY_DERIVED_PROVIDERS.has(provider)) { + parts.push(`privacy-key=${createHash('sha256').update(getHostPrivacyKey()).digest('hex').slice(0, 16)}`) } return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16) } diff --git a/packages/cli/tests/session-cache.test.ts b/packages/cli/tests/session-cache.test.ts index 7daa64d19..d76d6a52c 100644 --- a/packages/cli/tests/session-cache.test.ts +++ b/packages/cli/tests/session-cache.test.ts @@ -15,6 +15,9 @@ import { type SessionCache, cleanupOrphanedTempFiles, computeEnvFingerprint, + DURABLE_PROVIDER_NAMES, + KEY_DERIVED_PROVIDERS, + PROVIDER_PARSE_VERSIONS, emptyCache, fingerprintFile, loadCache, @@ -313,11 +316,12 @@ describe('computeEnvFingerprint', () => { // ── computeEnvFingerprint: privacy-key binding ───────────────────────── -// The six sourceRef providers derive their dedup keys from the per-install -// privacy key. A parse version cannot see that key change — a lost, rotated, or -// (unwritable config dir) ephemeral key would silently produce keys that never -// match the cached ones, so every record re-ingests as new. computeEnvFingerprint -// therefore folds a digest of the key in for exactly those providers. +// Seven providers derive their dedup keys from the per-install privacy key: the +// six sourceRef ones, plus copilot (its JetBrains per-turn digest is an HMAC +// under that key). A parse version cannot see the key change — a lost, rotated, +// or ephemeral key silently produces keys that never match the cached ones, so +// every record re-ingests as new. computeEnvFingerprint therefore folds a digest +// of the key in for exactly KEY_DERIVED_PROVIDERS. describe('computeEnvFingerprint — privacy-key binding', () => { async function fingerprintUnderKey(key: string, providers: string[]): Promise { const home = await mkdtemp(join(tmpdir(), 'codeburn-envfp-')) @@ -336,17 +340,46 @@ describe('computeEnvFingerprint — privacy-key binding', () => { const KEY_A = 'a1'.repeat(32) const KEY_B = 'b2'.repeat(32) - it('changes the fingerprint of a sourceRef provider and leaves other providers alone', async () => { - const providers = ['codebuff', 'claude'] + it('rotating the key moves every key-derived provider and no other', async () => { + // Both halves matter. Without the "moves" half a fingerprint that ignored + // the key would pass; without the "does not move" half a fingerprint that + // folded the key in for EVERY provider would pass, churning caches that owe + // the key nothing. + const KEY_DERIVED = ['codebuff', 'zerostack', 'pi', 'omp', 'grok', 'lingtai-tui', 'copilot'] + const UNAFFECTED = ['claude', 'codex', 'cursor', 'warp', 'unknown-provider'] + const providers = [...KEY_DERIVED, ...UNAFFECTED] + const underA = await fingerprintUnderKey(KEY_A, providers) const underB = await fingerprintUnderKey(KEY_B, providers) - // codebuff: key-derived dedup keys, so a different key must re-parse. - expect(underB[0]).not.toBe(underA[0]) - // claude: dedup keys owe nothing to the privacy key, so a rotation must NOT - // churn its cache. Without this half the test would pass on a fingerprint - // that folded the key in for everyone. - expect(underB[1]).toBe(underA[1]) + KEY_DERIVED.forEach((name, i) => { + expect(underB[i], `${name} must re-parse when the privacy key rotates`).not.toBe(underA[i]) + }) + UNAFFECTED.forEach((name, i) => { + const at = KEY_DERIVED.length + i + expect(underB[at], `${name} must NOT churn when the privacy key rotates`).toBe(underA[at]) + }) + }) + + it('copilot moves on rotation even though its parse version says nothing about source refs', async () => { + // The regression this pins: the fold set used to be sniffed out of the + // parse-version string (`includes('source-ref-fingerprint-v1')`). Copilot's + // dedup keys are just as key-derived — createHmac(privacyKey) over the + // JetBrains reply text — but its parse version reads + // `…-dedup-key-hmac-v1`, so the sniff missed it and its fingerprint held + // still across a rotation. Copilot is the sole DURABLE provider: its + // union-merge never deletes cached turns, it appends any turn whose key is + // not already cached. A held-still fingerprint therefore does not merely + // re-parse — it keeps the K1 keys forever AND appends the K2 ones for the + // same turns, inflating totals on every rotation, and on every single run + // when the key file is corrupt (each process mints a fresh ephemeral key). + expect(PROVIDER_PARSE_VERSIONS['copilot']).not.toContain('source-ref-fingerprint-v1') + expect(DURABLE_PROVIDER_NAMES.has('copilot')).toBe(true) + expect(KEY_DERIVED_PROVIDERS.has('copilot')).toBe(true) + + const [underA] = await fingerprintUnderKey(KEY_A, ['copilot']) + const [underB] = await fingerprintUnderKey(KEY_B, ['copilot']) + expect(underB).not.toBe(underA) }) it('is stable when the key is unchanged', async () => { From 9ec98d2f96ed8b4063f10f661fa671fe9d762a15 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 10:48:21 -0700 Subject: [PATCH 08/11] fix(core): stop normalizeModelIdentifier throwing on non-string input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It called raw.trim() unconditionally. Two decoders resolve their model through a plain-object lookup — warp's modelAliases[model], antigravity's modelMap[usage.model] — so a session whose model names a prototype member ('__proto__', 'constructor', 'toString', 'valueOf') gets an object or a function back off the prototype chain, and .trim() on that is a TypeError that takes the whole parse down with one hostile record. That is a regression against what this replaced: `z.string()` rejected a non-string gracefully. Anything that is not a string is simply not an identifier, so it collapses to 'unknown' like every other non-conforming value. Guarded at the shared boundary rather than at the two lookups: all 34 producers route through here, so one guard covers the sites that exist and any lookup added later. Tests drive the four reachable prototype names end to end through the real warp decoder (non-vacuously: the decode must still produce calls), plus the unit-level statement for every type a lookup can return. --- packages/core/src/schema.ts | 11 +++- .../core/tests/providers/warp-decode.test.ts | 54 ++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 0e5b907ad..a4e274528 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -106,7 +106,16 @@ export const ModelIdentifier = z * envelope. */ export function normalizeModelIdentifier(raw: string): string { - const trimmed = raw.trim() + // Not typed `unknown`, but reached with non-strings anyway. Two decoders + // resolve their model through a plain-object lookup — warp's + // `modelAliases[model]`, antigravity's `modelMap[usage.model]` — so a session + // naming its model '__proto__', 'constructor' or 'toString' gets an object or + // a function back off the prototype chain, and `.trim()` on that throws. The + // base this replaced took `z.string()`, which REJECTED a non-string + // gracefully; throwing here would be a regression that kills the whole parse + // over one hostile record. Anything that is not a string is simply not an + // identifier: it collapses to 'unknown' like every other non-conforming value. + const trimmed = typeof raw === 'string' ? raw.trim() : '' if (trimmed.length === 0 || trimmed.length > 128) return 'unknown' return MODEL_IDENTIFIER_PATTERN.test(trimmed) ? trimmed : 'unknown' } diff --git a/packages/core/tests/providers/warp-decode.test.ts b/packages/core/tests/providers/warp-decode.test.ts index f91d9435e..c8ec35656 100644 --- a/packages/core/tests/providers/warp-decode.test.ts +++ b/packages/core/tests/providers/warp-decode.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { decodeWarp, toObservations } from '../../src/providers/warp/index.js' import { ObservationEnvelope } from '../../src/observations.js' -import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import { OBSERVATION_SCHEMA_VERSION, normalizeModelIdentifier } from '../../src/schema.js' import type { DecodeContext } from '../../src/contracts.js' import type { WarpBlockRow, WarpConversationRow, WarpQueryRow } from '../../src/providers/warp/types.js' @@ -203,6 +203,58 @@ describe('warp rich decode (moved to @codeburn/core)', () => { expect(calls[0]!.inputTokens).toBeGreaterThan(0) }) + // The model name reaches the boundary through `modelAliases[model]` — a plain + // object, so a session whose model_id names a prototype member gets an object + // or a function back, not a string. normalizeModelIdentifier used to call + // .trim() on it and throw, taking the whole parse down with one hostile + // record; the base it replaced (z.string()) rejected gracefully. These are + // the reachable non-string inputs, end to end through the real decoder. + it.each(['__proto__', 'constructor', 'toString', 'valueOf'])( + 'a model_id naming the prototype member %s yields unknown instead of throwing', + (hostile) => { + const conversation: WarpConversationRow = { + ...BASE_CONVERSATION, + conversation_data: JSON.stringify({ + conversation_usage_metadata: { + token_usage: [{ + model_id: hostile, + warp_tokens: 300, + byok_tokens: 0, + warp_token_usage_by_category: { primary_agent: 300 }, + byok_token_usage_by_category: {}, + }], + }, + }), + } + const exchanges: WarpQueryRow[] = [makeExchange('ex-1')] + const { calls } = decodeWarp({ + records: [makeComposite('conv-a', conversation, exchanges)], + context, + }) + expect(calls.length).toBeGreaterThan(0) // non-vacuous: a dropped record proves nothing + + const { sessions } = toObservations( + { sessionId: 'conv-a', projectPath: '/Users/me/projects/codeburn', calls }, + { privacyKey: 'test-privacy-key', provider: 'warp' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.model).toBe('unknown') + }, + ) + + it('normalizeModelIdentifier collapses a non-string to unknown rather than throwing', () => { + // The unit-level statement of the same contract, for the inputs a lookup can + // return: no caller may be able to crash the boundary with a bad type. + for (const hostile of [{}, [], () => {}, 42, null, undefined, Object.prototype]) { + expect(normalizeModelIdentifier(hostile as unknown as string)).toBe('unknown') + } + }) + it('toObservations produces a schema-valid, content-free envelope', () => { const exchanges: WarpQueryRow[] = [makeExchange('ex-1')] const blocks: WarpBlockRow[] = [ From 0d7dbd81d27d020f3c0226c5d1203c872979131f Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 10:48:21 -0700 Subject: [PATCH 09/11] fix(core): reject an empty privacy key in copilot's JetBrains digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createHmac accepts '' happily and returns a digest anyone can recompute from the reply text — exactly the weakness the HMAC replaced. Every other keyed primitive in the codebase throws instead of degrading (fingerprint.ts's hmac, sanitizeDetail); this one now matches, with the same message. --- packages/core/src/providers/copilot/decode.ts | 5 +++++ packages/core/tests/providers/copilot-decode.test.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/packages/core/src/providers/copilot/decode.ts b/packages/core/src/providers/copilot/decode.ts index 5b0e508b5..bb75a5651 100644 --- a/packages/core/src/providers/copilot/decode.ts +++ b/packages/core/src/providers/copilot/decode.ts @@ -1082,6 +1082,11 @@ function decodeChatSession(envelope: Extract, seen: Set, calls: CopilotDecodedCall[], privacyKey: string): void { + // Same throw-on-empty discipline as fingerprint.ts and sanitizeDetail: a + // keyed digest must never silently degrade to an effectively unkeyed one. + // createHmac accepts '' happily, which would hand back a digest anyone can + // recompute from the reply text — the very weakness the HMAC replaced. + if (!privacyKey) throw new Error('privacyKey is required') const raw = envelope.raw const sessionId = envelope.sessionId const mtime = envelope.mtime diff --git a/packages/core/tests/providers/copilot-decode.test.ts b/packages/core/tests/providers/copilot-decode.test.ts index ca815254f..a4258f83e 100644 --- a/packages/core/tests/providers/copilot-decode.test.ts +++ b/packages/core/tests/providers/copilot-decode.test.ts @@ -201,6 +201,8 @@ describe('decodeCopilot — jetbrains arm', () => { return calls[0]!.deduplicationKey } + expect(() => keyFor('')).toThrow(/privacyKey is required/) // never a degenerate key + const keyA = keyFor('privacy-key-A') const keyB = keyFor('privacy-key-B') From e7c6dc156093666549be8460c86334de51a389a2 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 10:48:21 -0700 Subject: [PATCH 10/11] test(cli): refuse to overwrite a real privacy key from the test helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixed-privacy-key.ts writes /.config/codeburn/privacy-key unconditionally. Under vitest that is the throwaway sandbox env-isolation.ts mints, and harmless. Imported from anywhere else — tsx, a stray node -e, a REPL — homedir() is the developer's real home and the write silently replaces their key, re-keying every resource fingerprint and orphaning whatever was already synced. privacy-key.ts refuses to cause that on its own; a test helper must not cause it either. The sandbox has a recognizable shape (a mkdtemp dir under tmpdir() named codeburn-test-env-*), so the guard needs no new marker: anything else throws with the reason. --- packages/cli/tests/setup/fixed-privacy-key.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/cli/tests/setup/fixed-privacy-key.ts b/packages/cli/tests/setup/fixed-privacy-key.ts index 62c61f43f..2412a1dea 100644 --- a/packages/cli/tests/setup/fixed-privacy-key.ts +++ b/packages/cli/tests/setup/fixed-privacy-key.ts @@ -17,15 +17,34 @@ // far worse hole than the goldens are worth. import { createHmac } from 'node:crypto' import { mkdirSync, writeFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' +import { homedir, tmpdir } from 'node:os' +import { basename, join } from 'node:path' import { normalizePath } from '@codeburn/core' /** The pinned key every digest literal in the goldens is derived under. */ export const FIXED_PRIVACY_KEY = 'c0de6b12'.repeat(8) -const configDir = join(homedir(), '.config', 'codeburn') +// This module OVERWRITES /.config/codeburn/privacy-key. That is harmless +// against the throwaway sandbox env-isolation.ts mints, and destructive against +// a real one: silently replacing a developer's key re-keys every resource +// fingerprint and orphans everything already synced — the exact failure +// privacy-key.ts refuses to cause on its own (it never overwrites a key file). +// So refuse unless HOME really is that sandbox. Its shape is the check: a +// mkdtemp dir under tmpdir() named `codeburn-test-env-*`. Run outside vitest — +// tsx, a stray node -e, a REPL — and this throws instead of eating the key. +const home = homedir() +if (!home.startsWith(tmpdir()) || !basename(home).startsWith('codeburn-test-env-')) { + throw new Error( + `fixed-privacy-key.ts refuses to write into HOME=${home}: it overwrites ` + + `/.config/codeburn/privacy-key, and that is only safe against the ` + + `throwaway sandbox tests/setup/env-isolation.ts mints (a ` + + `codeburn-test-env-* dir under ${tmpdir()}). Import it only from a vitest ` + + `test run under that setup file.`, + ) +} + +const configDir = join(home, '.config', 'codeburn') mkdirSync(configDir, { recursive: true }) writeFileSync(join(configDir, 'privacy-key'), FIXED_PRIVACY_KEY + '\n', { mode: 0o600 }) From 895639ee1fbd2e75ee3b118150661a6cc85dea01 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 10:59:50 -0700 Subject: [PATCH 11/11] test(cli): pin the privacy-key rotation end to end; harden the key guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review follow-ups. 1. The rotation guarantee is now pinned by a real parse, not by two unit tests that both stub the fingerprint. The original defect was a wiring bug — right value, wrong selector — and a stubbed fingerprint cannot see a wiring bug. The new case warms the cache under key K1 (one JetBrains turn, one call), rotates the key file to K2, appends a second turn, and re-parses in a fresh module registry: exactly two calls, no K1-era dedup key anywhere, on disk or in the result. Restore the substring-sniff selector and it reports three calls for two turns — the user-visible shape of the bug. 2. The test-helper guard compared UNRESOLVED paths, so a symlink at a sandbox-shaped location (HOME=$TMPDIR/codeburn-test-env-x -> /Users/me) passed the prefix test and the write landed in the real home anyway. Both sides are realpath'd before comparing, which also stops a sibling directory sharing the tmpdir prefix from matching. Same refusal message. 3. CHANGELOG records the two API breaks that were missing: decodeCopilot throws on an empty privacyKey for JetBrains records, and SOURCE_REF_KEYED_PARSE_VERSION is no longer exported (KEY_DERIVED_PROVIDERS replaces it). --- CHANGELOG.md | 2 + .../tests/copilot-cache-invalidation.test.ts | 97 ++++++++++++++++++- packages/cli/tests/setup/fixed-privacy-key.ts | 21 +++- 3 files changed, 114 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 499044b3d..7a917dad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Changed (@codeburn/core — breaking; version bump deferred to the next release, which must take at least a minor under 0.x) - **The `model` field is bounded at the observation boundary.** `model` (and the optional `pricingModel`) on a `CallObservation` must now match the identifier charset `[A-Za-z0-9._:/@-]`, max 128 chars, in both the zod schema and the published `schemas/observation-0.2.0.json`. Every provider's `toObservations` normalizes through `normalizeModelIdentifier`, so a provider display name — Antigravity's `"Gemini 3.5 Flash (High)"`, Warp's and Devin's display strings — surfaces as `unknown` in an emitted observation rather than rejecting the whole envelope. **CLI output is unaffected:** normalization happens only at the observation boundary, so terminal, dashboard, menubar, and desktop numbers and model labels are byte-identical to before. The bound is a tightening of `observation-0.2.0` in place; an ARCHIVED pre-hardening 0.2.0 envelope whose `model` held a display name now fails validation against that same version string and must be re-normalized before re-validating. - **The diagnostics API changed shape.** `DIAGNOSTIC_DETAIL_MAX` is deleted, `sanitizeDetail(value)` becomes `sanitizeDetail(value, key)` and returns a 16-hex keyed fingerprint instead of a truncated message, `DiagnosticDetail` now accepts only that fingerprint, `RecordOutcome.diagnostics` may no longer carry a `detail`, and `isolateRecords` takes an optional `privacyKey`. A diagnostic now carries a record index, a controlled code, and a keyed fingerprint — never content. The previous rule (strip path separators, cap at 200 chars) let any slash-free path, command fragment, prompt line, or API key through verbatim. Callers with no key omit the field entirely; core never degrades to an unkeyed digest. +- **`decodeCopilot` throws on an empty `privacyKey`** when the record is a JetBrains store. That arm derives its per-turn dedup key with `createHmac(privacyKey, replyText)`, and `createHmac` accepts `''` silently — returning a digest anyone can recompute from the reply text, which is the exact weakness the HMAC replaced. It now refuses, matching every other keyed primitive in the package (`fingerprint.ts`, `sanitizeDetail`). A host calling `decodeCopilot` with `context.privacyKey: ''` used to get a (weak) result and now gets an error; supply a real per-install key. +- **`SOURCE_REF_KEYED_PARSE_VERSION` is no longer exported** from the CLI's session-cache module. It was a marker token that `computeEnvFingerprint` substring-matched to decide which providers re-parse when the host privacy key changes, and that selector silently excluded copilot — whose dedup keys are equally key-derived but whose parse version names no source ref. It is replaced by the explicit `KEY_DERIVED_PROVIDERS` set. ### Fixed (privacy) - **Dedup keys no longer carry raw host paths.** `dedupKey` ships on the observation envelope, and the codebuff, zerostack, pi, omp, grok and LingTai TUI decoders were folding the absolute source path (a chat directory, a session file) straight into it. They now fold a keyed HMAC fingerprint of that path (`sourceRefFingerprint`, new `source` domain), and the CLI bridge threads the real per-install privacy key into the rich decode instead of an empty one. (#933, thanks @ozymandiashh) diff --git a/packages/cli/tests/copilot-cache-invalidation.test.ts b/packages/cli/tests/copilot-cache-invalidation.test.ts index ef42b73cb..e6460bf78 100644 --- a/packages/cli/tests/copilot-cache-invalidation.test.ts +++ b/packages/cli/tests/copilot-cache-invalidation.test.ts @@ -29,9 +29,10 @@ // keyed dedup key below is a constant rather than a per-run random. import './setup/fixed-privacy-key.js' -import { describe, it, expect, beforeEach, afterAll } from 'vitest' +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest' import { mkdir, readFile, rm, writeFile } from 'fs/promises' import { createHash, createHmac } from 'crypto' +import { homedir } from 'os' import { join } from 'path' import { clearSessionCache, parseAllSessions } from '../src/parser.js' @@ -52,6 +53,9 @@ const JB_ROOT = join(TEST_ROOT, 'jetbrains') // 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?' +// A second turn, appended after the key rotation below. Distinct text so it +// gets its own digest rather than colliding into the per-content counter. +const REPLY_TEXT_2 = 'Sure - here is the second answer.' const STORE_ID = 'conv-1' // What computeEnvFingerprint('copilot') returned under the PRE-BUMP parse @@ -102,11 +106,11 @@ function jbDbContent(blobs: string[]): string { ) } -async function createJetBrainsDb(): Promise { +async function createJetBrainsDb(replies: string[] = [REPLY_TEXT]): 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)])) + await writeFile(dbPath, jbDbContent(replies.map(jbAssistantBlob))) return dbPath } @@ -245,3 +249,90 @@ describe('copilot session cache invalidation', () => { expect(keys).not.toContain(oldShapeKey()) }) }) + +// ── privacy-key rotation, end to end ─────────────────────────────────── + +// The bump above is only half the guarantee. Copilot's dedup key is +// createHmac(privacyKey, replyText), so its VALUE also moves whenever the host +// privacy key moves — and the privacy key is not a released artifact: it is a +// random per-install file that can be rotated, lost, restored from another +// machine's backup, or (when it is corrupt/unreadable) replaced by a FRESH +// ephemeral key in every single process. No parse-version bump can see any of +// that. +// +// computeEnvFingerprint folds a digest of the key in for KEY_DERIVED_PROVIDERS +// so the section rebuilds. The selector for that fold was originally sniffed +// out of the parse-version string ('source-ref-fingerprint-v1'), which copilot's +// version does not contain — right value, wrong selector, so copilot silently +// fell out. The unit tests for the fold both stub the fingerprint, so neither +// notices; only a real parse does. This is that parse. +describe('copilot privacy-key rotation (end to end)', () => { + const KEY_1 = '11'.repeat(32) + const KEY_2 = '22'.repeat(32) + + /** The dedup key the JetBrains decoder writes for `reply` under `key`. */ + function keyUnder(key: string, reply: string): string { + const digest = createHmac('sha256', key).update(reply).digest('hex').slice(0, 12) + return `copilot:jb:${STORE_ID}:${digest}:1` + } + + async function writeHostPrivacyKey(key: string): Promise { + const dir = join(homedir(), '.config', 'codeburn') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'privacy-key'), key + '\n', { mode: 0o600 }) + } + + // A fresh module registry per parse, so privacy-key.ts re-reads the file + // instead of serving the key it memoized — which is exactly what the next + // `codeburn` process does. + async function parseInFreshProcess(): Promise { + vi.resetModules() + const parser = await import('../src/parser.js') + parser.clearSessionCache() + const projects = await parser.parseAllSessions(undefined, 'copilot') + return projects + .flatMap(p => p.sessions) + .flatMap(s => s.turns) + .flatMap(t => t.assistantCalls) + .map(c => c.deduplicationKey) + } + + afterAll(async () => { + // Leave the pinned key on disk for any test that runs after this one. + await writeHostPrivacyKey(FIXED_PRIVACY_KEY) + }) + + it('rotating the key rebuilds the section instead of double-counting every turn', async () => { + const dbPath = await createJetBrainsDb() + + // Run 1 — warm the cache under K1. One turn in the .db, one call out. + await writeHostPrivacyKey(KEY_1) + expect(await parseInFreshProcess()).toEqual([keyUnder(KEY_1, REPLY_TEXT)]) + expect((await cachedCopilotKeys()).keys).toEqual([keyUnder(KEY_1, REPLY_TEXT)]) + + // Rotate the key, and have the user hold one more conversation turn. + await writeHostPrivacyKey(KEY_2) + await createJetBrainsDb([REPLY_TEXT, REPLY_TEXT_2]) + + // Run 2 — two turns in the .db, so two calls out. Not three. + // + // With the fold missing, the env fingerprint holds still, the section is + // MERGED rather than rebuilt, and copilot is the sole durable provider: + // its union-merge never deletes, it appends any turn whose key is not + // already cached. The K1 copy of turn 1 therefore survives alongside the + // K2 copy of the same turn — 2 real turns reported as 3 calls, and the + // inflation repeats on every rotation (and on every RUN when the key file + // is corrupt, since each process then mints a fresh ephemeral key). + const afterRotation = await parseInFreshProcess() + expect(afterRotation).toHaveLength(2) + expect([...afterRotation].sort()).toEqual( + [keyUnder(KEY_2, REPLY_TEXT), keyUnder(KEY_2, REPLY_TEXT_2)].sort(), + ) + expect(afterRotation).not.toContain(keyUnder(KEY_1, REPLY_TEXT)) + + // And the K1-era key is gone from disk, not merely absent from this read. + const { keys } = await cachedCopilotKeys() + expect(keys).toHaveLength(2) + expect(keys).not.toContain(keyUnder(KEY_1, REPLY_TEXT)) + }) +}) diff --git a/packages/cli/tests/setup/fixed-privacy-key.ts b/packages/cli/tests/setup/fixed-privacy-key.ts index 2412a1dea..0c620e0dc 100644 --- a/packages/cli/tests/setup/fixed-privacy-key.ts +++ b/packages/cli/tests/setup/fixed-privacy-key.ts @@ -16,9 +16,9 @@ // accept an externally supplied privacy key, and adding that knob would be a // far worse hole than the goldens are worth. import { createHmac } from 'node:crypto' -import { mkdirSync, writeFileSync } from 'node:fs' +import { mkdirSync, realpathSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, join, sep } from 'node:path' import { normalizePath } from '@codeburn/core' @@ -34,7 +34,22 @@ export const FIXED_PRIVACY_KEY = 'c0de6b12'.repeat(8) // mkdtemp dir under tmpdir() named `codeburn-test-env-*`. Run outside vitest — // tsx, a stray node -e, a REPL — and this throws instead of eating the key. const home = homedir() -if (!home.startsWith(tmpdir()) || !basename(home).startsWith('codeburn-test-env-')) { + +// Resolve both sides before comparing. Without this the check is a string +// prefix test on an UNRESOLVED path, so a symlink at a sandbox-shaped location +// (HOME=/tmp/codeburn-test-env-x -> /Users/me) passes it and the write lands in +// the real home anyway. It also makes the honest case work on macOS, where +// tmpdir() is /var/folders/... and the mkdtemp dir resolves under /private/var. +// A path that cannot be resolved falls through as-is and gets refused below. +const resolve = (path: string): string => { + try { + return realpathSync(path) + } catch { + return path + } +} +const realHome = resolve(home) +if (!realHome.startsWith(resolve(tmpdir()) + sep) || !basename(realHome).startsWith('codeburn-test-env-')) { throw new Error( `fixed-privacy-key.ts refuses to write into HOME=${home}: it overwrites ` + `/.config/codeburn/privacy-key, and that is only safe against the ` +