agent-ecosystem/src/main/utils/pathValidation.ts
iliya a6eabc840c feat: enhance team message handling and UI components
- Updated `dev:kill` script to use a dedicated Node.js script for improved process termination.
- Enhanced `TeamProvisioningService` to trigger team refresh events for live lead replies, improving message handling.
- Refactored message deduplication logic in `handleGetData` to prevent duplicate messages from lead sessions and lead processes.
- Introduced `validateOpenPathUserSelected` function to allow user-selected paths while enforcing security checks.
- Improved UI components in `TeamListView` and `ActivityItem` for better user experience and accessibility.
- Added progress bar for task completion in `DashboardView`, enhancing task tracking visibility.
2026-02-23 17:34:30 +02:00

305 lines
9.1 KiB
TypeScript

/**
* Path Validation Utilities.
*
* Provides security sandboxing for file path access to prevent
* unauthorized access to sensitive system files.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getClaudeBasePath } 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.normalize(input);
return isWindows ? normalized.toLowerCase() : normalized;
}
function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
return targetPath === rootPath || targetPath.startsWith(rootPath + path.sep);
}
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
*/
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(os.homedir(), 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(os.homedir(), 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(os.homedir(), 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 };
}