Skip to content
Draft
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
270 changes: 270 additions & 0 deletions apps/frontend/src/main/ipc-handlers/task/shell-escape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
/**
* Unit tests for shell-escape utilities.
* Tests command injection prevention via path escaping.
*/

import { describe, it, expect } from 'vitest';
import { escapePathForShell, escapePathForAppleScript } from './shell-escape';

describe('escapePathForShell', () => {
describe('null byte injection prevention', () => {
it('should reject paths containing null bytes', () => {
expect(escapePathForShell('/path/with\0null', 'linux')).toBeNull();
expect(escapePathForShell('C:\\path\\with\0null', 'win32')).toBeNull();
});

it('should reject null byte at start of path', () => {
expect(escapePathForShell('\0/path', 'darwin')).toBeNull();
});

it('should reject null byte at end of path', () => {
expect(escapePathForShell('/path\0', 'linux')).toBeNull();
});
});

describe('newline injection prevention', () => {
it('should reject paths containing LF newlines', () => {
expect(escapePathForShell('/path/with\nnewline', 'linux')).toBeNull();
expect(escapePathForShell('C:\\path\\with\nnewline', 'win32')).toBeNull();
});

it('should reject paths containing CR newlines', () => {
expect(escapePathForShell('/path/with\rnewline', 'darwin')).toBeNull();
expect(escapePathForShell('C:\\path\\with\rnewline', 'win32')).toBeNull();
});

it('should reject paths containing CRLF', () => {
expect(escapePathForShell('/path/with\r\nnewline', 'linux')).toBeNull();
});
});

describe('Windows platform (win32)', () => {
it('should reject paths with < character', () => {
expect(escapePathForShell('C:\\path<file', 'win32')).toBeNull();
});

it('should reject paths with > character', () => {
expect(escapePathForShell('C:\\path>file', 'win32')).toBeNull();
});

it('should reject paths with | pipe character', () => {
expect(escapePathForShell('C:\\path|command', 'win32')).toBeNull();
});

it('should reject paths with & ampersand', () => {
expect(escapePathForShell('C:\\path&command', 'win32')).toBeNull();
});

it('should reject paths with ^ caret', () => {
expect(escapePathForShell('C:\\path^file', 'win32')).toBeNull();
});

it('should reject paths with % percent', () => {
expect(escapePathForShell('C:\\path%VAR%', 'win32')).toBeNull();
});

it('should reject paths with ! exclamation', () => {
expect(escapePathForShell('C:\\path!file', 'win32')).toBeNull();
});

it('should reject paths with ` backtick', () => {
expect(escapePathForShell('C:\\path`command`', 'win32')).toBeNull();
});

it('should escape double quotes with double-double quotes', () => {
expect(escapePathForShell('C:\\path\\"file"', 'win32')).toBe('C:\\path\\""file""');
});

it('should pass through safe Windows paths unchanged', () => {
expect(escapePathForShell('C:\\Users\\name\\project', 'win32')).toBe('C:\\Users\\name\\project');
});

it('should allow paths with spaces', () => {
expect(escapePathForShell('C:\\Program Files\\app', 'win32')).toBe('C:\\Program Files\\app');
});

it('should allow paths with parentheses', () => {
expect(escapePathForShell('C:\\Program Files (x86)\\app', 'win32')).toBe('C:\\Program Files (x86)\\app');
});
});

describe('Unix platforms (linux, darwin)', () => {
it('should escape single quotes with quote-escape-quote pattern', () => {
const result = escapePathForShell("/path/with'quote", 'linux');
expect(result).toBe("/path/with'\\''quote");
});

it('should handle multiple single quotes', () => {
const result = escapePathForShell("it's a 'test'", 'darwin');
expect(result).toBe("it'\\''s a '\\''test'\\''");
});

it('should pass through safe Unix paths unchanged', () => {
expect(escapePathForShell('/usr/local/bin', 'linux')).toBe('/usr/local/bin');
expect(escapePathForShell('/Users/name/project', 'darwin')).toBe('/Users/name/project');
});

it('should allow paths with spaces', () => {
expect(escapePathForShell('/path/with spaces/file', 'linux')).toBe('/path/with spaces/file');
});

it('should allow paths with special characters (not injection vectors)', () => {
// These are safe when single-quoted in bash
expect(escapePathForShell('/path/$var', 'linux')).toBe('/path/$var');
expect(escapePathForShell('/path/`cmd`', 'darwin')).toBe('/path/`cmd`');
expect(escapePathForShell('/path/!history', 'linux')).toBe('/path/!history');
});

it('should allow paths with glob characters', () => {
// Safe when single-quoted
expect(escapePathForShell('/path/*.txt', 'linux')).toBe('/path/*.txt');
expect(escapePathForShell('/path/[abc]', 'darwin')).toBe('/path/[abc]');
});
});

describe('edge cases', () => {
it('should handle empty string', () => {
expect(escapePathForShell('', 'linux')).toBe('');
expect(escapePathForShell('', 'win32')).toBe('');
});

it('should handle path with only quotes', () => {
expect(escapePathForShell("'''", 'linux')).toBe("'\\'''\\'''\\''");
expect(escapePathForShell('"""', 'win32')).toBe('""""""');
});

it('should handle very long paths', () => {
const longPath = '/a'.repeat(1000);
expect(escapePathForShell(longPath, 'linux')).toBe(longPath);
});

it('should handle Unicode characters', () => {
expect(escapePathForShell('/path/日本語/файл', 'linux')).toBe('/path/日本語/файл');
expect(escapePathForShell('C:\\путь\\文件', 'win32')).toBe('C:\\путь\\文件');
});

it('should handle emoji in paths', () => {
expect(escapePathForShell('/path/📁/file', 'darwin')).toBe('/path/📁/file');
});
});

describe('command injection attack patterns', () => {
it('should block semicolon command chaining on Windows via newline', () => {
// Attacker might try: path; malicious_command
// But semicolons are actually safe on Windows with proper quoting
// The danger comes from newlines which we block
expect(escapePathForShell('path\n; malicious', 'win32')).toBeNull();
});

it('should block pipe injection on Windows', () => {
expect(escapePathForShell('path | evil-command', 'win32')).toBeNull();
});

it('should block command substitution on Windows', () => {
expect(escapePathForShell('path & cmd /c evil', 'win32')).toBeNull();
});

it('should escape quote escapes on Unix', () => {
// Attacker might try to break out with: ' ; evil '
const result = escapePathForShell("' ; evil '", 'linux');
expect(result).toBe("'\\'' ; evil '\\''");
});

it('should block null byte truncation attacks', () => {
// Attacker might try: /safe/path\0/../../etc/passwd
expect(escapePathForShell('/safe/path\0/../../etc/passwd', 'linux')).toBeNull();
});
});
});

describe('escapePathForAppleScript', () => {
describe('null byte injection prevention', () => {
it('should reject paths containing null bytes', () => {
expect(escapePathForAppleScript('/path/with\0null')).toBeNull();
});

it('should reject null byte at start of path', () => {
expect(escapePathForAppleScript('\0/path')).toBeNull();
});

it('should reject null byte at end of path', () => {
expect(escapePathForAppleScript('/path\0')).toBeNull();
});
});

describe('newline injection prevention', () => {
it('should reject paths containing LF newlines', () => {
expect(escapePathForAppleScript('/path/with\nnewline')).toBeNull();
});

it('should reject paths containing CR newlines', () => {
expect(escapePathForAppleScript('/path/with\rnewline')).toBeNull();
});

it('should reject paths containing CRLF', () => {
expect(escapePathForAppleScript('/path/with\r\nnewline')).toBeNull();
});
});

describe('AppleScript escaping', () => {
it('should escape backslashes', () => {
expect(escapePathForAppleScript('/path\\with\\backslashes')).toBe('/path\\\\with\\\\backslashes');
});

it('should escape double quotes', () => {
expect(escapePathForAppleScript('/path/with"quotes"')).toBe('/path/with\\"quotes\\"');
});

it('should escape both backslashes and double quotes', () => {
expect(escapePathForAppleScript('/path\\"complex"')).toBe('/path\\\\\\"complex\\"');
});

it('should pass through safe paths unchanged', () => {
expect(escapePathForAppleScript('/Users/name/project')).toBe('/Users/name/project');
});

it('should allow paths with spaces', () => {
expect(escapePathForAppleScript('/path/with spaces/file')).toBe('/path/with spaces/file');
});

it('should allow single quotes (safe in AppleScript double-quoted strings)', () => {
expect(escapePathForAppleScript("/path/with'quote")).toBe("/path/with'quote");
});
});

describe('edge cases', () => {
it('should handle empty string', () => {
expect(escapePathForAppleScript('')).toBe('');
});

it('should handle path with only double quotes', () => {
expect(escapePathForAppleScript('"""')).toBe('\\"\\"\\"');
});

it('should handle path with only backslashes', () => {
expect(escapePathForAppleScript('\\\\\\')).toBe('\\\\\\\\\\\\');
});

it('should handle Unicode characters', () => {
expect(escapePathForAppleScript('/path/日本語/файл')).toBe('/path/日本語/файл');
});

it('should handle emoji in paths', () => {
expect(escapePathForAppleScript('/path/📁/file')).toBe('/path/📁/file');
});
});

describe('command injection prevention', () => {
it('should escape double quotes used in injection attempts', () => {
// Attacker might try to break out of the AppleScript string
const result = escapePathForAppleScript('/path" & do shell script "evil"');
expect(result).toBe('/path\\" & do shell script \\"evil\\"');
});

it('should escape backslash escape attempts', () => {
// Attacker might try to use backslash to escape the escaping
const result = escapePathForAppleScript('/path\\"');
expect(result).toBe('/path\\\\\\"');
});
});
});
68 changes: 68 additions & 0 deletions apps/frontend/src/main/ipc-handlers/task/shell-escape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Shell path escaping utilities for secure command execution.
*
* These functions prevent command injection by properly escaping
* or rejecting dangerous characters in file paths.
*/

/**
* Escape a path for use in shell commands.
* Prevents command injection by escaping or rejecting dangerous characters.
*
* @param filePath - The path to escape
* @param platform - Target platform ('win32', 'darwin', 'linux')
* @returns Escaped path string safe for shell use, or null if path is invalid
*/
export function escapePathForShell(filePath: string, platform: NodeJS.Platform): string | null {
// Reject paths with null bytes (always dangerous)
if (filePath.includes('\0')) {
return null;
}

// Reject paths with newlines (can break command structure)
if (filePath.includes('\n') || filePath.includes('\r')) {
return null;
}

if (platform === 'win32') {
// Windows: Reject paths with characters that could escape cmd.exe quoting
// These characters can break out of double-quoted strings in cmd
const dangerousWinChars = /[<>|&^%!`]/;
if (dangerousWinChars.test(filePath)) {
return null;
}
// Double-quote the path (already done in caller, but escape any internal quotes)
return filePath.replace(/"/g, '""');
} else {
// Unix (macOS/Linux): Use single quotes and escape any internal single quotes
// Single-quoted strings in bash treat everything literally except single quotes
// Escape ' as '\'' (end quote, escaped quote, start quote)
return filePath.replace(/'/g, "'\\''");
}
Comment on lines +27 to +41
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use platform abstraction helpers instead of string checks.

Line 27 hardcodes platform === 'win32'. In apps/frontend/src/main/**, use the shared platform helpers (isWindows()/isMacOS()/isLinux()) to keep detection centralized and consistent (and handle non-standard/unknown platforms). As per coding guidelines, use the platform abstraction utilities instead of direct platform checks.

🤖 Prompt for AI Agents
In `@apps/frontend/src/main/ipc-handlers/task/shell-escape.ts` around lines 27 -
41, Replace the direct platform string check (platform === 'win32') in the
shell-escape logic with the shared platform helpers: import and use isWindows()
for the Windows branch and isMacOS()/isLinux() (or a combined "isUnix" check)
for the else branch so detection is centralized; update the code path around
dangerousWinChars, the filePath.replace('"', '""') Windows handling, and the
Unix single-quote escaping (filePath.replace(/'/g, "'\\''")) to be executed
under the appropriate helper-based conditions, and ensure you add the import for
the platform helpers at the top of the module.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function intentionally takes platform as a parameter rather than using internal helpers. This design choice enables:

  1. Testability - Tests can pass any platform value without mocking globals
  2. Pure function - No side effects or global dependencies
  3. Explicit contracts - Callers explicitly specify target platform

The calling code in worktree-handlers.ts already uses the platform helpers (isWindows(), isMacOS()) for control flow decisions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@youngmrz, you're absolutely right—thank you for the clarification! Accepting platform as a parameter is a solid design choice here. It keeps escapePathForShell pure and highly testable (as demonstrated by your comprehensive test suite), and as you noted, the actual platform detection still happens correctly in worktree-handlers.ts using the platform helpers.

This approach doesn't violate the platform abstraction principles since the detection layer uses the helpers and this utility remains a reusable, side-effect-free building block. Great architectural separation of concerns!


🧠 Learnings used
Learnt from: CR
Repo: AndyMik90/Auto-Claude PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-01-16T09:10:31.701Z
Learning: Applies to apps/frontend/src/main/**/*.{ts,tsx} : Use platform abstraction functions like `isWindows()`, `isMacOS()`, `isLinux()`, `getPathDelimiter()`, `getExecutableExtension()`, `findExecutable()`, `joinPaths()` from the platform module instead of hardcoding paths or platform checks

Learnt from: CR
Repo: AndyMik90/Auto-Claude PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-01-16T09:10:31.701Z
Learning: Applies to **/*.{ts,tsx,js,py} : Never hardcode platform-specific paths like `C:\Program Files\`, `/opt/homebrew/bin/`, or `/usr/local/bin/` directly in code

Learnt from: CR
Repo: AndyMik90/Auto-Claude PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-01-16T09:10:31.701Z
Learning: Applies to **/*.{ts,tsx,js,py} : Do not check `process.platform` directly in code - always import platform detection functions from the platform abstraction module

Learnt from: CR
Repo: AndyMik90/Auto-Claude PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-01-16T09:10:31.701Z
Learning: Applies to **/*.test.{ts,tsx,js,py} : Write tests for platform-specific code that mock `process.platform` or platform detection functions to test all three platforms (Windows, macOS, Linux)

Learnt from: CR
Repo: AndyMik90/Auto-Claude PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-01-16T09:10:31.702Z
Learning: When fixing platform-specific bugs, ensure the fix doesn't break other platforms and rely on multi-platform CI to validate (Windows, macOS, Linux)

Learnt from: CR
Repo: AndyMik90/Auto-Claude PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-01-16T09:10:31.701Z
Learning: When adding new platform-specific code, add it to dedicated platform abstraction modules (frontend: `apps/frontend/src/main/platform/`, backend: `apps/backend/core/platform/`) rather than scattered throughout the codebase

Learnt from: CR
Repo: AndyMik90/Auto-Claude PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-01-16T09:10:31.701Z
Learning: Applies to apps/backend/**/*.py : Use the platform abstraction module for path handling and executable detection in Python backend code

}

/**
* Escape a path for use in AppleScript double-quoted strings.
* AppleScript uses different escaping rules than POSIX shells.
*
* @param filePath - The path to escape (should already be validated)
* @returns Escaped path string safe for AppleScript double-quoted context
*/
export function escapePathForAppleScript(filePath: string): string | null {
// Reject paths with null bytes (always dangerous)
if (filePath.includes('\0')) {
return null;
}

// Reject paths with newlines (can break command structure)
if (filePath.includes('\n') || filePath.includes('\r')) {
return null;
}

// AppleScript double-quoted strings require escaping:
// 1. Backslashes must be escaped as \\
// 2. Double quotes must be escaped as \"
return filePath
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/"/g, '\\"'); // Then escape double quotes
}
Loading