mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 15:26:53 +05:00
Compare commits
2 Commits
ipuaro-v0.
...
ipuaro-v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c6eb6ce9b | ||
|
|
7d18e87423 |
@@ -5,6 +5,87 @@ 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/),
|
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).
|
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
|
||||||
|
|
||||||
|
- **DiffView Component (0.12.1)**
|
||||||
|
- Inline diff display with green (added) and red (removed) highlighting
|
||||||
|
- Header with file path and line range: `┌─── path (lines X-Y) ───┐`
|
||||||
|
- Line numbers with proper padding
|
||||||
|
- Stats footer showing additions and deletions count
|
||||||
|
|
||||||
|
- **ConfirmDialog Component (0.12.2)**
|
||||||
|
- Confirmation dialog with [Y] Apply / [N] Cancel / [E] Edit options
|
||||||
|
- Optional diff preview integration
|
||||||
|
- Keyboard input handling (Y/N/E keys, Escape)
|
||||||
|
- Visual selection feedback
|
||||||
|
|
||||||
|
- **ErrorDialog Component (0.12.3)**
|
||||||
|
- Error dialog with [R] Retry / [S] Skip / [A] Abort options
|
||||||
|
- Recoverable vs non-recoverable error handling
|
||||||
|
- Disabled buttons for non-recoverable errors
|
||||||
|
- Keyboard input with Escape support
|
||||||
|
|
||||||
|
- **Progress Component (0.12.4)**
|
||||||
|
- Progress bar display: `[=====> ] 45% (120/267 files)`
|
||||||
|
- Color-coded progress (cyan < 50%, yellow < 100%, green = 100%)
|
||||||
|
- Configurable width
|
||||||
|
- Label support for context
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Total tests: 1254 (unchanged - TUI components excluded from coverage)
|
||||||
|
- TUI layer now has 8 components + 2 hooks
|
||||||
|
- All v0.12.0 roadmap items complete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.11.0] - 2025-12-01 - TUI Basic
|
## [0.11.0] - 2025-12-01 - TUI Basic
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@samiyev/ipuaro",
|
"name": "@samiyev/ipuaro",
|
||||||
"version": "0.11.0",
|
"version": "0.13.0",
|
||||||
"description": "Local AI agent for codebase operations with infinite context feeling",
|
"description": "Local AI agent for codebase operations with infinite context feeling",
|
||||||
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
|
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export * from "./storage/index.js"
|
|||||||
export * from "./indexer/index.js"
|
export * from "./indexer/index.js"
|
||||||
export * from "./llm/index.js"
|
export * from "./llm/index.js"
|
||||||
export * from "./tools/index.js"
|
export * from "./tools/index.js"
|
||||||
|
export * from "./security/index.js"
|
||||||
|
|||||||
293
packages/ipuaro/src/infrastructure/security/PathValidator.ts
Normal file
293
packages/ipuaro/src/infrastructure/security/PathValidator.ts
Normal file
@@ -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<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)
|
||||||
|
|
||||||
|
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<PathValidationResult | null> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
9
packages/ipuaro/src/infrastructure/security/index.ts
Normal file
9
packages/ipuaro/src/infrastructure/security/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Security module exports
|
||||||
|
export {
|
||||||
|
PathValidator,
|
||||||
|
createPathValidator,
|
||||||
|
validatePath,
|
||||||
|
type PathValidationResult,
|
||||||
|
type PathValidationStatus,
|
||||||
|
type PathValidatorOptions,
|
||||||
|
} from "./PathValidator.js"
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ToolResult,
|
type ToolResult,
|
||||||
} from "../../../domain/value-objects/ToolResult.js"
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
import { hashLines } from "../../../shared/utils/hash.js"
|
import { hashLines } from "../../../shared/utils/hash.js"
|
||||||
|
import { PathValidator } from "../../security/PathValidator.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result data from create_file tool.
|
* Result data from create_file tool.
|
||||||
@@ -62,17 +63,18 @@ export class CreateFileTool implements ITool {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const callId = `${this.name}-${String(startTime)}`
|
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 content = params.content as string
|
||||||
|
|
||||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||||
|
|
||||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
let absolutePath: string
|
||||||
return createErrorResult(
|
let relativePath: string
|
||||||
callId,
|
try {
|
||||||
"Path must be within project root",
|
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||||
Date.now() - startTime,
|
} catch (error) {
|
||||||
)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return createErrorResult(callId, message, Date.now() - startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { promises as fs } from "node:fs"
|
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 { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||||
import {
|
import {
|
||||||
createErrorResult,
|
createErrorResult,
|
||||||
createSuccessResult,
|
createSuccessResult,
|
||||||
type ToolResult,
|
type ToolResult,
|
||||||
} from "../../../domain/value-objects/ToolResult.js"
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
|
import { PathValidator } from "../../security/PathValidator.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result data from delete_file tool.
|
* Result data from delete_file tool.
|
||||||
@@ -49,15 +49,16 @@ export class DeleteFileTool implements ITool {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const callId = `${this.name}-${String(startTime)}`
|
const callId = `${this.name}-${String(startTime)}`
|
||||||
|
|
||||||
const relativePath = params.path as string
|
const inputPath = params.path as string
|
||||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||||
|
|
||||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
let absolutePath: string
|
||||||
return createErrorResult(
|
let relativePath: string
|
||||||
callId,
|
try {
|
||||||
"Path must be within project root",
|
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||||
Date.now() - startTime,
|
} catch (error) {
|
||||||
)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return createErrorResult(callId, message, Date.now() - startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { promises as fs } from "node:fs"
|
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 { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||||
import { createFileData } from "../../../domain/value-objects/FileData.js"
|
import { createFileData } from "../../../domain/value-objects/FileData.js"
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
type ToolResult,
|
type ToolResult,
|
||||||
} from "../../../domain/value-objects/ToolResult.js"
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
import { hashLines } from "../../../shared/utils/hash.js"
|
import { hashLines } from "../../../shared/utils/hash.js"
|
||||||
|
import { PathValidator } from "../../security/PathValidator.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result data from edit_lines tool.
|
* Result data from edit_lines tool.
|
||||||
@@ -94,19 +94,20 @@ export class EditLinesTool implements ITool {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const callId = `${this.name}-${String(startTime)}`
|
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 startLine = params.start as number
|
||||||
const endLine = params.end as number
|
const endLine = params.end as number
|
||||||
const newContent = params.content as string
|
const newContent = params.content as string
|
||||||
|
|
||||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||||
|
|
||||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
let absolutePath: string
|
||||||
return createErrorResult(
|
let relativePath: string
|
||||||
callId,
|
try {
|
||||||
"Path must be within project root",
|
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||||
Date.now() - startTime,
|
} catch (error) {
|
||||||
)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return createErrorResult(callId, message, Date.now() - startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { promises as fs } from "node:fs"
|
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 { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||||
import type { ClassInfo } from "../../../domain/value-objects/FileAST.js"
|
import type { ClassInfo } from "../../../domain/value-objects/FileAST.js"
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
createSuccessResult,
|
createSuccessResult,
|
||||||
type ToolResult,
|
type ToolResult,
|
||||||
} from "../../../domain/value-objects/ToolResult.js"
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
|
import { PathValidator } from "../../security/PathValidator.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result data from get_class tool.
|
* Result data from get_class tool.
|
||||||
@@ -67,16 +67,17 @@ export class GetClassTool implements ITool {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const callId = `${this.name}-${String(startTime)}`
|
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 className = params.name as string
|
||||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||||
|
|
||||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
let absolutePath: string
|
||||||
return createErrorResult(
|
let relativePath: string
|
||||||
callId,
|
try {
|
||||||
"Path must be within project root",
|
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||||
Date.now() - startTime,
|
} catch (error) {
|
||||||
)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return createErrorResult(callId, message, Date.now() - startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { promises as fs } from "node:fs"
|
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 { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||||
import type { FunctionInfo } from "../../../domain/value-objects/FileAST.js"
|
import type { FunctionInfo } from "../../../domain/value-objects/FileAST.js"
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +6,7 @@ import {
|
|||||||
createSuccessResult,
|
createSuccessResult,
|
||||||
type ToolResult,
|
type ToolResult,
|
||||||
} from "../../../domain/value-objects/ToolResult.js"
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
|
import { PathValidator } from "../../security/PathValidator.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result data from get_function tool.
|
* Result data from get_function tool.
|
||||||
@@ -65,16 +65,17 @@ export class GetFunctionTool implements ITool {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const callId = `${this.name}-${String(startTime)}`
|
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 functionName = params.name as string
|
||||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||||
|
|
||||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
let absolutePath: string
|
||||||
return createErrorResult(
|
let relativePath: string
|
||||||
callId,
|
try {
|
||||||
"Path must be within project root",
|
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||||
Date.now() - startTime,
|
} catch (error) {
|
||||||
)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return createErrorResult(callId, message, Date.now() - startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { promises as fs } from "node:fs"
|
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 { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||||
import {
|
import {
|
||||||
createErrorResult,
|
createErrorResult,
|
||||||
createSuccessResult,
|
createSuccessResult,
|
||||||
type ToolResult,
|
type ToolResult,
|
||||||
} from "../../../domain/value-objects/ToolResult.js"
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
|
import { PathValidator } from "../../security/PathValidator.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result data from get_lines tool.
|
* Result data from get_lines tool.
|
||||||
@@ -84,15 +84,16 @@ export class GetLinesTool implements ITool {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const callId = `${this.name}-${String(startTime)}`
|
const callId = `${this.name}-${String(startTime)}`
|
||||||
|
|
||||||
const relativePath = params.path as string
|
const inputPath = params.path as string
|
||||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||||
|
|
||||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
let absolutePath: string
|
||||||
return createErrorResult(
|
let relativePath: string
|
||||||
callId,
|
try {
|
||||||
"Path must be within project root",
|
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||||
Date.now() - startTime,
|
} catch (error) {
|
||||||
)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return createErrorResult(callId, message, Date.now() - startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type ToolResult,
|
type ToolResult,
|
||||||
} from "../../../domain/value-objects/ToolResult.js"
|
} from "../../../domain/value-objects/ToolResult.js"
|
||||||
import { DEFAULT_IGNORE_PATTERNS } from "../../../domain/constants/index.js"
|
import { DEFAULT_IGNORE_PATTERNS } from "../../../domain/constants/index.js"
|
||||||
|
import { PathValidator } from "../../security/PathValidator.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tree node representing a file or directory.
|
* Tree node representing a file or directory.
|
||||||
@@ -89,16 +90,17 @@ export class GetStructureTool implements ITool {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const callId = `${this.name}-${String(startTime)}`
|
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 maxDepth = params.depth as number | undefined
|
||||||
const absolutePath = path.resolve(ctx.projectRoot, relativePath)
|
const pathValidator = new PathValidator(ctx.projectRoot)
|
||||||
|
|
||||||
if (!absolutePath.startsWith(ctx.projectRoot)) {
|
let absolutePath: string
|
||||||
return createErrorResult(
|
let relativePath: string
|
||||||
callId,
|
try {
|
||||||
"Path must be within project root",
|
;[absolutePath, relativePath] = pathValidator.resolveOrThrow(inputPath)
|
||||||
Date.now() - startTime,
|
} catch (error) {
|
||||||
)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return createErrorResult(callId, message, Date.now() - startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
83
packages/ipuaro/src/tui/components/ConfirmDialog.tsx
Normal file
83
packages/ipuaro/src/tui/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* ConfirmDialog component for TUI.
|
||||||
|
* Displays a confirmation dialog with [Y] Apply / [N] Cancel / [E] Edit options.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Box, Text, useInput } from "ink"
|
||||||
|
import React, { useState } from "react"
|
||||||
|
import type { ConfirmChoice } from "../../shared/types/index.js"
|
||||||
|
import { DiffView, type DiffViewProps } from "./DiffView.js"
|
||||||
|
|
||||||
|
export interface ConfirmDialogProps {
|
||||||
|
message: string
|
||||||
|
diff?: DiffViewProps
|
||||||
|
onSelect: (choice: ConfirmChoice) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChoiceButton({
|
||||||
|
hotkey,
|
||||||
|
label,
|
||||||
|
isSelected,
|
||||||
|
}: {
|
||||||
|
hotkey: string
|
||||||
|
label: string
|
||||||
|
isSelected: boolean
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color={isSelected ? "cyan" : "gray"}>
|
||||||
|
[<Text bold>{hotkey}</Text>] {label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({ message, diff, onSelect }: ConfirmDialogProps): React.JSX.Element {
|
||||||
|
const [selected, setSelected] = useState<ConfirmChoice | null>(null)
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
const lowerInput = input.toLowerCase()
|
||||||
|
|
||||||
|
if (lowerInput === "y") {
|
||||||
|
setSelected("apply")
|
||||||
|
onSelect("apply")
|
||||||
|
} else if (lowerInput === "n") {
|
||||||
|
setSelected("cancel")
|
||||||
|
onSelect("cancel")
|
||||||
|
} else if (lowerInput === "e") {
|
||||||
|
setSelected("edit")
|
||||||
|
onSelect("edit")
|
||||||
|
} else if (key.escape) {
|
||||||
|
setSelected("cancel")
|
||||||
|
onSelect("cancel")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor="yellow"
|
||||||
|
paddingX={1}
|
||||||
|
paddingY={1}
|
||||||
|
>
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text color="yellow" bold>
|
||||||
|
⚠ {message}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{diff && (
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<DiffView {...diff} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box gap={2}>
|
||||||
|
<ChoiceButton hotkey="Y" label="Apply" isSelected={selected === "apply"} />
|
||||||
|
<ChoiceButton hotkey="N" label="Cancel" isSelected={selected === "cancel"} />
|
||||||
|
<ChoiceButton hotkey="E" label="Edit" isSelected={selected === "edit"} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
193
packages/ipuaro/src/tui/components/DiffView.tsx
Normal file
193
packages/ipuaro/src/tui/components/DiffView.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* DiffView component for TUI.
|
||||||
|
* Displays inline diff with green (added) and red (removed) highlighting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Box, Text } from "ink"
|
||||||
|
import type React from "react"
|
||||||
|
|
||||||
|
export interface DiffViewProps {
|
||||||
|
filePath: string
|
||||||
|
oldLines: string[]
|
||||||
|
newLines: string[]
|
||||||
|
startLine: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffLine {
|
||||||
|
type: "add" | "remove" | "context"
|
||||||
|
content: string
|
||||||
|
lineNumber?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDiff(oldLines: string[], newLines: string[], startLine: number): DiffLine[] {
|
||||||
|
const result: DiffLine[] = []
|
||||||
|
|
||||||
|
let oldIdx = 0
|
||||||
|
let newIdx = 0
|
||||||
|
|
||||||
|
while (oldIdx < oldLines.length || newIdx < newLines.length) {
|
||||||
|
const oldLine = oldIdx < oldLines.length ? oldLines[oldIdx] : undefined
|
||||||
|
const newLine = newIdx < newLines.length ? newLines[newIdx] : undefined
|
||||||
|
|
||||||
|
if (oldLine === newLine) {
|
||||||
|
result.push({
|
||||||
|
type: "context",
|
||||||
|
content: oldLine ?? "",
|
||||||
|
lineNumber: startLine + newIdx,
|
||||||
|
})
|
||||||
|
oldIdx++
|
||||||
|
newIdx++
|
||||||
|
} else {
|
||||||
|
if (oldLine !== undefined) {
|
||||||
|
result.push({
|
||||||
|
type: "remove",
|
||||||
|
content: oldLine,
|
||||||
|
})
|
||||||
|
oldIdx++
|
||||||
|
}
|
||||||
|
if (newLine !== undefined) {
|
||||||
|
result.push({
|
||||||
|
type: "add",
|
||||||
|
content: newLine,
|
||||||
|
lineNumber: startLine + newIdx,
|
||||||
|
})
|
||||||
|
newIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinePrefix(line: DiffLine): string {
|
||||||
|
switch (line.type) {
|
||||||
|
case "add": {
|
||||||
|
return "+"
|
||||||
|
}
|
||||||
|
case "remove": {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
case "context": {
|
||||||
|
return " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineColor(line: DiffLine): string {
|
||||||
|
switch (line.type) {
|
||||||
|
case "add": {
|
||||||
|
return "green"
|
||||||
|
}
|
||||||
|
case "remove": {
|
||||||
|
return "red"
|
||||||
|
}
|
||||||
|
case "context": {
|
||||||
|
return "gray"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLineNumber(num: number | undefined, width: number): string {
|
||||||
|
if (num === undefined) {
|
||||||
|
return " ".repeat(width)
|
||||||
|
}
|
||||||
|
return String(num).padStart(width, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffLine({
|
||||||
|
line,
|
||||||
|
lineNumberWidth,
|
||||||
|
}: {
|
||||||
|
line: DiffLine
|
||||||
|
lineNumberWidth: number
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const prefix = getLinePrefix(line)
|
||||||
|
const color = getLineColor(line)
|
||||||
|
const lineNum = formatLineNumber(line.lineNumber, lineNumberWidth)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color="gray">{lineNum} </Text>
|
||||||
|
<Text color={color}>
|
||||||
|
{prefix} {line.content}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffHeader({
|
||||||
|
filePath,
|
||||||
|
startLine,
|
||||||
|
endLine,
|
||||||
|
}: {
|
||||||
|
filePath: string
|
||||||
|
startLine: number
|
||||||
|
endLine: number
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const lineRange =
|
||||||
|
startLine === endLine
|
||||||
|
? `line ${String(startLine)}`
|
||||||
|
: `lines ${String(startLine)}-${String(endLine)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color="gray">┌─── </Text>
|
||||||
|
<Text color="cyan">{filePath}</Text>
|
||||||
|
<Text color="gray"> ({lineRange}) ───┐</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffFooter(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color="gray">└───────────────────────────────────────┘</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffStats({
|
||||||
|
additions,
|
||||||
|
deletions,
|
||||||
|
}: {
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Box gap={1} marginTop={1}>
|
||||||
|
<Text color="green">+{String(additions)}</Text>
|
||||||
|
<Text color="red">-{String(deletions)}</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffView({
|
||||||
|
filePath,
|
||||||
|
oldLines,
|
||||||
|
newLines,
|
||||||
|
startLine,
|
||||||
|
}: DiffViewProps): React.JSX.Element {
|
||||||
|
const diffLines = computeDiff(oldLines, newLines, startLine)
|
||||||
|
const endLine = startLine + newLines.length - 1
|
||||||
|
const lineNumberWidth = String(endLine).length
|
||||||
|
|
||||||
|
const additions = diffLines.filter((l) => l.type === "add").length
|
||||||
|
const deletions = diffLines.filter((l) => l.type === "remove").length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" paddingX={1}>
|
||||||
|
<DiffHeader filePath={filePath} startLine={startLine} endLine={endLine} />
|
||||||
|
<Box flexDirection="column" paddingX={1}>
|
||||||
|
{diffLines.map((line, index) => (
|
||||||
|
<DiffLine
|
||||||
|
key={`${line.type}-${String(index)}`}
|
||||||
|
line={line}
|
||||||
|
lineNumberWidth={lineNumberWidth}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<DiffFooter />
|
||||||
|
<DiffStats additions={additions} deletions={deletions} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
packages/ipuaro/src/tui/components/ErrorDialog.tsx
Normal file
105
packages/ipuaro/src/tui/components/ErrorDialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* ErrorDialog component for TUI.
|
||||||
|
* Displays an error with [R] Retry / [S] Skip / [A] Abort options.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Box, Text, useInput } from "ink"
|
||||||
|
import React, { useState } from "react"
|
||||||
|
import type { ErrorChoice } from "../../shared/types/index.js"
|
||||||
|
|
||||||
|
export interface ErrorInfo {
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
recoverable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorDialogProps {
|
||||||
|
error: ErrorInfo
|
||||||
|
onChoice: (choice: ErrorChoice) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChoiceButton({
|
||||||
|
hotkey,
|
||||||
|
label,
|
||||||
|
isSelected,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
hotkey: string
|
||||||
|
label: string
|
||||||
|
isSelected: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}): React.JSX.Element {
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color="gray" dimColor>
|
||||||
|
[{hotkey}] {label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color={isSelected ? "cyan" : "gray"}>
|
||||||
|
[<Text bold>{hotkey}</Text>] {label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorDialog({ error, onChoice }: ErrorDialogProps): React.JSX.Element {
|
||||||
|
const [selected, setSelected] = useState<ErrorChoice | null>(null)
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
const lowerInput = input.toLowerCase()
|
||||||
|
|
||||||
|
if (lowerInput === "r" && error.recoverable) {
|
||||||
|
setSelected("retry")
|
||||||
|
onChoice("retry")
|
||||||
|
} else if (lowerInput === "s" && error.recoverable) {
|
||||||
|
setSelected("skip")
|
||||||
|
onChoice("skip")
|
||||||
|
} else if (lowerInput === "a") {
|
||||||
|
setSelected("abort")
|
||||||
|
onChoice("abort")
|
||||||
|
} else if (key.escape) {
|
||||||
|
setSelected("abort")
|
||||||
|
onChoice("abort")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" borderStyle="round" borderColor="red" paddingX={1} paddingY={1}>
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text color="red" bold>
|
||||||
|
x {error.type}: {error.message}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box gap={2}>
|
||||||
|
<ChoiceButton
|
||||||
|
hotkey="R"
|
||||||
|
label="Retry"
|
||||||
|
isSelected={selected === "retry"}
|
||||||
|
disabled={!error.recoverable}
|
||||||
|
/>
|
||||||
|
<ChoiceButton
|
||||||
|
hotkey="S"
|
||||||
|
label="Skip"
|
||||||
|
isSelected={selected === "skip"}
|
||||||
|
disabled={!error.recoverable}
|
||||||
|
/>
|
||||||
|
<ChoiceButton hotkey="A" label="Abort" isSelected={selected === "abort"} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!error.recoverable && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color="gray" dimColor>
|
||||||
|
This error is not recoverable. Press [A] to abort.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
packages/ipuaro/src/tui/components/Progress.tsx
Normal file
62
packages/ipuaro/src/tui/components/Progress.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Progress component for TUI.
|
||||||
|
* Displays a progress bar: [=====> ] 45% (120/267 files)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Box, Text } from "ink"
|
||||||
|
import type React from "react"
|
||||||
|
|
||||||
|
export interface ProgressProps {
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
label: string
|
||||||
|
width?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePercentage(current: number, total: number): number {
|
||||||
|
if (total === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return Math.min(100, Math.round((current / total) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgressBar(percentage: number, width: number): { filled: string; empty: string } {
|
||||||
|
const filledWidth = Math.round((percentage / 100) * width)
|
||||||
|
const emptyWidth = width - filledWidth
|
||||||
|
|
||||||
|
const filled = "=".repeat(Math.max(0, filledWidth - 1)) + (filledWidth > 0 ? ">" : "")
|
||||||
|
const empty = " ".repeat(Math.max(0, emptyWidth))
|
||||||
|
|
||||||
|
return { filled, empty }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressColor(percentage: number): string {
|
||||||
|
if (percentage >= 100) {
|
||||||
|
return "green"
|
||||||
|
}
|
||||||
|
if (percentage >= 50) {
|
||||||
|
return "yellow"
|
||||||
|
}
|
||||||
|
return "cyan"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Progress({ current, total, label, width = 30 }: ProgressProps): React.JSX.Element {
|
||||||
|
const percentage = calculatePercentage(current, total)
|
||||||
|
const { filled, empty } = createProgressBar(percentage, width)
|
||||||
|
const color = getProgressColor(percentage)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box gap={1}>
|
||||||
|
<Text color="gray">[</Text>
|
||||||
|
<Text color={color}>{filled}</Text>
|
||||||
|
<Text color="gray">{empty}</Text>
|
||||||
|
<Text color="gray">]</Text>
|
||||||
|
<Text color={color} bold>
|
||||||
|
{String(percentage)}%
|
||||||
|
</Text>
|
||||||
|
<Text color="gray">
|
||||||
|
({String(current)}/{String(total)} {label})
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,3 +5,7 @@
|
|||||||
export { StatusBar, type StatusBarProps } from "./StatusBar.js"
|
export { StatusBar, type StatusBarProps } from "./StatusBar.js"
|
||||||
export { Chat, type ChatProps } from "./Chat.js"
|
export { Chat, type ChatProps } from "./Chat.js"
|
||||||
export { Input, type InputProps } from "./Input.js"
|
export { Input, type InputProps } from "./Input.js"
|
||||||
|
export { DiffView, type DiffViewProps } from "./DiffView.js"
|
||||||
|
export { ConfirmDialog, type ConfirmDialogProps } from "./ConfirmDialog.js"
|
||||||
|
export { ErrorDialog, type ErrorDialogProps, type ErrorInfo } from "./ErrorDialog.js"
|
||||||
|
export { Progress, type ProgressProps } from "./Progress.js"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -224,7 +224,7 @@ describe("CreateFileTool", () => {
|
|||||||
const result = await tool.execute({ path: "../outside/file.ts", content: "test" }, ctx)
|
const result = await tool.execute({ path: "../outside/file.ts", content: "test" }, ctx)
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
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 () => {
|
it("should return error if file already exists", async () => {
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ describe("DeleteFileTool", () => {
|
|||||||
const result = await tool.execute({ path: "../outside/file.ts" }, ctx)
|
const result = await tool.execute({ path: "../outside/file.ts" }, ctx)
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
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 () => {
|
it("should return error if file does not exist", async () => {
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ describe("EditLinesTool", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
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 () => {
|
it("should return error when start exceeds file length", async () => {
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ describe("GetClassTool", () => {
|
|||||||
const result = await tool.execute({ path: "../outside/file.ts", name: "MyClass" }, ctx)
|
const result = await tool.execute({ path: "../outside/file.ts", name: "MyClass" }, ctx)
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
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 () => {
|
it("should handle class with no extends", async () => {
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ describe("GetFunctionTool", () => {
|
|||||||
const result = await tool.execute({ path: "../outside/file.ts", name: "myFunc" }, ctx)
|
const result = await tool.execute({ path: "../outside/file.ts", name: "myFunc" }, ctx)
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
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 () => {
|
it("should pad line numbers correctly for large files", async () => {
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ describe("GetLinesTool", () => {
|
|||||||
const result = await tool.execute({ path: "../outside/file.ts" }, ctx)
|
const result = await tool.execute({ path: "../outside/file.ts" }, ctx)
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
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 () => {
|
it("should return error when file not found", async () => {
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ describe("GetStructureTool", () => {
|
|||||||
const result = await tool.execute({ path: "../outside" }, ctx)
|
const result = await tool.execute({ path: "../outside" }, ctx)
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
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 () => {
|
it("should return error for non-directory path", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user