From 326677e848907ae34294bc4fb07aae65d2b1fcc4 Mon Sep 17 00:00:00 2001 From: sean10 Date: Mon, 8 Jun 2026 18:01:10 +0800 Subject: [PATCH 1/2] feat(kiro): support new file-based session format (~May 2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kiro CLI moved to per-session files under ~/.kiro/sessions/cli/: .json — metadata (session_id, cwd, title, created_at, updated_at) .jsonl — events: Prompt / AssistantMessage / ToolResults - Add KIRO_SESSIONS_DIR + scanKiroCliSessions() and loadKiroCliDetail() - Index the .jsonl files in _buildSessionFileIndex so they resolve to { format: 'kiro-cli' }, wiring detail/preview/search/replay/export end-to-end (previously listed but "Session file not found" on open) - Validate the sessionId as a strict UUID in loadKiroCliDetail before path.join to close a path-traversal vector on untrusted input - Add fixture-based tests covering scan, detail, path-traversal rejection, index resolution, and end-to-end wiring Old SQLite-based Kiro sessions remain fully supported alongside. --- src/data.js | 137 +++++++++++++++++++++++++++++++++ test/kiro-cli-session.test.js | 141 ++++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 test/kiro-cli-session.test.js diff --git a/src/data.js b/src/data.js index 601599b..ddbb749 100644 --- a/src/data.js +++ b/src/data.js @@ -164,6 +164,7 @@ const OMP_AGENT_DIR = process.env.OMP_CODING_AGENT_DIR || path.join(ALL_HOMES[0] const PI_SESSIONS_DIR = path.join(PI_AGENT_DIR, 'sessions'); const OMP_SESSIONS_DIR = path.join(OMP_AGENT_DIR, 'sessions'); const KIRO_DB = path.join(ALL_HOMES[0], 'Library', 'Application Support', 'kiro-cli', 'data.sqlite3'); +const KIRO_SESSIONS_DIR = path.join(ALL_HOMES[0], '.kiro', 'sessions', 'cli'); const COPILOT_SESSION_DIR = path.join(ALL_HOMES[0], '.copilot', 'session-state'); const COPILOT_JB_DIR = path.join(ALL_HOMES[0], '.copilot', 'jb'); const KILO_DB = path.join(ALL_HOMES[0], '.local', 'share', 'kilo', 'kilo.db'); @@ -1837,6 +1838,89 @@ function loadKiroDetail(conversationId) { } } +// ── Kiro CLI (new format: ~/.kiro/sessions/cli/, since ~May 2026) ───────────── + +function scanKiroCliSessions() { + const sessions = []; + if (!fs.existsSync(KIRO_SESSIONS_DIR)) return sessions; + + let files; + try { files = fs.readdirSync(KIRO_SESSIONS_DIR); } catch { return sessions; } + + for (const f of files) { + if (!f.endsWith('.json')) continue; + const sessionId = f.slice(0, -5); + // skip if not a strict UUID name + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) continue; + + try { + const meta = JSON.parse(fs.readFileSync(path.join(KIRO_SESSIONS_DIR, f), 'utf8')); + const createdMs = meta.created_at ? new Date(meta.created_at).getTime() : 0; + const updatedMs = meta.updated_at ? new Date(meta.updated_at).getTime() : 0; + const jsonlPath = path.join(KIRO_SESSIONS_DIR, sessionId + '.jsonl'); + const fileSize = fs.existsSync(jsonlPath) ? fs.statSync(jsonlPath).size : 0; + + sessions.push({ + id: sessionId, + tool: 'kiro', + format: 'kiro-cli', + project: meta.cwd || '', + project_short: (meta.cwd || '').replace(os.homedir(), '~'), + first_ts: createdMs || Date.now(), + last_ts: updatedMs || Date.now(), + messages: fileSize > 0 ? Math.max(2, Math.floor(fileSize / 3000)) : 0, + first_message: meta.title || '', + has_detail: fs.existsSync(jsonlPath), + file_size: fileSize, + detail_messages: 0, + }); + } catch {} + } + + return sessions; +} + +function loadKiroCliDetail(sessionId) { + // sessionId is untrusted here (resolved from a request param) — require a + // strict UUID before building the path to close a path-traversal vector. + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) { + return { messages: [] }; + } + const jsonlPath = path.join(KIRO_SESSIONS_DIR, sessionId + '.jsonl'); + if (!fs.existsSync(jsonlPath)) return { messages: [] }; + + const messages = []; + try { + const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + + const { kind, data } = entry; + if (!data) continue; + + if (kind === 'Prompt') { + // data.content is array of {kind, data} blocks + const text = (data.content || []) + .filter(b => b.kind === 'text') + .map(b => b.data || '') + .join('').trim(); + if (text) messages.push({ role: 'user', content: text.slice(0, 2000), uuid: data.message_id || '' }); + + } else if (kind === 'AssistantMessage') { + const text = (data.content || []) + .filter(b => b.kind === 'text') + .map(b => b.data || '') + .join('').trim(); + if (text) messages.push({ role: 'assistant', content: text.slice(0, 2000), uuid: data.message_id || '' }); + } + } + } catch {} + + return { messages: messages.slice(0, 200) }; +} + // ── Copilot Chat (VS Code extension) ───────────────────────── // Build workspace-hash -> project path mapping for VS Code workspaceStorage @@ -3468,6 +3552,14 @@ function loadSessions() { } } catch {} + // Load Kiro CLI sessions (new format: ~/.kiro/sessions/cli/, since ~May 2026) + try { + const kiroCliSessions = scanKiroCliSessions(); + for (const ks of kiroCliSessions) { + sessions[ks.id] = ks; + } + } catch {} + // Load Copilot CLI sessions try { const copilotSessions = scanCopilotCliSessions(); @@ -3737,6 +3829,9 @@ function loadSessionDetail(sessionId, project) { if (found.format === 'kiro') { return loadKiroDetail(sessionId); } + if (found.format === 'kiro-cli') { + return loadKiroCliDetail(sessionId); + } // Copilot CLI uses JSONL events if (found.format === 'copilot') { @@ -3972,6 +4067,7 @@ function exportSessionMarkdown(sessionId, project) { found.format === 'cursor' ? loadCursorDetail(sessionId) : found.format === 'opencode' ? loadOpenCodeDetail(sessionId) : found.format === 'kiro' ? loadKiroDetail(sessionId) : + found.format === 'kiro-cli' ? loadKiroCliDetail(sessionId) : found.format === 'kilo' ? loadKiloCliDetail(sessionId) : found.format === 'qwen' ? loadQwenDetail(sessionId, found.file) : found.format === 'pi' ? loadPiDetail(sessionId, found.file) : @@ -4117,6 +4213,20 @@ function _buildSessionFileIndex() { } catch {} } + // Index Kiro CLI file-based sessions (~/.kiro/sessions/cli/, since ~May 2026) + if (fs.existsSync(KIRO_SESSIONS_DIR)) { + try { + for (const f of fs.readdirSync(KIRO_SESSIONS_DIR)) { + if (!f.endsWith('.jsonl')) continue; + const sid = f.slice(0, -6); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sid)) continue; + if (!_sessionFileIndex[sid]) { + _sessionFileIndex[sid] = { file: path.join(KIRO_SESSIONS_DIR, f), format: 'kiro-cli', sessionId: sid }; + } + } + } catch {} + } + _sessionFileIndexTs = now; } @@ -4621,6 +4731,12 @@ function getSessionPreview(sessionId, project, limit) { return { role: m.role, content: m.content.slice(0, 300) }; }); } + if (found.format === 'kiro-cli') { + var detail = loadKiroCliDetail(sessionId); + return detail.messages.slice(0, limit).map(function(m) { + return { role: m.role, content: m.content.slice(0, 300) }; + }); + } // OpenCode: use loadOpenCodeDetail and slice if (found.format === 'opencode') { @@ -4753,6 +4869,13 @@ function buildSearchIndex(sessions) { texts.push({ role: msg.role, content: msg.content.slice(0, 500) }); } } + } else if (found.format === 'kiro-cli') { + const detail = loadKiroCliDetail(s.id); + for (const msg of detail.messages) { + if (msg.content && !isSystemMessage(msg.content)) { + texts.push({ role: msg.role, content: msg.content.slice(0, 500) }); + } + } } else if (found.format === 'cursor') { const detail = loadCursorDetail(s.id); for (const msg of detail.messages) { @@ -4909,6 +5032,18 @@ function getSessionReplay(sessionId, project) { }); } } + } else if (found.format === 'kiro-cli') { + const detail = loadKiroCliDetail(sessionId); + for (const msg of detail.messages) { + if (msg.content && !isSystemMessage(msg.content)) { + messages.push({ + role: msg.role, + content: msg.content.slice(0, 3000), + timestamp: 0, + ms: 0, + }); + } + } } else if (found.format === 'cursor') { const detail = loadCursorDetail(sessionId); for (const msg of detail.messages) { @@ -6393,5 +6528,7 @@ module.exports = { findPiSessionByResumeTarget, _sessionsNeedRescan, _updateScanMarkers, + scanKiroCliSessions, + loadKiroCliDetail, }, }; diff --git a/test/kiro-cli-session.test.js b/test/kiro-cli-session.test.js new file mode 100644 index 0000000..37ffd92 --- /dev/null +++ b/test/kiro-cli-session.test.js @@ -0,0 +1,141 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Reload src/data with os.homedir() pointed at a temp home so KIRO_SESSIONS_DIR +// (~/.kiro/sessions/cli) resolves inside the fixture. +function freshDataWithHome(home) { + const dataPath = require.resolve('../src/data'); + const handoffPath = require.resolve('../src/handoff'); + delete require.cache[handoffPath]; + delete require.cache[dataPath]; + const oldHome = os.homedir; + os.homedir = () => home; + try { + return require('../src/data'); + } finally { + os.homedir = oldHome; + } +} + +function tmpHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'codbash-kiro-')); +} + +// Write a Kiro CLI session pair: .json metadata + .jsonl events. +function writeKiroCliSession(home, sessionId, meta, events) { + const dir = path.join(home, '.kiro', 'sessions', 'cli'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify(meta)); + fs.writeFileSync( + path.join(dir, sessionId + '.jsonl'), + events.map(e => JSON.stringify(e)).join('\n') + '\n' + ); +} + +const UUID = '12345678-90ab-cdef-1234-567890abcdef'; + +function sampleMeta(cwd) { + return { + session_id: UUID, + cwd, + title: 'Fix the parser', + created_at: '2026-05-24T10:00:00.000Z', + updated_at: '2026-05-24T10:05:00.000Z', + }; +} + +function sampleEvents() { + return [ + { version: 1, kind: 'Prompt', data: { message_id: 'u1', content: [{ kind: 'text', data: 'Please fix the parser' }] } }, + { version: 1, kind: 'AssistantMessage', data: { message_id: 'a1', content: [{ kind: 'text', data: 'Parser fixed' }] } }, + { version: 1, kind: 'ToolResults', data: { results: [{ ok: true }] } }, + ]; +} + +test('scanKiroCliSessions reads metadata files into session summaries', () => { + const home = tmpHome(); + writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents()); + + const data = freshDataWithHome(home); + const sessions = data.__test.scanKiroCliSessions(); + + assert.equal(sessions.length, 1); + assert.equal(sessions[0].id, UUID); + assert.equal(sessions[0].tool, 'kiro'); + assert.equal(sessions[0].format, 'kiro-cli'); + assert.equal(sessions[0].project, '/tmp/project'); + assert.equal(sessions[0].first_message, 'Fix the parser'); + assert.equal(sessions[0].has_detail, true); + assert.equal(sessions[0].first_ts, Date.parse('2026-05-24T10:00:00.000Z')); + assert.equal(sessions[0].last_ts, Date.parse('2026-05-24T10:05:00.000Z')); +}); + +test('scanKiroCliSessions ignores non-UUID metadata files', () => { + const home = tmpHome(); + const dir = path.join(home, '.kiro', 'sessions', 'cli'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'not-a-uuid.json'), JSON.stringify({ title: 'nope' })); + + const data = freshDataWithHome(home); + assert.deepEqual(data.__test.scanKiroCliSessions(), []); +}); + +test('loadKiroCliDetail parses Prompt/AssistantMessage and skips ToolResults', () => { + const home = tmpHome(); + writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents()); + + const data = freshDataWithHome(home); + const detail = data.__test.loadKiroCliDetail(UUID); + + assert.equal(detail.messages.length, 2); + assert.deepEqual(detail.messages.map(m => m.role), ['user', 'assistant']); + assert.equal(detail.messages[0].content, 'Please fix the parser'); + assert.equal(detail.messages[1].content, 'Parser fixed'); +}); + +test('loadKiroCliDetail rejects path-traversal ids before touching the filesystem', () => { + const home = tmpHome(); + writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents()); + + const data = freshDataWithHome(home); + // A crafted id would resolve outside KIRO_SESSIONS_DIR without the UUID guard. + assert.deepEqual(data.__test.loadKiroCliDetail('../../../../etc/passwd'), { messages: [] }); + assert.deepEqual(data.__test.loadKiroCliDetail('..%2f..%2fsecret'), { messages: [] }); + assert.deepEqual(data.__test.loadKiroCliDetail(''), { messages: [] }); +}); + +test('findSessionFile resolves file-based Kiro sessions to the kiro-cli format', () => { + const home = tmpHome(); + writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents()); + + const data = freshDataWithHome(home); + const found = data.findSessionFile(UUID, '/tmp/project'); + + assert.ok(found, 'expected findSessionFile to resolve the kiro-cli session'); + assert.equal(found.format, 'kiro-cli'); + assert.equal(found.sessionId, UUID); + assert.match(found.file, /\.kiro[\/\\]sessions[\/\\]cli[\/\\]/); +}); + +test('detail, preview, search, replay, and export are wired end-to-end for kiro-cli', () => { + const home = tmpHome(); + writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents()); + + const data = freshDataWithHome(home); + + const detail = data.loadSessionDetail(UUID, '/tmp/project'); + assert.deepEqual(detail.messages.map(m => m.content), ['Please fix the parser', 'Parser fixed']); + + const preview = data.getSessionPreview(UUID, '/tmp/project', 10); + assert.deepEqual(preview.map(m => m.content), ['Please fix the parser', 'Parser fixed']); + + const replay = data.getSessionReplay(UUID, '/tmp/project'); + assert.deepEqual(replay.messages.map(m => m.content), ['Please fix the parser', 'Parser fixed']); + + const md = data.exportSessionMarkdown(UUID, '/tmp/project'); + assert.match(md, /Please fix the parser/); + assert.match(md, /Parser fixed/); +}); From 16e14ef3667fe8735aa0f84f346c6dda391fbd62 Mon Sep 17 00:00:00 2001 From: sean10 Date: Mon, 27 Jul 2026 14:46:34 +0800 Subject: [PATCH 2/2] fix(kiro): reject symlinked session files before reading (security review) The UUID check blocks ../ path traversal, but readFileSync still followed symlinks: a symlink named .jsonl (or .json) inside ~/.kiro/sessions/cli/ pointing at e.g. ~/.ssh/id_rsa would be read and exposed via the dashboard (detail/export/search/replay), especially when codbash binds to a LAN address. Guard both read sites with lstat + isSymbolicLink, matching the existing Claude reader (src/data.js: `if (entry.isSymbolicLink()) continue;`): - scanKiroCliSessions: readdir withFileTypes skips symlinked metadata; the .jsonl is lstat'd so a symlinked events file reports has_detail:false - loadKiroCliDetail: lstat the .jsonl and refuse non-regular files - _buildSessionFileIndex: skip symlinked .jsonl for defense-in-depth Tests craft UUID-named symlinks to out-of-tree files with content that would surface if followed (LEAKED-TITLE / LEAKED-CONTENT); verified they fail with the guards removed and pass with them in place. --- src/data.js | 27 ++++++++++++---- test/kiro-cli-session.test.js | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/src/data.js b/src/data.js index ddbb749..ef08b57 100644 --- a/src/data.js +++ b/src/data.js @@ -1845,9 +1845,13 @@ function scanKiroCliSessions() { if (!fs.existsSync(KIRO_SESSIONS_DIR)) return sessions; let files; - try { files = fs.readdirSync(KIRO_SESSIONS_DIR); } catch { return sessions; } + try { files = fs.readdirSync(KIRO_SESSIONS_DIR, { withFileTypes: true }); } catch { return sessions; } - for (const f of files) { + for (const entry of files) { + const f = entry.name; + // Reject symlinks so a crafted .json link can't leak an out-of-tree + // file through the dashboard (matches the Claude reader's symlink guard). + if (entry.isSymbolicLink() || !entry.isFile()) continue; if (!f.endsWith('.json')) continue; const sessionId = f.slice(0, -5); // skip if not a strict UUID name @@ -1858,7 +1862,12 @@ function scanKiroCliSessions() { const createdMs = meta.created_at ? new Date(meta.created_at).getTime() : 0; const updatedMs = meta.updated_at ? new Date(meta.updated_at).getTime() : 0; const jsonlPath = path.join(KIRO_SESSIONS_DIR, sessionId + '.jsonl'); - const fileSize = fs.existsSync(jsonlPath) ? fs.statSync(jsonlPath).size : 0; + // Only trust a regular (non-symlink) events file; a symlinked .jsonl + // would otherwise be followed by loadKiroCliDetail's readFileSync. + let jsonlStat = null; + try { jsonlStat = fs.lstatSync(jsonlPath); } catch {} + const hasDetail = !!(jsonlStat && jsonlStat.isFile()); + const fileSize = hasDetail ? jsonlStat.size : 0; sessions.push({ id: sessionId, @@ -1870,7 +1879,7 @@ function scanKiroCliSessions() { last_ts: updatedMs || Date.now(), messages: fileSize > 0 ? Math.max(2, Math.floor(fileSize / 3000)) : 0, first_message: meta.title || '', - has_detail: fs.existsSync(jsonlPath), + has_detail: hasDetail, file_size: fileSize, detail_messages: 0, }); @@ -1887,7 +1896,11 @@ function loadKiroCliDetail(sessionId) { return { messages: [] }; } const jsonlPath = path.join(KIRO_SESSIONS_DIR, sessionId + '.jsonl'); - if (!fs.existsSync(jsonlPath)) return { messages: [] }; + // Reject symlinks (and non-regular files) before reading so a crafted link + // named .jsonl can't leak an out-of-tree file through the dashboard. + let jsonlStat; + try { jsonlStat = fs.lstatSync(jsonlPath); } catch { return { messages: [] }; } + if (!jsonlStat.isFile()) return { messages: [] }; const messages = []; try { @@ -4216,7 +4229,9 @@ function _buildSessionFileIndex() { // Index Kiro CLI file-based sessions (~/.kiro/sessions/cli/, since ~May 2026) if (fs.existsSync(KIRO_SESSIONS_DIR)) { try { - for (const f of fs.readdirSync(KIRO_SESSIONS_DIR)) { + for (const entry of fs.readdirSync(KIRO_SESSIONS_DIR, { withFileTypes: true })) { + if (entry.isSymbolicLink() || !entry.isFile()) continue; + const f = entry.name; if (!f.endsWith('.jsonl')) continue; const sid = f.slice(0, -6); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sid)) continue; diff --git a/test/kiro-cli-session.test.js b/test/kiro-cli-session.test.js index 37ffd92..49735cd 100644 --- a/test/kiro-cli-session.test.js +++ b/test/kiro-cli-session.test.js @@ -107,6 +107,65 @@ test('loadKiroCliDetail rejects path-traversal ids before touching the filesyste assert.deepEqual(data.__test.loadKiroCliDetail(''), { messages: [] }); }); +test('symlinked session files are not followed by scan or detail', () => { + const home = tmpHome(); + const dir = path.join(home, '.kiro', 'sessions', 'cli'); + fs.mkdirSync(dir, { recursive: true }); + + // Targets living OUTSIDE KIRO_SESSIONS_DIR whose content WOULD surface in the + // dashboard if the symlinks were followed — this is what makes the test bite. + const outsideMeta = path.join(home, 'outside-meta.json'); + fs.writeFileSync(outsideMeta, JSON.stringify({ title: 'LEAKED-TITLE', cwd: '/secret' })); + const outsideEvents = path.join(home, 'outside-events.jsonl'); + fs.writeFileSync(outsideEvents, JSON.stringify( + { version: 1, kind: 'Prompt', data: { message_id: 'x', content: [{ kind: 'text', data: 'LEAKED-CONTENT' }] } } + ) + '\n'); + + // Craft UUID-named symlinks pointing at those out-of-tree files. + const linkUuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + fs.symlinkSync(outsideMeta, path.join(dir, linkUuid + '.json')); + fs.symlinkSync(outsideEvents, path.join(dir, linkUuid + '.jsonl')); + + const data = freshDataWithHome(home); + + // Scanner must not surface the symlinked session (nor its leaked title). + const scanned = data.__test.scanKiroCliSessions(); + assert.equal(scanned.some(s => s.id === linkUuid), false); + assert.equal(scanned.some(s => s.first_message === 'LEAKED-TITLE'), false); + + // Detail loader must refuse to read through the symlinked .jsonl even though + // its target is a perfectly valid events file. + const detail = data.__test.loadKiroCliDetail(linkUuid); + assert.deepEqual(detail, { messages: [] }); +}); + +test('a real .json with a symlinked .jsonl reports no detail and leaks nothing', () => { + const home = tmpHome(); + const dir = path.join(home, '.kiro', 'sessions', 'cli'); + fs.mkdirSync(dir, { recursive: true }); + + // Out-of-tree events file with content that WOULD surface if followed. + const outsideEvents = path.join(home, 'outside-events.jsonl'); + fs.writeFileSync(outsideEvents, JSON.stringify( + { version: 1, kind: 'Prompt', data: { message_id: 'x', content: [{ kind: 'text', data: 'LEAKED-CONTENT' }] } } + ) + '\n'); + + // Genuine metadata file, but the events file is a symlink to the out-of-tree file. + fs.writeFileSync(path.join(dir, UUID + '.json'), JSON.stringify(sampleMeta('/tmp/project'))); + fs.symlinkSync(outsideEvents, path.join(dir, UUID + '.jsonl')); + + const data = freshDataWithHome(home); + + const scanned = data.__test.scanKiroCliSessions(); + const s = scanned.find(x => x.id === UUID); + assert.ok(s, 'metadata-only session should still be listed'); + assert.equal(s.has_detail, false); + assert.equal(s.file_size, 0); + + const detail = data.__test.loadKiroCliDetail(UUID); + assert.deepEqual(detail, { messages: [] }); +}); + test('findSessionFile resolves file-based Kiro sessions to the kiro-cli format', () => { const home = tmpHome(); writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents());