fix: address nitpick feedback from PR #423

## Security Fix (Command Injection)
- Use `execFile` with argument arrays instead of string interpolation
- Add `safeOpenInEditor` helper that properly handles `open -a` commands
- Validate that worktreePath is an absolute path before execution
- Prevents shell metacharacter injection attacks

## Shared Type Definition
- Move `EditorInfo` interface to `@automaker/types` package
- Server and UI now import from shared package to prevent drift
- Re-export from use-available-editors.ts for convenience

## Remove Unused Code
- Remove unused `defaultEditorName` prop from WorktreeActionsDropdown
- Remove prop from WorktreeTab component interface
- Remove useDefaultEditor hook usage from WorktreePanel
- Export new hooks from hooks/index.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Stefan de Vogelaere
2026-01-11 16:37:05 +01:00
parent ac87594b5d
commit 33dd9ae347
8 changed files with 54 additions and 28 deletions

View File

@@ -4,18 +4,15 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { exec } from 'child_process'; import { exec, execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { homedir } from 'os'; import { homedir } from 'os';
import { isAbsolute } from 'path';
import type { EditorInfo } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
// Editor detection with caching
interface EditorInfo {
name: string;
command: string;
}
let cachedEditor: EditorInfo | null = null; let cachedEditor: EditorInfo | null = null;
let cachedEditors: EditorInfo[] | null = null; let cachedEditors: EditorInfo[] | null = null;
@@ -177,6 +174,21 @@ export function createGetDefaultEditorHandler() {
}; };
} }
/**
* Safely execute an editor command with a path argument
* Uses execFile to prevent command injection
*/
async function safeOpenInEditor(command: string, targetPath: string): Promise<void> {
// Handle 'open -a "AppPath"' style commands (macOS)
if (command.startsWith('open -a ')) {
const appPath = command.replace('open -a ', '').replace(/"/g, '');
await execFileAsync('open', ['-a', appPath, targetPath]);
} else {
// Simple commands like 'code', 'cursor', 'zed', etc.
await execFileAsync(command, [targetPath]);
}
}
export function createOpenInEditorHandler() { export function createOpenInEditorHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
@@ -193,6 +205,15 @@ export function createOpenInEditorHandler() {
return; return;
} }
// Security: Validate that worktreePath is an absolute path
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}
// Use specified editor command or detect default // Use specified editor command or detect default
let editor: EditorInfo; let editor: EditorInfo;
if (editorCommand) { if (editorCommand) {
@@ -205,7 +226,7 @@ export function createOpenInEditorHandler() {
} }
try { try {
await execAsync(`${editor.command} "${worktreePath}"`); await safeOpenInEditor(editor.command, worktreePath);
res.json({ res.json({
success: true, success: true,
result: { result: {
@@ -216,21 +237,21 @@ export function createOpenInEditorHandler() {
} catch (editorError) { } catch (editorError) {
// If the detected editor fails, try opening in default file manager as fallback // If the detected editor fails, try opening in default file manager as fallback
const platform = process.platform; const platform = process.platform;
let openCommand: string; let fallbackCommand: string;
let fallbackName: string; let fallbackName: string;
if (platform === 'darwin') { if (platform === 'darwin') {
openCommand = `open "${worktreePath}"`; fallbackCommand = 'open';
fallbackName = 'Finder'; fallbackName = 'Finder';
} else if (platform === 'win32') { } else if (platform === 'win32') {
openCommand = `explorer "${worktreePath}"`; fallbackCommand = 'explorer';
fallbackName = 'Explorer'; fallbackName = 'Explorer';
} else { } else {
openCommand = `xdg-open "${worktreePath}"`; fallbackCommand = 'xdg-open';
fallbackName = 'File Manager'; fallbackName = 'File Manager';
} }
await execAsync(openCommand); await execFileAsync(fallbackCommand, [worktreePath]);
res.json({ res.json({
success: true, success: true,
result: { result: {

View File

@@ -34,7 +34,6 @@ import { getEditorIcon } from '@/components/icons/editor-icons';
interface WorktreeActionsDropdownProps { interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo; worktree: WorktreeInfo;
isSelected: boolean; isSelected: boolean;
defaultEditorName: string;
aheadCount: number; aheadCount: number;
behindCount: number; behindCount: number;
isPulling: boolean; isPulling: boolean;
@@ -60,7 +59,6 @@ interface WorktreeActionsDropdownProps {
export function WorktreeActionsDropdown({ export function WorktreeActionsDropdown({
worktree, worktree,
isSelected, isSelected,
defaultEditorName,
aheadCount, aheadCount,
behindCount, behindCount,
isPulling, isPulling,

View File

@@ -17,7 +17,6 @@ interface WorktreeTabProps {
isActivating: boolean; isActivating: boolean;
isDevServerRunning: boolean; isDevServerRunning: boolean;
devServerInfo?: DevServerInfo; devServerInfo?: DevServerInfo;
defaultEditorName: string;
branches: BranchInfo[]; branches: BranchInfo[];
filteredBranches: BranchInfo[]; filteredBranches: BranchInfo[];
branchFilter: string; branchFilter: string;
@@ -58,7 +57,6 @@ export function WorktreeTab({
isActivating, isActivating,
isDevServerRunning, isDevServerRunning,
devServerInfo, devServerInfo,
defaultEditorName,
branches, branches,
filteredBranches, filteredBranches,
branchFilter, branchFilter,
@@ -315,7 +313,6 @@ export function WorktreeTab({
<WorktreeActionsDropdown <WorktreeActionsDropdown
worktree={worktree} worktree={worktree}
isSelected={isSelected} isSelected={isSelected}
defaultEditorName={defaultEditorName}
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
isPulling={isPulling} isPulling={isPulling}

View File

@@ -2,5 +2,5 @@ export { useWorktrees } from './use-worktrees';
export { useDevServers } from './use-dev-servers'; export { useDevServers } from './use-dev-servers';
export { useBranches } from './use-branches'; export { useBranches } from './use-branches';
export { useWorktreeActions } from './use-worktree-actions'; export { useWorktreeActions } from './use-worktree-actions';
export { useDefaultEditor } from './use-default-editor';
export { useRunningFeatures } from './use-running-features'; export { useRunningFeatures } from './use-running-features';
export { useAvailableEditors, useEffectiveDefaultEditor } from './use-available-editors';

View File

@@ -2,13 +2,12 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import type { EditorInfo } from '@automaker/types';
const logger = createLogger('AvailableEditors'); const logger = createLogger('AvailableEditors');
export interface EditorInfo { // Re-export EditorInfo for convenience
name: string; export type { EditorInfo };
command: string;
}
export function useAvailableEditors() { export function useAvailableEditors() {
const [editors, setEditors] = useState<EditorInfo[]>([]); const [editors, setEditors] = useState<EditorInfo[]>([]);

View File

@@ -9,7 +9,6 @@ import {
useDevServers, useDevServers,
useBranches, useBranches,
useWorktreeActions, useWorktreeActions,
useDefaultEditor,
useRunningFeatures, useRunningFeatures,
} from './hooks'; } from './hooks';
import { WorktreeTab } from './components'; import { WorktreeTab } from './components';
@@ -76,8 +75,6 @@ export function WorktreePanel({
fetchBranches, fetchBranches,
}); });
const { defaultEditorName } = useDefaultEditor();
const { hasRunningFeatures } = useRunningFeatures({ const { hasRunningFeatures } = useRunningFeatures({
runningFeatureIds, runningFeatureIds,
features, features,
@@ -192,7 +189,6 @@ export function WorktreePanel({
isActivating={isActivating} isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)} isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)} devServerInfo={getDevServerInfo(mainWorktree)}
defaultEditorName={defaultEditorName}
branches={branches} branches={branches}
filteredBranches={filteredBranches} filteredBranches={filteredBranches}
branchFilter={branchFilter} branchFilter={branchFilter}
@@ -247,7 +243,6 @@ export function WorktreePanel({
isActivating={isActivating} isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)} isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)} devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches} branches={branches}
filteredBranches={filteredBranches} filteredBranches={filteredBranches}
branchFilter={branchFilter} branchFilter={branchFilter}

13
libs/types/src/editor.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Editor types for the "Open In" functionality
*/
/**
* Information about an available code editor
*/
export interface EditorInfo {
/** Display name of the editor (e.g., "VS Code", "Cursor") */
name: string;
/** CLI command or open command to launch the editor */
command: string;
}

View File

@@ -204,6 +204,9 @@ export type {
// Port configuration // Port configuration
export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './ports.js'; export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './ports.js';
// Editor types
export type { EditorInfo } from './editor.js';
// Ideation types // Ideation types
export type { export type {
IdeaCategory, IdeaCategory,