diff --git a/eslint.config.js b/eslint.config.js
index 7e02cc7d..ff55833c 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -132,6 +132,7 @@ const rendererCrossFileGlobals = {
// Third-party renderer libs loaded as
+
diff --git a/public/viewer-panel.js b/public/viewer-panel.js
index e3a68c83..e40a8a74 100644
--- a/public/viewer-panel.js
+++ b/public/viewer-panel.js
@@ -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));
}
}
diff --git a/public/viewer-toolbar.js b/public/viewer-toolbar.js
index e275f6a5..705cd2bc 100644
--- a/public/viewer-toolbar.js
+++ b/public/viewer-toolbar.js
@@ -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');
diff --git a/test/ipc-path-validator.test.js b/test/ipc-path-validator.test.js
new file mode 100644
index 00000000..398755c2
--- /dev/null
+++ b/test/ipc-path-validator.test.js
@@ -0,0 +1,146 @@
+// test/ipc-path-validator.test.js — unit tests for the IPC path validation helper
+//
+// Tests the pure path-validation logic extracted from main.js.
+// No Electron, no fs I/O beyond what the module itself does.
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const os = require('os');
+const path = require('path');
+
+const { isSensitivePath, isAllowedMemoryPath } = require('../ipc-path-validator');
+
+const HOME = os.homedir();
+const CLAUDE_DIR = path.join(HOME, '.claude');
+
+// ── isSensitivePath ───────────────────────────────────────────────────────────
+
+test('isSensitivePath: rejects ~/.ssh/id_rsa', () => {
+ assert.equal(isSensitivePath(path.join(HOME, '.ssh', 'id_rsa')), true);
+});
+
+test('isSensitivePath: rejects ~/.ssh/ directory', () => {
+ assert.equal(isSensitivePath(path.join(HOME, '.ssh', 'config')), true);
+});
+
+test('isSensitivePath: rejects ~/.gnupg/secring.gpg', () => {
+ assert.equal(isSensitivePath(path.join(HOME, '.gnupg', 'secring.gpg')), true);
+});
+
+test('isSensitivePath: rejects ~/.aws/credentials', () => {
+ assert.equal(isSensitivePath(path.join(HOME, '.aws', 'credentials')), true);
+});
+
+test('isSensitivePath: allows ~/.aws/config (not credentials)', () => {
+ assert.equal(isSensitivePath(path.join(HOME, '.aws', 'config')), false);
+});
+
+test('isSensitivePath: rejects ~/.env file at home root', () => {
+ assert.equal(isSensitivePath(path.join(HOME, '.env')), true);
+});
+
+test('isSensitivePath: rejects project .env file', () => {
+ assert.equal(isSensitivePath('/home/user/project/.env'), true);
+});
+
+test('isSensitivePath: rejects .env.local', () => {
+ assert.equal(isSensitivePath('/home/user/project/.env.local'), true);
+});
+
+test('isSensitivePath: allows .env.example (not .env or .env.local)', () => {
+ assert.equal(isSensitivePath('/home/user/project/.env.example'), false);
+});
+
+test('isSensitivePath: rejects ~/.netrc', () => {
+ assert.equal(isSensitivePath(path.join(HOME, '.netrc')), true);
+});
+
+test('isSensitivePath: rejects ~/.docker/config.json', () => {
+ assert.equal(isSensitivePath(path.join(HOME, '.docker', 'config.json')), true);
+});
+
+test('isSensitivePath: rejects ~/.kube/config', () => {
+ assert.equal(isSensitivePath(path.join(HOME, '.kube', 'config')), true);
+});
+
+test('isSensitivePath: allows normal project file', () => {
+ assert.equal(isSensitivePath('/home/user/project/src/main.js'), false);
+});
+
+test('isSensitivePath: allows ~/.claude/CLAUDE.md', () => {
+ assert.equal(isSensitivePath(path.join(CLAUDE_DIR, 'CLAUDE.md')), false);
+});
+
+test('isSensitivePath: rejects traversal to .ssh via .claude path', () => {
+ // A path like ~/.claude/../.ssh/id_rsa resolves to ~/.ssh/id_rsa
+ assert.equal(isSensitivePath(path.join(CLAUDE_DIR, '..', '.ssh', 'id_rsa')), true);
+});
+
+// ── isAllowedMemoryPath ───────────────────────────────────────────────────────
+
+test('isAllowedMemoryPath: allows files under ~/.claude/', () => {
+ const { isAllowedMemoryPath: allowed } = require('../ipc-path-validator');
+ assert.equal(allowed(path.join(CLAUDE_DIR, 'CLAUDE.md'), []), true);
+});
+
+test('isAllowedMemoryPath: allows files deep under ~/.claude/', () => {
+ assert.equal(isAllowedMemoryPath(path.join(CLAUDE_DIR, 'memory', 'notes.md'), []), true);
+});
+
+test('isAllowedMemoryPath: allows ~/.claude itself', () => {
+ assert.equal(isAllowedMemoryPath(CLAUDE_DIR, []), true);
+});
+
+test('isAllowedMemoryPath: allows file under active project path', () => {
+ const projectPath = '/home/user/project';
+ assert.equal(
+ isAllowedMemoryPath(path.join(projectPath, 'CLAUDE.md'), [projectPath]),
+ true,
+ );
+});
+
+test('isAllowedMemoryPath: allows .work-files under active project', () => {
+ const projectPath = '/home/user/project';
+ assert.equal(
+ isAllowedMemoryPath(path.join(projectPath, '.work-files', 'notes.md'), [projectPath]),
+ true,
+ );
+});
+
+test('isAllowedMemoryPath: rejects file outside ~/.claude and outside projects', () => {
+ assert.equal(
+ isAllowedMemoryPath(path.join(HOME, 'Documents', 'secret.md'), []),
+ false,
+ );
+});
+
+test('isAllowedMemoryPath: rejects traversal escape from ~/.claude', () => {
+ // ~/.claude/../Documents/secret.md → ~/Documents/secret.md
+ assert.equal(
+ isAllowedMemoryPath(path.join(CLAUDE_DIR, '..', 'Documents', 'secret.md'), []),
+ false,
+ );
+});
+
+test('isAllowedMemoryPath: rejects file that is a prefix-match but not a subpath', () => {
+ // /home/user/.claude-evil/file.md must NOT be allowed just because it starts with the same chars
+ const evil = path.join(HOME, '.claude-evil', 'file.md');
+ assert.equal(isAllowedMemoryPath(evil, []), false);
+});
+
+test('isAllowedMemoryPath: accepts multiple project paths, first matching wins', () => {
+ const projectA = '/home/user/projectA';
+ const projectB = '/home/user/projectB';
+ assert.equal(
+ isAllowedMemoryPath(path.join(projectB, 'plans', 'plan.md'), [projectA, projectB]),
+ true,
+ );
+});
+
+test('isAllowedMemoryPath: rejects when project list is empty and path is outside ~/.claude', () => {
+ assert.equal(
+ isAllowedMemoryPath('/etc/passwd', []),
+ false,
+ );
+});