Merge branch 'main' into feature/shared-packages

This commit is contained in:
Test User
2025-12-20 23:55:03 -05:00
76 changed files with 1898 additions and 893 deletions

View File

@@ -32,9 +32,15 @@ export {
// Security
export {
PathNotAllowedError,
initAllowedPaths,
addAllowedPath,
isPathAllowed,
validatePath,
isPathWithinDirectory,
getAllowedRootDirectory,
getDataDirectory,
getAllowedPaths,
} from './security.js';
// Secure file system (validates paths before I/O operations)
export * as secureFs from './secure-fs.js';

View File

@@ -9,7 +9,7 @@
* Directory creation is handled separately by ensure* functions.
*/
import fs from "fs/promises";
import * as secureFs from "./secure-fs.js";
import path from "path";
/**
@@ -149,7 +149,7 @@ export function getBranchTrackingPath(projectPath: string): string {
*/
export async function ensureAutomakerDir(projectPath: string): Promise<string> {
const automakerDir = getAutomakerDir(projectPath);
await fs.mkdir(automakerDir, { recursive: true });
await secureFs.mkdir(automakerDir, { recursive: true });
return automakerDir;
}
@@ -211,6 +211,6 @@ export function getProjectSettingsPath(projectPath: string): string {
* @returns Promise resolving to the created data directory path
*/
export async function ensureDataDir(dataDir: string): Promise<string> {
await fs.mkdir(dataDir, { recursive: true });
await secureFs.mkdir(dataDir, { recursive: true });
return dataDir;
}

View File

@@ -0,0 +1,168 @@
/**
* Secure File System Adapter
*
* All file I/O operations must go through this adapter to enforce
* ALLOWED_ROOT_DIRECTORY restrictions at the actual access point,
* not just at the API layer. This provides defense-in-depth security.
*/
import fs from "fs/promises";
import type { Dirent } from "fs";
import path from "path";
import { validatePath } from "./security.js";
/**
* Wrapper around fs.access that validates path first
*/
export async function access(filePath: string, mode?: number): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.access(validatedPath, mode);
}
/**
* Wrapper around fs.readFile that validates path first
*/
export async function readFile(
filePath: string,
encoding?: BufferEncoding
): Promise<string | Buffer> {
const validatedPath = validatePath(filePath);
if (encoding) {
return fs.readFile(validatedPath, encoding);
}
return fs.readFile(validatedPath);
}
/**
* Wrapper around fs.writeFile that validates path first
*/
export async function writeFile(
filePath: string,
data: string | Buffer,
encoding?: BufferEncoding
): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.writeFile(validatedPath, data, encoding);
}
/**
* Wrapper around fs.mkdir that validates path first
*/
export async function mkdir(
dirPath: string,
options?: { recursive?: boolean; mode?: number }
): Promise<string | undefined> {
const validatedPath = validatePath(dirPath);
return fs.mkdir(validatedPath, options);
}
/**
* Wrapper around fs.readdir that validates path first
*/
export async function readdir(
dirPath: string,
options?: { withFileTypes?: false; encoding?: BufferEncoding }
): Promise<string[]>;
export async function readdir(
dirPath: string,
options: { withFileTypes: true; encoding?: BufferEncoding }
): Promise<Dirent[]>;
export async function readdir(
dirPath: string,
options?: { withFileTypes?: boolean; encoding?: BufferEncoding }
): Promise<string[] | Dirent[]> {
const validatedPath = validatePath(dirPath);
if (options?.withFileTypes === true) {
return fs.readdir(validatedPath, { withFileTypes: true });
}
return fs.readdir(validatedPath);
}
/**
* Wrapper around fs.stat that validates path first
*/
export async function stat(filePath: string): Promise<any> {
const validatedPath = validatePath(filePath);
return fs.stat(validatedPath);
}
/**
* Wrapper around fs.rm that validates path first
*/
export async function rm(
filePath: string,
options?: { recursive?: boolean; force?: boolean }
): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.rm(validatedPath, options);
}
/**
* Wrapper around fs.unlink that validates path first
*/
export async function unlink(filePath: string): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.unlink(validatedPath);
}
/**
* Wrapper around fs.copyFile that validates both paths first
*/
export async function copyFile(
src: string,
dest: string,
mode?: number
): Promise<void> {
const validatedSrc = validatePath(src);
const validatedDest = validatePath(dest);
return fs.copyFile(validatedSrc, validatedDest, mode);
}
/**
* Wrapper around fs.appendFile that validates path first
*/
export async function appendFile(
filePath: string,
data: string | Buffer,
encoding?: BufferEncoding
): Promise<void> {
const validatedPath = validatePath(filePath);
return fs.appendFile(validatedPath, data, encoding);
}
/**
* Wrapper around fs.rename that validates both paths first
*/
export async function rename(
oldPath: string,
newPath: string
): Promise<void> {
const validatedOldPath = validatePath(oldPath);
const validatedNewPath = validatePath(newPath);
return fs.rename(validatedOldPath, validatedNewPath);
}
/**
* Wrapper around fs.lstat that validates path first
* Returns file stats without following symbolic links
*/
export async function lstat(filePath: string): Promise<any> {
const validatedPath = validatePath(filePath);
return fs.lstat(validatedPath);
}
/**
* Wrapper around path.join that returns resolved path
* Does NOT validate - use this for path construction, then pass to other operations
*/
export function joinPath(...pathSegments: string[]): string {
return path.join(...pathSegments);
}
/**
* Wrapper around path.resolve that returns resolved path
* Does NOT validate - use this for path construction, then pass to other operations
*/
export function resolvePath(...pathSegments: string[]): string {
return path.resolve(...pathSegments);
}

View File

@@ -1,90 +1,143 @@
/**
* Security utilities for path validation
*
* SECURITY NOTICE: Path validation is currently DISABLED
*
* All path access checks always return true, allowing unrestricted file system access.
* This was a deliberate design decision for the following reasons:
*
* 1. Development Flexibility: AutoMaker is a development tool that needs to access
* various project directories chosen by the user. Strict path restrictions would
* limit its usefulness.
*
* 2. User Control: The application runs with the user's permissions. Users should
* have full control over which directories they work with, without artificial
* restrictions imposed by the tool.
*
* 3. Trust Model: AutoMaker operates under a trust model where the user is assumed
* to be working on their own projects. The tool itself doesn't perform operations
* without user initiation.
*
* SECURITY CONSIDERATIONS:
* - This module maintains the allowed paths list for API compatibility and potential
* future use, but does not enforce any restrictions.
* - If security restrictions are needed in the future, the infrastructure is in place
* to enable them by modifying isPathAllowed() to actually check the allowed list.
* - For production deployments or untrusted environments, consider re-enabling path
* validation or implementing additional security layers.
*
* FUTURE ENHANCEMENT: Consider adding an environment variable (e.g., ENABLE_PATH_SECURITY)
* to allow enabling strict path validation when needed for specific deployment scenarios.
* Enforces ALLOWED_ROOT_DIRECTORY constraint with appData exception
*/
import path from "path";
// Allowed project directories - kept for API compatibility
const allowedPaths = new Set<string>();
/**
* 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;
/**
* Initialize allowed paths from environment variable
* Note: All paths are now allowed regardless of this setting
* Initialize security settings from environment variables
* - ALLOWED_ROOT_DIRECTORY: main security boundary
* - DATA_DIR: appData exception, always allowed
*/
export function initAllowedPaths(): void {
const dirs = process.env.ALLOWED_PROJECT_DIRS;
if (dirs) {
for (const dir of dirs.split(",")) {
const trimmed = dir.trim();
if (trimmed) {
allowedPaths.add(path.resolve(trimmed));
}
}
// Load ALLOWED_ROOT_DIRECTORY
const rootDir = process.env.ALLOWED_ROOT_DIRECTORY;
if (rootDir) {
allowedRootDirectory = path.resolve(rootDir);
console.log(
`[Security] ✓ ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`
);
} else {
console.log(
"[Security] ⚠️ ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths"
);
}
// Load DATA_DIR (appData exception - always allowed)
const dataDir = process.env.DATA_DIR;
if (dataDir) {
allowedPaths.add(path.resolve(dataDir));
}
const workspaceDir = process.env.WORKSPACE_DIR;
if (workspaceDir) {
allowedPaths.add(path.resolve(workspaceDir));
dataDirectory = path.resolve(dataDir);
console.log(`[Security] ✓ DATA_DIR configured: ${dataDirectory}`);
}
}
/**
* Add a path to the allowed list (no-op, all paths allowed)
* Check if a path is allowed based on ALLOWED_ROOT_DIRECTORY
* Returns true if:
* - Path is within ALLOWED_ROOT_DIRECTORY, OR
* - Path is within DATA_DIR (appData exception), OR
* - No restrictions are configured (backward compatibility)
*/
export function addAllowedPath(filePath: string): void {
allowedPaths.add(path.resolve(filePath));
export function isPathAllowed(filePath: string): boolean {
const resolvedPath = path.resolve(filePath);
// Always allow appData directory (settings, credentials)
if (dataDirectory && isPathWithinDirectory(resolvedPath, dataDirectory)) {
return true;
}
// If no ALLOWED_ROOT_DIRECTORY restriction is configured, allow all paths
// Note: DATA_DIR is checked above as an exception, but doesn't restrict other paths
if (!allowedRootDirectory) {
return true;
}
// Allow if within ALLOWED_ROOT_DIRECTORY
if (
allowedRootDirectory &&
isPathWithinDirectory(resolvedPath, allowedRootDirectory)
) {
return true;
}
// If restrictions are configured but path doesn't match, deny
return false;
}
/**
* Check if a path is allowed - always returns true
*/
export function isPathAllowed(_filePath: string): boolean {
return true;
}
/**
* 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 {
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;
}
/**
* Get list of allowed paths (for debugging)
*/
export function getAllowedPaths(): string[] {
return Array.from(allowedPaths);
const paths: string[] = [];
if (allowedRootDirectory) {
paths.push(allowedRootDirectory);
}
if (dataDirectory) {
paths.push(dataDirectory);
}
return paths;
}