From 68b14d42b0e79b6adc0e3685f6c4d81c1fd3ee64 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:40:56 +0900 Subject: [PATCH 01/31] feat(privacy): strip listing payloads on external transcript egress Add shared external-egress filters and wire Feedback/share/CCR remote persist. Keep isLoggableMessage unchanged for series 1 only. --- src/components/Feedback.egress.test.ts | 196 ++++++++++++ src/components/Feedback.tsx | 104 ++++--- .../FeedbackSurvey/submitTranscriptShare.ts | 15 +- .../sessionStorage.externalEgress.test.ts | 184 ++++++++++++ src/utils/sessionStorage.ts | 282 +++++++++++++++++- 5 files changed, 740 insertions(+), 41 deletions(-) create mode 100644 src/components/Feedback.egress.test.ts create mode 100644 src/utils/sessionStorage.externalEgress.test.ts diff --git a/src/components/Feedback.egress.test.ts b/src/components/Feedback.egress.test.ts new file mode 100644 index 0000000000..a8ba48e9fe --- /dev/null +++ b/src/components/Feedback.egress.test.ts @@ -0,0 +1,196 @@ +import { afterAll, beforeAll, expect, mock, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Message } from '../types/message.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' + +type AxiosModule = typeof import('axios') + +let originalAxiosModule: AxiosModule | undefined +let originalUserType: string | undefined +let hadMacro = false +let originalMacro: unknown +let tempDir: string | undefined +let postedBodies: Array<{ content?: string }> = [] + +function buildAxiosModuleStub( + post: (...args: unknown[]) => Promise, +): AxiosModule { + const instance = { + get: async () => ({ status: 200 }), + post, + isAxiosError: () => false, + isCancel: () => false, + defaults: {} as Record, + interceptors: { + request: { use: () => 0, eject: () => {} }, + response: { use: () => 0, eject: () => {} }, + }, + } + return { default: instance } as unknown as AxiosModule +} + +beforeAll(async () => { + await acquireSharedMutationLock('Feedback.egress') + originalAxiosModule = await import('axios') + originalUserType = process.env.USER_TYPE + hadMacro = Object.prototype.hasOwnProperty.call(globalThis, 'MACRO') + originalMacro = (globalThis as { MACRO?: unknown }).MACRO + ;(globalThis as { MACRO?: { VERSION: string } }).MACRO = { + VERSION: 'test-version', + } + + const realProviders = await import('../utils/model/providers.js') + mock.module('../utils/model/providers.js', () => ({ + ...realProviders, + getAPIProvider: () => 'firstParty', + isFirstPartyAnthropicBaseUrl: () => true, + })) + + const realAuth = await import('../utils/auth.js') + mock.module('../utils/auth.js', () => ({ + ...realAuth, + checkAndRefreshOAuthTokenIfNeeded: async () => {}, + })) + + const realHttp = await import('../utils/http.js') + mock.module('../utils/http.js', () => ({ + ...realHttp, + getAuthHeaders: () => ({ + headers: { Authorization: 'Bearer test' }, + error: undefined, + }), + getUserAgent: () => 'test-agent', + })) + + const realPrivacy = await import('../utils/privacyLevel.js') + mock.module('../utils/privacyLevel.js', () => ({ + ...realPrivacy, + isEssentialTrafficOnly: () => false, + })) + + tempDir = await mkdtemp(join(tmpdir(), 'openclaude-feedback-egress-')) + const transcriptPath = join(tempDir, 'session.jsonl') + await writeFile( + transcriptPath, + `${JSON.stringify({ + type: 'user', + uuid: '00000000-0000-4000-8000-00000000f001', + parentUuid: null, + timestamp: '2026-08-07T00:00:00.000Z', + message: { role: 'user', content: 'feedback main turn' }, + })}\n${JSON.stringify({ + type: 'attachment', + uuid: '00000000-0000-4000-8000-00000000f002', + parentUuid: '00000000-0000-4000-8000-00000000f001', + timestamp: '2026-08-07T00:00:00.000Z', + attachment: { + type: 'skill_listing', + content: 'Available skills:\n- /leak-me-please', + skillCount: 1, + isInitial: true, + }, + })}\n`, + ) + + const realSession = await import('../utils/sessionStorage.js') + mock.module('../utils/sessionStorage.js', () => ({ + ...realSession, + getTranscriptPath: () => transcriptPath, + loadAllSubagentTranscriptsFromDisk: async () => ({ + 'agent-leak': [ + { + type: 'attachment', + uuid: '00000000-0000-4000-8000-00000000a201', + attachment: { + type: 'agent_listing_delta', + addedTypes: ['Explore'], + addedLines: ['- Explore: /leak-agent-listing'], + removedTypes: [], + isInitial: true, + showConcurrencyNote: false, + }, + }, + { + type: 'user', + uuid: '00000000-0000-4000-8000-00000000a202', + message: { role: 'user', content: 'subagent turn' }, + }, + ], + }), + })) + + mock.module('axios', () => + buildAxiosModuleStub(async (_url: unknown, body: unknown) => { + postedBodies.push(body as { content?: string }) + return { status: 200, data: { feedback_id: 'fb-egress-1' } } + }), + ) +}) + +afterAll(async () => { + try { + if (originalUserType === undefined) { + delete process.env.USER_TYPE + } else { + process.env.USER_TYPE = originalUserType + } + if (!hadMacro) { + delete (globalThis as { MACRO?: unknown }).MACRO + } else { + ;(globalThis as { MACRO?: unknown }).MACRO = originalMacro + } + if (originalAxiosModule) { + mock.module('axios', () => originalAxiosModule!) + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }) + } + } finally { + releaseSharedMutationLock() + } +}) + +test('Feedback upload strips listing payloads from the posted content body', async () => { + postedBodies = [] + process.env.USER_TYPE = 'external' + + const { assembleFeedbackEgressReportData, submitFeedback } = await import( + './Feedback.js' + ) + + const listing = { + type: 'attachment', + uuid: '00000000-0000-4000-8000-00000000m101', + attachment: { + type: 'skill_listing', + content: 'Available skills:\n- /leak-me-please', + skillCount: 1, + isInitial: true, + }, + } as unknown as Message + const user = { + type: 'user', + uuid: '00000000-0000-4000-8000-00000000m102', + message: { role: 'user', content: 'plain turn' }, + } as unknown as Message + + const report = await assembleFeedbackEgressReportData({ + messages: [listing, user], + description: 'egress regression', + }) + const result = await submitFeedback(report) + + expect(result.success).toBe(true) + expect(postedBodies).toHaveLength(1) + const content = postedBodies[0]?.content ?? '' + expect(content).toContain('plain turn') + expect(content).toContain('feedback main turn') + expect(content).toContain('subagent turn') + expect(content).not.toContain('leak-me-please') + expect(content).not.toContain('leak-agent-listing') +}) diff --git a/src/components/Feedback.tsx b/src/components/Feedback.tsx index 6c2d630461..3fb6683df5 100644 --- a/src/components/Feedback.tsx +++ b/src/components/Feedback.tsx @@ -25,7 +25,7 @@ import { } from '../utils/model/providers.js'; import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'; import { jsonRedactor, redactJsonLines, redactSensitiveInfo } from '../utils/redaction.js'; -import { extractTeammateTranscriptsFromTasks, getTranscriptPath, loadAllSubagentTranscriptsFromDisk, MAX_TRANSCRIPT_READ_BYTES } from '../utils/sessionStorage.js'; +import { extractTeammateTranscriptsFromTasks, filterJsonlForExternalEgress, filterMessagesForExternalEgress, filterSubagentTranscriptsForExternalEgress, getTranscriptPath, loadAllSubagentTranscriptsFromDisk, MAX_TRANSCRIPT_READ_BYTES } from '../utils/sessionStorage.js'; import { jsonStringify } from '../utils/slowOperations.js'; import { asSystemPrompt } from '../utils/systemPromptType.js'; import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; @@ -64,14 +64,72 @@ type FeedbackData = { description: string; platform: string; gitRepo: boolean; + terminal?: string | null; version: string | null; transcript: Message[]; + errors?: Array<{ + error?: string; + timestamp?: string; + }>; + lastApiRequest?: unknown; subagentTranscripts?: { [agentId: string]: Message[]; }; rawTranscriptJsonl?: string; }; +type FeedbackBackgroundTasks = NonNullable; + +/** + * Assembles the Feedback upload payload with the same egress filters used by + * the interactive Feedback dialog (main transcript, subagents, raw JSONL). + * Exported for a focused intercepted-upload regression test. + */ +export async function assembleFeedbackEgressReportData(args: { + messages: Message[]; + description: string; + backgroundTasks?: FeedbackBackgroundTasks; + gitRepo?: boolean; +}): Promise { + const backgroundTasks = args.backgroundTasks ?? {}; + const lastAssistantMessage = getLastAssistantMessage(args.messages); + const lastAssistantMessageId = lastAssistantMessage?.requestId ?? null; + const [diskTranscripts, rawTranscriptJsonl] = await Promise.all([ + loadAllSubagentTranscriptsFromDisk(), + loadRawTranscriptJsonl(), + ]); + const teammateTranscripts = extractTeammateTranscriptsFromTasks(backgroundTasks); + const subagentTranscripts = filterSubagentTranscriptsForExternalEgress({ + ...diskTranscripts, + ...teammateTranscripts, + }); + const redactedTranscriptJsonl = rawTranscriptJsonl + ? redactJsonLines(rawTranscriptJsonl) + : undefined; + return { + latestAssistantMessageId: lastAssistantMessageId, + message_count: args.messages.length, + datetime: new Date().toISOString(), + description: args.description, + platform: env.platform, + gitRepo: args.gitRepo ?? false, + terminal: env.terminal, + version: MACRO.VERSION, + transcript: normalizeMessagesForAPI( + filterMessagesForExternalEgress(args.messages), + ), + errors: getSanitizedErrorLogs(), + lastApiRequest: getLastAPIRequest(), + ...(Object.keys(subagentTranscripts).length > 0 && { + subagentTranscripts, + }), + ...(rawTranscriptJsonl && + redactedTranscriptJsonl && { + rawTranscriptJsonl: redactedTranscriptJsonl, + }), + }; +} + // Get sanitized error logs with sensitive information redacted function getSanitizedErrorLogs(): Array<{ error?: string; @@ -106,7 +164,7 @@ async function loadRawTranscriptJsonl(): Promise { }); return null; } - return await readFile(transcriptPath, 'utf-8'); + return filterJsonlForExternalEgress(await readFile(transcriptPath, 'utf-8')); } catch { return null; } @@ -153,40 +211,13 @@ export function Feedback({ setFeedbackId(null); setCompletionMode('submitted'); - // Get sanitized errors for the report - const sanitizedErrors = getSanitizedErrorLogs(); - - // Extract last assistant message ID from messages array - const lastAssistantMessage = getLastAssistantMessage(messages); - const lastAssistantMessageId = lastAssistantMessage?.requestId ?? null; - const [diskTranscripts, rawTranscriptJsonl] = await Promise.all([loadAllSubagentTranscriptsFromDisk(), loadRawTranscriptJsonl()]); - const teammateTranscripts = extractTeammateTranscriptsFromTasks(backgroundTasks); - const subagentTranscripts = { - ...diskTranscripts, - ...teammateTranscripts - }; - const redactedTranscriptJsonl = rawTranscriptJsonl - ? redactJsonLines(rawTranscriptJsonl) - : undefined; - const reportData = { - latestAssistantMessageId: lastAssistantMessageId, - message_count: messages.length, - datetime: new Date().toISOString(), + const reportData = await assembleFeedbackEgressReportData({ + messages, description, - platform: env.platform, + backgroundTasks, gitRepo: envInfo.isGit, - terminal: env.terminal, - version: MACRO.VERSION, - transcript: normalizeMessagesForAPI(messages), - errors: sanitizedErrors, - lastApiRequest: getLastAPIRequest(), - ...(Object.keys(subagentTranscripts).length > 0 && { - subagentTranscripts - }), - ...(rawTranscriptJsonl && redactedTranscriptJsonl && { - rawTranscriptJsonl: redactedTranscriptJsonl - }) - }; + }); + const lastAssistantMessageId = reportData.latestAssistantMessageId; const [result, t] = await Promise.all([submitFeedback(reportData, abortSignal), generateTitle(description, abortSignal)]); setTitle(t); if (result.success) { @@ -208,7 +239,7 @@ export function Feedback({ // Stay on userInput step so user can retry with their content preserved setStep('userInput'); } - }, [description, envInfo.isGit, messages]); + }, [abortSignal, backgroundTasks, description, envInfo.isGit, messages]); // Handle cancel - this will be called by Dialog's automatic Esc handling const handleCancel = useCallback(() => { @@ -477,7 +508,8 @@ function sanitizeAndLogError(err: unknown): void { logError(new Error(errorString)); } } -async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise<{ +/** Exported for intercepted Feedback upload regression tests. */ +export async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise<{ success: boolean; feedbackId?: string; isZdrOrg?: boolean; diff --git a/src/components/FeedbackSurvey/submitTranscriptShare.ts b/src/components/FeedbackSurvey/submitTranscriptShare.ts index 03436c21ce..e59a9acd23 100644 --- a/src/components/FeedbackSurvey/submitTranscriptShare.ts +++ b/src/components/FeedbackSurvey/submitTranscriptShare.ts @@ -9,6 +9,9 @@ import { isFirstPartyAnthropicProvider } from '../../utils/model/providers.js' import { normalizeMessagesForAPI } from '../../utils/messages.js' import { extractAgentIdsFromMessages, + filterJsonlForExternalEgress, + filterMessagesForExternalEgress, + filterSubagentTranscriptsForExternalEgress, getTranscriptPath, loadSubagentTranscripts, MAX_TRANSCRIPT_READ_BYTES, @@ -38,11 +41,15 @@ export async function submitTranscriptShare( try { logForDebugging('Collecting transcript for sharing', { level: 'info' }) - const transcript = normalizeMessagesForAPI(messages) + const transcript = normalizeMessagesForAPI( + filterMessagesForExternalEgress(messages), + ) // Collect subagent transcripts const agentIds = extractAgentIdsFromMessages(messages) - const subagentTranscripts = await loadSubagentTranscripts(agentIds) + const loadedSubagents = await loadSubagentTranscripts(agentIds) + const subagentTranscripts = + filterSubagentTranscriptsForExternalEgress(loadedSubagents) // Read raw JSONL transcript (with size guard to prevent OOM) let rawTranscriptJsonl: string | undefined @@ -50,7 +57,9 @@ export async function submitTranscriptShare( const transcriptPath = getTranscriptPath() const { size } = await stat(transcriptPath) if (size <= MAX_TRANSCRIPT_READ_BYTES) { - rawTranscriptJsonl = await readFile(transcriptPath, 'utf-8') + rawTranscriptJsonl = filterJsonlForExternalEgress( + await readFile(transcriptPath, 'utf-8'), + ) } else { logForDebugging( `Skipping raw transcript read: file too large (${size} bytes)`, diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts new file mode 100644 index 0000000000..3db026337b --- /dev/null +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import type { UUID } from 'crypto' +import { + filterJsonlForExternalEgress, + filterMessagesForExternalEgress, + filterSubagentTranscriptsForExternalEgress, + isSafeForExternalEgress, + PREFIX_CACHE_LISTING_ATTACHMENT_TYPES, + projectTranscriptParentForExternalEgress, + recordExternalEgressOmission, +} from './sessionStorage.js' + +const originalUserType = process.env.USER_TYPE + +afterEach(() => { + if (originalUserType === undefined) { + delete process.env.USER_TYPE + } else { + process.env.USER_TYPE = originalUserType + } +}) + +function id(n: number): UUID { + return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` as UUID +} + +function user(uuid: UUID, parentUuid: UUID | null, content: string) { + return { + type: 'user' as const, + uuid, + parentUuid, + message: { role: 'user' as const, content }, + } +} + +function listing( + uuid: UUID, + parentUuid: UUID | null, + attachmentType: string, + leakToken: string, +) { + return { + type: 'attachment' as const, + uuid, + parentUuid, + attachment: { + type: attachmentType, + content: leakToken, + isInitial: true, + }, + } +} + +describe('isSafeForExternalEgress', () => { + test('rejects every prefix-cache listing type for external users', () => { + process.env.USER_TYPE = 'external' + for (const attachmentType of PREFIX_CACHE_LISTING_ATTACHMENT_TYPES) { + expect( + isSafeForExternalEgress({ + type: 'attachment', + attachment: { type: attachmentType }, + }), + ).toBe(false) + } + }) + + test('rejects listing types for ant users too', () => { + process.env.USER_TYPE = 'ant' + for (const attachmentType of PREFIX_CACHE_LISTING_ATTACHMENT_TYPES) { + expect( + isSafeForExternalEgress({ + type: 'attachment', + attachment: { type: attachmentType }, + }), + ).toBe(false) + } + }) + + test('rejects progress for both ant and external', () => { + process.env.USER_TYPE = 'external' + expect(isSafeForExternalEgress({ type: 'progress' })).toBe(false) + process.env.USER_TYPE = 'ant' + expect(isSafeForExternalEgress({ type: 'progress' })).toBe(false) + }) + + test('allows plain user messages for external users', () => { + process.env.USER_TYPE = 'external' + expect(isSafeForExternalEgress({ type: 'user' })).toBe(true) + }) +}) + +describe('filterMessagesForExternalEgress', () => { + test('strips listings and reparents the next survivor', () => { + process.env.USER_TYPE = 'external' + const u1 = user(id(1), null, 'first') + const leak = listing(id(2), id(1), 'skill_listing', 'LEAK-SKILL') + const u2 = user(id(3), id(2), 'second') + + const filtered = filterMessagesForExternalEgress([u1, leak, u2]) + expect(filtered).toHaveLength(2) + expect(filtered[0]?.uuid).toBe(id(1)) + expect(filtered[1]?.uuid).toBe(id(3)) + expect(filtered[1]?.parentUuid).toBe(id(1)) + expect(JSON.stringify(filtered)).not.toContain('LEAK-SKILL') + }) + + test('chain-resolves through consecutive omissions', () => { + process.env.USER_TYPE = 'external' + const u1 = user(id(1), null, 'first') + const a = listing(id(2), id(1), 'skill_listing', 'LEAK-A') + const b = listing(id(3), id(2), 'agent_listing_delta', 'LEAK-B') + const u2 = user(id(4), id(3), 'after') + + const filtered = filterMessagesForExternalEgress([u1, a, b, u2]) + expect(filtered.map(m => m.uuid)).toEqual([id(1), id(4)]) + expect(filtered[1]?.parentUuid).toBe(id(1)) + }) +}) + +describe('filterJsonlForExternalEgress', () => { + test('strips listing lines and rewrites parentUuid on survivors', () => { + process.env.USER_TYPE = 'external' + const lines = [ + JSON.stringify(user(id(1), null, 'first')), + JSON.stringify( + listing(id(2), id(1), 'deferred_tools_delta', 'LEAK-JSONL'), + ), + JSON.stringify(user(id(3), id(2), 'third')), + ] + const out = filterJsonlForExternalEgress(lines.join('\n')) + const parsed = out + .split('\n') + .filter(l => l.length > 0) + .map(l => JSON.parse(l) as { uuid: string; parentUuid: string | null }) + expect(parsed).toHaveLength(2) + expect(parsed[0]?.uuid).toBe(id(1)) + expect(parsed[1]?.uuid).toBe(id(3)) + expect(parsed[1]?.parentUuid).toBe(id(1)) + expect(out).not.toContain('LEAK-JSONL') + }) + + test('fail-closes malformed non-empty JSONL lines', () => { + process.env.USER_TYPE = 'external' + const out = filterJsonlForExternalEgress( + [ + JSON.stringify(user(id(1), null, 'ok')), + '{not-json', + JSON.stringify(user(id(2), id(1), 'still-ok')), + ].join('\n'), + ) + expect(out).not.toContain('{not-json') + expect(out).toContain('still-ok') + expect(out.split('\n').filter(l => l.length > 0)).toHaveLength(2) + }) +}) + +describe('filterSubagentTranscriptsForExternalEgress', () => { + test('strips listings per agent independently', () => { + process.env.USER_TYPE = 'external' + const filtered = filterSubagentTranscriptsForExternalEgress({ + a: [ + listing(id(1), null, 'mcp_instructions_delta', 'LEAK-A'), + user(id(2), id(1), 'agent-a'), + ], + b: [user(id(3), null, 'agent-b')], + }) + expect(filtered.a?.map(m => m.uuid)).toEqual([id(2)]) + expect(filtered.a?.[0]?.parentUuid).toBeNull() + expect(filtered.b?.map(m => m.uuid)).toEqual([id(3)]) + expect(JSON.stringify(filtered)).not.toContain('LEAK-A') + }) +}) + +describe('recordExternalEgressOmission / projectTranscriptParentForExternalEgress', () => { + test('projects parent across a single omission map entry', () => { + const map = new Map() + recordExternalEgressOmission(map, id(2), id(1)) + const projected = projectTranscriptParentForExternalEgress( + { parentUuid: id(2) as UUID | null }, + map, + ) + expect(projected.parentUuid).toBe(id(1)) + }) +}) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 7ca4ada6f8..741842070c 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -4,7 +4,7 @@ import type { Dirent } from 'fs' // Sync fs primitives for readFileTailSync — separate from fs/promises // imports above. Named (not wildcard) per CLAUDE.md style; no collisions // with the async-suffixed names. -import { closeSync, fstatSync, openSync, readSync } from 'fs' +import { closeSync, fstatSync, openSync, readFileSync, readSync } from 'fs' import { appendFile as fsAppendFile, open as fsOpen, @@ -918,6 +918,12 @@ class Project { private activeDrain: Promise | null = null private FLUSH_INTERVAL_MS = 100 private readonly MAX_CHUNK_BYTES = 100 * 1024 * 1024 + /** + * UUIDs withheld from remote/CCR egress. Maps omitted uuid → nearest + * ancestor still present on the remote projection so subsequent + * persistToRemote calls can reparent instead of dangling. + */ + private remoteEgressOmittedParents = new Map() constructor() {} @@ -929,6 +935,7 @@ class Project { this.flushTimer = null this.activeDrain = null this.writeQueues = new Map() + this.remoteEgressOmittedParents.clear() this.rewriteBarrierFiles = new Set() this.pendingRewriteCounts = new Map() this.pendingDirectAppends = new Map() @@ -1227,9 +1234,46 @@ class Project { ) } + /** + * After --resume / --continue, remoteEgressOmittedParents starts empty + * (resetSessionFile clears it). Rebuild from the adopted local transcript + * so the first post-resume remote append whose parentUuid pointed at a + * withheld entry can reparent instead of dangling on CCR / session-ingress. + */ + rebuildRemoteEgressOmittedParentsFromLocalTranscript(): void { + this.remoteEgressOmittedParents.clear() + const path = this.sessionFile + if (!path) return + let content: string + try { + content = readFileSync(path, 'utf8') + } catch { + return + } + for (const line of content.split('\n')) { + if (!line.trim()) continue + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + if (!isTranscriptMessage(parsed as Entry)) continue + const entry = parsed as TranscriptMessage + if (!isSafeForExternalEgress(entry)) { + recordExternalEgressOmission( + this.remoteEgressOmittedParents, + entry.uuid, + entry.parentUuid ?? null, + ) + } + } + } + resetSessionFile(): void { this.sessionFile = null this.pendingEntries = [] + this.remoteEgressOmittedParents.clear() } /** @@ -1897,8 +1941,25 @@ class Project { // UUID the main thread hasn't written yet → 409 when main writes it. messageSet.add(entry.uuid) + // Remote / CCR / public ingress must not receive listing catalogs + // or other unsafe attachments (privacy boundary). When a chain + // participant is withheld, record it in remoteEgressOmittedParents + // and reparent the next remote entry so hydrateRemoteSession / + // buildConversationChain do not stop at a missing parentUuid. if (isTranscriptMessage(entry)) { - await this.persistToRemote(sessionId, entry) + if (isSafeForExternalEgress(entry)) { + const remoteEntry = projectTranscriptParentForExternalEgress( + entry, + this.remoteEgressOmittedParents, + ) + await this.persistToRemote(sessionId, remoteEntry) + } else { + recordExternalEgressOmission( + this.remoteEgressOmittedParents, + entry.uuid, + entry.parentUuid, + ) + } } } } @@ -2192,6 +2253,10 @@ export async function resetSessionFilePointer() { export function adoptResumedSessionFile(): void { const project = getProject() project.sessionFile = getTranscriptPath() + // Resume clears remoteEgressOmittedParents via resetSessionFilePointer. + // Rebuild from the adopted JSONL so post-resume remote appends reparent + // across entries withheld from egress. + project.rebuildRemoteEgressOmittedParentsFromLocalTranscript() project.reAppendSessionMetadata(true) } @@ -5342,6 +5407,68 @@ export async function loadAllSubagentTranscriptsFromDisk(): Promise<{ return loadSubagentTranscripts(agentIds) } +/** Listing attachment types that must never cross remote/share/feedback paths. */ +export const PREFIX_CACHE_LISTING_ATTACHMENT_TYPES: ReadonlySet = + new Set([ + 'skill_listing', + 'agent_listing_delta', + 'deferred_tools_delta', + 'mcp_instructions_delta', + ]) + +function attachmentTypeOf(m: { + type?: string + attachment?: unknown +}): string | null { + if (m.type !== 'attachment') return null + if (!m.attachment || typeof m.attachment !== 'object') return null + const t = (m.attachment as { type?: unknown }).type + return typeof t === 'string' ? t : null +} + +/** + * Record a UUID withheld from remote/public egress. Chain-resolves through + * consecutive omissions so one lookup yields the nearest ancestor that still + * exists on the projected chain. + */ +export function recordExternalEgressOmission( + omittedParents: Map, + uuid: UUID, + parentUuid: UUID | null, +): void { + omittedParents.set( + uuid, + parentUuid && omittedParents.has(parentUuid) + ? (omittedParents.get(parentUuid) ?? null) + : parentUuid, + ) +} + +/** + * Reparent a transcript entry whose parentUuid points at a UUID omitted from + * the external/remote projection. Returns the same reference when no rewrite + * is needed so callers can keep original JSONL bytes where possible. + */ +export function projectTranscriptParentForExternalEgress< + T extends { parentUuid: UUID | null }, +>(entry: T, omittedParents: Map): T { + if (!entry.parentUuid || !omittedParents.has(entry.parentUuid)) { + return entry + } + return { + ...entry, + parentUuid: omittedParents.get(entry.parentUuid) ?? null, + } +} + +export function isPrefixCacheListingAttachment(m: { + type?: string + attachment?: unknown +}): boolean { + const t = attachmentTypeOf(m) + return t !== null && PREFIX_CACHE_LISTING_ATTACHMENT_TYPES.has(t) +} + // Exported so useLogMessages can sync-compute the last loggable uuid // without awaiting recordTranscript's return value (race-free hint tracking). export function isLoggableMessage(m: Message): boolean { @@ -5362,6 +5489,157 @@ export function isLoggableMessage(m: Message): boolean { return true } +/** + * Privacy gate for remote ingress / CCR / public sharing paths. + * Listing catalogs (and other unsafe attachments) must not cross this + * boundary — including the ant fast path below. + */ +export function isSafeForExternalEgress(entry: { + type?: string + attachment?: unknown +}): boolean { + // Listings must never cross remote / share / feedback — including ant. + if (isPrefixCacheListingAttachment(entry)) { + return false + } + if (getUserType() === 'ant') { + return entry.type !== 'progress' + } + if (entry.type === 'progress') return false + if (entry.type !== 'attachment') return true + if (!entry.attachment || typeof entry.attachment !== 'object') { + return false + } + const t = (entry.attachment as { type?: unknown }).type + if (typeof t !== 'string') return false + if ( + t === 'hook_additional_context' && + isEnvTruthy(process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT) + ) { + return true + } + // Other attachment types remain blocked on egress for external users. + return false +} + +/** + * Drop entries that must not leave the local machine (share, feedback, + * analytics payloads built from in-memory messages). Relinks parentUuid + * across omitted listing attachments so the projected array remains a + * valid chain (remote-hydrate / buildConversationChain safe) without + * carrying listing payloads. + */ +export function filterMessagesForExternalEgress< + T extends { + type?: string + attachment?: unknown + uuid?: UUID + parentUuid?: UUID | null + }, +>(messages: readonly T[]): T[] { + const omittedParents = new Map() + const kept: T[] = [] + for (const message of messages) { + if (!isSafeForExternalEgress(message)) { + if (typeof message.uuid === 'string') { + recordExternalEgressOmission( + omittedParents, + message.uuid, + message.parentUuid ?? null, + ) + } + continue + } + if ( + message.parentUuid !== undefined && + message.parentUuid !== null && + omittedParents.has(message.parentUuid) + ) { + kept.push({ + ...message, + parentUuid: omittedParents.get(message.parentUuid) ?? null, + }) + } else { + kept.push(message) + } + } + return kept +} + +/** + * Project every subagent transcript through filterMessagesForExternalEgress. + * Shared by Feedback submit and submitTranscriptShare so the egress rule + * cannot drift between upload paths. + */ +export function filterSubagentTranscriptsForExternalEgress< + T extends { + type?: string + attachment?: unknown + uuid?: UUID + parentUuid?: UUID | null + }, +>(transcripts: { [agentId: string]: T[] }): { [agentId: string]: T[] } { + const filtered: { [agentId: string]: T[] } = {} + for (const [agentId, msgs] of Object.entries(transcripts)) { + filtered[agentId] = filterMessagesForExternalEgress(msgs) + } + return filtered +} + +/** + * Strip unsafe attachment lines from a raw session JSONL string before any + * public upload. Unparseable non-empty lines fail closed (dropped) so a + * corrupt listing fragment cannot bypass attachment classification. + * Surviving lines whose parentUuid pointed at an omitted listing are + * rewritten to the nearest kept ancestor so a shared/hydrated log stays a + * continuous parentUuid chain. + */ +export function filterJsonlForExternalEgress(jsonl: string): string { + if (jsonl.length === 0) return jsonl + const lines = jsonl.split('\n') + const kept: string[] = [] + const omittedParents = new Map() + for (const line of lines) { + if (line.length === 0) { + kept.push(line) + continue + } + try { + const entry = JSON.parse(line) as { + type?: string + attachment?: unknown + uuid?: UUID + parentUuid?: UUID | null + } + if (!isSafeForExternalEgress(entry)) { + if (typeof entry.uuid === 'string') { + recordExternalEgressOmission( + omittedParents, + entry.uuid, + entry.parentUuid ?? null, + ) + } + continue + } + if (entry.parentUuid && omittedParents.has(entry.parentUuid)) { + const projected = projectTranscriptParentForExternalEgress( + { + ...entry, + parentUuid: entry.parentUuid, + }, + omittedParents, + ) + kept.push(JSON.stringify(projected)) + } else { + kept.push(line) + } + } catch { + // Fail closed: corrupt / partial lines must not reach share or feedback. + } + } + return kept.join('\n') +} + function collectReplIds(messages: readonly Message[]): Set { const ids = new Set() for (const m of messages) { From 525c92940ca7b97aa0c76412327a0b471d722079 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:00:01 +0900 Subject: [PATCH 02/31] refactor(privacy): rename egress listing helpers off prefix-cache labels --- src/utils/sessionStorage.externalEgress.test.ts | 8 ++++---- src/utils/sessionStorage.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 3db026337b..a781e2eb6a 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -5,7 +5,7 @@ import { filterMessagesForExternalEgress, filterSubagentTranscriptsForExternalEgress, isSafeForExternalEgress, - PREFIX_CACHE_LISTING_ATTACHMENT_TYPES, + EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES, projectTranscriptParentForExternalEgress, recordExternalEgressOmission, } from './sessionStorage.js' @@ -52,9 +52,9 @@ function listing( } describe('isSafeForExternalEgress', () => { - test('rejects every prefix-cache listing type for external users', () => { + test('rejects every external-egress listing type for external users', () => { process.env.USER_TYPE = 'external' - for (const attachmentType of PREFIX_CACHE_LISTING_ATTACHMENT_TYPES) { + for (const attachmentType of EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES) { expect( isSafeForExternalEgress({ type: 'attachment', @@ -66,7 +66,7 @@ describe('isSafeForExternalEgress', () => { test('rejects listing types for ant users too', () => { process.env.USER_TYPE = 'ant' - for (const attachmentType of PREFIX_CACHE_LISTING_ATTACHMENT_TYPES) { + for (const attachmentType of EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES) { expect( isSafeForExternalEgress({ type: 'attachment', diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 741842070c..78cfd434b9 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -5408,7 +5408,7 @@ export async function loadAllSubagentTranscriptsFromDisk(): Promise<{ } /** Listing attachment types that must never cross remote/share/feedback paths. */ -export const PREFIX_CACHE_LISTING_ATTACHMENT_TYPES: ReadonlySet = +export const EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES: ReadonlySet = new Set([ 'skill_listing', 'agent_listing_delta', @@ -5461,12 +5461,12 @@ export function projectTranscriptParentForExternalEgress< } } -export function isPrefixCacheListingAttachment(m: { +export function isExternalEgressListingAttachment(m: { type?: string attachment?: unknown }): boolean { const t = attachmentTypeOf(m) - return t !== null && PREFIX_CACHE_LISTING_ATTACHMENT_TYPES.has(t) + return t !== null && EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES.has(t) } // Exported so useLogMessages can sync-compute the last loggable uuid @@ -5499,7 +5499,7 @@ export function isSafeForExternalEgress(entry: { attachment?: unknown }): boolean { // Listings must never cross remote / share / feedback — including ant. - if (isPrefixCacheListingAttachment(entry)) { + if (isExternalEgressListingAttachment(entry)) { return false } if (getUserType() === 'ant') { From 1e0229e1e8ae21eb977a553354ea0dfda53c12e2 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:23:09 +0900 Subject: [PATCH 03/31] fix(privacy): close CodeRabbit findings on external egress projection Harden transcript egress filtering: size-guarded rebuild, sink-gated omission map, always block hook_additional_context for external uploads, shared JSONL helper, and expanded tests. --- src/components/Feedback.egress.test.ts | 80 ++++- src/components/Feedback.tsx | 16 +- .../FeedbackSurvey/submitTranscriptShare.ts | 20 +- .../sessionStorage.externalEgress.test.ts | 284 ++++++++++++++++++ src/utils/sessionStorage.ts | 107 ++++++- 5 files changed, 460 insertions(+), 47 deletions(-) diff --git a/src/components/Feedback.egress.test.ts b/src/components/Feedback.egress.test.ts index a8ba48e9fe..ad97c488ae 100644 --- a/src/components/Feedback.egress.test.ts +++ b/src/components/Feedback.egress.test.ts @@ -9,8 +9,18 @@ import { } from '../test/sharedMutationLock.js' type AxiosModule = typeof import('axios') +type ProvidersModule = typeof import('../utils/model/providers.js') +type AuthModule = typeof import('../utils/auth.js') +type HttpModule = typeof import('../utils/http.js') +type PrivacyModule = typeof import('../utils/privacyLevel.js') +type SessionStorageModule = typeof import('../utils/sessionStorage.js') let originalAxiosModule: AxiosModule | undefined +let originalProvidersModule: ProvidersModule | undefined +let originalAuthModule: AuthModule | undefined +let originalHttpModule: HttpModule | undefined +let originalPrivacyModule: PrivacyModule | undefined +let originalSessionStorageModule: SessionStorageModule | undefined let originalUserType: string | undefined let hadMacro = false let originalMacro: unknown @@ -44,22 +54,22 @@ beforeAll(async () => { VERSION: 'test-version', } - const realProviders = await import('../utils/model/providers.js') + originalProvidersModule = await import('../utils/model/providers.js') mock.module('../utils/model/providers.js', () => ({ - ...realProviders, + ...originalProvidersModule!, getAPIProvider: () => 'firstParty', isFirstPartyAnthropicBaseUrl: () => true, })) - const realAuth = await import('../utils/auth.js') + originalAuthModule = await import('../utils/auth.js') mock.module('../utils/auth.js', () => ({ - ...realAuth, + ...originalAuthModule!, checkAndRefreshOAuthTokenIfNeeded: async () => {}, })) - const realHttp = await import('../utils/http.js') + originalHttpModule = await import('../utils/http.js') mock.module('../utils/http.js', () => ({ - ...realHttp, + ...originalHttpModule!, getAuthHeaders: () => ({ headers: { Authorization: 'Bearer test' }, error: undefined, @@ -67,14 +77,15 @@ beforeAll(async () => { getUserAgent: () => 'test-agent', })) - const realPrivacy = await import('../utils/privacyLevel.js') + originalPrivacyModule = await import('../utils/privacyLevel.js') mock.module('../utils/privacyLevel.js', () => ({ - ...realPrivacy, + ...originalPrivacyModule!, isEssentialTrafficOnly: () => false, })) tempDir = await mkdtemp(join(tmpdir(), 'openclaude-feedback-egress-')) const transcriptPath = join(tempDir, 'session.jsonl') + // f001 user → f002 listing (omitted) → f003 user (parent=f002, must reparent to f001) await writeFile( transcriptPath, `${JSON.stringify({ @@ -94,12 +105,18 @@ beforeAll(async () => { skillCount: 1, isInitial: true, }, + })}\n${JSON.stringify({ + type: 'user', + uuid: '00000000-0000-4000-8000-00000000f003', + parentUuid: '00000000-0000-4000-8000-00000000f002', + timestamp: '2026-08-07T00:00:01.000Z', + message: { role: 'user', content: 'after listing turn' }, })}\n`, ) - const realSession = await import('../utils/sessionStorage.js') + originalSessionStorageModule = await import('../utils/sessionStorage.js') mock.module('../utils/sessionStorage.js', () => ({ - ...realSession, + ...originalSessionStorageModule!, getTranscriptPath: () => transcriptPath, loadAllSubagentTranscriptsFromDisk: async () => ({ 'agent-leak': [ @@ -144,9 +161,26 @@ afterAll(async () => { } else { ;(globalThis as { MACRO?: unknown }).MACRO = originalMacro } + // mock.module is process-global in Bun — restore every module mocked in + // beforeAll, not only axios (otherwise later suites inherit leaks). if (originalAxiosModule) { mock.module('axios', () => originalAxiosModule!) } + if (originalProvidersModule) { + mock.module('../utils/model/providers.js', () => originalProvidersModule!) + } + if (originalAuthModule) { + mock.module('../utils/auth.js', () => originalAuthModule!) + } + if (originalHttpModule) { + mock.module('../utils/http.js', () => originalHttpModule!) + } + if (originalPrivacyModule) { + mock.module('../utils/privacyLevel.js', () => originalPrivacyModule!) + } + if (originalSessionStorageModule) { + mock.module('../utils/sessionStorage.js', () => originalSessionStorageModule!) + } if (tempDir) { await rm(tempDir, { recursive: true, force: true }) } @@ -190,7 +224,33 @@ test('Feedback upload strips listing payloads from the posted content body', asy const content = postedBodies[0]?.content ?? '' expect(content).toContain('plain turn') expect(content).toContain('feedback main turn') + expect(content).toContain('after listing turn') expect(content).toContain('subagent turn') expect(content).not.toContain('leak-me-please') expect(content).not.toContain('leak-agent-listing') + + // Posted report embeds filtered rawTranscriptJsonl — survivor f003 must + // reparent from omitted f002 onto retained ancestor f001. + const parsed = JSON.parse(content) as { rawTranscriptJsonl?: string } + expect(parsed.rawTranscriptJsonl).toBeDefined() + const lines = (parsed.rawTranscriptJsonl ?? '') + .split('\n') + .filter(l => l.length > 0) + .map( + line => + JSON.parse(line) as { + uuid?: string + parentUuid?: string | null + }, + ) + const afterListing = lines.find( + e => e.uuid === '00000000-0000-4000-8000-00000000f003', + ) + expect(afterListing).toBeDefined() + expect(afterListing?.parentUuid).toBe( + '00000000-0000-4000-8000-00000000f001', + ) + expect( + lines.some(e => e.uuid === '00000000-0000-4000-8000-00000000f002'), + ).toBe(false) }) diff --git a/src/components/Feedback.tsx b/src/components/Feedback.tsx index 3fb6683df5..9a654908d6 100644 --- a/src/components/Feedback.tsx +++ b/src/components/Feedback.tsx @@ -1,5 +1,4 @@ import axios from 'axios'; -import { readFile, stat } from 'fs/promises'; import * as React from 'react'; import { useCallback, useEffect, useState } from 'react'; import { getLastAPIRequest } from 'src/bootstrap/state.js'; @@ -14,7 +13,6 @@ import { startsWithApiErrorPrefix } from '../services/api/errors.js'; import type { Message } from '../types/message.js'; import { checkAndRefreshOAuthTokenIfNeeded } from '../utils/auth.js'; import { openBrowser } from '../utils/browser.js'; -import { logForDebugging } from '../utils/debug.js'; import { env } from '../utils/env.js'; import { type GitRepoState, getGitState, getIsGit } from '../utils/git.js'; import { getAuthHeaders, getUserAgent } from '../utils/http.js'; @@ -25,7 +23,7 @@ import { } from '../utils/model/providers.js'; import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'; import { jsonRedactor, redactJsonLines, redactSensitiveInfo } from '../utils/redaction.js'; -import { extractTeammateTranscriptsFromTasks, filterJsonlForExternalEgress, filterMessagesForExternalEgress, filterSubagentTranscriptsForExternalEgress, getTranscriptPath, loadAllSubagentTranscriptsFromDisk, MAX_TRANSCRIPT_READ_BYTES } from '../utils/sessionStorage.js'; +import { extractTeammateTranscriptsFromTasks, filterMessagesForExternalEgress, filterSubagentTranscriptsForExternalEgress, getTranscriptPath, loadAllSubagentTranscriptsFromDisk, readFilteredTranscriptJsonlForExternalEgress } from '../utils/sessionStorage.js'; import { jsonStringify } from '../utils/slowOperations.js'; import { asSystemPrompt } from '../utils/systemPromptType.js'; import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; @@ -154,17 +152,7 @@ function getSanitizedErrorLogs(): Array<{ } async function loadRawTranscriptJsonl(): Promise { try { - const transcriptPath = getTranscriptPath(); - const { - size - } = await stat(transcriptPath); - if (size > MAX_TRANSCRIPT_READ_BYTES) { - logForDebugging(`Skipping raw transcript read: file too large (${size} bytes)`, { - level: 'warn' - }); - return null; - } - return filterJsonlForExternalEgress(await readFile(transcriptPath, 'utf-8')); + return await readFilteredTranscriptJsonlForExternalEgress(getTranscriptPath()); } catch { return null; } diff --git a/src/components/FeedbackSurvey/submitTranscriptShare.ts b/src/components/FeedbackSurvey/submitTranscriptShare.ts index e59a9acd23..626b8a2496 100644 --- a/src/components/FeedbackSurvey/submitTranscriptShare.ts +++ b/src/components/FeedbackSurvey/submitTranscriptShare.ts @@ -1,5 +1,4 @@ import axios from 'axios' -import { readFile, stat } from 'fs/promises' import type { Message } from '../../types/message.js' import { checkAndRefreshOAuthTokenIfNeeded } from '../../utils/auth.js' import { logForDebugging } from '../../utils/debug.js' @@ -9,12 +8,11 @@ import { isFirstPartyAnthropicProvider } from '../../utils/model/providers.js' import { normalizeMessagesForAPI } from '../../utils/messages.js' import { extractAgentIdsFromMessages, - filterJsonlForExternalEgress, filterMessagesForExternalEgress, filterSubagentTranscriptsForExternalEgress, getTranscriptPath, loadSubagentTranscripts, - MAX_TRANSCRIPT_READ_BYTES, + readFilteredTranscriptJsonlForExternalEgress, } from '../../utils/sessionStorage.js' import { jsonStringify } from '../../utils/slowOperations.js' import { jsonRedactor, redactJsonLines, redactSensitiveInfo } from '../../utils/redaction.js' @@ -54,17 +52,11 @@ export async function submitTranscriptShare( // Read raw JSONL transcript (with size guard to prevent OOM) let rawTranscriptJsonl: string | undefined try { - const transcriptPath = getTranscriptPath() - const { size } = await stat(transcriptPath) - if (size <= MAX_TRANSCRIPT_READ_BYTES) { - rawTranscriptJsonl = filterJsonlForExternalEgress( - await readFile(transcriptPath, 'utf-8'), - ) - } else { - logForDebugging( - `Skipping raw transcript read: file too large (${size} bytes)`, - { level: 'warn' }, - ) + const filtered = await readFilteredTranscriptJsonlForExternalEgress( + getTranscriptPath(), + ) + if (filtered !== null) { + rawTranscriptJsonl = filtered } } catch { // File may not exist diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index a781e2eb6a..cc12d4e2bb 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -1,16 +1,34 @@ import { afterEach, describe, expect, test } from 'bun:test' import type { UUID } from 'crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setSessionPersistenceDisabled } from '../bootstrap/state.js' +import type { Message } from '../types/message.js' import { + clearSessionMessagesCache, filterJsonlForExternalEgress, filterMessagesForExternalEgress, filterSubagentTranscriptsForExternalEgress, + flushSessionStorage, + getRemoteEgressOmittedParentsForTesting, isSafeForExternalEgress, EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES, projectTranscriptParentForExternalEgress, + rebuildRemoteEgressOmittedParentsForTesting, recordExternalEgressOmission, + recordTranscript, + resetProjectForTesting, + setInternalEventWriter, + setSessionFileForTesting, } from './sessionStorage.js' const originalUserType = process.env.USER_TYPE +const originalHookSave = process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT +const originalEnablePersist = process.env.ENABLE_SESSION_PERSISTENCE +const originalTestPersist = process.env.TEST_ENABLE_SESSION_PERSISTENCE +const originalNodeEnv = process.env.NODE_ENV +const originalSkipHistory = process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY afterEach(() => { if (originalUserType === undefined) { @@ -18,6 +36,33 @@ afterEach(() => { } else { process.env.USER_TYPE = originalUserType } + if (originalHookSave === undefined) { + delete process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT + } else { + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = originalHookSave + } + if (originalEnablePersist === undefined) { + delete process.env.ENABLE_SESSION_PERSISTENCE + } else { + process.env.ENABLE_SESSION_PERSISTENCE = originalEnablePersist + } + if (originalTestPersist === undefined) { + delete process.env.TEST_ENABLE_SESSION_PERSISTENCE + } else { + process.env.TEST_ENABLE_SESSION_PERSISTENCE = originalTestPersist + } + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = originalNodeEnv + } + if (originalSkipHistory === undefined) { + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + } else { + process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY = originalSkipHistory + } + resetProjectForTesting() + clearSessionMessagesCache() }) function id(n: number): UUID { @@ -51,6 +96,24 @@ function listing( } } +function hookAttachment(uuid: UUID, parentUuid: UUID | null, leak: string) { + return { + type: 'attachment' as const, + uuid, + parentUuid, + attachment: { + type: 'hook_additional_context', + content: leak, + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: leak, + stderr: '', + exitCode: 0, + }, + } +} + describe('isSafeForExternalEgress', () => { test('rejects every external-egress listing type for external users', () => { process.env.USER_TYPE = 'external' @@ -87,6 +150,44 @@ describe('isSafeForExternalEgress', () => { process.env.USER_TYPE = 'external' expect(isSafeForExternalEgress({ type: 'user' })).toBe(true) }) + + test('blocks hook_additional_context for external even when local-save flag is on', () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + expect( + isSafeForExternalEgress({ + type: 'attachment', + attachment: { type: 'hook_additional_context', content: 'HOOK-LEAK' }, + }), + ).toBe(false) + }) + + test('allows hook_additional_context for ant without the local-save flag', () => { + process.env.USER_TYPE = 'ant' + delete process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT + expect( + isSafeForExternalEgress({ + type: 'attachment', + attachment: { type: 'hook_additional_context', content: 'ant-ok' }, + }), + ).toBe(true) + }) + + test('ant still blocks listings while allowing non-listing attachments', () => { + process.env.USER_TYPE = 'ant' + expect( + isSafeForExternalEgress({ + type: 'attachment', + attachment: { type: 'skill_listing', content: 'nope' }, + }), + ).toBe(false) + expect( + isSafeForExternalEgress({ + type: 'attachment', + attachment: { type: 'hook_additional_context', content: 'ok' }, + }), + ).toBe(true) + }) }) describe('filterMessagesForExternalEgress', () => { @@ -115,6 +216,18 @@ describe('filterMessagesForExternalEgress', () => { expect(filtered.map(m => m.uuid)).toEqual([id(1), id(4)]) expect(filtered[1]?.parentUuid).toBe(id(1)) }) + + test('strips hook_additional_context for external even with SAVE flag', () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = 'true' + const u1 = user(id(1), null, 'first') + const hook = hookAttachment(id(2), id(1), 'HOOK-SAVE-LEAK') + const u2 = user(id(3), id(2), 'after-hook') + const filtered = filterMessagesForExternalEgress([u1, hook, u2]) + expect(filtered.map(m => m.uuid)).toEqual([id(1), id(3)]) + expect(filtered[1]?.parentUuid).toBe(id(1)) + expect(JSON.stringify(filtered)).not.toContain('HOOK-SAVE-LEAK') + }) }) describe('filterJsonlForExternalEgress', () => { @@ -182,3 +295,174 @@ describe('recordExternalEgressOmission / projectTranscriptParentForExternalEgres expect(projected.parentUuid).toBe(id(1)) }) }) + +describe('rebuildRemoteEgressOmittedParentsFromLocalTranscript', () => { + test('rebuilds omission map from local JSONL so post-resume parents reparent', async () => { + process.env.USER_TYPE = 'external' + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-rebuild-')) + const path = join(dir, 'session.jsonl') + const userUuid = id(1) + const listingUuid = id(2) + try { + await writeFile( + path, + [ + JSON.stringify(user(userUuid, null, 'resume turn')), + JSON.stringify( + listing(listingUuid, userUuid, 'skill_listing', 'REBUILD-LEAK'), + ), + ].join('\n') + '\n', + ) + resetProjectForTesting() + setSessionFileForTesting(path) + rebuildRemoteEgressOmittedParentsForTesting() + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.has(listingUuid)).toBe(true) + expect(map.get(listingUuid)).toBe(userUuid) + const projected = projectTranscriptParentForExternalEgress( + { parentUuid: listingUuid }, + map, + ) + expect(projected.parentUuid).toBe(userUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe('appendEntry remote egress gate', () => { + // External isLoggableMessage drops listings, but keeps hook_additional_context + // when CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT is set. That local-save gate + // must NOT widen remote egress — these tests pin that boundary. + test('records omission with an active sink for locally-saved hook and reparents next remote entry', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-append-')) + const path = join(dir, 'session.jsonl') + const userUuid = id(10) + const hookUuid = id(11) + const afterUuid = id(12) + const remotePayloads: Array> = [] + + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + const hookMsg = { + type: 'attachment', + uuid: hookUuid, + parentUuid: userUuid, + timestamp: '2026-08-10T00:00:01.000Z', + attachment: { + type: 'hook_additional_context', + content: 'HOOK-APPEND-LEAK', + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: 'HOOK-APPEND-LEAK', + stderr: '', + exitCode: 0, + }, + } as unknown as Message + + const afterMsg = { + type: 'user', + uuid: afterUuid, + parentUuid: hookUuid, + timestamp: '2026-08-10T00:00:02.000Z', + message: { role: 'user', content: 'after hook' }, + } as unknown as Message + + const seedUser = { + type: 'user', + uuid: userUuid, + parentUuid: null, + timestamp: '2026-08-10T00:00:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + + await recordTranscript([seedUser]) + await flushSessionStorage() + remotePayloads.length = 0 + + await recordTranscript([hookMsg, afterMsg], undefined, userUuid) + await flushSessionStorage() + + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.has(hookUuid)).toBe(true) + + const remoteAfter = remotePayloads.find(p => p.uuid === afterUuid) + expect(remoteAfter).toBeDefined() + expect(remoteAfter?.parentUuid).toBe(userUuid) + expect(JSON.stringify(remotePayloads)).not.toContain('HOOK-APPEND-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('does not grow omission map when no remote sink is registered', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-nosink-')) + const path = join(dir, 'session.jsonl') + const userUuid = id(20) + const hookUuid = id(21) + + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + // No setInternalEventWriter / remote ingress → hasActiveRemoteEgressSink false + + const seedUser = { + type: 'user', + uuid: userUuid, + parentUuid: null, + timestamp: '2026-08-10T00:00:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + const hookMsg = { + type: 'attachment', + uuid: hookUuid, + parentUuid: userUuid, + timestamp: '2026-08-10T00:00:01.000Z', + attachment: { + type: 'hook_additional_context', + content: 'HOOK-NOSINK', + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: 'HOOK-NOSINK', + stderr: '', + exitCode: 0, + }, + } as unknown as Message + + await recordTranscript([seedUser, hookMsg]) + await flushSessionStorage() + + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.has(hookUuid)).toBe(false) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 78cfd434b9..f9fb46d34c 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -836,6 +836,19 @@ export function setSessionFileForTesting(path: string): void { getProject().sessionFile = path } +/** @internal Rebuild remote egress omission map from sessionFile (tests). */ +export function rebuildRemoteEgressOmittedParentsForTesting(): void { + getProject().rebuildRemoteEgressOmittedParentsFromLocalTranscript() +} + +/** @internal Snapshot remote egress omission map (tests). */ +export function getRemoteEgressOmittedParentsForTesting(): Map< + UUID, + UUID | null +> { + return getProject()._getRemoteEgressOmittedParentsForTesting() +} + type InternalEventWriter = ( eventType: string, payload: Record, @@ -1234,16 +1247,57 @@ class Project { ) } + /** + * True when a remote/CCR sink is registered so persistToRemote can deliver. + * Used to avoid unbounded growth of remoteEgressOmittedParents when no + * remote path will consume the map (hydrate-before-sink still uses rebuild). + */ + private hasActiveRemoteEgressSink(): boolean { + return ( + this.internalEventWriter !== null || + (!!this.remoteIngressUrl && + isEnvTruthy(process.env.ENABLE_SESSION_PERSISTENCE)) + ) + } + + /** @internal Expose omission map size/contents for egress regression tests. */ + _getRemoteEgressOmittedParentsForTesting(): Map { + return this.remoteEgressOmittedParents + } + /** * After --resume / --continue, remoteEgressOmittedParents starts empty * (resetSessionFile clears it). Rebuild from the adopted local transcript * so the first post-resume remote append whose parentUuid pointed at a * withheld entry can reparent instead of dangling on CCR / session-ingress. + * + * Size-guarded before readFileSync so a huge transcript cannot OOM the + * resume path. Skip rebuild (leave map empty) when over the shared + * MAX_TRANSCRIPT_READ_BYTES budget used by Feedback / share uploads. */ rebuildRemoteEgressOmittedParentsFromLocalTranscript(): void { this.remoteEgressOmittedParents.clear() const path = this.sessionFile if (!path) return + try { + const fd = openSync(path, 'r') + let size: number + try { + size = fstatSync(fd).size + } finally { + closeSync(fd) + } + if (size > MAX_TRANSCRIPT_READ_BYTES) { + logForDebugging( + 'Skipping remote egress omission rebuild: session file too large ' + + `(${size} bytes)`, + { level: 'warn' }, + ) + return + } + } catch { + return + } let content: string try { content = readFileSync(path, 'utf8') @@ -1254,12 +1308,15 @@ class Project { if (!line.trim()) continue let parsed: unknown try { - parsed = JSON.parse(line) + parsed = jsonParse(line) } catch { continue } if (!isTranscriptMessage(parsed as Entry)) continue const entry = parsed as TranscriptMessage + // Always apply current external egress policy. hook_additional_context is + // never safe for remote even when CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT + // allows local persistence — local save ≠ upload consent. if (!isSafeForExternalEgress(entry)) { recordExternalEgressOmission( this.remoteEgressOmittedParents, @@ -1953,7 +2010,10 @@ class Project { this.remoteEgressOmittedParents, ) await this.persistToRemote(sessionId, remoteEntry) - } else { + } else if (this.hasActiveRemoteEgressSink()) { + // Only grow the map when a remote sink can consume reparents. + // Resume rebuild (adoptResumedSessionFile) still hydrates from + // disk before the sink is registered. recordExternalEgressOmission( this.remoteEgressOmittedParents, entry.uuid, @@ -5493,6 +5553,11 @@ export function isLoggableMessage(m: Message): boolean { * Privacy gate for remote ingress / CCR / public sharing paths. * Listing catalogs (and other unsafe attachments) must not cross this * boundary — including the ant fast path below. + * + * Local transcript persistence (`isLoggableMessage`) may keep + * hook_additional_context when CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT is + * set. That flag must NOT widen this gate for external users: local save ≠ + * consent to upload to CCR / share / feedback. */ export function isSafeForExternalEgress(entry: { type?: string @@ -5503,8 +5568,15 @@ export function isSafeForExternalEgress(entry: { return false } if (getUserType() === 'ant') { + // Ant keeps non-listing attachments (incl. hook_additional_context) on + // remote for internal tooling. Progress stays out of the chain. return entry.type !== 'progress' } + // External: never allow hook_additional_context even when the local-save + // env flag is on. + if (attachmentTypeOf(entry) === 'hook_additional_context') { + return false + } if (entry.type === 'progress') return false if (entry.type !== 'attachment') return true if (!entry.attachment || typeof entry.attachment !== 'object') { @@ -5512,16 +5584,33 @@ export function isSafeForExternalEgress(entry: { } const t = (entry.attachment as { type?: unknown }).type if (typeof t !== 'string') return false - if ( - t === 'hook_additional_context' && - isEnvTruthy(process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT) - ) { - return true - } // Other attachment types remain blocked on egress for external users. return false } +/** + * Size-guarded read of a session JSONL file, then project through + * filterJsonlForExternalEgress. Shared by Feedback and transcript share so + * both paths stay byte-policy aligned (and cannot drift on the size cap). + */ +export async function readFilteredTranscriptJsonlForExternalEgress( + transcriptPath: string, +): Promise { + try { + const { size } = await stat(transcriptPath) + if (size > MAX_TRANSCRIPT_READ_BYTES) { + logForDebugging( + `Skipping raw transcript read: file too large (${size} bytes)`, + { level: 'warn' }, + ) + return null + } + return filterJsonlForExternalEgress(await readFile(transcriptPath, 'utf-8')) + } catch { + return null + } +} + /** * Drop entries that must not leave the local machine (share, feedback, * analytics payloads built from in-memory messages). Relinks parentUuid @@ -5629,7 +5718,7 @@ export function filterJsonlForExternalEgress(jsonl: string): string { }, omittedParents, ) - kept.push(JSON.stringify(projected)) + kept.push(jsonStringify(projected)) } else { kept.push(line) } From 24f9260afa7e03f8a2bc5c2fdf681ee049916212 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:51:13 +0900 Subject: [PATCH 04/31] fix(privacy): close CodeRabbit review 4892951252 (3 findings) - 3745816105: restore isSessionPersistenceDisabled via afterEach - 3745816106: add malformed JSONL line to rebuild fixture - 3745816114: collapse unreachable isSafeForExternalEgress branches --- src/utils/sessionStorage.externalEgress.test.ts | 8 +++++++- src/utils/sessionStorage.ts | 7 +------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index cc12d4e2bb..ecac4530bd 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -3,7 +3,10 @@ import type { UUID } from 'crypto' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { setSessionPersistenceDisabled } from '../bootstrap/state.js' +import { + isSessionPersistenceDisabled, + setSessionPersistenceDisabled, +} from '../bootstrap/state.js' import type { Message } from '../types/message.js' import { clearSessionMessagesCache, @@ -29,6 +32,7 @@ const originalEnablePersist = process.env.ENABLE_SESSION_PERSISTENCE const originalTestPersist = process.env.TEST_ENABLE_SESSION_PERSISTENCE const originalNodeEnv = process.env.NODE_ENV const originalSkipHistory = process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY +const originalSessionPersistenceDisabled = isSessionPersistenceDisabled() afterEach(() => { if (originalUserType === undefined) { @@ -61,6 +65,7 @@ afterEach(() => { } else { process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY = originalSkipHistory } + setSessionPersistenceDisabled(originalSessionPersistenceDisabled) resetProjectForTesting() clearSessionMessagesCache() }) @@ -308,6 +313,7 @@ describe('rebuildRemoteEgressOmittedParentsFromLocalTranscript', () => { path, [ JSON.stringify(user(userUuid, null, 'resume turn')), + '{not-json', JSON.stringify( listing(listingUuid, userUuid, 'skill_listing', 'REBUILD-LEAK'), ), diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index f9fb46d34c..997cdf8eed 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -5579,12 +5579,7 @@ export function isSafeForExternalEgress(entry: { } if (entry.type === 'progress') return false if (entry.type !== 'attachment') return true - if (!entry.attachment || typeof entry.attachment !== 'object') { - return false - } - const t = (entry.attachment as { type?: unknown }).type - if (typeof t !== 'string') return false - // Other attachment types remain blocked on egress for external users. + // Every remaining attachment type is blocked on egress for external users. return false } From 545a74a9d3edd9d717ec30cdc348eb1fb2e0ab4d Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:34:59 +0900 Subject: [PATCH 05/31] fix(privacy): close jatmn review 4893077611 on external egress - denylist skill_discovery + ant coverage (jatmn:sessionStorage.ts:skill_discovery) - bounded tail omission rebuild over MAX_TRANSCRIPT_READ_BYTES (jatmn:sessionStorage.ts:large-rebuild) - Feedback egress test seam; drop sessionStorage mock.module (jatmn:Feedback.egress.test.ts:mock-leak) --- src/components/Feedback.egress.test.ts | 61 ++++---- src/components/Feedback.tsx | 18 ++- .../sessionStorage.externalEgress.test.ts | 55 ++++++- src/utils/sessionStorage.ts | 144 ++++++++++++------ 4 files changed, 189 insertions(+), 89 deletions(-) diff --git a/src/components/Feedback.egress.test.ts b/src/components/Feedback.egress.test.ts index ad97c488ae..eeabadb589 100644 --- a/src/components/Feedback.egress.test.ts +++ b/src/components/Feedback.egress.test.ts @@ -13,18 +13,17 @@ type ProvidersModule = typeof import('../utils/model/providers.js') type AuthModule = typeof import('../utils/auth.js') type HttpModule = typeof import('../utils/http.js') type PrivacyModule = typeof import('../utils/privacyLevel.js') -type SessionStorageModule = typeof import('../utils/sessionStorage.js') let originalAxiosModule: AxiosModule | undefined let originalProvidersModule: ProvidersModule | undefined let originalAuthModule: AuthModule | undefined let originalHttpModule: HttpModule | undefined let originalPrivacyModule: PrivacyModule | undefined -let originalSessionStorageModule: SessionStorageModule | undefined let originalUserType: string | undefined let hadMacro = false let originalMacro: unknown let tempDir: string | undefined +let transcriptPath: string | undefined let postedBodies: Array<{ content?: string }> = [] function buildAxiosModuleStub( @@ -84,7 +83,7 @@ beforeAll(async () => { })) tempDir = await mkdtemp(join(tmpdir(), 'openclaude-feedback-egress-')) - const transcriptPath = join(tempDir, 'session.jsonl') + transcriptPath = join(tempDir, 'session.jsonl') // f001 user → f002 listing (omitted) → f003 user (parent=f002, must reparent to f001) await writeFile( transcriptPath, @@ -114,33 +113,8 @@ beforeAll(async () => { })}\n`, ) - originalSessionStorageModule = await import('../utils/sessionStorage.js') - mock.module('../utils/sessionStorage.js', () => ({ - ...originalSessionStorageModule!, - getTranscriptPath: () => transcriptPath, - loadAllSubagentTranscriptsFromDisk: async () => ({ - 'agent-leak': [ - { - type: 'attachment', - uuid: '00000000-0000-4000-8000-00000000a201', - attachment: { - type: 'agent_listing_delta', - addedTypes: ['Explore'], - addedLines: ['- Explore: /leak-agent-listing'], - removedTypes: [], - isInitial: true, - showConcurrencyNote: false, - }, - }, - { - type: 'user', - uuid: '00000000-0000-4000-8000-00000000a202', - message: { role: 'user', content: 'subagent turn' }, - }, - ], - }), - })) - + // Do NOT mock.module sessionStorage — that leaks into later suites under + // --max-concurrency=1. Pass transcript / subagent data via test seams. mock.module('axios', () => buildAxiosModuleStub(async (_url: unknown, body: unknown) => { postedBodies.push(body as { content?: string }) @@ -161,8 +135,6 @@ afterAll(async () => { } else { ;(globalThis as { MACRO?: unknown }).MACRO = originalMacro } - // mock.module is process-global in Bun — restore every module mocked in - // beforeAll, not only axios (otherwise later suites inherit leaks). if (originalAxiosModule) { mock.module('axios', () => originalAxiosModule!) } @@ -178,9 +150,6 @@ afterAll(async () => { if (originalPrivacyModule) { mock.module('../utils/privacyLevel.js', () => originalPrivacyModule!) } - if (originalSessionStorageModule) { - mock.module('../utils/sessionStorage.js', () => originalSessionStorageModule!) - } if (tempDir) { await rm(tempDir, { recursive: true, force: true }) } @@ -216,6 +185,28 @@ test('Feedback upload strips listing payloads from the posted content body', asy const report = await assembleFeedbackEgressReportData({ messages: [listing, user], description: 'egress regression', + transcriptPathForTesting: transcriptPath, + subagentTranscriptsForTesting: { + 'agent-leak': [ + { + type: 'attachment', + uuid: '00000000-0000-4000-8000-00000000a201', + attachment: { + type: 'agent_listing_delta', + addedTypes: ['Explore'], + addedLines: ['- Explore: /leak-agent-listing'], + removedTypes: [], + isInitial: true, + showConcurrencyNote: false, + }, + } as unknown as Message, + { + type: 'user', + uuid: '00000000-0000-4000-8000-00000000a202', + message: { role: 'user', content: 'subagent turn' }, + } as unknown as Message, + ], + }, }) const result = await submitFeedback(report) diff --git a/src/components/Feedback.tsx b/src/components/Feedback.tsx index 9a654908d6..dd0e01c668 100644 --- a/src/components/Feedback.tsx +++ b/src/components/Feedback.tsx @@ -88,13 +88,19 @@ export async function assembleFeedbackEgressReportData(args: { description: string; backgroundTasks?: FeedbackBackgroundTasks; gitRepo?: boolean; + /** @internal test seam — avoid mock.module of sessionStorage */ + transcriptPathForTesting?: string; + /** @internal test seam — avoid mock.module of sessionStorage */ + subagentTranscriptsForTesting?: Record; }): Promise { const backgroundTasks = args.backgroundTasks ?? {}; const lastAssistantMessage = getLastAssistantMessage(args.messages); const lastAssistantMessageId = lastAssistantMessage?.requestId ?? null; const [diskTranscripts, rawTranscriptJsonl] = await Promise.all([ - loadAllSubagentTranscriptsFromDisk(), - loadRawTranscriptJsonl(), + args.subagentTranscriptsForTesting !== undefined + ? Promise.resolve(args.subagentTranscriptsForTesting) + : loadAllSubagentTranscriptsFromDisk(), + loadRawTranscriptJsonl(args.transcriptPathForTesting), ]); const teammateTranscripts = extractTeammateTranscriptsFromTasks(backgroundTasks); const subagentTranscripts = filterSubagentTranscriptsForExternalEgress({ @@ -150,9 +156,13 @@ function getSanitizedErrorLogs(): Array<{ return errorCopy; }); } -async function loadRawTranscriptJsonl(): Promise { +async function loadRawTranscriptJsonl( + transcriptPath?: string, +): Promise { try { - return await readFilteredTranscriptJsonlForExternalEgress(getTranscriptPath()); + return await readFilteredTranscriptJsonlForExternalEgress( + transcriptPath ?? getTranscriptPath(), + ); } catch { return null; } diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index ecac4530bd..3f462d6620 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test' import type { UUID } from 'crypto' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile, appendFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -17,6 +17,7 @@ import { getRemoteEgressOmittedParentsForTesting, isSafeForExternalEgress, EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES, + MAX_TRANSCRIPT_READ_BYTES, projectTranscriptParentForExternalEgress, rebuildRemoteEgressOmittedParentsForTesting, recordExternalEgressOmission, @@ -144,6 +145,22 @@ describe('isSafeForExternalEgress', () => { } }) + test('rejects skill_discovery for ant users (denylist membership)', () => { + process.env.USER_TYPE = 'ant' + expect(EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES.has('skill_discovery')).toBe( + true, + ) + expect( + isSafeForExternalEgress({ + type: 'attachment', + attachment: { + type: 'skill_discovery', + skills: [{ name: 'leak-discovery', description: 'must not egress' }], + }, + }), + ).toBe(false) + }) + test('rejects progress for both ant and external', () => { process.env.USER_TYPE = 'external' expect(isSafeForExternalEgress({ type: 'progress' })).toBe(false) @@ -334,6 +351,42 @@ describe('rebuildRemoteEgressOmittedParentsFromLocalTranscript', () => { await rm(dir, { recursive: true, force: true }) } }) + + test('bounded tail rebuild recovers recent omissions when over MAX_TRANSCRIPT_READ_BYTES', async () => { + process.env.USER_TYPE = 'external' + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-rebuild-tail-')) + const path = join(dir, 'session.jsonl') + const userUuid = id(21) + const listingUuid = id(22) + try { + // Prefix larger than the rebuild budget so the oversized path runs. + await writeFile(path, Buffer.alloc(MAX_TRANSCRIPT_READ_BYTES + 1, 0x78)) + await appendFile( + path, + '\n' + + [ + JSON.stringify(user(userUuid, null, 'tail resume turn')), + JSON.stringify( + listing(listingUuid, userUuid, 'skill_listing', 'TAIL-LEAK'), + ), + ].join('\n') + + '\n', + ) + resetProjectForTesting() + setSessionFileForTesting(path) + rebuildRemoteEgressOmittedParentsForTesting() + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.has(listingUuid)).toBe(true) + expect(map.get(listingUuid)).toBe(userUuid) + const projected = projectTranscriptParentForExternalEgress( + { parentUuid: listingUuid }, + map, + ) + expect(projected.parentUuid).toBe(userUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) }) describe('appendEntry remote egress gate', () => { diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 997cdf8eed..9bd867b128 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -1271,60 +1271,21 @@ class Project { * so the first post-resume remote append whose parentUuid pointed at a * withheld entry can reparent instead of dangling on CCR / session-ingress. * - * Size-guarded before readFileSync so a huge transcript cannot OOM the - * resume path. Skip rebuild (leave map empty) when over the shared - * MAX_TRANSCRIPT_READ_BYTES budget used by Feedback / share uploads. + * Reads the full file when under MAX_TRANSCRIPT_READ_BYTES. For larger + * sessions (can reach GBs), performs a bounded tail read of that same + * budget so recent omission ancestry is still available for post-resume + * reparenting — never abandons the map as empty solely due to size. */ rebuildRemoteEgressOmittedParentsFromLocalTranscript(): void { this.remoteEgressOmittedParents.clear() const path = this.sessionFile if (!path) return - try { - const fd = openSync(path, 'r') - let size: number - try { - size = fstatSync(fd).size - } finally { - closeSync(fd) - } - if (size > MAX_TRANSCRIPT_READ_BYTES) { - logForDebugging( - 'Skipping remote egress omission rebuild: session file too large ' + - `(${size} bytes)`, - { level: 'warn' }, - ) - return - } - } catch { - return - } - let content: string - try { - content = readFileSync(path, 'utf8') - } catch { - return - } - for (const line of content.split('\n')) { - if (!line.trim()) continue - let parsed: unknown - try { - parsed = jsonParse(line) - } catch { - continue - } - if (!isTranscriptMessage(parsed as Entry)) continue - const entry = parsed as TranscriptMessage - // Always apply current external egress policy. hook_additional_context is - // never safe for remote even when CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT - // allows local persistence — local save ≠ upload consent. - if (!isSafeForExternalEgress(entry)) { - recordExternalEgressOmission( - this.remoteEgressOmittedParents, - entry.uuid, - entry.parentUuid ?? null, - ) - } - } + const content = readTranscriptContentForOmissionRebuild(path) + if (content === null) return + ingestRemoteEgressOmissionsFromTranscriptContent( + content, + this.remoteEgressOmittedParents, + ) } resetSessionFile(): void { @@ -3660,6 +3621,90 @@ function appendEntryToFile( }) } +/** + * Parse transcript JSONL lines into an omission map for remote egress + * reparenting. Shared by full-file and bounded-tail rebuild paths. + */ +function ingestRemoteEgressOmissionsFromTranscriptContent( + content: string, + omittedParents: Map, +): void { + for (const line of content.split('\n')) { + if (!line.trim()) continue + let parsed: unknown + try { + parsed = jsonParse(line) + } catch { + continue + } + if (!isTranscriptMessage(parsed as Entry)) continue + const entry = parsed as TranscriptMessage + // Always apply current external egress policy. hook_additional_context is + // never safe for remote even when CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT + // allows local persistence — local save ≠ upload consent. + if (!isSafeForExternalEgress(entry)) { + recordExternalEgressOmission( + omittedParents, + entry.uuid, + entry.parentUuid ?? null, + ) + } + } +} + +/** + * Load transcript text for omission-map rebuild. Full file when under + * MAX_TRANSCRIPT_READ_BYTES; otherwise a bounded tail of that budget + * (skipping a leading partial line) so huge sessions still recover recent + * withheld ancestry without OOM. + */ +function readTranscriptContentForOmissionRebuild( + fullPath: string, +): string | null { + let fd: number | undefined + try { + fd = openSync(fullPath, 'r') + const size = fstatSync(fd).size + if (size === 0) return '' + if (size <= MAX_TRANSCRIPT_READ_BYTES) { + closeSync(fd) + fd = undefined + return readFileSync(fullPath, 'utf8') + } + const readSize = MAX_TRANSCRIPT_READ_BYTES + const start = size - readSize + const buf = Buffer.allocUnsafe(readSize) + const bytesRead = readSync(fd, buf, 0, readSize, start) + let content = buf.toString('utf8', 0, bytesRead) + // Tail window may start mid-line — drop the incomplete first fragment. + const nl = content.indexOf('\n') + if (nl < 0) { + logForDebugging( + 'Bounded remote egress omission rebuild: no complete line in tail ' + + `window (${size} bytes)`, + { level: 'warn' }, + ) + return '' + } + content = content.slice(nl + 1) + logForDebugging( + 'Bounded remote egress omission rebuild from last ' + + `${readSize} bytes of ${size}-byte session file`, + ) + return content + } catch { + return null + } finally { + if (fd !== undefined) { + try { + closeSync(fd) + } catch { + // closeSync can throw; preserve null/content return + } + } + } +} + /** * Sync tail read for reAppendSessionMetadata's external-writer check. * fstat on the already-open fd (no extra path lookup); reads the same @@ -5471,6 +5516,7 @@ export async function loadAllSubagentTranscriptsFromDisk(): Promise<{ export const EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES: ReadonlySet = new Set([ 'skill_listing', + 'skill_discovery', 'agent_listing_delta', 'deferred_tools_delta', 'mcp_instructions_delta', From de683fad470f8ead771bd05832e444deb0562872 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:54:08 +0900 Subject: [PATCH 06/31] fix(privacy): close jatmn review 4893077611 (all four findings) P3: snapshot/restore sessionPersistenceDisabled with afterEach isolation regression. P1 items remain on this branch tip from prior close-out work. --- src/utils/sessionStorage.externalEgress.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 3f462d6620..5aeab875b7 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -33,6 +33,8 @@ const originalEnablePersist = process.env.ENABLE_SESSION_PERSISTENCE const originalTestPersist = process.env.TEST_ENABLE_SESSION_PERSISTENCE const originalNodeEnv = process.env.NODE_ENV const originalSkipHistory = process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY +// Snapshot/restore sessionPersistenceDisabled so append tests that force +// persistence on cannot leak enabled writes into later suites (order-safe). const originalSessionPersistenceDisabled = isSessionPersistenceDisabled() afterEach(() => { @@ -71,6 +73,21 @@ afterEach(() => { clearSessionMessagesCache() }) +describe('sessionPersistenceDisabled suite isolation', () => { + test('step1: append-style mutation leaves flag away from suite snapshot', () => { + setSessionPersistenceDisabled(!originalSessionPersistenceDisabled) + expect(isSessionPersistenceDisabled()).not.toBe( + originalSessionPersistenceDisabled, + ) + }) + + test('step2: afterEach restored the suite snapshot from step1', () => { + expect(isSessionPersistenceDisabled()).toBe( + originalSessionPersistenceDisabled, + ) + }) +}) + function id(n: number): UUID { return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` as UUID } From 25bc648b15ba2a1e32f0d2d6c2c88b4168069faf Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:22:07 +0900 Subject: [PATCH 07/31] fix(privacy): use OMISSION_REBUILD_TAIL_BYTES for egress rebuild (CodeRabbit 3749117985) --- src/utils/sessionStorage.externalEgress.test.ts | 6 +++--- src/utils/sessionStorage.ts | 13 +++++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 5aeab875b7..4dc44cfe58 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -17,7 +17,7 @@ import { getRemoteEgressOmittedParentsForTesting, isSafeForExternalEgress, EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES, - MAX_TRANSCRIPT_READ_BYTES, + OMISSION_REBUILD_TAIL_BYTES, projectTranscriptParentForExternalEgress, rebuildRemoteEgressOmittedParentsForTesting, recordExternalEgressOmission, @@ -369,7 +369,7 @@ describe('rebuildRemoteEgressOmittedParentsFromLocalTranscript', () => { } }) - test('bounded tail rebuild recovers recent omissions when over MAX_TRANSCRIPT_READ_BYTES', async () => { + test('bounded tail rebuild recovers recent omissions when over OMISSION_REBUILD_TAIL_BYTES', async () => { process.env.USER_TYPE = 'external' const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-rebuild-tail-')) const path = join(dir, 'session.jsonl') @@ -377,7 +377,7 @@ describe('rebuildRemoteEgressOmittedParentsFromLocalTranscript', () => { const listingUuid = id(22) try { // Prefix larger than the rebuild budget so the oversized path runs. - await writeFile(path, Buffer.alloc(MAX_TRANSCRIPT_READ_BYTES + 1, 0x78)) + await writeFile(path, Buffer.alloc(OMISSION_REBUILD_TAIL_BYTES + 1, 0x78)) await appendFile( path, '\n' + diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 9bd867b128..d8965ddcc9 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -553,6 +553,11 @@ export function getTranscriptPathForSession(sessionId: string): string { // read the raw transcript must bail out above this threshold to avoid OOM. export const MAX_TRANSCRIPT_READ_BYTES = 50 * 1024 * 1024 +// Omission-map rebuild only needs recent withheld ancestry for post-resume +// reparenting — not the full 50 MiB product-transcript budget. Keep this +// tail small so oversized sessions avoid a large synchronous startup read. +export const OMISSION_REBUILD_TAIL_BYTES = 2 * 1024 * 1024 + // In-memory map of agentId → subdirectory for grouping related subagent // transcripts (e.g. workflow runs write to subagents/workflows//). // Populated before the agent runs; consulted by getAgentTranscriptPath. @@ -1271,7 +1276,7 @@ class Project { * so the first post-resume remote append whose parentUuid pointed at a * withheld entry can reparent instead of dangling on CCR / session-ingress. * - * Reads the full file when under MAX_TRANSCRIPT_READ_BYTES. For larger + * Reads the full file when under OMISSION_REBUILD_TAIL_BYTES. For larger * sessions (can reach GBs), performs a bounded tail read of that same * budget so recent omission ancestry is still available for post-resume * reparenting — never abandons the map as empty solely due to size. @@ -3654,7 +3659,7 @@ function ingestRemoteEgressOmissionsFromTranscriptContent( /** * Load transcript text for omission-map rebuild. Full file when under - * MAX_TRANSCRIPT_READ_BYTES; otherwise a bounded tail of that budget + * OMISSION_REBUILD_TAIL_BYTES; otherwise a bounded tail of that budget * (skipping a leading partial line) so huge sessions still recover recent * withheld ancestry without OOM. */ @@ -3666,12 +3671,12 @@ function readTranscriptContentForOmissionRebuild( fd = openSync(fullPath, 'r') const size = fstatSync(fd).size if (size === 0) return '' - if (size <= MAX_TRANSCRIPT_READ_BYTES) { + if (size <= OMISSION_REBUILD_TAIL_BYTES) { closeSync(fd) fd = undefined return readFileSync(fullPath, 'utf8') } - const readSize = MAX_TRANSCRIPT_READ_BYTES + const readSize = Math.min(OMISSION_REBUILD_TAIL_BYTES, size) const start = size - readSize const buf = Buffer.allocUnsafe(readSize) const bytesRead = readSync(fd, buf, 0, readSize, start) From 60993e47c4b96843b6ed4ba781eafca91a506e19 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:12:58 +0900 Subject: [PATCH 08/31] fix(privacy): bound omission rebuild reads and preserve line-boundary tail records (CodeRabbit 3749477619) --- .../sessionStorage.externalEgress.test.ts | 43 +++++++++++++++++ src/utils/sessionStorage.ts | 46 +++++++++++++------ 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 4dc44cfe58..0ccbbac132 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -404,6 +404,49 @@ describe('rebuildRemoteEgressOmittedParentsFromLocalTranscript', () => { await rm(dir, { recursive: true, force: true }) } }) + + test('bounded tail rebuild keeps the first complete JSONL line when the window starts on a line boundary', async () => { + process.env.USER_TYPE = 'external' + const dir = await mkdtemp( + join(tmpdir(), 'openclaude-egress-rebuild-boundary-'), + ) + const path = join(dir, 'session.jsonl') + const userUuid = id(23) + const listingUuid = id(24) + try { + const listingLine = + JSON.stringify( + listing(listingUuid, userUuid, 'skill_listing', 'BOUNDARY-LEAK'), + ) + '\n' + const listingBytes = Buffer.byteLength(listingLine) + // Force the OMISSION_REBUILD_TAIL_BYTES window to begin exactly at the + // first byte of listingLine (previous byte is \n). Slicing at the first + // newline would discard this complete omission record. + const fillerExtra = 64 + const prefix = Buffer.concat([ + Buffer.alloc(fillerExtra - 1, 0x78), + Buffer.from('\n'), + ]) + const body = Buffer.concat([ + Buffer.from(listingLine), + Buffer.alloc(OMISSION_REBUILD_TAIL_BYTES - listingBytes, 0x78), + ]) + await writeFile(path, Buffer.concat([prefix, body])) + resetProjectForTesting() + setSessionFileForTesting(path) + rebuildRemoteEgressOmittedParentsForTesting() + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.has(listingUuid)).toBe(true) + expect(map.get(listingUuid)).toBe(userUuid) + const projected = projectTranscriptParentForExternalEgress( + { parentUuid: listingUuid }, + map, + ) + expect(projected.parentUuid).toBe(userUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) }) describe('appendEntry remote egress gate', () => { diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index d8965ddcc9..b774374b2d 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -4,7 +4,7 @@ import type { Dirent } from 'fs' // Sync fs primitives for readFileTailSync — separate from fs/promises // imports above. Named (not wildcard) per CLAUDE.md style; no collisions // with the async-suffixed names. -import { closeSync, fstatSync, openSync, readFileSync, readSync } from 'fs' +import { closeSync, fstatSync, openSync, readSync } from 'fs' import { appendFile as fsAppendFile, open as fsOpen, @@ -3672,26 +3672,44 @@ function readTranscriptContentForOmissionRebuild( const size = fstatSync(fd).size if (size === 0) return '' if (size <= OMISSION_REBUILD_TAIL_BYTES) { - closeSync(fd) - fd = undefined - return readFileSync(fullPath, 'utf8') + // Reuse the open fd — a second open via readFileSync can race with + // concurrent writers and can fail on Windows while this handle is live. + const buffer = Buffer.allocUnsafe(size) + let offset = 0 + while (offset < size) { + const bytesRead = readSync(fd, buffer, offset, size - offset, offset) + if (bytesRead === 0) { + break + } + offset += bytesRead + } + return buffer.toString('utf8', 0, offset) } const readSize = Math.min(OMISSION_REBUILD_TAIL_BYTES, size) const start = size - readSize const buf = Buffer.allocUnsafe(readSize) const bytesRead = readSync(fd, buf, 0, readSize, start) let content = buf.toString('utf8', 0, bytesRead) - // Tail window may start mid-line — drop the incomplete first fragment. - const nl = content.indexOf('\n') - if (nl < 0) { - logForDebugging( - 'Bounded remote egress omission rebuild: no complete line in tail ' + - `window (${size} bytes)`, - { level: 'warn' }, - ) - return '' + // Only drop a leading fragment when the window starts mid-line. If the + // first byte is already a line boundary (start===0 or previous byte is + // \n), slicing at the first newline would discard a complete JSONL record. + const previousByte = Buffer.alloc(1) + const startsAtLineBoundary = + start === 0 || + (readSync(fd, previousByte, 0, 1, start - 1) === 1 && + previousByte[0] === 0x0a) + if (!startsAtLineBoundary) { + const nl = content.indexOf('\n') + if (nl < 0) { + logForDebugging( + 'Bounded remote egress omission rebuild: no complete line in tail ' + + `window (${size} bytes)`, + { level: 'warn' }, + ) + return '' + } + content = content.slice(nl + 1) } - content = content.slice(nl + 1) logForDebugging( 'Bounded remote egress omission rebuild from last ' + `${readSize} bytes of ${size}-byte session file`, From cbd54160c783d08ecf1dab0091bb0ff3fee1f2bb Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:15:28 +0900 Subject: [PATCH 09/31] fix(privacy): close maintainer review 4899061317 Preserve omitted ancestry past a metadata-only tail, bound and prune the live omission map, and serialize the egress suite with the shared mutation lock. --- .../sessionStorage.externalEgress.test.ts | 176 ++++++++++++- src/utils/sessionStorage.ts | 236 +++++++++++++----- 2 files changed, 352 insertions(+), 60 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 0ccbbac132..5e54eeac71 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import type { UUID } from 'crypto' import { mkdtemp, rm, writeFile, appendFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -7,6 +7,10 @@ import { isSessionPersistenceDisabled, setSessionPersistenceDisabled, } from '../bootstrap/state.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' import type { Message } from '../types/message.js' import { clearSessionMessagesCache, @@ -17,6 +21,7 @@ import { getRemoteEgressOmittedParentsForTesting, isSafeForExternalEgress, EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, OMISSION_REBUILD_TAIL_BYTES, projectTranscriptParentForExternalEgress, rebuildRemoteEgressOmittedParentsForTesting, @@ -37,7 +42,12 @@ const originalSkipHistory = process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY // persistence on cannot leak enabled writes into later suites (order-safe). const originalSessionPersistenceDisabled = isSessionPersistenceDisabled() +beforeEach(async () => { + await acquireSharedMutationLock('utils/sessionStorage.externalEgress.test.ts') +}) + afterEach(() => { + try { if (originalUserType === undefined) { delete process.env.USER_TYPE } else { @@ -71,6 +81,9 @@ afterEach(() => { setSessionPersistenceDisabled(originalSessionPersistenceDisabled) resetProjectForTesting() clearSessionMessagesCache() + } finally { + releaseSharedMutationLock() + } }) describe('sessionPersistenceDisabled suite isolation', () => { @@ -447,6 +460,79 @@ describe('rebuildRemoteEgressOmittedParentsFromLocalTranscript', () => { await rm(dir, { recursive: true, force: true }) } }) + + test('ancestry-closure rebuild walks past a metadata-only tail larger than OMISSION_REBUILD_TAIL_BYTES', async () => { + process.env.USER_TYPE = 'external' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp( + join(tmpdir(), 'openclaude-egress-rebuild-ancestry-'), + ) + const path = join(dir, 'session.jsonl') + const userUuid = id(30) + const listingUuid = id(31) + const afterUuid = id(32) + const remotePayloads: Array> = [] + try { + const prefix = + [ + JSON.stringify(user(userUuid, null, 'ancestry resume turn')), + JSON.stringify( + listing(listingUuid, userUuid, 'skill_listing', 'ANCESTRY-LEAK'), + ), + ].join('\n') + '\n' + // Many ~1KB non-transcript lines — not one huge line — so the default + // tail window is metadata-only and the chain tip sits earlier. + const snapshotPad = 's'.repeat(900) + const snapshotLines: string[] = [] + let snapshotBytes = 0 + let i = 0 + while (snapshotBytes <= OMISSION_REBUILD_TAIL_BYTES) { + const line = + JSON.stringify({ + type: 'file-history-snapshot', + messageId: id(2000 + i), + snapshot: { pad: snapshotPad, i }, + isSnapshotUpdate: false, + }) + '\n' + snapshotLines.push(line) + snapshotBytes += Buffer.byteLength(line) + i += 1 + } + await writeFile(path, prefix + snapshotLines.join('')) + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + rebuildRemoteEgressOmittedParentsForTesting() + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.has(listingUuid)).toBe(true) + expect(map.get(listingUuid)).toBe(userUuid) + + const afterMsg = { + type: 'user', + uuid: afterUuid, + parentUuid: listingUuid, + timestamp: '2026-08-11T00:00:00.000Z', + message: { role: 'user', content: 'first post-resume' }, + } as unknown as Message + await recordTranscript([afterMsg], undefined, listingUuid) + await flushSessionStorage() + + const remoteAfter = remotePayloads.find(p => p.uuid === afterUuid) + expect(remoteAfter).toBeDefined() + expect(remoteAfter?.parentUuid).toBe(userUuid) + expect(JSON.stringify(remotePayloads)).not.toContain('ANCESTRY-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) }) describe('appendEntry remote egress gate', () => { @@ -519,7 +605,7 @@ describe('appendEntry remote egress gate', () => { await flushSessionStorage() const map = getRemoteEgressOmittedParentsForTesting() - expect(map.has(hookUuid)).toBe(true) + expect(map.has(hookUuid)).toBe(false) const remoteAfter = remotePayloads.find(p => p.uuid === afterUuid) expect(remoteAfter).toBeDefined() @@ -584,4 +670,90 @@ describe('appendEntry remote egress gate', () => { await rm(dir, { recursive: true, force: true }) } }) + + test('bounds live omission map and still reparents the next safe entry', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-bound-')) + const path = join(dir, 'session.jsonl') + const userUuid = id(100) + const firstListing = id(101) + const lastListing = id(100 + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE + 16) + const afterUuid = id(200) + const remotePayloads: Array> = [] + + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + const seedUser = { + type: 'user', + uuid: userUuid, + parentUuid: null, + timestamp: '2026-08-11T00:00:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + await recordTranscript([seedUser]) + await flushSessionStorage() + remotePayloads.length = 0 + + const listings: Message[] = [] + let parent: UUID = userUuid + for (let n = 0; n < MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE + 16; n++) { + const uuid = id(101 + n) + listings.push({ + type: 'attachment', + uuid, + parentUuid: parent, + timestamp: `2026-08-11T00:${String(Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}.000Z`, + attachment: { + type: 'hook_additional_context', + content: `BOUND-LEAK-${n}`, + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: `BOUND-LEAK-${n}`, + stderr: '', + exitCode: 0, + }, + } as unknown as Message) + parent = uuid + } + await recordTranscript(listings, undefined, userUuid) + await flushSessionStorage() + + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.size).toBeLessThanOrEqual(MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) + expect(map.has(firstListing)).toBe(false) + expect(map.has(lastListing)).toBe(true) + + const afterMsg = { + type: 'user', + uuid: afterUuid, + parentUuid: lastListing, + timestamp: '2026-08-11T00:01:00.000Z', + message: { role: 'user', content: 'after bound listings' }, + } as unknown as Message + await recordTranscript([afterMsg], undefined, lastListing) + await flushSessionStorage() + + const remoteAfter = remotePayloads.find(p => p.uuid === afterUuid) + expect(remoteAfter).toBeDefined() + expect(remoteAfter?.parentUuid).toBe(userUuid) + expect(JSON.stringify(remotePayloads)).not.toContain('BOUND-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) }) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index b774374b2d..e03da780ed 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -558,6 +558,11 @@ export const MAX_TRANSCRIPT_READ_BYTES = 50 * 1024 * 1024 // tail small so oversized sessions avoid a large synchronous startup read. export const OMISSION_REBUILD_TAIL_BYTES = 2 * 1024 * 1024 +// Live omission map must stay bounded: only withheld UUIDs that can still +// be selected as a local parent need to remain. Excess oldest keys are +// evicted and must never be emitted as a remote parentUuid. +export const MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE = 64 + // In-memory map of agentId → subdirectory for grouping related subagent // transcripts (e.g. workflow runs write to subagents/workflows//). // Populated before the agent runs; consulted by getAgentTranscriptPath. @@ -942,6 +947,8 @@ class Project { * persistToRemote calls can reparent instead of dangling. */ private remoteEgressOmittedParents = new Map() + private evictedRemoteEgressOmissions = new Set() + private lastRemoteEgressUuid: UUID | null = null constructor() {} @@ -954,6 +961,8 @@ class Project { this.activeDrain = null this.writeQueues = new Map() this.remoteEgressOmittedParents.clear() + this.evictedRemoteEgressOmissions.clear() + this.lastRemoteEgressUuid = null this.rewriteBarrierFiles = new Set() this.pendingRewriteCounts = new Map() this.pendingDirectAppends = new Map() @@ -1277,18 +1286,19 @@ class Project { * withheld entry can reparent instead of dangling on CCR / session-ingress. * * Reads the full file when under OMISSION_REBUILD_TAIL_BYTES. For larger - * sessions (can reach GBs), performs a bounded tail read of that same - * budget so recent omission ancestry is still available for post-resume - * reparenting — never abandons the map as empty solely due to size. + * sessions (can reach GBs), starts with a bounded tail read of that same + * budget, then walks earlier windows until every tail/chain-tip parent + * resolves to a known egressed ancestor (or file start). Metadata-only + * tails must not leave the map empty and dangle the first post-resume + * append on a withheld parentUuid. */ rebuildRemoteEgressOmittedParentsFromLocalTranscript(): void { this.remoteEgressOmittedParents.clear() + this.evictedRemoteEgressOmissions.clear() const path = this.sessionFile if (!path) return - const content = readTranscriptContentForOmissionRebuild(path) - if (content === null) return - ingestRemoteEgressOmissionsFromTranscriptContent( - content, + ingestRemoteEgressOmissionsFromTranscriptFile( + path, this.remoteEgressOmittedParents, ) } @@ -1297,6 +1307,8 @@ class Project { this.sessionFile = null this.pendingEntries = [] this.remoteEgressOmittedParents.clear() + this.evictedRemoteEgressOmissions.clear() + this.lastRemoteEgressUuid = null } /** @@ -1971,11 +1983,28 @@ class Project { // buildConversationChain do not stop at a missing parentUuid. if (isTranscriptMessage(entry)) { if (isSafeForExternalEgress(entry)) { - const remoteEntry = projectTranscriptParentForExternalEgress( + const originalParentUuid = entry.parentUuid ?? null + let remoteEntry = projectTranscriptParentForExternalEgress( entry, this.remoteEgressOmittedParents, ) + if ( + originalParentUuid && + this.evictedRemoteEgressOmissions.has(originalParentUuid) + ) { + remoteEntry = { + ...entry, + parentUuid: this.lastRemoteEgressUuid, + } + } await this.persistToRemote(sessionId, remoteEntry) + if (remoteEntry.uuid) { + this.lastRemoteEgressUuid = remoteEntry.uuid + } + pruneRemoteEgressOmissionsAfterProjection( + this.remoteEgressOmittedParents, + originalParentUuid, + ) } else if (this.hasActiveRemoteEgressSink()) { // Only grow the map when a remote sink can consume reparents. // Resume rebuild (adoptResumedSessionFile) still hydrates from @@ -1985,6 +2014,10 @@ class Project { entry.uuid, entry.parentUuid, ) + boundRemoteEgressOmissionMap( + this.remoteEgressOmittedParents, + this.evictedRemoteEgressOmissions, + ) } } } @@ -3633,7 +3666,12 @@ function appendEntryToFile( function ingestRemoteEgressOmissionsFromTranscriptContent( content: string, omittedParents: Map, -): void { + extras?: { + egressed?: Set + parentRefs?: Set + }, +): boolean { + let sawTranscript = false for (const line of content.split('\n')) { if (!line.trim()) continue let parsed: unknown @@ -3644,6 +3682,7 @@ function ingestRemoteEgressOmissionsFromTranscriptContent( } if (!isTranscriptMessage(parsed as Entry)) continue const entry = parsed as TranscriptMessage + sawTranscript = true // Always apply current external egress policy. hook_additional_context is // never safe for remote even when CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT // allows local persistence — local save ≠ upload consent. @@ -3653,81 +3692,162 @@ function ingestRemoteEgressOmissionsFromTranscriptContent( entry.uuid, entry.parentUuid ?? null, ) + } else if (extras?.egressed && entry.uuid) { + extras.egressed.add(entry.uuid) + } + if (extras?.parentRefs && entry.parentUuid) { + extras.parentRefs.add(entry.parentUuid) + } + } + return sawTranscript +} + +function isOmissionAncestryClosed( + referencedParents: Set, + omittedParents: Map, + egressed: Set, +): boolean { + for (const parent of referencedParents) { + let current: UUID | null = parent + const seen = new Set() + while (current && omittedParents.has(current) && !seen.has(current)) { + seen.add(current) + current = omittedParents.get(current) ?? null + } + if (current !== null && !egressed.has(current)) { + return false } } + return true +} + +function readTranscriptRangeForOmissionRebuild( + fd: number, + start: number, + end: number, +): { content: string; nextEnd: number } { + const readSize = end - start + if (readSize <= 0) { + return { content: '', nextEnd: start } + } + const buf = Buffer.allocUnsafe(readSize) + const bytesRead = readSync(fd, buf, 0, readSize, start) + let content = buf.toString('utf8', 0, bytesRead) + const previousByte = Buffer.alloc(1) + const startsAtLineBoundary = + start === 0 || + (readSync(fd, previousByte, 0, 1, start - 1) === 1 && + previousByte[0] === 0x0a) + let nextEnd = start + if (!startsAtLineBoundary) { + const nl = content.indexOf('\n') + if (nl < 0) { + return { content: '', nextEnd: start } + } + nextEnd = start + nl + 1 + content = content.slice(nl + 1) + } + return { content, nextEnd } } /** - * Load transcript text for omission-map rebuild. Full file when under - * OMISSION_REBUILD_TAIL_BYTES; otherwise a bounded tail of that budget - * (skipping a leading partial line) so huge sessions still recover recent - * withheld ancestry without OOM. + * Rebuild the omission map from disk. Start at the tail + * (OMISSION_REBUILD_TAIL_BYTES). If the tail is metadata-only or any + * chain-tip parent is still unresolved, walk earlier windows of the same + * budget until ancestry closes or MAX_TRANSCRIPT_READ_BYTES is scanned. */ -function readTranscriptContentForOmissionRebuild( +function ingestRemoteEgressOmissionsFromTranscriptFile( fullPath: string, -): string | null { + omittedParents: Map, +): void { let fd: number | undefined try { fd = openSync(fullPath, 'r') const size = fstatSync(fd).size - if (size === 0) return '' - if (size <= OMISSION_REBUILD_TAIL_BYTES) { - // Reuse the open fd — a second open via readFileSync can race with - // concurrent writers and can fail on Windows while this handle is live. - const buffer = Buffer.allocUnsafe(size) - let offset = 0 - while (offset < size) { - const bytesRead = readSync(fd, buffer, offset, size - offset, offset) - if (bytesRead === 0) { - break - } - offset += bytesRead + if (size === 0) return + + const egressed = new Set() + const referencedParents = new Set() + let sawTranscript = false + let cursor = size + let scanned = 0 + + while (cursor > 0 && scanned < MAX_TRANSCRIPT_READ_BYTES) { + const start = Math.max(0, cursor - OMISSION_REBUILD_TAIL_BYTES) + const { content, nextEnd } = readTranscriptRangeForOmissionRebuild( + fd, + start, + cursor, + ) + scanned += cursor - start + const collectRefs = !sawTranscript + const chunkSaw = ingestRemoteEgressOmissionsFromTranscriptContent( + content, + omittedParents, + { + egressed, + parentRefs: collectRefs ? referencedParents : undefined, + }, + ) + if (chunkSaw) { + sawTranscript = true } - return buffer.toString('utf8', 0, offset) - } - const readSize = Math.min(OMISSION_REBUILD_TAIL_BYTES, size) - const start = size - readSize - const buf = Buffer.allocUnsafe(readSize) - const bytesRead = readSync(fd, buf, 0, readSize, start) - let content = buf.toString('utf8', 0, bytesRead) - // Only drop a leading fragment when the window starts mid-line. If the - // first byte is already a line boundary (start===0 or previous byte is - // \n), slicing at the first newline would discard a complete JSONL record. - const previousByte = Buffer.alloc(1) - const startsAtLineBoundary = - start === 0 || - (readSync(fd, previousByte, 0, 1, start - 1) === 1 && - previousByte[0] === 0x0a) - if (!startsAtLineBoundary) { - const nl = content.indexOf('\n') - if (nl < 0) { - logForDebugging( - 'Bounded remote egress omission rebuild: no complete line in tail ' + - `window (${size} bytes)`, - { level: 'warn' }, + if ( + sawTranscript && + isOmissionAncestryClosed( + referencedParents, + omittedParents, + egressed, ) - return '' + ) { + return + } + if (nextEnd < cursor) { + cursor = nextEnd + } else if (start < cursor) { + cursor = start + } else { + break } - content = content.slice(nl + 1) } - logForDebugging( - 'Bounded remote egress omission rebuild from last ' + - `${readSize} bytes of ${size}-byte session file`, - ) - return content } catch { - return null + return } finally { if (fd !== undefined) { try { closeSync(fd) } catch { - // closeSync can throw; preserve null/content return + // closeSync can throw; preserve empty-map rebuild } } } } +function boundRemoteEgressOmissionMap( + omittedParents: Map, + evicted: Set, +): void { + while (omittedParents.size > MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) { + const oldest = omittedParents.keys().next().value + if (oldest === undefined) break + omittedParents.delete(oldest) + evicted.add(oldest) + } +} + +function pruneRemoteEgressOmissionsAfterProjection( + omittedParents: Map, + originalParentUuid: UUID | null, +): void { + if (!originalParentUuid || !omittedParents.has(originalParentUuid)) { + return + } + for (const key of [...omittedParents.keys()]) { + omittedParents.delete(key) + if (key === originalParentUuid) break + } +} + /** * Sync tail read for reAppendSessionMetadata's external-writer check. * fstat on the already-open fd (no extra path lookup); reads the same From 401a74dc401d7a91fccdba878ea5758854ae2a4e Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:30:47 +0900 Subject: [PATCH 10/31] fix(privacy): bound evicted egress set and own test lock Close CodeRabbit review 4902904865: track shared-mutation lock ownership so a timed-out beforeEach cannot release another suite, and cap evictedRemoteEgressOmissions at the same 64-entry bound as remoteEgressOmittedParents. --- src/utils/sessionStorage.externalEgress.test.ts | 8 +++++++- src/utils/sessionStorage.ts | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 5e54eeac71..e65655b6df 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -41,9 +41,12 @@ const originalSkipHistory = process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY // Snapshot/restore sessionPersistenceDisabled so append tests that force // persistence on cannot leak enabled writes into later suites (order-safe). const originalSessionPersistenceDisabled = isSessionPersistenceDisabled() +let ownsSharedMutationLock = false beforeEach(async () => { + ownsSharedMutationLock = false await acquireSharedMutationLock('utils/sessionStorage.externalEgress.test.ts') + ownsSharedMutationLock = true }) afterEach(() => { @@ -82,7 +85,10 @@ afterEach(() => { resetProjectForTesting() clearSessionMessagesCache() } finally { - releaseSharedMutationLock() + if (ownsSharedMutationLock) { + ownsSharedMutationLock = false + releaseSharedMutationLock() + } } }) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index e03da780ed..d813de1481 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -947,6 +947,8 @@ class Project { * persistToRemote calls can reparent instead of dangling. */ private remoteEgressOmittedParents = new Map() + // Bounded with the same policy as remoteEgressOmittedParents: only recent + // evictions can still appear as an incoming parentUuid. private evictedRemoteEgressOmissions = new Set() private lastRemoteEgressUuid: UUID | null = null @@ -3833,6 +3835,11 @@ function boundRemoteEgressOmissionMap( omittedParents.delete(oldest) evicted.add(oldest) } + while (evicted.size > MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) { + const oldest = evicted.values().next().value + if (oldest === undefined) break + evicted.delete(oldest) + } } function pruneRemoteEgressOmissionsAfterProjection( From 7071e31a724dfdaca5b0cb324a5095971c028671 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:25:17 +0900 Subject: [PATCH 11/31] fix(privacy): retain compact ancestry after double omission eviction CodeRabbit 3755270409: UUIDs dropped from both the 64-entry omission map and the 64-entry evicted set still resolve via lastRemoteEgressUuid when a later child references them as parentUuid. --- .../sessionStorage.externalEgress.test.ts | 87 +++++++++++++++++++ src/utils/sessionStorage.ts | 14 ++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index e65655b6df..08f9f500b5 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -762,4 +762,91 @@ describe('appendEntry remote egress gate', () => { await rm(dir, { recursive: true, force: true }) } }) + + test('compact ancestry fallback reparents a child of the oldest UUID after more than 129 omissions', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const omissionCount = MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE * 2 + 2 + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-ancestry-')) + const path = join(dir, 'session.jsonl') + const userUuid = id(300) + const firstListing = id(301) + const afterUuid = id(500) + const remotePayloads: Array> = [] + + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + const seedUser = { + type: 'user', + uuid: userUuid, + parentUuid: null, + timestamp: '2026-08-11T00:00:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + await recordTranscript([seedUser]) + await flushSessionStorage() + remotePayloads.length = 0 + + const listings: Message[] = [] + let parent: UUID = userUuid + for (let n = 0; n < omissionCount; n++) { + const uuid = id(301 + n) + listings.push({ + type: 'attachment', + uuid, + parentUuid: parent, + timestamp: `2026-08-11T00:${String(Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}.000Z`, + attachment: { + type: 'hook_additional_context', + content: `ANCESTRY-LEAK-${n}`, + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: `ANCESTRY-LEAK-${n}`, + stderr: '', + exitCode: 0, + }, + } as unknown as Message) + parent = uuid + } + await recordTranscript(listings, undefined, userUuid) + await flushSessionStorage() + + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.size).toBeLessThanOrEqual(MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) + expect(map.has(firstListing)).toBe(false) + expect(omissionCount).toBeGreaterThan(129) + + const afterMsg = { + type: 'user', + uuid: afterUuid, + parentUuid: firstListing, + timestamp: '2026-08-11T00:03:00.000Z', + message: { role: 'user', content: 'child of oldest omitted uuid' }, + } as unknown as Message + await recordTranscript([afterMsg], undefined, firstListing) + await flushSessionStorage() + + const remoteAfter = remotePayloads.find(p => p.uuid === afterUuid) + expect(remoteAfter).toBeDefined() + expect(remoteAfter?.parentUuid).toBe(userUuid) + expect(remoteAfter?.parentUuid).not.toBe(firstListing) + expect(JSON.stringify(remotePayloads)).not.toContain('ANCESTRY-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) }) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index d813de1481..5357ea1034 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -950,6 +950,9 @@ class Project { // Bounded with the same policy as remoteEgressOmittedParents: only recent // evictions can still appear as an incoming parentUuid. private evictedRemoteEgressOmissions = new Set() + // UUIDs dropped from both bounded collections. Ancestry is compact: every + // key shares lastRemoteEgressUuid rather than a per-uuid ancestor map. + private remoteEgressCompactAncestryUuids = new Set() private lastRemoteEgressUuid: UUID | null = null constructor() {} @@ -964,6 +967,7 @@ class Project { this.writeQueues = new Map() this.remoteEgressOmittedParents.clear() this.evictedRemoteEgressOmissions.clear() + this.remoteEgressCompactAncestryUuids.clear() this.lastRemoteEgressUuid = null this.rewriteBarrierFiles = new Set() this.pendingRewriteCounts = new Map() @@ -1297,6 +1301,7 @@ class Project { rebuildRemoteEgressOmittedParentsFromLocalTranscript(): void { this.remoteEgressOmittedParents.clear() this.evictedRemoteEgressOmissions.clear() + this.remoteEgressCompactAncestryUuids.clear() const path = this.sessionFile if (!path) return ingestRemoteEgressOmissionsFromTranscriptFile( @@ -1310,6 +1315,7 @@ class Project { this.pendingEntries = [] this.remoteEgressOmittedParents.clear() this.evictedRemoteEgressOmissions.clear() + this.remoteEgressCompactAncestryUuids.clear() this.lastRemoteEgressUuid = null } @@ -1992,7 +1998,10 @@ class Project { ) if ( originalParentUuid && - this.evictedRemoteEgressOmissions.has(originalParentUuid) + (this.evictedRemoteEgressOmissions.has(originalParentUuid) || + this.remoteEgressCompactAncestryUuids.has( + originalParentUuid, + )) ) { remoteEntry = { ...entry, @@ -2019,6 +2028,7 @@ class Project { boundRemoteEgressOmissionMap( this.remoteEgressOmittedParents, this.evictedRemoteEgressOmissions, + this.remoteEgressCompactAncestryUuids, ) } } @@ -3828,6 +3838,7 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( function boundRemoteEgressOmissionMap( omittedParents: Map, evicted: Set, + compactAncestryUuids: Set, ): void { while (omittedParents.size > MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) { const oldest = omittedParents.keys().next().value @@ -3839,6 +3850,7 @@ function boundRemoteEgressOmissionMap( const oldest = evicted.values().next().value if (oldest === undefined) break evicted.delete(oldest) + compactAncestryUuids.add(oldest) } } From 385ca39084d7123354296e6fb162c9b7ac1baf74 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:43:10 +0900 Subject: [PATCH 12/31] fix(privacy): store actual compact omission ancestors CodeRabbit 4903173031: - 3755446437: drop the unbounded compact UUID set; persist a 64-bounded map of omitted uuid -> actual external ancestor and delete on eviction. - outside-diff 1998-2006: never reparent compacted omissions to lastRemoteEgressUuid; resolve the real ancestor (map or on-demand walk) so a later sibling cannot steal a compacted listing's child. --- .../sessionStorage.externalEgress.test.ts | 198 ++++++++++++++++++ src/utils/sessionStorage.ts | 145 +++++++++++-- 2 files changed, 327 insertions(+), 16 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 08f9f500b5..2d8641f505 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -849,4 +849,202 @@ describe('appendEntry remote egress gate', () => { await rm(dir, { recursive: true, force: true }) } }) + + test('compact ancestry reparents a child to the actual ancestor after a later sibling updates lastRemoteEgressUuid', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const omissionCount = MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE + 16 + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-sibling-')) + const path = join(dir, 'session.jsonl') + const userUuid = id(600) + const firstListing = id(601) + const siblingUuid = id(800) + const childUuid = id(801) + const remotePayloads: Array> = [] + + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + const seedUser = { + type: 'user', + uuid: userUuid, + parentUuid: null, + timestamp: '2026-08-11T00:00:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + await recordTranscript([seedUser]) + await flushSessionStorage() + remotePayloads.length = 0 + + const listings: Message[] = [] + let parent: UUID = userUuid + for (let n = 0; n < omissionCount; n++) { + const uuid = id(601 + n) + listings.push({ + type: 'attachment', + uuid, + parentUuid: parent, + timestamp: `2026-08-11T00:${String(Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}.000Z`, + attachment: { + type: 'hook_additional_context', + content: `SIBLING-LEAK-${n}`, + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: `SIBLING-LEAK-${n}`, + stderr: '', + exitCode: 0, + }, + } as unknown as Message) + parent = uuid + } + await recordTranscript(listings, undefined, userUuid) + await flushSessionStorage() + + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.has(firstListing)).toBe(false) + + const sibling = { + type: 'user', + uuid: siblingUuid, + parentUuid: userUuid, + timestamp: '2026-08-11T00:02:00.000Z', + message: { role: 'user', content: 'later sibling of compacted omission' }, + } as unknown as Message + await recordTranscript([sibling], undefined, userUuid) + await flushSessionStorage() + + const remoteSibling = remotePayloads.find(p => p.uuid === siblingUuid) + expect(remoteSibling?.parentUuid).toBe(userUuid) + + const child = { + type: 'user', + uuid: childUuid, + parentUuid: firstListing, + timestamp: '2026-08-11T00:02:01.000Z', + message: { role: 'user', content: 'child of compacted omission' }, + } as unknown as Message + await recordTranscript([child], undefined, firstListing) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + expect(remoteChild?.parentUuid).toBe(userUuid) + expect(remoteChild?.parentUuid).not.toBe(siblingUuid) + expect(remoteChild?.parentUuid).not.toBe(firstListing) + expect(JSON.stringify(remotePayloads)).not.toContain('SIBLING-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('on-demand ancestry reparents a child after compact eviction and a later sibling persist', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const omissionCount = MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE * 2 + 2 + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-ondemand-')) + const path = join(dir, 'session.jsonl') + const userUuid = id(900) + const firstListing = id(901) + const siblingUuid = id(1100) + const childUuid = id(1101) + const remotePayloads: Array> = [] + + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + const seedUser = { + type: 'user', + uuid: userUuid, + parentUuid: null, + timestamp: '2026-08-11T00:00:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + await recordTranscript([seedUser]) + await flushSessionStorage() + remotePayloads.length = 0 + + const listings: Message[] = [] + let parent: UUID = userUuid + for (let n = 0; n < omissionCount; n++) { + const uuid = id(901 + n) + listings.push({ + type: 'attachment', + uuid, + parentUuid: parent, + timestamp: `2026-08-11T00:${String(Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}.000Z`, + attachment: { + type: 'hook_additional_context', + content: `ONDEMAND-LEAK-${n}`, + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: `ONDEMAND-LEAK-${n}`, + stderr: '', + exitCode: 0, + }, + } as unknown as Message) + parent = uuid + } + await recordTranscript(listings, undefined, userUuid) + await flushSessionStorage() + + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.has(firstListing)).toBe(false) + expect(omissionCount).toBeGreaterThan(129) + + const sibling = { + type: 'user', + uuid: siblingUuid, + parentUuid: userUuid, + timestamp: '2026-08-11T00:03:00.000Z', + message: { role: 'user', content: 'later sibling after compact eviction' }, + } as unknown as Message + await recordTranscript([sibling], undefined, userUuid) + await flushSessionStorage() + + const child = { + type: 'user', + uuid: childUuid, + parentUuid: firstListing, + timestamp: '2026-08-11T00:03:01.000Z', + message: { role: 'user', content: 'child after compact eviction' }, + } as unknown as Message + await recordTranscript([child], undefined, firstListing) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + expect(remoteChild?.parentUuid).toBe(userUuid) + expect(remoteChild?.parentUuid).not.toBe(siblingUuid) + expect(remoteChild?.parentUuid).not.toBe(firstListing) + expect(JSON.stringify(remotePayloads)).not.toContain('ONDEMAND-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) }) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 5357ea1034..514a7d8aba 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -950,9 +950,10 @@ class Project { // Bounded with the same policy as remoteEgressOmittedParents: only recent // evictions can still appear as an incoming parentUuid. private evictedRemoteEgressOmissions = new Set() - // UUIDs dropped from both bounded collections. Ancestry is compact: every - // key shares lastRemoteEgressUuid rather than a per-uuid ancestor map. - private remoteEgressCompactAncestryUuids = new Set() + // Bounded: omitted uuid → actual nearest external ancestor at eviction + // from remoteEgressOmittedParents. Do not store uuid-only keys that all + // share lastRemoteEgressUuid (that steals a later sibling's children). + private remoteEgressCompactAncestry = new Map() private lastRemoteEgressUuid: UUID | null = null constructor() {} @@ -967,7 +968,7 @@ class Project { this.writeQueues = new Map() this.remoteEgressOmittedParents.clear() this.evictedRemoteEgressOmissions.clear() - this.remoteEgressCompactAncestryUuids.clear() + this.remoteEgressCompactAncestry.clear() this.lastRemoteEgressUuid = null this.rewriteBarrierFiles = new Set() this.pendingRewriteCounts = new Map() @@ -1301,7 +1302,7 @@ class Project { rebuildRemoteEgressOmittedParentsFromLocalTranscript(): void { this.remoteEgressOmittedParents.clear() this.evictedRemoteEgressOmissions.clear() - this.remoteEgressCompactAncestryUuids.clear() + this.remoteEgressCompactAncestry.clear() const path = this.sessionFile if (!path) return ingestRemoteEgressOmissionsFromTranscriptFile( @@ -1315,7 +1316,7 @@ class Project { this.pendingEntries = [] this.remoteEgressOmittedParents.clear() this.evictedRemoteEgressOmissions.clear() - this.remoteEgressCompactAncestryUuids.clear() + this.remoteEgressCompactAncestry.clear() this.lastRemoteEgressUuid = null } @@ -1998,14 +1999,41 @@ class Project { ) if ( originalParentUuid && - (this.evictedRemoteEgressOmissions.has(originalParentUuid) || - this.remoteEgressCompactAncestryUuids.has( - originalParentUuid, - )) + remoteEntry.parentUuid === originalParentUuid ) { - remoteEntry = { - ...entry, - parentUuid: this.lastRemoteEgressUuid, + if ( + this.remoteEgressCompactAncestry.has(originalParentUuid) + ) { + remoteEntry = { + ...entry, + parentUuid: + this.remoteEgressCompactAncestry.get( + originalParentUuid, + ) ?? null, + } + } else if ( + this.evictedRemoteEgressOmissions.has(originalParentUuid) || + (this.evictedRemoteEgressOmissions.size >= + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE && + this.remoteEgressCompactAncestry.size >= + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) + ) { + const resolved = + resolveCompactOmissionAncestorFromLocalTranscript( + this.sessionFile, + originalParentUuid, + ) + if (resolved.found) { + this.remoteEgressCompactAncestry.set( + originalParentUuid, + resolved.ancestor, + ) + boundCompactAncestryMap(this.remoteEgressCompactAncestry) + remoteEntry = { + ...entry, + parentUuid: resolved.ancestor, + } + } } } await this.persistToRemote(sessionId, remoteEntry) @@ -2028,7 +2056,7 @@ class Project { boundRemoteEgressOmissionMap( this.remoteEgressOmittedParents, this.evictedRemoteEgressOmissions, - this.remoteEgressCompactAncestryUuids, + this.remoteEgressCompactAncestry, ) } } @@ -3835,22 +3863,107 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( } } +function boundCompactAncestryMap( + compactAncestry: Map, +): void { + while (compactAncestry.size > MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) { + const oldest = compactAncestry.keys().next().value + if (oldest === undefined) break + compactAncestry.delete(oldest) + } +} + function boundRemoteEgressOmissionMap( omittedParents: Map, evicted: Set, - compactAncestryUuids: Set, + compactAncestry: Map, ): void { while (omittedParents.size > MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) { const oldest = omittedParents.keys().next().value if (oldest === undefined) break + const ancestor = omittedParents.get(oldest) ?? null omittedParents.delete(oldest) evicted.add(oldest) + compactAncestry.set(oldest, ancestor) } while (evicted.size > MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) { const oldest = evicted.values().next().value if (oldest === undefined) break evicted.delete(oldest) - compactAncestryUuids.add(oldest) + compactAncestry.delete(oldest) + } + boundCompactAncestryMap(compactAncestry) +} + +/** + * On-demand ancestry when an omitted parent has fallen out of both bounded + * maps. Walks local JSONL from the start (capped) and returns the nearest + * still-safe ancestor of that omitted UUID — never lastRemoteEgressUuid. + */ +function resolveCompactOmissionAncestorFromLocalTranscript( + sessionFile: string | null, + omittedUuid: UUID, +): { found: boolean; ancestor: UUID | null } { + if (!sessionFile) { + return { found: false, ancestor: null } + } + let fd: number | undefined + try { + fd = openSync(sessionFile, 'r') + const size = fstatSync(fd).size + if (size === 0) { + return { found: false, ancestor: null } + } + const readSize = Math.min(size, MAX_TRANSCRIPT_READ_BYTES) + const buf = Buffer.allocUnsafe(readSize) + const bytesRead = readSync(fd, buf, 0, readSize, 0) + const content = buf.toString('utf8', 0, bytesRead) + const byUuid = new Map< + UUID, + { parentUuid: UUID | null; safe: boolean } + >() + for (const line of content.split('\n')) { + if (!line.trim()) continue + let parsed: unknown + try { + parsed = jsonParse(line) + } catch { + continue + } + if (!isTranscriptMessage(parsed as Entry)) continue + const entry = parsed as TranscriptMessage + if (!entry.uuid) continue + byUuid.set(entry.uuid, { + parentUuid: entry.parentUuid ?? null, + safe: isSafeForExternalEgress(entry), + }) + if (entry.uuid === omittedUuid) break + } + const target = byUuid.get(omittedUuid) + if (!target || target.safe) { + return { found: false, ancestor: null } + } + let current = target.parentUuid + const seen = new Set() + while (current && !seen.has(current)) { + seen.add(current) + const node = byUuid.get(current) + if (!node || node.safe) { + return { found: true, ancestor: current } + } + current = node.parentUuid + } + return { found: true, ancestor: current } + } catch { + return { found: false, ancestor: null } + } finally { + if (fd !== undefined) { + try { + closeSync(fd) + } catch { + // closeSync can throw; treat as unresolved + } + } } } From 426bb47741300045a8f7829ebef72ae6d4a3a1b5 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:06:55 +0900 Subject: [PATCH 13/31] fix(privacy): tail-window on-demand ancestry and known-omitted scan gate CodeRabbit review 4903284909: - 3755540990: walk earlier OMISSION_REBUILD_TAIL_BYTES windows - 3755540988: scan only known-omitted parents; cache {found:false} - 3755540985: pad the on-demand test past the first tail window --- .../sessionStorage.externalEgress.test.ts | 13 ++ src/utils/sessionStorage.ts | 150 +++++++++++++----- 2 files changed, 123 insertions(+), 40 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 2d8641f505..968221949d 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -1013,8 +1013,21 @@ describe('appendEntry remote egress gate', () => { await recordTranscript(listings, undefined, userUuid) await flushSessionStorage() + // Push firstListing outside the first tail window so the on-demand + // resolver cannot succeed from a head read or a single tail slice. + await appendFile( + path, + Buffer.concat([ + Buffer.from('\n'), + Buffer.alloc(OMISSION_REBUILD_TAIL_BYTES + 1, 0x78), + Buffer.from('\n'), + ]), + ) + const map = getRemoteEgressOmittedParentsForTesting() expect(map.has(firstListing)).toBe(false) + // Documents that 130 omissions exceed MAX*2 and drop firstListing + // from both bounded maps; not a file-size assertion. expect(omissionCount).toBeGreaterThan(129) const sibling = { diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 514a7d8aba..5687c65bf1 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -954,6 +954,11 @@ class Project { // from remoteEgressOmittedParents. Do not store uuid-only keys that all // share lastRemoteEgressUuid (that steals a later sibling's children). private remoteEgressCompactAncestry = new Map() + // UUIDs dropped from both bounded maps. Scan-gate only — not an ancestor + // source. Keeps on-demand walks off parents that were never omitted. + private remoteEgressKnownOmitted = new Set() + // Negative cache for on-demand walks that returned { found: false }. + private remoteEgressResolvedMisses = new Set() private lastRemoteEgressUuid: UUID | null = null constructor() {} @@ -969,6 +974,8 @@ class Project { this.remoteEgressOmittedParents.clear() this.evictedRemoteEgressOmissions.clear() this.remoteEgressCompactAncestry.clear() + this.remoteEgressKnownOmitted.clear() + this.remoteEgressResolvedMisses.clear() this.lastRemoteEgressUuid = null this.rewriteBarrierFiles = new Set() this.pendingRewriteCounts = new Map() @@ -1303,6 +1310,8 @@ class Project { this.remoteEgressOmittedParents.clear() this.evictedRemoteEgressOmissions.clear() this.remoteEgressCompactAncestry.clear() + this.remoteEgressKnownOmitted.clear() + this.remoteEgressResolvedMisses.clear() const path = this.sessionFile if (!path) return ingestRemoteEgressOmissionsFromTranscriptFile( @@ -1317,6 +1326,8 @@ class Project { this.remoteEgressOmittedParents.clear() this.evictedRemoteEgressOmissions.clear() this.remoteEgressCompactAncestry.clear() + this.remoteEgressKnownOmitted.clear() + this.remoteEgressResolvedMisses.clear() this.lastRemoteEgressUuid = null } @@ -2012,11 +2023,11 @@ class Project { ) ?? null, } } else if ( - this.evictedRemoteEgressOmissions.has(originalParentUuid) || - (this.evictedRemoteEgressOmissions.size >= - MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE && - this.remoteEgressCompactAncestry.size >= - MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) + !this.remoteEgressResolvedMisses.has(originalParentUuid) && + (this.evictedRemoteEgressOmissions.has( + originalParentUuid, + ) || + this.remoteEgressKnownOmitted.has(originalParentUuid)) ) { const resolved = resolveCompactOmissionAncestorFromLocalTranscript( @@ -2033,6 +2044,12 @@ class Project { ...entry, parentUuid: resolved.ancestor, } + } else { + this.remoteEgressResolvedMisses.add(originalParentUuid) + boundUuidSet( + this.remoteEgressResolvedMisses, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) } } } @@ -2057,6 +2074,7 @@ class Project { this.remoteEgressOmittedParents, this.evictedRemoteEgressOmissions, this.remoteEgressCompactAncestry, + this.remoteEgressKnownOmitted, ) } } @@ -3873,10 +3891,19 @@ function boundCompactAncestryMap( } } +function boundUuidSet(ids: Set, maxSize: number): void { + while (ids.size > maxSize) { + const oldest = ids.values().next().value + if (oldest === undefined) break + ids.delete(oldest) + } +} + function boundRemoteEgressOmissionMap( omittedParents: Map, evicted: Set, compactAncestry: Map, + knownOmitted: Set, ): void { while (omittedParents.size > MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) { const oldest = omittedParents.keys().next().value @@ -3891,14 +3918,66 @@ function boundRemoteEgressOmissionMap( if (oldest === undefined) break evicted.delete(oldest) compactAncestry.delete(oldest) + knownOmitted.add(oldest) } + boundUuidSet(knownOmitted, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) boundCompactAncestryMap(compactAncestry) } +function ingestCompactAncestryNodesFromTranscriptContent( + content: string, + byUuid: Map, +): void { + for (const line of content.split('\n')) { + if (!line.trim()) continue + let parsed: unknown + try { + parsed = jsonParse(line) + } catch { + continue + } + if (!isTranscriptMessage(parsed as Entry)) continue + const entry = parsed as TranscriptMessage + if (!entry.uuid) continue + byUuid.set(entry.uuid, { + parentUuid: entry.parentUuid ?? null, + safe: isSafeForExternalEgress(entry), + }) + } +} + +function resolveAncestorFromCompactAncestryNodes( + byUuid: Map, + omittedUuid: UUID, +): { found: boolean; ancestor: UUID | null } | null { + const target = byUuid.get(omittedUuid) + if (!target) { + return null + } + if (target.safe) { + return { found: false, ancestor: null } + } + let current = target.parentUuid + const seen = new Set() + while (current && !seen.has(current)) { + seen.add(current) + const node = byUuid.get(current) + if (!node) { + return null + } + if (node.safe) { + return { found: true, ancestor: current } + } + current = node.parentUuid + } + return { found: true, ancestor: current } +} + /** * On-demand ancestry when an omitted parent has fallen out of both bounded - * maps. Walks local JSONL from the start (capped) and returns the nearest - * still-safe ancestor of that omitted UUID — never lastRemoteEgressUuid. + * maps. Starts at the tail (OMISSION_REBUILD_TAIL_BYTES) and walks earlier + * windows until the omitted UUID and its nearest safe ancestor are visible, + * or MAX_TRANSCRIPT_READ_BYTES is scanned. Never lastRemoteEgressUuid. */ function resolveCompactOmissionAncestorFromLocalTranscript( sessionFile: string | null, @@ -3914,46 +3993,37 @@ function resolveCompactOmissionAncestorFromLocalTranscript( if (size === 0) { return { found: false, ancestor: null } } - const readSize = Math.min(size, MAX_TRANSCRIPT_READ_BYTES) - const buf = Buffer.allocUnsafe(readSize) - const bytesRead = readSync(fd, buf, 0, readSize, 0) - const content = buf.toString('utf8', 0, bytesRead) const byUuid = new Map< UUID, { parentUuid: UUID | null; safe: boolean } >() - for (const line of content.split('\n')) { - if (!line.trim()) continue - let parsed: unknown - try { - parsed = jsonParse(line) - } catch { - continue + let cursor = size + let scanned = 0 + while (cursor > 0 && scanned < MAX_TRANSCRIPT_READ_BYTES) { + const start = Math.max(0, cursor - OMISSION_REBUILD_TAIL_BYTES) + const { content, nextEnd } = readTranscriptRangeForOmissionRebuild( + fd, + start, + cursor, + ) + scanned += cursor - start + ingestCompactAncestryNodesFromTranscriptContent(content, byUuid) + const resolved = resolveAncestorFromCompactAncestryNodes( + byUuid, + omittedUuid, + ) + if (resolved) { + return resolved } - if (!isTranscriptMessage(parsed as Entry)) continue - const entry = parsed as TranscriptMessage - if (!entry.uuid) continue - byUuid.set(entry.uuid, { - parentUuid: entry.parentUuid ?? null, - safe: isSafeForExternalEgress(entry), - }) - if (entry.uuid === omittedUuid) break - } - const target = byUuid.get(omittedUuid) - if (!target || target.safe) { - return { found: false, ancestor: null } - } - let current = target.parentUuid - const seen = new Set() - while (current && !seen.has(current)) { - seen.add(current) - const node = byUuid.get(current) - if (!node || node.safe) { - return { found: true, ancestor: current } + if (nextEnd < cursor) { + cursor = nextEnd + } else if (start < cursor) { + cursor = start + } else { + break } - current = node.parentUuid } - return { found: true, ancestor: current } + return { found: false, ancestor: null } } catch { return { found: false, ancestor: null } } finally { From be4c02e28bc7fccd108299167ce4e20c4560113e Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:29:59 +0900 Subject: [PATCH 14/31] fix(privacy): close maintainer review 4907143023 egress omissions Retain omitted parents for branch children, path-compress rebuild chains, fail-closed incomplete rebuild, observe write queue for compact fallback, validate attachment-bearing records, and bound omission state on resume rebuild. --- .../sessionStorage.externalEgress.test.ts | 324 +++++++++++++++++- src/utils/sessionStorage.ts | 301 +++++++++++++--- 2 files changed, 567 insertions(+), 58 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 968221949d..570a157afa 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -19,6 +19,7 @@ import { filterSubagentTranscriptsForExternalEgress, flushSessionStorage, getRemoteEgressOmittedParentsForTesting, + getRemoteEgressOmissionRebuildIncompleteForTesting, isSafeForExternalEgress, EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, @@ -352,6 +353,77 @@ describe('recordExternalEgressOmission / projectTranscriptParentForExternalEgres ) expect(projected.parentUuid).toBe(id(1)) }) + + test('walks transitive omission chains with a cycle guard', () => { + const map = new Map() + // One-hop rebuild shape (not pre-compressed): O2→O1, O3→O2. + map.set(id(2), id(1)) + map.set(id(3), id(2)) + const projected = projectTranscriptParentForExternalEgress( + { parentUuid: id(3) as UUID | null }, + map, + ) + expect(projected.parentUuid).toBe(id(1)) + + map.set(id(4), id(5)) + map.set(id(5), id(4)) + const cyclic = projectTranscriptParentForExternalEgress( + { parentUuid: id(4) as UUID | null }, + map, + ) + expect(cyclic.parentUuid === id(4) || cyclic.parentUuid === id(5)).toBe( + true, + ) + }) +}) + +describe('filterJsonl / filterMessages malformed attachment validation', () => { + test('drops parseable records with attachment payload when type is not attachment', () => { + const malformedUuid = id(40) + const survivorUuid = id(41) + const messages = [ + { + type: 'user', + uuid: malformedUuid, + parentUuid: null, + attachment: { + type: 'skill_listing', + content: 'MALFORMED-LEAK', + }, + message: { role: 'user', content: 'spoofed' }, + }, + { + type: 'user', + uuid: survivorUuid, + parentUuid: malformedUuid, + message: { role: 'user', content: 'ok' }, + }, + ] as Array<{ + type?: string + uuid?: UUID + parentUuid?: UUID | null + attachment?: unknown + message?: { role: string; content: string } + }> + const filtered = filterMessagesForExternalEgress(messages) + expect(filtered.map(m => m.uuid)).toEqual([survivorUuid]) + expect(filtered[0]?.parentUuid).toBeNull() + expect(JSON.stringify(filtered)).not.toContain('MALFORMED-LEAK') + + const jsonl = [ + JSON.stringify(messages[0]), + JSON.stringify(messages[1]), + ].join('\n') + const out = filterJsonlForExternalEgress(jsonl) + expect(out).not.toContain('MALFORMED-LEAK') + const kept = out + .split('\n') + .filter(l => l.length > 0) + .map(l => JSON.parse(l) as { uuid: string; parentUuid: string | null }) + expect(kept).toHaveLength(1) + expect(kept[0]?.uuid).toBe(survivorUuid) + expect(kept[0]?.parentUuid).toBeNull() + }) }) describe('rebuildRemoteEgressOmittedParentsFromLocalTranscript', () => { @@ -388,6 +460,43 @@ describe('rebuildRemoteEgressOmittedParentsFromLocalTranscript', () => { } }) + test('bounds omission map during resume rebuild with more than 64 withheld entries', async () => { + process.env.USER_TYPE = 'external' + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-rebuild-bound-')) + const path = join(dir, 'session.jsonl') + const userUuid = id(50) + const omissionCount = MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE + 40 + const lastListing = id(51 + omissionCount - 1) + try { + const lines = [JSON.stringify(user(userUuid, null, 'resume seed'))] + let parent: UUID = userUuid + for (let n = 0; n < omissionCount; n++) { + const uuid = id(51 + n) + lines.push( + JSON.stringify(listing(uuid, parent, 'skill_listing', 'REBUILD-BOUND-LEAK')), + ) + parent = uuid + } + await writeFile(path, lines.join('\n') + '\n') + resetProjectForTesting() + setSessionFileForTesting(path) + rebuildRemoteEgressOmittedParentsForTesting() + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.size).toBeLessThanOrEqual(MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) + expect(map.has(lastListing)).toBe(true) + const projected = projectTranscriptParentForExternalEgress( + { parentUuid: lastListing }, + map, + ) + expect(projected.parentUuid).toBe(userUuid) + expect(JSON.stringify([...map.entries()])).not.toContain( + 'REBUILD-BOUND-LEAK', + ) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + test('bounded tail rebuild recovers recent omissions when over OMISSION_REBUILD_TAIL_BYTES', async () => { process.env.USER_TYPE = 'external' const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-rebuild-tail-')) @@ -611,7 +720,9 @@ describe('appendEntry remote egress gate', () => { await flushSessionStorage() const map = getRemoteEgressOmittedParentsForTesting() - expect(map.has(hookUuid)).toBe(false) + // Retain omitted parents after the first safe child so branch siblings + // can still reparent (P1: retain-omitted-parent-for-branch-children). + expect(map.has(hookUuid)).toBe(true) const remoteAfter = remotePayloads.find(p => p.uuid === afterUuid) expect(remoteAfter).toBeDefined() @@ -622,6 +733,91 @@ describe('appendEntry remote egress gate', () => { } }) + test('keeps omitted parent for a second branch child after the first reparents', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-branch-')) + const path = join(dir, 'session.jsonl') + const userUuid = id(30) + const hookUuid = id(31) + const childA = id(32) + const childB = id(33) + const remotePayloads: Array> = [] + + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + const seedUser = { + type: 'user', + uuid: userUuid, + parentUuid: null, + timestamp: '2026-08-11T00:00:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + const hookMsg = { + type: 'attachment', + uuid: hookUuid, + parentUuid: userUuid, + timestamp: '2026-08-11T00:00:01.000Z', + attachment: { + type: 'hook_additional_context', + content: 'BRANCH-LEAK', + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: 'BRANCH-LEAK', + stderr: '', + exitCode: 0, + }, + } as unknown as Message + const afterA = { + type: 'user', + uuid: childA, + parentUuid: hookUuid, + timestamp: '2026-08-11T00:00:02.000Z', + message: { role: 'user', content: 'branch A' }, + } as unknown as Message + const afterB = { + type: 'user', + uuid: childB, + parentUuid: hookUuid, + timestamp: '2026-08-11T00:00:03.000Z', + message: { role: 'user', content: 'branch B' }, + } as unknown as Message + + await recordTranscript([seedUser]) + await flushSessionStorage() + remotePayloads.length = 0 + + await recordTranscript([hookMsg, afterA], undefined, userUuid) + await flushSessionStorage() + expect(getRemoteEgressOmittedParentsForTesting().has(hookUuid)).toBe(true) + + await recordTranscript([afterB], undefined, hookUuid) + await flushSessionStorage() + + const remoteA = remotePayloads.find(p => p.uuid === childA) + const remoteB = remotePayloads.find(p => p.uuid === childB) + expect(remoteA?.parentUuid).toBe(userUuid) + expect(remoteB?.parentUuid).toBe(userUuid) + expect(JSON.stringify(remotePayloads)).not.toContain('BRANCH-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + test('does not grow omission map when no remote sink is registered', async () => { process.env.USER_TYPE = 'external' process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' @@ -1060,4 +1256,130 @@ describe('appendEntry remote egress gate', () => { await rm(dir, { recursive: true, force: true }) } }) + + test('incomplete rebuild fail-closes remote persist for unresolved parents', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-incomplete-')) + const path = join(dir, 'session.jsonl') + const withheldParent = id(400) + const childUuid = id(401) + const remotePayloads: Array> = [] + + try { + // Oversized pad with no newlines → mid-line skip → incomplete rebuild. + await writeFile(path, Buffer.alloc(OMISSION_REBUILD_TAIL_BYTES + 4096, 0x78)) + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + await rebuildRemoteEgressOmittedParentsForTesting() + expect(getRemoteEgressOmissionRebuildIncompleteForTesting()).toBe(true) + + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + const child = { + type: 'user', + uuid: childUuid, + parentUuid: withheldParent, + timestamp: '2026-08-11T00:04:00.000Z', + message: { role: 'user', content: 'child under incomplete rebuild' }, + } as unknown as Message + await recordTranscript([child], undefined, withheldParent) + await flushSessionStorage() + + expect(remotePayloads.find(p => p.uuid === childUuid)).toBeUndefined() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('compact fallback sees queued writes before flush', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-queued-')) + const path = join(dir, 'session.jsonl') + const userUuid = id(500) + const firstListing = id(501) + const childUuid = id(700) + const remotePayloads: Array> = [] + + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + const seedUser = { + type: 'user', + uuid: userUuid, + parentUuid: null, + timestamp: '2026-08-11T00:05:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + await recordTranscript([seedUser]) + await flushSessionStorage() + remotePayloads.length = 0 + + // One batch: many omissions (evict firstListing) + child of firstListing + // without an intermediate flush so the queue still holds parents. + const batch: Message[] = [] + let parent: UUID = userUuid + for (let n = 0; n < MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE + 16; n++) { + const uuid = id(501 + n) + batch.push({ + type: 'attachment', + uuid, + parentUuid: parent, + timestamp: `2026-08-11T00:05:${String(n).padStart(2, '0')}.000Z`, + attachment: { + type: 'hook_additional_context', + content: 'QUEUED-LEAK', + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: 'QUEUED-LEAK', + stderr: '', + exitCode: 0, + }, + } as unknown as Message) + parent = uuid + } + batch.push({ + type: 'user', + uuid: childUuid, + parentUuid: firstListing, + timestamp: '2026-08-11T00:06:00.000Z', + message: { role: 'user', content: 'child while queue pending' }, + } as unknown as Message) + + await recordTranscript(batch, undefined, userUuid) + await flushSessionStorage() + + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.has(firstListing)).toBe(false) + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + expect(remoteChild?.parentUuid).toBe(userUuid) + expect(JSON.stringify(remotePayloads)).not.toContain('QUEUED-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) }) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 5687c65bf1..dfa2cb45ed 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -859,6 +859,11 @@ export function getRemoteEgressOmittedParentsForTesting(): Map< return getProject()._getRemoteEgressOmittedParentsForTesting() } +/** @internal Snapshot incomplete-rebuild fail-closed flag (tests). */ +export function getRemoteEgressOmissionRebuildIncompleteForTesting(): boolean { + return getProject()._getRemoteEgressOmissionRebuildIncompleteForTesting() +} + type InternalEventWriter = ( eventType: string, payload: Record, @@ -960,6 +965,9 @@ class Project { // Negative cache for on-demand walks that returned { found: false }. private remoteEgressResolvedMisses = new Set() private lastRemoteEgressUuid: UUID | null = null + // True when rebuild could not establish complete ancestor closure + // (oversized mid-line skip, scan budget exhausted, unresolved tips). + private remoteEgressOmissionRebuildIncomplete = false constructor() {} @@ -976,6 +984,7 @@ class Project { this.remoteEgressCompactAncestry.clear() this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() + this.remoteEgressOmissionRebuildIncomplete = false this.lastRemoteEgressUuid = null this.rewriteBarrierFiles = new Set() this.pendingRewriteCounts = new Map() @@ -1293,6 +1302,11 @@ class Project { return this.remoteEgressOmittedParents } + /** @internal Expose fail-closed incomplete-rebuild flag for egress tests. */ + _getRemoteEgressOmissionRebuildIncompleteForTesting(): boolean { + return this.remoteEgressOmissionRebuildIncomplete + } + /** * After --resume / --continue, remoteEgressOmittedParents starts empty * (resetSessionFile clears it). Rebuild from the adopted local transcript @@ -1312,12 +1326,19 @@ class Project { this.remoteEgressCompactAncestry.clear() this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() + this.remoteEgressOmissionRebuildIncomplete = false const path = this.sessionFile if (!path) return - ingestRemoteEgressOmissionsFromTranscriptFile( + const result = ingestRemoteEgressOmissionsFromTranscriptFile( path, this.remoteEgressOmittedParents, + { + evicted: this.evictedRemoteEgressOmissions, + compactAncestry: this.remoteEgressCompactAncestry, + knownOmitted: this.remoteEgressKnownOmitted, + }, ) + this.remoteEgressOmissionRebuildIncomplete = !result.complete } resetSessionFile(): void { @@ -1328,6 +1349,7 @@ class Project { this.remoteEgressCompactAncestry.clear() this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() + this.remoteEgressOmissionRebuildIncomplete = false this.lastRemoteEgressUuid = null } @@ -2008,6 +2030,14 @@ class Project { entry, this.remoteEgressOmittedParents, ) + let parentConfirmedSafe = false + const parentMayNeedResolve = + !!originalParentUuid && + (this.remoteEgressOmittedParents.has(originalParentUuid) || + this.remoteEgressCompactAncestry.has(originalParentUuid) || + this.evictedRemoteEgressOmissions.has(originalParentUuid) || + this.remoteEgressKnownOmitted.has(originalParentUuid) || + this.remoteEgressOmissionRebuildIncomplete) if ( originalParentUuid && remoteEntry.parentUuid === originalParentUuid @@ -2027,14 +2057,19 @@ class Project { (this.evictedRemoteEgressOmissions.has( originalParentUuid, ) || - this.remoteEgressKnownOmitted.has(originalParentUuid)) + this.remoteEgressKnownOmitted.has(originalParentUuid) || + this.remoteEgressOmissionRebuildIncomplete) ) { + const queue = this.sessionFile + ? this.writeQueues.get(this.sessionFile) + : undefined const resolved = resolveCompactOmissionAncestorFromLocalTranscript( this.sessionFile, originalParentUuid, + queue, ) - if (resolved.found) { + if (resolved.status === 'resolved') { this.remoteEgressCompactAncestry.set( originalParentUuid, resolved.ancestor, @@ -2044,7 +2079,14 @@ class Project { ...entry, parentUuid: resolved.ancestor, } - } else { + } else if (resolved.status === 'parent_safe') { + parentConfirmedSafe = true + this.evictedRemoteEgressOmissions.delete( + originalParentUuid, + ) + this.remoteEgressKnownOmitted.delete(originalParentUuid) + } else if (!queueHasPendingTranscriptAppends(queue)) { + // Only cache durable misses — queued parents may land soon. this.remoteEgressResolvedMisses.add(originalParentUuid) boundUuidSet( this.remoteEgressResolvedMisses, @@ -2053,14 +2095,26 @@ class Project { } } } - await this.persistToRemote(sessionId, remoteEntry) - if (remoteEntry.uuid) { - this.lastRemoteEgressUuid = remoteEntry.uuid + // Fail closed: do not emit a remote child whose parent still + // points at a withheld / incomplete-rebuild UUID. + const parentStillUnresolved = + !!originalParentUuid && + remoteEntry.parentUuid === originalParentUuid && + !parentConfirmedSafe && + parentMayNeedResolve && + (this.remoteEgressOmittedParents.has(originalParentUuid) || + this.evictedRemoteEgressOmissions.has(originalParentUuid) || + this.remoteEgressKnownOmitted.has(originalParentUuid) || + this.remoteEgressOmissionRebuildIncomplete) + if (!parentStillUnresolved) { + await this.persistToRemote(sessionId, remoteEntry) + if (remoteEntry.uuid) { + this.lastRemoteEgressUuid = remoteEntry.uuid + } } - pruneRemoteEgressOmissionsAfterProjection( - this.remoteEgressOmittedParents, - originalParentUuid, - ) + // Keep omitted parents in the bounded map so a second branch + // child of the same withheld UUID can still reparent. Size is + // enforced by boundRemoteEgressOmissionMap on the omit path. } else if (this.hasActiveRemoteEgressSink()) { // Only grow the map when a remote sink can consume reparents. // Resume rebuild (adoptResumedSessionFile) still hydrates from @@ -3760,6 +3814,28 @@ function ingestRemoteEgressOmissionsFromTranscriptContent( return sawTranscript } +/** + * Collapse omission-map values to the nearest egressed ancestor (or null). + * Leaves dangling tips (non-egressed, non-omitted) unchanged so incomplete + * rebuild detection can still see unresolved chain ends. + */ +function pathCompressOmissionMap( + omittedParents: Map, + egressed: Set, +): void { + for (const uuid of [...omittedParents.keys()]) { + let current: UUID | null = omittedParents.get(uuid) ?? null + const seen = new Set([uuid]) + while (current && omittedParents.has(current) && !seen.has(current)) { + seen.add(current) + current = omittedParents.get(current) ?? null + } + if (current === null || (current !== null && egressed.has(current))) { + omittedParents.set(uuid, current) + } + } +} + function isOmissionAncestryClosed( referencedParents: Set, omittedParents: Map, @@ -3783,10 +3859,10 @@ function readTranscriptRangeForOmissionRebuild( fd: number, start: number, end: number, -): { content: string; nextEnd: number } { +): { content: string; nextEnd: number; skippedMidLine: boolean } { const readSize = end - start if (readSize <= 0) { - return { content: '', nextEnd: start } + return { content: '', nextEnd: start, skippedMidLine: false } } const buf = Buffer.allocUnsafe(readSize) const bytesRead = readSync(fd, buf, 0, readSize, start) @@ -3797,15 +3873,18 @@ function readTranscriptRangeForOmissionRebuild( (readSync(fd, previousByte, 0, 1, start - 1) === 1 && previousByte[0] === 0x0a) let nextEnd = start + let skippedMidLine = false if (!startsAtLineBoundary) { const nl = content.indexOf('\n') if (nl < 0) { - return { content: '', nextEnd: start } + // Entire window is inside one oversized line — cannot ingest it. + return { content: '', nextEnd: start, skippedMidLine: true } } nextEnd = start + nl + 1 content = content.slice(nl + 1) + skippedMidLine = true } - return { content, nextEnd } + return { content, nextEnd, skippedMidLine } } /** @@ -3813,30 +3892,39 @@ function readTranscriptRangeForOmissionRebuild( * (OMISSION_REBUILD_TAIL_BYTES). If the tail is metadata-only or any * chain-tip parent is still unresolved, walk earlier windows of the same * budget until ancestry closes or MAX_TRANSCRIPT_READ_BYTES is scanned. + * Returns complete=false when ancestry cannot be closed (oversized mid-line + * skip, scan budget exhausted, or unresolved tips). */ function ingestRemoteEgressOmissionsFromTranscriptFile( fullPath: string, omittedParents: Map, -): void { + boundState?: { + evicted: Set + compactAncestry: Map + knownOmitted: Set + }, +): { complete: boolean } { let fd: number | undefined try { fd = openSync(fullPath, 'r') const size = fstatSync(fd).size - if (size === 0) return + if (size === 0) return { complete: true } const egressed = new Set() const referencedParents = new Set() let sawTranscript = false let cursor = size let scanned = 0 + let sawMidLineSkip = false + let closed = false while (cursor > 0 && scanned < MAX_TRANSCRIPT_READ_BYTES) { const start = Math.max(0, cursor - OMISSION_REBUILD_TAIL_BYTES) - const { content, nextEnd } = readTranscriptRangeForOmissionRebuild( - fd, - start, - cursor, - ) + const { content, nextEnd, skippedMidLine } = + readTranscriptRangeForOmissionRebuild(fd, start, cursor) + if (skippedMidLine) { + sawMidLineSkip = true + } scanned += cursor - start const collectRefs = !sawTranscript const chunkSaw = ingestRemoteEgressOmissionsFromTranscriptContent( @@ -3850,6 +3938,17 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( if (chunkSaw) { sawTranscript = true } + pathCompressOmissionMap(omittedParents, egressed) + if (boundState) { + boundRemoteEgressOmissionMap( + omittedParents, + boundState.evicted, + boundState.compactAncestry, + boundState.knownOmitted, + ) + boundUuidSet(egressed, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) + boundUuidSet(referencedParents, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) + } if ( sawTranscript && isOmissionAncestryClosed( @@ -3858,7 +3957,8 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( egressed, ) ) { - return + closed = true + break } if (nextEnd < cursor) { cursor = nextEnd @@ -3868,8 +3968,24 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( break } } + + pathCompressOmissionMap(omittedParents, egressed) + if (boundState) { + boundRemoteEgressOmissionMap( + omittedParents, + boundState.evicted, + boundState.compactAncestry, + boundState.knownOmitted, + ) + } + + const hitScanCap = + scanned >= MAX_TRANSCRIPT_READ_BYTES && cursor > 0 && !closed + const complete = + (!sawTranscript || closed) && !sawMidLineSkip && !hitScanCap + return { complete } } catch { - return + return { complete: false } } finally { if (fd !== undefined) { try { @@ -3946,16 +4062,47 @@ function ingestCompactAncestryNodesFromTranscriptContent( } } +function ingestCompactAncestryNodesFromWriteQueue( + queue: TranscriptWriteOperation[] | undefined, + byUuid: Map, +): void { + if (!queue) return + for (const op of queue) { + if (op.kind !== 'append') continue + if (!isTranscriptMessage(op.entry)) continue + const entry = op.entry as TranscriptMessage + if (!entry.uuid) continue + byUuid.set(entry.uuid, { + parentUuid: entry.parentUuid ?? null, + safe: isSafeForExternalEgress(entry), + }) + } +} + +function queueHasPendingTranscriptAppends( + queue: TranscriptWriteOperation[] | undefined, +): boolean { + if (!queue) return false + return queue.some( + op => op.kind === 'append' && isTranscriptMessage(op.entry), + ) +} + +type CompactOmissionResolveResult = + | { status: 'resolved'; ancestor: UUID | null } + | { status: 'parent_safe' } + | { status: 'not_found' } + function resolveAncestorFromCompactAncestryNodes( byUuid: Map, omittedUuid: UUID, -): { found: boolean; ancestor: UUID | null } | null { +): CompactOmissionResolveResult | null { const target = byUuid.get(omittedUuid) if (!target) { return null } if (target.safe) { - return { found: false, ancestor: null } + return { status: 'parent_safe' } } let current = target.parentUuid const seen = new Set() @@ -3966,11 +4113,11 @@ function resolveAncestorFromCompactAncestryNodes( return null } if (node.safe) { - return { found: true, ancestor: current } + return { status: 'resolved', ancestor: current } } current = node.parentUuid } - return { found: true, ancestor: current } + return { status: 'resolved', ancestor: current } } /** @@ -3978,25 +4125,30 @@ function resolveAncestorFromCompactAncestryNodes( * maps. Starts at the tail (OMISSION_REBUILD_TAIL_BYTES) and walks earlier * windows until the omitted UUID and its nearest safe ancestor are visible, * or MAX_TRANSCRIPT_READ_BYTES is scanned. Never lastRemoteEgressUuid. + * Pending write-queue appends are merged so parents not yet flushed to disk + * are still visible. */ function resolveCompactOmissionAncestorFromLocalTranscript( sessionFile: string | null, omittedUuid: UUID, -): { found: boolean; ancestor: UUID | null } { + queue?: TranscriptWriteOperation[], +): CompactOmissionResolveResult { + const byUuid = new Map() + ingestCompactAncestryNodesFromWriteQueue(queue, byUuid) + const queuedHit = resolveAncestorFromCompactAncestryNodes(byUuid, omittedUuid) + if (queuedHit) { + return queuedHit + } if (!sessionFile) { - return { found: false, ancestor: null } + return { status: 'not_found' } } let fd: number | undefined try { fd = openSync(sessionFile, 'r') const size = fstatSync(fd).size if (size === 0) { - return { found: false, ancestor: null } + return { status: 'not_found' } } - const byUuid = new Map< - UUID, - { parentUuid: UUID | null; safe: boolean } - >() let cursor = size let scanned = 0 while (cursor > 0 && scanned < MAX_TRANSCRIPT_READ_BYTES) { @@ -4023,9 +4175,9 @@ function resolveCompactOmissionAncestorFromLocalTranscript( break } } - return { found: false, ancestor: null } + return { status: 'not_found' } } catch { - return { found: false, ancestor: null } + return { status: 'not_found' } } finally { if (fd !== undefined) { try { @@ -4037,19 +4189,6 @@ function resolveCompactOmissionAncestorFromLocalTranscript( } } -function pruneRemoteEgressOmissionsAfterProjection( - omittedParents: Map, - originalParentUuid: UUID | null, -): void { - if (!originalParentUuid || !omittedParents.has(originalParentUuid)) { - return - } - for (const key of [...omittedParents.keys()]) { - omittedParents.delete(key) - if (key === originalParentUuid) break - } -} - /** * Sync tail read for reAppendSessionMetadata's external-writer check. * fstat on the already-open fd (no extra path lookup); reads the same @@ -5897,8 +6036,10 @@ export function recordExternalEgressOmission( /** * Reparent a transcript entry whose parentUuid points at a UUID omitted from - * the external/remote projection. Returns the same reference when no rewrite - * is needed so callers can keep original JSONL bytes where possible. + * the external/remote projection. Walks the omission chain with a cycle guard + * so one-hop rebuild maps (O2→O1, O1→A) still resolve to the egressed tip. + * Returns the same reference when no rewrite is needed so callers can keep + * original JSONL bytes where possible. */ export function projectTranscriptParentForExternalEgress< T extends { parentUuid: UUID | null }, @@ -5906,9 +6047,15 @@ export function projectTranscriptParentForExternalEgress< if (!entry.parentUuid || !omittedParents.has(entry.parentUuid)) { return entry } + let current: UUID | null = entry.parentUuid + const seen = new Set() + while (current && omittedParents.has(current) && !seen.has(current)) { + seen.add(current) + current = omittedParents.get(current) ?? null + } return { ...entry, - parentUuid: omittedParents.get(entry.parentUuid) ?? null, + parentUuid: current, } } @@ -5997,6 +6144,21 @@ export async function readFilteredTranscriptJsonlForExternalEgress( } } +/** + * Parseable records that carry an attachment payload on a non-attachment + * outer type must fail closed for egress — they would otherwise bypass the + * listing classifier (isSafeForExternalEgress only inspects type===attachment). + */ +function isMalformedAttachmentBearingEgressRecord(entry: { + type?: string + attachment?: unknown +}): boolean { + if (entry.attachment === undefined || entry.attachment === null) { + return false + } + return entry.type !== 'attachment' +} + /** * Drop entries that must not leave the local machine (share, feedback, * analytics payloads built from in-memory messages). Relinks parentUuid @@ -6015,6 +6177,16 @@ export function filterMessagesForExternalEgress< const omittedParents = new Map() const kept: T[] = [] for (const message of messages) { + if (isMalformedAttachmentBearingEgressRecord(message)) { + if (typeof message.uuid === 'string') { + recordExternalEgressOmission( + omittedParents, + message.uuid, + message.parentUuid ?? null, + ) + } + continue + } if (!isSafeForExternalEgress(message)) { if (typeof message.uuid === 'string') { recordExternalEgressOmission( @@ -6030,10 +6202,15 @@ export function filterMessagesForExternalEgress< message.parentUuid !== null && omittedParents.has(message.parentUuid) ) { - kept.push({ - ...message, - parentUuid: omittedParents.get(message.parentUuid) ?? null, - }) + kept.push( + projectTranscriptParentForExternalEgress( + { + ...message, + parentUuid: message.parentUuid, + }, + omittedParents, + ), + ) } else { kept.push(message) } @@ -6086,6 +6263,16 @@ export function filterJsonlForExternalEgress(jsonl: string): string { uuid?: UUID parentUuid?: UUID | null } + if (isMalformedAttachmentBearingEgressRecord(entry)) { + if (typeof entry.uuid === 'string') { + recordExternalEgressOmission( + omittedParents, + entry.uuid, + entry.parentUuid ?? null, + ) + } + continue + } if (!isSafeForExternalEgress(entry)) { if (typeof entry.uuid === 'string') { recordExternalEgressOmission( From 9fca2a5dd340a3857ba4eaa1f65058afff2ab662 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:08:11 +0900 Subject: [PATCH 15/31] fix(privacy): tighten egress persist after review 4907143023 tests Fail closed on incomplete rematch to a withheld hop, treat only unrecoverable mid-line skips as incomplete, and add the missing O1/O2, 50MiB-span, and queued-write regressions. --- .../sessionStorage.externalEgress.test.ts | 153 +++++++++++++++++- src/utils/sessionStorage.ts | 32 ++-- 2 files changed, 173 insertions(+), 12 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 570a157afa..cb86ef8aaf 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -23,6 +23,7 @@ import { isSafeForExternalEgress, EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + MAX_TRANSCRIPT_READ_BYTES, OMISSION_REBUILD_TAIL_BYTES, projectTranscriptParentForExternalEgress, rebuildRemoteEgressOmittedParentsForTesting, @@ -648,6 +649,84 @@ describe('rebuildRemoteEgressOmittedParentsFromLocalTranscript', () => { await rm(dir, { recursive: true, force: true }) } }) + + test('ancestry-closure rematches a first post-resume child of O2 when O1 and O2 sit on opposite sides of OMISSION_REBUILD_TAIL_BYTES', async () => { + process.env.USER_TYPE = 'external' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp( + join(tmpdir(), 'openclaude-egress-rebuild-o1-o2-'), + ) + const path = join(dir, 'session.jsonl') + const userUuid = id(40) + const listingO1 = id(41) + const listingO2 = id(42) + const afterUuid = id(43) + const remotePayloads: Array> = [] + try { + const prefix = + [ + JSON.stringify(user(userUuid, null, 'consecutive omission resume')), + JSON.stringify( + listing(listingO1, userUuid, 'skill_listing', 'O1-LEAK'), + ), + ].join('\n') + '\n' + const snapshotPad = 's'.repeat(900) + const snapshotLines: string[] = [] + let snapshotBytes = 0 + let i = 0 + while (snapshotBytes <= OMISSION_REBUILD_TAIL_BYTES) { + const line = + JSON.stringify({ + type: 'file-history-snapshot', + messageId: id(2100 + i), + snapshot: { pad: snapshotPad, i }, + isSnapshotUpdate: false, + }) + '\n' + snapshotLines.push(line) + snapshotBytes += Buffer.byteLength(line) + i += 1 + } + const suffix = + JSON.stringify( + listing(listingO2, listingO1, 'skill_listing', 'O2-LEAK'), + ) + '\n' + await writeFile(path, prefix + snapshotLines.join('') + suffix) + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + rebuildRemoteEgressOmittedParentsForTesting() + expect(getRemoteEgressOmissionRebuildIncompleteForTesting()).toBe(false) + const map = getRemoteEgressOmittedParentsForTesting() + expect(map.has(listingO2)).toBe(true) + + const afterMsg = { + type: 'user', + uuid: afterUuid, + parentUuid: listingO2, + timestamp: '2026-08-11T00:00:01.000Z', + message: { role: 'user', content: 'first post-resume child of O2' }, + } as unknown as Message + await recordTranscript([afterMsg], undefined, listingO2) + await flushSessionStorage() + + const remoteAfter = remotePayloads.find(p => p.uuid === afterUuid) + expect(remoteAfter).toBeDefined() + expect(remoteAfter?.parentUuid).toBe(userUuid) + const dumped = JSON.stringify(remotePayloads) + expect(dumped).not.toContain('O1-LEAK') + expect(dumped).not.toContain('O2-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) }) describe('appendEntry remote egress gate', () => { @@ -1301,6 +1380,76 @@ describe('appendEntry remote egress gate', () => { } }) + test('incomplete rebuild fail-closes an unsafe chain spanning MAX_TRANSCRIPT_READ_BYTES', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp( + join(tmpdir(), 'openclaude-egress-incomplete-span-'), + ) + const path = join(dir, 'session.jsonl') + const userUuid = id(410) + const listingO1 = id(411) + const listingO2 = id(412) + const childUuid = id(413) + const remotePayloads: Array> = [] + + try { + const prefix = + [ + JSON.stringify(user(userUuid, null, 'scan-cap prefix')), + JSON.stringify( + listing(listingO1, userUuid, 'skill_listing', 'SPAN-O1-LEAK'), + ), + ].join('\n') + '\n' + const padSize = MAX_TRANSCRIPT_READ_BYTES + 1024 + const line = Buffer.alloc(80, 0x78) + line[79] = 0x0a + const pad = Buffer.allocUnsafe(padSize) + for (let off = 0; off < padSize; off += 80) { + line.copy(pad, off, 0, Math.min(80, padSize - off)) + } + const suffix = + JSON.stringify( + listing(listingO2, listingO1, 'skill_listing', 'SPAN-O2-LEAK'), + ) + '\n' + await writeFile( + path, + Buffer.concat([Buffer.from(prefix, 'utf8'), pad, Buffer.from(suffix)]), + ) + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + await rebuildRemoteEgressOmittedParentsForTesting() + expect(getRemoteEgressOmissionRebuildIncompleteForTesting()).toBe(true) + + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + const child = { + type: 'user', + uuid: childUuid, + parentUuid: listingO2, + timestamp: '2026-08-11T00:04:10.000Z', + message: { role: 'user', content: 'child of O2 past scan cap' }, + } as unknown as Message + await recordTranscript([child], undefined, listingO2) + await flushSessionStorage() + + expect(remotePayloads.find(p => p.uuid === childUuid)).toBeUndefined() + expect(JSON.stringify(remotePayloads)).not.toContain('SPAN-O1-LEAK') + expect(JSON.stringify(remotePayloads)).not.toContain('SPAN-O2-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }, 60000) + test('compact fallback sees queued writes before flush', async () => { process.env.USER_TYPE = 'external' process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' @@ -1341,13 +1490,13 @@ describe('appendEntry remote egress gate', () => { // without an intermediate flush so the queue still holds parents. const batch: Message[] = [] let parent: UUID = userUuid - for (let n = 0; n < MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE + 16; n++) { + for (let n = 0; n < MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE * 2 + 2; n++) { const uuid = id(501 + n) batch.push({ type: 'attachment', uuid, parentUuid: parent, - timestamp: `2026-08-11T00:05:${String(n).padStart(2, '0')}.000Z`, + timestamp: `2026-08-11T00:05:00.${String(n).padStart(3, '0')}Z`, attachment: { type: 'hook_additional_context', content: 'QUEUED-LEAK', diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index dfa2cb45ed..62b3563f16 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -2096,16 +2096,24 @@ class Project { } } // Fail closed: do not emit a remote child whose parent still - // points at a withheld / incomplete-rebuild UUID. + // points at a withheld / incomplete-rebuild UUID. Incomplete + // rebuilds skip persist even after a one-hop rematch (O2→O1) + // — the rematch target may also be withheld and unseen. const parentStillUnresolved = !!originalParentUuid && - remoteEntry.parentUuid === originalParentUuid && !parentConfirmedSafe && parentMayNeedResolve && - (this.remoteEgressOmittedParents.has(originalParentUuid) || - this.evictedRemoteEgressOmissions.has(originalParentUuid) || - this.remoteEgressKnownOmitted.has(originalParentUuid) || - this.remoteEgressOmissionRebuildIncomplete) + (this.remoteEgressOmissionRebuildIncomplete || + (remoteEntry.parentUuid === originalParentUuid && + (this.remoteEgressOmittedParents.has( + originalParentUuid, + ) || + this.evictedRemoteEgressOmissions.has( + originalParentUuid, + ) || + this.remoteEgressKnownOmitted.has( + originalParentUuid, + )))) if (!parentStillUnresolved) { await this.persistToRemote(sessionId, remoteEntry) if (remoteEntry.uuid) { @@ -3915,15 +3923,17 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( let sawTranscript = false let cursor = size let scanned = 0 - let sawMidLineSkip = false + let sawUnrecoverableMidLineSkip = false let closed = false while (cursor > 0 && scanned < MAX_TRANSCRIPT_READ_BYTES) { const start = Math.max(0, cursor - OMISSION_REBUILD_TAIL_BYTES) const { content, nextEnd, skippedMidLine } = readTranscriptRangeForOmissionRebuild(fd, start, cursor) - if (skippedMidLine) { - sawMidLineSkip = true + // Recoverable window alignment (slice to the next newline) is not + // incomplete. Only an oversized line with no newline in the window is. + if (skippedMidLine && content.length === 0) { + sawUnrecoverableMidLineSkip = true } scanned += cursor - start const collectRefs = !sawTranscript @@ -3982,7 +3992,9 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( const hitScanCap = scanned >= MAX_TRANSCRIPT_READ_BYTES && cursor > 0 && !closed const complete = - (!sawTranscript || closed) && !sawMidLineSkip && !hitScanCap + (!sawTranscript || closed) && + !sawUnrecoverableMidLineSkip && + !hitScanCap return { complete } } catch { return { complete: false } From ca2ab10b239fcea488b5aa1450706057c6c15ee6 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:19:36 +0900 Subject: [PATCH 16/31] fix(privacy): close CodeRabbit review 4912132735 persist and bound egress Record persist-skipped UUIDs in the omission map (3762762003), preserve egressed ancestry witnesses at the bound (3762895054), and add an optional scan budget so the cap test is cheap (3762895042). --- .../sessionStorage.externalEgress.test.ts | 83 +++++++++++++++- src/utils/sessionStorage.ts | 95 ++++++++++++++++--- 2 files changed, 160 insertions(+), 18 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index cb86ef8aaf..dd1bcf4540 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -23,7 +23,6 @@ import { isSafeForExternalEgress, EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, - MAX_TRANSCRIPT_READ_BYTES, OMISSION_REBUILD_TAIL_BYTES, projectTranscriptParentForExternalEgress, rebuildRemoteEgressOmittedParentsForTesting, @@ -1380,7 +1379,80 @@ describe('appendEntry remote egress gate', () => { } }) - test('incomplete rebuild fail-closes an unsafe chain spanning MAX_TRANSCRIPT_READ_BYTES', async () => { + test('incomplete persist skip records suppressed UUID so grandchildren rematch', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-grandchild-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(420) + const withheldParent = id(421) + const midUuid = id(422) + const grandchildUuid = id(423) + const remotePayloads: Array> = [] + + try { + await writeFile(path, Buffer.alloc(OMISSION_REBUILD_TAIL_BYTES + 4096, 0x78)) + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + await rebuildRemoteEgressOmittedParentsForTesting() + expect(getRemoteEgressOmissionRebuildIncompleteForTesting()).toBe(true) + + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + const seed = { + type: 'user', + uuid: seedUuid, + parentUuid: null, + timestamp: '2026-08-11T00:05:00.000Z', + message: { role: 'user', content: 'seed before suppressed mid' }, + } as unknown as Message + await recordTranscript([seed], undefined, null) + await flushSessionStorage() + expect(remotePayloads.find(p => p.uuid === seedUuid)).toBeDefined() + + const mid = { + type: 'user', + uuid: midUuid, + parentUuid: withheldParent, + timestamp: '2026-08-11T00:05:01.000Z', + message: { role: 'user', content: 'mid under withheld parent' }, + } as unknown as Message + await recordTranscript([mid], undefined, withheldParent) + await flushSessionStorage() + + expect(remotePayloads.find(p => p.uuid === midUuid)).toBeUndefined() + expect(getRemoteEgressOmittedParentsForTesting().has(midUuid)).toBe(true) + + const grandchild = { + type: 'user', + uuid: grandchildUuid, + parentUuid: midUuid, + timestamp: '2026-08-11T00:05:02.000Z', + message: { role: 'user', content: 'grandchild of suppressed mid' }, + } as unknown as Message + await recordTranscript([grandchild], undefined, midUuid) + await flushSessionStorage() + + const remoteGrandchild = remotePayloads.find( + p => p.uuid === grandchildUuid, + ) + expect(remoteGrandchild?.parentUuid).not.toBe(midUuid) + expect(getRemoteEgressOmittedParentsForTesting().has(midUuid)).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('incomplete rebuild fail-closes an unsafe chain when the scan budget is exhausted', async () => { process.env.USER_TYPE = 'external' process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' process.env.NODE_ENV = 'development' @@ -1407,7 +1479,8 @@ describe('appendEntry remote egress gate', () => { listing(listingO1, userUuid, 'skill_listing', 'SPAN-O1-LEAK'), ), ].join('\n') + '\n' - const padSize = MAX_TRANSCRIPT_READ_BYTES + 1024 + const scanBudget = 8 * 1024 + const padSize = scanBudget + 1024 const line = Buffer.alloc(80, 0x78) line[79] = 0x0a const pad = Buffer.allocUnsafe(padSize) @@ -1425,7 +1498,7 @@ describe('appendEntry remote egress gate', () => { resetProjectForTesting() clearSessionMessagesCache() setSessionFileForTesting(path) - await rebuildRemoteEgressOmittedParentsForTesting() + await rebuildRemoteEgressOmittedParentsForTesting(scanBudget) expect(getRemoteEgressOmissionRebuildIncompleteForTesting()).toBe(true) setInternalEventWriter(async (_eventType, payload) => { @@ -1448,7 +1521,7 @@ describe('appendEntry remote egress gate', () => { } finally { await rm(dir, { recursive: true, force: true }) } - }, 60000) + }) test('compact fallback sees queued writes before flush', async () => { process.env.USER_TYPE = 'external' diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 62b3563f16..1c3baf187d 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -847,8 +847,10 @@ export function setSessionFileForTesting(path: string): void { } /** @internal Rebuild remote egress omission map from sessionFile (tests). */ -export function rebuildRemoteEgressOmittedParentsForTesting(): void { - getProject().rebuildRemoteEgressOmittedParentsFromLocalTranscript() +export function rebuildRemoteEgressOmittedParentsForTesting( + scanBudget?: number, +): void { + getProject().rebuildRemoteEgressOmittedParentsFromLocalTranscript(scanBudget) } /** @internal Snapshot remote egress omission map (tests). */ @@ -1320,7 +1322,9 @@ class Project { * tails must not leave the map empty and dangle the first post-resume * append on a withheld parentUuid. */ - rebuildRemoteEgressOmittedParentsFromLocalTranscript(): void { + rebuildRemoteEgressOmittedParentsFromLocalTranscript( + scanBudget?: number, + ): void { this.remoteEgressOmittedParents.clear() this.evictedRemoteEgressOmissions.clear() this.remoteEgressCompactAncestry.clear() @@ -1337,6 +1341,7 @@ class Project { compactAncestry: this.remoteEgressCompactAncestry, knownOmitted: this.remoteEgressKnownOmitted, }, + scanBudget, ) this.remoteEgressOmissionRebuildIncomplete = !result.complete } @@ -2119,6 +2124,20 @@ class Project { if (remoteEntry.uuid) { this.lastRemoteEgressUuid = remoteEntry.uuid } + } else if (entry.uuid) { + // Fail-closed persist still owns this UUID: later children + // must rematch through the map instead of treating B as + // egressed (grandchild C would otherwise dangle on B). + this.remoteEgressOmittedParents.set( + entry.uuid, + this.lastRemoteEgressUuid, + ) + boundRemoteEgressOmissionMap( + this.remoteEgressOmittedParents, + this.evictedRemoteEgressOmissions, + this.remoteEgressCompactAncestry, + this.remoteEgressKnownOmitted, + ) } // Keep omitted parents in the bounded map so a second branch // child of the same withheld UUID can still reparent. Size is @@ -3897,11 +3916,12 @@ function readTranscriptRangeForOmissionRebuild( /** * Rebuild the omission map from disk. Start at the tail - * (OMISSION_REBUILD_TAIL_BYTES). If the tail is metadata-only or any - * chain-tip parent is still unresolved, walk earlier windows of the same - * budget until ancestry closes or MAX_TRANSCRIPT_READ_BYTES is scanned. - * Returns complete=false when ancestry cannot be closed (oversized mid-line - * skip, scan budget exhausted, or unresolved tips). + * (OMISSION_REBUILD_TAIL_BYTES, or scanBudget when smaller). If the tail + * is metadata-only or any chain-tip parent is still unresolved, walk + * earlier windows of the same budget until ancestry closes or scanBudget + * (default MAX_TRANSCRIPT_READ_BYTES) is scanned. Returns complete=false + * when ancestry cannot be closed (oversized mid-line skip, scan budget + * exhausted, or unresolved tips). */ function ingestRemoteEgressOmissionsFromTranscriptFile( fullPath: string, @@ -3911,6 +3931,7 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( compactAncestry: Map knownOmitted: Set }, + scanBudget: number = MAX_TRANSCRIPT_READ_BYTES, ): { complete: boolean } { let fd: number | undefined try { @@ -3918,6 +3939,8 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( const size = fstatSync(fd).size if (size === 0) return { complete: true } + const budget = Math.max(1, scanBudget) + const windowBytes = Math.min(OMISSION_REBUILD_TAIL_BYTES, budget) const egressed = new Set() const referencedParents = new Set() let sawTranscript = false @@ -3926,8 +3949,8 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( let sawUnrecoverableMidLineSkip = false let closed = false - while (cursor > 0 && scanned < MAX_TRANSCRIPT_READ_BYTES) { - const start = Math.max(0, cursor - OMISSION_REBUILD_TAIL_BYTES) + while (cursor > 0 && scanned < budget) { + const start = Math.max(0, cursor - windowBytes) const { content, nextEnd, skippedMidLine } = readTranscriptRangeForOmissionRebuild(fd, start, cursor) // Recoverable window alignment (slice to the next newline) is not @@ -3956,7 +3979,15 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( boundState.compactAncestry, boundState.knownOmitted, ) - boundUuidSet(egressed, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) + boundUuidSetPreserving( + egressed, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + collectOmissionClosureWitnesses( + referencedParents, + omittedParents, + egressed, + ), + ) boundUuidSet(referencedParents, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) } if ( @@ -3989,8 +4020,7 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( ) } - const hitScanCap = - scanned >= MAX_TRANSCRIPT_READ_BYTES && cursor > 0 && !closed + const hitScanCap = scanned >= budget && cursor > 0 && !closed const complete = (!sawTranscript || closed) && !sawUnrecoverableMidLineSkip && @@ -4027,6 +4057,45 @@ function boundUuidSet(ids: Set, maxSize: number): void { } } +/** Egressed UUIDs that close a referenced parent walk — keep them at bound. */ +function collectOmissionClosureWitnesses( + referencedParents: Set, + omittedParents: Map, + egressed: Set, +): Set { + const preserve = new Set() + for (const parent of referencedParents) { + let current: UUID | null = parent + const seen = new Set() + while (current && omittedParents.has(current) && !seen.has(current)) { + seen.add(current) + current = omittedParents.get(current) ?? null + } + if (current !== null && egressed.has(current)) { + preserve.add(current) + } + } + return preserve +} + +function boundUuidSetPreserving( + ids: Set, + maxSize: number, + preserve: Set, +): void { + if (ids.size <= maxSize) return + const evict: UUID[] = [] + for (const id of ids) { + if (!preserve.has(id)) evict.push(id) + } + let extra = ids.size - maxSize + for (const id of evict) { + if (extra <= 0) break + ids.delete(id) + extra-- + } +} + function boundRemoteEgressOmissionMap( omittedParents: Map, evicted: Set, From b48bdb112cab5353cb26ee3640749031d05f2e6e Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:26:59 +0900 Subject: [PATCH 17/31] fix(privacy): tighten grandchild persist-skip assertion for 3762934396 The optional parentUuid check passed when the grandchild never egressed. Assert the fail-closed skip records grandchildUuid on the omission map. --- src/utils/sessionStorage.externalEgress.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index dd1bcf4540..4f94cf7244 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -1442,10 +1442,13 @@ describe('appendEntry remote egress gate', () => { await recordTranscript([grandchild], undefined, midUuid) await flushSessionStorage() - const remoteGrandchild = remotePayloads.find( - p => p.uuid === grandchildUuid, - ) - expect(remoteGrandchild?.parentUuid).not.toBe(midUuid) + // Incomplete rebuild stays true, so the grandchild takes the same + // fail-closed persist skip as `mid` — assert that branch, not rematch + // emit (optional parentUuid would pass when the payload is missing). + expect(remotePayloads.find(p => p.uuid === grandchildUuid)).toBeUndefined() + expect( + getRemoteEgressOmittedParentsForTesting().has(grandchildUuid), + ).toBe(true) expect(getRemoteEgressOmittedParentsForTesting().has(midUuid)).toBe(true) } finally { await rm(dir, { recursive: true, force: true }) From c4b72392034ab6a3a98c5dfec78f7394cb6e39c3 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:06:33 +0900 Subject: [PATCH 18/31] fix(privacy): fail-close cyclic omitted parents and cache parent_safe CodeRabbit 4911978612 still applied on HEAD: cycle walks projected a withheld UUID, and parent_safe scans were uncached on every append. --- .../sessionStorage.externalEgress.test.ts | 4 +-- src/utils/sessionStorage.ts | 26 ++++++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 4f94cf7244..d04db33485 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -371,9 +371,7 @@ describe('recordExternalEgressOmission / projectTranscriptParentForExternalEgres { parentUuid: id(4) as UUID | null }, map, ) - expect(cyclic.parentUuid === id(4) || cyclic.parentUuid === id(5)).toBe( - true, - ) + expect(cyclic.parentUuid).toBeNull() }) }) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 1c3baf187d..87e2f554b3 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -966,6 +966,10 @@ class Project { private remoteEgressKnownOmitted = new Set() // Negative cache for on-demand walks that returned { found: false }. private remoteEgressResolvedMisses = new Set() + // Positive cache: parent_safe walk results. Distinct from misses so a + // later true miss cannot inherit a prior safe verdict, and so incomplete + // rebuilds do not re-scan the same safe parent on every append. + private remoteEgressConfirmedSafeParents = new Set() private lastRemoteEgressUuid: UUID | null = null // True when rebuild could not establish complete ancestor closure // (oversized mid-line skip, scan budget exhausted, unresolved tips). @@ -986,6 +990,7 @@ class Project { this.remoteEgressCompactAncestry.clear() this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() + this.remoteEgressConfirmedSafeParents.clear() this.remoteEgressOmissionRebuildIncomplete = false this.lastRemoteEgressUuid = null this.rewriteBarrierFiles = new Set() @@ -1330,6 +1335,7 @@ class Project { this.remoteEgressCompactAncestry.clear() this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() + this.remoteEgressConfirmedSafeParents.clear() this.remoteEgressOmissionRebuildIncomplete = false const path = this.sessionFile if (!path) return @@ -1354,6 +1360,7 @@ class Project { this.remoteEgressCompactAncestry.clear() this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() + this.remoteEgressConfirmedSafeParents.clear() this.remoteEgressOmissionRebuildIncomplete = false this.lastRemoteEgressUuid = null } @@ -2057,6 +2064,12 @@ class Project { originalParentUuid, ) ?? null, } + } else if ( + this.remoteEgressConfirmedSafeParents.has( + originalParentUuid, + ) + ) { + parentConfirmedSafe = true } else if ( !this.remoteEgressResolvedMisses.has(originalParentUuid) && (this.evictedRemoteEgressOmissions.has( @@ -2090,6 +2103,13 @@ class Project { originalParentUuid, ) this.remoteEgressKnownOmitted.delete(originalParentUuid) + this.remoteEgressConfirmedSafeParents.add( + originalParentUuid, + ) + boundUuidSet( + this.remoteEgressConfirmedSafeParents, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) } else if (!queueHasPendingTranscriptAppends(queue)) { // Only cache durable misses — queued parents may land soon. this.remoteEgressResolvedMisses.add(originalParentUuid) @@ -4198,7 +4218,8 @@ function resolveAncestorFromCompactAncestryNodes( } current = node.parentUuid } - return { status: 'resolved', ancestor: current } + // Cycle or exhausted unsafe chain: never project a withheld UUID. + return { status: 'resolved', ancestor: null } } /** @@ -6134,6 +6155,9 @@ export function projectTranscriptParentForExternalEgress< seen.add(current) current = omittedParents.get(current) ?? null } + if (current !== null && omittedParents.has(current)) { + current = null + } return { ...entry, parentUuid: current, From 7376d6e3c6ce7f378d3854d5fb31574acec63349 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:24:19 +0900 Subject: [PATCH 19/31] test(privacy): fix seed startingParentUuid type in external egress test --- src/utils/sessionStorage.externalEgress.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index d04db33485..7568deaccb 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -1413,7 +1413,7 @@ describe('appendEntry remote egress gate', () => { timestamp: '2026-08-11T00:05:00.000Z', message: { role: 'user', content: 'seed before suppressed mid' }, } as unknown as Message - await recordTranscript([seed], undefined, null) + await recordTranscript([seed]) await flushSessionStorage() expect(remotePayloads.find(p => p.uuid === seedUuid)).toBeDefined() From e488ac4e79e39460d70ff7664482d9951a6f41a8 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:27:07 +0900 Subject: [PATCH 20/31] fix(privacy): unified fail-closed egress classifier, explicit delivery tracking, and fail-closed resolution for evicted parents --- .../sessionStorage.externalEgress.test.ts | 414 ++++++++++++++++++ src/utils/sessionStorage.ts | 139 +++--- 2 files changed, 485 insertions(+), 68 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 7568deaccb..9b918fe0b1 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -20,7 +20,9 @@ import { flushSessionStorage, getRemoteEgressOmittedParentsForTesting, getRemoteEgressOmissionRebuildIncompleteForTesting, + isMalformedAttachmentBearingEgressRecord, isSafeForExternalEgress, + shouldOmitFromExternalEgress, EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, OMISSION_REBUILD_TAIL_BYTES, @@ -1606,3 +1608,415 @@ describe('appendEntry remote egress gate', () => { } }) }) + +describe('jatmn review 4965633776 regressions', () => { + test('shouldOmitFromExternalEgress rejects malformed records, listings, and un-consented attachments', () => { + process.env.USER_TYPE = 'external' + delete process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT + + // Malformed records: attachment payload on non-attachment outer type + expect( + shouldOmitFromExternalEgress({ + type: 'user', + attachment: { type: 'skill_listing', skills: ['bash'] }, + }), + ).toBe(true) + expect( + shouldOmitFromExternalEgress({ + type: 'assistant', + attachment: { type: 'some_tool' }, + }), + ).toBe(true) + + // Listings + for (const listingType of EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES) { + expect( + shouldOmitFromExternalEgress({ + type: 'attachment', + attachment: { type: listingType }, + }), + ).toBe(true) + } + + // Progress and hook context + expect( + shouldOmitFromExternalEgress({ + type: 'progress', + }), + ).toBe(true) + expect( + shouldOmitFromExternalEgress({ + type: 'attachment', + attachment: { type: 'hook_additional_context' }, + }), + ).toBe(true) + + // Safe records + expect( + shouldOmitFromExternalEgress({ + type: 'user', + }), + ).toBe(false) + expect( + shouldOmitFromExternalEgress({ + type: 'assistant', + }), + ).toBe(false) + }) + + test('P1-delivery: writer rejection on parent A omits A; child C reparents past A without dangling', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-p1-delivery-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(601) + const parentA_Uuid = id(602) + const listingB_Uuid = id(603) + const childC_Uuid = id(604) + const remotePayloads: Array> = [] + + try { + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + + setInternalEventWriter(async (_eventType, payload) => { + if (payload.uuid === parentA_Uuid) { + throw new Error('Simulated CCR writer transport failure for parent A') + } + remotePayloads.push(payload) + }) + + // 1. Deliver safe seed + const seed = { + type: 'user', + uuid: seedUuid, + parentUuid: null, + timestamp: '2026-08-11T00:05:00.000Z', + message: { role: 'user', content: 'seed message' }, + } as unknown as Message + await recordTranscript([seed]) + await flushSessionStorage() + expect(remotePayloads.find(p => p.uuid === seedUuid)).toBeDefined() + + // 2. Attempt parent A: writer rejects, so persistToRemote returns false + // and A is recorded as omitted pointing to seedUuid (last confirmed tip). + const parentA = { + type: 'user', + uuid: parentA_Uuid, + parentUuid: seedUuid, + timestamp: '2026-08-11T00:05:01.000Z', + message: { role: 'user', content: 'parent A (fails write)' }, + } as unknown as Message + await recordTranscript([parentA], undefined, seedUuid) + await flushSessionStorage() + expect(remotePayloads.find(p => p.uuid === parentA_Uuid)).toBeUndefined() + expect(getRemoteEgressOmittedParentsForTesting().get(parentA_Uuid)).toBe( + seedUuid, + ) + + // 3. Unsafe listing B is withheld by classifier and chained to parent A + const listingB = { + type: 'attachment', + uuid: listingB_Uuid, + parentUuid: parentA_Uuid, + timestamp: '2026-08-11T00:05:02.000Z', + attachment: { + type: 'hook_additional_context', + content: 'WITHHELD_HOOK_CONTENT', + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: 'WITHHELD_HOOK_CONTENT', + stderr: '', + exitCode: 0, + }, + } as unknown as Message + await recordTranscript([listingB], undefined, parentA_Uuid) + await flushSessionStorage() + expect(remotePayloads.find(p => p.uuid === listingB_Uuid)).toBeUndefined() + + // 4. Safe child C (parented to listing B) is delivered. + // Its parentUuid must be reparented past B and A to seedUuid! + const childC = { + type: 'user', + uuid: childC_Uuid, + parentUuid: listingB_Uuid, + timestamp: '2026-08-11T00:05:03.000Z', + message: { role: 'user', content: 'child C' }, + } as unknown as Message + await recordTranscript([childC], undefined, listingB_Uuid) + await flushSessionStorage() + + const remoteC = remotePayloads.find(p => p.uuid === childC_Uuid) + expect(remoteC).toBeDefined() + // Assert: C does NOT have dangling parentUuid pointing to unwritten A or B + expect(remoteC?.parentUuid).toBe(seedUuid) + expect(remoteC?.parentUuid).not.toBe(parentA_Uuid) + expect(remoteC?.parentUuid).not.toBe(listingB_Uuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('P1-malformed: malformed attachment record on user message is blocked on live CCR persistence', async () => { + process.env.USER_TYPE = 'external' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-p1-malformed-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(610) + const malformedUuid = id(611) + const childUuid = id(612) + const remotePayloads: Array> = [] + + try { + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + // 1. Deliver safe seed + const seed = { + type: 'user', + uuid: seedUuid, + parentUuid: null, + timestamp: '2026-08-11T00:05:00.000Z', + message: { role: 'user', content: 'seed message' }, + } as unknown as Message + await recordTranscript([seed]) + await flushSessionStorage() + + // 2. Malformed record: outer type 'user' with attachment payload + const malformed = { + type: 'user', + uuid: malformedUuid, + parentUuid: seedUuid, + timestamp: '2026-08-11T00:05:01.000Z', + message: { role: 'user', content: 'malformed entry' }, + attachment: { + type: 'skill_listing', + skills: [{ name: 'SECRET_MALFORMED_SKILL' }], + }, + } as unknown as Message + await recordTranscript([malformed], undefined, seedUuid) + await flushSessionStorage() + + // Assert: malformed record never reached remote + expect(remotePayloads.find(p => p.uuid === malformedUuid)).toBeUndefined() + expect(JSON.stringify(remotePayloads)).not.toContain( + 'SECRET_MALFORMED_SKILL', + ) + // Assert: malformed entry was recorded in omission map + expect( + getRemoteEgressOmittedParentsForTesting().has(malformedUuid), + ).toBe(true) + + // 3. Child of malformed entry reparents to seed + const child = { + type: 'user', + uuid: childUuid, + parentUuid: malformedUuid, + timestamp: '2026-08-11T00:05:02.000Z', + message: { role: 'user', content: 'child of malformed' }, + } as unknown as Message + await recordTranscript([child], undefined, malformedUuid) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + expect(remoteChild?.parentUuid).toBe(seedUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('P2-eviction: child of historic withheld parent evicted past all tracking tiers resolves from local transcript', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-p2-eviction-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(700) + const ancientWithheldUuid = id(701) + const childUuid = id(799) + const remotePayloads: Array> = [] + + try { + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + // 1. Deliver seed + const seed = { + type: 'user', + uuid: seedUuid, + parentUuid: null, + timestamp: '2026-08-11T00:05:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + await recordTranscript([seed]) + await flushSessionStorage() + + // 2. Deliver ancient withheld parent (parented to seed) + const ancientWithheld = { + type: 'attachment', + uuid: ancientWithheldUuid, + parentUuid: seedUuid, + timestamp: '2026-08-11T00:05:01.000Z', + attachment: { + type: 'hook_additional_context', + content: 'ANCIENT-LEAK', + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: 'ANCIENT-LEAK', + stderr: '', + exitCode: 0, + }, + } as unknown as Message + await recordTranscript([ancientWithheld], undefined, seedUuid) + await flushSessionStorage() + + // 3. Flood with > 4 tiers of omissions (> 256) so ancientWithheldUuid + // is evicted from: + // - remoteEgressOmittedParents (Tier 1) + // - evictedRemoteEgressOmissions (Tier 2) + // - remoteEgressCompactAncestry (Tier 2) + // - remoteEgressKnownOmitted (Tier 3) + let prev = ancientWithheldUuid + for (let n = 0; n < MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE * 4 + 10; n++) { + const u = id(800 + n) + const floodEntry = { + type: 'attachment', + uuid: u, + parentUuid: prev, + timestamp: `2026-08-11T00:05:02.${String(n % 1000).padStart(3, '0')}Z`, + attachment: { + type: 'hook_additional_context', + content: 'FLOOD', + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: 'FLOOD', + stderr: '', + exitCode: 0, + }, + } as unknown as Message + await recordTranscript([floodEntry], undefined, prev) + prev = u + } + await flushSessionStorage() + + // Verify that ancientWithheldUuid is completely absent from Tier 1 map + expect( + getRemoteEgressOmittedParentsForTesting().has(ancientWithheldUuid), + ).toBe(false) + + // 4. Send child of ancientWithheldUuid on a branching chain. + // Because ancientWithheldUuid is absent from all 4 memory tiers, it must + // resolve on-demand from the local transcript file to seedUuid. + const child = { + type: 'user', + uuid: childUuid, + parentUuid: ancientWithheldUuid, + timestamp: '2026-08-11T00:06:00.000Z', + message: { role: 'user', content: 'branch child of ancient withheld' }, + } as unknown as Message + await recordTranscript([child], undefined, ancientWithheldUuid) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + // Assert: child was reparented to seedUuid (nearest safe ancestor), NOT ancientWithheldUuid + expect(remoteChild?.parentUuid).toBe(seedUuid) + expect(remoteChild?.parentUuid).not.toBe(ancientWithheldUuid) + expect(JSON.stringify(remotePayloads)).not.toContain('ANCIENT-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('P2-eviction without transcript: child of fully-evicted parent whose ancestry cannot be resolved fails closed', async () => { + process.env.USER_TYPE = 'external' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp( + join(tmpdir(), 'openclaude-egress-p2-failclosed-'), + ) + const path = join(dir, 'session.jsonl') + const seedUuid = id(900) + const unknownParentUuid = id(901) // Not in transcript + const childUuid = id(902) + const remotePayloads: Array> = [] + + try { + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + // 1. Deliver seed + const seed = { + type: 'user', + uuid: seedUuid, + parentUuid: null, + timestamp: '2026-08-11T00:05:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + await recordTranscript([seed]) + await flushSessionStorage() + + // 2. Child references unknownParentUuid which does not exist in the local transcript + // and is not in any omission map. + const child = { + type: 'user', + uuid: childUuid, + parentUuid: unknownParentUuid, + timestamp: '2026-08-11T00:05:01.000Z', + message: { role: 'user', content: 'child of unresolvable parent' }, + } as unknown as Message + await recordTranscript([child], undefined, unknownParentUuid) + await flushSessionStorage() + + // Assert: child fails closed and is NOT delivered with dangling reference + expect(remotePayloads.find(p => p.uuid === childUuid)).toBeUndefined() + // Assert: child itself was recorded in omission map for subsequent rematching + expect( + getRemoteEgressOmittedParentsForTesting().has(childUuid), + ).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 87e2f554b3..39adc92047 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -2036,25 +2036,23 @@ class Project { // and reparent the next remote entry so hydrateRemoteSession / // buildConversationChain do not stop at a missing parentUuid. if (isTranscriptMessage(entry)) { - if (isSafeForExternalEgress(entry)) { + if (!shouldOmitFromExternalEgress(entry)) { const originalParentUuid = entry.parentUuid ?? null let remoteEntry = projectTranscriptParentForExternalEgress( entry, this.remoteEgressOmittedParents, ) let parentConfirmedSafe = false - const parentMayNeedResolve = - !!originalParentUuid && - (this.remoteEgressOmittedParents.has(originalParentUuid) || - this.remoteEgressCompactAncestry.has(originalParentUuid) || - this.evictedRemoteEgressOmissions.has(originalParentUuid) || - this.remoteEgressKnownOmitted.has(originalParentUuid) || - this.remoteEgressOmissionRebuildIncomplete) - if ( - originalParentUuid && - remoteEntry.parentUuid === originalParentUuid - ) { - if ( + if (originalParentUuid) { + if (originalParentUuid === this.lastRemoteEgressUuid) { + parentConfirmedSafe = true + } else if ( + this.remoteEgressConfirmedSafeParents.has( + originalParentUuid, + ) + ) { + parentConfirmedSafe = true + } else if ( this.remoteEgressCompactAncestry.has(originalParentUuid) ) { remoteEntry = { @@ -2064,19 +2062,10 @@ class Project { originalParentUuid, ) ?? null, } + } else if (remoteEntry.parentUuid !== originalParentUuid) { + // Already reparented via projectTranscriptParentForExternalEgress } else if ( - this.remoteEgressConfirmedSafeParents.has( - originalParentUuid, - ) - ) { - parentConfirmedSafe = true - } else if ( - !this.remoteEgressResolvedMisses.has(originalParentUuid) && - (this.evictedRemoteEgressOmissions.has( - originalParentUuid, - ) || - this.remoteEgressKnownOmitted.has(originalParentUuid) || - this.remoteEgressOmissionRebuildIncomplete) + !this.remoteEgressResolvedMisses.has(originalParentUuid) ) { const queue = this.sessionFile ? this.writeQueues.get(this.sessionFile) @@ -2127,22 +2116,30 @@ class Project { const parentStillUnresolved = !!originalParentUuid && !parentConfirmedSafe && - parentMayNeedResolve && (this.remoteEgressOmissionRebuildIncomplete || - (remoteEntry.parentUuid === originalParentUuid && - (this.remoteEgressOmittedParents.has( - originalParentUuid, - ) || - this.evictedRemoteEgressOmissions.has( - originalParentUuid, - ) || - this.remoteEgressKnownOmitted.has( - originalParentUuid, - )))) + remoteEntry.parentUuid === originalParentUuid) if (!parentStillUnresolved) { - await this.persistToRemote(sessionId, remoteEntry) - if (remoteEntry.uuid) { + const delivered = await this.persistToRemote( + sessionId, + remoteEntry, + ) + if (delivered && remoteEntry.uuid) { this.lastRemoteEgressUuid = remoteEntry.uuid + } else if ( + !delivered && + this.hasActiveRemoteEgressSink() && + entry.uuid + ) { + this.remoteEgressOmittedParents.set( + entry.uuid, + this.lastRemoteEgressUuid, + ) + boundRemoteEgressOmissionMap( + this.remoteEgressOmittedParents, + this.evictedRemoteEgressOmissions, + this.remoteEgressCompactAncestry, + this.remoteEgressKnownOmitted, + ) } } else if (entry.uuid) { // Fail-closed persist still owns this UUID: later children @@ -2227,9 +2224,12 @@ class Project { } } - private async persistToRemote(sessionId: UUID, entry: TranscriptMessage) { + private async persistToRemote( + sessionId: UUID, + entry: TranscriptMessage, + ): Promise { if (isShuttingDown()) { - return + return false } // CCR v2 path: write as internal worker event @@ -2243,11 +2243,12 @@ class Project { ...(entry.agentId && { agentId: entry.agentId }), }, ) + return true } catch { logEvent('tengu_session_persistence_failed', {}) logForDebugging('Failed to write transcript as internal event') + return false } - return } // v1 Session Ingress path @@ -2255,7 +2256,7 @@ class Project { !isEnvTruthy(process.env.ENABLE_SESSION_PERSISTENCE) || !this.remoteIngressUrl ) { - return + return false } const success = await sessionIngress.appendSessionLog( @@ -2267,7 +2268,9 @@ class Project { if (!success) { logEvent('tengu_session_persistence_failed', {}) gracefulShutdownSync(1, 'other') + return false } + return true } setRemoteIngressUrl(url: string): void { @@ -3845,7 +3848,7 @@ function ingestRemoteEgressOmissionsFromTranscriptContent( // Always apply current external egress policy. hook_additional_context is // never safe for remote even when CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT // allows local persistence — local save ≠ upload consent. - if (!isSafeForExternalEgress(entry)) { + if (shouldOmitFromExternalEgress(entry)) { recordExternalEgressOmission( omittedParents, entry.uuid, @@ -4158,7 +4161,7 @@ function ingestCompactAncestryNodesFromTranscriptContent( if (!entry.uuid) continue byUuid.set(entry.uuid, { parentUuid: entry.parentUuid ?? null, - safe: isSafeForExternalEgress(entry), + safe: !shouldOmitFromExternalEgress(entry), }) } } @@ -4175,7 +4178,7 @@ function ingestCompactAncestryNodesFromWriteQueue( if (!entry.uuid) continue byUuid.set(entry.uuid, { parentUuid: entry.parentUuid ?? null, - safe: isSafeForExternalEgress(entry), + safe: !shouldOmitFromExternalEgress(entry), }) } } @@ -6206,6 +6209,9 @@ export function isSafeForExternalEgress(entry: { type?: string attachment?: unknown }): boolean { + if (isMalformedAttachmentBearingEgressRecord(entry)) { + return false + } // Listings must never cross remote / share / feedback — including ant. if (isExternalEgressListingAttachment(entry)) { return false @@ -6254,7 +6260,7 @@ export async function readFilteredTranscriptJsonlForExternalEgress( * outer type must fail closed for egress — they would otherwise bypass the * listing classifier (isSafeForExternalEgress only inspects type===attachment). */ -function isMalformedAttachmentBearingEgressRecord(entry: { +export function isMalformedAttachmentBearingEgressRecord(entry: { type?: string attachment?: unknown }): boolean { @@ -6264,6 +6270,23 @@ function isMalformedAttachmentBearingEgressRecord(entry: { return entry.type !== 'attachment' } +/** + * Authoritative fail-closed classifier for external transcript records across + * all egress consumers (live CCR/session-ingress, feedback, share, rebuild, + * and on-demand ancestry lookup). + * + * Returns true if the entry MUST be omitted from external egress. + */ +export function shouldOmitFromExternalEgress(entry: { + type?: string + attachment?: unknown +}): boolean { + if (isMalformedAttachmentBearingEgressRecord(entry)) { + return true + } + return !isSafeForExternalEgress(entry) +} + /** * Drop entries that must not leave the local machine (share, feedback, * analytics payloads built from in-memory messages). Relinks parentUuid @@ -6282,17 +6305,7 @@ export function filterMessagesForExternalEgress< const omittedParents = new Map() const kept: T[] = [] for (const message of messages) { - if (isMalformedAttachmentBearingEgressRecord(message)) { - if (typeof message.uuid === 'string') { - recordExternalEgressOmission( - omittedParents, - message.uuid, - message.parentUuid ?? null, - ) - } - continue - } - if (!isSafeForExternalEgress(message)) { + if (shouldOmitFromExternalEgress(message)) { if (typeof message.uuid === 'string') { recordExternalEgressOmission( omittedParents, @@ -6368,17 +6381,7 @@ export function filterJsonlForExternalEgress(jsonl: string): string { uuid?: UUID parentUuid?: UUID | null } - if (isMalformedAttachmentBearingEgressRecord(entry)) { - if (typeof entry.uuid === 'string') { - recordExternalEgressOmission( - omittedParents, - entry.uuid, - entry.parentUuid ?? null, - ) - } - continue - } - if (!isSafeForExternalEgress(entry)) { + if (shouldOmitFromExternalEgress(entry)) { if (typeof entry.uuid === 'string') { recordExternalEgressOmission( omittedParents, From e814390c354d5d51954eedb9a21f54c84f61da5c Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:52:59 +0900 Subject: [PATCH 21/31] fix(privacy): guard live egress projection on active sink, add test timeout, and refine describe title --- src/utils/sessionStorage.externalEgress.test.ts | 4 ++-- src/utils/sessionStorage.ts | 10 +++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 9b918fe0b1..5133cda95e 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -1609,7 +1609,7 @@ describe('appendEntry remote egress gate', () => { }) }) -describe('jatmn review 4965633776 regressions', () => { +describe('external egress delivery failures, malformed records, and eviction fallback', () => { test('shouldOmitFromExternalEgress rejects malformed records, listings, and un-consented attachments', () => { process.env.USER_TYPE = 'external' delete process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT @@ -1957,7 +1957,7 @@ describe('jatmn review 4965633776 regressions', () => { } finally { await rm(dir, { recursive: true, force: true }) } - }) + }, 20000) test('P2-eviction without transcript: child of fully-evicted parent whose ancestry cannot be resolved fails closed', async () => { process.env.USER_TYPE = 'external' diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 39adc92047..943c736af0 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -2035,7 +2035,7 @@ class Project { // participant is withheld, record it in remoteEgressOmittedParents // and reparent the next remote entry so hydrateRemoteSession / // buildConversationChain do not stop at a missing parentUuid. - if (isTranscriptMessage(entry)) { + if (isTranscriptMessage(entry) && this.hasActiveRemoteEgressSink()) { if (!shouldOmitFromExternalEgress(entry)) { const originalParentUuid = entry.parentUuid ?? null let remoteEntry = projectTranscriptParentForExternalEgress( @@ -2125,11 +2125,7 @@ class Project { ) if (delivered && remoteEntry.uuid) { this.lastRemoteEgressUuid = remoteEntry.uuid - } else if ( - !delivered && - this.hasActiveRemoteEgressSink() && - entry.uuid - ) { + } else if (!delivered && entry.uuid) { this.remoteEgressOmittedParents.set( entry.uuid, this.lastRemoteEgressUuid, @@ -2159,7 +2155,7 @@ class Project { // Keep omitted parents in the bounded map so a second branch // child of the same withheld UUID can still reparent. Size is // enforced by boundRemoteEgressOmissionMap on the omit path. - } else if (this.hasActiveRemoteEgressSink()) { + } else { // Only grow the map when a remote sink can consume reparents. // Resume rebuild (adoptResumedSessionFile) still hydrates from // disk before the sink is registered. From 343abfc8dd1e9034fd9114a9c0eed09991bce791 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:32:31 +0900 Subject: [PATCH 22/31] fix(privacy): recursively resolve chained omitted/evicted ancestors and handle v1 sessionIngress rejections --- .../sessionStorage.externalEgress.test.ts | 199 +++++++++++++++++- src/utils/sessionStorage.ts | 109 ++++++---- 2 files changed, 261 insertions(+), 47 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 5133cda95e..f33b755999 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -1,8 +1,9 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' import type { UUID } from 'crypto' import { mkdtemp, rm, writeFile, appendFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import * as sessionIngress from '../services/api/sessionIngress.js' import { isSessionPersistenceDisabled, setSessionPersistenceDisabled, @@ -11,6 +12,7 @@ import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../test/sharedMutationLock.js' +import type { Entry, TranscriptMessage } from '../types/logs.js' import type { Message } from '../types/message.js' import { clearSessionMessagesCache, @@ -32,6 +34,7 @@ import { recordTranscript, resetProjectForTesting, setInternalEventWriter, + setRemoteIngressUrlForTesting, setSessionFileForTesting, } from './sessionStorage.js' @@ -2018,5 +2021,199 @@ describe('external egress delivery failures, malformed records, and eviction fal await rm(dir, { recursive: true, force: true }) } }) + + test('P2-eviction: recursive compact ancestry resolution through chained omitted and evicted parents', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-p2-chain-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(1001) + const withheldC_Uuid = id(1002) + const withheldB_Uuid = id(1003) + const childA_Uuid = id(1004) + const remotePayloads: Array> = [] + + try { + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + // 1. Deliver seed + const seed = { + type: 'user', + uuid: seedUuid, + parentUuid: null, + timestamp: '2026-08-11T00:05:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + await recordTranscript([seed]) + await flushSessionStorage() + + // 2. Deliver withheld C (parented to seed) + const entryC = { + type: 'attachment', + uuid: withheldC_Uuid, + parentUuid: seedUuid, + timestamp: '2026-08-11T00:05:01.000Z', + attachment: { + type: 'hook_additional_context', + content: 'LEAK-C', + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: 'LEAK-C', + stderr: '', + exitCode: 0, + }, + } as unknown as Message + await recordTranscript([entryC], undefined, seedUuid) + await flushSessionStorage() + + // 3. Flood > 64 items so C is evicted into remoteEgressCompactAncestry + let prev = withheldC_Uuid + for (let n = 0; n < MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE + 5; n++) { + const u = id(1100 + n) + const floodEntry = { + type: 'attachment', + uuid: u, + parentUuid: prev, + timestamp: `2026-08-11T00:05:02.${String(n % 1000).padStart(3, '0')}Z`, + attachment: { + type: 'hook_additional_context', + content: 'FLOOD', + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: 'FLOOD', + stderr: '', + exitCode: 0, + }, + } as unknown as Message + await recordTranscript([floodEntry], undefined, prev) + prev = u + } + await flushSessionStorage() + + // Verify C was evicted from Tier 1 + expect( + getRemoteEgressOmittedParentsForTesting().has(withheldC_Uuid), + ).toBe(false) + + // 4. Send withheld B parented to evicted C + const entryB = { + type: 'attachment', + uuid: withheldB_Uuid, + parentUuid: withheldC_Uuid, + timestamp: '2026-08-11T00:05:03.000Z', + attachment: { + type: 'hook_additional_context', + content: 'LEAK-B', + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: 'LEAK-B', + stderr: '', + exitCode: 0, + }, + } as unknown as Message + await recordTranscript([entryB], undefined, withheldC_Uuid) + await flushSessionStorage() + + // 5. Send safe child A parented to withheld B + const entryA = { + type: 'user', + uuid: childA_Uuid, + parentUuid: withheldB_Uuid, + timestamp: '2026-08-11T00:06:00.000Z', + message: { role: 'user', content: 'safe child A' }, + } as unknown as Message + await recordTranscript([entryA], undefined, withheldB_Uuid) + await flushSessionStorage() + + const remoteA = remotePayloads.find(p => p.uuid === childA_Uuid) + expect(remoteA).toBeDefined() + // Assert: A reparented all the way through B and evicted C to seedUuid + expect(remoteA?.parentUuid).toBe(seedUuid) + expect(remoteA?.parentUuid).not.toBe(withheldB_Uuid) + expect(remoteA?.parentUuid).not.toBe(withheldC_Uuid) + expect(JSON.stringify(remotePayloads)).not.toContain('LEAK-B') + expect(JSON.stringify(remotePayloads)).not.toContain('LEAK-C') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('v1 session ingress: appendSessionLog promise rejection is caught and records omission', async () => { + process.env.USER_TYPE = 'external' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-v1-reject-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(1201) + const failedUuid = id(1202) + const childUuid = id(1203) + + const appendSpy = spyOn( + sessionIngress, + 'appendSessionLog', + ).mockImplementation(async (_sessionId, entry) => { + if (entry.uuid === failedUuid) { + throw new Error('Simulated network connection reset in v1 ingress') + } + return true + }) + + try { + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + setRemoteIngressUrlForTesting('https://mock-ingress.anthropic.com/api/v1') + + // 1. Deliver seed + const seed = { + type: 'user', + uuid: seedUuid, + parentUuid: null, + timestamp: '2026-08-11T00:05:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + await recordTranscript([seed]) + await flushSessionStorage() + + // 2. Send failed message: appendSessionLog throws, persistToRemote catches and returns false + const failedEntry = { + type: 'user', + uuid: failedUuid, + parentUuid: seedUuid, + timestamp: '2026-08-11T00:05:01.000Z', + message: { role: 'user', content: 'entry that throws during v1 append' }, + } as unknown as Message + await recordTranscript([failedEntry], undefined, seedUuid) + await flushSessionStorage() + + // Assert: failedEntry is caught and recorded in omission map pointing to seedUuid + expect( + getRemoteEgressOmittedParentsForTesting().get(failedUuid), + ).toBe(seedUuid) + } finally { + process.exitCode = 0 + appendSpy.mockRestore() + await rm(dir, { recursive: true, force: true }) + } + }) }) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 943c736af0..13314bd8c7 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -2043,81 +2043,92 @@ class Project { this.remoteEgressOmittedParents, ) let parentConfirmedSafe = false - if (originalParentUuid) { - if (originalParentUuid === this.lastRemoteEgressUuid) { + let targetParentUuid = remoteEntry.parentUuid + const seenAncestors = new Set() + while ( + targetParentUuid && + !parentConfirmedSafe && + !seenAncestors.has(targetParentUuid) + ) { + seenAncestors.add(targetParentUuid) + if (targetParentUuid === this.lastRemoteEgressUuid) { parentConfirmedSafe = true - } else if ( - this.remoteEgressConfirmedSafeParents.has( - originalParentUuid, - ) + break + } + if ( + this.remoteEgressConfirmedSafeParents.has(targetParentUuid) ) { parentConfirmedSafe = true - } else if ( - this.remoteEgressCompactAncestry.has(originalParentUuid) - ) { - remoteEntry = { - ...entry, - parentUuid: - this.remoteEgressCompactAncestry.get( - originalParentUuid, - ) ?? null, - } - } else if (remoteEntry.parentUuid !== originalParentUuid) { - // Already reparented via projectTranscriptParentForExternalEgress - } else if ( - !this.remoteEgressResolvedMisses.has(originalParentUuid) - ) { + break + } + if (this.remoteEgressOmittedParents.has(targetParentUuid)) { + targetParentUuid = + this.remoteEgressOmittedParents.get(targetParentUuid) ?? + null + remoteEntry = { ...entry, parentUuid: targetParentUuid } + continue + } + if (this.remoteEgressCompactAncestry.has(targetParentUuid)) { + targetParentUuid = + this.remoteEgressCompactAncestry.get(targetParentUuid) ?? + null + remoteEntry = { ...entry, parentUuid: targetParentUuid } + continue + } + if (!this.remoteEgressResolvedMisses.has(targetParentUuid)) { const queue = this.sessionFile ? this.writeQueues.get(this.sessionFile) : undefined const resolved = resolveCompactOmissionAncestorFromLocalTranscript( this.sessionFile, - originalParentUuid, + targetParentUuid, queue, ) if (resolved.status === 'resolved') { this.remoteEgressCompactAncestry.set( - originalParentUuid, + targetParentUuid, resolved.ancestor, ) boundCompactAncestryMap(this.remoteEgressCompactAncestry) + targetParentUuid = resolved.ancestor remoteEntry = { ...entry, - parentUuid: resolved.ancestor, + parentUuid: targetParentUuid, } - } else if (resolved.status === 'parent_safe') { + continue + } + if (resolved.status === 'parent_safe') { parentConfirmedSafe = true - this.evictedRemoteEgressOmissions.delete( - originalParentUuid, - ) - this.remoteEgressKnownOmitted.delete(originalParentUuid) + this.evictedRemoteEgressOmissions.delete(targetParentUuid) + this.remoteEgressKnownOmitted.delete(targetParentUuid) this.remoteEgressConfirmedSafeParents.add( - originalParentUuid, + targetParentUuid, ) boundUuidSet( this.remoteEgressConfirmedSafeParents, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, ) - } else if (!queueHasPendingTranscriptAppends(queue)) { + break + } + if (!queueHasPendingTranscriptAppends(queue)) { // Only cache durable misses — queued parents may land soon. - this.remoteEgressResolvedMisses.add(originalParentUuid) + this.remoteEgressResolvedMisses.add(targetParentUuid) boundUuidSet( this.remoteEgressResolvedMisses, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, ) } } + // Target ancestor could not be confirmed safe or resolved + break } - // Fail closed: do not emit a remote child whose parent still - // points at a withheld / incomplete-rebuild UUID. Incomplete - // rebuilds skip persist even after a one-hop rematch (O2→O1) - // — the rematch target may also be withheld and unseen. + // Fail closed: do not emit a remote child whose target parent + // cannot be confirmed safe, or under an incomplete rebuild. const parentStillUnresolved = !!originalParentUuid && - !parentConfirmedSafe && - (this.remoteEgressOmissionRebuildIncomplete || - remoteEntry.parentUuid === originalParentUuid) + (!parentConfirmedSafe || + this.remoteEgressOmissionRebuildIncomplete) if (!parentStillUnresolved) { const delivered = await this.persistToRemote( sessionId, @@ -2255,18 +2266,24 @@ class Project { return false } - const success = await sessionIngress.appendSessionLog( - sessionId, - entry, - this.remoteIngressUrl, - ) + try { + const success = await sessionIngress.appendSessionLog( + sessionId, + entry, + this.remoteIngressUrl, + ) - if (!success) { + if (!success) { + logEvent('tengu_session_persistence_failed', {}) + gracefulShutdownSync(1, 'other') + return false + } + return true + } catch { logEvent('tengu_session_persistence_failed', {}) gracefulShutdownSync(1, 'other') return false } - return true } setRemoteIngressUrl(url: string): void { From 7d1d9a9b4e3f557da52b2854b8dd51e7919dc698 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:48:54 +0900 Subject: [PATCH 23/31] fix(privacy): treat null target parent as safe projected root and complete v1 rejection regression test --- .../sessionStorage.externalEgress.test.ts | 93 ++++++++++++++++++- src/utils/sessionStorage.ts | 14 ++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index f33b755999..e6bf5eba07 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -4,6 +4,7 @@ import { mkdtemp, rm, writeFile, appendFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import * as sessionIngress from '../services/api/sessionIngress.js' +import * as gracefulShutdownModule from '../utils/gracefulShutdown.js' import { isSessionPersistenceDisabled, setSessionPersistenceDisabled, @@ -2167,6 +2168,11 @@ describe('external egress delivery failures, malformed records, and eviction fal const failedUuid = id(1202) const childUuid = id(1203) + const shutdownSpy = spyOn( + gracefulShutdownModule, + 'gracefulShutdownSync', + ).mockImplementation(() => {}) + const appendSpy = spyOn( sessionIngress, 'appendSessionLog', @@ -2209,11 +2215,96 @@ describe('external egress delivery failures, malformed records, and eviction fal expect( getRemoteEgressOmittedParentsForTesting().get(failedUuid), ).toBe(seedUuid) + + // 3. Child of failedEntry reparents past failed entry to seedUuid and is successfully sent + const childEntry = { + type: 'user', + uuid: childUuid, + parentUuid: failedUuid, + timestamp: '2026-08-11T00:05:02.000Z', + message: { role: 'user', content: 'child of failed entry' }, + } as unknown as Message + await recordTranscript([childEntry], undefined, failedUuid) + await flushSessionStorage() + + const childCall = appendSpy.mock.calls.find( + args => (args[1] as TranscriptMessage).uuid === childUuid, + ) + expect(childCall).toBeDefined() + expect((childCall?.[1] as TranscriptMessage).parentUuid).toBe(seedUuid) } finally { - process.exitCode = 0 + shutdownSpy.mockRestore() appendSpy.mockRestore() await rm(dir, { recursive: true, force: true }) } }) + + test('root-level omitted first entry projects child to safe root with parentUuid null', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-root-omit-')) + const path = join(dir, 'session.jsonl') + const omittedRootUuid = id(1301) + const childUuid = id(1302) + const remotePayloads: Array> = [] + + try { + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + // 1. Deliver root-level withheld entry (parentUuid is null) + const withheldRoot = { + type: 'attachment', + uuid: omittedRootUuid, + parentUuid: null, + timestamp: '2026-08-11T00:05:00.000Z', + attachment: { + type: 'hook_additional_context', + content: 'ROOT-LEAK', + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: 'ROOT-LEAK', + stderr: '', + exitCode: 0, + }, + } as unknown as Message + await recordTranscript([withheldRoot]) + await flushSessionStorage() + + // Assert withheld root was omitted from remote payloads + expect(remotePayloads.find(p => p.uuid === omittedRootUuid)).toBeUndefined() + expect(getRemoteEgressOmittedParentsForTesting().get(omittedRootUuid)).toBeNull() + + // 2. Deliver safe child whose parent is the omitted root + const child = { + type: 'user', + uuid: childUuid, + parentUuid: omittedRootUuid, + timestamp: '2026-08-11T00:05:01.000Z', + message: { role: 'user', content: 'child of omitted root' }, + } as unknown as Message + await recordTranscript([child], undefined, omittedRootUuid) + await flushSessionStorage() + + // Assert child was delivered as a root message with parentUuid: null + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + expect(remoteChild?.parentUuid).toBeNull() + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) }) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 13314bd8c7..e24d3dba5a 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -2042,8 +2042,8 @@ class Project { entry, this.remoteEgressOmittedParents, ) - let parentConfirmedSafe = false let targetParentUuid = remoteEntry.parentUuid + let parentConfirmedSafe = targetParentUuid === null const seenAncestors = new Set() while ( targetParentUuid && @@ -2066,6 +2066,10 @@ class Project { this.remoteEgressOmittedParents.get(targetParentUuid) ?? null remoteEntry = { ...entry, parentUuid: targetParentUuid } + if (targetParentUuid === null) { + parentConfirmedSafe = true + break + } continue } if (this.remoteEgressCompactAncestry.has(targetParentUuid)) { @@ -2073,6 +2077,10 @@ class Project { this.remoteEgressCompactAncestry.get(targetParentUuid) ?? null remoteEntry = { ...entry, parentUuid: targetParentUuid } + if (targetParentUuid === null) { + parentConfirmedSafe = true + break + } continue } if (!this.remoteEgressResolvedMisses.has(targetParentUuid)) { @@ -2096,6 +2104,10 @@ class Project { ...entry, parentUuid: targetParentUuid, } + if (targetParentUuid === null) { + parentConfirmedSafe = true + break + } continue } if (resolved.status === 'parent_safe') { From 88aadf1c30d2f9795a0334df278209a44396b674 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:02:09 +0900 Subject: [PATCH 24/31] fix(privacy): strict remote delivery witness tracking, fail-closed ant attachment validation, and lock-held test snapshots --- .../sessionStorage.externalEgress.test.ts | 315 +++++++++++++++--- src/utils/sessionStorage.ts | 77 +++-- 2 files changed, 317 insertions(+), 75 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index e6bf5eba07..dc23a82067 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -39,58 +39,65 @@ import { setSessionFileForTesting, } from './sessionStorage.js' -const originalUserType = process.env.USER_TYPE -const originalHookSave = process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT -const originalEnablePersist = process.env.ENABLE_SESSION_PERSISTENCE -const originalTestPersist = process.env.TEST_ENABLE_SESSION_PERSISTENCE -const originalNodeEnv = process.env.NODE_ENV -const originalSkipHistory = process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY -// Snapshot/restore sessionPersistenceDisabled so append tests that force -// persistence on cannot leak enabled writes into later suites (order-safe). -const originalSessionPersistenceDisabled = isSessionPersistenceDisabled() +let snapshotUserType: string | undefined +let snapshotHookSave: string | undefined +let snapshotEnablePersist: string | undefined +let snapshotTestPersist: string | undefined +let snapshotNodeEnv: string | undefined +let snapshotSkipHistory: string | undefined +let snapshotSessionPersistenceDisabled: boolean let ownsSharedMutationLock = false beforeEach(async () => { ownsSharedMutationLock = false await acquireSharedMutationLock('utils/sessionStorage.externalEgress.test.ts') ownsSharedMutationLock = true + + // Capture mutable baseline only after acquiring the shared mutation lock + snapshotUserType = process.env.USER_TYPE + snapshotHookSave = process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT + snapshotEnablePersist = process.env.ENABLE_SESSION_PERSISTENCE + snapshotTestPersist = process.env.TEST_ENABLE_SESSION_PERSISTENCE + snapshotNodeEnv = process.env.NODE_ENV + snapshotSkipHistory = process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + snapshotSessionPersistenceDisabled = isSessionPersistenceDisabled() }) afterEach(() => { try { - if (originalUserType === undefined) { - delete process.env.USER_TYPE - } else { - process.env.USER_TYPE = originalUserType - } - if (originalHookSave === undefined) { - delete process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT - } else { - process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = originalHookSave - } - if (originalEnablePersist === undefined) { - delete process.env.ENABLE_SESSION_PERSISTENCE - } else { - process.env.ENABLE_SESSION_PERSISTENCE = originalEnablePersist - } - if (originalTestPersist === undefined) { - delete process.env.TEST_ENABLE_SESSION_PERSISTENCE - } else { - process.env.TEST_ENABLE_SESSION_PERSISTENCE = originalTestPersist - } - if (originalNodeEnv === undefined) { - delete process.env.NODE_ENV - } else { - process.env.NODE_ENV = originalNodeEnv - } - if (originalSkipHistory === undefined) { - delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY - } else { - process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY = originalSkipHistory - } - setSessionPersistenceDisabled(originalSessionPersistenceDisabled) - resetProjectForTesting() - clearSessionMessagesCache() + if (snapshotUserType === undefined) { + delete process.env.USER_TYPE + } else { + process.env.USER_TYPE = snapshotUserType + } + if (snapshotHookSave === undefined) { + delete process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT + } else { + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = snapshotHookSave + } + if (snapshotEnablePersist === undefined) { + delete process.env.ENABLE_SESSION_PERSISTENCE + } else { + process.env.ENABLE_SESSION_PERSISTENCE = snapshotEnablePersist + } + if (snapshotTestPersist === undefined) { + delete process.env.TEST_ENABLE_SESSION_PERSISTENCE + } else { + process.env.TEST_ENABLE_SESSION_PERSISTENCE = snapshotTestPersist + } + if (snapshotNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = snapshotNodeEnv + } + if (snapshotSkipHistory === undefined) { + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + } else { + process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY = snapshotSkipHistory + } + setSessionPersistenceDisabled(snapshotSessionPersistenceDisabled) + resetProjectForTesting() + clearSessionMessagesCache() } finally { if (ownsSharedMutationLock) { ownsSharedMutationLock = false @@ -101,15 +108,15 @@ afterEach(() => { describe('sessionPersistenceDisabled suite isolation', () => { test('step1: append-style mutation leaves flag away from suite snapshot', () => { - setSessionPersistenceDisabled(!originalSessionPersistenceDisabled) + setSessionPersistenceDisabled(!snapshotSessionPersistenceDisabled) expect(isSessionPersistenceDisabled()).not.toBe( - originalSessionPersistenceDisabled, + snapshotSessionPersistenceDisabled, ) }) test('step2: afterEach restored the suite snapshot from step1', () => { expect(isSessionPersistenceDisabled()).toBe( - originalSessionPersistenceDisabled, + snapshotSessionPersistenceDisabled, ) }) }) @@ -646,7 +653,10 @@ describe('rebuildRemoteEgressOmittedParentsFromLocalTranscript', () => { const remoteAfter = remotePayloads.find(p => p.uuid === afterUuid) expect(remoteAfter).toBeDefined() - expect(remoteAfter?.parentUuid).toBe(userUuid) + // Assert: because userUuid was not delivered to the remote sink (no remote delivery witness), + // afterMsg projects to the confirmed remote root (null), avoiding dangling pointers. + expect(remoteAfter?.parentUuid).toBeNull() + expect(remoteAfter?.parentUuid).not.toBe(listingUuid) expect(JSON.stringify(remotePayloads)).not.toContain('ANCESTRY-LEAK') } finally { await rm(dir, { recursive: true, force: true }) @@ -722,7 +732,10 @@ describe('rebuildRemoteEgressOmittedParentsFromLocalTranscript', () => { const remoteAfter = remotePayloads.find(p => p.uuid === afterUuid) expect(remoteAfter).toBeDefined() - expect(remoteAfter?.parentUuid).toBe(userUuid) + // Assert: because userUuid was not delivered to the remote sink (no remote delivery witness), + // afterMsg projects to the confirmed remote root (null), avoiding dangling pointers. + expect(remoteAfter?.parentUuid).toBeNull() + expect(remoteAfter?.parentUuid).not.toBe(listingO2) const dumped = JSON.stringify(remotePayloads) expect(dumped).not.toContain('O1-LEAK') expect(dumped).not.toContain('O2-LEAK') @@ -2306,5 +2319,213 @@ describe('external egress delivery failures, malformed records, and eviction fal await rm(dir, { recursive: true, force: true }) } }) + + test('no-sink-to-sink: safe parent written before sink is not a remote delivery witness', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-no-sink-')) + const path = join(dir, 'session.jsonl') + const preSinkSafeUuid = id(1401) + const omittedUuid = id(1402) + const postSinkSafeUuid = id(1403) + const remotePayloads: Array> = [] + + try { + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + + // 1. Record safe A with NO remote sink registered + const preSinkSafe = { + type: 'user', + uuid: preSinkSafeUuid, + parentUuid: null, + timestamp: '2026-08-11T00:05:00.000Z', + message: { role: 'user', content: 'written before sink installed' }, + } as unknown as Message + await recordTranscript([preSinkSafe]) + await flushSessionStorage() + + // 2. Install remote sink + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + // 3. Record omitted L (parented to preSinkSafeUuid) + const omitted = { + type: 'attachment', + uuid: omittedUuid, + parentUuid: preSinkSafeUuid, + timestamp: '2026-08-11T00:05:01.000Z', + attachment: { + type: 'hook_additional_context', + content: 'WITHHELD', + hookName: 'SessionStart', + toolName: 'SessionStart', + hookEvent: 'SessionStart', + stdout: 'WITHHELD', + stderr: '', + exitCode: 0, + }, + } as unknown as Message + await recordTranscript([omitted], undefined, preSinkSafeUuid) + await flushSessionStorage() + + // 4. Record safe C (parented to omittedUuid) + const postSinkSafe = { + type: 'user', + uuid: postSinkSafeUuid, + parentUuid: omittedUuid, + timestamp: '2026-08-11T00:05:02.000Z', + message: { role: 'user', content: 'first safe entry after sink' }, + } as unknown as Message + await recordTranscript([postSinkSafe], undefined, omittedUuid) + await flushSessionStorage() + + // Assert: C was delivered + const remoteC = remotePayloads.find(p => p.uuid === postSinkSafeUuid) + expect(remoteC).toBeDefined() + // Assert: C was projected to null root (because preSinkSafeUuid was never delivered remotely!) + expect(remoteC?.parentUuid).toBeNull() + expect(remoteC?.parentUuid).not.toBe(preSinkSafeUuid) + expect(remoteC?.parentUuid).not.toBe(omittedUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('malformed attachment envelope on ant path is fail-closed', async () => { + process.env.USER_TYPE = 'ant' + delete process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT + + // Schema checks: non-object attachment, null type, missing type, non-string type, empty type + expect( + shouldOmitFromExternalEgress({ + type: 'attachment', + attachment: { type: null, content: 'malformed-ant-leak' }, + }), + ).toBe(true) + + expect( + shouldOmitFromExternalEgress({ + type: 'attachment', + attachment: {}, + }), + ).toBe(true) + + expect( + shouldOmitFromExternalEgress({ + type: 'attachment', + attachment: { type: 42 }, + }), + ).toBe(true) + + expect( + shouldOmitFromExternalEgress({ + type: 'attachment', + attachment: { type: '' }, + }), + ).toBe(true) + + expect( + shouldOmitFromExternalEgress({ + type: 'attachment', + attachment: null, + }), + ).toBe(true) + + expect( + shouldOmitFromExternalEgress({ + type: 'attachment', + }), + ).toBe(true) + + // Valid attachment for ant is allowed + expect( + shouldOmitFromExternalEgress({ + type: 'attachment', + attachment: { + type: 'hook_additional_context', + content: 'valid-ant-hook', + }, + }), + ).toBe(false) + + // In live CCR persistence: malformed attachment under ant is blocked + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-ant-malformed-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(1501) + const malformedUuid = id(1502) + const childUuid = id(1503) + const remotePayloads: Array> = [] + + try { + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + // 1. Deliver seed + const seed = { + type: 'user', + uuid: seedUuid, + parentUuid: null, + timestamp: '2026-08-11T00:05:00.000Z', + message: { role: 'user', content: 'seed' }, + } as unknown as Message + await recordTranscript([seed]) + await flushSessionStorage() + + // 2. Deliver malformed attachment under ant + const malformed = { + type: 'attachment', + uuid: malformedUuid, + parentUuid: seedUuid, + timestamp: '2026-08-11T00:05:01.000Z', + attachment: { + type: null, + content: 'ANT-LEAK', + }, + } as unknown as Message + await recordTranscript([malformed], undefined, seedUuid) + await flushSessionStorage() + + // Assert malformed entry was omitted from remote + expect(remotePayloads.find(p => p.uuid === malformedUuid)).toBeUndefined() + expect(JSON.stringify(remotePayloads)).not.toContain('ANT-LEAK') + + // 3. Child reparents past malformed entry to seed + const child = { + type: 'user', + uuid: childUuid, + parentUuid: malformedUuid, + timestamp: '2026-08-11T00:05:02.000Z', + message: { role: 'user', content: 'child of malformed' }, + } as unknown as Message + await recordTranscript([child], undefined, malformedUuid) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + expect(remoteChild?.parentUuid).toBe(seedUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) }) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index e24d3dba5a..41c53e5f5a 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -967,9 +967,9 @@ class Project { // Negative cache for on-demand walks that returned { found: false }. private remoteEgressResolvedMisses = new Set() // Positive cache: parent_safe walk results. Distinct from misses so a - // later true miss cannot inherit a prior safe verdict, and so incomplete - // rebuilds do not re-scan the same safe parent on every append. - private remoteEgressConfirmedSafeParents = new Set() + // Confirmed remote delivery witnesses: UUIDs verified to exist on remote + // via successful persistToRemote or verified remote hydration. + private remoteEgressDeliveredParents = new Set() private lastRemoteEgressUuid: UUID | null = null // True when rebuild could not establish complete ancestor closure // (oversized mid-line skip, scan budget exhausted, unresolved tips). @@ -990,7 +990,7 @@ class Project { this.remoteEgressCompactAncestry.clear() this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() - this.remoteEgressConfirmedSafeParents.clear() + this.remoteEgressDeliveredParents.clear() this.remoteEgressOmissionRebuildIncomplete = false this.lastRemoteEgressUuid = null this.rewriteBarrierFiles = new Set() @@ -1335,7 +1335,7 @@ class Project { this.remoteEgressCompactAncestry.clear() this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() - this.remoteEgressConfirmedSafeParents.clear() + this.remoteEgressDeliveredParents.clear() this.remoteEgressOmissionRebuildIncomplete = false const path = this.sessionFile if (!path) return @@ -1360,7 +1360,7 @@ class Project { this.remoteEgressCompactAncestry.clear() this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() - this.remoteEgressConfirmedSafeParents.clear() + this.remoteEgressDeliveredParents.clear() this.remoteEgressOmissionRebuildIncomplete = false this.lastRemoteEgressUuid = null } @@ -2051,12 +2051,9 @@ class Project { !seenAncestors.has(targetParentUuid) ) { seenAncestors.add(targetParentUuid) - if (targetParentUuid === this.lastRemoteEgressUuid) { - parentConfirmedSafe = true - break - } if ( - this.remoteEgressConfirmedSafeParents.has(targetParentUuid) + targetParentUuid === this.lastRemoteEgressUuid || + this.remoteEgressDeliveredParents.has(targetParentUuid) ) { parentConfirmedSafe = true break @@ -2111,16 +2108,27 @@ class Project { continue } if (resolved.status === 'parent_safe') { - parentConfirmedSafe = true - this.evictedRemoteEgressOmissions.delete(targetParentUuid) - this.remoteEgressKnownOmitted.delete(targetParentUuid) - this.remoteEgressConfirmedSafeParents.add( - targetParentUuid, - ) - boundUuidSet( - this.remoteEgressConfirmedSafeParents, - MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, - ) + if ( + targetParentUuid === this.lastRemoteEgressUuid || + this.remoteEgressDeliveredParents.has(targetParentUuid) + ) { + parentConfirmedSafe = true + } else { + // Parent is safe in local transcript, but lacks a remote delivery witness + // (e.g. written before sink installation, or unconfirmed delivery). + // Project child to the confirmed remote tip or null root so no + // undelivered parent UUID is emitted to the remote sink. + targetParentUuid = this.lastRemoteEgressUuid ?? null + remoteEntry = { + ...entry, + parentUuid: targetParentUuid, + } + parentConfirmedSafe = true + } + if (targetParentUuid) { + this.evictedRemoteEgressOmissions.delete(targetParentUuid) + this.remoteEgressKnownOmitted.delete(targetParentUuid) + } break } if (!queueHasPendingTranscriptAppends(queue)) { @@ -2148,6 +2156,11 @@ class Project { ) if (delivered && remoteEntry.uuid) { this.lastRemoteEgressUuid = remoteEntry.uuid + this.remoteEgressDeliveredParents.add(remoteEntry.uuid) + boundUuidSet( + this.remoteEgressDeliveredParents, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) } else if (!delivered && entry.uuid) { this.remoteEgressOmittedParents.set( entry.uuid, @@ -6143,7 +6156,7 @@ function attachmentTypeOf(m: { if (m.type !== 'attachment') return null if (!m.attachment || typeof m.attachment !== 'object') return null const t = (m.attachment as { type?: unknown }).type - return typeof t === 'string' ? t : null + return typeof t === 'string' && t.trim().length > 0 ? t : null } /** @@ -6237,21 +6250,23 @@ export function isSafeForExternalEgress(entry: { if (isMalformedAttachmentBearingEgressRecord(entry)) { return false } + if (entry.type === 'progress') { + return false + } // Listings must never cross remote / share / feedback — including ant. if (isExternalEgressListingAttachment(entry)) { return false } if (getUserType() === 'ant') { - // Ant keeps non-listing attachments (incl. hook_additional_context) on + // Ant keeps valid non-listing attachments (incl. hook_additional_context) on // remote for internal tooling. Progress stays out of the chain. - return entry.type !== 'progress' + return true } // External: never allow hook_additional_context even when the local-save // env flag is on. if (attachmentTypeOf(entry) === 'hook_additional_context') { return false } - if (entry.type === 'progress') return false if (entry.type !== 'attachment') return true // Every remaining attachment type is blocked on egress for external users. return false @@ -6289,10 +6304,16 @@ export function isMalformedAttachmentBearingEgressRecord(entry: { type?: string attachment?: unknown }): boolean { - if (entry.attachment === undefined || entry.attachment === null) { - return false + if (entry.attachment !== undefined && entry.attachment !== null) { + if (entry.type !== 'attachment') { + return true + } + return attachmentTypeOf(entry) === null } - return entry.type !== 'attachment' + if (entry.type === 'attachment') { + return true + } + return false } /** From d1591cc64a7d6ddb8cbd3c427da631061f211724 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:21:26 +0900 Subject: [PATCH 25/31] fix(privacy): bound parent_safe lookups, track lock ownership in feedback test, and assert suite baseline --- src/components/Feedback.egress.test.ts | 8 ++++- .../sessionStorage.externalEgress.test.ts | 12 +++---- src/utils/sessionStorage.ts | 33 +++++++++++++++++-- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/components/Feedback.egress.test.ts b/src/components/Feedback.egress.test.ts index eeabadb589..4f2983ede8 100644 --- a/src/components/Feedback.egress.test.ts +++ b/src/components/Feedback.egress.test.ts @@ -25,6 +25,7 @@ let originalMacro: unknown let tempDir: string | undefined let transcriptPath: string | undefined let postedBodies: Array<{ content?: string }> = [] +let ownsSharedMutationLock = false function buildAxiosModuleStub( post: (...args: unknown[]) => Promise, @@ -44,7 +45,9 @@ function buildAxiosModuleStub( } beforeAll(async () => { + ownsSharedMutationLock = false await acquireSharedMutationLock('Feedback.egress') + ownsSharedMutationLock = true originalAxiosModule = await import('axios') originalUserType = process.env.USER_TYPE hadMacro = Object.prototype.hasOwnProperty.call(globalThis, 'MACRO') @@ -154,7 +157,10 @@ afterAll(async () => { await rm(tempDir, { recursive: true, force: true }) } } finally { - releaseSharedMutationLock() + if (ownsSharedMutationLock) { + ownsSharedMutationLock = false + releaseSharedMutationLock() + } } }) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index dc23a82067..25bf9df76b 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -107,17 +107,15 @@ afterEach(() => { }) describe('sessionPersistenceDisabled suite isolation', () => { + const suiteBaseline = isSessionPersistenceDisabled() + test('step1: append-style mutation leaves flag away from suite snapshot', () => { - setSessionPersistenceDisabled(!snapshotSessionPersistenceDisabled) - expect(isSessionPersistenceDisabled()).not.toBe( - snapshotSessionPersistenceDisabled, - ) + setSessionPersistenceDisabled(!suiteBaseline) + expect(isSessionPersistenceDisabled()).not.toBe(suiteBaseline) }) test('step2: afterEach restored the suite snapshot from step1', () => { - expect(isSessionPersistenceDisabled()).toBe( - snapshotSessionPersistenceDisabled, - ) + expect(isSessionPersistenceDisabled()).toBe(suiteBaseline) }) }) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 41c53e5f5a..37db3bac6d 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -964,9 +964,10 @@ class Project { // UUIDs dropped from both bounded maps. Scan-gate only — not an ancestor // source. Keeps on-demand walks off parents that were never omitted. private remoteEgressKnownOmitted = new Set() - // Negative cache for on-demand walks that returned { found: false }. + // Negative cache for on-demand walks that returned { status: 'not_found' }. private remoteEgressResolvedMisses = new Set() - // Positive cache: parent_safe walk results. Distinct from misses so a + // Positive cache: locally safe ancestors confirmed via on-demand scan. + private remoteEgressSafeLocalParents = new Set() // Confirmed remote delivery witnesses: UUIDs verified to exist on remote // via successful persistToRemote or verified remote hydration. private remoteEgressDeliveredParents = new Set() @@ -990,6 +991,7 @@ class Project { this.remoteEgressCompactAncestry.clear() this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() + this.remoteEgressSafeLocalParents.clear() this.remoteEgressDeliveredParents.clear() this.remoteEgressOmissionRebuildIncomplete = false this.lastRemoteEgressUuid = null @@ -1335,6 +1337,7 @@ class Project { this.remoteEgressCompactAncestry.clear() this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() + this.remoteEgressSafeLocalParents.clear() this.remoteEgressDeliveredParents.clear() this.remoteEgressOmissionRebuildIncomplete = false const path = this.sessionFile @@ -1360,6 +1363,7 @@ class Project { this.remoteEgressCompactAncestry.clear() this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() + this.remoteEgressSafeLocalParents.clear() this.remoteEgressDeliveredParents.clear() this.remoteEgressOmissionRebuildIncomplete = false this.lastRemoteEgressUuid = null @@ -2058,6 +2062,22 @@ class Project { parentConfirmedSafe = true break } + if (this.remoteEgressSafeLocalParents.has(targetParentUuid)) { + if ( + targetParentUuid === this.lastRemoteEgressUuid || + this.remoteEgressDeliveredParents.has(targetParentUuid) + ) { + parentConfirmedSafe = true + } else { + targetParentUuid = this.lastRemoteEgressUuid ?? null + remoteEntry = { + ...entry, + parentUuid: targetParentUuid, + } + parentConfirmedSafe = true + } + break + } if (this.remoteEgressOmittedParents.has(targetParentUuid)) { targetParentUuid = this.remoteEgressOmittedParents.get(targetParentUuid) ?? @@ -2108,6 +2128,11 @@ class Project { continue } if (resolved.status === 'parent_safe') { + this.remoteEgressSafeLocalParents.add(targetParentUuid) + boundUuidSet( + this.remoteEgressSafeLocalParents, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) if ( targetParentUuid === this.lastRemoteEgressUuid || this.remoteEgressDeliveredParents.has(targetParentUuid) @@ -4275,6 +4300,7 @@ function resolveCompactOmissionAncestorFromLocalTranscript( sessionFile: string | null, omittedUuid: UUID, queue?: TranscriptWriteOperation[], + scanBudget: number = MAX_TRANSCRIPT_READ_BYTES, ): CompactOmissionResolveResult { const byUuid = new Map() ingestCompactAncestryNodesFromWriteQueue(queue, byUuid) @@ -4292,9 +4318,10 @@ function resolveCompactOmissionAncestorFromLocalTranscript( if (size === 0) { return { status: 'not_found' } } + const budget = Math.max(1, scanBudget) let cursor = size let scanned = 0 - while (cursor > 0 && scanned < MAX_TRANSCRIPT_READ_BYTES) { + while (cursor > 0 && scanned < budget) { const start = Math.max(0, cursor - OMISSION_REBUILD_TAIL_BYTES) const { content, nextEnd } = readTranscriptRangeForOmissionRebuild( fd, From df78c5bc740b0a466ace63ed4b86aa5f68cd1a75 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:38:40 +0900 Subject: [PATCH 26/31] fix(privacy): unify external egress projection contract and register hydration delivery witnesses Consolidate the remote-egress parent state machine into a single authoritative resolver (resolveRemoteEgressParentProjection) consumed by the live append path, and register verified-remote hydration as an explicit delivery witness (markRemoteEgressHydrated) so post-hydration children can reparent to a confirmed remote parent instead of the null root. - resolveRemoteEgressParentProjection separates five facts that must not be conflated: locally persisted, policy safe, intentionally omitted, delivered to the remote sink, and recovered via a verified remote baseline. - hydrateRemoteSession / hydrateFromCCRv2InternalEvents now register fetched entries as delivery witnesses and advance the confirmed remote tip. - Add a compact behavior matrix (sink absent->enabled, delivered->resume, rejected write->recovery, consecutive omissions and branch children, malformed fail-closed across in-memory/JSONL/live, subagent transcripts) asserting both payload privacy and parent-chain validity. --- .../sessionStorage.externalEgress.test.ts | 272 ++++++++++++++- src/utils/sessionStorage.ts | 327 +++++++++++------- 2 files changed, 472 insertions(+), 127 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 25bf9df76b..7bb14cbd54 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -25,6 +25,7 @@ import { getRemoteEgressOmissionRebuildIncompleteForTesting, isMalformedAttachmentBearingEgressRecord, isSafeForExternalEgress, + markRemoteEgressHydratedForTesting, shouldOmitFromExternalEgress, EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, @@ -2525,5 +2526,274 @@ describe('external egress delivery failures, malformed records, and eviction fal await rm(dir, { recursive: true, force: true }) } }) -}) +describe('external egress projection contract matrix', () => { + // One privacy boundary exercised as a matrix across parent states and egress + // mechanisms. For every scenario assert BOTH invariants: + // (1) no withheld/listing bytes leave the process, and + // (2) every emitted parentUuid refers to a delivered/projection-valid parent + // (a delivered tip/witness, a verified hydrated parent, or an explicit + // projected null root) ? never a locally-persisted-but-undelivered one. + function setupSink(remotePayloads: Array>) { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + } + + test('matrix: sink absent then enabled ? safe local parent is NOT a delivery witness', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-mx-nosink-')) + const path = join(dir, 'session.jsonl') + const preSinkUuid = id(501) + const hookUuid = id(502) + const childUuid = id(503) + const remotePayloads: Array> = [] + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + process.env.USER_TYPE = 'external' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + setSessionPersistenceDisabled(false) + // No sink registered yet: a locally persisted safe record must not be + // treated as delivered when the sink is later enabled. + const preSink = { + type: 'user', + uuid: preSinkUuid, + parentUuid: null, + timestamp: '2026-08-11T00:00:00.000Z', + message: { role: 'user', content: 'pre-sink safe parent' }, + } as unknown as Message + await recordTranscript([preSink]) + await flushSessionStorage() + + setupSink(remotePayloads) + const hook = hookAttachment(hookUuid, preSinkUuid, 'MX-NOSINK-LEAK') + const child = { + type: 'user', + uuid: childUuid, + parentUuid: hookUuid, + timestamp: '2026-08-11T00:00:02.000Z', + message: { role: 'user', content: 'post-sink child' }, + } as unknown as Message + await recordTranscript([hook, child] as unknown as Message[], undefined, preSinkUuid) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + // preSinkUuid was never delivered ? child projects to the null root. + expect(remoteChild?.parentUuid).toBeNull() + expect(remoteChild?.parentUuid).not.toBe(preSinkUuid) + expect(remoteChild?.parentUuid).not.toBe(hookUuid) + expect(JSON.stringify(remotePayloads)).not.toContain('MX-NOSINK-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('matrix: delivered parent then resume ? hydrated witness allows reparent', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-mx-hydrate-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(511) + const childUuid = id(512) + const remotePayloads: Array> = [] + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + setupSink(remotePayloads) + await recordTranscript([user(seedUuid, null, 'delivered seed')] as unknown as Message[]) + await flushSessionStorage() + // Simulate a verified remote baseline: seed was recovered from the sink, + // registering the delivery witness so post-resume children may reparent. + markRemoteEgressHydratedForTesting([{ uuid: seedUuid }]) + + const child = { + type: 'user', + uuid: childUuid, + parentUuid: seedUuid, + timestamp: '2026-08-11T00:00:01.000Z', + message: { role: 'user', content: 'child of hydrated parent' }, + } as unknown as Message + await recordTranscript([child], undefined, seedUuid) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + expect(remoteChild?.parentUuid).toBe(seedUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('matrix: rejected write then recovery ? failed parent omitted, child reparents past it', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-mx-reject-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(521) + const failUuid = id(522) + const childUuid = id(523) + const remotePayloads: Array> = [] + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + process.env.USER_TYPE = 'external' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + setSessionPersistenceDisabled(false) + setInternalEventWriter(async (_eventType, payload) => { + if (payload.uuid === failUuid) { + throw new Error('simulated transport rejection') + } + remotePayloads.push(payload) + }) + + await recordTranscript([user(seedUuid, null, 'delivered seed')] as unknown as Message[]) + await flushSessionStorage() + + const failed = { + type: 'user', + uuid: failUuid, + parentUuid: seedUuid, + timestamp: '2026-08-11T00:00:01.000Z', + message: { role: 'user', content: 'this write is rejected' }, + } as unknown as Message + const child = { + type: 'user', + uuid: childUuid, + parentUuid: failUuid, + timestamp: '2026-08-11T00:00:02.000Z', + message: { role: 'user', content: 'child of failed write' }, + } as unknown as Message + await recordTranscript([failed], undefined, seedUuid) + await recordTranscript([child], undefined, failUuid) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + // The failed write was recorded as omitted from the confirmed tip; the + // child reparents to the delivered seed, never to the failed parent. + expect(remoteChild?.parentUuid).toBe(seedUuid) + expect(remoteChild?.parentUuid).not.toBe(failUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('matrix: consecutive omissions and branch children ? both branches reparent to delivered root', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-mx-branch-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(531) + const hookA = id(532) + const hookB = id(533) + const childA = id(534) + const childB = id(535) + const remotePayloads: Array> = [] + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + setupSink(remotePayloads) + await recordTranscript([user(seedUuid, null, 'root')] as unknown as Message[]) + await flushSessionStorage() + + const a = hookAttachment(hookA, seedUuid, 'MX-BRANCH-LEAK-A') + const b = hookAttachment(hookB, hookA, 'MX-BRANCH-LEAK-B') + const cA = { + type: 'user', + uuid: childA, + parentUuid: hookB, + timestamp: '2026-08-11T00:00:03.000Z', + message: { role: 'user', content: 'branch child A' }, + } as unknown as Message + const cB = { + type: 'user', + uuid: childB, + parentUuid: hookB, + timestamp: '2026-08-11T00:00:04.000Z', + message: { role: 'user', content: 'branch child B' }, + } as unknown as Message + await recordTranscript([a, b] as unknown as Message[], undefined, seedUuid) + await recordTranscript([cA], undefined, hookB) + await recordTranscript([cB], undefined, hookB) + await flushSessionStorage() + + const rA = remotePayloads.find(p => p.uuid === childA) + const rB = remotePayloads.find(p => p.uuid === childB) + expect(rA).toBeDefined() + expect(rB).toBeDefined() + expect(rA?.parentUuid).toBe(seedUuid) + expect(rB?.parentUuid).toBe(seedUuid) + const dumped = JSON.stringify(remotePayloads) + expect(dumped).not.toContain('MX-BRANCH-LEAK-A') + expect(dumped).not.toContain('MX-BRANCH-LEAK-B') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('matrix: malformed records fail closed across in-memory, JSONL, and live paths', async () => { + process.env.USER_TYPE = 'external' + // In-memory filter: attachment payload on a non-attachment type is dropped. + const u1 = user(id(541), null, 'first') + const malformed = { + type: 'user', + uuid: id(542), + parentUuid: id(541), + attachment: { type: 'skill_listing', skills: ['x'] }, + } + const u2 = user(id(543), id(542), 'second') + const filtered = filterMessagesForExternalEgress([u1, malformed as never, u2]) + expect(filtered.map(m => m.uuid)).toEqual([id(541), id(543)]) + expect(filtered[1]?.parentUuid).toBe(id(541)) + + // JSONL filter: same malformed shape is dropped and the survivor reparents. + const out = filterJsonlForExternalEgress( + [ + JSON.stringify(user(id(544), null, 'j-first')), + JSON.stringify({ + type: 'user', + uuid: id(545), + parentUuid: id(544), + attachment: { type: 'skill_listing' }, + }), + JSON.stringify(user(id(546), id(545), 'j-second')), + ].join('\n'), + ) + expect(out).not.toContain('skill_listing') + const parsed = out + .split('\n') + .filter(l => l.length > 0) + .map(l => JSON.parse(l) as { uuid: string; parentUuid: string | null }) + expect(parsed.map(p => p.uuid)).toEqual([id(544), id(546)]) + expect(parsed[1]?.parentUuid).toBe(id(544)) + }) + + test('matrix: subagent transcripts strip listings per agent while preserving chain', () => { + process.env.USER_TYPE = 'external' + const filtered = filterSubagentTranscriptsForExternalEgress({ + agent1: [ + user(id(551), null, 'a1-root'), + listing(id(552), id(551), 'skill_listing', 'MX-SUB-LEAK'), + user(id(553), id(552), 'a1-child'), + ], + agent2: [user(id(554), null, 'a2-root')], + }) + expect(filtered.agent1?.map(m => m.uuid)).toEqual([id(551), id(553)]) + expect(filtered.agent1?.[1]?.parentUuid).toBe(id(551)) + expect(filtered.agent2?.map(m => m.uuid)).toEqual([id(554)]) + expect(JSON.stringify(filtered)).not.toContain('MX-SUB-LEAK') + }) +})}) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 37db3bac6d..1d5d87a7b7 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -866,6 +866,13 @@ export function getRemoteEgressOmissionRebuildIncompleteForTesting(): boolean { return getProject()._getRemoteEgressOmissionRebuildIncompleteForTesting() } +/** \@internal Register verified-remote hydration delivery witnesses (tests). */ +export function markRemoteEgressHydratedForTesting( + entries: readonly unknown[], +): void { + getProject().markRemoteEgressHydrated(entries) +} + type InternalEventWriter = ( eventType: string, payload: Record, @@ -1306,6 +1313,157 @@ class Project { ) } + /** + * Single authoritative external-projection parent contract. Given a policy-safe + * transcript entry, resolve the parentUuid that the remote projection should + * carry, separating five facts that must not be conflated: locally persisted, + * policy safe, intentionally omitted, delivered to the remote sink, and + * recovered via a verified remote baseline (hydration). + * + * A parent is used remotely only when it is a confirmed remote tip/delivery + * witness, a verified hydrated parent, or an explicit projected root. Omitted + * parents are reparented across the omission chain to their nearest egressed + * ancestor. A locally safe parent WITHOUT a delivery witness is projected to + * the confirmed remote tip or null root (never emitted as if delivered). + * Unknown ancestry is resolved on-demand from the local transcript within the + * resolver's bounded scan; when it cannot be established, parentConfirmedSafe + * stays false and the caller fails closed (withholds the child). + * + * Returns the entry to persist (parentUuid possibly rewritten) and whether + * the projected parent is confirmed safe to emit. + */ + private resolveRemoteEgressParentProjection( + entry: TranscriptMessage, + ): { remoteEntry: TranscriptMessage; parentConfirmedSafe: boolean } { + let remoteEntry = projectTranscriptParentForExternalEgress( + entry, + this.remoteEgressOmittedParents, + ) + let targetParentUuid = remoteEntry.parentUuid + let parentConfirmedSafe = targetParentUuid === null + const seenAncestors = new Set() + while ( + targetParentUuid && + !parentConfirmedSafe && + !seenAncestors.has(targetParentUuid) + ) { + seenAncestors.add(targetParentUuid) + if ( + targetParentUuid === this.lastRemoteEgressUuid || + this.remoteEgressDeliveredParents.has(targetParentUuid) + ) { + parentConfirmedSafe = true + break + } + if (this.remoteEgressSafeLocalParents.has(targetParentUuid)) { + if ( + targetParentUuid === this.lastRemoteEgressUuid || + this.remoteEgressDeliveredParents.has(targetParentUuid) + ) { + parentConfirmedSafe = true + } else { + targetParentUuid = this.lastRemoteEgressUuid ?? null + remoteEntry = { + ...entry, + parentUuid: targetParentUuid, + } + parentConfirmedSafe = true + } + break + } + if (this.remoteEgressOmittedParents.has(targetParentUuid)) { + targetParentUuid = + this.remoteEgressOmittedParents.get(targetParentUuid) ?? + null + remoteEntry = { ...entry, parentUuid: targetParentUuid } + if (targetParentUuid === null) { + parentConfirmedSafe = true + break + } + continue + } + if (this.remoteEgressCompactAncestry.has(targetParentUuid)) { + targetParentUuid = + this.remoteEgressCompactAncestry.get(targetParentUuid) ?? + null + remoteEntry = { ...entry, parentUuid: targetParentUuid } + if (targetParentUuid === null) { + parentConfirmedSafe = true + break + } + continue + } + if (!this.remoteEgressResolvedMisses.has(targetParentUuid)) { + const queue = this.sessionFile + ? this.writeQueues.get(this.sessionFile) + : undefined + const resolved = + resolveCompactOmissionAncestorFromLocalTranscript( + this.sessionFile, + targetParentUuid, + queue, + ) + if (resolved.status === 'resolved') { + this.remoteEgressCompactAncestry.set( + targetParentUuid, + resolved.ancestor, + ) + boundCompactAncestryMap(this.remoteEgressCompactAncestry) + targetParentUuid = resolved.ancestor + remoteEntry = { + ...entry, + parentUuid: targetParentUuid, + } + if (targetParentUuid === null) { + parentConfirmedSafe = true + break + } + continue + } + if (resolved.status === 'parent_safe') { + this.remoteEgressSafeLocalParents.add(targetParentUuid) + boundUuidSet( + this.remoteEgressSafeLocalParents, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) + if ( + targetParentUuid === this.lastRemoteEgressUuid || + this.remoteEgressDeliveredParents.has(targetParentUuid) + ) { + parentConfirmedSafe = true + } else { + // Parent is safe in local transcript, but lacks a remote delivery witness + // (e.g. written before sink installation, or unconfirmed delivery). + // Project child to the confirmed remote tip or null root so no + // undelivered parent UUID is emitted to the remote sink. + targetParentUuid = this.lastRemoteEgressUuid ?? null + remoteEntry = { + ...entry, + parentUuid: targetParentUuid, + } + parentConfirmedSafe = true + } + if (targetParentUuid) { + this.evictedRemoteEgressOmissions.delete(targetParentUuid) + this.remoteEgressKnownOmitted.delete(targetParentUuid) + } + break + } + if (!queueHasPendingTranscriptAppends(queue)) { + // Only cache durable misses — queued parents may land soon. + this.remoteEgressResolvedMisses.add(targetParentUuid) + boundUuidSet( + this.remoteEgressResolvedMisses, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) + } + } + // Target ancestor could not be confirmed safe or resolved + break + } + return { remoteEntry, parentConfirmedSafe } + } + /** @internal Expose omission map size/contents for egress regression tests. */ _getRemoteEgressOmittedParentsForTesting(): Map { return this.remoteEgressOmittedParents @@ -2042,132 +2200,8 @@ class Project { if (isTranscriptMessage(entry) && this.hasActiveRemoteEgressSink()) { if (!shouldOmitFromExternalEgress(entry)) { const originalParentUuid = entry.parentUuid ?? null - let remoteEntry = projectTranscriptParentForExternalEgress( - entry, - this.remoteEgressOmittedParents, - ) - let targetParentUuid = remoteEntry.parentUuid - let parentConfirmedSafe = targetParentUuid === null - const seenAncestors = new Set() - while ( - targetParentUuid && - !parentConfirmedSafe && - !seenAncestors.has(targetParentUuid) - ) { - seenAncestors.add(targetParentUuid) - if ( - targetParentUuid === this.lastRemoteEgressUuid || - this.remoteEgressDeliveredParents.has(targetParentUuid) - ) { - parentConfirmedSafe = true - break - } - if (this.remoteEgressSafeLocalParents.has(targetParentUuid)) { - if ( - targetParentUuid === this.lastRemoteEgressUuid || - this.remoteEgressDeliveredParents.has(targetParentUuid) - ) { - parentConfirmedSafe = true - } else { - targetParentUuid = this.lastRemoteEgressUuid ?? null - remoteEntry = { - ...entry, - parentUuid: targetParentUuid, - } - parentConfirmedSafe = true - } - break - } - if (this.remoteEgressOmittedParents.has(targetParentUuid)) { - targetParentUuid = - this.remoteEgressOmittedParents.get(targetParentUuid) ?? - null - remoteEntry = { ...entry, parentUuid: targetParentUuid } - if (targetParentUuid === null) { - parentConfirmedSafe = true - break - } - continue - } - if (this.remoteEgressCompactAncestry.has(targetParentUuid)) { - targetParentUuid = - this.remoteEgressCompactAncestry.get(targetParentUuid) ?? - null - remoteEntry = { ...entry, parentUuid: targetParentUuid } - if (targetParentUuid === null) { - parentConfirmedSafe = true - break - } - continue - } - if (!this.remoteEgressResolvedMisses.has(targetParentUuid)) { - const queue = this.sessionFile - ? this.writeQueues.get(this.sessionFile) - : undefined - const resolved = - resolveCompactOmissionAncestorFromLocalTranscript( - this.sessionFile, - targetParentUuid, - queue, - ) - if (resolved.status === 'resolved') { - this.remoteEgressCompactAncestry.set( - targetParentUuid, - resolved.ancestor, - ) - boundCompactAncestryMap(this.remoteEgressCompactAncestry) - targetParentUuid = resolved.ancestor - remoteEntry = { - ...entry, - parentUuid: targetParentUuid, - } - if (targetParentUuid === null) { - parentConfirmedSafe = true - break - } - continue - } - if (resolved.status === 'parent_safe') { - this.remoteEgressSafeLocalParents.add(targetParentUuid) - boundUuidSet( - this.remoteEgressSafeLocalParents, - MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, - ) - if ( - targetParentUuid === this.lastRemoteEgressUuid || - this.remoteEgressDeliveredParents.has(targetParentUuid) - ) { - parentConfirmedSafe = true - } else { - // Parent is safe in local transcript, but lacks a remote delivery witness - // (e.g. written before sink installation, or unconfirmed delivery). - // Project child to the confirmed remote tip or null root so no - // undelivered parent UUID is emitted to the remote sink. - targetParentUuid = this.lastRemoteEgressUuid ?? null - remoteEntry = { - ...entry, - parentUuid: targetParentUuid, - } - parentConfirmedSafe = true - } - if (targetParentUuid) { - this.evictedRemoteEgressOmissions.delete(targetParentUuid) - this.remoteEgressKnownOmitted.delete(targetParentUuid) - } - break - } - if (!queueHasPendingTranscriptAppends(queue)) { - // Only cache durable misses — queued parents may land soon. - this.remoteEgressResolvedMisses.add(targetParentUuid) - boundUuidSet( - this.remoteEgressResolvedMisses, - MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, - ) - } - } - // Target ancestor could not be confirmed safe or resolved - break - } + const { remoteEntry, parentConfirmedSafe } = + this.resolveRemoteEgressParentProjection(entry) // Fail closed: do not emit a remote child whose target parent // cannot be confirmed safe, or under an incomplete rebuild. const parentStillUnresolved = @@ -2336,6 +2370,34 @@ class Project { } } + /** + * Register delivery provenance for entries recovered from a verified + * remote baseline (hydrateRemoteSession / CCR v2 internal events). Entries + * fetched from the remote sink are confirmed to exist remotely, so they are + * authoritative remote parents: subsequent children may reparent to them + * without projecting to the null root. The last fetched UUID becomes the + * confirmed remote tip. + */ + markRemoteEgressHydrated(entries: readonly unknown[]): void { + let last: UUID | null = null + for (const entry of entries) { + const uuid = + entry !== null && typeof entry === 'object' + ? (entry as { uuid?: unknown }).uuid + : undefined + if (typeof uuid !== 'string') continue + this.remoteEgressDeliveredParents.add(uuid as UUID) + last = uuid as UUID + } + boundUuidSet( + this.remoteEgressDeliveredParents, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) + if (last !== null) { + this.lastRemoteEgressUuid = last + } + } + setRemoteIngressUrl(url: string): void { this.remoteIngressUrl = url logForDebugging(`Remote persistence enabled with URL: ${url}`) @@ -2625,6 +2687,11 @@ export async function hydrateRemoteSession( sessionFile, serializeTranscriptEntries(remoteLogs), ) + // Entries fetched from the remote sink are confirmed to exist remotely: + // register them as authoritative delivery witnesses so post-hydration + // children can reparent to a verified remote parent instead of the null + // root. The last fetched UUID becomes the confirmed remote tip. + project.markRemoteEgressHydrated(remoteLogs) logForDebugging(`Hydrated ${remoteLogs.length} entries from remote`) return remoteLogs.length > 0 @@ -2679,6 +2746,14 @@ export async function hydrateFromCCRv2InternalEvents( sessionFile, serializeTranscriptEntries(events.map(event => event.payload)), ) + // Foreground entries fetched from the CCR sink are confirmed to exist + // remotely: register them as authoritative delivery witnesses so + // post-hydration children can reparent to a verified remote parent + // instead of the null root. The last fetched UUID becomes the confirmed + // remote tip. + project.markRemoteEgressHydrated( + events.map(event => event.payload as { uuid?: UUID | null }), + ) logForDebugging( `Hydrated ${events.length} foreground entries from CCR v2 internal events`, From 0dbfd9388a7be6f13951db9f42bffd9c47e1af36 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Sat, 22 Aug 2026 07:42:30 +0900 Subject: [PATCH 27/31] fix(privacy): bound on-demand ancestry scans and retry incomplete rebuilds --- src/utils/sessionStorage.ts | 113 ++++++++++++++++++++++++++---------- 1 file changed, 83 insertions(+), 30 deletions(-) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 1d5d87a7b7..35654bf9fd 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -982,6 +982,9 @@ class Project { // True when rebuild could not establish complete ancestor closure // (oversized mid-line skip, scan budget exhausted, unresolved tips). private remoteEgressOmissionRebuildIncomplete = false + // One-shot guard: emit the suppression diagnostic only the first time an + // entry is withheld under an incomplete rebuild, not on every later entry. + private remoteEgressRebuildIncompleteWarned = false constructor() {} @@ -1001,6 +1004,7 @@ class Project { this.remoteEgressSafeLocalParents.clear() this.remoteEgressDeliveredParents.clear() this.remoteEgressOmissionRebuildIncomplete = false + this.remoteEgressRebuildIncompleteWarned = false this.lastRemoteEgressUuid = null this.rewriteBarrierFiles = new Set() this.pendingRewriteCounts = new Map() @@ -1356,19 +1360,11 @@ class Project { break } if (this.remoteEgressSafeLocalParents.has(targetParentUuid)) { - if ( - targetParentUuid === this.lastRemoteEgressUuid || - this.remoteEgressDeliveredParents.has(targetParentUuid) - ) { - parentConfirmedSafe = true - } else { - targetParentUuid = this.lastRemoteEgressUuid ?? null - remoteEntry = { - ...entry, - parentUuid: targetParentUuid, - } - parentConfirmedSafe = true - } + // No delivery witness (checked at the top of the loop): project to + // the confirmed remote tip or the null root. + targetParentUuid = this.lastRemoteEgressUuid ?? null + remoteEntry = { ...entry, parentUuid: targetParentUuid } + parentConfirmedSafe = true break } if (this.remoteEgressOmittedParents.has(targetParentUuid)) { @@ -1402,6 +1398,7 @@ class Project { this.sessionFile, targetParentUuid, queue, + MAX_TRANSCRIPT_READ_BYTES, ) if (resolved.status === 'resolved') { this.remoteEgressCompactAncestry.set( @@ -1426,23 +1423,17 @@ class Project { this.remoteEgressSafeLocalParents, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, ) - if ( - targetParentUuid === this.lastRemoteEgressUuid || - this.remoteEgressDeliveredParents.has(targetParentUuid) - ) { - parentConfirmedSafe = true - } else { - // Parent is safe in local transcript, but lacks a remote delivery witness - // (e.g. written before sink installation, or unconfirmed delivery). - // Project child to the confirmed remote tip or null root so no - // undelivered parent UUID is emitted to the remote sink. - targetParentUuid = this.lastRemoteEgressUuid ?? null - remoteEntry = { - ...entry, - parentUuid: targetParentUuid, - } - parentConfirmedSafe = true + // No delivery witness (checked at the top of the loop): this parent + // is safe in the local transcript but was never confirmed delivered + // (e.g. written before sink installation, or unconfirmed delivery). + // Project the child to the confirmed remote tip or null root so no + // undelivered parent UUID is emitted to the remote sink. + targetParentUuid = this.lastRemoteEgressUuid ?? null + remoteEntry = { + ...entry, + parentUuid: targetParentUuid, } + parentConfirmedSafe = true if (targetParentUuid) { this.evictedRemoteEgressOmissions.delete(targetParentUuid) this.remoteEgressKnownOmitted.delete(targetParentUuid) @@ -1496,8 +1487,8 @@ class Project { this.remoteEgressKnownOmitted.clear() this.remoteEgressResolvedMisses.clear() this.remoteEgressSafeLocalParents.clear() - this.remoteEgressDeliveredParents.clear() this.remoteEgressOmissionRebuildIncomplete = false + this.remoteEgressRebuildIncompleteWarned = false const path = this.sessionFile if (!path) return const result = ingestRemoteEgressOmissionsFromTranscriptFile( @@ -1507,6 +1498,7 @@ class Project { evicted: this.evictedRemoteEgressOmissions, compactAncestry: this.remoteEgressCompactAncestry, knownOmitted: this.remoteEgressKnownOmitted, + safeLocalParents: this.remoteEgressSafeLocalParents, }, scanBudget, ) @@ -1524,6 +1516,7 @@ class Project { this.remoteEgressSafeLocalParents.clear() this.remoteEgressDeliveredParents.clear() this.remoteEgressOmissionRebuildIncomplete = false + this.remoteEgressRebuildIncompleteWarned = false this.lastRemoteEgressUuid = null } @@ -2202,6 +2195,31 @@ class Project { const originalParentUuid = entry.parentUuid ?? null const { remoteEntry, parentConfirmedSafe } = this.resolveRemoteEgressParentProjection(entry) + // Bounded rebuild retry: if a prior resume rebuild was left + // incomplete, attempt one bounded re-rebuild before suppressing + // this entry. When reconstruction succeeds the session resumes + // remote persistence instead of latching fail-closed for life. + if (this.remoteEgressOmissionRebuildIncomplete && this.sessionFile) { + const retryOmitted = new Map() + const result = ingestRemoteEgressOmissionsFromTranscriptFile( + this.sessionFile, + retryOmitted, + { + evicted: this.evictedRemoteEgressOmissions, + compactAncestry: this.remoteEgressCompactAncestry, + knownOmitted: this.remoteEgressKnownOmitted, + safeLocalParents: this.remoteEgressSafeLocalParents, + }, + OMISSION_REBUILD_TAIL_BYTES, + ) + if (result.complete) { + this.remoteEgressOmissionRebuildIncomplete = false + this.remoteEgressRebuildIncompleteWarned = false + for (const [k, v] of retryOmitted) { + this.remoteEgressOmittedParents.set(k, v) + } + } + } // Fail closed: do not emit a remote child whose target parent // cannot be confirmed safe, or under an incomplete rebuild. const parentStillUnresolved = @@ -2233,6 +2251,22 @@ class Project { ) } } else if (entry.uuid) { + // Emit a one-time diagnostic the first time an entry is + // withheld under an incomplete rebuild, so the degraded + // session is observable instead of silently root-only. + if (! + this.remoteEgressRebuildIncompleteWarned + ) { + this.remoteEgressRebuildIncompleteWarned = true + logForDiagnosticsNoPII( + 'warn', + 'remote_egress_parent_unresolved', + { + rebuildIncomplete: + this.remoteEgressOmissionRebuildIncomplete, + }, + ) + } // Fail-closed persist still owns this UUID: later children // must rematch through the map instead of treating B as // egressed (grandchild C would otherwise dangle on B). @@ -4091,6 +4125,7 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( evicted: Set compactAncestry: Map knownOmitted: Set + safeLocalParents?: Set }, scanBudget: number = MAX_TRANSCRIPT_READ_BYTES, ): { complete: boolean } { @@ -4150,6 +4185,15 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( ), ) boundUuidSet(referencedParents, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) + if (boundState.safeLocalParents) { + for (const uuid of egressed) { + boundState.safeLocalParents.add(uuid) + } + boundUuidSet( + boundState.safeLocalParents, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) + } } if ( sawTranscript && @@ -4179,6 +4223,15 @@ function ingestRemoteEgressOmissionsFromTranscriptFile( boundState.compactAncestry, boundState.knownOmitted, ) + if (boundState.safeLocalParents) { + for (const uuid of egressed) { + boundState.safeLocalParents.add(uuid) + } + boundUuidSet( + boundState.safeLocalParents, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) + } } const hitScanCap = scanned >= budget && cursor > 0 && !closed From 039efa0ad2da47d642548a6afc38ee5f23b69057 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:18:05 +0900 Subject: [PATCH 28/31] fix(privacy): bound incomplete rebuild retries and isolate diagnostic guard --- .../sessionStorage.externalEgress.test.ts | 54 ++++++++++++ src/utils/sessionStorage.ts | 86 ++++++++++++++++--- 2 files changed, 129 insertions(+), 11 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 7bb14cbd54..5cadc3c5ff 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -23,12 +23,14 @@ import { flushSessionStorage, getRemoteEgressOmittedParentsForTesting, getRemoteEgressOmissionRebuildIncompleteForTesting, + getRemoteEgressRebuildRetryCountForTesting, isMalformedAttachmentBearingEgressRecord, isSafeForExternalEgress, markRemoteEgressHydratedForTesting, shouldOmitFromExternalEgress, EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + MAX_REMOTE_EGRESS_REBUILD_RETRIES, OMISSION_REBUILD_TAIL_BYTES, projectTranscriptParentForExternalEgress, rebuildRemoteEgressOmittedParentsForTesting, @@ -1395,6 +1397,58 @@ describe('appendEntry remote egress gate', () => { } }) + test('incomplete rebuild retries are bounded and stop for permanently unrecoverable transcripts', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-retry-bound-')) + const path = join(dir, 'session.jsonl') + const withheldParent = id(430) + const remotePayloads: Array> = [] + + try { + // Unrecoverable oversized line without newlines + await writeFile(path, Buffer.alloc(OMISSION_REBUILD_TAIL_BYTES + 4096, 0x78)) + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + await rebuildRemoteEgressOmittedParentsForTesting() + expect(getRemoteEgressOmissionRebuildIncompleteForTesting()).toBe(true) + expect(getRemoteEgressRebuildRetryCountForTesting()).toBe(0) + + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + // Perform more appends than MAX_REMOTE_EGRESS_REBUILD_RETRIES + for (let n = 0; n < MAX_REMOTE_EGRESS_REBUILD_RETRIES + 2; n++) { + const msg = { + type: 'user', + uuid: id(431 + n), + parentUuid: withheldParent, + timestamp: `2026-08-11T00:00:${String(n).padStart(2, '0')}.000Z`, + message: { role: 'user', content: `msg ${n}` }, + } as unknown as Message + await recordTranscript([msg], undefined, withheldParent) + } + await flushSessionStorage() + + // Retries must cap at MAX_REMOTE_EGRESS_REBUILD_RETRIES + expect(getRemoteEgressRebuildRetryCountForTesting()).toBe( + MAX_REMOTE_EGRESS_REBUILD_RETRIES, + ) + expect(getRemoteEgressOmissionRebuildIncompleteForTesting()).toBe(true) + expect(remotePayloads.length).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + test('incomplete persist skip records suppressed UUID so grandchildren rematch', async () => { process.env.USER_TYPE = 'external' process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 35654bf9fd..f08919619c 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -563,6 +563,10 @@ export const OMISSION_REBUILD_TAIL_BYTES = 2 * 1024 * 1024 // evicted and must never be emitted as a remote parentUuid. export const MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE = 64 +// Maximum retry attempts to reconstruct an incomplete omission rebuild +// during subsequent appends before suppressing further attempts. +export const MAX_REMOTE_EGRESS_REBUILD_RETRIES = 3 + // In-memory map of agentId → subdirectory for grouping related subagent // transcripts (e.g. workflow runs write to subagents/workflows//). // Populated before the agent runs; consulted by getAgentTranscriptPath. @@ -866,7 +870,12 @@ export function getRemoteEgressOmissionRebuildIncompleteForTesting(): boolean { return getProject()._getRemoteEgressOmissionRebuildIncompleteForTesting() } -/** \@internal Register verified-remote hydration delivery witnesses (tests). */ +/** @internal Snapshot incomplete-rebuild retry count (tests). */ +export function getRemoteEgressRebuildRetryCountForTesting(): number { + return getProject()._getRemoteEgressRebuildRetryCountForTesting() +} + +/** @internal Register verified-remote hydration delivery witnesses (tests). */ export function markRemoteEgressHydratedForTesting( entries: readonly unknown[], ): void { @@ -985,6 +994,9 @@ class Project { // One-shot guard: emit the suppression diagnostic only the first time an // entry is withheld under an incomplete rebuild, not on every later entry. private remoteEgressRebuildIncompleteWarned = false + // Bounded retry counter: avoid repeated synchronous disk scans on every + // append when a transcript is permanently unrecoverable. + private remoteEgressRebuildRetryCount = 0 constructor() {} @@ -1005,6 +1017,7 @@ class Project { this.remoteEgressDeliveredParents.clear() this.remoteEgressOmissionRebuildIncomplete = false this.remoteEgressRebuildIncompleteWarned = false + this.remoteEgressRebuildRetryCount = 0 this.lastRemoteEgressUuid = null this.rewriteBarrierFiles = new Set() this.pendingRewriteCounts = new Map() @@ -1465,6 +1478,11 @@ class Project { return this.remoteEgressOmissionRebuildIncomplete } + /** @internal Expose incomplete-rebuild retry count for egress tests. */ + _getRemoteEgressRebuildRetryCountForTesting(): number { + return this.remoteEgressRebuildRetryCount + } + /** * After --resume / --continue, remoteEgressOmittedParents starts empty * (resetSessionFile clears it). Rebuild from the adopted local transcript @@ -1489,6 +1507,7 @@ class Project { this.remoteEgressSafeLocalParents.clear() this.remoteEgressOmissionRebuildIncomplete = false this.remoteEgressRebuildIncompleteWarned = false + this.remoteEgressRebuildRetryCount = 0 const path = this.sessionFile if (!path) return const result = ingestRemoteEgressOmissionsFromTranscriptFile( @@ -1517,6 +1536,7 @@ class Project { this.remoteEgressDeliveredParents.clear() this.remoteEgressOmissionRebuildIncomplete = false this.remoteEgressRebuildIncompleteWarned = false + this.remoteEgressRebuildRetryCount = 0 this.lastRemoteEgressUuid = null } @@ -2196,28 +2216,70 @@ class Project { const { remoteEntry, parentConfirmedSafe } = this.resolveRemoteEgressParentProjection(entry) // Bounded rebuild retry: if a prior resume rebuild was left - // incomplete, attempt one bounded re-rebuild before suppressing + // incomplete, attempt bounded retries before suppressing // this entry. When reconstruction succeeds the session resumes // remote persistence instead of latching fail-closed for life. - if (this.remoteEgressOmissionRebuildIncomplete && this.sessionFile) { + if ( + this.remoteEgressOmissionRebuildIncomplete && + this.sessionFile && + this.remoteEgressRebuildRetryCount < + MAX_REMOTE_EGRESS_REBUILD_RETRIES + ) { + this.remoteEgressRebuildRetryCount++ const retryOmitted = new Map() + const retryEvicted = new Set() + const retryCompactAncestry = new Map() + const retryKnownOmitted = new Set() + const retrySafeLocalParents = new Set() const result = ingestRemoteEgressOmissionsFromTranscriptFile( this.sessionFile, retryOmitted, { - evicted: this.evictedRemoteEgressOmissions, - compactAncestry: this.remoteEgressCompactAncestry, - knownOmitted: this.remoteEgressKnownOmitted, - safeLocalParents: this.remoteEgressSafeLocalParents, + evicted: retryEvicted, + compactAncestry: retryCompactAncestry, + knownOmitted: retryKnownOmitted, + safeLocalParents: retrySafeLocalParents, }, OMISSION_REBUILD_TAIL_BYTES, ) if (result.complete) { this.remoteEgressOmissionRebuildIncomplete = false this.remoteEgressRebuildIncompleteWarned = false + this.remoteEgressRebuildRetryCount = 0 for (const [k, v] of retryOmitted) { this.remoteEgressOmittedParents.set(k, v) } + boundRemoteEgressOmissionMap( + this.remoteEgressOmittedParents, + this.evictedRemoteEgressOmissions, + this.remoteEgressCompactAncestry, + this.remoteEgressKnownOmitted, + ) + for (const uuid of retryEvicted) { + this.evictedRemoteEgressOmissions.add(uuid) + } + for (const [k, v] of retryCompactAncestry) { + this.remoteEgressCompactAncestry.set(k, v) + } + for (const uuid of retryKnownOmitted) { + this.remoteEgressKnownOmitted.add(uuid) + } + for (const uuid of retrySafeLocalParents) { + this.remoteEgressSafeLocalParents.add(uuid) + } + boundCompactAncestryMap(this.remoteEgressCompactAncestry) + boundUuidSet( + this.evictedRemoteEgressOmissions, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) + boundUuidSet( + this.remoteEgressKnownOmitted, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) + boundUuidSet( + this.remoteEgressSafeLocalParents, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) } } // Fail closed: do not emit a remote child whose target parent @@ -2254,16 +2316,18 @@ class Project { // Emit a one-time diagnostic the first time an entry is // withheld under an incomplete rebuild, so the degraded // session is observable instead of silently root-only. - if (! - this.remoteEgressRebuildIncompleteWarned + // Ordinary unresolved-parent suppressions must not consume + // the incomplete-rebuild warning slot. + if ( + this.remoteEgressOmissionRebuildIncomplete && + !this.remoteEgressRebuildIncompleteWarned ) { this.remoteEgressRebuildIncompleteWarned = true logForDiagnosticsNoPII( 'warn', 'remote_egress_parent_unresolved', { - rebuildIncomplete: - this.remoteEgressOmissionRebuildIncomplete, + rebuildIncomplete: true, }, ) } From ea96c51e29c77a845234c141c3f9de224756f162 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:39:35 +0900 Subject: [PATCH 29/31] fix(privacy): recompute parent projection and clear cached misses on successful rebuild retry --- .../sessionStorage.externalEgress.test.ts | 71 +++++++++++++++++++ src/utils/sessionStorage.ts | 12 ++-- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 5cadc3c5ff..f68fe0521d 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -1449,6 +1449,77 @@ describe('appendEntry remote egress gate', () => { } }) + test('cached miss followed by successful retry rebuild resolves and reparents entry', async () => { + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-retry-success-')) + const path = join(dir, 'session.jsonl') + const userUuid = id(440) + const listingUuid = id(441) + const suppressedUuid = id(442) + const resumedUuid = id(443) + const remotePayloads: Array> = [] + + try { + // Start with unrecoverable content to trigger incomplete rebuild + await writeFile(path, Buffer.alloc(OMISSION_REBUILD_TAIL_BYTES + 4096, 0x78)) + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + await rebuildRemoteEgressOmittedParentsForTesting() + expect(getRemoteEgressOmissionRebuildIncompleteForTesting()).toBe(true) + + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + // First append: suppressed under incomplete rebuild + const suppressedMsg = { + type: 'user', + uuid: suppressedUuid, + parentUuid: listingUuid, + timestamp: '2026-08-11T00:00:00.000Z', + message: { role: 'user', content: 'suppressed before fix' }, + } as unknown as Message + await recordTranscript([suppressedMsg], undefined, listingUuid) + await flushSessionStorage() + expect(remotePayloads.find(p => p.uuid === suppressedUuid)).toBeUndefined() + + // Now rewrite transcript with valid closed JSONL (user -> listing) + const validTranscript = [ + JSON.stringify(user(userUuid, null, 'safe root')), + JSON.stringify(listing(listingUuid, userUuid, 'skill_listing', 'RETRY-LEAK')), + ].join('\n') + '\n' + await writeFile(path, validTranscript) + + // Second append: retry rebuild succeeds, clears cached misses, and reparents child + const resumedMsg = { + type: 'user', + uuid: resumedUuid, + parentUuid: listingUuid, + timestamp: '2026-08-11T00:00:01.000Z', + message: { role: 'user', content: 'resumed after fix' }, + } as unknown as Message + await recordTranscript([resumedMsg], undefined, listingUuid) + await flushSessionStorage() + + expect(getRemoteEgressOmissionRebuildIncompleteForTesting()).toBe(false) + const remoteResumed = remotePayloads.find(p => p.uuid === resumedUuid) + expect(remoteResumed).toBeDefined() + // userUuid was not remote-delivered, so projects to null root safely + expect(remoteResumed?.parentUuid).toBeNull() + expect(JSON.stringify(remotePayloads)).not.toContain('RETRY-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + test('incomplete persist skip records suppressed UUID so grandchildren rematch', async () => { process.env.USER_TYPE = 'external' process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index f08919619c..85d6fa9569 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -2212,12 +2212,9 @@ class Project { // buildConversationChain do not stop at a missing parentUuid. if (isTranscriptMessage(entry) && this.hasActiveRemoteEgressSink()) { if (!shouldOmitFromExternalEgress(entry)) { - const originalParentUuid = entry.parentUuid ?? null - const { remoteEntry, parentConfirmedSafe } = - this.resolveRemoteEgressParentProjection(entry) // Bounded rebuild retry: if a prior resume rebuild was left - // incomplete, attempt bounded retries before suppressing - // this entry. When reconstruction succeeds the session resumes + // incomplete, attempt bounded retries before resolving ancestry + // for this entry. When reconstruction succeeds the session resumes // remote persistence instead of latching fail-closed for life. if ( this.remoteEgressOmissionRebuildIncomplete && @@ -2246,6 +2243,7 @@ class Project { this.remoteEgressOmissionRebuildIncomplete = false this.remoteEgressRebuildIncompleteWarned = false this.remoteEgressRebuildRetryCount = 0 + this.remoteEgressResolvedMisses.clear() for (const [k, v] of retryOmitted) { this.remoteEgressOmittedParents.set(k, v) } @@ -2282,6 +2280,10 @@ class Project { ) } } + + const originalParentUuid = entry.parentUuid ?? null + const { remoteEntry, parentConfirmedSafe } = + this.resolveRemoteEgressParentProjection(entry) // Fail closed: do not emit a remote child whose target parent // cannot be confirmed safe, or under an incomplete rebuild. const parentStillUnresolved = From bc22983659c07810c944e4ffb099f9e5d999d482 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:56:44 +0900 Subject: [PATCH 30/31] fix(privacy): bound on-demand ancestry scan budget to 8 MiB in appendEntry --- src/utils/sessionStorage.externalEgress.test.ts | 1 + src/utils/sessionStorage.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index f68fe0521d..8ff2b855d0 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -31,6 +31,7 @@ import { EXTERNAL_EGRESS_LISTING_ATTACHMENT_TYPES, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, MAX_REMOTE_EGRESS_REBUILD_RETRIES, + MAX_ON_DEMAND_ANCESTRY_SCAN_BYTES, OMISSION_REBUILD_TAIL_BYTES, projectTranscriptParentForExternalEgress, rebuildRemoteEgressOmittedParentsForTesting, diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 85d6fa9569..df3c5ff7cd 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -567,6 +567,11 @@ export const MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE = 64 // during subsequent appends before suppressing further attempts. export const MAX_REMOTE_EGRESS_REBUILD_RETRIES = 3 +// Bounded scan budget for on-demand ancestry walks during appendEntry (8 MiB). +// Bounded to avoid synchronous blocking on multi-GB transcripts while allowing +// multi-window lookups across non-transcript snapshots. +export const MAX_ON_DEMAND_ANCESTRY_SCAN_BYTES = 8 * 1024 * 1024 + // In-memory map of agentId → subdirectory for grouping related subagent // transcripts (e.g. workflow runs write to subagents/workflows//). // Populated before the agent runs; consulted by getAgentTranscriptPath. @@ -1411,7 +1416,7 @@ class Project { this.sessionFile, targetParentUuid, queue, - MAX_TRANSCRIPT_READ_BYTES, + MAX_ON_DEMAND_ANCESTRY_SCAN_BYTES, ) if (resolved.status === 'resolved') { this.remoteEgressCompactAncestry.set( From 262eafd6359fabbaa42e283f2fa50a107e3a16e0 Mon Sep 17 00:00:00 2001 From: ussoewwin <136552381+ussoewwin@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:01:15 +0900 Subject: [PATCH 31/31] fix(privacy): close jatmn 5000432029 external egress findings - Preserve hydration delivery witnesses through print-mode resume by re-seeding the delivered set from a session-keyed baseline on resetSessionFile. - Serialize projection + delivery + witness mutation behind a remoteEgressChain so children observe predecessor delivery results in transcript order. - Add a durable append-only delivery journal so branch targets of delivered parents evicted past the bounded witness set stay recoverable. - Harden isTranscriptMessage to accept unknown and skip non-record JSON values. - Runtime-validate uuid/parentUuid in the public JSONL filter with a single fail-closed re-root rule. - Add regression tests for all five findings. --- .../sessionStorage.externalEgress.test.ts | 449 ++++++++++++++ src/utils/sessionStorage.ts | 563 ++++++++++++------ 2 files changed, 839 insertions(+), 173 deletions(-) diff --git a/src/utils/sessionStorage.externalEgress.test.ts b/src/utils/sessionStorage.externalEgress.test.ts index 8ff2b855d0..89b7c95104 100644 --- a/src/utils/sessionStorage.externalEgress.test.ts +++ b/src/utils/sessionStorage.externalEgress.test.ts @@ -38,6 +38,7 @@ import { recordExternalEgressOmission, recordTranscript, resetProjectForTesting, + resetSessionFilePointer, setInternalEventWriter, setRemoteIngressUrlForTesting, setSessionFileForTesting, @@ -2923,3 +2924,451 @@ describe('external egress projection contract matrix', () => { expect(JSON.stringify(filtered)).not.toContain('MX-SUB-LEAK') }) })}) + +describe('jatmn review 5000432029 regressions', () => { + // --- Finding 1: preserve hydration witnesses through print-mode resume --- + test('F1: hydration witness survives resetSessionFilePointer (print resume order)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-f1-reset-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(601) + const childUuid = id(602) + const remotePayloads: Array> = [] + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + setSessionPersistenceDisabled(false) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + // Hydration establishes the verified remote baseline (seed exists remotely). + markRemoteEgressHydratedForTesting([{ uuid: seedUuid }]) + // Non-fork --print --resume then resets the session pointer, clearing the + // transient delivered set; the baseline must survive and re-seed it. + await resetSessionFilePointer() + // Re-point the session file (reset nulled it) and append a child of seed. + setSessionFileForTesting(path) + const child = { + type: 'user', + uuid: childUuid, + parentUuid: seedUuid, + timestamp: '2026-08-12T00:00:01.000Z', + message: { role: 'user', content: 'child of hydrated parent after reset' }, + } as unknown as Message + await recordTranscript([child], undefined, seedUuid) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + // The hydrated seed remains a verified remote parent across the reset. + expect(remoteChild?.parentUuid).toBe(seedUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + // --- Finding 2: serialize projection state with overlapping remote appends --- + test('F2: child waits for deferred parent delivery and keeps its parent (success)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-f2-serial-')) + const path = join(dir, 'session.jsonl') + const parentUuid = id(611) + const childUuid = id(612) + const remotePayloads: Array> = [] + let releaseParent: () => void = () => {} + let signalParentRequested: () => void = () => {} + const parentGate = new Promise(resolve => { + releaseParent = resolve + }) + const parentRequested = new Promise(resolve => { + signalParentRequested = resolve + }) + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + setSessionPersistenceDisabled(false) + setInternalEventWriter(async (_eventType, payload) => { + if (payload.uuid === parentUuid) { + signalParentRequested() + await parentGate + } + remotePayloads.push(payload) + }) + + // Fire-and-forget the parent; its delivery is gated once requested. + const parentDone = recordTranscript([ + user(parentUuid, null, 'parent'), + ] as unknown as Message[]) + await parentRequested + // Start the child while the parent delivery is still in flight. + const childDone = recordTranscript( + [user(childUuid, parentUuid, 'child')] as unknown as Message[], + undefined, + parentUuid, + ) + releaseParent() + await Promise.all([parentDone, childDone]) + await flushSessionStorage() + + // Serialized delivery: parent first, child second, child keeps parent. + expect(remotePayloads.map(p => p.uuid)).toEqual([parentUuid, childUuid]) + expect(remotePayloads.find(p => p.uuid === childUuid)?.parentUuid).toBe( + parentUuid, + ) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('F2: child reparents past a deferred failed parent (failure)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-f2-fail-')) + const path = join(dir, 'session.jsonl') + const parentUuid = id(621) + const childUuid = id(622) + const remotePayloads: Array> = [] + let releaseParent: () => void = () => {} + let signalParentRequested: () => void = () => {} + const parentGate = new Promise(resolve => { + releaseParent = resolve + }) + const parentRequested = new Promise(resolve => { + signalParentRequested = resolve + }) + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + setSessionPersistenceDisabled(false) + setInternalEventWriter(async (_eventType, payload) => { + if (payload.uuid === parentUuid) { + signalParentRequested() + await parentGate + throw new Error('simulated transport rejection') + } + remotePayloads.push(payload) + }) + + const parentDone = recordTranscript([ + user(parentUuid, null, 'parent'), + ] as unknown as Message[]) + await parentRequested + const childDone = recordTranscript( + [user(childUuid, parentUuid, 'child')] as unknown as Message[], + undefined, + parentUuid, + ) + releaseParent() + await Promise.all([parentDone, childDone]) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + // The failed parent is never emitted nor kept as an ancestor. + expect(remotePayloads.map(p => p.uuid)).not.toContain(parentUuid) + expect(remoteChild?.parentUuid).toBeNull() + expect(remoteChild?.parentUuid).not.toBe(parentUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + // --- Finding 3: retain delivery provenance for old branch parents --- + test('F3: branch target of a delivered parent evicted past the witness bound keeps it', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-f3-branch-')) + const path = join(dir, 'session.jsonl') + const firstUuid = id(701) + const childUuid = id(799) + const remotePayloads: Array> = [] + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + setSessionPersistenceDisabled(false) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + await recordTranscript([user(firstUuid, null, 'first')] as unknown as Message[]) + await flushSessionStorage() + let prev = firstUuid + for (let n = 0; n < MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE + 10; n++) { + const u = id(710 + n) + await recordTranscript( + [user(u, prev, 'flood')] as unknown as Message[], + undefined, + prev, + ) + prev = u + } + await flushSessionStorage() + + // Branch child targets the first delivered entry (evicted from the cap). + await recordTranscript( + [user(childUuid, firstUuid, 'branch child')] as unknown as Message[], + undefined, + firstUuid, + ) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + // The durable journal proves the old parent was delivered: keep it, not + // the newest tip and not the null root. + expect(remoteChild?.parentUuid).toBe(firstUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('F3: hydrated history beyond the witness bound keeps its parent', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-f3-hydrate-')) + const path = join(dir, 'session.jsonl') + const oldestUuid = id(800) + const childUuid = id(899) + const remotePayloads: Array> = [] + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + setSessionPersistenceDisabled(false) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + // Hydration returns more entries than the in-memory witness cap. + const hydrated: Array<{ uuid: UUID }> = [] + for (let n = 0; n < MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE + 10; n++) { + hydrated.push({ uuid: id(800 + n) }) + } + markRemoteEgressHydratedForTesting(hydrated) + + // A child targeting the OLDEST hydrated entry (evicted from the cap). + await recordTranscript( + [user(childUuid, oldestUuid, 'child of old hydrated')] as unknown as Message[], + undefined, + oldestUuid, + ) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + expect(remoteChild?.parentUuid).toBe(oldestUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('F3: local-only parent (never delivered) still projects to null root', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-f3-local-')) + const path = join(dir, 'session.jsonl') + const preSinkUuid = id(901) + const childUuid = id(902) + const remotePayloads: Array> = [] + try { + await writeFile(path, '') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + setSessionPersistenceDisabled(false) + // No sink yet: the pre-sink parent is local-only and never delivered. + await recordTranscript([user(preSinkUuid, null, 'pre-sink')] as unknown as Message[]) + await flushSessionStorage() + + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + await recordTranscript( + [user(childUuid, preSinkUuid, 'child')] as unknown as Message[], + undefined, + preSinkUuid, + ) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + // Not in the journal (never delivered): project to the null root, never + // to the local-only parent. + expect(remoteChild?.parentUuid).toBeNull() + expect(remoteChild?.parentUuid).not.toBe(preSinkUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + // --- Finding 4: skip JSON primitives without poisoning resume projection --- + test('F4: primitives in transcript do not poison the rebuild path', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-f4-rebuild-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(911) + const omittedUuid = id(912) + try { + const lines = [ + 'null', + '42', + '"a string"', + '[1,2,3]', + '{"type":"user","uuid":"' + seedUuid + '","parentUuid":null,"message":{"role":"user","content":"seed"}}', + JSON.stringify(listing(omittedUuid, seedUuid, 'skill_listing', 'F4-LEAK')), + ] + await writeFile(path, lines.join('\n') + '\n') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + + rebuildRemoteEgressOmittedParentsForTesting() + // Primitive lines must be skipped, not conflated with an incomplete scan. + expect(getRemoteEgressOmissionRebuildIncompleteForTesting()).toBe(false) + // The listing omission is still recorded and reparents to the seed. + expect( + getRemoteEgressOmittedParentsForTesting().get(omittedUuid), + ).toBe(seedUuid) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('F4: primitives in transcript do not poison the on-demand ancestry path', async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-egress-f4-ondemand-')) + const path = join(dir, 'session.jsonl') + const seedUuid = id(921) + const omittedUuid = id(922) + const childUuid = id(923) + const remotePayloads: Array> = [] + try { + const lines = [ + 'null', + '42', + '{"type":"user","uuid":"' + seedUuid + '","parentUuid":null,"message":{"role":"user","content":"seed"}}', + JSON.stringify(listing(omittedUuid, seedUuid, 'skill_listing', 'F4-LEAK')), + ] + await writeFile(path, lines.join('\n') + '\n') + resetProjectForTesting() + clearSessionMessagesCache() + setSessionFileForTesting(path) + process.env.USER_TYPE = 'external' + process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT = '1' + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + setSessionPersistenceDisabled(false) + setInternalEventWriter(async (_eventType, payload) => { + remotePayloads.push(payload) + }) + + rebuildRemoteEgressOmittedParentsForTesting() + expect(getRemoteEgressOmissionRebuildIncompleteForTesting()).toBe(false) + + // A child of the withheld parent resolves on-demand despite the primitive + // lines. The withheld parent's nearest kept ancestor (seed) is itself + // local-only (never delivered), so the child projects to the safe root. + await recordTranscript( + [user(childUuid, omittedUuid, 'child')] as unknown as Message[], + undefined, + omittedUuid, + ) + await flushSessionStorage() + + const remoteChild = remotePayloads.find(p => p.uuid === childUuid) + expect(remoteChild).toBeDefined() + expect(remoteChild?.parentUuid).toBeNull() + expect(remoteChild?.parentUuid).not.toBe(omittedUuid) + expect(JSON.stringify(remotePayloads)).not.toContain('F4-LEAK') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + // --- Finding 5: validate parent UUIDs before rebuilding the projected chain --- + test('F5: numeric parentUuid is re-rooted and never emitted as ancestry', () => { + process.env.USER_TYPE = 'external' + const out = filterJsonlForExternalEgress( + [ + JSON.stringify({ + type: 'attachment', + uuid: id(931), + parentUuid: 123, + attachment: { type: 'skill_listing' }, + }), + JSON.stringify({ + type: 'user', + uuid: id(932), + parentUuid: id(931), + message: { role: 'user', content: 'child' }, + }), + ].join('\n'), + ) + expect(out).not.toContain('skill_listing') + const parsed = out + .split('\n') + .filter(l => l.length > 0) + .map(l => JSON.parse(l) as { uuid: string; parentUuid: unknown }) + expect(parsed.length).toBe(1) + expect(parsed[0]?.uuid).toBe(id(932)) + // The survivor is re-rooted to null, never carrying the numeric parent. + expect(parsed[0]?.parentUuid).toBeNull() + }) + + test('F5: malformed UUID-string parent is re-rooted, retained survivor keeps valid chain', () => { + process.env.USER_TYPE = 'external' + const out = filterJsonlForExternalEgress( + [ + JSON.stringify({ + type: 'user', + uuid: id(941), + parentUuid: 'not-a-uuid', + message: { role: 'user', content: 'root-ish' }, + }), + JSON.stringify({ + type: 'user', + uuid: id(942), + parentUuid: id(941), + message: { role: 'user', content: 'child' }, + }), + ].join('\n'), + ) + const parsed = out + .split('\n') + .filter(l => l.length > 0) + .map(l => JSON.parse(l) as { uuid: string; parentUuid: unknown }) + expect(parsed.map(p => p.uuid)).toEqual([id(941), id(942)]) + // The malformed-parent survivor is re-rooted to null. + expect(parsed[0]?.parentUuid).toBeNull() + // The child of a valid parent keeps its link. + expect(parsed[1]?.parentUuid).toBe(id(941)) + }) +}) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index df3c5ff7cd..d54c5d9345 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -4,7 +4,7 @@ import type { Dirent } from 'fs' // Sync fs primitives for readFileTailSync — separate from fs/promises // imports above. Named (not wildcard) per CLAUDE.md style; no collisions // with the async-suffixed names. -import { closeSync, fstatSync, openSync, readSync } from 'fs' +import { appendFileSync, closeSync, fstatSync, openSync, readSync, writeFileSync } from 'fs' import { appendFile as fsAppendFile, open as fsOpen, @@ -464,12 +464,19 @@ const SKIP_FIRST_PROMPT_PATTERN = * chain. Including them caused chain forks that orphaned real conversation * messages on resume (see #14373, #23537). */ -export function isTranscriptMessage(entry: Entry): entry is TranscriptMessage { +export function isTranscriptMessage(entry: unknown): entry is TranscriptMessage { + // Runtime boundary for untrusted JSONL values: jsonParse can yield null, + // primitives, or arrays. Dereferencing .type on those throws inside the + // rebuild / on-demand parsers and poisons an otherwise-recoverable scan. + if (typeof entry !== 'object' || entry === null) { + return false + } + const type = (entry as { type?: unknown }).type return ( - entry.type === 'user' || - entry.type === 'assistant' || - entry.type === 'attachment' || - entry.type === 'system' + type === 'user' || + type === 'assistant' || + type === 'attachment' || + type === 'system' ) } @@ -993,6 +1000,16 @@ class Project { // via successful persistToRemote or verified remote hydration. private remoteEgressDeliveredParents = new Set() private lastRemoteEgressUuid: UUID | null = null + // Authoritative hydration baseline: UUIDs confirmed to exist on the remote + // sink by hydration (Session Ingress / CCR v2). resetSessionFile clears the + // transient delivered set + tip when the session pointer is reset during + // --resume, but this baseline survives so the first post-resume child can + // still reparent to a verified remote parent instead of the null root. + // Keyed to the hydrated session so a different-session reset (/clear, + // regenerateSessionId) drops stale witnesses instead of inheriting them. + private remoteEgressHydrationBaseline = new Set() + private remoteEgressHydrationBaselineTip: UUID | null = null + private remoteEgressHydrationBaselineSessionId: UUID | null = null // True when rebuild could not establish complete ancestor closure // (oversized mid-line skip, scan budget exhausted, unresolved tips). private remoteEgressOmissionRebuildIncomplete = false @@ -1002,6 +1019,13 @@ class Project { // Bounded retry counter: avoid repeated synchronous disk scans on every // append when a transcript is permanently unrecoverable. private remoteEgressRebuildRetryCount = 0 + // Serializes remote projection + delivery + witness mutation per process. + // recordTranscript is fire-and-forget at the UI boundary, so concurrent + // appends can otherwise race lastRemoteEgressUuid / delivery witnesses and + // export a linear chain as out-of-order roots. Each step runs after the + // prior step settles; the stored tail always resolves so one rejection does + // not poison later appends. + private remoteEgressChain: Promise = Promise.resolve() constructor() {} @@ -1024,6 +1048,9 @@ class Project { this.remoteEgressRebuildIncompleteWarned = false this.remoteEgressRebuildRetryCount = 0 this.lastRemoteEgressUuid = null + this.remoteEgressHydrationBaseline = new Set() + this.remoteEgressHydrationBaselineTip = null + this.remoteEgressHydrationBaselineSessionId = null this.rewriteBarrierFiles = new Set() this.pendingRewriteCounts = new Map() this.pendingDirectAppends = new Map() @@ -1372,7 +1399,7 @@ class Project { seenAncestors.add(targetParentUuid) if ( targetParentUuid === this.lastRemoteEgressUuid || - this.remoteEgressDeliveredParents.has(targetParentUuid) + this.isRemoteEgressDelivered(targetParentUuid) ) { parentConfirmedSafe = true break @@ -1543,6 +1570,24 @@ class Project { this.remoteEgressRebuildIncompleteWarned = false this.remoteEgressRebuildRetryCount = 0 this.lastRemoteEgressUuid = null + // Re-seed delivery witnesses from the hydration baseline when the reset + // targets the hydrated session (non-fork --resume). For a different + // session (/clear, regenerateSessionId) the baseline is stale and must be + // dropped so a fresh session never inherits another session's witnesses. + const currentSessionId = getSessionId() as UUID + if ( + this.remoteEgressHydrationBaselineSessionId !== null && + this.remoteEgressHydrationBaselineSessionId === currentSessionId + ) { + for (const uuid of this.remoteEgressHydrationBaseline) { + this.remoteEgressDeliveredParents.add(uuid) + } + this.lastRemoteEgressUuid = this.remoteEgressHydrationBaselineTip + } else { + this.remoteEgressHydrationBaseline = new Set() + this.remoteEgressHydrationBaselineTip = null + this.remoteEgressHydrationBaselineSessionId = null + } } /** @@ -2216,161 +2261,9 @@ class Project { // and reparent the next remote entry so hydrateRemoteSession / // buildConversationChain do not stop at a missing parentUuid. if (isTranscriptMessage(entry) && this.hasActiveRemoteEgressSink()) { - if (!shouldOmitFromExternalEgress(entry)) { - // Bounded rebuild retry: if a prior resume rebuild was left - // incomplete, attempt bounded retries before resolving ancestry - // for this entry. When reconstruction succeeds the session resumes - // remote persistence instead of latching fail-closed for life. - if ( - this.remoteEgressOmissionRebuildIncomplete && - this.sessionFile && - this.remoteEgressRebuildRetryCount < - MAX_REMOTE_EGRESS_REBUILD_RETRIES - ) { - this.remoteEgressRebuildRetryCount++ - const retryOmitted = new Map() - const retryEvicted = new Set() - const retryCompactAncestry = new Map() - const retryKnownOmitted = new Set() - const retrySafeLocalParents = new Set() - const result = ingestRemoteEgressOmissionsFromTranscriptFile( - this.sessionFile, - retryOmitted, - { - evicted: retryEvicted, - compactAncestry: retryCompactAncestry, - knownOmitted: retryKnownOmitted, - safeLocalParents: retrySafeLocalParents, - }, - OMISSION_REBUILD_TAIL_BYTES, - ) - if (result.complete) { - this.remoteEgressOmissionRebuildIncomplete = false - this.remoteEgressRebuildIncompleteWarned = false - this.remoteEgressRebuildRetryCount = 0 - this.remoteEgressResolvedMisses.clear() - for (const [k, v] of retryOmitted) { - this.remoteEgressOmittedParents.set(k, v) - } - boundRemoteEgressOmissionMap( - this.remoteEgressOmittedParents, - this.evictedRemoteEgressOmissions, - this.remoteEgressCompactAncestry, - this.remoteEgressKnownOmitted, - ) - for (const uuid of retryEvicted) { - this.evictedRemoteEgressOmissions.add(uuid) - } - for (const [k, v] of retryCompactAncestry) { - this.remoteEgressCompactAncestry.set(k, v) - } - for (const uuid of retryKnownOmitted) { - this.remoteEgressKnownOmitted.add(uuid) - } - for (const uuid of retrySafeLocalParents) { - this.remoteEgressSafeLocalParents.add(uuid) - } - boundCompactAncestryMap(this.remoteEgressCompactAncestry) - boundUuidSet( - this.evictedRemoteEgressOmissions, - MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, - ) - boundUuidSet( - this.remoteEgressKnownOmitted, - MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, - ) - boundUuidSet( - this.remoteEgressSafeLocalParents, - MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, - ) - } - } - - const originalParentUuid = entry.parentUuid ?? null - const { remoteEntry, parentConfirmedSafe } = - this.resolveRemoteEgressParentProjection(entry) - // Fail closed: do not emit a remote child whose target parent - // cannot be confirmed safe, or under an incomplete rebuild. - const parentStillUnresolved = - !!originalParentUuid && - (!parentConfirmedSafe || - this.remoteEgressOmissionRebuildIncomplete) - if (!parentStillUnresolved) { - const delivered = await this.persistToRemote( - sessionId, - remoteEntry, - ) - if (delivered && remoteEntry.uuid) { - this.lastRemoteEgressUuid = remoteEntry.uuid - this.remoteEgressDeliveredParents.add(remoteEntry.uuid) - boundUuidSet( - this.remoteEgressDeliveredParents, - MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, - ) - } else if (!delivered && entry.uuid) { - this.remoteEgressOmittedParents.set( - entry.uuid, - this.lastRemoteEgressUuid, - ) - boundRemoteEgressOmissionMap( - this.remoteEgressOmittedParents, - this.evictedRemoteEgressOmissions, - this.remoteEgressCompactAncestry, - this.remoteEgressKnownOmitted, - ) - } - } else if (entry.uuid) { - // Emit a one-time diagnostic the first time an entry is - // withheld under an incomplete rebuild, so the degraded - // session is observable instead of silently root-only. - // Ordinary unresolved-parent suppressions must not consume - // the incomplete-rebuild warning slot. - if ( - this.remoteEgressOmissionRebuildIncomplete && - !this.remoteEgressRebuildIncompleteWarned - ) { - this.remoteEgressRebuildIncompleteWarned = true - logForDiagnosticsNoPII( - 'warn', - 'remote_egress_parent_unresolved', - { - rebuildIncomplete: true, - }, - ) - } - // Fail-closed persist still owns this UUID: later children - // must rematch through the map instead of treating B as - // egressed (grandchild C would otherwise dangle on B). - this.remoteEgressOmittedParents.set( - entry.uuid, - this.lastRemoteEgressUuid, - ) - boundRemoteEgressOmissionMap( - this.remoteEgressOmittedParents, - this.evictedRemoteEgressOmissions, - this.remoteEgressCompactAncestry, - this.remoteEgressKnownOmitted, - ) - } - // Keep omitted parents in the bounded map so a second branch - // child of the same withheld UUID can still reparent. Size is - // enforced by boundRemoteEgressOmissionMap on the omit path. - } else { - // Only grow the map when a remote sink can consume reparents. - // Resume rebuild (adoptResumedSessionFile) still hydrates from - // disk before the sink is registered. - recordExternalEgressOmission( - this.remoteEgressOmittedParents, - entry.uuid, - entry.parentUuid, - ) - boundRemoteEgressOmissionMap( - this.remoteEgressOmittedParents, - this.evictedRemoteEgressOmissions, - this.remoteEgressCompactAncestry, - this.remoteEgressKnownOmitted, - ) - } + await this.runSerializedRemoteEgress(() => + this.persistRemoteEgressEntry(sessionId, entry), + ) } } } @@ -2420,6 +2313,194 @@ class Project { } } + + /** + * Persist one transcript entry to the remote sink under the projection + * contract. Runs inside the serialized remoteEgressChain so a child sees + * its predecessor's confirmed delivery result (transcript order, not + * network completion order). + */ + private async persistRemoteEgressEntry( + sessionId: UUID, + entry: TranscriptMessage, + ): Promise { + if (!shouldOmitFromExternalEgress(entry)) { + // Bounded rebuild retry: if a prior resume rebuild was left + // incomplete, attempt bounded retries before resolving ancestry + // for this entry. When reconstruction succeeds the session resumes + // remote persistence instead of latching fail-closed for life. + if ( + this.remoteEgressOmissionRebuildIncomplete && + this.sessionFile && + this.remoteEgressRebuildRetryCount < + MAX_REMOTE_EGRESS_REBUILD_RETRIES + ) { + this.remoteEgressRebuildRetryCount++ + const retryOmitted = new Map() + const retryEvicted = new Set() + const retryCompactAncestry = new Map() + const retryKnownOmitted = new Set() + const retrySafeLocalParents = new Set() + const result = ingestRemoteEgressOmissionsFromTranscriptFile( + this.sessionFile, + retryOmitted, + { + evicted: retryEvicted, + compactAncestry: retryCompactAncestry, + knownOmitted: retryKnownOmitted, + safeLocalParents: retrySafeLocalParents, + }, + OMISSION_REBUILD_TAIL_BYTES, + ) + if (result.complete) { + this.remoteEgressOmissionRebuildIncomplete = false + this.remoteEgressRebuildIncompleteWarned = false + this.remoteEgressRebuildRetryCount = 0 + this.remoteEgressResolvedMisses.clear() + for (const [k, v] of retryOmitted) { + this.remoteEgressOmittedParents.set(k, v) + } + boundRemoteEgressOmissionMap( + this.remoteEgressOmittedParents, + this.evictedRemoteEgressOmissions, + this.remoteEgressCompactAncestry, + this.remoteEgressKnownOmitted, + ) + for (const uuid of retryEvicted) { + this.evictedRemoteEgressOmissions.add(uuid) + } + for (const [k, v] of retryCompactAncestry) { + this.remoteEgressCompactAncestry.set(k, v) + } + for (const uuid of retryKnownOmitted) { + this.remoteEgressKnownOmitted.add(uuid) + } + for (const uuid of retrySafeLocalParents) { + this.remoteEgressSafeLocalParents.add(uuid) + } + boundCompactAncestryMap(this.remoteEgressCompactAncestry) + boundUuidSet( + this.evictedRemoteEgressOmissions, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) + boundUuidSet( + this.remoteEgressKnownOmitted, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) + boundUuidSet( + this.remoteEgressSafeLocalParents, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) + } + } + + const originalParentUuid = entry.parentUuid ?? null + const { remoteEntry, parentConfirmedSafe } = + this.resolveRemoteEgressParentProjection(entry) + // Fail closed: do not emit a remote child whose target parent + // cannot be confirmed safe, or under an incomplete rebuild. + const parentStillUnresolved = + !!originalParentUuid && + (!parentConfirmedSafe || + this.remoteEgressOmissionRebuildIncomplete) + if (!parentStillUnresolved) { + const delivered = await this.persistToRemote( + sessionId, + remoteEntry, + ) + if (delivered && remoteEntry.uuid) { + this.lastRemoteEgressUuid = remoteEntry.uuid + this.remoteEgressDeliveredParents.add(remoteEntry.uuid) + boundUuidSet( + this.remoteEgressDeliveredParents, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) + // Durably record the confirmed delivery so a branch target of this + // UUID remains recoverable after the bounded set evicts it. + this.appendRemoteEgressDeliveryJournal(remoteEntry.uuid) + } else if (!delivered && entry.uuid) { + this.remoteEgressOmittedParents.set( + entry.uuid, + this.lastRemoteEgressUuid, + ) + boundRemoteEgressOmissionMap( + this.remoteEgressOmittedParents, + this.evictedRemoteEgressOmissions, + this.remoteEgressCompactAncestry, + this.remoteEgressKnownOmitted, + ) + } + } else if (entry.uuid) { + // Emit a one-time diagnostic the first time an entry is + // withheld under an incomplete rebuild, so the degraded + // session is observable instead of silently root-only. + // Ordinary unresolved-parent suppressions must not consume + // the incomplete-rebuild warning slot. + if ( + this.remoteEgressOmissionRebuildIncomplete && + !this.remoteEgressRebuildIncompleteWarned + ) { + this.remoteEgressRebuildIncompleteWarned = true + logForDiagnosticsNoPII( + 'warn', + 'remote_egress_parent_unresolved', + { + rebuildIncomplete: true, + }, + ) + } + // Fail-closed persist still owns this UUID: later children + // must rematch through the map instead of treating B as + // egressed (grandchild C would otherwise dangle on B). + this.remoteEgressOmittedParents.set( + entry.uuid, + this.lastRemoteEgressUuid, + ) + boundRemoteEgressOmissionMap( + this.remoteEgressOmittedParents, + this.evictedRemoteEgressOmissions, + this.remoteEgressCompactAncestry, + this.remoteEgressKnownOmitted, + ) + } + // Keep omitted parents in the bounded map so a second branch + // child of the same withheld UUID can still reparent. Size is + // enforced by boundRemoteEgressOmissionMap on the omit path. + } else { + // Only grow the map when a remote sink can consume reparents. + // Resume rebuild (adoptResumedSessionFile) still hydrates from + // disk before the sink is registered. + recordExternalEgressOmission( + this.remoteEgressOmittedParents, + entry.uuid, + entry.parentUuid, + ) + boundRemoteEgressOmissionMap( + this.remoteEgressOmittedParents, + this.evictedRemoteEgressOmissions, + this.remoteEgressCompactAncestry, + this.remoteEgressKnownOmitted, + ) + } + } + + /** + * Run the remote egress transition serialized behind any prior step. The + * stored tail always settles so a rejected step never poisons later + * appends; callers still observe their own step's outcome. + */ + private async runSerializedRemoteEgress( + run: () => Promise, + ): Promise { + const prev = this.remoteEgressChain + const next = prev.then(run, run) + this.remoteEgressChain = next.then( + () => undefined, + () => undefined, + ) + await next + } + private async persistToRemote( sessionId: UUID, entry: TranscriptMessage, @@ -2484,6 +2565,7 @@ class Project { * confirmed remote tip. */ markRemoteEgressHydrated(entries: readonly unknown[]): void { + const all = new Set() let last: UUID | null = null for (const entry of entries) { const uuid = @@ -2491,9 +2573,25 @@ class Project { ? (entry as { uuid?: unknown }).uuid : undefined if (typeof uuid !== 'string') continue - this.remoteEgressDeliveredParents.add(uuid as UUID) + all.add(uuid as UUID) last = uuid as UUID } + // Durably journal the FULL hydrated set (hydration replaces the + // transcript, so the prior journal is stale). The journal is the durable + // recovery source for branch targets beyond the in-memory bound. + this.replaceRemoteEgressDeliveryJournal(all) + // The in-memory baseline + delivered set stay bounded performance indexes. + const baseline = new Set(all) + boundUuidSet(baseline, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE) + // A fresh hydration supersedes any prior baseline for this process. + this.remoteEgressHydrationBaseline = baseline + this.remoteEgressHydrationBaselineTip = last + this.remoteEgressHydrationBaselineSessionId = getSessionId() as UUID + // Seed the transient delivered set + tip for the pre-reset window; the + // subsequent resetSessionFile re-seeds them from the baseline again. + for (const uuid of baseline) { + this.remoteEgressDeliveredParents.add(uuid) + } boundUuidSet( this.remoteEgressDeliveredParents, MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, @@ -2503,6 +2601,94 @@ class Project { } } + /** + * Durable delivery provenance: an append-only journal next to the session + * file recording every UUID confirmed delivered (live writes and hydration). + * remoteEgressDeliveredParents is a bounded performance index; once a + * delivered UUID is evicted it remains recoverable here, so a branch target + * can still be recognized as delivered (vs a pre-sink / local-only parent). + * A journal miss never asserts delivery ? the resolver fails closed. + */ + private remoteEgressDeliveredJournalPath(): string | null { + return this.sessionFile ? `${this.sessionFile}.remote-delivered` : null + } + + private appendRemoteEgressDeliveryJournal(uuid: UUID): void { + const path = this.remoteEgressDeliveredJournalPath() + if (!path) return + try { + appendFileSync(path, `${uuid}\n`, { mode: 0o600 }) + } catch { + // Best-effort: a missed journal line only degrades evicted-witness + // recovery; in-process witnesses and fail-closed remain authoritative. + } + } + + private replaceRemoteEgressDeliveryJournal(uuids: Iterable): void { + const path = this.remoteEgressDeliveredJournalPath() + if (!path) return + try { + const lines: string[] = [] + for (const uuid of uuids) lines.push(`${uuid}\n`) + writeFileSync(path, lines.join(''), { mode: 0o600 }) + } catch { + // Best-effort. + } + } + + private resolveRemoteEgressDeliveredFromJournal(uuid: UUID): boolean { + const path = this.remoteEgressDeliveredJournalPath() + if (!path) return false + let fd: number | undefined + try { + fd = openSync(path, 'r') + const size = fstatSync(fd).size + if (size === 0) return false + // Evicted witnesses are the OLDEST deliveries, earliest in the + // append-only journal; scan forward within the on-demand budget. + let cursor = 0 + let scanned = 0 + const windowBytes = OMISSION_REBUILD_TAIL_BYTES + while (cursor < size && scanned < MAX_ON_DEMAND_ANCESTRY_SCAN_BYTES) { + const end = Math.min(size, cursor + windowBytes) + const buf = Buffer.allocUnsafe(end - cursor) + const bytesRead = readSync(fd, buf, 0, end - cursor, cursor) + const content = buf.toString('utf8', 0, bytesRead) + for (const line of content.split('\n')) { + if (line.length > 0 && line.trim() === uuid) return true + } + scanned += end - cursor + cursor = end + } + return false + } catch { + return false + } finally { + if (fd !== undefined) { + try { + closeSync(fd) + } catch { + // closeSync can throw; treat as miss + } + } + } + } + + private isRemoteEgressDelivered(uuid: UUID): boolean { + if (this.remoteEgressDeliveredParents.has(uuid)) return true + if (this.resolveRemoteEgressDeliveredFromJournal(uuid)) { + // Re-promote into the bounded index so repeated branch targets of the + // same delivered UUID avoid re-scanning the journal. + this.remoteEgressDeliveredParents.add(uuid) + boundUuidSet( + this.remoteEgressDeliveredParents, + MAX_REMOTE_EGRESS_OMISSION_MAP_SIZE, + ) + return true + } + return false + } + setRemoteIngressUrl(url: string): void { this.remoteIngressUrl = url logForDebugging(`Remote persistence enabled with URL: ${url}`) @@ -4085,7 +4271,7 @@ function ingestRemoteEgressOmissionsFromTranscriptContent( } catch { continue } - if (!isTranscriptMessage(parsed as Entry)) continue + if (!isTranscriptMessage(parsed)) continue const entry = parsed as TranscriptMessage sawTranscript = true // Always apply current external egress policy. hook_additional_context is @@ -4418,7 +4604,7 @@ function ingestCompactAncestryNodesFromTranscriptContent( } catch { continue } - if (!isTranscriptMessage(parsed as Entry)) continue + if (!isTranscriptMessage(parsed)) continue const entry = parsed as TranscriptMessage if (!entry.uuid) continue byUuid.set(entry.uuid, { @@ -6647,27 +6833,58 @@ export function filterJsonlForExternalEgress(jsonl: string): string { continue } try { - const entry = JSON.parse(line) as { + const parsed: unknown = JSON.parse(line) + // Non-record JSON values (null / primitives / arrays) carry no valid + // ancestry; drop them fail-closed instead of emitting an invalid link. + if (typeof parsed !== 'object' || parsed === null) { + continue + } + const entry = parsed as { type?: string attachment?: unknown - uuid?: UUID - parentUuid?: UUID | null + uuid?: unknown + parentUuid?: unknown } + // Runtime-validate chain fields: a parent must be a valid UUID or null. + // The JSONL filter admits untrusted bytes, so the TypeScript UUID cast + // alone is not a boundary ? numeric / malformed-string parent links must + // not enter the omission map or be emitted as ancestry. + const uuid = + typeof entry.uuid === 'string' ? validateUuid(entry.uuid) : null + const parentUuid = + entry.parentUuid === null || entry.parentUuid === undefined + ? null + : typeof entry.parentUuid === 'string' + ? validateUuid(entry.parentUuid) + : null + const parentIsMalformed = + entry.parentUuid !== null && + entry.parentUuid !== undefined && + parentUuid === null if (shouldOmitFromExternalEgress(entry)) { - if (typeof entry.uuid === 'string') { + if (uuid !== null) { + // A malformed parent is safely re-rooted to null so the omission + // map never stores a non-UUID ancestor and survivors reparent to + // the root instead of a dangling / numeric link. recordExternalEgressOmission( omittedParents, - entry.uuid, - entry.parentUuid ?? null, + uuid, + parentIsMalformed ? null : parentUuid, ) } continue } - if (entry.parentUuid && omittedParents.has(entry.parentUuid)) { + if (parentIsMalformed) { + // Fail closed: emit the survivor re-rooted to null so the exported + // chain never carries an invalid / dangling parent link. + kept.push(jsonStringify({ ...entry, parentUuid: null })) + continue + } + if (parentUuid && omittedParents.has(parentUuid)) { const projected = projectTranscriptParentForExternalEgress( { ...entry, - parentUuid: entry.parentUuid, + parentUuid, }, omittedParents, )