From 2c6eb6ce9bbd5b31994a225b3188071567ddf47e Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Mon, 1 Dec 2025 14:02:23 +0500 Subject: [PATCH] feat(ipuaro): add PathValidator security utility (v0.13.0) Add centralized path validation to prevent path traversal attacks. - PathValidator class with sync/async validation methods - Protects against '..' and '~' traversal patterns - Validates paths are within project root - Refactored all 7 file tools to use PathValidator - 51 new tests for PathValidator --- packages/ipuaro/CHANGELOG.md | 45 +++ packages/ipuaro/package.json | 2 +- packages/ipuaro/src/infrastructure/index.ts | 1 + .../infrastructure/security/PathValidator.ts | 293 ++++++++++++++++ .../src/infrastructure/security/index.ts | 9 + .../tools/edit/CreateFileTool.ts | 18 +- .../tools/edit/DeleteFileTool.ts | 19 +- .../tools/edit/EditLinesTool.ts | 19 +- .../infrastructure/tools/read/GetClassTool.ts | 19 +- .../tools/read/GetFunctionTool.ts | 19 +- .../infrastructure/tools/read/GetLinesTool.ts | 19 +- .../tools/read/GetStructureTool.ts | 18 +- .../security/PathValidator.test.ts | 320 ++++++++++++++++++ .../tools/edit/CreateFileTool.test.ts | 2 +- .../tools/edit/DeleteFileTool.test.ts | 2 +- .../tools/edit/EditLinesTool.test.ts | 2 +- .../tools/read/GetClassTool.test.ts | 2 +- .../tools/read/GetFunctionTool.test.ts | 2 +- .../tools/read/GetLinesTool.test.ts | 2 +- .../tools/read/GetStructureTool.test.ts | 2 +- 20 files changed, 746 insertions(+), 69 deletions(-) create mode 100644 packages/ipuaro/src/infrastructure/security/PathValidator.ts create mode 100644 packages/ipuaro/src/infrastructure/security/index.ts create mode 100644 packages/ipuaro/tests/unit/infrastructure/security/PathValidator.test.ts diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index 9421638..aacdd2d 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -5,6 +5,51 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.13.0] - 2025-12-01 - Security + +### Added + +- **PathValidator Utility (0.13.3)** + - Centralized path validation for all file operations + - Prevents path traversal attacks (`..`, `~`) + - Validates paths are within project root + - Sync (`validateSync`) and async (`validate`) validation methods + - Quick check method (`isWithin`) for simple validations + - Resolution methods (`resolve`, `relativize`, `resolveOrThrow`) + - Detailed validation results with status and reason + - Options for file existence, directory/file type checks + +- **Security Module** + - New `infrastructure/security` module + - Exports: `PathValidator`, `createPathValidator`, `validatePath` + - Type exports: `PathValidationResult`, `PathValidationStatus`, `PathValidatorOptions` + +### Changed + +- **Refactored All File Tools to Use PathValidator** + - GetLinesTool: Uses PathValidator for path validation + - GetFunctionTool: Uses PathValidator for path validation + - GetClassTool: Uses PathValidator for path validation + - GetStructureTool: Uses PathValidator for path validation + - EditLinesTool: Uses PathValidator for path validation + - CreateFileTool: Uses PathValidator for path validation + - DeleteFileTool: Uses PathValidator for path validation + +- **Improved Error Messages** + - More specific error messages from PathValidator + - "Path contains traversal patterns" for `..` attempts + - "Path is outside project root" for absolute paths outside project + - "Path is empty" for empty/whitespace paths + +### Technical Details + +- Total tests: 1305 (51 new PathValidator tests) +- Test coverage: ~98% maintained +- No breaking changes to existing tool APIs +- Security validation is now consistent across all 7 file tools + +--- + ## [0.12.0] - 2025-12-01 - TUI Advanced ### Added diff --git a/packages/ipuaro/package.json b/packages/ipuaro/package.json index 50f1d25..951582f 100644 --- a/packages/ipuaro/package.json +++ b/packages/ipuaro/package.json @@ -1,6 +1,6 @@ { "name": "@samiyev/ipuaro", - "version": "0.12.0", + "version": "0.13.0", "description": "Local AI agent for codebase operations with infinite context feeling", "author": "Fozilbek Samiyev ", "license": "MIT", diff --git a/packages/ipuaro/src/infrastructure/index.ts b/packages/ipuaro/src/infrastructure/index.ts index 4fb4f91..db71978 100644 --- a/packages/ipuaro/src/infrastructure/index.ts +++ b/packages/ipuaro/src/infrastructure/index.ts @@ -3,3 +3,4 @@ export * from "./storage/index.js" export * from "./indexer/index.js" export * from "./llm/index.js" export * from "./tools/index.js" +export * from "./security/index.js" diff --git a/packages/ipuaro/src/infrastructure/security/PathValidator.ts b/packages/ipuaro/src/infrastructure/security/PathValidator.ts new file mode 100644 index 0000000..0eebc0b --- /dev/null +++ b/packages/ipuaro/src/infrastructure/security/PathValidator.ts @@ -0,0 +1,293 @@ +import * as path from "node:path" +import { promises as fs } from "node:fs" + +/** + * Path validation result classification. + */ +export type PathValidationStatus = "valid" | "invalid" | "outside_project" + +/** + * Result of path validation. + */ +export interface PathValidationResult { + /** Validation status */ + status: PathValidationStatus + /** Reason for the status */ + reason: string + /** Normalized absolute path (only if valid) */ + absolutePath?: string + /** Normalized relative path (only if valid) */ + relativePath?: string +} + +/** + * Options for path validation. + */ +export interface PathValidatorOptions { + /** Allow paths that don't exist yet (for create operations) */ + allowNonExistent?: boolean + /** Check if path is a directory */ + requireDirectory?: boolean + /** Check if path is a file */ + requireFile?: boolean + /** Follow symlinks when checking existence */ + followSymlinks?: boolean +} + +/** + * Path validator for ensuring file operations stay within project boundaries. + * Prevents path traversal attacks and unauthorized file access. + */ +export class PathValidator { + private readonly projectRoot: string + + constructor(projectRoot: string) { + this.projectRoot = path.resolve(projectRoot) + } + + /** + * Validate a path and return detailed result. + * @param inputPath - Path to validate (relative or absolute) + * @param options - Validation options + */ + async validate( + inputPath: string, + options: PathValidatorOptions = {}, + ): Promise { + if (!inputPath || inputPath.trim() === "") { + return { + status: "invalid", + reason: "Path is empty", + } + } + + const normalizedInput = inputPath.trim() + + if (this.containsTraversalPatterns(normalizedInput)) { + return { + status: "invalid", + reason: "Path contains traversal patterns", + } + } + + const absolutePath = path.resolve(this.projectRoot, normalizedInput) + + if (!this.isWithinProject(absolutePath)) { + return { + status: "outside_project", + reason: "Path is outside project root", + } + } + + const relativePath = path.relative(this.projectRoot, absolutePath) + + if (!options.allowNonExistent) { + const existsResult = await this.checkExists(absolutePath, options) + if (existsResult) { + return existsResult + } + } + + return { + status: "valid", + reason: "Path is valid", + absolutePath, + relativePath, + } + } + + /** + * Synchronous validation for simple checks. + * Does not check file existence or type. + * @param inputPath - Path to validate (relative or absolute) + */ + validateSync(inputPath: string): PathValidationResult { + if (!inputPath || inputPath.trim() === "") { + return { + status: "invalid", + reason: "Path is empty", + } + } + + const normalizedInput = inputPath.trim() + + if (this.containsTraversalPatterns(normalizedInput)) { + return { + status: "invalid", + reason: "Path contains traversal patterns", + } + } + + const absolutePath = path.resolve(this.projectRoot, normalizedInput) + + if (!this.isWithinProject(absolutePath)) { + return { + status: "outside_project", + reason: "Path is outside project root", + } + } + + const relativePath = path.relative(this.projectRoot, absolutePath) + + return { + status: "valid", + reason: "Path is valid", + absolutePath, + relativePath, + } + } + + /** + * Quick check if path is within project. + * @param inputPath - Path to check (relative or absolute) + */ + isWithin(inputPath: string): boolean { + if (!inputPath || inputPath.trim() === "") { + return false + } + + const normalizedInput = inputPath.trim() + + if (this.containsTraversalPatterns(normalizedInput)) { + return false + } + + const absolutePath = path.resolve(this.projectRoot, normalizedInput) + return this.isWithinProject(absolutePath) + } + + /** + * Resolve a path relative to project root. + * Returns null if path would be outside project. + * @param inputPath - Path to resolve + */ + resolve(inputPath: string): string | null { + const result = this.validateSync(inputPath) + return result.status === "valid" ? (result.absolutePath ?? null) : null + } + + /** + * Resolve a path or throw an error if invalid. + * @param inputPath - Path to resolve + * @returns Tuple of [absolutePath, relativePath] + * @throws Error if path is invalid + */ + resolveOrThrow(inputPath: string): [absolutePath: string, relativePath: string] { + const result = this.validateSync(inputPath) + if (result.status !== "valid" || result.absolutePath === undefined) { + throw new Error(result.reason) + } + return [result.absolutePath, result.relativePath ?? ""] + } + + /** + * Get relative path from project root. + * Returns null if path would be outside project. + * @param inputPath - Path to make relative + */ + relativize(inputPath: string): string | null { + const result = this.validateSync(inputPath) + return result.status === "valid" ? (result.relativePath ?? null) : null + } + + /** + * Get the project root path. + */ + getProjectRoot(): string { + return this.projectRoot + } + + /** + * Check if path contains directory traversal patterns. + */ + private containsTraversalPatterns(inputPath: string): boolean { + const normalized = inputPath.replace(/\\/g, "/") + + if (normalized.includes("..")) { + return true + } + + if (normalized.startsWith("~")) { + return true + } + + return false + } + + /** + * Check if absolute path is within project root. + */ + private isWithinProject(absolutePath: string): boolean { + const normalizedProject = this.projectRoot.replace(/\\/g, "/") + const normalizedPath = absolutePath.replace(/\\/g, "/") + + if (normalizedPath === normalizedProject) { + return true + } + + const projectWithSep = normalizedProject.endsWith("/") + ? normalizedProject + : `${normalizedProject}/` + + return normalizedPath.startsWith(projectWithSep) + } + + /** + * Check file existence and type. + */ + private async checkExists( + absolutePath: string, + options: PathValidatorOptions, + ): Promise { + try { + const statFn = options.followSymlinks ? fs.stat : fs.lstat + const stats = await statFn(absolutePath) + + if (options.requireDirectory && !stats.isDirectory()) { + return { + status: "invalid", + reason: "Path is not a directory", + } + } + + if (options.requireFile && !stats.isFile()) { + return { + status: "invalid", + reason: "Path is not a file", + } + } + + return null + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { + status: "invalid", + reason: "Path does not exist", + } + } + + return { + status: "invalid", + reason: `Cannot access path: ${(error as Error).message}`, + } + } + } +} + +/** + * Create a path validator for a project. + * @param projectRoot - Root directory of the project + */ +export function createPathValidator(projectRoot: string): PathValidator { + return new PathValidator(projectRoot) +} + +/** + * Standalone function for quick path validation. + * @param inputPath - Path to validate + * @param projectRoot - Project root directory + */ +export function validatePath(inputPath: string, projectRoot: string): boolean { + const validator = new PathValidator(projectRoot) + return validator.isWithin(inputPath) +} diff --git a/packages/ipuaro/src/infrastructure/security/index.ts b/packages/ipuaro/src/infrastructure/security/index.ts new file mode 100644 index 0000000..50cedd6 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/security/index.ts @@ -0,0 +1,9 @@ +// Security module exports +export { + PathValidator, + createPathValidator, + validatePath, + type PathValidationResult, + type PathValidationStatus, + type PathValidatorOptions, +} from "./PathValidator.js" diff --git a/packages/ipuaro/src/infrastructure/tools/edit/CreateFileTool.ts b/packages/ipuaro/src/infrastructure/tools/edit/CreateFileTool.ts index 7815bee..b9256fd 100644 --- a/packages/ipuaro/src/infrastructure/tools/edit/CreateFileTool.ts +++ b/packages/ipuaro/src/infrastructure/tools/edit/CreateFileTool.ts @@ -8,6 +8,7 @@ import { type ToolResult, } from "../../../domain/value-objects/ToolResult.js" import { hashLines } from "../../../shared/utils/hash.js" +import { PathValidator } from "../../security/PathValidator.js" /** * Result data from create_file tool. @@ -62,17 +63,18 @@ export class CreateFileTool implements ITool { const startTime = Date.now() const callId = `${this.name}-${String(startTime)}` - const relativePath = params.path as string + const inputPath = params.path as string const content = params.content as string - const absolutePath = path.resolve(ctx.projectRoot, relativePath) + const pathValidator = new PathValidator(ctx.projectRoot) - if (!absolutePath.startsWith(ctx.projectRoot)) { - return createErrorResult( - callId, - "Path must be within project root", - Date.now() - startTime, - ) + let absolutePath: string + let relativePath: string + try { + ;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) } try { diff --git a/packages/ipuaro/src/infrastructure/tools/edit/DeleteFileTool.ts b/packages/ipuaro/src/infrastructure/tools/edit/DeleteFileTool.ts index 70ad445..7775fd0 100644 --- a/packages/ipuaro/src/infrastructure/tools/edit/DeleteFileTool.ts +++ b/packages/ipuaro/src/infrastructure/tools/edit/DeleteFileTool.ts @@ -1,11 +1,11 @@ import { promises as fs } from "node:fs" -import * as path from "node:path" import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" import { createErrorResult, createSuccessResult, type ToolResult, } from "../../../domain/value-objects/ToolResult.js" +import { PathValidator } from "../../security/PathValidator.js" /** * Result data from delete_file tool. @@ -49,15 +49,16 @@ export class DeleteFileTool implements ITool { const startTime = Date.now() const callId = `${this.name}-${String(startTime)}` - const relativePath = params.path as string - const absolutePath = path.resolve(ctx.projectRoot, relativePath) + const inputPath = params.path as string + const pathValidator = new PathValidator(ctx.projectRoot) - if (!absolutePath.startsWith(ctx.projectRoot)) { - return createErrorResult( - callId, - "Path must be within project root", - Date.now() - startTime, - ) + let absolutePath: string + let relativePath: string + try { + ;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) } try { diff --git a/packages/ipuaro/src/infrastructure/tools/edit/EditLinesTool.ts b/packages/ipuaro/src/infrastructure/tools/edit/EditLinesTool.ts index fe193b2..4d2d950 100644 --- a/packages/ipuaro/src/infrastructure/tools/edit/EditLinesTool.ts +++ b/packages/ipuaro/src/infrastructure/tools/edit/EditLinesTool.ts @@ -1,5 +1,4 @@ import { promises as fs } from "node:fs" -import * as path from "node:path" import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" import { createFileData } from "../../../domain/value-objects/FileData.js" import { @@ -8,6 +7,7 @@ import { type ToolResult, } from "../../../domain/value-objects/ToolResult.js" import { hashLines } from "../../../shared/utils/hash.js" +import { PathValidator } from "../../security/PathValidator.js" /** * Result data from edit_lines tool. @@ -94,19 +94,20 @@ export class EditLinesTool implements ITool { const startTime = Date.now() const callId = `${this.name}-${String(startTime)}` - const relativePath = params.path as string + const inputPath = params.path as string const startLine = params.start as number const endLine = params.end as number const newContent = params.content as string - const absolutePath = path.resolve(ctx.projectRoot, relativePath) + const pathValidator = new PathValidator(ctx.projectRoot) - if (!absolutePath.startsWith(ctx.projectRoot)) { - return createErrorResult( - callId, - "Path must be within project root", - Date.now() - startTime, - ) + let absolutePath: string + let relativePath: string + try { + ;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) } try { diff --git a/packages/ipuaro/src/infrastructure/tools/read/GetClassTool.ts b/packages/ipuaro/src/infrastructure/tools/read/GetClassTool.ts index 72e54fd..bca0a45 100644 --- a/packages/ipuaro/src/infrastructure/tools/read/GetClassTool.ts +++ b/packages/ipuaro/src/infrastructure/tools/read/GetClassTool.ts @@ -1,5 +1,4 @@ import { promises as fs } from "node:fs" -import * as path from "node:path" import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" import type { ClassInfo } from "../../../domain/value-objects/FileAST.js" import { @@ -7,6 +6,7 @@ import { createSuccessResult, type ToolResult, } from "../../../domain/value-objects/ToolResult.js" +import { PathValidator } from "../../security/PathValidator.js" /** * Result data from get_class tool. @@ -67,16 +67,17 @@ export class GetClassTool implements ITool { const startTime = Date.now() const callId = `${this.name}-${String(startTime)}` - const relativePath = params.path as string + const inputPath = params.path as string const className = params.name as string - const absolutePath = path.resolve(ctx.projectRoot, relativePath) + const pathValidator = new PathValidator(ctx.projectRoot) - if (!absolutePath.startsWith(ctx.projectRoot)) { - return createErrorResult( - callId, - "Path must be within project root", - Date.now() - startTime, - ) + let absolutePath: string + let relativePath: string + try { + ;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) } try { diff --git a/packages/ipuaro/src/infrastructure/tools/read/GetFunctionTool.ts b/packages/ipuaro/src/infrastructure/tools/read/GetFunctionTool.ts index 3177b14..61ef5eb 100644 --- a/packages/ipuaro/src/infrastructure/tools/read/GetFunctionTool.ts +++ b/packages/ipuaro/src/infrastructure/tools/read/GetFunctionTool.ts @@ -1,5 +1,4 @@ import { promises as fs } from "node:fs" -import * as path from "node:path" import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" import type { FunctionInfo } from "../../../domain/value-objects/FileAST.js" import { @@ -7,6 +6,7 @@ import { createSuccessResult, type ToolResult, } from "../../../domain/value-objects/ToolResult.js" +import { PathValidator } from "../../security/PathValidator.js" /** * Result data from get_function tool. @@ -65,16 +65,17 @@ export class GetFunctionTool implements ITool { const startTime = Date.now() const callId = `${this.name}-${String(startTime)}` - const relativePath = params.path as string + const inputPath = params.path as string const functionName = params.name as string - const absolutePath = path.resolve(ctx.projectRoot, relativePath) + const pathValidator = new PathValidator(ctx.projectRoot) - if (!absolutePath.startsWith(ctx.projectRoot)) { - return createErrorResult( - callId, - "Path must be within project root", - Date.now() - startTime, - ) + let absolutePath: string + let relativePath: string + try { + ;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) } try { diff --git a/packages/ipuaro/src/infrastructure/tools/read/GetLinesTool.ts b/packages/ipuaro/src/infrastructure/tools/read/GetLinesTool.ts index 3bfea0d..8ce3157 100644 --- a/packages/ipuaro/src/infrastructure/tools/read/GetLinesTool.ts +++ b/packages/ipuaro/src/infrastructure/tools/read/GetLinesTool.ts @@ -1,11 +1,11 @@ import { promises as fs } from "node:fs" -import * as path from "node:path" import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js" import { createErrorResult, createSuccessResult, type ToolResult, } from "../../../domain/value-objects/ToolResult.js" +import { PathValidator } from "../../security/PathValidator.js" /** * Result data from get_lines tool. @@ -84,15 +84,16 @@ export class GetLinesTool implements ITool { const startTime = Date.now() const callId = `${this.name}-${String(startTime)}` - const relativePath = params.path as string - const absolutePath = path.resolve(ctx.projectRoot, relativePath) + const inputPath = params.path as string + const pathValidator = new PathValidator(ctx.projectRoot) - if (!absolutePath.startsWith(ctx.projectRoot)) { - return createErrorResult( - callId, - "Path must be within project root", - Date.now() - startTime, - ) + let absolutePath: string + let relativePath: string + try { + ;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) } try { diff --git a/packages/ipuaro/src/infrastructure/tools/read/GetStructureTool.ts b/packages/ipuaro/src/infrastructure/tools/read/GetStructureTool.ts index 45f08da..e50fd12 100644 --- a/packages/ipuaro/src/infrastructure/tools/read/GetStructureTool.ts +++ b/packages/ipuaro/src/infrastructure/tools/read/GetStructureTool.ts @@ -7,6 +7,7 @@ import { type ToolResult, } from "../../../domain/value-objects/ToolResult.js" import { DEFAULT_IGNORE_PATTERNS } from "../../../domain/constants/index.js" +import { PathValidator } from "../../security/PathValidator.js" /** * Tree node representing a file or directory. @@ -89,16 +90,17 @@ export class GetStructureTool implements ITool { const startTime = Date.now() const callId = `${this.name}-${String(startTime)}` - const relativePath = (params.path as string | undefined) ?? "" + const inputPath = (params.path as string | undefined) ?? "." const maxDepth = params.depth as number | undefined - const absolutePath = path.resolve(ctx.projectRoot, relativePath) + const pathValidator = new PathValidator(ctx.projectRoot) - if (!absolutePath.startsWith(ctx.projectRoot)) { - return createErrorResult( - callId, - "Path must be within project root", - Date.now() - startTime, - ) + let absolutePath: string + let relativePath: string + try { + ;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return createErrorResult(callId, message, Date.now() - startTime) } try { diff --git a/packages/ipuaro/tests/unit/infrastructure/security/PathValidator.test.ts b/packages/ipuaro/tests/unit/infrastructure/security/PathValidator.test.ts new file mode 100644 index 0000000..bf75ed0 --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/security/PathValidator.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import * as path from "node:path" +import * as fs from "node:fs/promises" +import * as os from "node:os" +import { + PathValidator, + createPathValidator, + validatePath, +} from "../../../../src/infrastructure/security/PathValidator.js" + +describe("PathValidator", () => { + let validator: PathValidator + let tempDir: string + let projectRoot: string + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pathvalidator-test-")) + projectRoot = path.join(tempDir, "project") + await fs.mkdir(projectRoot) + validator = new PathValidator(projectRoot) + }) + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + describe("constructor", () => { + it("should resolve project root to absolute path", () => { + const relativeValidator = new PathValidator("./project") + expect(relativeValidator.getProjectRoot()).toBe(path.resolve("./project")) + }) + + it("should store project root", () => { + expect(validator.getProjectRoot()).toBe(projectRoot) + }) + }) + + describe("validateSync", () => { + it("should validate relative path within project", () => { + const result = validator.validateSync("src/file.ts") + expect(result.status).toBe("valid") + expect(result.absolutePath).toBe(path.join(projectRoot, "src/file.ts")) + expect(result.relativePath).toBe(path.join("src", "file.ts")) + }) + + it("should validate nested relative paths", () => { + const result = validator.validateSync("src/components/Button.tsx") + expect(result.status).toBe("valid") + }) + + it("should validate root level files", () => { + const result = validator.validateSync("package.json") + expect(result.status).toBe("valid") + expect(result.relativePath).toBe("package.json") + }) + + it("should reject empty path", () => { + const result = validator.validateSync("") + expect(result.status).toBe("invalid") + expect(result.reason).toBe("Path is empty") + }) + + it("should reject whitespace-only path", () => { + const result = validator.validateSync(" ") + expect(result.status).toBe("invalid") + expect(result.reason).toBe("Path is empty") + }) + + it("should reject path with .. traversal", () => { + const result = validator.validateSync("../outside") + expect(result.status).toBe("invalid") + expect(result.reason).toBe("Path contains traversal patterns") + }) + + it("should reject path with embedded .. traversal", () => { + const result = validator.validateSync("src/../../../etc/passwd") + expect(result.status).toBe("invalid") + expect(result.reason).toBe("Path contains traversal patterns") + }) + + it("should reject path starting with tilde", () => { + const result = validator.validateSync("~/secret/file") + expect(result.status).toBe("invalid") + expect(result.reason).toBe("Path contains traversal patterns") + }) + + it("should reject absolute path outside project", () => { + const result = validator.validateSync("/etc/passwd") + expect(result.status).toBe("outside_project") + expect(result.reason).toBe("Path is outside project root") + }) + + it("should accept absolute path inside project", () => { + const absoluteInside = path.join(projectRoot, "src/file.ts") + const result = validator.validateSync(absoluteInside) + expect(result.status).toBe("valid") + }) + + it("should trim whitespace from path", () => { + const result = validator.validateSync(" src/file.ts ") + expect(result.status).toBe("valid") + }) + + it("should handle Windows-style backslashes", () => { + const result = validator.validateSync("src\\components\\file.ts") + expect(result.status).toBe("valid") + }) + + it("should reject path that resolves outside via symlink-like patterns", () => { + const result = validator.validateSync("src/./../../etc") + expect(result.status).toBe("invalid") + expect(result.reason).toBe("Path contains traversal patterns") + }) + }) + + describe("validate (async)", () => { + beforeEach(async () => { + await fs.mkdir(path.join(projectRoot, "src"), { recursive: true }) + await fs.writeFile(path.join(projectRoot, "src/file.ts"), "// content") + await fs.mkdir(path.join(projectRoot, "dist"), { recursive: true }) + }) + + it("should validate existing file", async () => { + const result = await validator.validate("src/file.ts") + expect(result.status).toBe("valid") + }) + + it("should reject non-existent file by default", async () => { + const result = await validator.validate("src/nonexistent.ts") + expect(result.status).toBe("invalid") + expect(result.reason).toBe("Path does not exist") + }) + + it("should allow non-existent file with allowNonExistent option", async () => { + const result = await validator.validate("src/newfile.ts", { allowNonExistent: true }) + expect(result.status).toBe("valid") + }) + + it("should validate directory when requireDirectory is true", async () => { + const result = await validator.validate("src", { requireDirectory: true }) + expect(result.status).toBe("valid") + }) + + it("should reject file when requireDirectory is true", async () => { + const result = await validator.validate("src/file.ts", { requireDirectory: true }) + expect(result.status).toBe("invalid") + expect(result.reason).toBe("Path is not a directory") + }) + + it("should validate file when requireFile is true", async () => { + const result = await validator.validate("src/file.ts", { requireFile: true }) + expect(result.status).toBe("valid") + }) + + it("should reject directory when requireFile is true", async () => { + const result = await validator.validate("src", { requireFile: true }) + expect(result.status).toBe("invalid") + expect(result.reason).toBe("Path is not a file") + }) + + it("should handle permission errors gracefully", async () => { + const result = await validator.validate("src/../../../root/secret") + expect(result.status).toBe("invalid") + }) + + it("should still check traversal before existence", async () => { + const result = await validator.validate("../outside", { allowNonExistent: true }) + expect(result.status).toBe("invalid") + expect(result.reason).toBe("Path contains traversal patterns") + }) + }) + + describe("isWithin", () => { + it("should return true for path within project", () => { + expect(validator.isWithin("src/file.ts")).toBe(true) + }) + + it("should return true for project root itself", () => { + expect(validator.isWithin(".")).toBe(true) + expect(validator.isWithin("")).toBe(false) + }) + + it("should return false for path outside project", () => { + expect(validator.isWithin("/etc/passwd")).toBe(false) + }) + + it("should return false for traversal path", () => { + expect(validator.isWithin("../outside")).toBe(false) + }) + + it("should return false for empty path", () => { + expect(validator.isWithin("")).toBe(false) + }) + + it("should return false for tilde path", () => { + expect(validator.isWithin("~/file")).toBe(false) + }) + }) + + describe("resolve", () => { + it("should resolve valid relative path to absolute", () => { + const result = validator.resolve("src/file.ts") + expect(result).toBe(path.join(projectRoot, "src/file.ts")) + }) + + it("should return null for invalid path", () => { + expect(validator.resolve("../outside")).toBeNull() + }) + + it("should return null for empty path", () => { + expect(validator.resolve("")).toBeNull() + }) + + it("should return null for path outside project", () => { + expect(validator.resolve("/etc/passwd")).toBeNull() + }) + }) + + describe("relativize", () => { + it("should return relative path for valid input", () => { + const result = validator.relativize("src/file.ts") + expect(result).toBe(path.join("src", "file.ts")) + }) + + it("should handle absolute path within project", () => { + const absolutePath = path.join(projectRoot, "src/file.ts") + const result = validator.relativize(absolutePath) + expect(result).toBe(path.join("src", "file.ts")) + }) + + it("should return null for path outside project", () => { + expect(validator.relativize("/etc/passwd")).toBeNull() + }) + + it("should return null for traversal path", () => { + expect(validator.relativize("../outside")).toBeNull() + }) + }) + + describe("edge cases", () => { + it("should handle path with multiple slashes", () => { + const result = validator.validateSync("src///file.ts") + expect(result.status).toBe("valid") + }) + + it("should handle path with dots in filename", () => { + const result = validator.validateSync("src/file.test.ts") + expect(result.status).toBe("valid") + }) + + it("should handle hidden files", () => { + const result = validator.validateSync(".gitignore") + expect(result.status).toBe("valid") + }) + + it("should handle hidden directories", () => { + const result = validator.validateSync(".github/workflows/ci.yml") + expect(result.status).toBe("valid") + }) + + it("should handle single dot current directory", () => { + const result = validator.validateSync("./src/file.ts") + expect(result.status).toBe("valid") + }) + + it("should handle project root as path", () => { + const result = validator.validateSync(projectRoot) + expect(result.status).toBe("valid") + }) + + it("should handle unicode characters in path", () => { + const result = validator.validateSync("src/файл.ts") + expect(result.status).toBe("valid") + }) + + it("should handle spaces in path", () => { + const result = validator.validateSync("src/my file.ts") + expect(result.status).toBe("valid") + }) + }) +}) + +describe("createPathValidator", () => { + it("should create PathValidator instance", () => { + const validator = createPathValidator("/tmp/project") + expect(validator).toBeInstanceOf(PathValidator) + expect(validator.getProjectRoot()).toBe("/tmp/project") + }) +}) + +describe("validatePath", () => { + let tempDir: string + let projectRoot: string + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "validatepath-test-")) + projectRoot = path.join(tempDir, "project") + await fs.mkdir(projectRoot) + }) + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + it("should return true for valid path", () => { + expect(validatePath("src/file.ts", projectRoot)).toBe(true) + }) + + it("should return false for traversal path", () => { + expect(validatePath("../outside", projectRoot)).toBe(false) + }) + + it("should return false for path outside project", () => { + expect(validatePath("/etc/passwd", projectRoot)).toBe(false) + }) + + it("should return false for empty path", () => { + expect(validatePath("", projectRoot)).toBe(false) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/edit/CreateFileTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/edit/CreateFileTool.test.ts index 84f8a58..b588507 100644 --- a/packages/ipuaro/tests/unit/infrastructure/tools/edit/CreateFileTool.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/tools/edit/CreateFileTool.test.ts @@ -224,7 +224,7 @@ describe("CreateFileTool", () => { const result = await tool.execute({ path: "../outside/file.ts", content: "test" }, ctx) expect(result.success).toBe(false) - expect(result.error).toBe("Path must be within project root") + expect(result.error).toBe("Path contains traversal patterns") }) it("should return error if file already exists", async () => { diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/edit/DeleteFileTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/edit/DeleteFileTool.test.ts index 703081c..bc5cd8f 100644 --- a/packages/ipuaro/tests/unit/infrastructure/tools/edit/DeleteFileTool.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/tools/edit/DeleteFileTool.test.ts @@ -189,7 +189,7 @@ describe("DeleteFileTool", () => { const result = await tool.execute({ path: "../outside/file.ts" }, ctx) expect(result.success).toBe(false) - expect(result.error).toBe("Path must be within project root") + expect(result.error).toBe("Path contains traversal patterns") }) it("should return error if file does not exist", async () => { diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/edit/EditLinesTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/edit/EditLinesTool.test.ts index 2bab6db..fd79316 100644 --- a/packages/ipuaro/tests/unit/infrastructure/tools/edit/EditLinesTool.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/tools/edit/EditLinesTool.test.ts @@ -296,7 +296,7 @@ describe("EditLinesTool", () => { ) expect(result.success).toBe(false) - expect(result.error).toBe("Path must be within project root") + expect(result.error).toBe("Path contains traversal patterns") }) it("should return error when start exceeds file length", async () => { diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/read/GetClassTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetClassTool.test.ts index 38006a2..d68e357 100644 --- a/packages/ipuaro/tests/unit/infrastructure/tools/read/GetClassTool.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetClassTool.test.ts @@ -271,7 +271,7 @@ describe("GetClassTool", () => { const result = await tool.execute({ path: "../outside/file.ts", name: "MyClass" }, ctx) expect(result.success).toBe(false) - expect(result.error).toBe("Path must be within project root") + expect(result.error).toBe("Path contains traversal patterns") }) it("should handle class with no extends", async () => { diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/read/GetFunctionTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetFunctionTool.test.ts index 9ed0288..29de065 100644 --- a/packages/ipuaro/tests/unit/infrastructure/tools/read/GetFunctionTool.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetFunctionTool.test.ts @@ -229,7 +229,7 @@ describe("GetFunctionTool", () => { const result = await tool.execute({ path: "../outside/file.ts", name: "myFunc" }, ctx) expect(result.success).toBe(false) - expect(result.error).toBe("Path must be within project root") + expect(result.error).toBe("Path contains traversal patterns") }) it("should pad line numbers correctly for large files", async () => { diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/read/GetLinesTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetLinesTool.test.ts index 88f2ef8..884ad93 100644 --- a/packages/ipuaro/tests/unit/infrastructure/tools/read/GetLinesTool.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetLinesTool.test.ts @@ -214,7 +214,7 @@ describe("GetLinesTool", () => { const result = await tool.execute({ path: "../outside/file.ts" }, ctx) expect(result.success).toBe(false) - expect(result.error).toBe("Path must be within project root") + expect(result.error).toBe("Path contains traversal patterns") }) it("should return error when file not found", async () => { diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/read/GetStructureTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetStructureTool.test.ts index 523acad..479e0d4 100644 --- a/packages/ipuaro/tests/unit/infrastructure/tools/read/GetStructureTool.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/tools/read/GetStructureTool.test.ts @@ -228,7 +228,7 @@ describe("GetStructureTool", () => { const result = await tool.execute({ path: "../outside" }, ctx) expect(result.success).toBe(false) - expect(result.error).toBe("Path must be within project root") + expect(result.error).toBe("Path contains traversal patterns") }) it("should return error for non-directory path", async () => {