refactor: implement ALLOWED_ROOT_DIRECTORY security and fix path validation

This commit consolidates directory security from two environment variables
(WORKSPACE_DIR, ALLOWED_PROJECT_DIRS) into a single ALLOWED_ROOT_DIRECTORY variable
while maintaining backward compatibility.

Changes:
- Re-enabled path validation in security.ts (was previously disabled)
- Implemented isPathAllowed() to check ALLOWED_ROOT_DIRECTORY with DATA_DIR exception
- Added backward compatibility for legacy ALLOWED_PROJECT_DIRS and WORKSPACE_DIR
- Implemented path traversal protection via isPathWithinDirectory() helper
- Added PathNotAllowedError custom exception for security violations
- Updated all FS route endpoints to validate paths and return 403 on violation
- Updated template clone endpoint to validate project paths
- Updated workspace config endpoints to use ALLOWED_ROOT_DIRECTORY
- Fixed stat() response property access bug in project-init.ts
- Updated security tests to expect actual validation behavior

Security improvements:
- Path validation now enforced at all layers (routes, project init, agent services)
- appData directory (DATA_DIR) always allowed for settings/credentials
- Backward compatible with existing ALLOWED_PROJECT_DIRS/WORKSPACE_DIR configurations
- Protection against path traversal attacks

Backend test results: 654/654 passing 

🤖 Generated with Claude Code

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Test User
2025-12-20 15:59:32 -05:00
parent 7d0656bb14
commit 8ff4b5912a
25 changed files with 424 additions and 72 deletions

View File

@@ -16,9 +16,16 @@ ANTHROPIC_API_KEY=sk-ant-...
# If set, all API requests must include X-API-Key header # If set, all API requests must include X-API-Key header
AUTOMAKER_API_KEY= AUTOMAKER_API_KEY=
# Restrict file operations to these directories (comma-separated) # Root directory for projects and file operations
# Important for security in multi-tenant environments # If set, users can only create/open projects and files within this directory
ALLOWED_PROJECT_DIRS=/home/user/projects,/var/www # Recommended for sandboxed deployments (Docker, restricted environments)
# Example: ALLOWED_ROOT_DIRECTORY=/projects
ALLOWED_ROOT_DIRECTORY=
# (Legacy) Restrict file operations to these directories (comma-separated)
# DEPRECATED: Use ALLOWED_ROOT_DIRECTORY instead for simpler configuration
# This is kept for backward compatibility
# ALLOWED_PROJECT_DIRS=/home/user/projects,/var/www
# CORS origin - which domains can access the API # CORS origin - which domains can access the API
# Use "*" for development, set specific origin for production # Use "*" for development, set specific origin for production

View File

@@ -1,18 +1,53 @@
/** /**
* Security utilities for path validation * Security utilities for path validation
* Note: All permission checks have been disabled to allow unrestricted access * Enforces ALLOWED_ROOT_DIRECTORY constraint with appData exception
*/ */
import path from "path"; import path from "path";
// Allowed project directories - kept for API compatibility /**
* Error thrown when a path is not allowed by security policy
*/
export class PathNotAllowedError extends Error {
constructor(filePath: string) {
super(
`Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY or DATA_DIR.`
);
this.name = "PathNotAllowedError";
}
}
// Allowed root directory - main security boundary
let allowedRootDirectory: string | null = null;
// Data directory - always allowed for settings/credentials
let dataDirectory: string | null = null;
// Allowed project directories - kept for backward compatibility and API compatibility
const allowedPaths = new Set<string>(); const allowedPaths = new Set<string>();
/** /**
* Initialize allowed paths from environment variable * Initialize security settings from environment variables
* Note: All paths are now allowed regardless of this setting * - ALLOWED_ROOT_DIRECTORY: main security boundary
* - DATA_DIR: appData exception, always allowed
* - ALLOWED_PROJECT_DIRS: legacy variable, stored for compatibility
*/ */
export function initAllowedPaths(): void { export function initAllowedPaths(): void {
// Load ALLOWED_ROOT_DIRECTORY (new single variable)
const rootDir = process.env.ALLOWED_ROOT_DIRECTORY;
if (rootDir) {
allowedRootDirectory = path.resolve(rootDir);
allowedPaths.add(allowedRootDirectory);
}
// Load DATA_DIR (appData exception - always allowed)
const dataDir = process.env.DATA_DIR;
if (dataDir) {
dataDirectory = path.resolve(dataDir);
allowedPaths.add(dataDirectory);
}
// Load legacy ALLOWED_PROJECT_DIRS for backward compatibility
const dirs = process.env.ALLOWED_PROJECT_DIRS; const dirs = process.env.ALLOWED_PROJECT_DIRS;
if (dirs) { if (dirs) {
for (const dir of dirs.split(",")) { for (const dir of dirs.split(",")) {
@@ -23,11 +58,7 @@ export function initAllowedPaths(): void {
} }
} }
const dataDir = process.env.DATA_DIR; // Load legacy WORKSPACE_DIR for backward compatibility
if (dataDir) {
allowedPaths.add(path.resolve(dataDir));
}
const workspaceDir = process.env.WORKSPACE_DIR; const workspaceDir = process.env.WORKSPACE_DIR;
if (workspaceDir) { if (workspaceDir) {
allowedPaths.add(path.resolve(workspaceDir)); allowedPaths.add(path.resolve(workspaceDir));
@@ -35,24 +66,96 @@ export function initAllowedPaths(): void {
} }
/** /**
* Add a path to the allowed list (no-op, all paths allowed) * Add a path to the allowed list
* Used when dynamically creating new directories within the allowed root
*/ */
export function addAllowedPath(filePath: string): void { export function addAllowedPath(filePath: string): void {
allowedPaths.add(path.resolve(filePath)); allowedPaths.add(path.resolve(filePath));
} }
/** /**
* Check if a path is allowed - always returns true * Check if a path is allowed based on ALLOWED_ROOT_DIRECTORY and legacy paths
* Returns true if:
* - Path is within ALLOWED_ROOT_DIRECTORY, OR
* - Path is within any legacy allowed path (ALLOWED_PROJECT_DIRS, WORKSPACE_DIR), OR
* - Path is within DATA_DIR (appData exception), OR
* - No restrictions are configured (backward compatibility)
*/ */
export function isPathAllowed(_filePath: string): boolean { export function isPathAllowed(filePath: string): boolean {
return true; // If no restrictions are configured, allow all paths (backward compatibility)
if (!allowedRootDirectory && allowedPaths.size === 0) {
return true;
}
const resolvedPath = path.resolve(filePath);
// Always allow appData directory (settings, credentials)
if (dataDirectory && isPathWithinDirectory(resolvedPath, dataDirectory)) {
return true;
}
// Allow if within ALLOWED_ROOT_DIRECTORY
if (allowedRootDirectory && isPathWithinDirectory(resolvedPath, allowedRootDirectory)) {
return true;
}
// Check legacy allowed paths (ALLOWED_PROJECT_DIRS, WORKSPACE_DIR)
for (const allowedPath of allowedPaths) {
if (isPathWithinDirectory(resolvedPath, allowedPath)) {
return true;
}
}
// If any restrictions are configured but path doesn't match, deny
return false;
} }
/** /**
* Validate a path - just resolves the path without checking permissions * Validate a path - resolves it and checks permissions
* Throws PathNotAllowedError if path is not allowed
*/ */
export function validatePath(filePath: string): string { export function validatePath(filePath: string): string {
return path.resolve(filePath); const resolvedPath = path.resolve(filePath);
if (!isPathAllowed(resolvedPath)) {
throw new PathNotAllowedError(filePath);
}
return resolvedPath;
}
/**
* Check if a path is within a directory, with protection against path traversal
* Returns true only if resolvedPath is within directoryPath
*/
export function isPathWithinDirectory(
resolvedPath: string,
directoryPath: string
): boolean {
// Get the relative path from directory to the target
const relativePath = path.relative(directoryPath, resolvedPath);
// If relative path starts with "..", it's outside the directory
// If relative path is absolute, it's outside the directory
// If relative path is empty or ".", it's the directory itself
return (
!relativePath.startsWith("..") &&
!path.isAbsolute(relativePath)
);
}
/**
* Get the configured allowed root directory
*/
export function getAllowedRootDirectory(): string | null {
return allowedRootDirectory;
}
/**
* Get the configured data directory
*/
export function getDataDirectory(): string | null {
return dataDirectory;
} }
/** /**

View File

@@ -6,6 +6,7 @@ import type { Request, Response } from "express";
import fs from "fs/promises"; import fs from "fs/promises";
import os from "os"; import os from "os";
import path from "path"; import path from "path";
import { isPathAllowed, PathNotAllowedError } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
export function createBrowseHandler() { export function createBrowseHandler() {
@@ -16,6 +17,11 @@ export function createBrowseHandler() {
// Default to home directory if no path provided // Default to home directory if no path provided
const targetPath = dirPath ? path.resolve(dirPath) : os.homedir(); const targetPath = dirPath ? path.resolve(dirPath) : os.homedir();
// Validate that the path is allowed
if (!isPathAllowed(targetPath)) {
throw new PathNotAllowedError(dirPath || targetPath);
}
// Detect available drives on Windows // Detect available drives on Windows
const detectDrives = async (): Promise<string[]> => { const detectDrives = async (): Promise<string[]> => {
if (os.platform() !== "win32") { if (os.platform() !== "win32") {
@@ -100,6 +106,12 @@ export function createBrowseHandler() {
} }
} }
} catch (error) { } catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, "Browse directories failed"); logError(error, "Browse directories failed");
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -4,7 +4,7 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import fs from "fs/promises"; import fs from "fs/promises";
import { validatePath } from "../../../lib/security.js"; import { validatePath, PathNotAllowedError } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
export function createDeleteHandler() { export function createDeleteHandler() {
@@ -22,6 +22,12 @@ export function createDeleteHandler() {
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, "Delete file failed"); logError(error, "Delete file failed");
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -5,6 +5,7 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import { isPathAllowed, PathNotAllowedError } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
export function createExistsHandler() { export function createExistsHandler() {
@@ -17,10 +18,13 @@ export function createExistsHandler() {
return; return;
} }
// For exists, we check but don't require the path to be pre-allowed
// This allows the UI to validate user-entered paths
const resolvedPath = path.resolve(filePath); const resolvedPath = path.resolve(filePath);
// Validate that the path is allowed
if (!isPathAllowed(resolvedPath)) {
throw new PathNotAllowedError(filePath);
}
try { try {
await fs.access(resolvedPath); await fs.access(resolvedPath);
res.json({ success: true, exists: true }); res.json({ success: true, exists: true });
@@ -28,6 +32,12 @@ export function createExistsHandler() {
res.json({ success: true, exists: false }); res.json({ success: true, exists: false });
} }
} catch (error) { } catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, "Check exists failed"); logError(error, "Check exists failed");
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -6,7 +6,7 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import { addAllowedPath } from "../../../lib/security.js"; import { addAllowedPath, isPathAllowed, PathNotAllowedError } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
export function createMkdirHandler() { export function createMkdirHandler() {
@@ -21,6 +21,11 @@ export function createMkdirHandler() {
const resolvedPath = path.resolve(dirPath); const resolvedPath = path.resolve(dirPath);
// Validate that the path is allowed
if (!isPathAllowed(resolvedPath)) {
throw new PathNotAllowedError(dirPath);
}
// Check if path already exists using lstat (doesn't follow symlinks) // Check if path already exists using lstat (doesn't follow symlinks)
try { try {
const stats = await fs.lstat(resolvedPath); const stats = await fs.lstat(resolvedPath);
@@ -52,6 +57,12 @@ export function createMkdirHandler() {
res.json({ success: true }); res.json({ success: true });
} catch (error: any) { } catch (error: any) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
// Handle ELOOP specifically // Handle ELOOP specifically
if (error.code === "ELOOP") { if (error.code === "ELOOP") {
logError(error, "Create directory failed - symlink loop detected"); logError(error, "Create directory failed - symlink loop detected");

View File

@@ -4,7 +4,7 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import fs from "fs/promises"; import fs from "fs/promises";
import { validatePath } from "../../../lib/security.js"; import { validatePath, PathNotAllowedError } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
// Optional files that are expected to not exist in new projects // Optional files that are expected to not exist in new projects
@@ -39,6 +39,12 @@ export function createReadHandler() {
res.json({ success: true, content }); res.json({ success: true, content });
} catch (error) { } catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
// Don't log ENOENT errors for optional files (expected to be missing in new projects) // Don't log ENOENT errors for optional files (expected to be missing in new projects)
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || "")); const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ""));
if (shouldLog) { if (shouldLog) {

View File

@@ -4,7 +4,7 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import fs from "fs/promises"; import fs from "fs/promises";
import { validatePath } from "../../../lib/security.js"; import { validatePath, PathNotAllowedError } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
export function createReaddirHandler() { export function createReaddirHandler() {
@@ -28,6 +28,12 @@ export function createReaddirHandler() {
res.json({ success: true, entries: result }); res.json({ success: true, entries: result });
} catch (error) { } catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, "Read directory failed"); logError(error, "Read directory failed");
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -4,7 +4,7 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import fs from "fs/promises"; import fs from "fs/promises";
import { validatePath } from "../../../lib/security.js"; import { validatePath, PathNotAllowedError } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
export function createStatHandler() { export function createStatHandler() {
@@ -30,6 +30,12 @@ export function createStatHandler() {
}, },
}); });
} catch (error) { } catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, "Get file stats failed"); logError(error, "Get file stats failed");
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -5,7 +5,7 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import { validatePath } from "../../../lib/security.js"; import { validatePath, PathNotAllowedError } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
import { mkdirSafe } from "../../../lib/fs-utils.js"; import { mkdirSafe } from "../../../lib/fs-utils.js";
@@ -30,6 +30,12 @@ export function createWriteHandler() {
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, "Write file failed"); logError(error, "Write file failed");
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -6,7 +6,7 @@ import type { Request, Response } from "express";
import { spawn } from "child_process"; import { spawn } from "child_process";
import path from "path"; import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import { addAllowedPath } from "../../../lib/security.js"; import { addAllowedPath, isPathAllowed, PathNotAllowedError } from "../../../lib/security.js";
import { logger, getErrorMessage, logError } from "../common.js"; import { logger, getErrorMessage, logError } from "../common.js";
export function createCloneHandler() { export function createCloneHandler() {
@@ -63,6 +63,24 @@ export function createCloneHandler() {
return; return;
} }
// Validate that parent directory is within allowed root directory
if (!isPathAllowed(resolvedParent)) {
res.status(403).json({
success: false,
error: `Parent directory not allowed: ${parentDir}. Must be within ALLOWED_ROOT_DIRECTORY.`,
});
return;
}
// Validate that project path will be within allowed root directory
if (!isPathAllowed(resolvedProject)) {
res.status(403).json({
success: false,
error: `Project path not allowed: ${projectPath}. Must be within ALLOWED_ROOT_DIRECTORY.`,
});
return;
}
// Check if directory already exists // Check if directory already exists
try { try {
await fs.access(projectPath); await fs.access(projectPath);

View File

@@ -4,13 +4,16 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import fs from "fs/promises"; import fs from "fs/promises";
import { addAllowedPath } from "../../../lib/security.js"; import path from "path";
import { addAllowedPath, getAllowedRootDirectory } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
export function createConfigHandler() { export function createConfigHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
try { try {
const workspaceDir = process.env.WORKSPACE_DIR; // Prefer ALLOWED_ROOT_DIRECTORY, fall back to WORKSPACE_DIR for backward compatibility
const allowedRootDirectory = getAllowedRootDirectory();
const workspaceDir = process.env.WORKSPACE_DIR || allowedRootDirectory;
if (!workspaceDir) { if (!workspaceDir) {
res.json({ res.json({
@@ -22,29 +25,30 @@ export function createConfigHandler() {
// Check if the directory exists // Check if the directory exists
try { try {
const stats = await fs.stat(workspaceDir); const resolvedWorkspaceDir = path.resolve(workspaceDir);
const stats = await fs.stat(resolvedWorkspaceDir);
if (!stats.isDirectory()) { if (!stats.isDirectory()) {
res.json({ res.json({
success: true, success: true,
configured: false, configured: false,
error: "WORKSPACE_DIR is not a valid directory", error: "Configured workspace directory is not a valid directory",
}); });
return; return;
} }
// Add workspace dir to allowed paths // Add workspace dir to allowed paths
addAllowedPath(workspaceDir); addAllowedPath(resolvedWorkspaceDir);
res.json({ res.json({
success: true, success: true,
configured: true, configured: true,
workspaceDir, workspaceDir: resolvedWorkspaceDir,
}); });
} catch { } catch {
res.json({ res.json({
success: true, success: true,
configured: false, configured: false,
error: "WORKSPACE_DIR path does not exist", error: "Configured workspace directory path does not exist",
}); });
} }
} catch (error) { } catch (error) {

View File

@@ -5,45 +5,49 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import { addAllowedPath } from "../../../lib/security.js"; import { addAllowedPath, getAllowedRootDirectory } from "../../../lib/security.js";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
export function createDirectoriesHandler() { export function createDirectoriesHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
try { try {
const workspaceDir = process.env.WORKSPACE_DIR; // Prefer ALLOWED_ROOT_DIRECTORY, fall back to WORKSPACE_DIR for backward compatibility
const allowedRootDirectory = getAllowedRootDirectory();
const workspaceDir = process.env.WORKSPACE_DIR || allowedRootDirectory;
if (!workspaceDir) { if (!workspaceDir) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "WORKSPACE_DIR is not configured", error: "Workspace directory is not configured (set ALLOWED_ROOT_DIRECTORY or WORKSPACE_DIR)",
}); });
return; return;
} }
const resolvedWorkspaceDir = path.resolve(workspaceDir);
// Check if directory exists // Check if directory exists
try { try {
await fs.stat(workspaceDir); await fs.stat(resolvedWorkspaceDir);
} catch { } catch {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: "WORKSPACE_DIR path does not exist", error: "Workspace directory path does not exist",
}); });
return; return;
} }
// Add workspace dir to allowed paths // Add workspace dir to allowed paths
addAllowedPath(workspaceDir); addAllowedPath(resolvedWorkspaceDir);
// Read directory contents // Read directory contents
const entries = await fs.readdir(workspaceDir, { withFileTypes: true }); const entries = await fs.readdir(resolvedWorkspaceDir, { withFileTypes: true });
// Filter to directories only and map to result format // Filter to directories only and map to result format
const directories = entries const directories = entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.map((entry) => ({ .map((entry) => ({
name: entry.name, name: entry.name,
path: path.join(workspaceDir, entry.name), path: path.join(resolvedWorkspaceDir, entry.name),
})) }))
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));

View File

@@ -13,6 +13,7 @@ import { readImageAsBase64 } from "../lib/image-handler.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js"; import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { createChatOptions } from "../lib/sdk-options.js"; import { createChatOptions } from "../lib/sdk-options.js";
import { isAbortError } from "../lib/error-handler.js"; import { isAbortError } from "../lib/error-handler.js";
import { isPathAllowed, PathNotAllowedError } from "../lib/security.js";
interface Message { interface Message {
id: string; id: string;
@@ -80,11 +81,20 @@ export class AgentService {
const metadata = await this.loadMetadata(); const metadata = await this.loadMetadata();
const sessionMetadata = metadata[sessionId]; const sessionMetadata = metadata[sessionId];
// Determine the effective working directory
const effectiveWorkingDirectory = workingDirectory || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed
if (!isPathAllowed(resolvedWorkingDirectory)) {
throw new PathNotAllowedError(effectiveWorkingDirectory);
}
this.sessions.set(sessionId, { this.sessions.set(sessionId, {
messages, messages,
isRunning: false, isRunning: false,
abortController: null, abortController: null,
workingDirectory: workingDirectory || process.cwd(), workingDirectory: resolvedWorkingDirectory,
sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID
}); });
} }
@@ -461,11 +471,28 @@ export class AgentService {
const sessionId = this.generateId(); const sessionId = this.generateId();
const metadata = await this.loadMetadata(); const metadata = await this.loadMetadata();
// Determine the effective working directory
const effectiveWorkingDirectory = workingDirectory || projectPath || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed
if (!isPathAllowed(resolvedWorkingDirectory)) {
throw new PathNotAllowedError(effectiveWorkingDirectory);
}
// Validate that projectPath is allowed if provided
if (projectPath) {
const resolvedProjectPath = path.resolve(projectPath);
if (!isPathAllowed(resolvedProjectPath)) {
throw new PathNotAllowedError(projectPath);
}
}
const session: SessionMetadata = { const session: SessionMetadata = {
id: sessionId, id: sessionId,
name, name,
projectPath, projectPath,
workingDirectory: workingDirectory || projectPath || process.cwd(), workingDirectory: resolvedWorkingDirectory,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
model, model,

View File

@@ -24,6 +24,7 @@ import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency
import type { Feature } from "./feature-loader.js"; import type { Feature } from "./feature-loader.js";
import { FeatureLoader } from "./feature-loader.js"; import { FeatureLoader } from "./feature-loader.js";
import { getFeatureDir, getAutomakerDir, getFeaturesDir, getContextDir } from "../lib/automaker-paths.js"; import { getFeatureDir, getAutomakerDir, getFeaturesDir, getContextDir } from "../lib/automaker-paths.js";
import { isPathAllowed, PathNotAllowedError } from "../lib/security.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -486,6 +487,11 @@ export class AutoModeService {
this.runningFeatures.set(featureId, tempRunningFeature); this.runningFeatures.set(featureId, tempRunningFeature);
try { try {
// Validate that project path is allowed
if (!isPathAllowed(projectPath)) {
throw new PathNotAllowedError(projectPath);
}
// Check if feature has existing context - if so, resume instead of starting fresh // Check if feature has existing context - if so, resume instead of starting fresh
// Skip this check if we're already being called with a continuation prompt (from resumeFeature) // Skip this check if we're already being called with a continuation prompt (from resumeFeature)
if (!options?.continuationPrompt) { if (!options?.continuationPrompt) {
@@ -549,6 +555,11 @@ export class AutoModeService {
? path.resolve(worktreePath) ? path.resolve(worktreePath)
: path.resolve(projectPath); : path.resolve(projectPath);
// Validate that working directory is allowed
if (!isPathAllowed(workDir)) {
throw new PathNotAllowedError(workDir);
}
// Update running feature with actual worktree info // Update running feature with actual worktree info
tempRunningFeature.worktreePath = worktreePath; tempRunningFeature.worktreePath = worktreePath;
tempRunningFeature.branchName = branchName ?? null; tempRunningFeature.branchName = branchName ?? null;

View File

@@ -129,16 +129,38 @@ describe("security.ts", () => {
}); });
describe("isPathAllowed", () => { describe("isPathAllowed", () => {
it("should allow all paths (permissions disabled)", async () => { it("should allow paths within configured allowed directories", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed/project"; process.env.ALLOWED_PROJECT_DIRS = "/allowed/project";
process.env.DATA_DIR = ""; process.env.DATA_DIR = "";
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, isPathAllowed } = await import( const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js" "@/lib/security.js"
); );
initAllowedPaths(); initAllowedPaths();
// All paths are now allowed regardless of configuration // Paths within allowed directory should be allowed
expect(isPathAllowed("/allowed/project/file.txt")).toBe(true);
expect(isPathAllowed("/allowed/project/subdir/file.txt")).toBe(true);
// Paths outside allowed directory should be denied
expect(isPathAllowed("/not/allowed/file.txt")).toBe(false);
expect(isPathAllowed("/tmp/file.txt")).toBe(false);
expect(isPathAllowed("/etc/passwd")).toBe(false);
});
it("should allow all paths when no restrictions are configured", async () => {
delete process.env.ALLOWED_PROJECT_DIRS;
delete process.env.DATA_DIR;
delete process.env.WORKSPACE_DIR;
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, isPathAllowed } = await import(
"@/lib/security.js"
);
initAllowedPaths();
// All paths should be allowed when no restrictions are configured
expect(isPathAllowed("/allowed/project/file.txt")).toBe(true); expect(isPathAllowed("/allowed/project/file.txt")).toBe(true);
expect(isPathAllowed("/not/allowed/file.txt")).toBe(true); expect(isPathAllowed("/not/allowed/file.txt")).toBe(true);
expect(isPathAllowed("/tmp/file.txt")).toBe(true); expect(isPathAllowed("/tmp/file.txt")).toBe(true);
@@ -148,9 +170,10 @@ describe("security.ts", () => {
}); });
describe("validatePath", () => { describe("validatePath", () => {
it("should return resolved path for any path (permissions disabled)", async () => { it("should return resolved path for allowed paths", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed"; process.env.ALLOWED_PROJECT_DIRS = "/allowed";
process.env.DATA_DIR = ""; process.env.DATA_DIR = "";
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, validatePath } = await import( const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js" "@/lib/security.js"
@@ -161,26 +184,43 @@ describe("security.ts", () => {
expect(result).toBe(path.resolve("/allowed/file.txt")); expect(result).toBe(path.resolve("/allowed/file.txt"));
}); });
it("should not throw error for any path (permissions disabled)", async () => { it("should throw error for paths outside allowed directories", async () => {
process.env.ALLOWED_PROJECT_DIRS = "/allowed"; process.env.ALLOWED_PROJECT_DIRS = "/allowed";
process.env.DATA_DIR = ""; process.env.DATA_DIR = "";
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, validatePath } = await import( const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js" "@/lib/security.js"
); );
initAllowedPaths(); initAllowedPaths();
// All paths are now allowed, no errors thrown // Disallowed paths should throw PathNotAllowedError
expect(() => validatePath("/disallowed/file.txt")).toThrow();
});
it("should not throw error for any path when no restrictions are configured", async () => {
delete process.env.ALLOWED_PROJECT_DIRS;
delete process.env.DATA_DIR;
delete process.env.WORKSPACE_DIR;
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js"
);
initAllowedPaths();
// All paths are allowed when no restrictions configured
expect(() => validatePath("/disallowed/file.txt")).not.toThrow(); expect(() => validatePath("/disallowed/file.txt")).not.toThrow();
expect(validatePath("/disallowed/file.txt")).toBe( expect(validatePath("/disallowed/file.txt")).toBe(
path.resolve("/disallowed/file.txt") path.resolve("/disallowed/file.txt")
); );
}); });
it("should resolve relative paths", async () => { it("should resolve relative paths within allowed directory", async () => {
const cwd = process.cwd(); const cwd = process.cwd();
process.env.ALLOWED_PROJECT_DIRS = cwd; process.env.ALLOWED_PROJECT_DIRS = cwd;
process.env.DATA_DIR = ""; process.env.DATA_DIR = "";
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, validatePath } = await import( const { initAllowedPaths, validatePath } = await import(
"@/lib/security.js" "@/lib/security.js"

View File

@@ -191,7 +191,9 @@ export function WorktreeTab({
)} )}
onClick={() => onSelectWorktree(worktree)} onClick={() => onSelectWorktree(worktree)}
disabled={isActivating} disabled={isActivating}
title="Click to preview main" title={`Click to preview ${worktree.branch}`}
aria-label={worktree.branch}
data-testid={`worktree-branch-${worktree.branch}`}
> >
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />} {isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && ( {isActivating && !isRunning && (

View File

@@ -239,6 +239,24 @@ export function WelcomeView() {
const api = getElectronAPI(); const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`; const projectPath = `${parentDir}/${projectName}`;
// Validate that parent directory exists
const parentExists = await api.exists(parentDir);
if (!parentExists) {
toast.error("Parent directory does not exist", {
description: `Cannot create project in non-existent directory: ${parentDir}`,
});
return;
}
// Verify parent is actually a directory
const parentStat = await api.stat(parentDir);
if (parentStat && !parentStat.isDirectory) {
toast.error("Parent path is not a directory", {
description: `${parentDir} is not a directory`,
});
return;
}
// Create project directory // Create project directory
const mkdirResult = await api.mkdir(projectPath); const mkdirResult = await api.mkdir(projectPath);
if (!mkdirResult.success) { if (!mkdirResult.success) {

View File

@@ -48,6 +48,34 @@ export async function initializeProject(
const existingFiles: string[] = []; const existingFiles: string[] = [];
try { try {
// Validate that the project directory exists and is a directory
const projectExists = await api.exists(projectPath);
if (!projectExists) {
return {
success: false,
isNewProject: false,
error: `Project directory does not exist: ${projectPath}. Create it first before initializing.`,
};
}
// Verify it's actually a directory (not a file)
const projectStat = await api.stat(projectPath);
if (!projectStat.success) {
return {
success: false,
isNewProject: false,
error: projectStat.error || `Failed to stat project directory: ${projectPath}`,
};
}
if (projectStat.stats && !projectStat.stats.isDirectory) {
return {
success: false,
isNewProject: false,
error: `Project path is not a directory: ${projectPath}`,
};
}
// Initialize git repository if it doesn't exist // Initialize git repository if it doesn't exist
const gitDirExists = await api.exists(`${projectPath}/.git`); const gitDirExists = await api.exists(`${projectPath}/.git`);
if (!gitDirExists) { if (!gitDirExists) {

View File

@@ -46,28 +46,31 @@ test.describe("Spec Editor Persistence", () => {
// Step 4: Click on the Spec Editor in the sidebar // Step 4: Click on the Spec Editor in the sidebar
await navigateToSpecEditor(page); await navigateToSpecEditor(page);
// Step 5: Wait for the spec editor to load // Step 5: Wait for the spec view to load (not empty state)
await waitForElement(page, "spec-view", { timeout: 10000 });
// Step 6: Wait for the spec editor to load
const specEditor = await getByTestId(page, "spec-editor"); const specEditor = await getByTestId(page, "spec-editor");
await specEditor.waitFor({ state: "visible", timeout: 10000 }); await specEditor.waitFor({ state: "visible", timeout: 10000 });
// Step 6: Wait for CodeMirror to initialize (it has a .cm-content element) // Step 7: Wait for CodeMirror to initialize (it has a .cm-content element)
await specEditor.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 }); await specEditor.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
// Step 7: Modify the editor content to "hello world" // Step 8: Modify the editor content to "hello world"
await setEditorContent(page, "hello world"); await setEditorContent(page, "hello world");
// Verify content was set before saving // Verify content was set before saving
const contentBeforeSave = await getEditorContent(page); const contentBeforeSave = await getEditorContent(page);
expect(contentBeforeSave.trim()).toBe("hello world"); expect(contentBeforeSave.trim()).toBe("hello world");
// Step 8: Click the save button and wait for save to complete // Step 9: Click the save button and wait for save to complete
await clickSaveButton(page); await clickSaveButton(page);
// Step 9: Refresh the page // Step 10: Refresh the page
await page.reload(); await page.reload();
await waitForNetworkIdle(page); await waitForNetworkIdle(page);
// Step 10: Navigate back to the spec editor // Step 11: Navigate back to the spec editor
// After reload, we need to wait for the app to initialize // After reload, we need to wait for the app to initialize
await waitForElement(page, "sidebar", { timeout: 10000 }); await waitForElement(page, "sidebar", { timeout: 10000 });
@@ -116,7 +119,7 @@ test.describe("Spec Editor Persistence", () => {
); );
} }
// Step 11: Verify the content was persisted // Step 12: Verify the content was persisted
const persistedContent = await getEditorContent(page); const persistedContent = await getEditorContent(page);
expect(persistedContent.trim()).toBe("hello world"); expect(persistedContent.trim()).toBe("hello world");
}); });

View File

@@ -37,8 +37,25 @@ export async function navigateToSpec(page: Page): Promise<void> {
await page.goto("/spec"); await page.goto("/spec");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Wait for the spec view to be visible // Wait for loading state to complete first (if present)
await waitForElement(page, "spec-view", { timeout: 10000 }); const loadingElement = page.locator('[data-testid="spec-view-loading"]');
try {
const loadingVisible = await loadingElement.isVisible({ timeout: 2000 });
if (loadingVisible) {
// Wait for loading to disappear (spec view or empty state will appear)
await loadingElement.waitFor({ state: "hidden", timeout: 10000 });
}
} catch {
// Loading element not found or already hidden, continue
}
// Wait for either the main spec view or empty state to be visible
// The spec-view element appears when loading is complete and spec exists
// The spec-view-empty element appears when loading is complete and spec doesn't exist
await Promise.race([
waitForElement(page, "spec-view", { timeout: 10000 }).catch(() => null),
waitForElement(page, "spec-view-empty", { timeout: 10000 }).catch(() => null),
]);
} }
/** /**

View File

@@ -128,7 +128,7 @@ export async function waitForContextFile(
filename: string, filename: string,
timeout: number = 10000 timeout: number = 10000
): Promise<void> { ): Promise<void> {
const locator = await getByTestId(page, `context-file-${filename}`); const locator = page.locator(`[data-testid="context-file-${filename}"]`);
await locator.waitFor({ state: "visible", timeout }); await locator.waitFor({ state: "visible", timeout });
} }

View File

@@ -103,9 +103,10 @@ test.describe("Worktree Integration Tests", () => {
const branchLabel = page.getByText("Branch:"); const branchLabel = page.getByText("Branch:");
await expect(branchLabel).toBeVisible({ timeout: 10000 }); await expect(branchLabel).toBeVisible({ timeout: 10000 });
// Verify main branch button is displayed // Wait for worktrees to load and main branch button to appear
const mainBranchButton = page.getByRole("button", { name: "main" }); // Use data-testid for more reliable selection
await expect(mainBranchButton).toBeVisible({ timeout: 10000 }); const mainBranchButton = page.locator('[data-testid="worktree-branch-main"]');
await expect(mainBranchButton).toBeVisible({ timeout: 15000 });
}); });
test("should select main branch by default when app loads with stale worktree data", async ({ test("should select main branch by default when app loads with stale worktree data", async ({

View File

@@ -2,9 +2,13 @@ services:
server: server:
volumes: volumes:
# Mount your workspace directory to /projects inside the container # Mount your workspace directory to /projects inside the container
# Example: mount your local /workspace to /projects inside the container
- /Users/webdevcody/Workspace/automaker-workspace:/projects:rw - /Users/webdevcody/Workspace/automaker-workspace:/projects:rw
environment: environment:
# Set workspace directory so the UI can discover projects # Set root directory for all projects and file operations
- WORKSPACE_DIR=/projects # Users can only create/open projects within this directory
# Ensure /projects is in allowed directories - ALLOWED_ROOT_DIRECTORY=/projects
- ALLOWED_PROJECT_DIRS=/projects
# Optional: Set workspace directory for UI project discovery
# Falls back to ALLOWED_ROOT_DIRECTORY if not set
# - WORKSPACE_DIR=/projects

View File

@@ -37,11 +37,13 @@ services:
# Optional - authentication (leave empty to disable) # Optional - authentication (leave empty to disable)
- AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-} - AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-}
# Optional - restrict to specific directories within container only # Optional - restrict to specific directory within container only
# These paths are INSIDE the container, not on your host # Projects and files can only be created/accessed within this directory
- ALLOWED_PROJECT_DIRS=${ALLOWED_PROJECT_DIRS:-/projects} # Paths are INSIDE the container, not on your host
# Default: /projects
- ALLOWED_ROOT_DIRECTORY=${ALLOWED_ROOT_DIRECTORY:-/projects}
# Optional - data directory for sessions, etc. (container-only) # Optional - data directory for sessions, settings, etc. (container-only)
- DATA_DIR=/data - DATA_DIR=/data
# Optional - CORS origin (default allows all) # Optional - CORS origin (default allows all)