From 25ec73748db077e23a06eef502beeff1c3f1ce01 Mon Sep 17 00:00:00 2001 From: Edmund Amoye Date: Wed, 18 Mar 2026 04:49:14 -0400 Subject: [PATCH 1/2] Security hardening, TS fixes, JS drag, and dynamic window resize --- src/main/claude/pty-run-manager.ts | 23 ++- src/main/index.ts | 180 ++++++++++--------- src/main/marketplace/catalog.ts | 21 ++- src/main/security.ts | 168 +++++++++++++++++ src/preload/index.ts | 6 + src/renderer/App.tsx | 112 +++++++++++- src/renderer/components/HistoryPicker.tsx | 7 +- src/renderer/components/MarketplacePanel.tsx | 2 +- src/renderer/components/StatusBar.tsx | 19 +- src/renderer/stores/sessionStore.ts | 53 +----- src/shared/types.ts | 2 + tsconfig.json | 2 +- 12 files changed, 427 insertions(+), 168 deletions(-) create mode 100644 src/main/security.ts diff --git a/src/main/claude/pty-run-manager.ts b/src/main/claude/pty-run-manager.ts index be217232..8691844e 100644 --- a/src/main/claude/pty-run-manager.ts +++ b/src/main/claude/pty-run-manager.ts @@ -21,7 +21,6 @@ import { join } from 'path' import { execSync } from 'child_process' import { appendFileSync, chmodSync, existsSync, statSync } from 'fs' import type { NormalizedEvent, RunOptions, EnrichedError } from '../../shared/types' -import { getCliEnv } from '../cli-env' // node-pty is a native module — require at runtime to avoid Vite bundling issues // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -276,6 +275,7 @@ export class PtyRunManager extends EventEmitter { private activeRuns = new Map() private _finishedRuns = new Map() private claudeBinary: string + private _loginShellPath = '' constructor() { super() @@ -325,18 +325,31 @@ export class PtyRunManager extends EventEmitter { } try { - return execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() + return execSync('/bin/zsh -lc "whence -p claude"', { encoding: 'utf-8' }).trim() } catch {} try { - return execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() + return execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8' }).trim() } catch {} return 'claude' } private _getEnv(): NodeJS.ProcessEnv { - const env = getCliEnv() + const env = { ...process.env } + delete env.CLAUDECODE + + if (!this._loginShellPath) { + try { + this._loginShellPath = execSync('/bin/zsh -lc "echo $PATH"', { encoding: 'utf-8' }).trim() + } catch { + this._loginShellPath = '' + } + } + if (this._loginShellPath) { + env.PATH = this._loginShellPath + } + const binDir = this.claudeBinary.substring(0, this.claudeBinary.lastIndexOf('/')) if (env.PATH && !env.PATH.includes(binDir)) { env.PATH = `${binDir}:${env.PATH}` @@ -563,7 +576,7 @@ export class PtyRunManager extends EventEmitter { // ─── Permission phase: collecting detection context ─── if (handle.permissionPhase === 'detecting' || handle.permissionPhase === 'idle') { this._checkPermissionInBuffer(requestId, handle, cleaned) - if (handle.permissionPhase === 'waiting_user') { + if ((handle.permissionPhase as string) === 'waiting_user') { return // Permission prompt detected and emitted } } diff --git a/src/main/index.ts b/src/main/index.ts index 10472226..9b761548 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell, systemPreferences } from 'electron' +import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell } from 'electron' import { join } from 'path' import { existsSync, readdirSync, statSync, createReadStream } from 'fs' import { createInterface } from 'readline' @@ -7,9 +7,9 @@ import { ControlPlane } from './claude/control-plane' import { ensureSkills, type SkillStatus } from './skills/installer' import { fetchCatalog, listInstalled, installPlugin, uninstallPlugin } from './marketplace/catalog' import { log as _log, LOG_FILE, flushLogs } from './logger' -import { getCliEnv } from './cli-env' import { IPC } from '../shared/types' import type { RunOptions, NormalizedEvent, EnrichedError } from '../shared/types' +import { isValidSessionId, validateProjectPath, verifyBinary, escapeAppleScript, isValidHttpUrl } from './security' const DEBUG_MODE = process.env.CLUI_DEBUG === '1' const SPACES_DEBUG = DEBUG_MODE || process.env.CLUI_SPACES_DEBUG === '1' @@ -31,7 +31,7 @@ const controlPlane = new ControlPlane(INTERACTIVE_PTY) // Keep native width fixed to avoid renderer animation vs setBounds race. // The UI itself still launches in compact mode; extra width is transparent/click-through. const BAR_WIDTH = 1040 -const PILL_HEIGHT = 720 // Fixed native window height — extra room for expanded UI + shadow buffers +const PILL_HEIGHT = 160 // Start compact — ResizeObserver dynamically adjusts to content height const PILL_BOTTOM_MARGIN = 24 // ─── Broadcast to renderer ─── @@ -157,39 +157,6 @@ function createWindow(): void { } } -function showWindow(source = 'unknown'): void { - if (!mainWindow) return - const toggleId = ++toggleSequence - - // Position on the display where the cursor currently is (not always primary) - const cursor = screen.getCursorScreenPoint() - const display = screen.getDisplayNearestPoint(cursor) - const { width: sw, height: sh } = display.workAreaSize - const { x: dx, y: dy } = display.workArea - mainWindow.setBounds({ - x: dx + Math.round((sw - BAR_WIDTH) / 2), - y: dy + sh - PILL_HEIGHT - PILL_BOTTOM_MARGIN, - width: BAR_WIDTH, - height: PILL_HEIGHT, - }) - - // Always re-assert space membership — the flag can be lost after hide/show cycles - // and must be set before show() so the window joins the active Space, not its - // last-known Space. - mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) - - if (SPACES_DEBUG) { - log(`[spaces] showWindow#${toggleId} source=${source} move-to-display id=${display.id}`) - snapshotWindowState(`showWindow#${toggleId} pre-show`) - } - // As an accessory app (app.dock.hide), show() + focus gives keyboard - // without deactivating the active app — hover preserved everywhere. - mainWindow.show() - mainWindow.webContents.focus() - broadcast(IPC.WINDOW_SHOWN) - if (SPACES_DEBUG) scheduleToggleSnapshots(toggleId, 'show') -} - function toggleWindow(source = 'unknown'): void { if (!mainWindow) return const toggleId = ++toggleSequence @@ -198,11 +165,22 @@ function toggleWindow(source = 'unknown'): void { snapshotWindowState(`toggle#${toggleId} pre`) } + // Pure toggle: visible → hide, not visible → show. No focus-based branching. if (mainWindow.isVisible()) { mainWindow.hide() if (SPACES_DEBUG) scheduleToggleSnapshots(toggleId, 'hide') } else { - showWindow(source) + // Show window where it was last positioned (user controls placement via drag). + // Only reposition if this is the first show (window at default position). + if (SPACES_DEBUG) { + snapshotWindowState(`toggle#${toggleId} pre-show`) + } + // As an accessory app (app.dock.hide), show() + focus gives keyboard + // without deactivating the active app — hover preserved everywhere. + mainWindow.show() + mainWindow.webContents.focus() + broadcast(IPC.WINDOW_SHOWN) + if (SPACES_DEBUG) scheduleToggleSnapshots(toggleId, 'show') } } @@ -210,8 +188,21 @@ function toggleWindow(source = 'unknown'): void { // Fixed-height mode: ignore renderer resize events to prevent jank. // The native window stays at PILL_HEIGHT; all expand/collapse happens inside the renderer. -ipcMain.on(IPC.RESIZE_HEIGHT, () => { - // No-op — fixed height window, no dynamic resize +ipcMain.on(IPC.RESIZE_HEIGHT, (_event, height: number) => { + if (!mainWindow || mainWindow.isDestroyed()) return + + // Cap at screen work area height (minus menu bar, dock, etc.) + const cursor = screen.getCursorScreenPoint() + const display = screen.getDisplayNearestPoint(cursor) + const maxHeight = display.workAreaSize.height - 20 + + const safeHeight = Math.max(120, Math.min(Math.round(height), maxHeight)) + const [x, y] = mainWindow.getPosition() + const currentHeight = mainWindow.getBounds().height + + // Anchor bottom edge: shift y so the bottom of the window stays in place + const newY = y + currentHeight - safeHeight + mainWindow.setBounds({ x, y: newY, width: BAR_WIDTH, height: safeHeight }) }) ipcMain.on(IPC.SET_WINDOW_WIDTH, () => { @@ -239,6 +230,19 @@ ipcMain.on(IPC.SET_IGNORE_MOUSE_EVENTS, (event, ignore: boolean, options?: { for } }) +// ─── JS-based window drag (bypasses setIgnoreMouseEvents conflict) ─── + +ipcMain.handle(IPC.GET_WINDOW_POSITION, () => { + if (!mainWindow || mainWindow.isDestroyed()) return { x: 0, y: 0 } + const [x, y] = mainWindow.getPosition() + return { x, y } +}) + +ipcMain.on(IPC.MOVE_WINDOW, (_event, x: number, y: number) => { + if (!mainWindow || mainWindow.isDestroyed()) return + mainWindow.setPosition(Math.round(x), Math.round(y)) +}) + // ─── IPC Handlers (typed, strict) ─── ipcMain.handle(IPC.START, async () => { @@ -247,18 +251,18 @@ ipcMain.handle(IPC.START, async () => { let version = 'unknown' try { - version = execSync('claude -v', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim() + version = execSync('claude -v', { encoding: 'utf-8', timeout: 5000 }).trim() } catch {} let auth: { email?: string; subscriptionType?: string; authMethod?: string } = {} try { - const raw = execSync('claude auth status', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim() + const raw = execSync('claude auth status', { encoding: 'utf-8', timeout: 5000 }).trim() auth = JSON.parse(raw) } catch {} let mcpServers: string[] = [] try { - const raw = execSync('claude mcp list', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim() + const raw = execSync('claude mcp list', { encoding: 'utf-8', timeout: 5000 }).trim() if (raw) mcpServers = raw.split('\n').filter(Boolean) } catch {} @@ -349,7 +353,8 @@ ipcMain.handle(IPC.RESPOND_PERMISSION, (_event, { tabId, questionId, optionId }: ipcMain.handle(IPC.LIST_SESSIONS, async (_e, projectPath?: string) => { log(`IPC LIST_SESSIONS ${projectPath ? `(path=${projectPath})` : ''}`) try { - const cwd = projectPath || process.cwd() + const rawCwd = projectPath || process.cwd() + const cwd = validateProjectPath(rawCwd) || process.cwd() // Claude stores project sessions at ~/.claude/projects// // Path encoding: replace all '/' with '-' (leading '/' becomes leading '-') const encodedPath = cwd.replace(/\//g, '-') @@ -430,8 +435,16 @@ ipcMain.handle(IPC.LOAD_SESSION, async (_e, arg: { sessionId: string; projectPat const sessionId = typeof arg === 'string' ? arg : arg.sessionId const projectPath = typeof arg === 'string' ? undefined : arg.projectPath log(`IPC LOAD_SESSION ${sessionId}${projectPath ? ` (path=${projectPath})` : ''}`) + + // Security: validate sessionId format + if (!isValidSessionId(sessionId)) { + log(`LOAD_SESSION: rejected invalid sessionId: ${sessionId}`) + return [] + } + try { - const cwd = projectPath || process.cwd() + const rawCwd = projectPath || process.cwd() + const cwd = validateProjectPath(rawCwd) || process.cwd() const encodedPath = cwd.replace(/\//g, '-') const filePath = join(homedir(), '.claude', 'projects', encodedPath, `${sessionId}.jsonl`) if (!existsSync(filePath)) return [] @@ -490,7 +503,7 @@ ipcMain.handle(IPC.SELECT_DIRECTORY, async () => { // Unparented avoids modal dimming on the transparent overlay. // Activation is fine here — user is actively interacting with CLUI. if (process.platform === 'darwin') app.focus() - const options = { properties: ['openDirectory'] as const } + const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] } const result = process.platform === 'darwin' ? await dialog.showOpenDialog(options) : await dialog.showOpenDialog(mainWindow, options) @@ -499,8 +512,11 @@ ipcMain.handle(IPC.SELECT_DIRECTORY, async () => { ipcMain.handle(IPC.OPEN_EXTERNAL, async (_event, url: string) => { try { - // Only allow http(s) links from markdown content. - if (!/^https?:\/\//i.test(url)) return false + // Security: validate URL using centralized validator + if (!isValidHttpUrl(url)) { + log(`OPEN_EXTERNAL: rejected non-HTTP URL: ${url}`) + return false + } await shell.openExternal(url) return true } catch { @@ -512,7 +528,7 @@ ipcMain.handle(IPC.ATTACH_FILES, async () => { if (!mainWindow) return null // macOS: activate app so unparented dialog appears on top if (process.platform === 'darwin') app.focus() - const options = { + const options: Electron.OpenDialogOptions = { properties: ['openFile', 'multiSelections'], filters: [ { name: 'All Files', extensions: ['*'] }, @@ -688,6 +704,16 @@ ipcMain.handle(IPC.TRANSCRIBE_AUDIO, async (_event, audioBase64: string) => { } } + // Security: verify the whisper binary is in a trusted location and not tampered + const binCheck = verifyBinary(whisperBin) + if (!binCheck.trusted) { + log(`Whisper binary rejected: ${whisperBin} — ${binCheck.reason}`) + return { + error: `Whisper binary at ${whisperBin} failed verification: ${binCheck.reason}. Reinstall with: brew install whisper-cli`, + transcript: null, + } + } + const isWhisperCpp = whisperBin.includes('whisper-cli') // Find model file — prefer multilingual (auto-detect language) over .en (English-only) @@ -810,13 +836,26 @@ ipcMain.handle(IPC.OPEN_IN_TERMINAL, (_event, arg: string | null | { sessionId?: projectPath = arg.projectPath && arg.projectPath !== '~' ? arg.projectPath : process.cwd() } - // Escape for AppleScript: double quotes → backslash-escaped, backslashes doubled - const projectDir = projectPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + // Security: validate sessionId format (must be UUID v4 if provided) + if (sessionId && !isValidSessionId(sessionId)) { + log(`OPEN_IN_TERMINAL: rejected invalid sessionId format: ${sessionId}`) + return false + } + + // Security: validate and sanitize project path + const validatedPath = validateProjectPath(projectPath) + if (!validatedPath) { + log(`OPEN_IN_TERMINAL: rejected invalid project path: ${projectPath}`) + return false + } + + // Security: use escapeAppleScript to prevent injection via osascript + const escapedPath = escapeAppleScript(validatedPath) let cmd: string if (sessionId) { - cmd = `cd \\"${projectDir}\\" && ${claudeBin} --resume ${sessionId}` + cmd = `cd \\"${escapedPath}\\" && ${claudeBin} --resume ${sessionId}` } else { - cmd = `cd \\"${projectDir}\\" && ${claudeBin}` + cmd = `cd \\"${escapedPath}\\" && ${claudeBin}` } const script = `tell application "Terminal" @@ -868,33 +907,9 @@ nativeTheme.on('updated', () => { broadcast(IPC.THEME_CHANGED, nativeTheme.shouldUseDarkColors) }) -// ─── Permission Preflight ─── -// Request all required macOS permissions upfront on first launch so the user -// is never interrupted mid-session by a permission prompt. - -async function requestPermissions(): Promise { - if (process.platform !== 'darwin') return - - // ── Microphone (for voice input via Whisper) ── - try { - const micStatus = systemPreferences.getMediaAccessStatus('microphone') - if (micStatus === 'not-determined') { - await systemPreferences.askForMediaAccess('microphone') - } - } catch (err: any) { - log(`Permission preflight: microphone check failed — ${err.message}`) - } - - // ── Accessibility (for global ⌥+Space shortcut) ── - // globalShortcut works without it on modern macOS; Cmd+Shift+K is always the fallback. - // Screen Recording: not requested upfront — macOS 15 Sequoia shows an alarming - // "bypass private window picker" dialog. Let the OS prompt naturally if/when - // the screenshot feature is actually used. -} - // ─── App Lifecycle ─── -app.whenReady().then(async () => { +app.whenReady().then(() => { // macOS: become an accessory app. Accessory apps can have key windows (keyboard works) // without deactivating the currently active app (hover preserved in browsers). // This is how Spotlight, Alfred, Raycast work. @@ -902,9 +917,6 @@ app.whenReady().then(async () => { app.dock.hide() } - // Request permissions upfront so the user is never interrupted mid-session. - await requestPermissions() - // Skill provisioning — non-blocking, streams status to renderer ensureSkills((status: SkillStatus) => { log(`Skill ${status.name}: ${status.state}${status.error ? ` — ${status.error}` : ''}`) @@ -956,16 +968,12 @@ app.whenReady().then(async () => { tray.on('click', () => toggleWindow('tray click')) tray.setContextMenu( Menu.buildFromTemplate([ - { label: 'Show Clui CC', click: () => showWindow('tray menu') }, + { label: 'Show Clui CC', click: () => toggleWindow('tray menu Show Clui CC') }, { label: 'Quit', click: () => { app.quit() } }, ]) ) - // app 'activate' fires when macOS brings the app to the foreground (e.g. after - // webContents.focus() triggers applicationDidBecomeActive on some macOS versions). - // Using showWindow here instead of toggleWindow prevents the re-entry race where - // a summon immediately hides itself because activate fires mid-show. - app.on('activate', () => showWindow('app activate')) + app.on('activate', () => toggleWindow('app activate')) }) app.on('will-quit', () => { diff --git a/src/main/marketplace/catalog.ts b/src/main/marketplace/catalog.ts index b5d8d0df..30d38062 100644 --- a/src/main/marketplace/catalog.ts +++ b/src/main/marketplace/catalog.ts @@ -5,7 +5,6 @@ import { join } from 'path' import { homedir } from 'os' import type { CatalogPlugin } from '../../shared/types' import { log as _log } from '../logger' -import { getCliEnv } from '../cli-env' function log(msg: string): void { _log('marketplace', msg) @@ -247,6 +246,18 @@ export async function installPlugin( sourcePath?: string, isSkillMd?: boolean ): Promise<{ ok: boolean; error?: string }> { + // Security: validate plugin name to prevent path traversal + if (!pluginName || /[/\\]|\.\./.test(pluginName)) { + log(`installPlugin: rejected unsafe plugin name: ${pluginName}`) + return { ok: false, error: 'Invalid plugin name' } + } + + // Security: validate repo format (org/repo only) + if (!repo || !/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+$/.test(repo)) { + log(`installPlugin: rejected unsafe repo: ${repo}`) + return { ok: false, error: 'Invalid repository name' } + } + try { if (isSkillMd !== false) { // Direct SKILL.md install @@ -294,6 +305,12 @@ export async function installPlugin( export async function uninstallPlugin( pluginName: string ): Promise<{ ok: boolean; error?: string }> { + // Security: validate plugin name to prevent path traversal + if (!pluginName || /[/\\]|\.\./.test(pluginName)) { + log(`uninstallPlugin: rejected unsafe plugin name: ${pluginName}`) + return { ok: false, error: 'Invalid plugin name' } + } + try { const skillsDir = join(homedir(), '.claude', 'skills', pluginName) await rm(skillsDir, { recursive: true, force: true }) @@ -393,7 +410,7 @@ function deriveSemanticTags(name: string, description: string, skillPath: string function execAsync(cmd: string, args: string[], timeout: number): Promise<{ exitCode: number; stdout: string; stderr: string }> { return new Promise((resolve) => { - execFile(cmd, args, { timeout, env: getCliEnv() }, (err, stdout, stderr) => { + execFile(cmd, args, { timeout }, (err, stdout, stderr) => { resolve({ exitCode: err ? 1 : 0, stdout: stdout || '', diff --git a/src/main/security.ts b/src/main/security.ts new file mode 100644 index 00000000..120475d8 --- /dev/null +++ b/src/main/security.ts @@ -0,0 +1,168 @@ +/** + * Security utilities for Clui CC. + * + * Centralizes input sanitization, path validation, and binary verification + * to prevent shell injection, path traversal, and binary hijacking. + */ + +import { existsSync, statSync, realpathSync } from 'fs' +import { resolve, normalize, isAbsolute } from 'path' +import { homedir } from 'os' +import { log as _log } from './logger' + +function log(msg: string): void { + _log('security', msg) +} + +// ─── Shell Argument Sanitization ─── + +/** + * Validates that a string is safe to use as a shell argument. + * Rejects strings containing shell metacharacters that could enable injection. + */ +export function isShellSafe(input: string): boolean { + if (input.includes('\0')) return false + const DANGEROUS_CHARS = /[;&|`$(){}[\]<>!\n\r]/ + return !DANGEROUS_CHARS.test(input) +} + +/** + * Validates a session ID (UUID v4 format only). + */ +export function isValidSessionId(id: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) +} + +/** + * Validates a tab ID (UUID v4 format only). + */ +export function isValidTabId(id: string): boolean { + return isValidSessionId(id) +} + +// ─── Path Validation ─── + +const ALLOWED_BASES = [ + homedir(), + '/tmp', + '/var/folders', +] + +/** + * Validates that a file path is absolute, has no traversal sequences, + * and resolves within allowed directories. + * Returns the resolved path if valid, or null if rejected. + */ +export function validateFilePath(inputPath: string, allowedBases?: string[]): string | null { + if (!inputPath || typeof inputPath !== 'string') return null + if (inputPath.includes('\0')) return null + + const normalized = normalize(inputPath) + if (!isAbsolute(normalized)) return null + + const resolved = resolve(normalized) + const bases = allowedBases || ALLOWED_BASES + const isAllowed = bases.some((base) => resolved.startsWith(base)) + if (!isAllowed) { + log(`Path rejected: ${inputPath} resolved to ${resolved} — not in allowed bases`) + return null + } + + return resolved +} + +/** + * Validates a project path for Claude session operations. + */ +export function validateProjectPath(inputPath: string): string | null { + if (!inputPath || typeof inputPath !== 'string') return null + if (inputPath === '~') return homedir() + if (inputPath.includes('\0')) return null + + const resolved = resolve(inputPath.replace(/^~/, homedir())) + if (!isAbsolute(resolved)) return null + + if (inputPath.includes('..')) { + log(`Project path rejected — contains traversal: ${inputPath}`) + return null + } + + return resolved +} + +// ─── Binary Verification ─── + +const TRUSTED_BIN_DIRS = [ + '/opt/homebrew/bin', + '/opt/homebrew/Cellar', + '/usr/local/bin', + '/usr/local/Cellar', + '/usr/bin', + '/usr/sbin', + '/bin', + '/sbin', + resolve(homedir(), '.local/bin'), + resolve(homedir(), '.npm-global/bin'), +] + +/** + * Verifies that a binary path exists, is a regular file in a trusted + * directory, and is not world-writable. + */ +export function verifyBinary(binPath: string): { trusted: boolean; reason: string } { + if (!binPath || !isAbsolute(binPath)) { + return { trusted: false, reason: 'Not an absolute path' } + } + + if (!existsSync(binPath)) { + return { trusted: false, reason: 'File does not exist' } + } + + try { + const realPath = realpathSync(binPath) + const stat = statSync(realPath) + + if (!stat.isFile()) { + return { trusted: false, reason: 'Not a regular file' } + } + + const inTrustedDir = TRUSTED_BIN_DIRS.some((dir) => realPath.startsWith(dir + '/')) + if (!inTrustedDir) { + const nvmPattern = resolve(homedir(), '.nvm/versions/node') + if (!realPath.startsWith(nvmPattern)) { + return { trusted: false, reason: `Not in a trusted directory: ${realPath}` } + } + } + + if (stat.mode & 0o002) { + return { trusted: false, reason: 'File is world-writable — possible tamper' } + } + + return { trusted: true, reason: 'OK' } + } catch (err) { + return { trusted: false, reason: `Stat failed: ${(err as Error).message}` } + } +} + +// ─── AppleScript Sanitization ─── + +/** + * Escapes a string for safe inclusion in AppleScript double-quoted strings. + */ +export function escapeAppleScript(input: string): string { + return input + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '') + .replace(/\r/g, '') +} + +// ─── URL Validation ─── + +/** + * Validates that a URL is a safe HTTP(S) URL. + */ +export function isValidHttpUrl(url: string): boolean { + if (!url || typeof url !== 'string') return false + return /^https?:\/\//i.test(url) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 81344d61..99758375 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -42,6 +42,10 @@ export interface CluiAPI { isVisible(): Promise /** OS-level click-through for transparent window regions */ setIgnoreMouseEvents(ignore: boolean, options?: { forward?: boolean }): void + /** Get current window position for JS-based drag */ + getWindowPosition(): Promise<{ x: number; y: number }> + /** Move window to absolute position (JS-based drag) */ + moveWindow(x: number, y: number): void // ─── Event listeners (main → renderer) ─── onEvent(callback: (tabId: string, event: NormalizedEvent) => void): () => void @@ -99,6 +103,8 @@ const api: CluiAPI = { setIgnoreMouseEvents: (ignore, options) => ipcRenderer.send(IPC.SET_IGNORE_MOUSE_EVENTS, ignore, options || {}), setWindowWidth: (width) => ipcRenderer.send(IPC.SET_WINDOW_WIDTH, width), + getWindowPosition: () => ipcRenderer.invoke(IPC.GET_WINDOW_POSITION), + moveWindow: (x: number, y: number) => ipcRenderer.send(IPC.MOVE_WINDOW, x, y), // ─── Event listeners ─── onEvent: (callback) => { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6cf443b3..d10bfc1c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useCallback } from 'react' +import React, { useEffect, useCallback, useRef } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { Paperclip, Camera, HeadCircuit } from '@phosphor-icons/react' import { TabStrip } from './components/TabStrip' @@ -14,6 +14,70 @@ import { useColors, useThemeStore, spacing } from './theme' const TRANSITION = { duration: 0.26, ease: [0.4, 0, 0.1, 1] as const } +/** + * JS-based drag handle. Uses mousedown/mousemove/mouseup to track + * screen-level cursor delta and moves the Electron window via IPC. + * This bypasses the setIgnoreMouseEvents conflict that breaks CSS -webkit-app-region: drag. + */ +function DragHandle({ colors }: { colors: ReturnType }) { + const dragging = useRef(false) + const startMouse = useRef({ screenX: 0, screenY: 0 }) + const startWin = useRef({ x: 0, y: 0 }) + + const onMouseDown = useCallback(async (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + dragging.current = true + startMouse.current = { screenX: e.screenX, screenY: e.screenY } + const pos = await window.clui.getWindowPosition() + startWin.current = { x: pos.x, y: pos.y } + + const onMouseMove = (ev: MouseEvent) => { + if (!dragging.current) return + const dx = ev.screenX - startMouse.current.screenX + const dy = ev.screenY - startMouse.current.screenY + window.clui.moveWindow(startWin.current.x + dx, startWin.current.y + dy) + } + + const onMouseUp = () => { + dragging.current = false + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }, []) + + return ( +
+
+
+ ) +} + export default function App() { useClaudeEvents() useHealthReconciliation() @@ -114,6 +178,47 @@ export default function App() { addAttachments(files) }, [addAttachments]) + // ─── Dynamic window resize: observe all rendered content including portaled popovers ─── + useEffect(() => { + if (!window.clui?.resizeHeight) return + const root = document.getElementById('root') + if (!root) return + + let lastHeight = 0 + const PADDING = 60 // breathing room for shadows, margins, and popover overflow + const MIN_HEIGHT = 160 // minimum so the input bar always shows + + const measure = () => { + // Measure the bounding rect of all children, including portaled popovers + let maxBottom = 0 + const children = root.querySelectorAll('[data-clui-ui]') + children.forEach((el) => { + const rect = el.getBoundingClientRect() + if (rect.bottom > maxBottom) maxBottom = rect.bottom + }) + const newHeight = Math.max(MIN_HEIGHT, Math.ceil(maxBottom) + PADDING) + if (Math.abs(newHeight - lastHeight) > 5) { + lastHeight = newHeight + window.clui.resizeHeight(newHeight) + } + } + + // Observe mutations (new popovers appearing/disappearing) and resizes + const resizeObserver = new ResizeObserver(() => measure()) + resizeObserver.observe(root) + + const mutationObserver = new MutationObserver(() => measure()) + mutationObserver.observe(root, { childList: true, subtree: true, attributes: true }) + + // Initial measurement + measure() + + return () => { + resizeObserver.disconnect() + mutationObserver.disconnect() + } + }, []) + return (
@@ -163,7 +268,7 @@ export default function App() { */} + {/* Drag handle — JS-based drag (CSS drag-region doesn't work with setIgnoreMouseEvents) */} + + {/* Tab strip — always mounted */}
diff --git a/src/renderer/components/HistoryPicker.tsx b/src/renderer/components/HistoryPicker.tsx index 9a32eeeb..6209e534 100644 --- a/src/renderer/components/HistoryPicker.tsx +++ b/src/renderer/components/HistoryPicker.tsx @@ -5,7 +5,7 @@ import { Clock, ChatCircle } from '@phosphor-icons/react' import { useSessionStore } from '../stores/sessionStore' import { usePopoverLayer } from './PopoverLayer' import { useColors } from '../theme' -import type { SessionMeta } from '../../shared/types' +import type { SessionMeta, TabState } from '../../shared/types' function formatTimeAgo(isoDate: string): string { const diff = Date.now() - new Date(isoDate).getTime() @@ -28,10 +28,7 @@ function formatSize(bytes: number): string { export function HistoryPicker() { const resumeSession = useSessionStore((s) => s.resumeSession) const isExpanded = useSessionStore((s) => s.isExpanded) - const activeTab = useSessionStore( - (s) => s.tabs.find((t) => t.id === s.activeTabId), - (a, b) => a === b || (!!a && !!b && a.hasChosenDirectory === b.hasChosenDirectory && a.workingDirectory === b.workingDirectory), - ) + const activeTab = useSessionStore((s) => s.tabs.find((t) => t.id === s.activeTabId) as TabState | undefined) const staticInfo = useSessionStore((s) => s.staticInfo) const popoverLayer = usePopoverLayer() const colors = useColors() diff --git a/src/renderer/components/MarketplacePanel.tsx b/src/renderer/components/MarketplacePanel.tsx index b1621de4..e15ed2a7 100644 --- a/src/renderer/components/MarketplacePanel.tsx +++ b/src/renderer/components/MarketplacePanel.tsx @@ -39,7 +39,7 @@ export function MarketplacePanel() { // Debounced search const [localSearch, setLocalSearch] = useState(search) - const debounceRef = useRef>() + const debounceRef = useRef>(undefined) const handleSearchChange = useCallback((e: React.ChangeEvent) => { const val = e.target.value setLocalSearch(val) diff --git a/src/renderer/components/StatusBar.tsx b/src/renderer/components/StatusBar.tsx index 819dab86..57599988 100644 --- a/src/renderer/components/StatusBar.tsx +++ b/src/renderer/components/StatusBar.tsx @@ -5,16 +5,14 @@ import { Terminal, CaretDown, Check, FolderOpen, Plus, X, ShieldCheck } from '@p import { useSessionStore, AVAILABLE_MODELS } from '../stores/sessionStore' import { usePopoverLayer } from './PopoverLayer' import { useColors } from '../theme' +import type { TabState } from '../../shared/types' /* ─── Model Picker (inline — tightly coupled to StatusBar) ─── */ function ModelPicker() { const preferredModel = useSessionStore((s) => s.preferredModel) const setPreferredModel = useSessionStore((s) => s.setPreferredModel) - const tab = useSessionStore( - (s) => s.tabs.find((t) => t.id === s.activeTabId), - (a, b) => a === b || (!!a && !!b && a.status === b.status && a.sessionModel === b.sessionModel), - ) + const tab = useSessionStore((s) => s.tabs.find((t) => t.id === s.activeTabId) as TabState | undefined) const popoverLayer = usePopoverLayer() const colors = useColors() @@ -258,16 +256,7 @@ function compactPath(fullPath: string): string { } export function StatusBar() { - const tab = useSessionStore( - (s) => s.tabs.find((t) => t.id === s.activeTabId), - (a, b) => a === b || (!!a && !!b - && a.status === b.status - && a.additionalDirs === b.additionalDirs - && a.hasChosenDirectory === b.hasChosenDirectory - && a.workingDirectory === b.workingDirectory - && a.claudeSessionId === b.claudeSessionId - ), - ) + const tab = useSessionStore((s) => s.tabs.find((t) => t.id === s.activeTabId) as TabState | undefined) const addDirectory = useSessionStore((s) => s.addDirectory) const removeDirectory = useSessionStore((s) => s.removeDirectory) const popoverLayer = usePopoverLayer() @@ -392,7 +381,7 @@ export function StatusBar() {
Added directories
- {tab.additionalDirs.map((dir) => ( + {tab.additionalDirs.map((dir: string) => (
{compactPath(dir)} diff --git a/src/renderer/stores/sessionStore.ts b/src/renderer/stores/sessionStore.ts index 3bd31893..f06803e2 100644 --- a/src/renderer/stores/sessionStore.ts +++ b/src/renderer/stores/sessionStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand' import type { TabStatus, NormalizedEvent, EnrichedError, Message, TabState, Attachment, CatalogPlugin, PluginStatus } from '../../shared/types' import { useThemeStore } from '../theme' +// @ts-expect-error — Vite resolves this asset at bundle time import notificationSrc from '../../../resources/notification.mp3' // ─── Known models ─── @@ -686,37 +687,8 @@ export const useSessionStore = create((set, get) => ({ break } - case 'task_update': { - // ── Text fallback ── - // text_chunk events (from stream_event deltas) are the primary render path. - // If they didn't arrive for this run (timing, partial stream, etc.), the - // assembled assistant event still has the full text — extract it here. - // "This run" = everything after the last user message. + case 'task_update': if (event.message?.content) { - const lastUserIdx = (() => { - for (let i = updated.messages.length - 1; i >= 0; i--) { - if (updated.messages[i].role === 'user') return i - } - return -1 - })() - const hasStreamedText = updated.messages - .slice(lastUserIdx + 1) - .some((m) => m.role === 'assistant' && !m.toolName) - - if (!hasStreamedText) { - const textContent = event.message.content - .filter((b) => b.type === 'text' && b.text) - .map((b) => b.text!) - .join('') - if (textContent) { - updated.messages = [ - ...updated.messages, - { id: nextMsgId(), role: 'assistant' as const, content: textContent, timestamp: Date.now() }, - ] - } - } - - // ── Tool card deduplication (unchanged) ── for (const block of event.message.content) { if (block.type === 'tool_use' && block.name) { const exists = updated.messages.find( @@ -740,7 +712,6 @@ export const useSessionStore = create((set, get) => ({ } } break - } case 'task_complete': updated.status = 'completed' @@ -754,26 +725,6 @@ export const useSessionStore = create((set, get) => ({ usage: event.usage, sessionId: event.sessionId, } - // ── Final text fallback ── - // If neither text_chunks nor task_update text produced an assistant message, - // use event.result (the CLI's assembled final output) as last resort. - if (event.result) { - const lastUserIdx2 = (() => { - for (let i = updated.messages.length - 1; i >= 0; i--) { - if (updated.messages[i].role === 'user') return i - } - return -1 - })() - const hasAnyText = updated.messages - .slice(lastUserIdx2 + 1) - .some((m) => m.role === 'assistant' && !m.toolName) - if (!hasAnyText) { - updated.messages = [ - ...updated.messages, - { id: nextMsgId(), role: 'assistant' as const, content: event.result, timestamp: Date.now() }, - ] - } - } // Mark as unread unless the user is actively viewing this tab // (active tab with card expanded). A collapsed active tab still // counts as "unread" — the user hasn't seen the response yet. diff --git a/src/shared/types.ts b/src/shared/types.ts index 9745613e..a8bf3b13 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -341,6 +341,8 @@ export const IPC = { WINDOW_SHOWN: 'clui:window-shown', SET_IGNORE_MOUSE_EVENTS: 'clui:set-ignore-mouse-events', IS_VISIBLE: 'clui:is-visible', + MOVE_WINDOW: 'clui:move-window', + GET_WINDOW_POSITION: 'clui:get-window-position', // Skill provisioning (main → renderer) SKILL_STATUS: 'clui:skill-status', diff --git a/tsconfig.json b/tsconfig.json index 6c851212..17961b3b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "declaration": true, "jsx": "react-jsx" }, - "include": ["src/**/*"], + "include": ["src/**/*", "resources/**/*"], "exclude": ["node_modules", "dist"] } From 438d3b4e9d183bed65e823363e5a098070f5d8ca Mon Sep 17 00:00:00 2001 From: Edmund Amoye Date: Wed, 18 Mar 2026 12:11:25 -0400 Subject: [PATCH 2/2] backup: add claude action log Co-Authored-By: Claude Opus 4.6 (1M context) --- claude-action-log.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 claude-action-log.md diff --git a/claude-action-log.md b/claude-action-log.md new file mode 100644 index 00000000..82c36254 --- /dev/null +++ b/claude-action-log.md @@ -0,0 +1,10 @@ +# Claude Action Log — clui-cc-public + +This file keeps a record of everything Claude does in this project. Each entry explains what happened and why, in plain English. + +--- + +## 2026-03-18 + +**Session:** Initial log file created. +- This log was set up so there's a running history of what Claude does in this project. Think of it like a diary — every time Claude makes a change, runs a command, or asks for permission, it gets written down here so you can look back and understand what happened.