- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
/**
|
|
* Path Validator
|
|
*
|
|
* Security-focused path validation for file system operations
|
|
*/
|
|
|
|
import * as path from 'node:path';
|
|
import * as fs from 'node:fs/promises';
|
|
import { FileSystemConfig, PathValidation } from './types.js';
|
|
import type { IDextoLogger } from '@dexto/core';
|
|
|
|
/**
|
|
* Callback type for checking if a path is in an approved directory.
|
|
* Used to consult ApprovalManager without creating a direct dependency.
|
|
*/
|
|
export type DirectoryApprovalChecker = (filePath: string) => boolean;
|
|
|
|
/**
|
|
* PathValidator - Validates file paths for security and policy compliance
|
|
*
|
|
* Security checks:
|
|
* 1. Path traversal detection (../, symbolic links)
|
|
* 2. Allowed paths enforcement (whitelist + approved directories)
|
|
* 3. Blocked paths detection (blacklist)
|
|
* 4. File extension restrictions
|
|
* 5. Absolute path normalization
|
|
*
|
|
* PathValidator can optionally consult an external approval checker (e.g., ApprovalManager)
|
|
* to determine if paths outside the config's allowed paths are accessible.
|
|
*/
|
|
export class PathValidator {
|
|
private config: FileSystemConfig;
|
|
private normalizedAllowedPaths: string[];
|
|
private normalizedBlockedPaths: string[];
|
|
private normalizedBlockedExtensions: string[];
|
|
private logger: IDextoLogger;
|
|
private directoryApprovalChecker: DirectoryApprovalChecker | undefined;
|
|
|
|
constructor(config: FileSystemConfig, logger: IDextoLogger) {
|
|
this.config = config;
|
|
this.logger = logger;
|
|
|
|
// Normalize allowed paths to absolute paths
|
|
const workingDir = config.workingDirectory || process.cwd();
|
|
this.normalizedAllowedPaths = config.allowedPaths.map((p) => path.resolve(workingDir, p));
|
|
|
|
// Normalize blocked paths
|
|
this.normalizedBlockedPaths = config.blockedPaths.map((p) => path.normalize(p));
|
|
|
|
// Normalize blocked extensions: ensure leading dot and lowercase
|
|
this.normalizedBlockedExtensions = (config.blockedExtensions || []).map((ext) => {
|
|
const e = ext.startsWith('.') ? ext : `.${ext}`;
|
|
return e.toLowerCase();
|
|
});
|
|
|
|
this.logger.debug(
|
|
`PathValidator initialized with ${this.normalizedAllowedPaths.length} allowed paths`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Set a callback to check if a path is in an approved directory.
|
|
* This allows PathValidator to consult ApprovalManager without a direct dependency.
|
|
*
|
|
* @param checker Function that returns true if path is in an approved directory
|
|
*/
|
|
setDirectoryApprovalChecker(checker: DirectoryApprovalChecker): void {
|
|
this.directoryApprovalChecker = checker;
|
|
this.logger.debug('Directory approval checker configured');
|
|
}
|
|
|
|
/**
|
|
* Validate a file path for security and policy compliance
|
|
*/
|
|
async validatePath(filePath: string): Promise<PathValidation> {
|
|
// 1. Check for empty path
|
|
if (!filePath || filePath.trim() === '') {
|
|
return {
|
|
isValid: false,
|
|
error: 'Path cannot be empty',
|
|
};
|
|
}
|
|
|
|
// 2. Normalize the path to absolute
|
|
const workingDir = this.config.workingDirectory || process.cwd();
|
|
let normalizedPath: string;
|
|
|
|
try {
|
|
// Handle both absolute and relative paths
|
|
normalizedPath = path.isAbsolute(filePath)
|
|
? path.resolve(filePath)
|
|
: path.resolve(workingDir, filePath);
|
|
|
|
// Canonicalize to handle symlinks and resolve real paths (async, non-blocking)
|
|
try {
|
|
normalizedPath = await fs.realpath(normalizedPath);
|
|
} catch {
|
|
// If the path doesn't exist yet (e.g., writes), fallback to the resolved path
|
|
// Policy checks continue to use normalizedPath
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
isValid: false,
|
|
error: `Failed to normalize path: ${error instanceof Error ? error.message : String(error)}`,
|
|
};
|
|
}
|
|
|
|
// 3. Check for path traversal attempts
|
|
if (this.hasPathTraversal(filePath, normalizedPath)) {
|
|
return {
|
|
isValid: false,
|
|
error: 'Path traversal detected',
|
|
};
|
|
}
|
|
|
|
// 4. Check if path is within allowed paths
|
|
if (!this.isPathAllowed(normalizedPath)) {
|
|
return {
|
|
isValid: false,
|
|
error: `Path is not within allowed paths. Allowed: ${this.normalizedAllowedPaths.join(', ')}`,
|
|
};
|
|
}
|
|
|
|
// 5. Check if path is blocked
|
|
const blockedReason = this.isPathBlocked(normalizedPath);
|
|
if (blockedReason) {
|
|
return {
|
|
isValid: false,
|
|
error: `Path is blocked: ${blockedReason}`,
|
|
};
|
|
}
|
|
|
|
// 6. Check file extension if applicable
|
|
const ext = path.extname(normalizedPath).toLowerCase();
|
|
if (ext && this.normalizedBlockedExtensions.includes(ext)) {
|
|
return {
|
|
isValid: false,
|
|
error: `File extension ${ext} is not allowed`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
isValid: true,
|
|
normalizedPath,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if path contains traversal attempts
|
|
*/
|
|
private hasPathTraversal(originalPath: string, normalizedPath: string): boolean {
|
|
// Check for ../ patterns in original path
|
|
if (originalPath.includes('../') || originalPath.includes('..\\')) {
|
|
// Verify the normalized path still escapes allowed boundaries
|
|
const workingDir = this.config.workingDirectory || process.cwd();
|
|
const relative = path.relative(workingDir, normalizedPath);
|
|
if (relative.startsWith('..')) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if path is within allowed paths (whitelist check)
|
|
* Also consults the directory approval checker if configured.
|
|
* Uses the sync version since the path is already normalized at this point.
|
|
*/
|
|
private isPathAllowed(normalizedPath: string): boolean {
|
|
return this.isPathAllowedSync(normalizedPath);
|
|
}
|
|
|
|
/**
|
|
* Check if path matches blocked patterns (blacklist check)
|
|
*/
|
|
private isPathBlocked(normalizedPath: string): string | null {
|
|
const roots =
|
|
this.normalizedAllowedPaths.length > 0
|
|
? this.normalizedAllowedPaths
|
|
: [this.config.workingDirectory || process.cwd()];
|
|
|
|
for (const blocked of this.normalizedBlockedPaths) {
|
|
for (const root of roots) {
|
|
// Resolve blocked relative to each allowed root unless already absolute
|
|
const blockedFull = path.isAbsolute(blocked)
|
|
? path.normalize(blocked)
|
|
: path.resolve(root, blocked);
|
|
// Segment-aware prefix check
|
|
if (
|
|
normalizedPath === blockedFull ||
|
|
normalizedPath.startsWith(blockedFull + path.sep)
|
|
) {
|
|
return `Within blocked directory: ${blocked}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Quick check if a path is allowed (for internal use)
|
|
* Note: This assumes the path is already normalized/canonicalized
|
|
*/
|
|
isPathAllowedQuick(normalizedPath: string): boolean {
|
|
return this.isPathAllowedSync(normalizedPath) && !this.isPathBlocked(normalizedPath);
|
|
}
|
|
|
|
/**
|
|
* Synchronous path allowed check (for already-normalized paths)
|
|
* This is used internally when we already have a canonicalized path
|
|
*/
|
|
private isPathAllowedSync(normalizedPath: string): boolean {
|
|
// Empty allowedPaths means all paths are allowed
|
|
if (this.normalizedAllowedPaths.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
// Check if path is within any config-allowed path
|
|
const isInConfigPaths = this.normalizedAllowedPaths.some((allowedPath) => {
|
|
const relative = path.relative(allowedPath, normalizedPath);
|
|
// Path is allowed if it doesn't escape the allowed directory
|
|
return !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
});
|
|
|
|
if (isInConfigPaths) {
|
|
return true;
|
|
}
|
|
|
|
// Fallback: check ApprovalManager via callback (includes working dir + approved dirs)
|
|
if (this.directoryApprovalChecker) {
|
|
return this.directoryApprovalChecker(normalizedPath);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if a file path is within the configured allowed paths (from config only).
|
|
* This method does NOT consult ApprovalManager - it only checks the static config paths.
|
|
*
|
|
* This is used by file tools to determine if a path needs directory approval.
|
|
* Paths within config-allowed directories don't need directory approval prompts.
|
|
*
|
|
* @param filePath The file path to check (can be relative or absolute)
|
|
* @returns true if the path is within config-allowed paths, false otherwise
|
|
*/
|
|
async isPathWithinAllowed(filePath: string): Promise<boolean> {
|
|
if (!filePath || filePath.trim() === '') {
|
|
return false;
|
|
}
|
|
|
|
// Normalize the path to absolute
|
|
const workingDir = this.config.workingDirectory || process.cwd();
|
|
let normalizedPath: string;
|
|
|
|
try {
|
|
normalizedPath = path.isAbsolute(filePath)
|
|
? path.resolve(filePath)
|
|
: path.resolve(workingDir, filePath);
|
|
|
|
// Try to resolve symlinks for existing files (async, non-blocking)
|
|
try {
|
|
normalizedPath = await fs.realpath(normalizedPath);
|
|
} catch {
|
|
// Path doesn't exist yet, use resolved path
|
|
}
|
|
} catch {
|
|
// Failed to normalize, treat as not within allowed
|
|
return false;
|
|
}
|
|
|
|
// Only check config paths - do NOT consult approval checker here
|
|
// This method is used for prompting decisions, not execution decisions
|
|
return this.isInConfigAllowedPaths(normalizedPath);
|
|
}
|
|
|
|
/**
|
|
* Check if path is within config-allowed paths only (no approval checker).
|
|
* Used for prompting decisions.
|
|
*/
|
|
private isInConfigAllowedPaths(normalizedPath: string): boolean {
|
|
// Empty allowedPaths means all paths are allowed
|
|
if (this.normalizedAllowedPaths.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
return this.normalizedAllowedPaths.some((allowedPath) => {
|
|
const relative = path.relative(allowedPath, normalizedPath);
|
|
return !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get normalized allowed paths
|
|
*/
|
|
getAllowedPaths(): string[] {
|
|
return [...this.normalizedAllowedPaths];
|
|
}
|
|
|
|
/**
|
|
* Get blocked paths
|
|
*/
|
|
getBlockedPaths(): string[] {
|
|
return [...this.normalizedBlockedPaths];
|
|
}
|
|
}
|