diff --git a/src/cli.ts b/src/cli.ts index c1d2308..342a9f2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -78,8 +78,12 @@ const program = new Command() .name('codeburn') .description('See where your AI coding tokens go - by task, tool, model, and project') .version(version) + .option('--verbose', 'print warnings to stderr on read failures and skipped files') -program.hook('preAction', async () => { +program.hook('preAction', async (thisCommand) => { + if (thisCommand.opts<{ verbose?: boolean }>().verbose) { + process.env['CODEBURN_VERBOSE'] = '1' + } await loadCurrency() }) diff --git a/src/context-budget.ts b/src/context-budget.ts index 3db8e95..b5c72d6 100644 --- a/src/context-budget.ts +++ b/src/context-budget.ts @@ -1,8 +1,10 @@ -import { readdir, readFile } from 'fs/promises' +import { readdir } from 'fs/promises' import { existsSync } from 'fs' import { join } from 'path' import { homedir } from 'os' +import { readSessionFile } from './fs-utils.js' + const CHARS_PER_TOKEN = 4 const SYSTEM_BASE_TOKENS = 10400 const TOOL_TOKENS_OVERHEAD = 400 @@ -23,9 +25,9 @@ function estimateTokens(text: string): number { async function readConfigFile(path: string): Promise | null> { if (!existsSync(path)) return null - try { - return JSON.parse(await readFile(path, 'utf-8')) - } catch { return null } + const raw = await readSessionFile(path) + if (raw === null) return null + try { return JSON.parse(raw) } catch { return null } } async function countMcpTools(projectPath?: string): Promise { @@ -91,10 +93,9 @@ async function scanMemoryFiles(projectPath?: string): Promise } export async function discoverProjectCwd(sessionDir: string): Promise { + let files: string[] try { - const files = (await readdir(sessionDir)).filter(f => f.endsWith('.jsonl')) - if (files.length === 0) return null - const content = await readFile(join(sessionDir, files[0]), 'utf-8') - for (const line of content.split('\n')) { - if (!line.trim()) continue - try { - const entry = JSON.parse(line) - if (entry.cwd && typeof entry.cwd === 'string') return entry.cwd - } catch { continue } - } + files = (await readdir(sessionDir)).filter(f => f.endsWith('.jsonl')) } catch { return null } + if (files.length === 0) return null + const content = await readSessionFile(join(sessionDir, files[0])) + if (content === null) return null + for (const line of content.split('\n')) { + if (!line.trim()) continue + try { + const entry = JSON.parse(line) + if (entry.cwd && typeof entry.cwd === 'string') return entry.cwd + } catch { continue } + } return null } diff --git a/src/fs-utils.ts b/src/fs-utils.ts new file mode 100644 index 0000000..49eff20 --- /dev/null +++ b/src/fs-utils.ts @@ -0,0 +1,93 @@ +import { readFile, stat } from 'fs/promises' +import { readFileSync, statSync, createReadStream } from 'fs' +import { createInterface } from 'readline' + +// Hard cap well below V8's 512 MB string limit even with split('\n') doubling. +// Stream threshold chosen as empirical breakeven between readFile+split peak +// memory and createReadStream+readline overhead for typical session files. +export const MAX_SESSION_FILE_BYTES = 128 * 1024 * 1024 +export const STREAM_THRESHOLD_BYTES = 8 * 1024 * 1024 + +function verbose(): boolean { + return process.env.CODEBURN_VERBOSE === '1' +} + +function warn(msg: string): void { + if (verbose()) process.stderr.write(`codeburn: ${msg}\n`) +} + +async function readViaStream(filePath: string): Promise { + const chunks: string[] = [] + const stream = createReadStream(filePath, { encoding: 'utf-8' }) + const rl = createInterface({ input: stream, crlfDelay: Infinity }) + for await (const line of rl) chunks.push(line) + return chunks.join('\n') +} + +export async function readSessionFile(filePath: string): Promise { + let size: number + try { + size = (await stat(filePath)).size + } catch (err) { + warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) + return null + } + + if (size > MAX_SESSION_FILE_BYTES) { + warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`) + return null + } + + try { + if (size >= STREAM_THRESHOLD_BYTES) return await readViaStream(filePath) + return await readFile(filePath, 'utf-8') + } catch (err) { + warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) + return null + } +} + +export function readSessionFileSync(filePath: string): string | null { + let size: number + try { + size = statSync(filePath).size + } catch (err) { + warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) + return null + } + + if (size > MAX_SESSION_FILE_BYTES) { + warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`) + return null + } + + try { + return readFileSync(filePath, 'utf-8') + } catch (err) { + warn(`read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) + return null + } +} + +export async function* readSessionLines(filePath: string): AsyncGenerator { + let size: number + try { + size = (await stat(filePath)).size + } catch (err) { + warn(`stat failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) + return + } + + if (size > MAX_SESSION_FILE_BYTES) { + warn(`skipped oversize file ${filePath} (${size} bytes > cap ${MAX_SESSION_FILE_BYTES})`) + return + } + + const stream = createReadStream(filePath, { encoding: 'utf-8' }) + const rl = createInterface({ input: stream, crlfDelay: Infinity }) + try { + for await (const line of rl) yield line + } catch (err) { + warn(`stream read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`) + } +} diff --git a/src/menubar.ts b/src/menubar.ts index a5abb5d..51d7e41 100644 --- a/src/menubar.ts +++ b/src/menubar.ts @@ -10,6 +10,16 @@ const PLUGIN_REFRESH = '5m' const SWIFTBAR_PREFERENCES_DOMAIN = 'com.ameba.SwiftBar' const SWIFTBAR_PLUGIN_DIRECTORY_KEY = 'PluginDirectory' +const MENUBAR_LABEL_MAX_LENGTH = 14 +const MENUBAR_LABEL_ALLOWLIST = /[^A-Za-z0-9 ._/-]/g + +// SwiftBar/xbar parse `|` as the metadata separator and interpret ANSI escapes +// on some paths. Replace anything outside a conservative allowlist with `?` +// and truncate before padEnd. +function sanitizeMenubarLabel(name: string): string { + return name.replace(MENUBAR_LABEL_ALLOWLIST, '?').slice(0, MENUBAR_LABEL_MAX_LENGTH) +} + function getSwiftBarPluginDir(): string { return join(homedir(), 'Library', 'Application Support', 'SwiftBar', 'plugins') } @@ -138,7 +148,7 @@ export function renderMenubarFormat( lines.push(`Activity - Today | size=12 color=#FF8C42`) for (const cat of today.categories.slice(0, 8)) { const bar = miniBar(cat.cost, maxCat) - const name = cat.name.padEnd(14) + const name = sanitizeMenubarLabel(cat.name).padEnd(14) lines.push(`${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`) } lines.push('---') @@ -148,7 +158,7 @@ export function renderMenubarFormat( for (const model of today.models.slice(0, 5)) { if (model.name === '') continue const bar = miniBar(model.cost, maxModel) - const name = model.name.padEnd(14) + const name = sanitizeMenubarLabel(model.name).padEnd(14) lines.push(`${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`) } @@ -164,7 +174,7 @@ export function renderMenubarFormat( lines.push(`--Activity | size=12 color=#FF8C42`) for (const cat of week.categories.slice(0, 8)) { const bar = miniBar(cat.cost, weekMaxCat) - const name = cat.name.padEnd(14) + const name = sanitizeMenubarLabel(cat.name).padEnd(14) lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`) } lines.push(`-----`) @@ -172,7 +182,7 @@ export function renderMenubarFormat( for (const model of week.models.slice(0, 5)) { if (model.name === '') continue const bar = miniBar(model.cost, weekMaxModel) - const name = model.name.padEnd(14) + const name = sanitizeMenubarLabel(model.name).padEnd(14) lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`) } @@ -182,7 +192,7 @@ export function renderMenubarFormat( lines.push(`--Activity | size=12 color=#FF8C42`) for (const cat of thirtyDays.categories.slice(0, 8)) { const bar = miniBar(cat.cost, tdMaxCat) - const name = cat.name.padEnd(14) + const name = sanitizeMenubarLabel(cat.name).padEnd(14) lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`) } lines.push(`-----`) @@ -190,7 +200,7 @@ export function renderMenubarFormat( for (const model of thirtyDays.models.slice(0, 5)) { if (model.name === '') continue const bar = miniBar(model.cost, tdMaxModel) - const name = model.name.padEnd(14) + const name = sanitizeMenubarLabel(model.name).padEnd(14) lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`) } @@ -200,7 +210,7 @@ export function renderMenubarFormat( lines.push(`--Activity | size=12 color=#FF8C42`) for (const cat of month.categories.slice(0, 8)) { const bar = miniBar(cat.cost, monthMaxCat) - const name = cat.name.padEnd(14) + const name = sanitizeMenubarLabel(cat.name).padEnd(14) lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`) } lines.push(`-----`) @@ -208,7 +218,7 @@ export function renderMenubarFormat( for (const model of month.models.slice(0, 5)) { if (model.name === '') continue const bar = miniBar(model.cost, monthMaxModel) - const name = model.name.padEnd(14) + const name = sanitizeMenubarLabel(model.name).padEnd(14) lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`) } diff --git a/src/optimize.ts b/src/optimize.ts index fa8ff8e..433cb99 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -1,9 +1,10 @@ import chalk from 'chalk' -import { readdir, readFile, stat } from 'fs/promises' -import { existsSync, readFileSync, statSync } from 'fs' +import { readdir, stat } from 'fs/promises' +import { existsSync, statSync } from 'fs' import { basename, join } from 'path' import { homedir } from 'os' +import { readSessionFile, readSessionFileSync } from './fs-utils.js' import { discoverAllSessions } from './providers/index.js' import type { DateRange, ProjectSummary } from './types.js' import { formatCost } from './currency.js' @@ -227,10 +228,8 @@ export async function scanJsonlFile( dateRange: DateRange | undefined, recentCutoffMs = Date.now() - RECENT_WINDOW_MS, ): Promise { - let content: string - try { - content = await readFile(filePath, 'utf-8') - } catch { return { calls: [], cwds: [], apiCalls: [], userMessages: [] } } + const content = await readSessionFile(filePath) + if (content === null) return { calls: [], cwds: [], apiCalls: [], userMessages: [] } const calls: ToolCall[] = [] const cwds: string[] = [] @@ -328,7 +327,9 @@ async function scanSessions(dateRange?: DateRange): Promise { // ============================================================================ function readJsonFile(path: string): Record | null { - try { return JSON.parse(readFileSync(path, 'utf-8')) } catch { return null } + const raw = readSessionFileSync(path) + if (raw === null) return null + try { return JSON.parse(raw) } catch { return null } } function shortHomePath(absPath: string): string { @@ -571,8 +572,8 @@ export function detectMissingClaudeignore(projectCwds: Set): WasteFindin function expandImports(filePath: string, seen: Set, depth: number): { totalLines: number; importedFiles: number } { if (depth > MAX_IMPORT_DEPTH || seen.has(filePath)) return { totalLines: 0, importedFiles: 0 } seen.add(filePath) - let content: string - try { content = readFileSync(filePath, 'utf-8') } catch { return { totalLines: 0, importedFiles: 0 } } + const content = readSessionFileSync(filePath) + if (content === null) return { totalLines: 0, importedFiles: 0 } let totalLines = content.split('\n').length let importedFiles = 0 @@ -865,11 +866,10 @@ function readShellProfileLimit(): number | null { for (const profile of SHELL_PROFILES) { const path = join(homedir(), profile) if (!existsSync(path)) continue - try { - const content = readFileSync(path, 'utf-8') - const match = content.match(/^\s*export\s+BASH_MAX_OUTPUT_LENGTH\s*=\s*['"]?(\d+)['"]?/m) - if (match) return parseInt(match[1], 10) - } catch { continue } + const content = readSessionFileSync(path) + if (content === null) continue + const match = content.match(/^\s*export\s+BASH_MAX_OUTPUT_LENGTH\s*=\s*['"]?(\d+)['"]?/m) + if (match) return parseInt(match[1], 10) } return null } diff --git a/src/parser.ts b/src/parser.ts index 429cb45..5ba0da9 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,5 +1,6 @@ -import { readdir, readFile } from 'fs/promises' +import { readdir } from 'fs/promises' import { basename, join } from 'path' +import { readSessionFile } from './fs-utils.js' import { calculateCost, getShortModelName } from './models.js' import { discoverAllSessions, getProvider } from './providers/index.js' import type { ParsedProviderCall } from './providers/types.js' @@ -168,10 +169,10 @@ function buildSessionSummary( project: string, turns: ClassifiedTurn[], ): SessionSummary { - const modelBreakdown: SessionSummary['modelBreakdown'] = {} - const toolBreakdown: SessionSummary['toolBreakdown'] = {} - const mcpBreakdown: SessionSummary['mcpBreakdown'] = {} - const bashBreakdown: SessionSummary['bashBreakdown'] = {} + const modelBreakdown: SessionSummary['modelBreakdown'] = Object.create(null) + const toolBreakdown: SessionSummary['toolBreakdown'] = Object.create(null) + const mcpBreakdown: SessionSummary['mcpBreakdown'] = Object.create(null) + const bashBreakdown: SessionSummary['bashBreakdown'] = Object.create(null) const categoryBreakdown: SessionSummary['categoryBreakdown'] = {} as SessionSummary['categoryBreakdown'] let totalCost = 0 @@ -265,12 +266,8 @@ async function parseSessionFile( seenMsgIds: Set, dateRange?: DateRange, ): Promise { - let content: string - try { - content = await readFile(filePath, 'utf-8') - } catch { - return null - } + const content = await readSessionFile(filePath) + if (content === null) return null const lines = content.split('\n').filter(l => l.trim()) const entries: JournalEntry[] = [] diff --git a/src/providers/codex.ts b/src/providers/codex.ts index bf64e52..01d48b7 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -1,7 +1,8 @@ -import { readdir, readFile, stat } from 'fs/promises' +import { readdir, stat } from 'fs/promises' import { basename, join } from 'path' import { homedir } from 'os' +import { readSessionFile } from '../fs-utils.js' import { calculateCost } from '../models.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' @@ -65,10 +66,11 @@ function sanitizeProject(cwd: string): string { } async function readFirstLine(filePath: string): Promise { + const content = await readSessionFile(filePath) + if (content === null) return null + const line = content.split('\n')[0] + if (!line?.trim()) return null try { - const content = await readFile(filePath, 'utf-8') - const line = content.split('\n')[0] - if (!line?.trim()) return null return JSON.parse(line) as CodexEntry } catch { return null @@ -139,13 +141,8 @@ function resolveModel(info: CodexEntry['payload'], sessionModel?: string): strin function createParser(source: SessionSource, seenKeys: Set): SessionParser { return { async *parse(): AsyncGenerator { - let content: string - try { - content = await readFile(source.path, 'utf-8') - } catch { - return - } - + const content = await readSessionFile(source.path) + if (content === null) return const lines = content.split('\n').filter(l => l.trim()) let sessionModel: string | undefined let sessionId = '' diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index 9cb7983..f32738f 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -1,7 +1,8 @@ -import { readdir, readFile, stat } from 'fs/promises' +import { readdir, stat } from 'fs/promises' import { basename, dirname, join } from 'path' import { homedir } from 'os' +import { readSessionFile } from '../fs-utils.js' import { calculateCost } from '../models.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' @@ -85,13 +86,8 @@ function parseCwd(yaml: string): string | null { function createParser(source: SessionSource, seenKeys: Set): SessionParser { return { async *parse(): AsyncGenerator { - let content: string - try { - content = await readFile(source.path, 'utf-8') - } catch { - return - } - + const content = await readSessionFile(source.path) + if (content === null) return const sessionId = basename(dirname(source.path)) const lines = content.split('\n').filter(l => l.trim()) let currentModel = '' @@ -177,11 +173,11 @@ async function discoverSessionsInDir(sessionStateDir: string): Promise { + const content = await readSessionFile(filePath) + if (content === null) return null + const line = content.split('\n')[0] + if (!line?.trim()) return null try { - const content = await readFile(filePath, 'utf-8') - const line = content.split('\n')[0] - if (!line?.trim()) return null return JSON.parse(line) as PiEntry } catch { return null @@ -108,13 +110,8 @@ async function discoverSessionsInDir(sessionsDir: string): Promise): SessionParser { return { async *parse(): AsyncGenerator { - let content: string - try { - content = await readFile(source.path, 'utf-8') - } catch { - return - } - + const content = await readSessionFile(source.path) + if (content === null) return const lines = content.split('\n').filter(l => l.trim()) let sessionId = basename(source.path, '.jsonl') let pendingUserMessage = '' diff --git a/tests/fixtures/security/proto-bash.jsonl b/tests/fixtures/security/proto-bash.jsonl new file mode 100644 index 0000000..8e6b5e7 --- /dev/null +++ b/tests/fixtures/security/proto-bash.jsonl @@ -0,0 +1 @@ +{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-bash","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"b1","name":"Bash","input":{"command":"/x/__proto__"}}],"usage":{"input_tokens":1,"output_tokens":1}}} diff --git a/tests/fixtures/security/proto-model.jsonl b/tests/fixtures/security/proto-model.jsonl new file mode 100644 index 0000000..0aabf71 --- /dev/null +++ b/tests/fixtures/security/proto-model.jsonl @@ -0,0 +1 @@ +{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-model","type":"message","role":"assistant","model":"__proto__","content":[{"type":"text","text":"x"}],"usage":{"input_tokens":1,"output_tokens":1}}} diff --git a/tests/fixtures/security/proto-tool.jsonl b/tests/fixtures/security/proto-tool.jsonl new file mode 100644 index 0000000..a93a853 --- /dev/null +++ b/tests/fixtures/security/proto-tool.jsonl @@ -0,0 +1 @@ +{"type":"assistant","sessionId":"security-test","timestamp":"2026-04-16T00:00:00Z","message":{"id":"pwn-tool","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"t1","name":"__proto__","input":{}}],"usage":{"input_tokens":1,"output_tokens":1}}} diff --git a/tests/fs-utils.test.ts b/tests/fs-utils.test.ts new file mode 100644 index 0000000..7c5f38b --- /dev/null +++ b/tests/fs-utils.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { mkdtemp, writeFile, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { + MAX_SESSION_FILE_BYTES, + STREAM_THRESHOLD_BYTES, + readSessionFile, +} from '../src/fs-utils.js' + +describe('readSessionFile', () => { + const tmpDirs: string[] = [] + + afterEach(async () => { + delete process.env.CODEBURN_VERBOSE + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } + }) + + async function tmpPath(content: string | Buffer): Promise { + const base = await mkdtemp(join(tmpdir(), 'codeburn-fs-')) + tmpDirs.push(base) + const p = join(base, 'x.jsonl') + await writeFile(p, content) + return p + } + + it('returns content for small files via readFile fast path', async () => { + const p = await tmpPath('hello\nworld\n') + expect(await readSessionFile(p)).toBe('hello\nworld\n') + }) + + it('returns content for files at the stream threshold via stream path', async () => { + const p = await tmpPath(Buffer.alloc(STREAM_THRESHOLD_BYTES, 'a')) + const got = await readSessionFile(p) + expect(got).not.toBeNull() + expect(got!.length).toBe(STREAM_THRESHOLD_BYTES) + }) + + it('returns null and skips files over the cap', async () => { + const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'b')) + expect(await readSessionFile(p)).toBeNull() + }) + + it('emits stderr warning under CODEBURN_VERBOSE=1 for skipped file', async () => { + process.env.CODEBURN_VERBOSE = '1' + const p = await tmpPath(Buffer.alloc(MAX_SESSION_FILE_BYTES + 1, 'c')) + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + await readSessionFile(p) + expect(spy).toHaveBeenCalled() + const msg = (spy.mock.calls[0][0] as string) + expect(msg).toContain('codeburn') + expect(msg).toContain('oversize') + spy.mockRestore() + }) + + it('returns null on stat failure without throwing', async () => { + expect(await readSessionFile('/nonexistent/path/x.jsonl')).toBeNull() + }) +}) diff --git a/tests/security/menubar-injection.test.ts b/tests/security/menubar-injection.test.ts new file mode 100644 index 0000000..d8de96d --- /dev/null +++ b/tests/security/menubar-injection.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' + +import { renderMenubarFormat, type PeriodData } from '../../src/menubar.js' + +const ESC = '\u001b' + +function period(name: string): PeriodData { + return { + label: 'x', + cost: 0.01, + calls: 1, + inputTokens: 1, + outputTokens: 1, + cacheReadTokens: 0, + cacheWriteTokens: 0, + categories: [{ name, cost: 0.01, turns: 1, editTurns: 0, oneShotTurns: 1 }], + models: [{ name, cost: 0.01, calls: 1 }], + } +} + +function linesWithToken(output: string, token: string): string[] { + return output.split('\n').filter(l => l.includes(token)) +} + +describe('MEDIUM-2 menubar directive separator injection', () => { + it('strips pipe separators from model names', () => { + const p = period('foo | href=https://attacker.example/pwn') + const out = renderMenubarFormat(p, p, p, p) + for (const line of linesWithToken(out, 'foo')) { + expect(line.split('|').length).toBeLessThanOrEqual(2) + } + }) + + it('strips ANSI escapes from model names', () => { + const p = period(`foo${ESC}[31mMODEL${ESC}[0m`) + const out = renderMenubarFormat(p, p, p, p) + expect(out).not.toContain(ESC) + }) + + it('strips pipe separators from category names', () => { + const p = period('cat | color=red') + const out = renderMenubarFormat(p, p, p, p) + for (const line of linesWithToken(out, 'cat')) { + expect(line.split('|').length).toBeLessThanOrEqual(2) + } + }) +}) diff --git a/tests/security/prototype-pollution.test.ts b/tests/security/prototype-pollution.test.ts new file mode 100644 index 0000000..6b6075c --- /dev/null +++ b/tests/security/prototype-pollution.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, afterEach, beforeEach } from 'vitest' +import { mkdtemp, mkdir, cp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { parseAllSessions } from '../../src/parser.js' +import type { DateRange } from '../../src/types.js' + +// Fixtures carry timestamp 2026-04-16T00:00:00Z. The range below must stay +// wide enough to include that date; if the fixtures move, move FIXTURE_DAY too. +const FIXTURE_DAY = Date.UTC(2026, 3, 16) // month index 3 = April (Date.UTC is 0-indexed) +const RANGE_BEFORE_MS = FIXTURE_DAY - 24 * 60 * 60 * 1000 +const RANGE_AFTER_MS = FIXTURE_DAY + 24 * 60 * 60 * 1000 +const PROJECT_NAME = 'codeburn-poc-testing' + +function makeRange(offsetMs: number): DateRange { + return { + start: new Date(RANGE_BEFORE_MS + offsetMs), + end: new Date(RANGE_AFTER_MS + offsetMs), + } +} + +// Hermeticity note: the Claude provider also scans a fixed Desktop sessions +// dir independent of CLAUDE_CONFIG_DIR. The narrow dateRange above excludes +// any real sessions in practice, but these tests are not strictly isolated +// on a machine with April 2026 Claude Desktop activity. A stricter fix +// belongs in a follow-up to discoverSessions itself. + +describe('HIGH-1 prototype pollution via unchecked bracket-assign', () => { + const tmpDirs: string[] = [] + let originalConfigDir: string | undefined + + beforeEach(() => { + originalConfigDir = process.env['CLAUDE_CONFIG_DIR'] + }) + + afterEach(async () => { + delete (Object.prototype as Record).calls + if (originalConfigDir === undefined) { + delete process.env['CLAUDE_CONFIG_DIR'] + } else { + process.env['CLAUDE_CONFIG_DIR'] = originalConfigDir + } + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } + }) + + async function setupPoc(fixture: string): Promise { + const base = await mkdtemp(join(tmpdir(), 'codeburn-sec-')) + tmpDirs.push(base) + const projectDir = join(base, 'projects', PROJECT_NAME) + await mkdir(projectDir, { recursive: true }) + await cp(join(__dirname, '..', 'fixtures', 'security', fixture), join(projectDir, 'pwn.jsonl')) + process.env['CLAUDE_CONFIG_DIR'] = base + return base + } + + it('does not pollute Object.prototype when session contains tool_use name "__proto__"', async () => { + await setupPoc('proto-tool.jsonl') + await expect(parseAllSessions(makeRange(0), 'claude')).resolves.not.toThrow() + expect(({} as Record).calls).toBeUndefined() + }) + + it('does not pollute Object.prototype when bash command basename is "__proto__"', async () => { + await setupPoc('proto-bash.jsonl') + await expect(parseAllSessions(makeRange(1), 'claude')).resolves.not.toThrow() + expect(({} as Record).calls).toBeUndefined() + }) + + it('does not pollute Object.prototype when model name is "__proto__"', async () => { + await setupPoc('proto-model.jsonl') + await expect(parseAllSessions(makeRange(2), 'claude')).resolves.not.toThrow() + expect(({} as Record).calls).toBeUndefined() + }) +})