refactor: integrate secure file system operations across services

This commit replaces direct file system operations with a secure file system adapter to enhance security by enforcing path validation. The changes include:

- Replaced `fs` imports with `secureFs` in various services and utilities.
- Updated file operations in `agent-service`, `auto-mode-service`, `feature-loader`, and `settings-service` to use the secure file system methods.
- Ensured that all file I/O operations are validated against the ALLOWED_ROOT_DIRECTORY.

This refactor aims to prevent unauthorized file access and improve overall security posture.

Tests: All unit tests passing.

🤖 Generated with Claude Code
This commit is contained in:
Test User
2025-12-20 18:45:39 -05:00
parent ade80484bb
commit f3c9e828e2
45 changed files with 329 additions and 551 deletions

View File

@@ -5,7 +5,7 @@
import { AbortError } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises";
import * as secureFs from "../lib/secure-fs.js";
import type { EventEmitter } from "../lib/events.js";
import { ProviderFactory } from "../providers/provider-factory.js";
import type { ExecuteOptions } from "../providers/types.js";
@@ -63,7 +63,7 @@ export class AgentService {
}
async initialize(): Promise<void> {
await fs.mkdir(this.stateDir, { recursive: true });
await secureFs.mkdir(this.stateDir, { recursive: true });
}
/**
@@ -401,7 +401,7 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
const data = await fs.readFile(sessionFile, "utf-8");
const data = await secureFs.readFile(sessionFile, "utf-8") as string;
return JSON.parse(data);
} catch {
return [];
@@ -412,7 +412,7 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
await fs.writeFile(
await secureFs.writeFile(
sessionFile,
JSON.stringify(messages, null, 2),
"utf-8"
@@ -425,7 +425,7 @@ export class AgentService {
async loadMetadata(): Promise<Record<string, SessionMetadata>> {
try {
const data = await fs.readFile(this.metadataFile, "utf-8");
const data = await secureFs.readFile(this.metadataFile, "utf-8") as string;
return JSON.parse(data);
} catch {
return {};
@@ -433,7 +433,7 @@ export class AgentService {
}
async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> {
await fs.writeFile(
await secureFs.writeFile(
this.metadataFile,
JSON.stringify(metadata, null, 2),
"utf-8"
@@ -551,7 +551,7 @@ export class AgentService {
// Delete session file
try {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
await fs.unlink(sessionFile);
await secureFs.unlink(sessionFile);
} catch {
// File may not exist
}

View File

@@ -14,7 +14,7 @@ import type { ExecuteOptions } from "../providers/types.js";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import * as secureFs from "../lib/secure-fs.js";
import type { EventEmitter } from "../lib/events.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
@@ -698,7 +698,7 @@ export class AutoModeService {
let hasContext = false;
try {
await fs.access(contextPath);
await secureFs.access(contextPath);
hasContext = true;
} catch {
// No context
@@ -706,7 +706,7 @@ export class AutoModeService {
if (hasContext) {
// Load previous context and continue
const context = await fs.readFile(contextPath, "utf-8");
const context = await secureFs.readFile(contextPath, "utf-8") as string;
return this.executeFeatureWithContext(
projectPath,
featureId,
@@ -766,7 +766,7 @@ export class AutoModeService {
const contextPath = path.join(featureDir, "agent-output.md");
let previousContext = "";
try {
previousContext = await fs.readFile(contextPath, "utf-8");
previousContext = await secureFs.readFile(contextPath, "utf-8") as string;
} catch {
// No previous context
}
@@ -832,7 +832,7 @@ Address the follow-up instructions above. Review the previous work and make the
const featureDirForImages = getFeatureDir(projectPath, featureId);
const featureImagesDir = path.join(featureDirForImages, "images");
await fs.mkdir(featureImagesDir, { recursive: true });
await secureFs.mkdir(featureImagesDir, { recursive: true });
for (const imagePath of imagePaths) {
try {
@@ -841,7 +841,7 @@ Address the follow-up instructions above. Review the previous work and make the
const destPath = path.join(featureImagesDir, filename);
// Copy the image
await fs.copyFile(imagePath, destPath);
await secureFs.copyFile(imagePath, destPath);
// Store the absolute path (external storage uses absolute paths)
copiedImagePaths.push(destPath);
@@ -883,7 +883,7 @@ Address the follow-up instructions above. Review the previous work and make the
const featurePath = path.join(featureDirForSave, "feature.json");
try {
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
} catch (error) {
console.error(`[AutoMode] Failed to save feature.json:`, error);
}
@@ -949,7 +949,7 @@ Address the follow-up instructions above. Review the previous work and make the
let workDir = projectPath;
try {
await fs.access(worktreePath);
await secureFs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree
@@ -1018,7 +1018,7 @@ Address the follow-up instructions above. Review the previous work and make the
// Use the provided worktree path if given
if (providedWorktreePath) {
try {
await fs.access(providedWorktreePath);
await secureFs.access(providedWorktreePath);
workDir = providedWorktreePath;
console.log(`[AutoMode] Committing in provided worktree: ${workDir}`);
} catch {
@@ -1034,7 +1034,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId
);
try {
await fs.access(legacyWorktreePath);
await secureFs.access(legacyWorktreePath);
workDir = legacyWorktreePath;
console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`);
} catch {
@@ -1097,7 +1097,7 @@ Address the follow-up instructions above. Review the previous work and make the
const contextPath = path.join(featureDir, "agent-output.md");
try {
await fs.access(contextPath);
await secureFs.access(contextPath);
return true;
} catch {
return false;
@@ -1115,9 +1115,9 @@ Address the follow-up instructions above. Review the previous work and make the
try {
// Check if directory exists first
await fs.access(contextDir);
await secureFs.access(contextDir);
const files = await fs.readdir(contextDir);
const files = await secureFs.readdir(contextDir) as string[];
// Filter for text-based context files (case-insensitive for Windows)
const textFiles = files.filter((f) => {
const lower = f.toLowerCase();
@@ -1130,7 +1130,7 @@ Address the follow-up instructions above. Review the previous work and make the
for (const file of textFiles) {
// Use path.join for cross-platform path construction
const filePath = path.join(contextDir, file);
const content = await fs.readFile(filePath, "utf-8");
const content = await secureFs.readFile(filePath, "utf-8") as string;
contents.push(`## ${file}\n\n${content}`);
}
@@ -1229,8 +1229,8 @@ Format your response as a structured markdown document.`;
// Save analysis to .automaker directory
const automakerDir = getAutomakerDir(projectPath);
const analysisPath = path.join(automakerDir, "project-analysis.md");
await fs.mkdir(automakerDir, { recursive: true });
await fs.writeFile(analysisPath, analysisResult);
await secureFs.mkdir(automakerDir, { recursive: true });
await secureFs.writeFile(analysisPath, analysisResult);
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId: analysisFeatureId,
@@ -1498,7 +1498,7 @@ Format your response as a structured markdown document.`;
const featurePath = path.join(featureDir, "feature.json");
try {
const data = await fs.readFile(featurePath, "utf-8");
const data = await secureFs.readFile(featurePath, "utf-8") as string;
return JSON.parse(data);
} catch {
return null;
@@ -1515,7 +1515,7 @@ Format your response as a structured markdown document.`;
const featurePath = path.join(featureDir, "feature.json");
try {
const data = await fs.readFile(featurePath, "utf-8");
const data = await secureFs.readFile(featurePath, "utf-8") as string;
const feature = JSON.parse(data);
feature.status = status;
feature.updatedAt = new Date().toISOString();
@@ -1527,7 +1527,7 @@ Format your response as a structured markdown document.`;
// Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined;
}
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
} catch {
// Feature file may not exist
}
@@ -1550,7 +1550,7 @@ Format your response as a structured markdown document.`;
);
try {
const data = await fs.readFile(featurePath, "utf-8");
const data = await secureFs.readFile(featurePath, "utf-8") as string;
const feature = JSON.parse(data);
// Initialize planSpec if it doesn't exist
@@ -1571,7 +1571,7 @@ Format your response as a structured markdown document.`;
}
feature.updatedAt = new Date().toISOString();
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
} catch (error) {
console.error(`[AutoMode] Failed to update planSpec for ${featureId}:`, error);
}
@@ -1582,7 +1582,7 @@ Format your response as a structured markdown document.`;
const featuresDir = getFeaturesDir(projectPath);
try {
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }) as any[];
const allFeatures: Feature[] = [];
const pendingFeatures: Feature[] = [];
@@ -1595,7 +1595,7 @@ Format your response as a structured markdown document.`;
"feature.json"
);
try {
const data = await fs.readFile(featurePath, "utf-8");
const data = await secureFs.readFile(featurePath, "utf-8") as string;
const feature = JSON.parse(data);
allFeatures.push(feature);
@@ -1799,7 +1799,7 @@ This helps parse your summary correctly in the output logs.`;
// Create a mock file with "yellow" content as requested in the test
const mockFilePath = path.join(workDir, "yellow.txt");
await fs.writeFile(mockFilePath, "yellow");
await secureFs.writeFile(mockFilePath, "yellow");
this.emitAutoModeEvent("auto_mode_progress", {
featureId,
@@ -1824,8 +1824,8 @@ This is a mock agent response for CI/CD testing.
This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
`;
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, mockOutput);
await secureFs.mkdir(path.dirname(outputPath), { recursive: true });
await secureFs.writeFile(outputPath, mockOutput);
console.log(
`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`
@@ -1901,8 +1901,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
// Helper to write current responseText to file
const writeToFile = async (): Promise<void> => {
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, responseText);
await secureFs.mkdir(path.dirname(outputPath), { recursive: true });
await secureFs.writeFile(outputPath, responseText);
} catch (error) {
// Log but don't crash - file write errors shouldn't stop execution
console.error(

View File

@@ -4,7 +4,7 @@
*/
import path from "path";
import fs from "fs/promises";
import * as secureFs from "../lib/secure-fs.js";
import {
getFeaturesDir,
getFeatureDir,
@@ -88,7 +88,7 @@ export class FeatureLoader {
if (!newPathSet.has(oldPath)) {
try {
// Paths are now absolute
await fs.unlink(oldPath);
await secureFs.unlink(oldPath);
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
} catch (error) {
// Ignore errors when deleting (file may already be gone)
@@ -116,7 +116,7 @@ export class FeatureLoader {
}
const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId);
await fs.mkdir(featureImagesDir, { recursive: true });
await secureFs.mkdir(featureImagesDir, { recursive: true });
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> =
[];
@@ -139,7 +139,7 @@ export class FeatureLoader {
// Check if file exists
try {
await fs.access(fullOriginalPath);
await secureFs.access(fullOriginalPath);
} catch {
console.warn(
`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`
@@ -152,14 +152,14 @@ export class FeatureLoader {
const newPath = path.join(featureImagesDir, filename);
// Copy the file
await fs.copyFile(fullOriginalPath, newPath);
await secureFs.copyFile(fullOriginalPath, newPath);
console.log(
`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`
);
// Try to delete the original temp file
try {
await fs.unlink(fullOriginalPath);
await secureFs.unlink(fullOriginalPath);
} catch {
// Ignore errors when deleting temp file
}
@@ -217,13 +217,13 @@ export class FeatureLoader {
// Check if features directory exists
try {
await fs.access(featuresDir);
await secureFs.access(featuresDir);
} catch {
return [];
}
// Read all feature directories
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }) as any[];
const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load each feature
@@ -233,7 +233,7 @@ export class FeatureLoader {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try {
const content = await fs.readFile(featureJsonPath, "utf-8");
const content = await secureFs.readFile(featureJsonPath, "utf-8") as string;
const feature = JSON.parse(content);
if (!feature.id) {
@@ -280,7 +280,7 @@ export class FeatureLoader {
async get(projectPath: string, featureId: string): Promise<Feature | null> {
try {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = await fs.readFile(featureJsonPath, "utf-8");
const content = await secureFs.readFile(featureJsonPath, "utf-8") as string;
return JSON.parse(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
@@ -309,7 +309,7 @@ export class FeatureLoader {
await ensureAutomakerDir(projectPath);
// Create feature directory
await fs.mkdir(featureDir, { recursive: true });
await secureFs.mkdir(featureDir, { recursive: true });
// Migrate images from temp directory to feature directory
const migratedImagePaths = await this.migrateImages(
@@ -328,7 +328,7 @@ export class FeatureLoader {
};
// Write feature.json
await fs.writeFile(
await secureFs.writeFile(
featureJsonPath,
JSON.stringify(feature, null, 2),
"utf-8"
@@ -380,7 +380,7 @@ export class FeatureLoader {
// Write back to file
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await fs.writeFile(
await secureFs.writeFile(
featureJsonPath,
JSON.stringify(updatedFeature, null, 2),
"utf-8"
@@ -396,7 +396,7 @@ export class FeatureLoader {
async delete(projectPath: string, featureId: string): Promise<boolean> {
try {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.rm(featureDir, { recursive: true, force: true });
await secureFs.rm(featureDir, { recursive: true, force: true });
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
return true;
} catch (error) {
@@ -417,7 +417,7 @@ export class FeatureLoader {
): Promise<string | null> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
const content = await fs.readFile(agentOutputPath, "utf-8");
const content = await secureFs.readFile(agentOutputPath, "utf-8") as string;
return content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
@@ -440,10 +440,10 @@ export class FeatureLoader {
content: string
): Promise<void> {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.mkdir(featureDir, { recursive: true });
await secureFs.mkdir(featureDir, { recursive: true });
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.writeFile(agentOutputPath, content, "utf-8");
await secureFs.writeFile(agentOutputPath, content, "utf-8");
}
/**
@@ -455,7 +455,7 @@ export class FeatureLoader {
): Promise<void> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.unlink(agentOutputPath);
await secureFs.unlink(agentOutputPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;

View File

@@ -7,8 +7,7 @@
* - Per-project settings ({projectPath}/.automaker/settings.json)
*/
import fs from "fs/promises";
import path from "path";
import * as secureFs from "../lib/secure-fs.js";
import { createLogger } from "../lib/logger.js";
import {
getGlobalSettingsPath,
@@ -47,12 +46,12 @@ async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const content = JSON.stringify(data, null, 2);
try {
await fs.writeFile(tempPath, content, "utf-8");
await fs.rename(tempPath, filePath);
await secureFs.writeFile(tempPath, content, "utf-8");
await secureFs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await fs.unlink(tempPath);
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
@@ -65,7 +64,7 @@ async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = await fs.readFile(filePath, "utf-8");
const content = await secureFs.readFile(filePath, "utf-8") as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
@@ -81,7 +80,7 @@ async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
*/
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
await secureFs.access(filePath);
return true;
} catch {
return false;