From c0ec826cb5823055d435a6c63cf4c2622259f3ae Mon Sep 17 00:00:00 2001 From: Sviatoslav Likhtarchyk Date: Wed, 1 Jul 2026 11:28:05 +0300 Subject: [PATCH 01/12] feat(agents): add session-origin-audit utility for transcript marker and audit log --- .../__tests__/session-origin-audit.test.ts | 71 +++++++++++++++++++ .../core/session/session-origin-audit.ts | 49 +++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/agents/core/__tests__/session-origin-audit.test.ts create mode 100644 src/agents/core/session/session-origin-audit.ts diff --git a/src/agents/core/__tests__/session-origin-audit.test.ts b/src/agents/core/__tests__/session-origin-audit.test.ts new file mode 100644 index 00000000..98f0e462 --- /dev/null +++ b/src/agents/core/__tests__/session-origin-audit.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + appendAuditEvent, + appendTranscriptMarker, +} from '../session/session-origin-audit.js'; + +const TMP = join(tmpdir(), `codemie-audit-test-${process.pid}`); +const auditFile = join(TMP, 'logs', 'session-origin-audit.jsonl'); +const transcriptFile = join(TMP, 'transcript.jsonl'); + +beforeEach(() => { + mkdirSync(join(TMP, 'logs'), { recursive: true }); + writeFileSync(transcriptFile, '{"type":"user","uuid":"abc"}\n'); +}); + +afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +describe('appendAuditEvent', () => { + it('creates the file and appends a valid JSON line', () => { + appendAuditEvent('resume_blocked', { claudeSessionId: 'ses-1' }, join(TMP, 'logs')); + const lines = readFileSync(auditFile, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(1); + const parsed = JSON.parse(lines[0]); + expect(parsed.event).toBe('resume_blocked'); + expect(parsed.data.claudeSessionId).toBe('ses-1'); + expect(typeof parsed.ts).toBe('string'); + }); + + it('appends multiple events', () => { + appendAuditEvent('resume_blocked', { claudeSessionId: 'a' }, join(TMP, 'logs')); + appendAuditEvent('resume_external_confirmed', { claudeSessionId: 'b' }, join(TMP, 'logs')); + const lines = readFileSync(auditFile, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + }); + + it('is non-fatal when the directory does not exist', () => { + expect(() => + appendAuditEvent('resume_blocked', {}, '/nonexistent/path/that/does/not/exist/logs') + ).not.toThrow(); + }); +}); + +describe('appendTranscriptMarker', () => { + it('appends a codemie_session_start line to the transcript', () => { + appendTranscriptMarker(transcriptFile, 'codemie-id-1', 'claude'); + const lines = readFileSync(transcriptFile, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + const marker = JSON.parse(lines[1]); + expect(marker.type).toBe('codemie_session_start'); + expect(marker.codemie_session_id).toBe('codemie-id-1'); + expect(marker.codemie_agent).toBe('claude'); + expect(typeof marker.uuid).toBe('string'); + expect(typeof marker.timestamp).toBe('string'); + }); + + it('is non-fatal when the transcript file does not exist', () => { + expect(() => + appendTranscriptMarker('/does/not/exist/session.jsonl', 'id', 'claude') + ).not.toThrow(); + }); + + it('is non-fatal when transcript path is empty string', () => { + expect(() => appendTranscriptMarker('', 'id', 'claude')).not.toThrow(); + }); +}); diff --git a/src/agents/core/session/session-origin-audit.ts b/src/agents/core/session/session-origin-audit.ts new file mode 100644 index 00000000..567a01f4 --- /dev/null +++ b/src/agents/core/session/session-origin-audit.ts @@ -0,0 +1,49 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { getCodemiePath } from '../../../utils/paths.js'; +import { logger } from '../../../utils/logger.js'; + +const LOG_FILENAME = 'session-origin-audit.jsonl'; + +export type AuditEventName = + | 'transcript_marker_written' + | 'resume_blocked' + | 'resume_external_confirmed'; + +export function appendAuditEvent( + event: AuditEventName, + data: Record, + logsDir?: string, +): void { + try { + const dir = logsDir ?? getCodemiePath('logs'); + mkdirSync(dir, { recursive: true }); + const line = JSON.stringify({ ts: new Date().toISOString(), event, data }) + '\n'; + appendFileSync(join(dir, LOG_FILENAME), line); + } catch { + // non-fatal — audit log write failure must never break a user session + } +} + +export function appendTranscriptMarker( + transcriptPath: string, + codemieSessionId: string, + codemieAgent: string, +): void { + if (!transcriptPath) return; + try { + const marker = + JSON.stringify({ + type: 'codemie_session_start', + uuid: randomUUID(), + codemie_session_id: codemieSessionId, + codemie_agent: codemieAgent, + timestamp: new Date().toISOString(), + }) + '\n'; + appendFileSync(transcriptPath, marker); + logger.debug(`[session-origin] Marker written to transcript: ${transcriptPath}`); + } catch (err) { + logger.warn(`[session-origin] Failed to write transcript marker (non-fatal): ${err}`); + } +} From 39293626f15c9a8df21d43ee058c674cf79e929f Mon Sep 17 00:00:00 2001 From: Sviatoslav Likhtarchyk Date: Wed, 1 Jul 2026 11:31:19 +0300 Subject: [PATCH 02/12] feat(cli): write codemie_session_start marker to Claude transcript at SessionStart --- src/cli/commands/hook.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index 0c3207ad..e5881e28 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -703,6 +703,17 @@ async function createSessionRecord(event: SessionStartEvent, sessionId: string, ...(event.transcript_path && { agentSessionFile: event.transcript_path }), }; await sessionStore.saveSession(existing); + const { appendTranscriptMarker: writeMarker, appendAuditEvent: writeAudit } = await import( + '../../agents/core/session/session-origin-audit.js' + ); + if (event.transcript_path) { + writeMarker(event.transcript_path, sessionId, agentName); + writeAudit('transcript_marker_written', { + codemieSessionId: sessionId, + claudeSessionId: event.session_id, + transcriptPath: event.transcript_path, + }); + } logger.info( `[hook:SessionStart] Session re-entered (source=${event.source}): preserved ` + `startTime=${existing.startTime} activeDurationMs=${existing.activeDurationMs}` @@ -733,6 +744,22 @@ async function createSessionRecord(event: SessionStartEvent, sessionId: string, // Save session await sessionStore.saveSession(session); + const { appendTranscriptMarker, appendAuditEvent } = await import( + '../../agents/core/session/session-origin-audit.js' + ); + if (session.correlation.agentSessionFile) { + appendTranscriptMarker( + session.correlation.agentSessionFile, + sessionId, + agentName, + ); + appendAuditEvent('transcript_marker_written', { + codemieSessionId: sessionId, + claudeSessionId: event.session_id, + transcriptPath: session.correlation.agentSessionFile, + }); + } + logger.info( `[hook:SessionStart] Session created: id=${sessionId} agent=${agentName} ` + `provider=${provider} agent_session=${event.session_id}` From 2f94192872d92eecfcea4c801c7ad216b7487fd4 Mon Sep 17 00:00:00 2001 From: Sviatoslav Likhtarchyk Date: Wed, 1 Jul 2026 11:34:22 +0300 Subject: [PATCH 03/12] feat(agents): add scanSessionsForClaudeId ownership lookup helper --- .../core/__tests__/session-ownership.test.ts | 48 +++++++++++++++++++ src/agents/core/session/session-ownership.ts | 32 +++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/agents/core/__tests__/session-ownership.test.ts create mode 100644 src/agents/core/session/session-ownership.ts diff --git a/src/agents/core/__tests__/session-ownership.test.ts b/src/agents/core/__tests__/session-ownership.test.ts new file mode 100644 index 00000000..06475045 --- /dev/null +++ b/src/agents/core/__tests__/session-ownership.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { scanSessionsForClaudeId } from '../session/session-ownership.js'; + +const TMP = join(tmpdir(), `codemie-ownership-test-${process.pid}`); + +beforeEach(() => mkdirSync(TMP, { recursive: true })); +afterEach(() => rmSync(TMP, { recursive: true, force: true })); + +function writeSession(id: string, claudeSessionId: string): void { + writeFileSync( + join(TMP, `${id}.json`), + JSON.stringify({ correlation: { agentSessionId: claudeSessionId } }), + ); +} + +describe('scanSessionsForClaudeId', () => { + it('returns true when a session file has a matching agentSessionId', () => { + writeSession('codemie-1', 'claude-abc-123'); + expect(scanSessionsForClaudeId('claude-abc-123', TMP)).toBe(true); + }); + + it('returns false when no session matches', () => { + writeSession('codemie-1', 'claude-other'); + expect(scanSessionsForClaudeId('claude-abc-123', TMP)).toBe(false); + }); + + it('returns false when sessions dir is empty', () => { + expect(scanSessionsForClaudeId('claude-abc-123', TMP)).toBe(false); + }); + + it('returns false when sessions dir does not exist', () => { + expect(scanSessionsForClaudeId('id', '/nonexistent/path')).toBe(false); + }); + + it('skips malformed JSON files without throwing', () => { + writeFileSync(join(TMP, 'bad.json'), 'not json{{{'); + writeSession('codemie-1', 'claude-abc-123'); + expect(scanSessionsForClaudeId('claude-abc-123', TMP)).toBe(true); + }); + + it('skips _metrics.json files', () => { + writeFileSync(join(TMP, 'session1_metrics.json'), JSON.stringify({ correlation: { agentSessionId: 'claude-abc-123' } })); + expect(scanSessionsForClaudeId('claude-abc-123', TMP)).toBe(false); + }); +}); diff --git a/src/agents/core/session/session-ownership.ts b/src/agents/core/session/session-ownership.ts new file mode 100644 index 00000000..e1ffee1d --- /dev/null +++ b/src/agents/core/session/session-ownership.ts @@ -0,0 +1,32 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getCodemiePath } from '../../../utils/paths.js'; +import { logger } from '../../../utils/logger.js'; + +export function scanSessionsForClaudeId( + claudeSessionId: string, + sessionsDir?: string, +): boolean { + const dir = sessionsDir ?? getCodemiePath('sessions'); + let files: string[]; + try { + files = readdirSync(dir).filter( + (f) => f.endsWith('.json') && !f.includes('_metrics'), + ); + } catch { + return false; + } + for (const f of files) { + try { + const record = JSON.parse(readFileSync(join(dir, f), 'utf-8')) as { + correlation?: { agentSessionId?: string }; + }; + if (record.correlation?.agentSessionId === claudeSessionId) { + return true; + } + } catch { + logger.debug(`[session-ownership] Skipping unreadable session file: ${f}`); + } + } + return false; +} From 620e4c593a49fb8a0f40685d2fe870911c4990ac Mon Sep 17 00:00:00 2001 From: Sviatoslav Likhtarchyk Date: Wed, 1 Jul 2026 11:38:45 +0300 Subject: [PATCH 04/12] feat(analytics): label external (non-CodeMie) native sessions with provider native-external --- .../analytics/__tests__/native-loader.test.ts | 44 +++++++++++++++++++ src/cli/commands/analytics/native-loader.ts | 25 ++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/analytics/__tests__/native-loader.test.ts b/src/cli/commands/analytics/__tests__/native-loader.test.ts index a57f474f..80e08d96 100644 --- a/src/cli/commands/analytics/__tests__/native-loader.test.ts +++ b/src/cli/commands/analytics/__tests__/native-loader.test.ts @@ -140,6 +140,7 @@ describe('loadNativeSessions', () => { metrics: { tools: {} }, }) as never, realPath: (p) => p, + hasOwnershipMarker: () => false, }; const out = await loadNativeSessions(undefined, deps); expect(out.map((s) => s.sessionId)).toEqual(['fresh']); // tracked one deduped out @@ -154,6 +155,7 @@ describe('loadNativeSessions', () => { ], parse: async () => null, realPath: (p) => p, + hasOwnershipMarker: () => false, }; expect(await loadNativeSessions(undefined, deps)).toEqual([]); }); @@ -180,6 +182,47 @@ describe('synthesizeRawSession — /clear sentinel in post-/clear file', () => { }); }); +describe('loadNativeSessions — external session labeling', () => { + const baseDescriptor = { + sessionId: 'ext-1', + filePath: '/logs/ext-1.jsonl', + createdAt: 1000, + updatedAt: 2000, + agentName: 'claude', + }; + const parsedSession = { + sessionId: 'ext-1', + agentName: 'claude', + metadata: {}, + messages: [ + { type: 'assistant', timestamp: '2026-06-08T10:00:00Z', message: { role: 'assistant', model: 'claude-sonnet-4-6' } }, + ], + metrics: { tools: {} }, + } as never; + + function makeDeps(hasMarker: boolean): NativeLoaderDeps { + return { + trackedLogPaths: () => new Set(), + discover: async () => [{ agentName: 'claude', descriptor: baseDescriptor }], + parse: async () => parsedSession, + realPath: (p) => p, + hasOwnershipMarker: () => hasMarker, + }; + } + + it('sets provider native-external when marker absent', async () => { + const results = await loadNativeSessions(undefined, makeDeps(false)); + expect(results).toHaveLength(1); + expect(results[0].startEvent!.data.provider).toBe('native-external'); + }); + + it('keeps provider native when marker present', async () => { + const results = await loadNativeSessions(undefined, makeDeps(true)); + expect(results).toHaveLength(1); + expect(results[0].startEvent!.data.provider).toBe('native'); + }); +}); + describe('synthesizeCodexRawSession', () => { it('derives turns from task_complete and carries codex metrics', () => { const desc = { sessionId: 'cx', filePath: '/rollout.jsonl', createdAt: 1000, updatedAt: 2000, agentName: 'codex' }; @@ -247,6 +290,7 @@ describe('loadNativeSessions codex child dedup', () => { metrics: { tools: {} }, } as never), realPath: (p) => p, + hasOwnershipMarker: () => false, }; const out = await loadNativeSessions(undefined, deps); expect(out.map((s) => s.sessionId)).toEqual(['parent-uuid']); diff --git a/src/cli/commands/analytics/native-loader.ts b/src/cli/commands/analytics/native-loader.ts index eab10114..b642a0e8 100644 --- a/src/cli/commands/analytics/native-loader.ts +++ b/src/cli/commands/analytics/native-loader.ts @@ -46,6 +46,8 @@ export interface NativeLoaderDeps { parse(agentName: string, filePath: string, sessionId: string): Promise; /** Resolve a path to its real (symlink-free) form for dedup comparison. */ realPath(p: string): string; + /** Returns true when the transcript at filePath contains a codemie_session_start marker (first 10 lines). */ + hasOwnershipMarker(filePath: string): boolean; } function safeRealPath(p: string): string { @@ -120,6 +122,23 @@ export const realNativeDeps: NativeLoaderDeps = { } }, realPath: safeRealPath, + hasOwnershipMarker(filePath: string): boolean { + try { + const content = readFileSync(filePath, 'utf-8'); + return content + .split('\n') + .slice(0, 10) + .some((line) => { + try { + return (JSON.parse(line) as { type?: string }).type === 'codemie_session_start'; + } catch { + return false; + } + }); + } catch { + return false; + } + }, }; interface RawMessage { @@ -438,7 +457,11 @@ export async function loadNativeSessions( if (!parsed) { continue; } - out.push(synthesizeRawSession(agentName, descriptor, parsed)); + const raw = synthesizeRawSession(agentName, descriptor, parsed); + if (!deps.hasOwnershipMarker(descriptor.filePath) && raw.startEvent) { + raw.startEvent.data.provider = 'native-external'; + } + out.push(raw); } return out; } From 5a8d5152fff00b3f888ab730b4ec027bf2e5c20b Mon Sep 17 00:00:00 2001 From: Sviatoslav Likhtarchyk Date: Wed, 1 Jul 2026 11:40:57 +0300 Subject: [PATCH 05/12] feat(analytics): highlight external sessions in analytics terminal output --- src/cli/commands/analytics/formatter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/analytics/formatter.ts b/src/cli/commands/analytics/formatter.ts index 34419295..9f3160d3 100644 --- a/src/cli/commands/analytics/formatter.ts +++ b/src/cli/commands/analytics/formatter.ts @@ -159,7 +159,11 @@ export class AnalyticsFormatter { console.log(chalk.dim(` ${'-'.repeat(54)}`)); console.log(chalk.gray(` Agent: ${session.agentName}`)); - console.log(chalk.gray(` Provider: ${session.provider}`)); + const providerLabel = + session.provider === 'native-external' + ? chalk.yellow('native [external ⚠ not CodeMie-managed]') + : session.provider; + console.log(chalk.gray(` Provider: `) + providerLabel); console.log(chalk.gray(` Duration: ${this.formatDuration(session.duration)}`)); console.log(chalk.gray(` Turns: ${session.totalTurns}`)); From 6b7403947fdec80b025269e0162397c0224657c0 Mon Sep 17 00:00:00 2001 From: Sviatoslav Likhtarchyk Date: Wed, 1 Jul 2026 11:44:39 +0300 Subject: [PATCH 06/12] feat(agents): validate resume session ownership and suppress conv sync for external sessions --- src/agents/core/AgentCLI.ts | 52 +++++++++++++++++++ .../core/__tests__/AgentCLI-resume.test.ts | 14 +++++ 2 files changed, 66 insertions(+) create mode 100644 src/agents/core/__tests__/AgentCLI-resume.test.ts diff --git a/src/agents/core/AgentCLI.ts b/src/agents/core/AgentCLI.ts index 218daacc..b2a8d683 100644 --- a/src/agents/core/AgentCLI.ts +++ b/src/agents/core/AgentCLI.ts @@ -305,6 +305,28 @@ export class AgentCLI { // Serialize full profile config for proxy plugins (read once at CLI level) providerEnv.CODEMIE_PROFILE_CONFIG = JSON.stringify(config); + // Resume ownership check — after providerEnv is built so we can extend it + if (options.resume) { + const resumeId = options.resume as string; + const { scanSessionsForClaudeId } = await import('./session/session-ownership.js'); + const isOwned = scanSessionsForClaudeId(resumeId); + + if (!isOwned) { + const confirmed = await this.promptExternalResume(resumeId); + const { appendAuditEvent } = await import('./session/session-origin-audit.js'); + + if (!confirmed) { + appendAuditEvent('resume_blocked', { claudeSessionId: resumeId }); + console.log(chalk.white(`\nUse 'claude --resume ${resumeId}' to resume without CodeMie tracking.\n`)); + process.exit(1); + } + + Object.assign(providerEnv, buildResumeEnvOverride(true)); + appendAuditEvent('resume_external_confirmed', { claudeSessionId: resumeId }); + logger.info(`[AgentCLI] External resume confirmed for session ${resumeId}; conversation sync suppressed`); + } + } + // Set profile name in logger for log formatting logger.setProfileName(config.name || 'default'); @@ -546,6 +568,32 @@ export class AgentCLI { return true; } + private async promptExternalResume(sessionId: string): Promise { + if (!process.stdin.isTTY || process.env.CODEMIE_NO_PROMPTS === '1') { + console.error( + chalk.red(`\n✗ Session ${sessionId} was not created through CodeMie.\n`) + + chalk.white(`Non-interactive mode: resume blocked. Use 'claude --resume ${sessionId}'.\n`) + ); + return false; + } + + const { createInterface } = await import('node:readline'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + console.log(chalk.yellow(`\n⚠ Warning: Session ${sessionId} was not created through CodeMie.`)); + console.log(chalk.white('If you continue:')); + console.log(chalk.white(' • Token usage and API metrics WILL be tracked via the CodeMie proxy.')); + console.log(chalk.white(' • Conversation transcript will NOT be synced to your CodeMie account history.\n')); + console.log(chalk.dim(`To resume without any CodeMie tracking, use: claude --resume ${sessionId}\n`)); + + return new Promise((resolve) => { + rl.question(chalk.yellow('Continue with CodeMie? (y/N): '), (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === 'y'); + }); + }); + } + /** * Run the CLI */ @@ -553,3 +601,7 @@ export class AgentCLI { await this.program.parseAsync(argv); } } + +export function buildResumeEnvOverride(isExternal: boolean): Record { + return isExternal ? { CODEMIE_CONV_SYNC_DISABLED: '1' } : {}; +} diff --git a/src/agents/core/__tests__/AgentCLI-resume.test.ts b/src/agents/core/__tests__/AgentCLI-resume.test.ts new file mode 100644 index 00000000..b8fafa19 --- /dev/null +++ b/src/agents/core/__tests__/AgentCLI-resume.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { buildResumeEnvOverride } from '../AgentCLI.js'; + +describe('buildResumeEnvOverride', () => { + it('returns CODEMIE_CONV_SYNC_DISABLED=1 for an external confirmed resume', () => { + const env = buildResumeEnvOverride(true); + expect(env).toEqual({ CODEMIE_CONV_SYNC_DISABLED: '1' }); + }); + + it('returns empty object for a CodeMie-owned session', () => { + const env = buildResumeEnvOverride(false); + expect(env).toEqual({}); + }); +}); From 99b69c3a0b95f6dea0004693ee4f2dd8f675fa7e Mon Sep 17 00:00:00 2001 From: Sviatoslav Likhtarchyk Date: Wed, 1 Jul 2026 11:49:18 +0300 Subject: [PATCH 07/12] feat(providers): skip conversation sync when CODEMIE_CONV_SYNC_DISABLED=1 --- .../__tests__/syncProcessor-guard.test.ts | 29 +++++++++++++++++++ .../processors/conversations/syncProcessor.ts | 4 +++ 2 files changed, 33 insertions(+) create mode 100644 src/providers/plugins/sso/session/processors/conversations/__tests__/syncProcessor-guard.test.ts diff --git a/src/providers/plugins/sso/session/processors/conversations/__tests__/syncProcessor-guard.test.ts b/src/providers/plugins/sso/session/processors/conversations/__tests__/syncProcessor-guard.test.ts new file mode 100644 index 00000000..c60bf925 --- /dev/null +++ b/src/providers/plugins/sso/session/processors/conversations/__tests__/syncProcessor-guard.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createSyncProcessor } from '../syncProcessor.js'; + +describe('createSyncProcessor — CODEMIE_CONV_SYNC_DISABLED guard', () => { + let originalEnv: string | undefined; + + beforeEach(() => { + originalEnv = process.env.CODEMIE_CONV_SYNC_DISABLED; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.CODEMIE_CONV_SYNC_DISABLED; + } else { + process.env.CODEMIE_CONV_SYNC_DISABLED = originalEnv; + } + }); + + it('returns early with a disabled message when CODEMIE_CONV_SYNC_DISABLED=1', async () => { + process.env.CODEMIE_CONV_SYNC_DISABLED = '1'; + const processor = createSyncProcessor(); + const result = await processor.process( + { sessionId: 'test-session' } as never, + {} as never, + ); + expect(result.success).toBe(true); + expect(result.message).toMatch(/disabled/i); + }); +}); diff --git a/src/providers/plugins/sso/session/processors/conversations/syncProcessor.ts b/src/providers/plugins/sso/session/processors/conversations/syncProcessor.ts index 4cbbcbf0..78571286 100644 --- a/src/providers/plugins/sso/session/processors/conversations/syncProcessor.ts +++ b/src/providers/plugins/sso/session/processors/conversations/syncProcessor.ts @@ -43,6 +43,10 @@ export function createSyncProcessor(): SessionProcessor { * Process conversations for sync */ async function processConversations(session: ParsedSession, context: ProcessingContext): Promise { + if (process.env.CODEMIE_CONV_SYNC_DISABLED === '1') { + logger.debug('[conv-sync] Conversation sync disabled for this session (CODEMIE_CONV_SYNC_DISABLED=1)'); + return { success: true, message: 'Conversation sync disabled for external session resume' }; + } if (isSyncing) { return { success: true, message: 'Sync in progress' }; } From cba714b7e6c3e830ee769ce7d6b4dd70b2eca487 Mon Sep 17 00:00:00 2001 From: Sviatoslav Likhtarchyk Date: Wed, 1 Jul 2026 12:01:43 +0300 Subject: [PATCH 08/12] fix(agents): address post-review issues in session origin validation - Fix _metrics.json filter in session-ownership: use endsWith instead of includes to avoid skipping legitimate session files whose names merely contain the substring _metrics - Guard ConversationsProcessor.shouldProcess against CODEMIE_CONV_SYNC_DISABLED to prevent writing PENDING payloads for external resume sessions - Extract shouldBlockNonInteractiveResume as module-level export from AgentCLI so the non-TTY/no-prompts path can be unit-tested without mocking private state; refactor promptExternalResume to delegate to it - Add tests for shouldBlockNonInteractiveResume and the _metrics filter fix Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/AgentCLI.ts | 6 +++- .../core/__tests__/AgentCLI-resume.test.ts | 31 +++++++++++++++++-- .../core/__tests__/session-ownership.test.ts | 5 +++ src/agents/core/session/session-ownership.ts | 2 +- .../claude.conversations-processor.ts | 1 + 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/agents/core/AgentCLI.ts b/src/agents/core/AgentCLI.ts index b2a8d683..27758a65 100644 --- a/src/agents/core/AgentCLI.ts +++ b/src/agents/core/AgentCLI.ts @@ -569,7 +569,7 @@ export class AgentCLI { } private async promptExternalResume(sessionId: string): Promise { - if (!process.stdin.isTTY || process.env.CODEMIE_NO_PROMPTS === '1') { + if (shouldBlockNonInteractiveResume()) { console.error( chalk.red(`\n✗ Session ${sessionId} was not created through CodeMie.\n`) + chalk.white(`Non-interactive mode: resume blocked. Use 'claude --resume ${sessionId}'.\n`) @@ -605,3 +605,7 @@ export class AgentCLI { export function buildResumeEnvOverride(isExternal: boolean): Record { return isExternal ? { CODEMIE_CONV_SYNC_DISABLED: '1' } : {}; } + +export function shouldBlockNonInteractiveResume(): boolean { + return !process.stdin.isTTY || process.env.CODEMIE_NO_PROMPTS === '1'; +} diff --git a/src/agents/core/__tests__/AgentCLI-resume.test.ts b/src/agents/core/__tests__/AgentCLI-resume.test.ts index b8fafa19..9dfee00d 100644 --- a/src/agents/core/__tests__/AgentCLI-resume.test.ts +++ b/src/agents/core/__tests__/AgentCLI-resume.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { buildResumeEnvOverride } from '../AgentCLI.js'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildResumeEnvOverride, shouldBlockNonInteractiveResume } from '../AgentCLI.js'; describe('buildResumeEnvOverride', () => { it('returns CODEMIE_CONV_SYNC_DISABLED=1 for an external confirmed resume', () => { @@ -12,3 +12,30 @@ describe('buildResumeEnvOverride', () => { expect(env).toEqual({}); }); }); + +describe('shouldBlockNonInteractiveResume', () => { + let origNoPrompts: string | undefined; + + beforeEach(() => { + origNoPrompts = process.env.CODEMIE_NO_PROMPTS; + }); + + afterEach(() => { + if (origNoPrompts === undefined) { + delete process.env.CODEMIE_NO_PROMPTS; + } else { + process.env.CODEMIE_NO_PROMPTS = origNoPrompts; + } + }); + + it('returns true when CODEMIE_NO_PROMPTS=1', () => { + process.env.CODEMIE_NO_PROMPTS = '1'; + expect(shouldBlockNonInteractiveResume()).toBe(true); + }); + + it('returns true when stdin is not a TTY (test environment default)', () => { + delete process.env.CODEMIE_NO_PROMPTS; + // In Vitest, process.stdin.isTTY is false/undefined — non-interactive by default + expect(shouldBlockNonInteractiveResume()).toBe(true); + }); +}); diff --git a/src/agents/core/__tests__/session-ownership.test.ts b/src/agents/core/__tests__/session-ownership.test.ts index 06475045..81b847b3 100644 --- a/src/agents/core/__tests__/session-ownership.test.ts +++ b/src/agents/core/__tests__/session-ownership.test.ts @@ -45,4 +45,9 @@ describe('scanSessionsForClaudeId', () => { writeFileSync(join(TMP, 'session1_metrics.json'), JSON.stringify({ correlation: { agentSessionId: 'claude-abc-123' } })); expect(scanSessionsForClaudeId('claude-abc-123', TMP)).toBe(false); }); + + it('does not skip a session file whose name contains _metrics but does not end with _metrics.json', () => { + writeFileSync(join(TMP, 'my_metrics_session.json'), JSON.stringify({ correlation: { agentSessionId: 'claude-abc-123' } })); + expect(scanSessionsForClaudeId('claude-abc-123', TMP)).toBe(true); + }); }); diff --git a/src/agents/core/session/session-ownership.ts b/src/agents/core/session/session-ownership.ts index e1ffee1d..356a49e0 100644 --- a/src/agents/core/session/session-ownership.ts +++ b/src/agents/core/session/session-ownership.ts @@ -11,7 +11,7 @@ export function scanSessionsForClaudeId( let files: string[]; try { files = readdirSync(dir).filter( - (f) => f.endsWith('.json') && !f.includes('_metrics'), + (f) => f.endsWith('.json') && !f.endsWith('_metrics.json'), ); } catch { return false; diff --git a/src/agents/plugins/claude/session/processors/claude.conversations-processor.ts b/src/agents/plugins/claude/session/processors/claude.conversations-processor.ts index 1be19885..13da462a 100644 --- a/src/agents/plugins/claude/session/processors/claude.conversations-processor.ts +++ b/src/agents/plugins/claude/session/processors/claude.conversations-processor.ts @@ -38,6 +38,7 @@ export class ConversationsProcessor implements SessionProcessor { } shouldProcess(session: ParsedSession): boolean { + if (process.env.CODEMIE_CONV_SYNC_DISABLED === '1') return false; return session.messages && session.messages.length > 0; } From 82fdc3022d56be4275f7ee2dc93627041a92b193 Mon Sep 17 00:00:00 2001 From: Sviatoslav Likhtarchyk Date: Wed, 1 Jul 2026 14:04:45 +0300 Subject: [PATCH 09/12] fix(agents): support slug-based resume and harden session origin validation - Remove UUID_RE guard that rejected non-UUID --resume values (e.g. ticket slugs like epmcdme-12992); sanitize resumeId for display with \p{Cc}/gu - Also set process.env.CODEMIE_CONV_SYNC_DISABLED in parent process so same-process conversation sync consumers respect the suppression flag; clean up after adapter.run() completes - Standardize _metrics file filter to endsWith('_metrics.json') in native-loader.ts, consistent with session-ownership.ts - Write sidecar marker in session-origin-audit.ts and add existsSync guard before transcript append (non-fatal, race-condition-free ownership signal) - Add CODEMIE_CONV_SYNC_DISABLED guard to codex, gemini, opencode conversation processors Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/AgentCLI.ts | 27 +++++--- .../core/session/session-origin-audit.ts | 39 +++++++++-- .../codex.conversations-processor.ts | 1 + .../gemini.conversations-processor.ts | 1 + .../opencode.conversations-processor.ts | 1 + src/cli/commands/analytics/native-loader.ts | 65 +++++++++++++++++-- 6 files changed, 117 insertions(+), 17 deletions(-) diff --git a/src/agents/core/AgentCLI.ts b/src/agents/core/AgentCLI.ts index 27758a65..21873c3b 100644 --- a/src/agents/core/AgentCLI.ts +++ b/src/agents/core/AgentCLI.ts @@ -307,7 +307,11 @@ export class AgentCLI { // Resume ownership check — after providerEnv is built so we can extend it if (options.resume) { - const resumeId = options.resume as string; + // Strip ANSI escape sequences and control chars before using the value in output. + // Claude's own --resume accepts non-UUID identifiers (slugs, ticket IDs, etc.), + // so we must not validate the format here. + + const resumeId = (options.resume as string).replace(/\p{Cc}/gu, ''); const { scanSessionsForClaudeId } = await import('./session/session-ownership.js'); const isOwned = scanSessionsForClaudeId(resumeId); @@ -317,11 +321,13 @@ export class AgentCLI { if (!confirmed) { appendAuditEvent('resume_blocked', { claudeSessionId: resumeId }); - console.log(chalk.white(`\nUse 'claude --resume ${resumeId}' to resume without CodeMie tracking.\n`)); process.exit(1); } + // Inject into subprocess env (for lifecycle hook subprocesses that inherit it) + // and into the current process env (for same-process consumers such as sso syncProcessor). Object.assign(providerEnv, buildResumeEnvOverride(true)); + process.env.CODEMIE_CONV_SYNC_DISABLED = '1'; appendAuditEvent('resume_external_confirmed', { claudeSessionId: resumeId }); logger.info(`[AgentCLI] External resume confirmed for session ${resumeId}; conversation sync suppressed`); } @@ -338,6 +344,8 @@ export class AgentCLI { // Run the agent (welcome message will be shown inside) await this.adapter.run(agentArgs, providerEnv); + // Clean up the process-level flag set for same-process conversation sync consumers. + delete process.env.CODEMIE_CONV_SYNC_DISABLED; } catch (error) { // Show user-friendly error message in console first const errorMessage = error instanceof Error ? error.message : String(error); @@ -572,7 +580,8 @@ export class AgentCLI { if (shouldBlockNonInteractiveResume()) { console.error( chalk.red(`\n✗ Session ${sessionId} was not created through CodeMie.\n`) + - chalk.white(`Non-interactive mode: resume blocked. Use 'claude --resume ${sessionId}'.\n`) + chalk.white(`Non-interactive mode: resume blocked.\n`) + + chalk.white(`Use 'claude --resume ${sessionId}' to resume without CodeMie tracking.\n`) ); return false; } @@ -586,12 +595,14 @@ export class AgentCLI { console.log(chalk.white(' • Conversation transcript will NOT be synced to your CodeMie account history.\n')); console.log(chalk.dim(`To resume without any CodeMie tracking, use: claude --resume ${sessionId}\n`)); - return new Promise((resolve) => { - rl.question(chalk.yellow('Continue with CodeMie? (y/N): '), (answer) => { - rl.close(); - resolve(answer.trim().toLowerCase() === 'y'); + try { + const answer = await new Promise((resolve) => { + rl.question(chalk.yellow('Continue with CodeMie? (y/N): '), resolve); }); - }); + return answer.trim().toLowerCase() === 'y'; + } finally { + rl.close(); + } } /** diff --git a/src/agents/core/session/session-origin-audit.ts b/src/agents/core/session/session-origin-audit.ts index 567a01f4..dab022ce 100644 --- a/src/agents/core/session/session-origin-audit.ts +++ b/src/agents/core/session/session-origin-audit.ts @@ -1,4 +1,4 @@ -import { appendFileSync, mkdirSync } from 'node:fs'; +import { appendFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { getCodemiePath } from '../../../utils/paths.js'; @@ -32,18 +32,47 @@ export function appendTranscriptMarker( codemieAgent: string, ): void { if (!transcriptPath) return; + + // CR-006: write a race-condition-free sidecar marker in ~/.codemie/sessions/ instead of + // appending to the live Claude transcript (avoids byte-level interleaving on concurrent writes). + try { + const sessionsDir = getCodemiePath('sessions'); + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync( + join(sessionsDir, `${codemieSessionId}-codemie-marker.json`), + JSON.stringify({ + transcriptPath, + codemieSessionId, + codemieAgent, + timestamp: new Date().toISOString(), + }), + 'utf-8', + ); + logger.debug(`[session-origin] Sidecar marker written for session ${codemieSessionId}`); + } catch (err) { + logger.debug(`[session-origin] Failed to write sidecar marker (non-fatal): ${err}`); + } + + // CR-007: spec says "skip and emit debug log if transcript not yet present" (non-fatal). + if (!existsSync(transcriptPath)) { + logger.debug(`[session-origin] Transcript file not yet present, skipping transcript marker write`); + return; + } + + // Append transcript marker for self-describing backward compat (legacy sessions without sidecar). try { - const marker = + appendFileSync( + transcriptPath, JSON.stringify({ type: 'codemie_session_start', uuid: randomUUID(), codemie_session_id: codemieSessionId, codemie_agent: codemieAgent, timestamp: new Date().toISOString(), - }) + '\n'; - appendFileSync(transcriptPath, marker); + }) + '\n', + ); logger.debug(`[session-origin] Marker written to transcript: ${transcriptPath}`); } catch (err) { - logger.warn(`[session-origin] Failed to write transcript marker (non-fatal): ${err}`); + logger.debug(`[session-origin] Failed to write transcript marker (non-fatal): ${err}`); } } diff --git a/src/agents/plugins/codex/session/processors/codex.conversations-processor.ts b/src/agents/plugins/codex/session/processors/codex.conversations-processor.ts index fc3d948c..d8197c2c 100644 --- a/src/agents/plugins/codex/session/processors/codex.conversations-processor.ts +++ b/src/agents/plugins/codex/session/processors/codex.conversations-processor.ts @@ -77,6 +77,7 @@ export class CodexConversationsProcessor implements SessionProcessor { readonly priority = 2; shouldProcess(session: ParsedSession): boolean { + if (process.env.CODEMIE_CONV_SYNC_DISABLED === '1') return false; return session.messages.length > 0; } diff --git a/src/agents/plugins/gemini/session/processors/gemini.conversations-processor.ts b/src/agents/plugins/gemini/session/processors/gemini.conversations-processor.ts index eae5d894..9989c4f4 100644 --- a/src/agents/plugins/gemini/session/processors/gemini.conversations-processor.ts +++ b/src/agents/plugins/gemini/session/processors/gemini.conversations-processor.ts @@ -32,6 +32,7 @@ export class GeminiConversationsProcessor implements SessionProcessor { readonly priority = 2; // Run after metrics shouldProcess(session: ParsedSession): boolean { + if (process.env.CODEMIE_CONV_SYNC_DISABLED === '1') return false; return Boolean(session.messages && session.messages.length > 0); } diff --git a/src/agents/plugins/opencode/session/processors/opencode.conversations-processor.ts b/src/agents/plugins/opencode/session/processors/opencode.conversations-processor.ts index 2708ee06..2525be43 100644 --- a/src/agents/plugins/opencode/session/processors/opencode.conversations-processor.ts +++ b/src/agents/plugins/opencode/session/processors/opencode.conversations-processor.ts @@ -52,6 +52,7 @@ export class OpenCodeConversationsProcessor implements SessionProcessor { * Check if session has data to process */ shouldProcess(session: ParsedSession): boolean { + if (process.env.CODEMIE_CONV_SYNC_DISABLED === '1') return false; return session.messages.length > 0; } diff --git a/src/cli/commands/analytics/native-loader.ts b/src/cli/commands/analytics/native-loader.ts index b642a0e8..489486ed 100644 --- a/src/cli/commands/analytics/native-loader.ts +++ b/src/cli/commands/analytics/native-loader.ts @@ -12,7 +12,7 @@ * real path) so they are not double-counted. */ -import { realpathSync, readdirSync, readFileSync } from 'node:fs'; +import { realpathSync, readdirSync, readFileSync, openSync, readSync, closeSync } from 'node:fs'; import { join } from 'node:path'; import type { RawSessionData } from './data-loader.js'; import type { AnalyticsFilter } from './types.js'; @@ -69,7 +69,7 @@ function readTrackedLogPaths(): Set { } let files: string[]; try { - files = readdirSync(dir).filter((f) => f.endsWith('.json') && !f.includes('_metrics')); + files = readdirSync(dir).filter((f) => f.endsWith('.json') && !f.endsWith('_metrics.json')); } catch { return out; } @@ -89,6 +89,49 @@ function readTrackedLogPaths(): Set { return out; } +/** + * Lazy-built ownership index: union of + * (a) agentSessionFile real-paths from correlation records (*.json, non-metrics, non-marker) + * (b) transcriptPath values from sidecar marker files (*-codemie-marker.json) + * Cached for the lifetime of the process — analytics runs are short-lived. + */ +let ownershipIndexCache: Set | null = null; + +function buildOwnershipIndex(): Set { + const out = new Set(); + let dir: string; + try { + dir = getCodemiePath('sessions'); + } catch { + return out; + } + let files: string[]; + try { + files = readdirSync(dir).filter((f) => f.endsWith('.json')); + } catch { + return out; + } + for (const f of files) { + try { + if (f.endsWith('-codemie-marker.json')) { + const marker = JSON.parse(readFileSync(join(dir, f), 'utf-8')) as { + transcriptPath?: string; + }; + if (marker.transcriptPath) out.add(safeRealPath(marker.transcriptPath)); + } else if (!f.endsWith('_metrics.json')) { + const meta = JSON.parse(readFileSync(join(dir, f), 'utf-8')) as { + correlation?: { agentSessionFile?: string }; + }; + const asf = meta.correlation?.agentSessionFile; + if (asf) out.add(safeRealPath(asf)); + } + } catch { + // skip unreadable / malformed files + } + } + return out; +} + export const realNativeDeps: NativeLoaderDeps = { trackedLogPaths: readTrackedLogPaths, async discover(maxAgeDays) { @@ -123,9 +166,23 @@ export const realNativeDeps: NativeLoaderDeps = { }, realPath: safeRealPath, hasOwnershipMarker(filePath: string): boolean { + // CR-003: index-first check — correlation index + sidecar markers + if (!ownershipIndexCache) ownershipIndexCache = buildOwnershipIndex(); + if (ownershipIndexCache.has(safeRealPath(filePath))) return true; + + // CR-002: bounded transcript scan (4 KB) for legacy sessions without sidecar try { - const content = readFileSync(filePath, 'utf-8'); - return content + const fd = openSync(filePath, 'r'); + const buf = Buffer.alloc(4096); + let bytesRead: number; + try { + bytesRead = readSync(fd, buf, 0, 4096, 0); + } finally { + closeSync(fd); + } + return buf + .subarray(0, bytesRead) + .toString('utf-8') .split('\n') .slice(0, 10) .some((line) => { From d4cb05eba943346c9ee2fe74b67daba1014d9e8a Mon Sep 17 00:00:00 2001 From: Sviatoslav Likhtarchyk Date: Wed, 1 Jul 2026 14:08:33 +0300 Subject: [PATCH 10/12] chore: add EPMCDME-12992 superpowers artifacts Work item, spec, plan, two code-review verdicts, task state, gate plan, and QA report for session origin validation. Generated with AI Co-Authored-By: codemie-ai --- ...EPMCDME-12992-session-origin-validation.md | 857 ++++++++++++++++++ .../code-review-final.json | 62 ++ .../code-review-final.json | 139 +++ ...-12992-session-origin-validation-design.md | 146 +++ .../gate-plan.json | 17 + .../qa-report.md | 29 + docs/superpowers/work-items/EPMCDME-12992.md | 37 + 7 files changed, 1287 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-EPMCDME-12992-session-origin-validation.md create mode 100644 docs/superpowers/reviews/2026-07-01-EPMCDME-12992-2/code-review-final.json create mode 100644 docs/superpowers/reviews/2026-07-01-EPMCDME-12992/code-review-final.json create mode 100644 docs/superpowers/specs/2026-07-01-EPMCDME-12992-session-origin-validation-design.md create mode 100644 docs/superpowers/tasks/2026-07-01-epmcdme-12992-session-origin-validation/gate-plan.json create mode 100644 docs/superpowers/tasks/2026-07-01-epmcdme-12992-session-origin-validation/qa-report.md create mode 100644 docs/superpowers/work-items/EPMCDME-12992.md diff --git a/docs/superpowers/plans/2026-07-01-EPMCDME-12992-session-origin-validation.md b/docs/superpowers/plans/2026-07-01-EPMCDME-12992-session-origin-validation.md new file mode 100644 index 00000000..c02b1ed1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-EPMCDME-12992-session-origin-validation.md @@ -0,0 +1,857 @@ +# Session Origin Validation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prevent CodeMie from ingesting Claude Code sessions that were not created through CodeMie, by adding dual-signal ownership detection (correlation index + transcript marker), labeling external sessions in analytics, and warning users who try to resume non-CodeMie sessions. + +**Architecture:** (1) A new `session-origin-audit.ts` utility writes a `codemie_session_start` ownership marker into Claude transcripts at SessionStart and maintains an append-only audit log. (2) `native-loader.ts` checks each discovered transcript against a new `hasOwnershipMarker` dep, setting `provider: 'native-external'` on unowned sessions. (3) `AgentCLI.ts` validates `--resume ` ownership before spawning Claude and suppresses conversation sync via `CODEMIE_CONV_SYNC_DISABLED=1` for confirmed external resumes. (4) `syncProcessor.ts` respects that env flag. + +**Tech Stack:** Node.js, TypeScript, `node:fs` (appendFileSync/mkdirSync), `node:readline` (y/N prompt), `node:crypto` (randomUUID), Vitest + +--- + +### Task 1: Audit log + transcript marker utility + +**Files:** +- Create: `src/agents/core/session/session-origin-audit.ts` +- Create: `src/agents/core/__tests__/session-origin-audit.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +// src/agents/core/__tests__/session-origin-audit.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// We test by pointing the functions at a temp dir instead of ~/.codemie +// The functions accept optional override paths for testability. +import { + appendAuditEvent, + appendTranscriptMarker, +} from '../session/session-origin-audit.js'; + +const TMP = join(tmpdir(), `codemie-audit-test-${process.pid}`); +const auditFile = join(TMP, 'logs', 'session-origin-audit.jsonl'); +const transcriptFile = join(TMP, 'transcript.jsonl'); + +beforeEach(() => { + mkdirSync(join(TMP, 'logs'), { recursive: true }); + writeFileSync(transcriptFile, '{"type":"user","uuid":"abc"}\n'); +}); + +afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +describe('appendAuditEvent', () => { + it('creates the file and appends a valid JSON line', () => { + appendAuditEvent('resume_blocked', { claudeSessionId: 'ses-1' }, join(TMP, 'logs')); + const lines = readFileSync(auditFile, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(1); + const parsed = JSON.parse(lines[0]); + expect(parsed.event).toBe('resume_blocked'); + expect(parsed.data.claudeSessionId).toBe('ses-1'); + expect(typeof parsed.ts).toBe('string'); + }); + + it('appends multiple events', () => { + appendAuditEvent('resume_blocked', { claudeSessionId: 'a' }, join(TMP, 'logs')); + appendAuditEvent('resume_external_confirmed', { claudeSessionId: 'b' }, join(TMP, 'logs')); + const lines = readFileSync(auditFile, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + }); + + it('is non-fatal when the directory does not exist', () => { + expect(() => + appendAuditEvent('resume_blocked', {}, '/nonexistent/path/that/does/not/exist/logs') + ).not.toThrow(); + }); +}); + +describe('appendTranscriptMarker', () => { + it('appends a codemie_session_start line to the transcript', () => { + appendTranscriptMarker(transcriptFile, 'codemie-id-1', 'claude'); + const lines = readFileSync(transcriptFile, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + const marker = JSON.parse(lines[1]); + expect(marker.type).toBe('codemie_session_start'); + expect(marker.codemie_session_id).toBe('codemie-id-1'); + expect(marker.codemie_agent).toBe('claude'); + expect(typeof marker.uuid).toBe('string'); + expect(typeof marker.timestamp).toBe('string'); + }); + + it('is non-fatal when the transcript file does not exist', () => { + expect(() => + appendTranscriptMarker('/does/not/exist/session.jsonl', 'id', 'claude') + ).not.toThrow(); + }); + + it('is non-fatal when transcript path is empty string', () => { + expect(() => appendTranscriptMarker('', 'id', 'claude')).not.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npm run test:unit -- src/agents/core/__tests__/session-origin-audit.test.ts +``` + +Expected: FAIL — `session-origin-audit.js` not found. + +- [ ] **Step 3: Implement `session-origin-audit.ts`** + +```typescript +// src/agents/core/session/session-origin-audit.ts +import { appendFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { getCodemiePath } from '../../../utils/paths.js'; +import { logger } from '../../../utils/logger.js'; + +const LOG_FILENAME = 'session-origin-audit.jsonl'; + +export type AuditEventName = + | 'transcript_marker_written' + | 'resume_blocked' + | 'resume_external_confirmed'; + +export function appendAuditEvent( + event: AuditEventName, + data: Record, + logsDir?: string, +): void { + try { + const dir = logsDir ?? getCodemiePath('logs'); + mkdirSync(dir, { recursive: true }); + const line = JSON.stringify({ ts: new Date().toISOString(), event, data }) + '\n'; + appendFileSync(join(dir, LOG_FILENAME), line); + } catch { + // non-fatal — audit log write failure must never break a user session + } +} + +export function appendTranscriptMarker( + transcriptPath: string, + codemieSessionId: string, + codemieAgent: string, +): void { + if (!transcriptPath) return; + try { + const marker = JSON.stringify({ + type: 'codemie_session_start', + uuid: randomUUID(), + codemie_session_id: codemieSessionId, + codemie_agent: codemieAgent, + timestamp: new Date().toISOString(), + }) + '\n'; + appendFileSync(transcriptPath, marker); + logger.debug(`[session-origin] Marker written to transcript: ${transcriptPath}`); + } catch (err) { + logger.warn(`[session-origin] Failed to write transcript marker (non-fatal): ${err}`); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npm run test:unit -- src/agents/core/__tests__/session-origin-audit.test.ts +``` + +Expected: all 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/agents/core/session/session-origin-audit.ts src/agents/core/__tests__/session-origin-audit.test.ts +git commit -m "feat(security): add session-origin-audit utility for transcript marker and audit log" +``` + +--- + +### Task 2: Write ownership marker at SessionStart + +**Files:** +- Modify: `src/cli/commands/hook.ts` — `createSessionRecord()`, lines 733–744 + +Test-first: no — `createSessionRecord` uses dynamic imports and real `SessionStore`; the utility functions being called are fully tested in Task 1. + +- [ ] **Step 1: Locate the save call in `createSessionRecord`** + +Open `src/cli/commands/hook.ts`. Find line 734: `await sessionStore.saveSession(session);` + +- [ ] **Step 2: Import and call `appendTranscriptMarker` + `appendAuditEvent` after the save** + +```typescript +// After line 734 (await sessionStore.saveSession(session);), BEFORE the logger.info call: + +const { appendTranscriptMarker, appendAuditEvent } = await import( + '../agents/core/session/session-origin-audit.js' +); + +if (session.correlation.agentSessionFile) { + appendTranscriptMarker( + session.correlation.agentSessionFile, + sessionId, + agentName, + ); + appendAuditEvent('transcript_marker_written', { + codemieSessionId: sessionId, + claudeSessionId: event.session_id, + transcriptPath: session.correlation.agentSessionFile, + }); +} +``` + +- [ ] **Step 3: Also add the marker on re-entered sessions (compact flow)** + +In the same function, the re-entry block at line 695 also calls `sessionStore.saveSession(existing)` and then `return`. Add the same marker call there: + +```typescript +// After line 705 (await sessionStore.saveSession(existing);), before the logger.info: + +const { appendTranscriptMarker: writeMarker, appendAuditEvent: writeAudit } = await import( + '../agents/core/session/session-origin-audit.js' +); +if (event.transcript_path) { + writeMarker(event.transcript_path, sessionId, agentName); + writeAudit('transcript_marker_written', { + codemieSessionId: sessionId, + claudeSessionId: event.session_id, + transcriptPath: event.transcript_path, + }); +} +return; +``` + +- [ ] **Step 4: Typecheck** + +```bash +npm run typecheck +``` + +Expected: no errors in `hook.ts`. + +- [ ] **Step 5: Commit** + +```bash +git add src/cli/commands/hook.ts +git commit -m "feat(security): write codemie_session_start marker to Claude transcript at SessionStart" +``` + +--- + +### Task 3: Session ownership helper (extracted + testable) + +**Files:** +- Create: `src/agents/core/session/session-ownership.ts` +- Create: `src/agents/core/__tests__/session-ownership.test.ts` + +This small module holds the filesystem-level ownership lookup so it can be tested without instantiating `AgentCLI`. + +- [ ] **Step 1: Write the failing tests** + +```typescript +// src/agents/core/__tests__/session-ownership.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { scanSessionsForClaudeId } from '../session/session-ownership.js'; + +const TMP = join(tmpdir(), `codemie-ownership-test-${process.pid}`); + +beforeEach(() => mkdirSync(TMP, { recursive: true })); +afterEach(() => rmSync(TMP, { recursive: true, force: true })); + +function writeSession(id: string, claudeSessionId: string): void { + writeFileSync( + join(TMP, `${id}.json`), + JSON.stringify({ correlation: { agentSessionId: claudeSessionId } }), + ); +} + +describe('scanSessionsForClaudeId', () => { + it('returns true when a session file has a matching agentSessionId', () => { + writeSession('codemie-1', 'claude-abc-123'); + expect(scanSessionsForClaudeId('claude-abc-123', TMP)).toBe(true); + }); + + it('returns false when no session matches', () => { + writeSession('codemie-1', 'claude-other'); + expect(scanSessionsForClaudeId('claude-abc-123', TMP)).toBe(false); + }); + + it('returns false when sessions dir is empty', () => { + expect(scanSessionsForClaudeId('claude-abc-123', TMP)).toBe(false); + }); + + it('returns false when sessions dir does not exist', () => { + expect(scanSessionsForClaudeId('id', '/nonexistent/path')).toBe(false); + }); + + it('skips malformed JSON files without throwing', () => { + writeFileSync(join(TMP, 'bad.json'), 'not json{{{'); + writeSession('codemie-1', 'claude-abc-123'); + expect(scanSessionsForClaudeId('claude-abc-123', TMP)).toBe(true); + }); + + it('skips _metrics.json files', () => { + writeFileSync(join(TMP, 'session1_metrics.json'), JSON.stringify({ correlation: { agentSessionId: 'claude-abc-123' } })); + expect(scanSessionsForClaudeId('claude-abc-123', TMP)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npm run test:unit -- src/agents/core/__tests__/session-ownership.test.ts +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `session-ownership.ts`** + +```typescript +// src/agents/core/session/session-ownership.ts +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getCodemiePath } from '../../../utils/paths.js'; +import { logger } from '../../../utils/logger.js'; + +/** + * Scan ~/.codemie/sessions/ for a record whose correlation.agentSessionId + * matches the given Claude session ID. Returns true when found (CodeMie-owned). + */ +export function scanSessionsForClaudeId( + claudeSessionId: string, + sessionsDir?: string, +): boolean { + const dir = sessionsDir ?? getCodemiePath('sessions'); + let files: string[]; + try { + files = readdirSync(dir).filter( + (f) => f.endsWith('.json') && !f.includes('_metrics'), + ); + } catch { + return false; + } + for (const f of files) { + try { + const record = JSON.parse(readFileSync(join(dir, f), 'utf-8')) as { + correlation?: { agentSessionId?: string }; + }; + if (record.correlation?.agentSessionId === claudeSessionId) { + return true; + } + } catch { + logger.debug(`[session-ownership] Skipping unreadable session file: ${f}`); + } + } + return false; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npm run test:unit -- src/agents/core/__tests__/session-ownership.test.ts +``` + +Expected: all 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/agents/core/session/session-ownership.ts src/agents/core/__tests__/session-ownership.test.ts +git commit -m "feat(security): add scanSessionsForClaudeId ownership lookup helper" +``` + +--- + +### Task 4: Native loader — label external sessions + +**Files:** +- Modify: `src/cli/commands/analytics/native-loader.ts` +- Modify: `src/cli/commands/analytics/__tests__/native-loader.test.ts` + +- [ ] **Step 1: Write the failing tests — add `hasOwnershipMarker` to `NativeLoaderDeps` and test external labeling** + +Add this block at the end of `src/cli/commands/analytics/__tests__/native-loader.test.ts`: + +```typescript +describe('loadNativeSessions — external session labeling', () => { + const baseDescriptor = { + sessionId: 'ext-1', + filePath: '/logs/ext-1.jsonl', + createdAt: 1000, + updatedAt: 2000, + agentName: 'claude', + }; + const parsedSession = { + sessionId: 'ext-1', + agentName: 'claude', + metadata: {}, + messages: [ + { type: 'assistant', timestamp: '2026-06-08T10:00:00Z', message: { role: 'assistant', model: 'claude-sonnet-4-6' } }, + ], + metrics: { tools: {} }, + } as never; + + function makeDeps(hasMarker: boolean): NativeLoaderDeps { + return { + trackedLogPaths: () => new Set(), + discover: async () => [{ agentName: 'claude', descriptor: baseDescriptor }], + parse: async () => parsedSession, + realPath: (p) => p, + hasOwnershipMarker: () => hasMarker, + }; + } + + it('sets provider native-external when marker absent', async () => { + const results = await loadNativeSessions(undefined, makeDeps(false)); + expect(results).toHaveLength(1); + expect(results[0].startEvent!.data.provider).toBe('native-external'); + }); + + it('keeps provider native when marker present', async () => { + const results = await loadNativeSessions(undefined, makeDeps(true)); + expect(results).toHaveLength(1); + expect(results[0].startEvent!.data.provider).toBe('native'); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +npm run test:unit -- src/cli/commands/analytics/__tests__/native-loader.test.ts +``` + +Expected: FAIL — `NativeLoaderDeps` missing `hasOwnershipMarker`, `makeDeps` type error. + +- [ ] **Step 3: Extend `NativeLoaderDeps` interface and `realNativeDeps`** + +In `native-loader.ts`, add `hasOwnershipMarker` to the `NativeLoaderDeps` interface: + +```typescript +export interface NativeLoaderDeps { + trackedLogPaths(): Set; + discover(maxAgeDays: number): Promise; + parse(agentName: string, filePath: string, sessionId: string): Promise; + realPath(p: string): string; + /** Returns true when the transcript at filePath contains a codemie_session_start marker. */ + hasOwnershipMarker(filePath: string): boolean; +} +``` + +Add the implementation to `realNativeDeps`: + +```typescript +export const realNativeDeps: NativeLoaderDeps = { + trackedLogPaths: readTrackedLogPaths, + // ... (existing discover, parse, realPath unchanged) + hasOwnershipMarker(filePath: string): boolean { + try { + const content = readFileSync(filePath, 'utf-8'); + return content + .split('\n') + .slice(0, 10) + .some((line) => { + try { + return (JSON.parse(line) as { type?: string }).type === 'codemie_session_start'; + } catch { + return false; + } + }); + } catch { + return false; + } + }, +}; +``` + +- [ ] **Step 4: Use `hasOwnershipMarker` in `loadNativeSessions` to label external sessions** + +In `loadNativeSessions`, replace the synthesis call at the end of the loop: + +```typescript +// Replace: +out.push(synthesizeRawSession(agentName, descriptor, parsed)); + +// With: +const raw = synthesizeRawSession(agentName, descriptor, parsed); +if (!deps.hasOwnershipMarker(descriptor.filePath) && raw.startEvent) { + raw.startEvent.data.provider = 'native-external'; +} +out.push(raw); +``` + +- [ ] **Step 5: Run tests** + +```bash +npm run test:unit -- src/cli/commands/analytics/__tests__/native-loader.test.ts +``` + +Expected: all tests PASS (existing tests pass because the default `realNativeDeps` hasOwnershipMarker returns false for non-existent paths, but the existing tests use injected deps without `hasOwnershipMarker` — those need to be updated to include the field). + +Update the two existing `loadNativeSessions` test `deps` objects to include `hasOwnershipMarker: () => false` (they test tracking/dedup, not origin, so `false` is fine there): + +```typescript +// In both existing loadNativeSessions describe blocks, add to the deps object: +hasOwnershipMarker: () => false, +``` + +- [ ] **Step 6: Run tests again to confirm all pass** + +```bash +npm run test:unit -- src/cli/commands/analytics/__tests__/native-loader.test.ts +``` + +Expected: all tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/cli/commands/analytics/native-loader.ts src/cli/commands/analytics/__tests__/native-loader.test.ts +git commit -m "feat(security): label external (non-CodeMie) native sessions with provider native-external" +``` + +--- + +### Task 5: Terminal formatter — highlight external sessions + +**Files:** +- Modify: `src/cli/commands/analytics/formatter.ts` (line 162) + +No separate unit test — the formatter outputs chalk-colored console output; visual rendering is verified by running `codemie analytics`. + +- [ ] **Step 1: Open `formatter.ts` and locate the provider line** + +Line 162: +```typescript +console.log(chalk.gray(` Provider: ${session.provider}`)); +``` + +- [ ] **Step 2: Replace with external-aware rendering** + +```typescript +const providerLabel = + session.provider === 'native-external' + ? chalk.yellow('native [external ⚠ not CodeMie-managed]') + : session.provider; +console.log(chalk.gray(` Provider: `) + providerLabel); +``` + +- [ ] **Step 3: Typecheck** + +```bash +npm run typecheck +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/cli/commands/analytics/formatter.ts +git commit -m "feat(security): highlight external sessions in analytics terminal output" +``` + +--- + +### Task 6: AgentCLI — resume ownership check and sync suppression + +**Files:** +- Modify: `src/agents/core/AgentCLI.ts` + +Test-first: yes — `scanSessionsForClaudeId` (from Task 3) is already tested. This task tests the env-var injection path via a focused unit test on the new `buildResumeEnvOverride` helper. + +- [ ] **Step 1: Write the failing test for the env override helper** + +```typescript +// src/agents/core/__tests__/AgentCLI-resume.test.ts +import { describe, it, expect } from 'vitest'; +import { buildResumeEnvOverride } from '../AgentCLI.js'; + +describe('buildResumeEnvOverride', () => { + it('returns CODEMIE_CONV_SYNC_DISABLED=1 for an external confirmed resume', () => { + const env = buildResumeEnvOverride(true); + expect(env).toEqual({ CODEMIE_CONV_SYNC_DISABLED: '1' }); + }); + + it('returns empty object for a CodeMie-owned session', () => { + const env = buildResumeEnvOverride(false); + expect(env).toEqual({}); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +npm run test:unit -- src/agents/core/__tests__/AgentCLI-resume.test.ts +``` + +Expected: FAIL — `buildResumeEnvOverride` not exported from `AgentCLI.js`. + +- [ ] **Step 3: Export `buildResumeEnvOverride` from `AgentCLI.ts`** + +Add near the top of the class (or as a module-level export below the class): + +```typescript +/** Pure helper — exported for unit testing. */ +export function buildResumeEnvOverride(isExternal: boolean): Record { + return isExternal ? { CODEMIE_CONV_SYNC_DISABLED: '1' } : {}; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npm run test:unit -- src/agents/core/__tests__/AgentCLI-resume.test.ts +``` + +Expected: both tests PASS. + +- [ ] **Step 5: Add the resume validation flow to `handleRun`** + +Add a new private method `promptExternalResume` to `AgentCLI`: + +```typescript +private async promptExternalResume(sessionId: string): Promise { + if (!process.stdin.isTTY || process.env.CODEMIE_NO_PROMPTS === '1') { + console.error( + chalk.red(`\n✗ Session ${sessionId} was not created through CodeMie.\n`) + + chalk.white(`Non-interactive mode: resume blocked. Use 'claude --resume ${sessionId}'.\n`) + ); + return false; + } + + const { createInterface } = await import('node:readline'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + console.log(chalk.yellow(`\n⚠ Warning: Session ${sessionId} was not created through CodeMie.`)); + console.log(chalk.white('If you continue:')); + console.log(chalk.white(' • Token usage and API metrics WILL be tracked via the CodeMie proxy.')); + console.log(chalk.white(' • Conversation transcript will NOT be synced to your CodeMie account history.\n')); + console.log(chalk.dim(`To resume without any CodeMie tracking, use: claude --resume ${sessionId}\n`)); + + return new Promise((resolve) => { + rl.question(chalk.yellow('Continue with CodeMie? (y/N): '), (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === 'y'); + }); + }); +} +``` + +- [ ] **Step 6: Add ownership check in `handleRun` after `providerEnv` is built** + +In `handleRun`, locate line 307 (`providerEnv.CODEMIE_PROFILE_CONFIG = JSON.stringify(config);`). Add immediately after it: + +```typescript +// Resume ownership check — after providerEnv is built so we can extend it +if (options.resume) { + const resumeId = options.resume as string; + const { scanSessionsForClaudeId } = await import('./session/session-ownership.js'); + const isOwned = scanSessionsForClaudeId(resumeId); + + if (!isOwned) { + const confirmed = await this.promptExternalResume(resumeId); + const { appendAuditEvent } = await import('./session/session-origin-audit.js'); + + if (!confirmed) { + appendAuditEvent('resume_blocked', { claudeSessionId: resumeId }); + console.log(chalk.white(`\nUse 'claude --resume ${resumeId}' to resume without CodeMie tracking.\n`)); + process.exit(1); + } + + // User confirmed — suppress conversation transcript sync only + Object.assign(providerEnv, buildResumeEnvOverride(true)); + appendAuditEvent('resume_external_confirmed', { claudeSessionId: resumeId }); + logger.info(`[AgentCLI] External resume confirmed for session ${resumeId}; conversation sync suppressed`); + } +} +``` + +- [ ] **Step 7: Typecheck** + +```bash +npm run typecheck +``` + +Expected: no errors. + +- [ ] **Step 8: Commit** + +```bash +git add src/agents/core/AgentCLI.ts src/agents/core/__tests__/AgentCLI-resume.test.ts +git commit -m "feat(security): validate resume session ownership in AgentCLI and suppress conv sync for external sessions" +``` + +--- + +### Task 7: ConversationSyncProcessor — respect CODEMIE_CONV_SYNC_DISABLED + +**Files:** +- Modify: `src/providers/plugins/sso/session/processors/conversations/syncProcessor.ts` +- Create: `src/providers/plugins/sso/session/processors/conversations/__tests__/syncProcessor-guard.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/providers/plugins/sso/session/processors/conversations/__tests__/syncProcessor-guard.test.ts +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +describe('createSyncProcessor — CODEMIE_CONV_SYNC_DISABLED guard', () => { + let originalEnv: string | undefined; + + beforeEach(() => { + originalEnv = process.env.CODEMIE_CONV_SYNC_DISABLED; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.CODEMIE_CONV_SYNC_DISABLED; + } else { + process.env.CODEMIE_CONV_SYNC_DISABLED = originalEnv; + } + }); + + it('returns early with a skipped message when CODEMIE_CONV_SYNC_DISABLED=1', async () => { + process.env.CODEMIE_CONV_SYNC_DISABLED = '1'; + + // We import dynamically so the env var is read at call time + const { createSyncProcessor } = await import('../syncProcessor.js'); + const processor = createSyncProcessor(); + + // Provide a minimal mock session + context — the processor must not reach + // readJSONL or any API call when the guard fires + const mockReadJSONL = vi.fn(); + vi.doMock('../../../utils/jsonl-reader.js', () => ({ readJSONL: mockReadJSONL })); + + const result = await processor.process( + { sessionId: 'test-session' } as never, + {} as never, + ); + + expect(result.success).toBe(true); + expect(result.message).toMatch(/disabled/i); + expect(mockReadJSONL).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +npm run test:unit -- src/providers/plugins/sso/session/processors/conversations/__tests__/syncProcessor-guard.test.ts +``` + +Expected: FAIL — processor has no CODEMIE_CONV_SYNC_DISABLED guard; either skips the guard or calls readJSONL. + +- [ ] **Step 3: Add the guard at the top of `processConversations`** + +In `syncProcessor.ts`, inside `processConversations`, add before the `isSyncing` check: + +```typescript +async function processConversations(session: ParsedSession, context: ProcessingContext): Promise { + if (process.env.CODEMIE_CONV_SYNC_DISABLED === '1') { + logger.debug('[conv-sync] Conversation sync disabled for this session (CODEMIE_CONV_SYNC_DISABLED=1)'); + return { success: true, message: 'Conversation sync disabled for external session resume' }; + } + + if (isSyncing) { + return { success: true, message: 'Sync in progress' }; + } + // ... rest unchanged +``` + +- [ ] **Step 4: Run tests** + +```bash +npm run test:unit -- src/providers/plugins/sso/session/processors/conversations/__tests__/syncProcessor-guard.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +```bash +npm run typecheck +``` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/providers/plugins/sso/session/processors/conversations/syncProcessor.ts src/providers/plugins/sso/session/processors/conversations/__tests__/syncProcessor-guard.test.ts +git commit -m "feat(security): skip conversation sync when CODEMIE_CONV_SYNC_DISABLED=1" +``` + +--- + +### Task 8: Full test pass + lint + +**Files:** none (validation only) + +- [ ] **Step 1: Run all unit tests** + +```bash +npm run test:unit +``` + +Expected: all tests pass. + +- [ ] **Step 2: Run lint** + +```bash +npm run lint +``` + +Expected: zero errors, zero warnings. + +- [ ] **Step 3: Run typecheck** + +```bash +npm run typecheck +``` + +Expected: no errors. + +- [ ] **Step 4: Final commit if any lint auto-fixes were applied** + +```bash +git add -p +git commit -m "chore: lint fixes from session origin validation" +``` + +--- + +## Self-Review + +**Spec coverage:** +| Spec section | Task | +|---|---| +| Section 1 — Transcript marker at SessionStart | Tasks 1 + 2 | +| Section 2 — Analytics external label | Tasks 3 + 4 | +| Section 3 — Resume validation + sync suppression | Tasks 3 (ownership helper) + 6 (AgentCLI) + 7 (syncProcessor) | +| Section 4 — Audit logging | Task 1 (utility) + called in Tasks 2 + 6 | +| Acceptance criteria — no placeholder TODOs | Verified | + +**Type consistency:** +- `appendTranscriptMarker` / `appendAuditEvent` — same names in Tasks 1, 2, 6 ✓ +- `scanSessionsForClaudeId` — same name in Tasks 3, 6 ✓ +- `buildResumeEnvOverride` — defined and used in Task 6 only ✓ +- `CODEMIE_CONV_SYNC_DISABLED` — set in Task 6, checked in Task 7 ✓ +- `provider: 'native-external'` — set in Task 4, rendered in Task 5 ✓ + +**Placeholder scan:** None found. diff --git a/docs/superpowers/reviews/2026-07-01-EPMCDME-12992-2/code-review-final.json b/docs/superpowers/reviews/2026-07-01-EPMCDME-12992-2/code-review-final.json new file mode 100644 index 00000000..66b66003 --- /dev/null +++ b/docs/superpowers/reviews/2026-07-01-EPMCDME-12992-2/code-review-final.json @@ -0,0 +1,62 @@ +{ + "gate_id": "code-review.final", + "decision": "request-changes", + "confidence": "low", + "rationale": "One of three review lenses ran (blind). Edge-case and acceptance lenses failed with a model access error (Bedrock 403), so coverage is partial and confidence is forced to low. CR-R-001 is a critical regression: the UUID_RE guard added at AgentCLI.ts:311-315 hard-exits for any non-UUID resume value — this directly blocks the user's stated requirement that 'codemie-claude --resume epmcdme-12992' must work. Two major findings (CR-R-002, CR-R-003) are also actionable. Three additional items were deferred (no_rotation, cache_lifetime, test_pollution) and are not blocking.", + "risk_flags": ["breaking-change"], + "business_review": [], + "standards_review": [ + { + "standard": "commit-format", + "status": "pass", + "notes": "Conventional Commits format followed. Scopes used (agents, providers, analytics, cli) are in the allowed list." + }, + { + "standard": "code-quality", + "status": "pass", + "notes": "ES modules, async/await, .js extensions, no require(), no console.log debug output, no generic Error throws, getCodemiePath() used for ~/.codemie paths." + }, + { + "standard": "security", + "status": "partial", + "notes": "Ownership trust boundary relies entirely on user-writable ~/.codemie/sessions/ files with no integrity check — an attacker could plant a correlation file to bypass the external-resume warning. Acceptable for a local-file trust model, but worth documenting. The resumeId is used in chalk output without sanitizing ANSI escape sequences; removing UUID_RE (CR-R-001 fix) reintroduces the ANSI injection vector — the fix must include a display sanitizer." + } + ], + "findings": [ + { + "id": "CR-R-001", + "severity": "critical", + "triage": "patch", + "title": "UUID_RE regex hard-rejects slug-based resume values", + "file": "src/agents/core/AgentCLI.ts:311-315", + "problem": "A strict UUID regex was added to guard against ANSI injection in the resumeId display path. It calls process.exit(1) for any non-UUID value with 'Invalid session ID: expected UUID format.' The user explicitly requires 'codemie-claude --resume epmcdme-12992' (ticket slug) to work. Claude's own --resume flag accepts identifiers beyond UUIDs. This is a hard breaking regression introduced by the prior CR-004 ANSI injection fix.", + "impact": "Any non-UUID resume identifier (slug like 'epmcdme-12992', ticket ID, custom handle) is silently hard-rejected before reaching the ownership check or prompt. The entire slug-based resume flow is dead.", + "recommendation": "Remove the UUID_RE block. Sanitize resumeId for safe display only: const safeId = resumeId.replace(/[\\x00-\\x1F\\x7F-\\x9F]/g, ''); and use safeId in chalk/console output instead of resumeId. The ownership check (scanSessionsForClaudeId) already provides semantic validation; additional format restrictions belong in the underlying adapter, not here." + }, + { + "id": "CR-R-002", + "severity": "major", + "triage": "patch", + "title": "_metrics filter inconsistency between buildOwnershipIndex and scanSessionsForClaudeId", + "file": "src/cli/commands/analytics/native-loader.ts:72,121", + "problem": "readTrackedLogPaths() (line 72) and buildOwnershipIndex() (line 121) filter with !f.includes('_metrics'), which excludes any filename containing the substring '_metrics' anywhere. session-ownership.ts:14 correctly uses !f.endsWith('_metrics.json'). A legitimate session file named e.g. 'abc_metrics_detail.json' would be excluded from the index in native-loader but processed in session-ownership.", + "impact": "Silent inconsistency: sessions that buildOwnershipIndex skips are not marked as CodeMie-owned in analytics, causing them to appear as 'native-external' even when they are owned. The probability is low (unlikely filenames), but the divergence is a latent correctness bug.", + "recommendation": "Standardize on !f.endsWith('_metrics.json') everywhere. Update both filter expressions in native-loader.ts to match session-ownership.ts." + }, + { + "id": "CR-R-003", + "severity": "major", + "triage": "patch", + "title": "CODEMIE_CONV_SYNC_DISABLED injected into subprocess env but read from process.env — scope gap for same-process consumers", + "file": "src/agents/core/AgentCLI.ts:328", + "problem": "Object.assign(providerEnv, buildResumeEnvOverride(true)) adds CODEMIE_CONV_SYNC_DISABLED to the object passed to adapter.run(). All five conversation processors and the sso syncProcessor read process.env.CODEMIE_CONV_SYNC_DISABLED. If any of these run in the parent AgentCLI process (not the spawned subprocess that inherits providerEnv), the suppression silently never activates. The sso syncProcessor in src/providers/plugins/sso/ is the primary concern — its execution context may differ from the claude/codex/gemini agent-executor subprocess.", + "impact": "External session transcripts could be silently synced to the user's CodeMie account history even when the user was explicitly told 'conversation transcript will NOT be synced.' Data privacy violation against the feature's stated contract.", + "recommendation": "Verify that every processor that checks CODEMIE_CONV_SYNC_DISABLED runs within the subprocess context that receives providerEnv. If the sso syncProcessor or any other processor runs in the parent process, also set process.env.CODEMIE_CONV_SYNC_DISABLED = '1' for the duration of the adapter.run() call (with cleanup in a finally block). Add a code comment documenting which process context is expected." + } + ], + "deferred": [ + "ownershipIndexCache never invalidated (acceptable for short-lived CLI analytics runs)", + "audit log has no rotation/size cap (acceptable for log volume in practice)", + "appendTranscriptMarker test writes sidecar to real ~/.codemie/sessions/ (no sessionsDir override param in the function signature)" + ] +} diff --git a/docs/superpowers/reviews/2026-07-01-EPMCDME-12992/code-review-final.json b/docs/superpowers/reviews/2026-07-01-EPMCDME-12992/code-review-final.json new file mode 100644 index 00000000..0130affa --- /dev/null +++ b/docs/superpowers/reviews/2026-07-01-EPMCDME-12992/code-review-final.json @@ -0,0 +1,139 @@ +{ + "gate_id": "code-review.final", + "run_id": "docs/superpowers/reviews/2026-07-01-EPMCDME-12992", + "decision": "request-changes", + "confidence": "medium", + "rationale": "7 findings (1 critical, 6 major). Critical: CODEMIE_CONV_SYNC_DISABLED guard missing in gemini, codex, and opencode conversation processors — users are explicitly told transcript will not sync but it still does for non-Claude agent sessions, directly violating the stated consent contract. Major issues: hasOwnershipMarker reads entire transcript file into memory (OOM risk); dual-signal index-first ownership check absent leaving latent false-external labeling for re-entered sessions; resumeId passed to chalk/console/logger without UUID validation enabling ANSI injection that can hide the security warning; readline not closed before process.exit(1) leaving terminal in broken state; appendFileSync races with Claude's live transcript writes risking JSONL corruption; appendTranscriptMarker logs at warn instead of debug for missing transcript file (spec deviation + log noise). Standards review: commit format pass, code quality pass, security partial (CR-004). Acceptance: AC1/AC3/AC4/AC6/AC7/AC8 pass; AC2/AC5 partial (dual-signal architecture deviation). 0 deferred findings (excluded from output); 3 dismissed: TOCTOU false-negative race (very low probability, acceptable), CODEMIE_CONV_SYNC_DISABLED process-scope concern (env var is set on subprocess env only, not parent), non-interactive double-output UX inconsistency (low severity, no security impact).", + "risk_flags": ["security"], + "findings": [ + { + "id": "CR-001", + "title": "CODEMIE_CONV_SYNC_DISABLED guard missing in gemini, codex, and opencode conversation processors", + "file": "src/agents/plugins/gemini/session/processors/gemini.conversations-processor.ts:34", + "severity": "critical", + "triage": "patch", + "problem": "The CODEMIE_CONV_SYNC_DISABLED=1 guard is present only in claude.conversations-processor.ts (shouldProcess) and sso/syncProcessor.ts (processConversations). The equivalent processors for gemini (gemini.conversations-processor.ts:34), codex (codex.conversations-processor.ts:79), and opencode (opencode.conversations-processor.ts:54) do not check this env var and will return shouldProcess()=true and sync conversation transcripts for any session type when CODEMIE_CONV_SYNC_DISABLED=1 is set.", + "impact": "Critical data leakage and consent violation: when a user confirms resuming an external (non-CodeMie-owned) session via codemie-gemini, codemie-codex, or codemie-opencode, they are explicitly told 'Conversation transcript will NOT be synced to your CodeMie account history.' That promise is false — the conversation data is synced. The security fix is complete only for Claude sessions; the other three agent types are unprotected.", + "recommendation": "Add the same guard at the top of shouldProcess() in every agent-type conversation processor: `if (process.env.CODEMIE_CONV_SYNC_DISABLED === '1') return false;`. Files to update: src/agents/plugins/gemini/session/processors/gemini.conversations-processor.ts, src/agents/plugins/codex/session/processors/codex.conversations-processor.ts, src/agents/plugins/opencode/session/processors/opencode.conversations-processor.ts. Search for all SessionProcessor implementations that handle conversation data to ensure complete coverage." + }, + { + "id": "CR-002", + "title": "hasOwnershipMarker reads entire transcript file into memory before slicing to 10 lines", + "file": "src/cli/commands/analytics/native-loader.ts:127", + "severity": "major", + "triage": "patch", + "problem": "readFileSync(filePath, 'utf-8') in hasOwnershipMarker reads the complete file before calling .split('\\n').slice(0, 10). Long Claude sessions produce transcript files of tens of MB. When codemie analytics runs with a large session history (default window is 3650 days), every discovered native transcript is fully loaded into memory before the first 10 lines are examined.", + "impact": "The codemie analytics command can exhaust available memory or become extremely slow when there are many large transcript files, causing OOM crashes or multi-second latency per session. A user with a year of sessions could trigger this on every analytics run.", + "recommendation": "Read only the first ~4 KB by streaming: open the file with fs.openSync and read(fd, buf, 0, 4096, 0), then split the buffer on newlines and scan those. Alternatively use createReadStream with highWaterMark: 4096 and destroy the stream after 10 newlines are seen. This caps per-file memory to 4 KB regardless of file size." + }, + { + "id": "CR-003", + "title": "Dual-signal ownership check not implemented — index-first path missing, latent false-external labeling for re-entered sessions", + "file": "src/cli/commands/analytics/native-loader.ts:461", + "severity": "major", + "triage": "patch", + "problem": "The approved spec defines a two-stage ownership resolution: (1) build a CodeMieSessionIndex from ~/.codemie/sessions/*.json keyed by agentSessionFile real-path and check it first; (2) fall back to transcript-line scan only when not in the index. The implementation uses only the transcript-line scan (hasOwnershipMarker). For re-entered CodeMie sessions, the ownership marker is appended beyond line 10 of an existing transcript (prior content precedes the re-entry marker), so they can be falsely labeled native-external if the index-based dedup ever misses them. Currently the dedup step (readTrackedLogPaths excludes indexed sessions from discovery) provides implicit coverage, but this is fragile: symlink normalization differences or any future code path change that allows an indexed session to reach hasOwnershipMarker() will produce a false positive label.", + "impact": "Latent correctness defect: CodeMie-owned re-entered sessions could be labeled native-external in analytics, falsely presenting a data-leakage warning to users. Also a direct deviation from the approved dual-signal spec design.", + "recommendation": "Add an index-first check before calling hasOwnershipMarker(): build a Set of real-path'd agentSessionFile values from ~/.codemie/sessions/*.json, and if descriptor.filePath (after realPath normalization) is in that set, assign provider='native' directly and skip the transcript scan. This matches spec Section 2's stated 'In index → origin: codemie' branch and removes the latent false-positive path." + }, + { + "id": "CR-004", + "title": "resumeId used in terminal output without UUID validation — ANSI escape injection can hide security warning", + "file": "src/agents/core/AgentCLI.ts:311", + "severity": "major", + "triage": "patch", + "problem": "The resumeId value from options.resume is passed directly to chalk.yellow(), chalk.red(), chalk.white(), chalk.dim(), console.log(), and logger.info() without validating that it matches UUID format. A crafted --resume argument containing ANSI escape sequences (e.g., \\x1b[2J to clear the screen or OSC sequences to rewrite the terminal title line) is rendered verbatim by chalk, potentially erasing or replacing the warning message that informs the user about tracking implications.", + "impact": "An attacker controlling the --resume argument (via malicious shell script, CI injection, or a crafted README one-liner) can suppress or distort the external-resume warning that is the primary user-consent mechanism of this security feature, defeating its purpose.", + "recommendation": "Validate resumeId against expected UUID format before any display or audit use: `const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (!UUID_RE.test(resumeId)) { console.error('Invalid session ID: expected UUID format'); process.exit(1); }` Add this check immediately after `const resumeId = options.resume as string;`." + }, + { + "id": "CR-005", + "title": "readline interface not closed before process.exit(1) — terminal left in broken state; non-interactive path emits duplicate messages", + "file": "src/agents/core/AgentCLI.ts:319", + "severity": "major", + "triage": "patch", + "problem": "Two issues in promptExternalResume: (1) If an exception occurs during the readline promise setup (e.g., stdin closed unexpectedly), the rl interface is created but never closed, leaving the terminal's raw-mode flag set. (2) In non-interactive mode, promptExternalResume() calls console.error() with one message and returns false; then the caller's !confirmed branch also calls console.log() with a different message (line 320). The user sees two separate messages to two different streams — inconsistent with interactive mode which shows only the console.log message.", + "impact": "Terminal can be left in a broken state requiring `reset` after an unexpected error. Non-interactive mode (CI/scripted) emits two conflicting messages to stdout and stderr, breaking log parsing and potentially confusing users.", + "recommendation": "Wrap the readline interaction in try/finally to ensure rl.close() always runs. For non-interactive output consistency: consolidate both output paths into promptExternalResume() — the non-interactive branch should emit the 'Use claude --resume' suggestion and the caller's console.log should be removed, so both paths produce identical user-facing output." + }, + { + "id": "CR-006", + "title": "appendTranscriptMarker uses appendFileSync on actively-written Claude transcript — concurrent write risk", + "file": "src/agents/core/session/session-origin-audit.ts:43", + "severity": "major", + "triage": "patch", + "problem": "appendTranscriptMarker uses appendFileSync to write to the Claude Code JSONL transcript file, which is being actively written by the Claude Code process during the session. O_APPEND writes from two processes are atomically safe only up to PIPE_BUF (~4 KB) on POSIX; no advisory file lock is held. If Claude is mid-write on a large streaming event when the SessionStart hook fires, byte-level interleaving can produce a malformed JSONL line immediately after the write boundary.", + "impact": "Malformed JSONL lines in the transcript break the hasOwnershipMarker scan (the JSON.parse call throws, the marker is missed, and the session is labeled native-external) and can break downstream analytics session parsing, causing data loss or incorrect session reconstruction.", + "recommendation": "Use a separate sidecar file for the ownership marker instead of appending to the live Claude transcript — e.g., write the marker to `{codemieSessionId}-codemie-marker.json` in ~/.codemie/sessions/ alongside the existing session record. This completely avoids write contention. The hasOwnershipMarker function in native-loader should then check for this sidecar by building the correlation index, aligning with the spec's intended dual-signal approach (CR-003)." + }, + { + "id": "CR-007", + "title": "appendTranscriptMarker logs at warn instead of debug for missing transcript file — spec deviation and log noise", + "file": "src/agents/core/session/session-origin-audit.ts:47", + "severity": "major", + "triage": "patch", + "problem": "The spec states: 'If the transcript file does not exist at SessionStart time, the write is skipped and a debug log is emitted (non-fatal).' The implementation catches ENOENT from appendFileSync and logs at warn level (logger.warn). There is no pre-flight existsSync() check — the write is always attempted and the warn fires whenever Claude hasn't yet created the transcript file, which is expected for new sessions where Claude writes the file after the SessionStart hook fires.", + "impact": "Operators see warn-level log noise on every new CodeMie session startup. Warn-level messages are treated as actionable alerts in most log aggregation systems, creating false operational alerts that obscure real warnings.", + "recommendation": "Add `if (!existsSync(transcriptPath)) { logger.debug('[session-origin] Transcript file not yet present, skipping marker write'); return; }` before the appendFileSync call. This matches the spec's stated behavior and reduces log noise to the debug level where it belongs." + } + ], + "business_review": [ + { + "criterion": "AC1 — No external session ingestion: sync suppressed on confirmed external resume", + "status": "pass", + "notes": "CODEMIE_CONV_SYNC_DISABLED=1 injected via buildResumeEnvOverride(true) into subprocess env. claude.conversations-processor.ts shouldProcess() and sso/syncProcessor.ts processConversations() both guard it. Metrics/session sync unaffected by design." + }, + { + "criterion": "AC2 — Analytics shows only CodeMie sessions: external labeled, not hidden", + "status": "partial", + "notes": "provider='native-external' label and chalk.yellow formatter rendering correct. However the spec's dual-signal index-first architecture is absent — only transcript scan used for origin classification (see CR-003)." + }, + { + "criterion": "AC3 — Resume validates origin: ownership check + warn + confirm flow", + "status": "pass", + "notes": "Full flow in AgentCLI.handleRun(): scanSessionsForClaudeId() check → promptExternalResume() warning + y/N prompt → audit events on block or confirm. Warning text matches spec exactly. Non-interactive guard (shouldBlockNonInteractiveResume) correctly blocks on !isTTY || CODEMIE_NO_PROMPTS=1." + }, + { + "criterion": "AC4 — Non-CodeMie sessions blocked from sync: CODEMIE_CONV_SYNC_DISABLED + processor guard", + "status": "partial", + "notes": "Guard present in claude processor and SSO syncProcessor only. Gemini, codex, opencode processors missing the guard (CR-001) — conversation transcripts still sync for those agent types on confirmed external resume." + }, + { + "criterion": "AC5 — .claude/projects parsing restricted: index + marker dual-signal filter", + "status": "partial", + "notes": "Transcript marker scan (first 10 lines) implemented and correct for new sessions. CodeMieSessionIndex-first check from spec Section 2 not implemented as a distinct origin-classification step (see CR-003). Dedup provides implicit coverage currently." + }, + { + "criterion": "AC6 — Audit logging: append-only log with all events", + "status": "pass", + "notes": "All three event types present (transcript_marker_written, resume_blocked, resume_external_confirmed). Non-fatal try/catch. Uses getCodemiePath('logs') for default path. Both SessionStart paths (new + re-entry) emit the marker and audit event." + }, + { + "criterion": "AC7 — Regression tests: coverage for all new behavior", + "status": "pass", + "notes": "4 new test files: session-ownership.test.ts (7 cases), session-origin-audit.test.ts (5 cases), AgentCLI-resume.test.ts (4 cases), syncProcessor-guard.test.ts (1 case). native-loader.test.ts extended with external labeling coverage. Unit coverage of all new components is solid." + }, + { + "criterion": "AC8 — Security: no data leakage on confirmed external resume (for Claude sessions)", + "status": "pass", + "notes": "For Claude sessions specifically: consent flow explicit, tracking consequences disclosed before confirmation, conv sync suppressed, metrics/proxy tracking by design. No credentials logged. Note: AC4 partial — non-Claude session types still leak (CR-001)." + } + ], + "standards_review": [ + { + "standard": "commit-format", + "status": "pass", + "notes": "All 8 commits follow Conventional Commits type(scope): description. Scopes (agents, cli, analytics, providers) match the project's allowed scope list. fix() used correctly for the post-review fixes commit." + }, + { + "standard": "code-quality", + "status": "pass", + "notes": "ES modules with .js imports throughout. async/await used. Explicit return types on all exports. No `any` in production code. `as never` in tests is standard Vitest type-narrowing pattern. getCodemiePath() used for all ~/.codemie paths. Dynamic imports (import('./session/...')) follow the project async-import pattern." + }, + { + "standard": "security", + "status": "partial", + "notes": "ANSI injection via unvalidated resumeId raised as CR-004. No secrets or credentials logged. appendFileSync to user-supplied transcript_path is mitigated by non-fatal catch but the concurrency risk is raised as CR-006. The consent model is explicit and correct for Claude sessions." + } + ] +} diff --git a/docs/superpowers/specs/2026-07-01-EPMCDME-12992-session-origin-validation-design.md b/docs/superpowers/specs/2026-07-01-EPMCDME-12992-session-origin-validation-design.md new file mode 100644 index 00000000..67f66462 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-EPMCDME-12992-session-origin-validation-design.md @@ -0,0 +1,146 @@ +# Design: Session Origin Validation + +**Ticket:** EPMCDME-12992 +**Date:** 2026-07-01 +**Status:** Approved +**Complexity:** 24 (brainstorming) + +## Problem + +CodeMie parses `~/.claude/projects/` too broadly and ingests Claude Code sessions that were never created through CodeMie. This causes: + +1. Non-CodeMie sessions appearing in CodeMie Analytics and account history. +2. `codemie-claude --resume ` silently syncing arbitrary Claude sessions into CodeMie datasets. +3. Potential customer data leakage when a user accidentally resumes client-work sessions via `codemie-claude`. + +## Approach: Dual Signal — Correlation Index + Transcript Marker + +Ownership is determined by two complementary signals: + +1. **Correlation index** — `~/.codemie/sessions/*.json` records `correlation.agentSessionFile` (transcript path) and `correlation.agentSessionId` (Claude's session UUID). Fast O(1) lookup, already present. +2. **Transcript marker** — a `codemie_session_start` JSON line appended to the Claude `.jsonl` at session start. Makes the transcript self-describing; survives `~/.codemie/sessions/` cleanup. + +## Section 1: Ownership Marker at Session Start + +**File:** `src/agents/core/hook.ts` → `createSessionRecord()` + +After the `~/.codemie/sessions/{id}.json` file is written, append one line to `correlation.agentSessionFile`: + +```jsonl +{"type":"codemie_session_start","uuid":"","codemie_session_id":"","codemie_agent":"","timestamp":""} +``` + +- Non-standard event type — ignored by Claude Code. +- If the transcript file does not yet exist at `SessionStart` time, the write is skipped and a debug log is emitted (non-fatal). +- Write failures are non-fatal: log warning, continue. + +## Section 2: Analytics — Label External Sessions + +**File:** `src/cli/commands/analytics/native-loader.ts` + +### Ownership resolution for each discovered transcript + +1. Build `CodeMieSessionIndex: Map` from all `~/.codemie/sessions/*.json`. +2. For each transcript path discovered by `discoverSessions()`: + - In index → `origin: 'codemie'`, ingest as today. + - Not in index → scan first 10 lines of the transcript for `{"type":"codemie_session_start",...}`. + - Marker found → `origin: 'codemie'`. + - Marker not found → `origin: 'external'`. +3. Sessions with `origin: 'external'` are synthesized into `RawSessionData` normally but with: + - `data.origin = 'external'` + - `data.provider = 'native-external'` (replaces `'native'`) + +### Rendering + +Analytics report renderers check `provider === 'native-external'` and append a label (e.g., `[external]`) to the session line. No sessions are hidden — all are surfaced, external ones are clearly marked. + +### Error handling + +- If building `CodeMieSessionIndex` fails → treat all sessions as `origin: 'external'` (conservative; prevents leakage by default). +- If transcript read fails during marker scan → treat as `origin: 'external'`. + +## Section 3: Resume Validation + +**File:** `src/agents/core/AgentCLI.ts` → `handleRun()` + +Before flag transformation and subprocess spawn, when `--resume ` is present: + +1. Scan `~/.codemie/sessions/*.json` for a record where `correlation.agentSessionId === sessionId`. +2. **Found** → CodeMie-owned; proceed normally. +3. **Not found** → print: + + ``` + ⚠ Warning: Session was not created through CodeMie. + If you continue: + • Token usage and API metrics WILL be tracked via the CodeMie proxy. + • Conversation transcript will NOT be synced to your CodeMie account history. + + To resume without any CodeMie tracking, use: claude --resume + + Continue with CodeMie? (y/N): + ``` + +4. **User confirms `y`** → spawn Claude with `--resume`, inject `CODEMIE_CONV_SYNC_DISABLED=1` into subprocess env. +5. **User declines** → exit 1, print: `Use 'claude --resume ' to resume without CodeMie tracking.` + +### Sync suppression (conversation transcript only) + +Because the CodeMie proxy captures token usage and API metrics for all traffic regardless of session origin, **only conversation transcript sync is suppressed** for confirmed external resumes. + +`ConversationSyncProcessor` checks `process.env.CODEMIE_CONV_SYNC_DISABLED === '1'` and skips transcript upload. `MetricsSyncProcessor` and `SessionSyncer` run normally — token/metric data flows through the proxy as expected. + +The env var is renamed `CODEMIE_CONV_SYNC_DISABLED` (not `CODEMIE_SYNC_DISABLED`) to be precise about what is suppressed. + +### Error handling + +- `~/.codemie/sessions/` scan failure → treat as "not found" → show warning flow (conservative). +- Non-interactive (piped stdin / `--yes` / `CODEMIE_NO_PROMPTS=1`) → behave as if user declined; exit 1 with error message. + +## Section 4: Audit Logging + +**New file:** `src/agents/core/session/session-origin-audit.ts` + +Append-only audit log at `~/.codemie/logs/session-origin-audit.jsonl`: + +```jsonl +{"ts":"","event":"transcript_marker_written","sessionId":"","claudeSessionId":""} +{"ts":"","event":"resume_blocked","sessionId":""} +{"ts":"","event":"resume_external_confirmed","sessionId":"","codemieSessionId":""} +``` + +Events: +- `transcript_marker_written` — emitted after successful marker write in `createSessionRecord()`. +- `resume_blocked` — emitted when user declines or non-interactive exit. +- `resume_external_confirmed` — emitted when user confirms resume of external session. + +Write failures are non-fatal (log to stderr debug, continue). + +## Files Changed + +| File | Change | +|------|--------| +| `src/agents/core/hook.ts` | Append `codemie_session_start` marker to transcript in `createSessionRecord()` | +| `src/agents/core/AgentCLI.ts` | Add resume validation in `handleRun()` before flag transform; inject `CODEMIE_CONV_SYNC_DISABLED=1` on confirmed external resume | +| `src/cli/commands/analytics/native-loader.ts` | Add `CodeMieSessionIndex` build + origin labeling logic | +| `src/cli/commands/analytics/types.ts` | Add `origin: 'codemie' \| 'external'` to `RawSessionData` | +| `src/cli/commands/analytics/sources/sessions-source.ts` | Pass `origin` through to renderer | +| `src/cli/commands/analytics/report-renderer.ts` (or equivalent) | Render `[external]` label for `native-external` sessions | +| `src/providers/plugins/sso/session/processors/conversations/syncProcessor.ts` | Check `CODEMIE_CONV_SYNC_DISABLED` and skip transcript upload when set | +| `src/agents/core/session/session-origin-audit.ts` | **New file** — append-only audit log utility | + +## Out of Scope + +- Deletion of already-ingested non-CodeMie data from the server (backend concern). +- Changes to `~/.codemie/sessions/` file format. +- Modifying Claude's JSONL format beyond appending a non-standard event line. + +## Acceptance Criteria Mapping + +| AC | Implementation | +|----|----------------| +| No external session ingestion | Sync suppression via `CODEMIE_SYNC_DISABLED` when user confirms external resume | +| Analytics shows only CodeMie sessions | `origin='external'` label on non-CodeMie sessions; no hidden sessions | +| Resume validates origin | `handleRun()` ownership check + warn + confirm flow | +| Non-CodeMie sessions blocked from sync | `CODEMIE_SYNC_DISABLED` env + sync processor guard | +| `.claude/projects` parsing restricted | Index + marker dual-signal filter in `native-loader.ts` | +| Audit logging | `session-origin-audit.ts` append-only log | diff --git a/docs/superpowers/tasks/2026-07-01-epmcdme-12992-session-origin-validation/gate-plan.json b/docs/superpowers/tasks/2026-07-01-epmcdme-12992-session-origin-validation/gate-plan.json new file mode 100644 index 00000000..525cac6e --- /dev/null +++ b/docs/superpowers/tasks/2026-07-01-epmcdme-12992-session-origin-validation/gate-plan.json @@ -0,0 +1,17 @@ +{ + "schema": 1, + "runner": "npm", + "gates": [ + {"id": "license", "command": "npm run license-check", "available": true}, + {"id": "lint", "command": "npm run lint", "available": true}, + {"id": "typecheck", "command": "npm run typecheck", "available": true}, + {"id": "build", "command": "npm run build", "available": true}, + {"id": "unit", "command": "npm run test:unit", "available": true}, + {"id": "integration", "command": "npm run test:integration", "available": true}, + {"id": "secrets", "command": "npm run validate:secrets", "available": true}, + {"id": "commitlint", "command": "npm run commitlint:last", "available": true}, + {"id": "ui", "command": "", "available": false} + ], + "ui_globs": ["\\.(tsx|jsx|css|html|vue|svelte)$", "src/(ui|frontend|components)/"], + "detected_at": "2026-07-01T14:02:00.000Z" +} diff --git a/docs/superpowers/tasks/2026-07-01-epmcdme-12992-session-origin-validation/qa-report.md b/docs/superpowers/tasks/2026-07-01-epmcdme-12992-session-origin-validation/qa-report.md new file mode 100644 index 00000000..818e18a5 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-01-epmcdme-12992-session-origin-validation/qa-report.md @@ -0,0 +1,29 @@ +# QA Gate Report — epmcdme-12992-session-origin-validation + +**Branch**: EPMCDME-12992 +**Runner**: npm +**Started**: 2026-07-01T14:02:00Z +**Status**: PASSED + +## Gates + +| Gate | Status | Command | Notes | +|-------------|---------|-------------------------------|-------| +| license | PASS | `npm run license-check` | Apache-2.0 headers OK; 457 MIT, 108 Apache-2.0 | +| lint | PASS | `npm run lint` | 0 errors, 0 warnings (auto-fixed: `no-control-regex` via `\p{Cc}/gu`) | +| typecheck | PASS | `npm run typecheck` | No diagnostics | +| build | PASS | `npm run build` | dist/ rebuilt; all plugin assets copied | +| unit | PASS | `npm run test:unit` | 148 files, 2194 passed, 1 skipped | +| integration | PASS | `npm run test:integration` | 27 files, 220 passed, 1 skipped | +| secrets | SKIPPED | `npm run validate:secrets` | No staged changes to scan (changes uncommitted) | +| commitlint | PASS | `npm run commitlint:last` | 0 problems, 0 warnings | +| ui | SKIPPED | — | No UI surface changed | + +## Lint auto-fix applied + +The ANSI sanitizer regex `[\x00-\x1F\x7F-\x9F]` triggered `no-control-regex`. +Replaced with `\p{Cc}/gu` (Unicode Control category — identical coverage, ESLint-clean). + +## Drift signal + +no diff --git a/docs/superpowers/work-items/EPMCDME-12992.md b/docs/superpowers/work-items/EPMCDME-12992.md new file mode 100644 index 00000000..b7998b39 --- /dev/null +++ b/docs/superpowers/work-items/EPMCDME-12992.md @@ -0,0 +1,37 @@ +# Work Item: EPMCDME-12992 + +**External Ticket**: https://jiraeu.epam.com/browse/EPMCDME-12992 +**Type**: Bug | **Priority**: Critical +**Status**: In Progress +**Assignee**: Sviatoslav Likhtarchyk +**Epic**: EPMCDME-347 +**Labels**: analytics, claude, codemie-cli, codemie_feedback, codemie_implemented, data-leakage, security +**Component**: CodeMie Backend + +## Summary +CodeMie imports unrelated Claude Code sessions from .claude/projects causing potential data leakage + +## Description +CodeMie does not reliably distinguish between Claude Code sessions belonging to CodeMie and sessions created outside of CodeMie. Users who run regular Claude sessions and then connect via CodeMie SSO see unrelated sessions in CodeMie Analytics and history. + +**Security Risk**: `codemie-claude --resume ` can ingest a non-CodeMie session into CodeMie datasets, potentially exposing customer code and conversation history. + +## Acceptance Criteria +- [ ] CodeMie does not ingest Claude Code sessions created outside CodeMie +- [ ] CodeMie Analytics shows only CodeMie-associated Claude sessions +- [ ] `codemie-claude --resume ` validates session ownership/origin before syncing data +- [ ] Non-CodeMie sessions are blocked from syncing or require explicit safe handling that does not upload data +- [ ] `.claude/projects` parsing is restricted to CodeMie-owned sessions only +- [ ] Existing unrelated test data can be removed from affected user's CodeMie account history/analytics +- [ ] Regression tests cover: CodeMie-created sessions sync, external sessions ignored, external resumed sessions not uploaded, Analytics shows no non-CodeMie sessions +- [ ] Security review confirms customer/private Claude session data cannot be accidentally ingested into CodeMie + +## Linked Artifacts +- docs/superpowers/runs/20260630-1451-main/requirements.md + +## History +| Timestamp | Event | Actor | Note | +|-----------|-------|-------|------| +| 2026-06-30T14:51:00Z | work_item.created | requirements-intake | Created from EPMCDME-12992 via brianna adapter | +| 2026-06-30T14:51:00Z | work_item.adapter_receipt | requirements-intake | Jira ticket resolved successfully via brianna | +| 2026-06-30T14:51:00Z | work_item.linked_artifact | requirements-intake | Linked requirements.md | From 4784d66e7077bc6de3db7ee67211f0efb5908c69 Mon Sep 17 00:00:00 2001 From: Sviatoslav Likhtarchyk Date: Wed, 1 Jul 2026 15:23:05 +0300 Subject: [PATCH 11/12] chore: add missing work-item-events.jsonl superpowers artifact Generated with AI Co-Authored-By: codemie-ai --- docs/superpowers/work-items/work-item-events.jsonl | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/superpowers/work-items/work-item-events.jsonl diff --git a/docs/superpowers/work-items/work-item-events.jsonl b/docs/superpowers/work-items/work-item-events.jsonl new file mode 100644 index 00000000..7d797716 --- /dev/null +++ b/docs/superpowers/work-items/work-item-events.jsonl @@ -0,0 +1,6 @@ +{"schema":1,"ts":"2026-06-30T12:06:20Z","event":"work_item.created","run_id":"20260630-1206-analytics-otel-source","phase":1,"actor":"requirements-intake","summary":"Local work item created: review-pr-384-analytics-otel-source","artifacts":["docs/superpowers/work-items/review-pr-384-analytics-otel-source.md"],"data":{"external_sync":"pending","source":"free-form"}} +{"schema":1,"ts":"2026-06-30T12:06:21Z","event":"work_item.adapter_warning","run_id":"20260630-1206-analytics-otel-source","phase":1,"actor":"requirements-intake","summary":"No Jira ticket referenced; GitHub PR #384 noted as external reference (ticket-unresolved)","artifacts":[],"data":{"adapter":"codemie-jira-assistant","reason":"no-ticket-id-in-input"}} +{"schema":1,"ts":"2026-06-30T12:06:22Z","event":"work_item.linked_artifact","run_id":"20260630-1206-analytics-otel-source","phase":1,"actor":"requirements-intake","summary":"requirements.md linked to work item","artifacts":["docs/superpowers/runs/20260630-1206-analytics-otel-source/requirements.md"],"data":{}} +{"schema":1,"ts":"2026-06-30T14:51:00Z","event":"work_item.created","run_id":"20260630-1451-main","phase":1,"actor":"requirements-intake","summary":"Canonical work item created for EPMCDME-12992","artifacts":["docs/superpowers/work-items/EPMCDME-12992.md"],"data":{"external_ticket":"https://jiraeu.epam.com/browse/EPMCDME-12992","adapter":"brianna"}} +{"schema":1,"ts":"2026-06-30T14:51:00Z","event":"work_item.adapter_receipt","run_id":"20260630-1451-main","phase":1,"actor":"requirements-intake","summary":"Jira adapter (brianna) resolved EPMCDME-12992 successfully","artifacts":[],"data":{"status":"ok","ticket":"EPMCDME-12992"}} +{"schema":1,"ts":"2026-06-30T14:51:00Z","event":"work_item.linked_artifact","run_id":"20260630-1451-main","phase":1,"actor":"requirements-intake","summary":"requirements.md linked to EPMCDME-12992","artifacts":["docs/superpowers/runs/20260630-1451-main/requirements.md"],"data":{}} From 960f127abf8e0af2bf1a5ff24ce8959b5701927f Mon Sep 17 00:00:00 2001 From: Sviatoslav Likhtarchyk Date: Wed, 1 Jul 2026 15:46:11 +0300 Subject: [PATCH 12/12] fix(analytics): show external warning badge for native-external sessions in HTML report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HTML report now renders an amber warning badge in both the sessions table ("⚠ ext" tag) and the session-detail modal header ("⚠ external — not CodeMie-managed") when session.provider is "native-external". Previously the sessions were silently shown with no visual distinction from CodeMie-owned sessions. - Fix pre-existing crash in AnalyticsAggregator.deriveTitle: guard against prompt.text being a non-string value (e.g. content-block array) to prevent "raw.replace is not a function" TypeErrors that blocked HTML report generation. Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/analytics/aggregator.ts | 2 +- src/cli/commands/analytics/report/client/app.js | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/analytics/aggregator.ts b/src/cli/commands/analytics/aggregator.ts index 14161618..d4510152 100644 --- a/src/cli/commands/analytics/aggregator.ts +++ b/src/cli/commands/analytics/aggregator.ts @@ -789,7 +789,7 @@ export class AnalyticsAggregator { } for (const prompt of prompts) { const raw = prompt?.text; - if (!raw) { + if (!raw || typeof raw !== 'string') { continue; } const cleaned = raw diff --git a/src/cli/commands/analytics/report/client/app.js b/src/cli/commands/analytics/report/client/app.js index 18403d91..2c945773 100644 --- a/src/cli/commands/analytics/report/client/app.js +++ b/src/cli/commands/analytics/report/client/app.js @@ -812,7 +812,7 @@ var promptCell = '' + esc(truncStr(s.title || '—', 80)) + ''; return [new Date(s.startTime).toISOString().slice(0, 16).replace('T', ' '), promptCell, - '' + esc(s.agentName) + '', + '' + esc(s.agentName) + '' + (s.provider === 'native-external' ? ' ⚠ ext' : ''), '' + esc(shortPath(s.project)) + '', branchCell, fmtNum(s.turns), fmtNum(s.netLines), fmtTokens(tkIn(s)), fmtTokens(tkOut(s)), fmtTokens(tkCached(s)), fmtUSD(s.costUSD)]; }), @@ -1048,7 +1048,11 @@ } htxt.appendChild(el('div', 'modal-title', esc(truncStr(firstWords(sessTitle(s), 10), 120)))); var metaBits = [s.agentName, (s.models && s.models[0]) || null, shortPath(s.project), s.branch].filter(Boolean); - htxt.appendChild(el('div', 'modal-meta', metaBits.map(function (b) { return esc(b); }).join(' · '))); + var metaHtml = metaBits.map(function (b) { return esc(b); }).join(' · '); + if (s.provider === 'native-external') { + metaHtml += ' ⚠ external — not CodeMie-managed'; + } + htxt.appendChild(el('div', 'modal-meta', metaHtml)); head.appendChild(htxt); var headBtns = el('div'); headBtns.style.cssText = 'display:flex;gap:6px;align-items:center;flex-shrink:0;'; var exportBtn = el('button', 'modal-export', '↓ JSON');