Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ const rendererCrossFileGlobals = {
// Third-party renderer libs loaded as <script>
morphdom: 'readonly',
marked: 'readonly',
DOMPurify: 'readonly',
ViewerPanel: 'readonly',

// Switchboard preload bridge
Expand Down
77 changes: 77 additions & 0 deletions ipc-path-validator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// ipc-path-validator.js — path validation helpers for IPC file-access handlers.
//
// Two layers of defense:
// 1. isSensitivePath — denylist for well-known credential / secret files.
// Used by the file-panel handlers (read-file-for-panel, save-file-for-panel,
// watch-file) which intentionally accept arbitrary project paths (OSC 8
// hyperlinks from terminal output). Blocking a static denylist is lighter
// than allowlisting because the legitimate surface is unbounded.
//
// 2. isAllowedMemoryPath — strict allowlist for memory/plan handlers
// (read-memory, save-memory) that should only touch ~/.claude/ or active
// project directories.
//
// Both helpers resolve the incoming path (path.resolve) before comparing, so
// ../traversal sequences are normalised before any check fires.

'use strict';

const os = require('os');
const path = require('path');

const CLAUDE_DIR = path.join(os.homedir(), '.claude');

// Patterns matching well-known credential / secret file locations.
// A match on the resolved absolute path blocks the operation.
const SENSITIVE_PATH_PATTERNS = [
/[/\\]\.ssh[/\\]/i,
/[/\\]\.gnupg[/\\]/i,
/[/\\]\.aws[/\\]credentials$/i,
/[/\\]\.env$/i,
/[/\\]\.env\.local$/i,
/[/\\]\.netrc$/i,
/[/\\]\.docker[/\\]config\.json$/i,
/[/\\]\.kube[/\\]config$/i,
];

/**
* Returns true when `filePath` resolves to a sensitive credential location.
*
* @param {string} filePath - Absolute or relative path from the renderer.
* @returns {boolean}
*/
function isSensitivePath(filePath) {
const resolved = path.resolve(filePath);
return SENSITIVE_PATH_PATTERNS.some(pattern => pattern.test(resolved));
}

/**
* Returns true when `filePath` is allowed for memory/plan read-write operations.
*
* Allowed roots:
* - ~/.claude/ (and ~/.claude itself)
* - any path in `activeProjectPaths`
*
* @param {string} filePath - Absolute or relative path from the renderer.
* @param {string[]} activeProjectPaths - Array of active project root paths.
* @returns {boolean}
*/
function isAllowedMemoryPath(filePath, activeProjectPaths) {
const resolved = path.resolve(filePath);

// Must start with CLAUDE_DIR + sep, or equal CLAUDE_DIR exactly.
if (resolved === CLAUDE_DIR || resolved.startsWith(CLAUDE_DIR + path.sep)) {
return true;
}

// Must start with an active project path + sep (strict prefix, not a substring).
for (const projectPath of activeProjectPaths) {
if (projectPath && resolved.startsWith(projectPath + path.sep)) {
return true;
}
}

return false;
}

module.exports = { isSensitivePath, isAllowedMemoryPath };
52 changes: 48 additions & 4 deletions main.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, shell } = require('electron');
const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, session, shell } = require('electron');
const { Worker } = require('worker_threads');

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
Expand All @@ -16,7 +16,7 @@
}

// getFolderIndexMtimeMs moved to session-cache.js
const { startMcpServer, shutdownMcpServer, shutdownAll: shutdownAllMcp, resolvePendingDiff, rekeyMcpServer, cleanStaleLockFiles } = require('./mcp-bridge');

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { fetchAndTransformUsage } = require('./claude-auth');
log.transports.file.level = app.isPackaged ? 'info' : 'debug';
log.transports.console.level = app.isPackaged ? 'info' : 'debug';
Expand All @@ -36,9 +36,10 @@
);

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs } = require('./shell-profiles');

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { isSensitivePath, isAllowedMemoryPath: _isAllowedMemoryPath } = require('./ipc-path-validator');



Expand Down Expand Up @@ -73,7 +74,7 @@
getMeta, getAllMeta, toggleStar, setName, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
deleteCachedSession, deleteCachedFolder, replaceSessionMetrics, touchCachedModified,
getFolderMeta, getAllFolderMeta, setFolderMeta,

Check warning on line 77 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 77 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u
upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType,
searchByType, isSearchIndexPopulated, searchFtsRecreated,
getSetting, setSetting, deleteSetting,
Expand All @@ -91,6 +92,31 @@
const activeSessions = new Map();
let mainWindow = null;

// Wrapper that plumbs the set of known project roots into isAllowedMemoryPath.
// The Plans/Memory panel (get-memories) surfaces CLAUDE.md / agents.md and
// .claude/*.md from EVERY indexed project — not just ones with a live session —
// so the allowlist must cover every known project root, otherwise reading a
// memory file for a project without an open session would be rejected.
function isAllowedMemoryPath(filePath) {
const projectPaths = new Set();
for (const [, s] of activeSessions) {
if (s.projectPath) projectPaths.add(s.projectPath);
}
// All indexed projects (same enumeration as the get-memories handler).
try {
const { deriveProjectPath } = require('./derive-project-path');
if (fs.existsSync(PROJECTS_DIR)) {
for (const d of fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
if (!d.isDirectory() || d.name === '.git') continue;
const folderPath = path.join(PROJECTS_DIR, d.name);
const p = deriveProjectPath(folderPath, d.name);
if (p) projectPaths.add(p);
}
}
} catch {}
return _isAllowedMemoryPath(filePath, [...projectPaths]);
}

// Subagent live-tail watchers (watchId → { filePath, parentSessionId, agentId, teardown })
const subagentWatchers = new Map();
let subagentWatcherSeq = 0;
Expand Down Expand Up @@ -290,8 +316,8 @@
setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 319 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 319 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 319 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 319 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker } = sessionCache;

Check warning on line 320 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 320 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');


Expand Down Expand Up @@ -565,7 +591,9 @@

ipcMain.handle('read-file-for-panel', async (_event, filePath) => {
try {
const content = fs.readFileSync(filePath, 'utf8');
const resolved = path.resolve(filePath);
if (isSensitivePath(resolved)) return { ok: false, error: 'access to sensitive path denied' };
const content = fs.readFileSync(resolved, 'utf8');
return { ok: true, content };
} catch (err) {
return { ok: false, error: err.message };
Expand All @@ -575,6 +603,7 @@
ipcMain.handle('save-file-for-panel', async (_event, filePath, content) => {
try {
const resolved = path.resolve(filePath);
if (isSensitivePath(resolved)) return { ok: false, error: 'access to sensitive path denied' };
if (!fs.existsSync(resolved)) return { ok: false, error: 'File does not exist' };
fs.writeFileSync(resolved, content, 'utf8');
return { ok: true };
Expand All @@ -588,6 +617,7 @@

ipcMain.handle('watch-file', (_event, filePath) => {
const resolved = path.resolve(filePath);
if (isSensitivePath(resolved)) return { ok: false, error: 'access to sensitive path denied' };
if (fileWatchers.has(resolved)) return { ok: true };
try {
let debounce = null;
Expand Down Expand Up @@ -947,9 +977,8 @@
ipcMain.handle('read-memory', (_event, filePath) => {
try {
const resolved = path.resolve(filePath);
// Allow paths under ~/.claude/ or any .md file that exists
if (!resolved.endsWith('.md')) return '';
if (!resolved.startsWith(CLAUDE_DIR) && !fs.existsSync(resolved)) return '';
if (!isAllowedMemoryPath(resolved)) return '';
return fs.readFileSync(resolved, 'utf8');
} catch (err) {
console.error('Error reading memory file:', err);
Expand All @@ -962,6 +991,7 @@
try {
const resolved = path.resolve(filePath);
if (!resolved.endsWith('.md')) return { ok: false, error: 'not a .md file' };
if (!isAllowedMemoryPath(resolved)) return { ok: false, error: 'path not allowed' };
if (!fs.existsSync(resolved)) return { ok: false, error: 'file does not exist' };
fs.writeFileSync(resolved, content, 'utf8');
return { ok: true };
Expand Down Expand Up @@ -1410,7 +1440,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 1443 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 1443 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down Expand Up @@ -1885,6 +1915,20 @@
});

app.whenReady().then(() => {
// Content Security Policy — restrict what the renderer can load or execute.
// 'unsafe-inline' for style-src is required because the app sets .style.*
// properties via JavaScript and style.css uses data: URIs in background-image.
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; font-src 'self'",
],
},
});
});

buildMenu();
createWindow();
startProjectsWatcher();
Expand Down
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"better-sqlite3": "^12.0.0",
"dompurify": "^3.4.8",
"electron-log": "^5.3.0",
"electron-updater": "^6.3.0",
"marked": "^17.0.4",
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
<script src="../node_modules/@xterm/addon-unicode-graphemes/lib/addon-unicode-graphemes.js"></script>
<script src="../node_modules/@xterm/addon-webgl/lib/addon-webgl.js"></script>
<script src="../node_modules/morphdom/dist/morphdom-umd.js"></script>
<script src="../node_modules/dompurify/dist/purify.min.js"></script>
<script src="codemirror-bundle.js"></script>
<script src="icons.js"></script>
<script src="viewer-toolbar.js"></script>
Expand Down
2 changes: 1 addition & 1 deletion public/viewer-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ class ViewerPanel {
}

if (this.previewMode) {
this.previewEl.innerHTML = window.marked.parse(newContent);
this.previewEl.innerHTML = DOMPurify.sanitize(window.marked.parse(newContent));
}
}

Expand Down
2 changes: 1 addition & 1 deletion public/viewer-toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function flashButtonText(btn, text, duration = 1200) {
function toggleMarkdownPreview({ editorEl, previewEl, toggleBtn, editorView, isPreview, storageKey }) {
if (!isPreview) {
const content = editorView ? editorView.state.doc.toString() : '';
previewEl.innerHTML = window.marked.parse(content);
previewEl.innerHTML = DOMPurify.sanitize(window.marked.parse(content));
editorEl.style.display = 'none';
previewEl.style.display = 'block';
toggleBtn.classList.add('active');
Expand Down
Loading
Loading