agent-ecosystem/src/main/utils/pathValidation.ts

424 lines
12 KiB
TypeScript

/**
* Path Validation Utilities.
*
* Provides security sandboxing for file path access to prevent
* unauthorized access to sensitive system files.
*
* Cross-platform: uses path.resolve() for consistent drive-letter
* handling on Windows (normalizeForCompare, isPathWithinRoot).
*/
import * as fs from 'fs';
import * as path from 'path';
import { getClaudeBasePath, getHomeDir } from './pathDecoder';
/**
* Sensitive file patterns that should never be accessible.
* These are checked against the normalized absolute path.
*/
const SENSITIVE_PATTERNS: RegExp[] = [
// SSH keys and config
/[/\\]\.ssh[/\\]/i,
// AWS credentials
/[/\\]\.aws[/\\]/i,
// GCP credentials
/[/\\]\.config[/\\]gcloud[/\\]/i,
// Azure credentials
/[/\\]\.azure[/\\]/i,
// Environment files (anywhere in path)
/[/\\]\.env($|\.)/i,
// Git credentials
/[/\\]\.git-credentials$/i,
/[/\\]\.gitconfig$/i,
// NPM tokens
/[/\\]\.npmrc$/i,
// Docker credentials
/[/\\]\.docker[/\\]config\.json$/i,
// Kubernetes config
/[/\\]\.kube[/\\]config$/i,
// Password files
/[/\\]\.password/i,
/[/\\]\.secret/i,
// Private keys
/[/\\]id_rsa$/i,
/[/\\]id_ed25519$/i,
/[/\\]id_ecdsa$/i,
/[/\\][^/\\]*\.pem$/i,
/[/\\][^/\\]*\.key$/i,
// System files
/^\/etc\/passwd$/,
/^\/etc\/shadow$/,
// Credentials in filename
/credentials\.json$/i,
/secrets\.json$/i,
/tokens\.json$/i,
];
/**
* Result of path validation.
*/
export interface PathValidationResult {
valid: boolean;
error?: string;
normalizedPath?: string;
}
function normalizeForCompare(input: string, isWindows: boolean): string {
const normalized = path.resolve(path.normalize(input));
return isWindows ? normalized.toLowerCase() : normalized;
}
export function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
const isWindows = process.platform === 'win32';
const target = normalizeForCompare(targetPath, isWindows);
const root = normalizeForCompare(rootPath, isWindows);
const relative = path.relative(root, target);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function resolveRealPathIfExists(inputPath: string): string | null {
try {
return fs.realpathSync.native(inputPath);
} catch {
return null;
}
}
/**
* Checks if a path matches any sensitive file patterns.
*
* @param normalizedPath - The normalized absolute path to check
* @returns true if path matches a sensitive pattern
*/
export function matchesSensitivePattern(normalizedPath: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(normalizedPath));
}
/**
* Checks if a path is within allowed directories.
*
* Allowed directories:
* - The project path itself
* - The ~/.claude directory (for session data)
*
* @param normalizedPath - The normalized absolute path to check
* @param projectPath - The project root path (can be null for global access)
* @returns true if path is within allowed directories
*/
export function isPathWithinAllowedDirectories(
normalizedPath: string,
projectPath: string | null
): boolean {
const isWindows = process.platform === 'win32';
const normalizedTarget = normalizeForCompare(normalizedPath, isWindows);
const claudeDir = getClaudeBasePath();
const normalizedClaudeDir = normalizeForCompare(claudeDir, isWindows);
// Always allow access to ~/.claude for session data
if (isPathWithinRoot(normalizedTarget, normalizedClaudeDir)) {
return true;
}
// If project path provided, allow access within project
if (projectPath) {
const normalizedProjectPath = normalizeForCompare(projectPath, isWindows);
if (isPathWithinRoot(normalizedTarget, normalizedProjectPath)) {
return true;
}
}
return false;
}
/**
* Validates a file path for safe reading.
*
* Security checks performed:
* 1. Path must be absolute
* 2. Path traversal prevention (no ..)
* 3. Must be within allowed directories (project or ~/.claude)
* 4. Must not match sensitive file patterns
*
* @param filePath - The file path to validate
* @param projectPath - The project root path (can be null for global access)
* @returns Validation result with normalized path if valid
*/
export function validateFilePath(
filePath: string,
projectPath: string | null
): PathValidationResult {
// Must be a non-empty string
if (!filePath || typeof filePath !== 'string') {
return { valid: false, error: 'Invalid file path' };
}
// Expand ~ to home directory
const expandedPath = filePath.startsWith('~')
? path.join(getHomeDir(), filePath.slice(1))
: filePath;
// Must be absolute path
const normalizedInput = path.normalize(expandedPath);
if (!path.isAbsolute(normalizedInput)) {
return { valid: false, error: 'Path must be absolute' };
}
// Normalize and resolve the path to remove traversal segments safely
const normalizedPath = path.resolve(normalizedInput);
// Check against sensitive patterns
if (matchesSensitivePattern(normalizedPath)) {
return { valid: false, error: 'Access to sensitive files is not allowed' };
}
// Check if within allowed directories
if (!isPathWithinAllowedDirectories(normalizedPath, projectPath)) {
return {
valid: false,
error: 'Path is outside allowed directories (project or Claude root)',
};
}
// If target exists, validate real path containment to prevent symlink escapes.
const realTargetPath = resolveRealPathIfExists(normalizedPath);
if (realTargetPath) {
const isWindows = process.platform === 'win32';
const normalizedRealTarget = normalizeForCompare(realTargetPath, isWindows);
if (matchesSensitivePattern(normalizedRealTarget)) {
return { valid: false, error: 'Access to sensitive files is not allowed' };
}
const realProjectPath = projectPath
? (resolveRealPathIfExists(projectPath) ?? path.resolve(path.normalize(projectPath)))
: null;
if (!isPathWithinAllowedDirectories(normalizedRealTarget, realProjectPath)) {
return {
valid: false,
error: 'Path is outside allowed directories (project or Claude root)',
};
}
}
return { valid: true, normalizedPath };
}
/**
* Validates a path for opening when it was explicitly chosen by the user
* via the system folder picker. Only checks sensitive patterns, not
* allowed-directories (project / ~/.claude).
*
* @param targetPath - The path to open
* @returns Validation result
*/
export function validateOpenPathUserSelected(targetPath: string): PathValidationResult {
if (!targetPath || typeof targetPath !== 'string') {
return { valid: false, error: 'Invalid path' };
}
const expandedPath = targetPath.startsWith('~')
? path.join(getHomeDir(), targetPath.slice(1))
: targetPath;
const normalizedPath = path.resolve(path.normalize(expandedPath));
if (!path.isAbsolute(normalizedPath)) {
return { valid: false, error: 'Path must be absolute' };
}
if (matchesSensitivePattern(normalizedPath)) {
return { valid: false, error: 'Cannot open sensitive files' };
}
const realTargetPath = resolveRealPathIfExists(normalizedPath);
if (realTargetPath) {
const isWindows = process.platform === 'win32';
const normalizedRealTarget = normalizeForCompare(realTargetPath, isWindows);
if (matchesSensitivePattern(normalizedRealTarget)) {
return { valid: false, error: 'Cannot open sensitive files' };
}
}
return { valid: true, normalizedPath };
}
/**
* Validates a path for shell:openPath operation.
* More permissive than file reading - allows opening project directories
* and Claude data directories.
*
* @param targetPath - The path to open
* @param projectPath - The project root path (can be null)
* @returns Validation result
*/
export function validateOpenPath(
targetPath: string,
projectPath: string | null
): PathValidationResult {
if (!targetPath || typeof targetPath !== 'string') {
return { valid: false, error: 'Invalid path' };
}
// Expand ~ to home directory
const expandedPath = targetPath.startsWith('~')
? path.join(getHomeDir(), targetPath.slice(1))
: targetPath;
const normalizedPath = path.resolve(path.normalize(expandedPath));
// Must be absolute after expansion
if (!path.isAbsolute(normalizedPath)) {
return { valid: false, error: 'Path must be absolute' };
}
// Check against sensitive patterns (still block sensitive files)
if (matchesSensitivePattern(normalizedPath)) {
return { valid: false, error: 'Cannot open sensitive files' };
}
// For shell:openPath, we're more permissive but still require
// the path to be within project or claude directories
if (!isPathWithinAllowedDirectories(normalizedPath, projectPath)) {
return {
valid: false,
error: 'Path is outside allowed directories',
};
}
// If target exists, validate real path containment to prevent symlink escapes.
const realTargetPath = resolveRealPathIfExists(normalizedPath);
if (realTargetPath) {
const isWindows = process.platform === 'win32';
const normalizedRealTarget = normalizeForCompare(realTargetPath, isWindows);
if (matchesSensitivePattern(normalizedRealTarget)) {
return { valid: false, error: 'Cannot open sensitive files' };
}
const realProjectPath = projectPath
? (resolveRealPathIfExists(projectPath) ?? path.resolve(path.normalize(projectPath)))
: null;
if (!isPathWithinAllowedDirectories(normalizedRealTarget, realProjectPath)) {
return {
valid: false,
error: 'Path is outside allowed directories',
};
}
}
return { valid: true, normalizedPath };
}
// =============================================================================
// Editor-specific validation utilities
// =============================================================================
const MAX_FILENAME_LENGTH = 255;
/** Characters forbidden in file/directory names. */
// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters
const INVALID_FILENAME_CHARS = /[\x00-\x1f/\\:*?"<>|]/;
const WINDOWS_RESERVED_BASENAMES = new Set([
'con',
'prn',
'aux',
'nul',
'com1',
'com2',
'com3',
'com4',
'com5',
'com6',
'com7',
'com8',
'com9',
'lpt1',
'lpt2',
'lpt3',
'lpt4',
'lpt5',
'lpt6',
'lpt7',
'lpt8',
'lpt9',
]);
export function isWindowsReservedFileName(name: string): boolean {
if (typeof name !== 'string') {
return false;
}
const normalized = name
.trim()
.replace(/[. ]+$/g, '')
.toLowerCase();
if (!normalized) {
return false;
}
const stem = normalized.split('.')[0] ?? normalized;
return WINDOWS_RESERVED_BASENAMES.has(stem);
}
/**
* Validates a file or directory name for creation.
* Prevents path traversal, control chars, and OS-invalid characters.
*/
export function validateFileName(name: string): PathValidationResult {
if (!name || typeof name !== 'string') {
return { valid: false, error: 'Name is required' };
}
const trimmed = name.trim();
if (trimmed.length === 0) {
return { valid: false, error: 'Name cannot be empty' };
}
if (trimmed.length > MAX_FILENAME_LENGTH) {
return { valid: false, error: `Name exceeds ${MAX_FILENAME_LENGTH} characters` };
}
if (trimmed === '.' || trimmed === '..') {
return { valid: false, error: 'Invalid name' };
}
if (INVALID_FILENAME_CHARS.test(trimmed)) {
return { valid: false, error: 'Name contains invalid characters' };
}
if (/[. ]$/.test(name)) {
return { valid: false, error: 'Name cannot end with a space or period' };
}
if (isWindowsReservedFileName(trimmed)) {
return { valid: false, error: 'Name is reserved on Windows' };
}
return { valid: true };
}
/** Blocked device/pseudo-filesystem path prefixes. */
const DEVICE_PATH_PREFIXES = ['/dev/', '/proc/', '/sys/'];
const WINDOWS_DEVICE_PREFIX = '\\\\.\\';
/**
* Returns true if the path points to a device or pseudo-filesystem
* (/dev/, /proc/, /sys/, \\\\.\\).
*/
export function isDevicePath(filePath: string): boolean {
const lower = filePath.toLowerCase();
if (DEVICE_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
return true;
}
return filePath.startsWith(WINDOWS_DEVICE_PREFIX);
}
/**
* Returns true if the path contains a `.git/` segment.
* Used to block writes to git internals.
*/
export function isGitInternalPath(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, '/');
return normalized.includes('/.git/') || normalized.endsWith('/.git');
}