feat: implement secure file system access and path validation

- Introduced a restricted file system wrapper to ensure all file operations are confined to the script's directory, enhancing security.
- Updated various modules to utilize the new secure file system methods, replacing direct fs calls with validated operations.
- Enhanced path validation in the server routes and context loaders to prevent unauthorized access to the file system.
- Adjusted environment variable handling to use centralized methods for reading and writing API keys, ensuring consistent security practices.

This change improves the overall security posture of the application by enforcing strict file access controls and validating paths before any operations are performed.
This commit is contained in:
Test User
2025-12-31 18:03:01 -05:00
parent 847a8ff327
commit 2b89b0606c
21 changed files with 1577 additions and 2198 deletions

View File

@@ -24,7 +24,7 @@ ALLOWED_ROOT_DIRECTORY=
# CORS origin - which domains can access the API
# Use "*" for development, set specific origin for production
CORS_ORIGIN=*
CORS_ORIGIN=http://localhost:3007
# ============================================
# OPTIONAL - Server

View File

@@ -10,8 +10,8 @@
import type { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import * as secureFs from './secure-fs.js';
const DATA_DIR = process.env.DATA_DIR || './data';
const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
@@ -41,8 +41,8 @@ setInterval(() => {
*/
function loadSessions(): void {
try {
if (fs.existsSync(SESSIONS_FILE)) {
const data = fs.readFileSync(SESSIONS_FILE, 'utf-8');
if (secureFs.existsSync(SESSIONS_FILE)) {
const data = secureFs.readFileSync(SESSIONS_FILE, 'utf-8') as string;
const sessions = JSON.parse(data) as Array<
[string, { createdAt: number; expiresAt: number }]
>;
@@ -74,12 +74,9 @@ function loadSessions(): void {
*/
async function saveSessions(): Promise<void> {
try {
await fs.promises.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
await secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
const sessions = Array.from(validSessions.entries());
await fs.promises.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
encoding: 'utf-8',
mode: 0o600,
});
await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), 'utf-8');
} catch (error) {
console.error('[Auth] Failed to save sessions:', error);
}
@@ -101,8 +98,8 @@ function ensureApiKey(): string {
// Try to read from file
try {
if (fs.existsSync(API_KEY_FILE)) {
const key = fs.readFileSync(API_KEY_FILE, 'utf-8').trim();
if (secureFs.existsSync(API_KEY_FILE)) {
const key = (secureFs.readFileSync(API_KEY_FILE, 'utf-8') as string).trim();
if (key) {
console.log('[Auth] Loaded API key from file');
return key;
@@ -115,8 +112,8 @@ function ensureApiKey(): string {
// Generate new key
const newKey = crypto.randomUUID();
try {
fs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
fs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8' });
console.log('[Auth] Generated new API key');
} catch (error) {
console.error('[Auth] Failed to save API key:', error);

View File

@@ -6,6 +6,7 @@
import { secureFs } from '@automaker/platform';
export const {
// Async methods
access,
readFile,
writeFile,
@@ -20,6 +21,16 @@ export const {
lstat,
joinPath,
resolvePath,
// Sync methods
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
readdirSync,
statSync,
accessSync,
unlinkSync,
rmSync,
// Throttling configuration and monitoring
configureThrottling,
getThrottlingConfig,

View File

@@ -15,7 +15,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger, readImageAsBase64 } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import * as fs from 'fs';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
@@ -57,13 +57,13 @@ function filterSafeHeaders(headers: Record<string, unknown>): Record<string, unk
*/
function findActualFilePath(requestedPath: string): string | null {
// First, try the exact path
if (fs.existsSync(requestedPath)) {
if (secureFs.existsSync(requestedPath)) {
return requestedPath;
}
// Try with Unicode normalization
const normalizedPath = requestedPath.normalize('NFC');
if (fs.existsSync(normalizedPath)) {
if (secureFs.existsSync(normalizedPath)) {
return normalizedPath;
}
@@ -72,12 +72,12 @@ function findActualFilePath(requestedPath: string): string | null {
const dir = path.dirname(requestedPath);
const baseName = path.basename(requestedPath);
if (!fs.existsSync(dir)) {
if (!secureFs.existsSync(dir)) {
return null;
}
try {
const files = fs.readdirSync(dir);
const files = secureFs.readdirSync(dir);
// Normalize the requested basename for comparison
// Replace various space-like characters with regular space for comparison
@@ -281,9 +281,9 @@ export function createDescribeImageHandler(
}
// Log path + stats (this is often where issues start: missing file, perms, size)
let stat: fs.Stats | null = null;
let stat: ReturnType<typeof secureFs.statSync> | null = null;
try {
stat = fs.statSync(actualPath);
stat = secureFs.statSync(actualPath);
logger.info(
`[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}`
);

View File

@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import os from 'os';
import path from 'path';
import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
import { getAllowedRootDirectory, PathNotAllowedError, isPathAllowed } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createBrowseHandler() {
@@ -40,9 +40,16 @@ export function createBrowseHandler() {
return drives;
};
// Get parent directory
// Get parent directory - only if it's within the allowed root
const parentPath = path.dirname(targetPath);
const hasParent = parentPath !== targetPath;
// Determine if parent navigation should be allowed:
// 1. Must have a different parent (not at filesystem root)
// 2. If ALLOWED_ROOT_DIRECTORY is set, parent must be within it
const hasParent = parentPath !== targetPath && isPathAllowed(parentPath);
// Security: Don't expose parent path outside allowed root
const safeParentPath = hasParent ? parentPath : null;
// Get available drives
const drives = await detectDrives();
@@ -70,7 +77,7 @@ export function createBrowseHandler() {
res.json({
success: true,
currentPath: targetPath,
parentPath: hasParent ? parentPath : null,
parentPath: safeParentPath,
directories,
drives,
});
@@ -84,7 +91,7 @@ export function createBrowseHandler() {
res.json({
success: true,
currentPath: targetPath,
parentPath: hasParent ? parentPath : null,
parentPath: safeParentPath,
directories: [],
drives,
warning:

View File

@@ -5,7 +5,7 @@
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { isPathAllowed } from '@automaker/platform';
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createValidatePathHandler() {
@@ -20,6 +20,20 @@ export function createValidatePathHandler() {
const resolvedPath = path.resolve(filePath);
// Validate path against ALLOWED_ROOT_DIRECTORY before checking if it exists
if (!isPathAllowed(resolvedPath)) {
const allowedRoot = getAllowedRootDirectory();
const errorMessage = allowedRoot
? `Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}`
: `Path not allowed: ${filePath}`;
res.status(403).json({
success: false,
error: errorMessage,
isAllowed: false,
});
return;
}
// Check if path exists
try {
const stats = await secureFs.stat(resolvedPath);
@@ -32,7 +46,7 @@ export function createValidatePathHandler() {
res.json({
success: true,
path: resolvedPath,
isAllowed: isPathAllowed(resolvedPath),
isAllowed: true,
});
} catch {
res.status(400).json({ success: false, error: 'Path does not exist' });

View File

@@ -4,7 +4,7 @@
import { createLogger } from '@automaker/utils';
import path from 'path';
import fs from 'fs/promises';
import { secureFs } from '@automaker/platform';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('Setup');
@@ -35,36 +35,13 @@ export function getAllApiKeys(): Record<string, string> {
/**
* Helper to persist API keys to .env file
* Uses centralized secureFs.writeEnvKey for path validation
*/
export async function persistApiKeyToEnv(key: string, value: string): Promise<void> {
const envPath = path.join(process.cwd(), '.env');
try {
let envContent = '';
try {
envContent = await fs.readFile(envPath, 'utf-8');
} catch {
// .env file doesn't exist, we'll create it
}
// Parse existing env content
const lines = envContent.split('\n');
const keyRegex = new RegExp(`^${key}=`);
let found = false;
const newLines = lines.map((line) => {
if (keyRegex.test(line)) {
found = true;
return `${key}=${value}`;
}
return line;
});
if (!found) {
// Add the key at the end
newLines.push(`${key}=${value}`);
}
await fs.writeFile(envPath, newLines.join('\n'));
await secureFs.writeEnvKey(envPath, key, value);
logger.info(`[Setup] Persisted ${key} to .env file`);
} catch (error) {
logger.error(`[Setup] Failed to persist ${key} to .env:`, error);

View File

@@ -4,9 +4,7 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform';
import { getApiKey } from './common.js';
const execAsync = promisify(exec);
@@ -37,30 +35,12 @@ export async function getClaudeStatus() {
// Version command might not be available
}
} catch {
// Not in PATH, try common locations based on platform
const commonPaths = isWindows
? (() => {
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return [
// Windows-specific paths
path.join(os.homedir(), '.local', 'bin', 'claude.exe'),
path.join(appData, 'npm', 'claude.cmd'),
path.join(appData, 'npm', 'claude'),
path.join(appData, '.npm-global', 'bin', 'claude.cmd'),
path.join(appData, '.npm-global', 'bin', 'claude'),
];
})()
: [
// Unix (Linux/macOS) paths
path.join(os.homedir(), '.local', 'bin', 'claude'),
path.join(os.homedir(), '.claude', 'local', 'claude'),
'/usr/local/bin/claude',
path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
];
// Not in PATH, try common locations from centralized system paths
const commonPaths = getClaudeCliPaths();
for (const p of commonPaths) {
try {
await fs.access(p);
if (await systemPathAccess(p)) {
cliPath = p;
installed = true;
method = 'local';
@@ -73,6 +53,7 @@ export async function getClaudeStatus() {
// Version command might not be available
}
break;
}
} catch {
// Not found at this path
}
@@ -82,7 +63,7 @@ export async function getClaudeStatus() {
// Check authentication - detect all possible auth methods
// Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth
// apiKeys.anthropic stores direct API keys for pay-per-use
let auth = {
const auth = {
authenticated: false,
method: 'none' as string,
hasCredentialsFile: false,
@@ -97,76 +78,36 @@ export async function getClaudeStatus() {
hasRecentActivity: false,
};
const claudeDir = path.join(os.homedir(), '.claude');
// Use centralized system paths to check Claude authentication indicators
const indicators = await getClaudeAuthIndicators();
// Check for recent Claude CLI activity - indicates working authentication
// The stats-cache.json file is only populated when the CLI is working properly
const statsCachePath = path.join(claudeDir, 'stats-cache.json');
try {
const statsContent = await fs.readFile(statsCachePath, 'utf-8');
const stats = JSON.parse(statsContent);
// Check if there's any activity (which means the CLI is authenticated and working)
if (stats.dailyActivity && stats.dailyActivity.length > 0) {
// Check for recent activity (indicates working authentication)
if (indicators.hasStatsCacheWithActivity) {
auth.hasRecentActivity = true;
auth.hasCliAuth = true;
auth.authenticated = true;
auth.method = 'cli_authenticated';
}
} catch {
// Stats file doesn't exist or is invalid
}
// Check for settings.json - indicates CLI has been set up
const settingsPath = path.join(claudeDir, 'settings.json');
try {
await fs.access(settingsPath);
// If settings exist but no activity, CLI might be set up but not authenticated
if (!auth.hasCliAuth) {
// Try to check for other indicators of auth
const sessionsDir = path.join(claudeDir, 'projects');
try {
const sessions = await fs.readdir(sessionsDir);
if (sessions.length > 0) {
// Check for settings + sessions (indicates CLI is set up)
if (!auth.hasCliAuth && indicators.hasSettingsFile && indicators.hasProjectsSessions) {
auth.hasCliAuth = true;
auth.authenticated = true;
auth.method = 'cli_authenticated';
}
} catch {
// Sessions directory doesn't exist
}
}
} catch {
// Settings file doesn't exist
}
// Check for credentials file (OAuth tokens from claude login)
// Note: Claude CLI may use ".credentials.json" (hidden) or "credentials.json" depending on version/platform
const credentialsPaths = [
path.join(claudeDir, '.credentials.json'),
path.join(claudeDir, 'credentials.json'),
];
for (const credentialsPath of credentialsPaths) {
try {
const credentialsContent = await fs.readFile(credentialsPath, 'utf-8');
const credentials = JSON.parse(credentialsContent);
// Check credentials file
if (indicators.hasCredentialsFile && indicators.credentials) {
auth.hasCredentialsFile = true;
// Check what type of token is in credentials
if (credentials.oauth_token || credentials.access_token) {
if (indicators.credentials.hasOAuthToken) {
auth.hasStoredOAuthToken = true;
auth.oauthTokenValid = true;
auth.authenticated = true;
auth.method = 'oauth_token'; // Stored OAuth token from credentials file
} else if (credentials.api_key) {
auth.method = 'oauth_token';
} else if (indicators.credentials.hasApiKey) {
auth.apiKeyValid = true;
auth.authenticated = true;
auth.method = 'api_key'; // Stored API key in credentials file
}
break; // Found and processed credentials file
} catch {
// No credentials file at this path or invalid format
auth.method = 'api_key';
}
}
@@ -174,21 +115,21 @@ export async function getClaudeStatus() {
if (auth.hasEnvApiKey) {
auth.authenticated = true;
auth.apiKeyValid = true;
auth.method = 'api_key_env'; // API key from ANTHROPIC_API_KEY env var
auth.method = 'api_key_env';
}
// In-memory stored OAuth token (from setup wizard - subscription auth)
if (!auth.authenticated && getApiKey('anthropic_oauth_token')) {
auth.authenticated = true;
auth.oauthTokenValid = true;
auth.method = 'oauth_token'; // Stored OAuth token from setup wizard
auth.method = 'oauth_token';
}
// In-memory stored API key (from settings UI - pay-per-use)
if (!auth.authenticated && getApiKey('anthropic')) {
auth.authenticated = true;
auth.apiKeyValid = true;
auth.method = 'api_key'; // Manually stored API key
auth.method = 'api_key';
}
return {

View File

@@ -5,40 +5,22 @@
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import path from 'path';
import fs from 'fs/promises';
import { secureFs } from '@automaker/platform';
const logger = createLogger('Setup');
// In-memory storage reference (imported from common.ts pattern)
// We need to modify common.ts to export a deleteApiKey function
import { setApiKey } from '../common.js';
/**
* Remove an API key from the .env file
* Uses centralized secureFs.removeEnvKey for path validation
*/
async function removeApiKeyFromEnv(key: string): Promise<void> {
const envPath = path.join(process.cwd(), '.env');
try {
let envContent = '';
try {
envContent = await fs.readFile(envPath, 'utf-8');
} catch {
// .env file doesn't exist, nothing to delete
return;
}
// Parse existing env content and remove the key
const lines = envContent.split('\n');
const keyRegex = new RegExp(`^${key}=`);
const newLines = lines.filter((line) => !keyRegex.test(line));
// Remove empty lines at the end
while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') {
newLines.pop();
}
await fs.writeFile(envPath, newLines.join('\n') + (newLines.length > 0 ? '\n' : ''));
await secureFs.removeEnvKey(envPath, key);
logger.info(`[Setup] Removed ${key} from .env file`);
} catch (error) {
logger.error(`[Setup] Failed to remove ${key} from .env:`, error);

View File

@@ -5,27 +5,14 @@
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { getGitHubCliPaths, getExtendedPath, systemPathAccess } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations
const extendedPath = [
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
]
.filter(Boolean)
.join(':');
const execEnv = {
...process.env,
PATH: extendedPath,
PATH: getExtendedPath(),
};
export interface GhStatus {
@@ -55,25 +42,16 @@ async function getGhStatus(): Promise<GhStatus> {
status.path = stdout.trim().split(/\r?\n/)[0];
status.installed = true;
} catch {
// gh not in PATH, try common locations
const commonPaths = isWindows
? [
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'),
path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'),
]
: [
'/opt/homebrew/bin/gh',
'/usr/local/bin/gh',
path.join(os.homedir(), '.local', 'bin', 'gh'),
'/home/linuxbrew/.linuxbrew/bin/gh',
];
// gh not in PATH, try common locations from centralized system paths
const commonPaths = getGitHubCliPaths();
for (const p of commonPaths) {
try {
await fs.access(p);
if (await systemPathAccess(p)) {
status.path = p;
status.installed = true;
break;
}
} catch {
// Not found at this path
}

View File

@@ -22,12 +22,12 @@ export function createSessionsListHandler() {
}
export function createSessionsCreateHandler() {
return (req: Request, res: Response): void => {
return async (req: Request, res: Response): Promise<void> => {
try {
const terminalService = getTerminalService();
const { cwd, cols, rows, shell } = req.body;
const session = terminalService.createSession({
const session = await terminalService.createSession({
cwd,
cols: cols || 80,
rows: rows || 24,

View File

@@ -8,8 +8,13 @@
import * as pty from 'node-pty';
import { EventEmitter } from 'events';
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
// secureFs is used for user-controllable paths (working directory validation)
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
import * as secureFs from '../lib/secure-fs.js';
// System paths module handles shell binary checks and WSL detection
// These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing
import { systemPathExists, systemPathReadFileSync, getWslVersionPath } from '@automaker/platform';
// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
@@ -68,7 +73,7 @@ export class TerminalService extends EventEmitter {
if (platform === 'linux' && this.isWSL()) {
// In WSL, prefer the user's configured shell or bash
const userShell = process.env.SHELL || '/bin/bash';
if (fs.existsSync(userShell)) {
if (systemPathExists(userShell)) {
return { shell: userShell, args: ['--login'] };
}
return { shell: '/bin/bash', args: ['--login'] };
@@ -80,10 +85,10 @@ export class TerminalService extends EventEmitter {
const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
if (fs.existsSync(pwshCore)) {
if (systemPathExists(pwshCore)) {
return { shell: pwshCore, args: [] };
}
if (fs.existsSync(pwsh)) {
if (systemPathExists(pwsh)) {
return { shell: pwsh, args: [] };
}
return { shell: 'cmd.exe', args: [] };
@@ -92,10 +97,10 @@ export class TerminalService extends EventEmitter {
case 'darwin': {
// macOS: prefer user's shell, then zsh, then bash
const userShell = process.env.SHELL;
if (userShell && fs.existsSync(userShell)) {
if (userShell && systemPathExists(userShell)) {
return { shell: userShell, args: ['--login'] };
}
if (fs.existsSync('/bin/zsh')) {
if (systemPathExists('/bin/zsh')) {
return { shell: '/bin/zsh', args: ['--login'] };
}
return { shell: '/bin/bash', args: ['--login'] };
@@ -105,10 +110,10 @@ export class TerminalService extends EventEmitter {
default: {
// Linux: prefer user's shell, then bash, then sh
const userShell = process.env.SHELL;
if (userShell && fs.existsSync(userShell)) {
if (userShell && systemPathExists(userShell)) {
return { shell: userShell, args: ['--login'] };
}
if (fs.existsSync('/bin/bash')) {
if (systemPathExists('/bin/bash')) {
return { shell: '/bin/bash', args: ['--login'] };
}
return { shell: '/bin/sh', args: [] };
@@ -122,8 +127,9 @@ export class TerminalService extends EventEmitter {
isWSL(): boolean {
try {
// Check /proc/version for Microsoft/WSL indicators
if (fs.existsSync('/proc/version')) {
const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
const wslVersionPath = getWslVersionPath();
if (systemPathExists(wslVersionPath)) {
const version = systemPathReadFileSync(wslVersionPath, 'utf-8').toLowerCase();
return version.includes('microsoft') || version.includes('wsl');
}
// Check for WSL environment variable
@@ -157,8 +163,9 @@ export class TerminalService extends EventEmitter {
/**
* Validate and resolve a working directory path
* Includes basic sanitization against null bytes and path normalization
* Uses secureFs to enforce ALLOWED_ROOT_DIRECTORY for user-provided paths
*/
private resolveWorkingDirectory(requestedCwd?: string): string {
private async resolveWorkingDirectory(requestedCwd?: string): Promise<string> {
const homeDir = os.homedir();
// If no cwd requested, use home
@@ -187,15 +194,19 @@ export class TerminalService extends EventEmitter {
}
// Check if path exists and is a directory
// Using secureFs.stat to enforce ALLOWED_ROOT_DIRECTORY security boundary
// This prevents terminals from being opened in directories outside the allowed workspace
try {
const stat = fs.statSync(cwd);
if (stat.isDirectory()) {
const statResult = await secureFs.stat(cwd);
if (statResult.isDirectory()) {
return cwd;
}
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
return homeDir;
} catch {
console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`);
console.warn(
`[Terminal] Working directory does not exist or not allowed: ${cwd}, falling back to home`
);
return homeDir;
}
}
@@ -228,7 +239,7 @@ export class TerminalService extends EventEmitter {
* Create a new terminal session
* Returns null if the maximum session limit has been reached
*/
createSession(options: TerminalOptions = {}): TerminalSession | null {
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
// Check session limit
if (this.sessions.size >= maxSessions) {
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
@@ -241,7 +252,8 @@ export class TerminalService extends EventEmitter {
const shell = options.shell || detectedShell;
// Validate and resolve working directory
const cwd = this.resolveWorkingDirectory(options.cwd);
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
const cwd = await this.resolveWorkingDirectory(options.cwd);
// Build environment with some useful defaults
// These settings ensure consistent terminal behavior across platforms

View File

@@ -566,14 +566,15 @@ export class HttpApiClient implements ElectronAPI {
const result = await this.post<{
success: boolean;
path?: string;
isAllowed?: boolean;
error?: string;
}>('/api/fs/validate-path', { filePath: path });
if (result.success && result.path) {
if (result.success && result.path && result.isAllowed !== false) {
return { canceled: false, filePaths: [result.path] };
}
console.error('Invalid directory:', result.error);
console.error('Invalid directory:', result.error || 'Path not allowed');
return { canceled: true, filePaths: [] };
}

View File

@@ -3,15 +3,36 @@
*
* This version spawns the backend server and uses HTTP API for most operations.
* Only native features (dialogs, shell) use IPC.
*
* SECURITY: All file system access uses centralized methods from @automaker/platform.
*/
import path from 'path';
import { spawn, execSync, ChildProcess } from 'child_process';
import fs from 'fs';
import crypto from 'crypto';
import http, { Server } from 'http';
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
import {
findNodeExecutable,
buildEnhancedPath,
initAllowedPaths,
isPathAllowed,
getAllowedRootDirectory,
// Electron userData operations
setElectronUserDataPath,
electronUserDataReadFileSync,
electronUserDataWriteFileSync,
electronUserDataExists,
// Electron app bundle operations
setElectronAppPaths,
electronAppExists,
electronAppReadFileSync,
electronAppStatSync,
electronAppStat,
electronAppReadFile,
// System path operations
systemPathExists,
} from '@automaker/platform';
// Development environment
const isDev = !app.isPackaged;
@@ -64,21 +85,19 @@ let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
let apiKey: string | null = null;
/**
* Get path to API key file in user data directory
* Get the relative path to API key file within userData
*/
function getApiKeyPath(): string {
return path.join(app.getPath('userData'), '.api-key');
}
const API_KEY_FILENAME = '.api-key';
/**
* Ensure an API key exists - load from file or generate new one.
* This key is passed to the server for CSRF protection.
* Uses centralized electronUserData methods for path validation.
*/
function ensureApiKey(): string {
const keyPath = getApiKeyPath();
try {
if (fs.existsSync(keyPath)) {
const key = fs.readFileSync(keyPath, 'utf-8').trim();
if (electronUserDataExists(API_KEY_FILENAME)) {
const key = electronUserDataReadFileSync(API_KEY_FILENAME).trim();
if (key) {
apiKey = key;
console.log('[Electron] Loaded existing API key');
@@ -92,7 +111,7 @@ function ensureApiKey(): string {
// Generate new key
apiKey = crypto.randomUUID();
try {
fs.writeFileSync(keyPath, apiKey, { encoding: 'utf-8', mode: 0o600 });
electronUserDataWriteFileSync(API_KEY_FILENAME, apiKey, { encoding: 'utf-8', mode: 0o600 });
console.log('[Electron] Generated new API key');
} catch (error) {
console.error('[Electron] Failed to save API key:', error);
@@ -102,6 +121,7 @@ function ensureApiKey(): string {
/**
* Get icon path - works in both dev and production, cross-platform
* Uses centralized electronApp methods for path validation.
*/
function getIconPath(): string | null {
let iconFile: string;
@@ -117,29 +137,32 @@ function getIconPath(): string | null {
? path.join(__dirname, '../public', iconFile)
: path.join(__dirname, '../dist/public', iconFile);
if (!fs.existsSync(iconPath)) {
try {
if (!electronAppExists(iconPath)) {
console.warn(`[Electron] Icon not found at: ${iconPath}`);
return null;
}
} catch (error) {
console.warn(`[Electron] Icon check failed: ${iconPath}`, error);
return null;
}
return iconPath;
}
/**
* Get path to window bounds settings file
* Relative path to window bounds settings file within userData
*/
function getWindowBoundsPath(): string {
return path.join(app.getPath('userData'), 'window-bounds.json');
}
const WINDOW_BOUNDS_FILENAME = 'window-bounds.json';
/**
* Load saved window bounds from disk
* Uses centralized electronUserData methods for path validation.
*/
function loadWindowBounds(): WindowBounds | null {
try {
const boundsPath = getWindowBoundsPath();
if (fs.existsSync(boundsPath)) {
const data = fs.readFileSync(boundsPath, 'utf-8');
if (electronUserDataExists(WINDOW_BOUNDS_FILENAME)) {
const data = electronUserDataReadFileSync(WINDOW_BOUNDS_FILENAME);
const bounds = JSON.parse(data) as WindowBounds;
// Validate the loaded data has required fields
if (
@@ -159,11 +182,11 @@ function loadWindowBounds(): WindowBounds | null {
/**
* Save window bounds to disk
* Uses centralized electronUserData methods for path validation.
*/
function saveWindowBounds(bounds: WindowBounds): void {
try {
const boundsPath = getWindowBoundsPath();
fs.writeFileSync(boundsPath, JSON.stringify(bounds, null, 2), 'utf-8');
electronUserDataWriteFileSync(WINDOW_BOUNDS_FILENAME, JSON.stringify(bounds, null, 2));
console.log('[Electron] Window bounds saved');
} catch (error) {
console.warn('[Electron] Failed to save window bounds:', (error as Error).message);
@@ -241,6 +264,7 @@ function validateBounds(bounds: WindowBounds): WindowBounds {
/**
* Start static file server for production builds
* Uses centralized electronApp methods for serving static files from app bundle.
*/
async function startStaticServer(): Promise<void> {
const staticPath = path.join(__dirname, '../dist');
@@ -253,20 +277,24 @@ async function startStaticServer(): Promise<void> {
} else if (!path.extname(filePath)) {
// For client-side routing, serve index.html for paths without extensions
const possibleFile = filePath + '.html';
if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) {
try {
if (!electronAppExists(filePath) && !electronAppExists(possibleFile)) {
filePath = path.join(staticPath, 'index.html');
} else if (fs.existsSync(possibleFile)) {
} else if (electronAppExists(possibleFile)) {
filePath = possibleFile;
}
} catch {
filePath = path.join(staticPath, 'index.html');
}
}
fs.stat(filePath, (err, stats) => {
electronAppStat(filePath, (err, stats) => {
if (err || !stats?.isFile()) {
filePath = path.join(staticPath, 'index.html');
}
fs.readFile(filePath, (error, content) => {
if (error) {
electronAppReadFile(filePath, (error, content) => {
if (error || !content) {
response.writeHead(500);
response.end('Server Error');
return;
@@ -308,6 +336,7 @@ async function startStaticServer(): Promise<void> {
/**
* Start the backend server
* Uses centralized methods for path validation.
*/
async function startServer(): Promise<void> {
// Find Node.js executable (handles desktop launcher scenarios)
@@ -318,9 +347,18 @@ async function startServer(): Promise<void> {
const command = nodeResult.nodePath;
// Validate that the found Node executable actually exists
if (command !== 'node' && !fs.existsSync(command)) {
// systemPathExists is used because node-finder returns system paths
if (command !== 'node') {
try {
if (!systemPathExists(command)) {
throw new Error(
`Node.js executable not found at: ${command} (source: ${nodeResult.source})`
);
}
} catch {
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
}
}
let args: string[];
let serverPath: string;
@@ -332,9 +370,11 @@ async function startServer(): Promise<void> {
const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx');
let tsxCliPath: string;
if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) {
// Check for tsx in app bundle paths
try {
if (electronAppExists(path.join(serverNodeModules, 'dist/cli.mjs'))) {
tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
} else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) {
} else if (electronAppExists(path.join(rootNodeModules, 'dist/cli.mjs'))) {
tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs');
} else {
try {
@@ -345,13 +385,26 @@ async function startServer(): Promise<void> {
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
}
}
} catch {
try {
tsxCliPath = require.resolve('tsx/cli.mjs', {
paths: [path.join(__dirname, '../../server')],
});
} catch {
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
}
}
args = [tsxCliPath, 'watch', serverPath];
} else {
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
args = [serverPath];
if (!fs.existsSync(serverPath)) {
try {
if (!electronAppExists(serverPath)) {
throw new Error(`Server not found at: ${serverPath}`);
}
} catch {
throw new Error(`Server not found at: ${serverPath}`);
}
}
@@ -360,6 +413,13 @@ async function startServer(): Promise<void> {
? path.join(process.resourcesPath, 'server', 'node_modules')
: path.join(__dirname, '../../server/node_modules');
// Server root directory - where .env file is located
// In dev: apps/server (not apps/server/src)
// In production: resources/server
const serverRoot = app.isPackaged
? path.join(process.resourcesPath, 'server')
: path.join(__dirname, '../../server');
// Build enhanced PATH that includes Node.js directory (cross-platform)
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
if (enhancedPath !== process.env.PATH) {
@@ -383,10 +443,11 @@ async function startServer(): Promise<void> {
console.log('[Electron] Starting backend server...');
console.log('[Electron] Server path:', serverPath);
console.log('[Electron] Server root (cwd):', serverRoot);
console.log('[Electron] NODE_PATH:', serverNodeModules);
serverProcess = spawn(command, args, {
cwd: path.dirname(serverPath),
cwd: serverRoot,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
@@ -541,6 +602,28 @@ app.whenReady().then(async () => {
console.warn('[Electron] Failed to set userData path:', (error as Error).message);
}
// Initialize centralized path helpers for Electron
// This must be done before any file operations
setElectronUserDataPath(app.getPath('userData'));
// In development mode, allow access to the entire project root (for source files, node_modules, etc.)
// In production, only allow access to the built app directory and resources
if (isDev) {
// __dirname is apps/ui/dist-electron, so go up 3 levels to get project root
const projectRoot = path.join(__dirname, '../../..');
setElectronAppPaths([__dirname, projectRoot]);
} else {
setElectronAppPaths(__dirname, process.resourcesPath);
}
console.log('[Electron] Initialized path security helpers');
// Initialize security settings for path validation
// Set DATA_DIR before initializing so it's available for security checks
process.env.DATA_DIR = app.getPath('userData');
// ALLOWED_ROOT_DIRECTORY should already be in process.env if set by user
// (it will be passed to server process, but we also need it in main process for dialog validation)
initAllowedPaths();
if (process.platform === 'darwin' && app.dock) {
const iconPath = getIconPath();
if (iconPath) {
@@ -631,6 +714,22 @@ ipcMain.handle('dialog:openDirectory', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory', 'createDirectory'],
});
// Validate selected path against ALLOWED_ROOT_DIRECTORY if configured
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0];
if (!isPathAllowed(selectedPath)) {
const allowedRoot = getAllowedRootDirectory();
const errorMessage = allowedRoot
? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}`
: 'The selected directory is not allowed.';
await dialog.showErrorBox('Directory Not Allowed', errorMessage);
return { canceled: true, filePaths: [] };
}
}
return result;
});

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,14 @@
* Automaker - Cross-Platform Development Environment Setup and Launch Script
*
* This script works on Windows, macOS, and Linux.
*
* SECURITY NOTE: This script uses a restricted fs wrapper that only allows
* operations within the script's directory (__dirname). This is a standalone
* launch script that runs before the platform library is available.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import fsNative from 'fs';
import http from 'http';
import path from 'path';
import readline from 'readline';
@@ -21,6 +25,43 @@ const crossSpawn = require('cross-spawn');
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// =============================================================================
// Restricted fs wrapper - only allows operations within __dirname
// =============================================================================
/**
* Validate that a path is within the script's directory
* @param {string} targetPath - Path to validate
* @returns {string} - Resolved path if valid
* @throws {Error} - If path is outside __dirname
*/
function validateScriptPath(targetPath) {
const resolved = path.resolve(__dirname, targetPath);
const normalizedBase = path.resolve(__dirname);
if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) {
throw new Error(`[init.mjs] Security: Path access denied outside script directory: ${targetPath}`);
}
return resolved;
}
/**
* Restricted fs operations - only within script directory
*/
const fs = {
existsSync(targetPath) {
const validated = validateScriptPath(targetPath);
return fsNative.existsSync(validated);
},
mkdirSync(targetPath, options) {
const validated = validateScriptPath(targetPath);
return fsNative.mkdirSync(validated, options);
},
createWriteStream(targetPath) {
const validated = validateScriptPath(targetPath);
return fsNative.createWriteStream(validated);
},
};
// Colors for terminal output (works on modern terminals including Windows)
const colors = {
green: '\x1b[0;32m',

View File

@@ -55,3 +55,63 @@ export {
type NodeFinderResult,
type NodeFinderOptions,
} from './node-finder.js';
// System paths for tool detection (GitHub CLI, Claude CLI, Node.js, etc.)
export * as systemPaths from './system-paths.js';
export {
// CLI tool paths
getGitHubCliPaths,
getClaudeCliPaths,
getClaudeConfigDir,
getClaudeCredentialPaths,
getClaudeSettingsPath,
getClaudeStatsCachePath,
getClaudeProjectsDir,
getShellPaths,
getExtendedPath,
// Node.js paths
getNvmPaths,
getFnmPaths,
getNodeSystemPaths,
getScoopNodePath,
getChocolateyNodePath,
getWslVersionPath,
// System path operations
systemPathExists,
systemPathAccess,
systemPathIsExecutable,
systemPathReadFile,
systemPathReadFileSync,
systemPathWriteFileSync,
systemPathReaddir,
systemPathReaddirSync,
systemPathStatSync,
systemPathStat,
isAllowedSystemPath,
// High-level methods
findFirstExistingPath,
findGitHubCliPath,
findClaudeCliPath,
getClaudeAuthIndicators,
type ClaudeAuthIndicators,
// Electron userData operations
setElectronUserDataPath,
getElectronUserDataPath,
isElectronUserDataPath,
electronUserDataReadFileSync,
electronUserDataWriteFileSync,
electronUserDataExists,
// Script directory operations
setScriptBaseDir,
getScriptBaseDir,
scriptDirExists,
scriptDirMkdirSync,
scriptDirCreateWriteStream,
// Electron app bundle operations
setElectronAppPaths,
electronAppExists,
electronAppReadFileSync,
electronAppStatSync,
electronAppStat,
electronAppReadFile,
} from './system-paths.js';

View File

@@ -3,12 +3,25 @@
*
* Handles finding Node.js when the app is launched from desktop environments
* (macOS Finder, Windows Explorer, Linux desktop) where PATH may be limited.
*
* Uses centralized system-paths module for all file system access.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
import {
systemPathExists,
systemPathIsExecutable,
systemPathReaddirSync,
systemPathReadFileSync,
getNvmPaths,
getFnmPaths,
getNodeSystemPaths,
getScoopNodePath,
getChocolateyNodePath,
getWslVersionPath,
} from './system-paths.js';
/** Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") */
const VERSION_DIR_PATTERN = /^v?\d+/;
@@ -45,18 +58,11 @@ export interface NodeFinderOptions {
/**
* Check if a file exists and is executable
* On Windows, only checks existence (X_OK is not meaningful)
* Uses centralized systemPathIsExecutable for path validation
*/
function isExecutable(filePath: string): boolean {
try {
if (process.platform === 'win32') {
// On Windows, fs.constants.X_OK is not meaningful - just check existence
fs.accessSync(filePath, fs.constants.F_OK);
} else {
// On Unix-like systems, check for execute permission
fs.accessSync(filePath, fs.constants.X_OK);
}
return true;
return systemPathIsExecutable(filePath);
} catch {
return false;
}
@@ -71,11 +77,14 @@ function findNodeFromVersionManager(
basePath: string,
binSubpath: string = 'bin/node'
): string | null {
if (!fs.existsSync(basePath)) return null;
try {
if (!systemPathExists(basePath)) return null;
} catch {
return null;
}
try {
const allVersions = fs
.readdirSync(basePath)
const allVersions = systemPathReaddirSync(basePath)
.filter((v) => VERSION_DIR_PATTERN.test(v))
// Semantic version sort - newest first using localeCompare with numeric option
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }));
@@ -101,39 +110,30 @@ function findNodeFromVersionManager(
/**
* Find Node.js on macOS
*/
function findNodeMacOS(homeDir: string): NodeFinderResult | null {
// Check Homebrew paths in order of preference
const homebrewPaths = [
// Apple Silicon
'/opt/homebrew/bin/node',
// Intel
'/usr/local/bin/node',
];
for (const nodePath of homebrewPaths) {
function findNodeMacOS(_homeDir: string): NodeFinderResult | null {
// Check system paths (Homebrew, system)
const systemPaths = getNodeSystemPaths();
for (const nodePath of systemPaths) {
if (isExecutable(nodePath)) {
// Determine source based on path
if (nodePath.includes('homebrew') || nodePath === '/usr/local/bin/node') {
return { nodePath, source: 'homebrew' };
}
return { nodePath, source: 'system' };
}
// System Node
if (isExecutable('/usr/bin/node')) {
return { nodePath: '/usr/bin/node', source: 'system' };
}
// NVM installation
const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node');
const nvmPaths = getNvmPaths();
for (const nvmPath of nvmPaths) {
const nvmNode = findNodeFromVersionManager(nvmPath);
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm' };
}
}
// fnm installation (multiple possible locations)
const fnmPaths = [
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'),
];
// fnm installation
const fnmPaths = getFnmPaths();
for (const fnmBasePath of fnmPaths) {
const fnmNode = findNodeFromVersionManager(fnmBasePath);
if (fnmNode) {
@@ -147,15 +147,9 @@ function findNodeMacOS(homeDir: string): NodeFinderResult | null {
/**
* Find Node.js on Linux
*/
function findNodeLinux(homeDir: string): NodeFinderResult | null {
// Common Linux paths
const systemPaths = [
'/usr/bin/node',
'/usr/local/bin/node',
// Snap installation
'/snap/bin/node',
];
function findNodeLinux(_homeDir: string): NodeFinderResult | null {
// Check system paths
const systemPaths = getNodeSystemPaths();
for (const nodePath of systemPaths) {
if (isExecutable(nodePath)) {
return { nodePath, source: 'system' };
@@ -163,18 +157,16 @@ function findNodeLinux(homeDir: string): NodeFinderResult | null {
}
// NVM installation
const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node');
const nvmPaths = getNvmPaths();
for (const nvmPath of nvmPaths) {
const nvmNode = findNodeFromVersionManager(nvmPath);
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm' };
}
}
// fnm installation
const fnmPaths = [
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
path.join(homeDir, '.fnm', 'node-versions'),
];
const fnmPaths = getFnmPaths();
for (const fnmBasePath of fnmPaths) {
const fnmNode = findNodeFromVersionManager(fnmBasePath);
if (fnmNode) {
@@ -188,40 +180,27 @@ function findNodeLinux(homeDir: string): NodeFinderResult | null {
/**
* Find Node.js on Windows
*/
function findNodeWindows(homeDir: string): NodeFinderResult | null {
function findNodeWindows(_homeDir: string): NodeFinderResult | null {
// Program Files paths
const programFilesPaths = [
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'),
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'nodejs', 'node.exe'),
];
for (const nodePath of programFilesPaths) {
const systemPaths = getNodeSystemPaths();
for (const nodePath of systemPaths) {
if (isExecutable(nodePath)) {
return { nodePath, source: 'program-files' };
}
}
// NVM for Windows
const nvmWindowsPath = path.join(
process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
'nvm'
);
const nvmNode = findNodeFromVersionManager(nvmWindowsPath, 'node.exe');
const nvmPaths = getNvmPaths();
for (const nvmPath of nvmPaths) {
const nvmNode = findNodeFromVersionManager(nvmPath, 'node.exe');
if (nvmNode) {
return { nodePath: nvmNode, source: 'nvm-windows' };
}
}
// fnm on Windows (prioritize canonical installation path over shell shims)
const fnmWindowsPaths = [
path.join(homeDir, '.fnm', 'node-versions'),
path.join(
process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
'fnm',
'node-versions'
),
];
for (const fnmBasePath of fnmWindowsPaths) {
// fnm on Windows
const fnmPaths = getFnmPaths();
for (const fnmBasePath of fnmPaths) {
const fnmNode = findNodeFromVersionManager(fnmBasePath, 'node.exe');
if (fnmNode) {
return { nodePath: fnmNode, source: 'fnm' };
@@ -229,17 +208,13 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null {
}
// Scoop installation
const scoopPath = path.join(homeDir, 'scoop', 'apps', 'nodejs', 'current', 'node.exe');
const scoopPath = getScoopNodePath();
if (isExecutable(scoopPath)) {
return { nodePath: scoopPath, source: 'scoop' };
}
// Chocolatey installation
const chocoPath = path.join(
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
'bin',
'node.exe'
);
const chocoPath = getChocolateyNodePath();
if (isExecutable(chocoPath)) {
return { nodePath: chocoPath, source: 'chocolatey' };
}

View File

@@ -11,7 +11,7 @@
*/
import fs from 'fs/promises';
import type { Dirent } from 'fs';
import fsSync, { type Dirent, type Stats } from 'fs';
import path from 'path';
import pLimit from 'p-limit';
import { validatePath } from './security.js';
@@ -305,3 +305,323 @@ export function joinPath(...pathSegments: string[]): string {
export function resolvePath(...pathSegments: string[]): string {
return path.resolve(...pathSegments);
}
// =============================================================================
// Synchronous File System Methods
// =============================================================================
/**
* Options for writeFileSync
*/
export interface WriteFileSyncOptions {
encoding?: BufferEncoding;
mode?: number;
flag?: string;
}
/**
* Synchronous wrapper around fs.existsSync that validates path first
*/
export function existsSync(filePath: string): boolean {
const validatedPath = validatePath(filePath);
return fsSync.existsSync(validatedPath);
}
/**
* Synchronous wrapper around fs.readFileSync that validates path first
*/
export function readFileSync(filePath: string, encoding?: BufferEncoding): string | Buffer {
const validatedPath = validatePath(filePath);
if (encoding) {
return fsSync.readFileSync(validatedPath, encoding);
}
return fsSync.readFileSync(validatedPath);
}
/**
* Synchronous wrapper around fs.writeFileSync that validates path first
*/
export function writeFileSync(
filePath: string,
data: string | Buffer,
options?: WriteFileSyncOptions
): void {
const validatedPath = validatePath(filePath);
fsSync.writeFileSync(validatedPath, data, options);
}
/**
* Synchronous wrapper around fs.mkdirSync that validates path first
*/
export function mkdirSync(
dirPath: string,
options?: { recursive?: boolean; mode?: number }
): string | undefined {
const validatedPath = validatePath(dirPath);
return fsSync.mkdirSync(validatedPath, options);
}
/**
* Synchronous wrapper around fs.readdirSync that validates path first
*/
export function readdirSync(dirPath: string, options?: { withFileTypes?: false }): string[];
export function readdirSync(dirPath: string, options: { withFileTypes: true }): Dirent[];
export function readdirSync(
dirPath: string,
options?: { withFileTypes?: boolean }
): string[] | Dirent[] {
const validatedPath = validatePath(dirPath);
if (options?.withFileTypes === true) {
return fsSync.readdirSync(validatedPath, { withFileTypes: true });
}
return fsSync.readdirSync(validatedPath);
}
/**
* Synchronous wrapper around fs.statSync that validates path first
*/
export function statSync(filePath: string): Stats {
const validatedPath = validatePath(filePath);
return fsSync.statSync(validatedPath);
}
/**
* Synchronous wrapper around fs.accessSync that validates path first
*/
export function accessSync(filePath: string, mode?: number): void {
const validatedPath = validatePath(filePath);
fsSync.accessSync(validatedPath, mode);
}
/**
* Synchronous wrapper around fs.unlinkSync that validates path first
*/
export function unlinkSync(filePath: string): void {
const validatedPath = validatePath(filePath);
fsSync.unlinkSync(validatedPath);
}
/**
* Synchronous wrapper around fs.rmSync that validates path first
*/
export function rmSync(filePath: string, options?: { recursive?: boolean; force?: boolean }): void {
const validatedPath = validatePath(filePath);
fsSync.rmSync(validatedPath, options);
}
// =============================================================================
// Environment File Operations
// =============================================================================
/**
* Read and parse an .env file from a validated path
* Returns a record of key-value pairs
*/
export async function readEnvFile(envPath: string): Promise<Record<string, string>> {
const validatedPath = validatePath(envPath);
try {
const content = await executeWithRetry(
() => fs.readFile(validatedPath, 'utf-8'),
`readEnvFile(${envPath})`
);
return parseEnvContent(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
throw error;
}
}
/**
* Read and parse an .env file synchronously from a validated path
*/
export function readEnvFileSync(envPath: string): Record<string, string> {
const validatedPath = validatePath(envPath);
try {
const content = fsSync.readFileSync(validatedPath, 'utf-8');
return parseEnvContent(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
throw error;
}
}
/**
* Parse .env file content into a record
*/
function parseEnvContent(content: string): Record<string, string> {
const result: Record<string, string> = {};
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const equalIndex = trimmed.indexOf('=');
if (equalIndex > 0) {
const key = trimmed.slice(0, equalIndex).trim();
const value = trimmed.slice(equalIndex + 1).trim();
result[key] = value;
}
}
return result;
}
/**
* Write or update a key-value pair in an .env file
* Preserves existing content and comments
*/
export async function writeEnvKey(envPath: string, key: string, value: string): Promise<void> {
const validatedPath = validatePath(envPath);
let content = '';
try {
content = await executeWithRetry(
() => fs.readFile(validatedPath, 'utf-8'),
`readFile(${envPath})`
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
// File doesn't exist, will create new one
}
const newContent = updateEnvContent(content, key, value);
await executeWithRetry(() => fs.writeFile(validatedPath, newContent), `writeFile(${envPath})`);
}
/**
* Write or update a key-value pair in an .env file (synchronous)
*/
export function writeEnvKeySync(envPath: string, key: string, value: string): void {
const validatedPath = validatePath(envPath);
let content = '';
try {
content = fsSync.readFileSync(validatedPath, 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
// File doesn't exist, will create new one
}
const newContent = updateEnvContent(content, key, value);
fsSync.writeFileSync(validatedPath, newContent);
}
/**
* Remove a key from an .env file
*/
export async function removeEnvKey(envPath: string, key: string): Promise<void> {
const validatedPath = validatePath(envPath);
let content = '';
try {
content = await executeWithRetry(
() => fs.readFile(validatedPath, 'utf-8'),
`readFile(${envPath})`
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return; // File doesn't exist, nothing to remove
}
throw error;
}
const newContent = removeEnvKeyFromContent(content, key);
await executeWithRetry(() => fs.writeFile(validatedPath, newContent), `writeFile(${envPath})`);
}
/**
* Remove a key from an .env file (synchronous)
*/
export function removeEnvKeySync(envPath: string, key: string): void {
const validatedPath = validatePath(envPath);
let content = '';
try {
content = fsSync.readFileSync(validatedPath, 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return; // File doesn't exist, nothing to remove
}
throw error;
}
const newContent = removeEnvKeyFromContent(content, key);
fsSync.writeFileSync(validatedPath, newContent);
}
/**
* Update .env content with a new key-value pair
*/
function updateEnvContent(content: string, key: string, value: string): string {
const lines = content.split('\n');
const keyRegex = new RegExp(`^${escapeRegex(key)}=`);
let found = false;
const newLines = lines.map((line) => {
if (keyRegex.test(line.trim())) {
found = true;
return `${key}=${value}`;
}
return line;
});
if (!found) {
// Add the key at the end
if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') {
newLines.push(`${key}=${value}`);
} else {
// Replace last empty line or add to empty file
if (newLines.length === 0 || (newLines.length === 1 && newLines[0] === '')) {
newLines[0] = `${key}=${value}`;
} else {
newLines[newLines.length - 1] = `${key}=${value}`;
}
}
}
// Ensure file ends with newline
let result = newLines.join('\n');
if (!result.endsWith('\n')) {
result += '\n';
}
return result;
}
/**
* Remove a key from .env content
*/
function removeEnvKeyFromContent(content: string, key: string): string {
const lines = content.split('\n');
const keyRegex = new RegExp(`^${escapeRegex(key)}=`);
const newLines = lines.filter((line) => !keyRegex.test(line.trim()));
// Remove trailing empty lines
while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') {
newLines.pop();
}
// Ensure file ends with newline if there's content
let result = newLines.join('\n');
if (result.length > 0 && !result.endsWith('\n')) {
result += '\n';
}
return result;
}
/**
* Escape special regex characters in a string
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -0,0 +1,787 @@
/**
* System Paths Configuration
*
* Centralized configuration for ALL system paths that automaker needs to access
* outside of the ALLOWED_ROOT_DIRECTORY. These are well-known system paths for
* tools like GitHub CLI, Claude CLI, Node.js version managers, etc.
*
* ALL file system access must go through this module or secureFs.
* Direct fs imports are NOT allowed anywhere else in the codebase.
*
* Categories of system paths:
* 1. CLI Tools: GitHub CLI, Claude CLI
* 2. Version Managers: NVM, fnm, Volta
* 3. Shells: /bin/zsh, /bin/bash, PowerShell
* 4. Electron userData: API keys, window bounds, app settings
* 5. Script directories: node_modules, logs (relative to script)
*/
import os from 'os';
import path from 'path';
import fsSync from 'fs';
import fs from 'fs/promises';
// =============================================================================
// System Tool Path Definitions
// =============================================================================
/**
* Get common paths where GitHub CLI might be installed
*/
export function getGitHubCliPaths(): string[] {
const isWindows = process.platform === 'win32';
if (isWindows) {
return [
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'),
path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'),
].filter(Boolean);
}
return [
'/opt/homebrew/bin/gh',
'/usr/local/bin/gh',
path.join(os.homedir(), '.local', 'bin', 'gh'),
'/home/linuxbrew/.linuxbrew/bin/gh',
];
}
/**
* Get common paths where Claude CLI might be installed
*/
export function getClaudeCliPaths(): string[] {
const isWindows = process.platform === 'win32';
if (isWindows) {
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return [
path.join(os.homedir(), '.local', 'bin', 'claude.exe'),
path.join(appData, 'npm', 'claude.cmd'),
path.join(appData, 'npm', 'claude'),
path.join(appData, '.npm-global', 'bin', 'claude.cmd'),
path.join(appData, '.npm-global', 'bin', 'claude'),
];
}
return [
path.join(os.homedir(), '.local', 'bin', 'claude'),
path.join(os.homedir(), '.claude', 'local', 'claude'),
'/usr/local/bin/claude',
path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
];
}
/**
* Get the Claude configuration directory path
*/
export function getClaudeConfigDir(): string {
return path.join(os.homedir(), '.claude');
}
/**
* Get paths to Claude credential files
*/
export function getClaudeCredentialPaths(): string[] {
const claudeDir = getClaudeConfigDir();
return [path.join(claudeDir, '.credentials.json'), path.join(claudeDir, 'credentials.json')];
}
/**
* Get path to Claude settings file
*/
export function getClaudeSettingsPath(): string {
return path.join(getClaudeConfigDir(), 'settings.json');
}
/**
* Get path to Claude stats cache file
*/
export function getClaudeStatsCachePath(): string {
return path.join(getClaudeConfigDir(), 'stats-cache.json');
}
/**
* Get path to Claude projects/sessions directory
*/
export function getClaudeProjectsDir(): string {
return path.join(getClaudeConfigDir(), 'projects');
}
/**
* Get common shell paths for shell detection
*/
export function getShellPaths(): string[] {
if (process.platform === 'win32') {
return [
process.env.COMSPEC || 'cmd.exe',
'powershell.exe',
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
];
}
return ['/bin/zsh', '/bin/bash', '/bin/sh'];
}
// =============================================================================
// Node.js Version Manager Paths
// =============================================================================
/**
* Get NVM installation paths
*/
export function getNvmPaths(): string[] {
const homeDir = os.homedir();
if (process.platform === 'win32') {
const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
return [path.join(appData, 'nvm')];
}
return [path.join(homeDir, '.nvm', 'versions', 'node')];
}
/**
* Get fnm installation paths
*/
export function getFnmPaths(): string[] {
const homeDir = os.homedir();
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
return [
path.join(homeDir, '.fnm', 'node-versions'),
path.join(localAppData, 'fnm', 'node-versions'),
];
}
if (process.platform === 'darwin') {
return [
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'),
];
}
return [
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
path.join(homeDir, '.fnm', 'node-versions'),
];
}
/**
* Get common Node.js installation paths (not version managers)
*/
export function getNodeSystemPaths(): string[] {
if (process.platform === 'win32') {
return [
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'),
path.join(
process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)',
'nodejs',
'node.exe'
),
];
}
if (process.platform === 'darwin') {
return ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node'];
}
// Linux
return ['/usr/bin/node', '/usr/local/bin/node', '/snap/bin/node'];
}
/**
* Get Scoop installation path for Node.js (Windows)
*/
export function getScoopNodePath(): string {
return path.join(os.homedir(), 'scoop', 'apps', 'nodejs', 'current', 'node.exe');
}
/**
* Get Chocolatey installation path for Node.js (Windows)
*/
export function getChocolateyNodePath(): string {
return path.join(
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
'bin',
'node.exe'
);
}
/**
* Get WSL detection path
*/
export function getWslVersionPath(): string {
return '/proc/version';
}
/**
* Extended PATH environment for finding system tools
*/
export function getExtendedPath(): string {
const paths = [
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
];
return paths.filter(Boolean).join(process.platform === 'win32' ? ';' : ':');
}
// =============================================================================
// System Path Access Methods (Unconstrained - only for system tool detection)
// =============================================================================
/**
* Check if a file exists at a system path (synchronous)
* IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions.
* Only use for checking system tool installation paths.
*/
export function systemPathExists(filePath: string): boolean {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
return fsSync.existsSync(filePath);
}
/**
* Check if a file is accessible at a system path (async)
* IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions.
* Only use for checking system tool installation paths.
*/
export async function systemPathAccess(filePath: string): Promise<boolean> {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Check if a file has execute permission (synchronous)
* On Windows, only checks existence (X_OK is not meaningful)
*/
export function systemPathIsExecutable(filePath: string): boolean {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
try {
if (process.platform === 'win32') {
fsSync.accessSync(filePath, fsSync.constants.F_OK);
} else {
fsSync.accessSync(filePath, fsSync.constants.X_OK);
}
return true;
} catch {
return false;
}
}
/**
* Read a file from an allowed system path (async)
* IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions.
* Only use for reading Claude config files and similar system configs.
*/
export async function systemPathReadFile(
filePath: string,
encoding: BufferEncoding = 'utf-8'
): Promise<string> {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
return fs.readFile(filePath, encoding);
}
/**
* Read a file from an allowed system path (synchronous)
*/
export function systemPathReadFileSync(
filePath: string,
encoding: BufferEncoding = 'utf-8'
): string {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
return fsSync.readFileSync(filePath, encoding);
}
/**
* Write a file to an allowed system path (synchronous)
*/
export function systemPathWriteFileSync(
filePath: string,
data: string,
options?: { encoding?: BufferEncoding; mode?: number }
): void {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
fsSync.writeFileSync(filePath, data, options);
}
/**
* Read directory contents from an allowed system path (async)
* IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions.
*/
export async function systemPathReaddir(dirPath: string): Promise<string[]> {
if (!isAllowedSystemPath(dirPath)) {
throw new Error(`[SystemPaths] Access denied: ${dirPath} is not an allowed system path`);
}
return fs.readdir(dirPath);
}
/**
* Read directory contents from an allowed system path (synchronous)
*/
export function systemPathReaddirSync(dirPath: string): string[] {
if (!isAllowedSystemPath(dirPath)) {
throw new Error(`[SystemPaths] Access denied: ${dirPath} is not an allowed system path`);
}
return fsSync.readdirSync(dirPath);
}
/**
* Get file stats from a system path (synchronous)
*/
export function systemPathStatSync(filePath: string): fsSync.Stats {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
return fsSync.statSync(filePath);
}
/**
* Get file stats from a system path (async)
*/
export async function systemPathStat(filePath: string): Promise<fsSync.Stats> {
if (!isAllowedSystemPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`);
}
return fs.stat(filePath);
}
// =============================================================================
// Path Validation
// =============================================================================
/**
* All paths that are allowed for system tool detection
*/
function getAllAllowedSystemPaths(): string[] {
return [
// GitHub CLI paths
...getGitHubCliPaths(),
// Claude CLI paths
...getClaudeCliPaths(),
// Claude config directory and files
getClaudeConfigDir(),
...getClaudeCredentialPaths(),
getClaudeSettingsPath(),
getClaudeStatsCachePath(),
getClaudeProjectsDir(),
// Shell paths
...getShellPaths(),
// Node.js system paths
...getNodeSystemPaths(),
getScoopNodePath(),
getChocolateyNodePath(),
// WSL detection
getWslVersionPath(),
];
}
/**
* Get all allowed directories (for recursive access)
*/
function getAllAllowedSystemDirs(): string[] {
return [
// Claude config
getClaudeConfigDir(),
getClaudeProjectsDir(),
// Version managers (need recursive access for version directories)
...getNvmPaths(),
...getFnmPaths(),
];
}
/**
* Check if a path is an allowed system path
* Paths must either be exactly in the allowed list, or be inside an allowed directory
*/
export function isAllowedSystemPath(filePath: string): boolean {
const normalizedPath = path.resolve(filePath);
const allowedPaths = getAllAllowedSystemPaths();
// Check for exact match
if (allowedPaths.includes(normalizedPath)) {
return true;
}
// Check if the path is inside an allowed directory
const allowedDirs = getAllAllowedSystemDirs();
for (const allowedDir of allowedDirs) {
const normalizedAllowedDir = path.resolve(allowedDir);
// Check if path is exactly the allowed dir or inside it
if (
normalizedPath === normalizedAllowedDir ||
normalizedPath.startsWith(normalizedAllowedDir + path.sep)
) {
return true;
}
}
return false;
}
// =============================================================================
// Electron userData Operations
// =============================================================================
// Store the Electron userData path (set by Electron main process)
let electronUserDataPath: string | null = null;
/**
* Set the Electron userData path (called from Electron main process)
*/
export function setElectronUserDataPath(userDataPath: string): void {
electronUserDataPath = userDataPath;
}
/**
* Get the Electron userData path
*/
export function getElectronUserDataPath(): string | null {
return electronUserDataPath;
}
/**
* Check if a path is within the Electron userData directory
*/
export function isElectronUserDataPath(filePath: string): boolean {
if (!electronUserDataPath) return false;
const normalizedPath = path.resolve(filePath);
const normalizedUserData = path.resolve(electronUserDataPath);
return (
normalizedPath === normalizedUserData ||
normalizedPath.startsWith(normalizedUserData + path.sep)
);
}
/**
* Read a file from Electron userData directory
*/
export function electronUserDataReadFileSync(
relativePath: string,
encoding: BufferEncoding = 'utf-8'
): string {
if (!electronUserDataPath) {
throw new Error('[SystemPaths] Electron userData path not initialized');
}
const fullPath = path.join(electronUserDataPath, relativePath);
return fsSync.readFileSync(fullPath, encoding);
}
/**
* Write a file to Electron userData directory
*/
export function electronUserDataWriteFileSync(
relativePath: string,
data: string,
options?: { encoding?: BufferEncoding; mode?: number }
): void {
if (!electronUserDataPath) {
throw new Error('[SystemPaths] Electron userData path not initialized');
}
const fullPath = path.join(electronUserDataPath, relativePath);
fsSync.writeFileSync(fullPath, data, options);
}
/**
* Check if a file exists in Electron userData directory
*/
export function electronUserDataExists(relativePath: string): boolean {
if (!electronUserDataPath) return false;
const fullPath = path.join(electronUserDataPath, relativePath);
return fsSync.existsSync(fullPath);
}
// =============================================================================
// Script Directory Operations (for init.mjs and similar)
// =============================================================================
// Store the script's base directory
let scriptBaseDir: string | null = null;
/**
* Set the script base directory
*/
export function setScriptBaseDir(baseDir: string): void {
scriptBaseDir = baseDir;
}
/**
* Get the script base directory
*/
export function getScriptBaseDir(): string | null {
return scriptBaseDir;
}
/**
* Check if a file exists relative to script base directory
*/
export function scriptDirExists(relativePath: string): boolean {
if (!scriptBaseDir) {
throw new Error('[SystemPaths] Script base directory not initialized');
}
const fullPath = path.join(scriptBaseDir, relativePath);
return fsSync.existsSync(fullPath);
}
/**
* Create a directory relative to script base directory
*/
export function scriptDirMkdirSync(relativePath: string, options?: { recursive?: boolean }): void {
if (!scriptBaseDir) {
throw new Error('[SystemPaths] Script base directory not initialized');
}
const fullPath = path.join(scriptBaseDir, relativePath);
fsSync.mkdirSync(fullPath, options);
}
/**
* Create a write stream for a file relative to script base directory
*/
export function scriptDirCreateWriteStream(relativePath: string): fsSync.WriteStream {
if (!scriptBaseDir) {
throw new Error('[SystemPaths] Script base directory not initialized');
}
const fullPath = path.join(scriptBaseDir, relativePath);
return fsSync.createWriteStream(fullPath);
}
// =============================================================================
// Electron App Bundle Operations (for accessing app's own files)
// =============================================================================
// Store the Electron app bundle paths (can have multiple allowed directories)
let electronAppDirs: string[] = [];
let electronResourcesPath: string | null = null;
/**
* Set the Electron app directories (called from Electron main process)
* In development mode, pass the project root to allow access to source files.
* In production mode, pass __dirname and process.resourcesPath.
*
* @param appDirOrDirs - Single directory or array of directories to allow
* @param resourcesPath - Optional resources path (for packaged apps)
*/
export function setElectronAppPaths(appDirOrDirs: string | string[], resourcesPath?: string): void {
electronAppDirs = Array.isArray(appDirOrDirs) ? appDirOrDirs : [appDirOrDirs];
electronResourcesPath = resourcesPath || null;
}
/**
* Check if a path is within the Electron app bundle (any of the allowed directories)
*/
function isElectronAppPath(filePath: string): boolean {
const normalizedPath = path.resolve(filePath);
// Check against all allowed app directories
for (const appDir of electronAppDirs) {
const normalizedAppDir = path.resolve(appDir);
if (
normalizedPath === normalizedAppDir ||
normalizedPath.startsWith(normalizedAppDir + path.sep)
) {
return true;
}
}
// Check against resources path (for packaged apps)
if (electronResourcesPath) {
const normalizedResources = path.resolve(electronResourcesPath);
if (
normalizedPath === normalizedResources ||
normalizedPath.startsWith(normalizedResources + path.sep)
) {
return true;
}
}
return false;
}
/**
* Check if a file exists within the Electron app bundle
*/
export function electronAppExists(filePath: string): boolean {
if (!isElectronAppPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`);
}
return fsSync.existsSync(filePath);
}
/**
* Read a file from the Electron app bundle
*/
export function electronAppReadFileSync(filePath: string): Buffer {
if (!isElectronAppPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`);
}
return fsSync.readFileSync(filePath);
}
/**
* Get file stats from the Electron app bundle
*/
export function electronAppStatSync(filePath: string): fsSync.Stats {
if (!isElectronAppPath(filePath)) {
throw new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`);
}
return fsSync.statSync(filePath);
}
/**
* Get file stats from the Electron app bundle (async with callback for compatibility)
*/
export function electronAppStat(
filePath: string,
callback: (err: NodeJS.ErrnoException | null, stats: fsSync.Stats | undefined) => void
): void {
if (!isElectronAppPath(filePath)) {
callback(
new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`),
undefined
);
return;
}
fsSync.stat(filePath, callback);
}
/**
* Read a file from the Electron app bundle (async with callback for compatibility)
*/
export function electronAppReadFile(
filePath: string,
callback: (err: NodeJS.ErrnoException | null, data: Buffer | undefined) => void
): void {
if (!isElectronAppPath(filePath)) {
callback(
new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`),
undefined
);
return;
}
fsSync.readFile(filePath, callback);
}
// =============================================================================
// High-level Tool Detection Methods
// =============================================================================
/**
* Find the first existing path from a list of system paths
*/
export async function findFirstExistingPath(paths: string[]): Promise<string | null> {
for (const p of paths) {
if (await systemPathAccess(p)) {
return p;
}
}
return null;
}
/**
* Check if GitHub CLI is installed and return its path
*/
export async function findGitHubCliPath(): Promise<string | null> {
return findFirstExistingPath(getGitHubCliPaths());
}
/**
* Check if Claude CLI is installed and return its path
*/
export async function findClaudeCliPath(): Promise<string | null> {
return findFirstExistingPath(getClaudeCliPaths());
}
/**
* Get Claude authentication status by checking various indicators
*/
export interface ClaudeAuthIndicators {
hasCredentialsFile: boolean;
hasSettingsFile: boolean;
hasStatsCacheWithActivity: boolean;
hasProjectsSessions: boolean;
credentials: {
hasOAuthToken: boolean;
hasApiKey: boolean;
} | null;
}
export async function getClaudeAuthIndicators(): Promise<ClaudeAuthIndicators> {
const result: ClaudeAuthIndicators = {
hasCredentialsFile: false,
hasSettingsFile: false,
hasStatsCacheWithActivity: false,
hasProjectsSessions: false,
credentials: null,
};
// Check settings file
try {
if (await systemPathAccess(getClaudeSettingsPath())) {
result.hasSettingsFile = true;
}
} catch {
// Ignore errors
}
// Check stats cache for recent activity
try {
const statsContent = await systemPathReadFile(getClaudeStatsCachePath());
const stats = JSON.parse(statsContent);
if (stats.dailyActivity && stats.dailyActivity.length > 0) {
result.hasStatsCacheWithActivity = true;
}
} catch {
// Ignore errors
}
// Check for sessions in projects directory
try {
const sessions = await systemPathReaddir(getClaudeProjectsDir());
if (sessions.length > 0) {
result.hasProjectsSessions = true;
}
} catch {
// Ignore errors
}
// Check credentials files
const credentialPaths = getClaudeCredentialPaths();
for (const credPath of credentialPaths) {
try {
const content = await systemPathReadFile(credPath);
const credentials = JSON.parse(content);
result.hasCredentialsFile = true;
result.credentials = {
hasOAuthToken: !!(credentials.oauth_token || credentials.access_token),
hasApiKey: !!credentials.api_key,
};
break;
} catch {
// Continue to next path
}
}
return result;
}

View File

@@ -10,7 +10,7 @@
*/
import path from 'path';
import fs from 'fs/promises';
import { secureFs } from '@automaker/platform';
/**
* Metadata structure for context files
@@ -38,6 +38,16 @@ export interface ContextFilesResult {
formattedPrompt: string;
}
/**
* File system module interface for context loading
* Compatible with secureFs from @automaker/platform
*/
export interface ContextFsModule {
access: (path: string) => Promise<void>;
readdir: (path: string) => Promise<string[]>;
readFile: (path: string, encoding?: BufferEncoding) => Promise<string | Buffer>;
}
/**
* Options for loading context files
*/
@@ -45,11 +55,7 @@ export interface LoadContextFilesOptions {
/** Project path to load context from */
projectPath: string;
/** Optional custom secure fs module (for dependency injection) */
fsModule?: {
access: (path: string) => Promise<void>;
readdir: (path: string) => Promise<string[]>;
readFile: (path: string, encoding: string) => Promise<string>;
};
fsModule?: ContextFsModule;
}
/**
@@ -64,12 +70,12 @@ function getContextDir(projectPath: string): string {
*/
async function loadContextMetadata(
contextDir: string,
fsModule: typeof fs
fsModule: ContextFsModule
): Promise<ContextMetadata> {
const metadataPath = path.join(contextDir, 'context-metadata.json');
try {
const content = await fsModule.readFile(metadataPath, 'utf-8');
return JSON.parse(content);
return JSON.parse(content as string);
} catch {
// Metadata file doesn't exist yet - that's fine
return { files: {} };
@@ -148,7 +154,7 @@ ${formattedFiles.join('\n\n---\n\n')}
export async function loadContextFiles(
options: LoadContextFilesOptions
): Promise<ContextFilesResult> {
const { projectPath, fsModule = fs } = options;
const { projectPath, fsModule = secureFs } = options;
const contextDir = path.resolve(getContextDir(projectPath));
try {
@@ -169,7 +175,7 @@ export async function loadContextFiles(
}
// Load metadata for descriptions
const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs);
const metadata = await loadContextMetadata(contextDir, fsModule);
// Load each file with its content and metadata
const files: ContextFileInfo[] = [];
@@ -180,7 +186,7 @@ export async function loadContextFiles(
files.push({
name: fileName,
path: filePath,
content,
content: content as string,
description: metadata.files[fileName]?.description,
});
} catch (error) {
@@ -209,7 +215,7 @@ export async function loadContextFiles(
export async function getContextFilesSummary(
options: LoadContextFilesOptions
): Promise<Array<{ name: string; path: string; description?: string }>> {
const { projectPath, fsModule = fs } = options;
const { projectPath, fsModule = secureFs } = options;
const contextDir = path.resolve(getContextDir(projectPath));
try {
@@ -225,7 +231,7 @@ export async function getContextFilesSummary(
return [];
}
const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs);
const metadata = await loadContextMetadata(contextDir, fsModule);
return textFiles.map((fileName) => ({
name: fileName,