diff --git a/src/node/services/tools/fileCommon.test.ts b/src/node/services/tools/fileCommon.test.ts index 2acb50334e..8caccea833 100644 --- a/src/node/services/tools/fileCommon.test.ts +++ b/src/node/services/tools/fileCommon.test.ts @@ -147,6 +147,25 @@ describe("fileCommon", () => { const result = validatePathInCwd("../outside.ts", cwdWithSlash, runtime); expect(result).not.toBeNull(); }); + + it("should reject tilde paths outside cwd", () => { + // Tilde paths expand to home directory, which is outside /workspace/project + const result = validatePathInCwd("~/other-project/file.ts", cwd, runtime); + expect(result).not.toBeNull(); + expect(result?.error).toContain("restricted to the workspace directory"); + }); + + it("should reject tilde paths to sensitive files", () => { + const result = validatePathInCwd("~/.ssh/id_rsa", cwd, runtime); + expect(result).not.toBeNull(); + expect(result?.error).toContain("restricted to the workspace directory"); + }); + + it("should reject bare tilde path", () => { + const result = validatePathInCwd("~", cwd, runtime); + expect(result).not.toBeNull(); + expect(result?.error).toContain("restricted to the workspace directory"); + }); }); describe("validateNoRedundantPrefix", () => { diff --git a/src/node/services/tools/fileCommon.ts b/src/node/services/tools/fileCommon.ts index 08e3aa7902..aa9adb0b51 100644 --- a/src/node/services/tools/fileCommon.ts +++ b/src/node/services/tools/fileCommon.ts @@ -3,6 +3,7 @@ import assert from "@/common/utils/assert"; import { createPatch } from "diff"; import type { FileStat, Runtime } from "@/node/runtime/Runtime"; import { SSHRuntime } from "@/node/runtime/SSHRuntime"; +import { expandTilde } from "@/node/runtime/tildeExpansion"; import type { ToolConfiguration } from "@/common/utils/tools/tools"; /** @@ -216,13 +217,19 @@ export function validatePathInCwd( assert(path.isAbsolute(dir), `extraAllowedDir must be an absolute path: '${dir}'`); } - const filePathIsAbsolute = path.isAbsolute(filePath); + // Expand tildes FIRST so we validate the actual destination path. + // Without this, ~/outside/file.ts would be treated as a relative path + // (path.isAbsolute('~/...') returns false) and incorrectly pass validation. + const expandedPath = expandTilde(filePath); + const filePathIsAbsolute = path.isAbsolute(expandedPath); // Only allow extraAllowedDirs when the caller provides an absolute path. // This prevents relative-path escapes (e.g., ../...) from bypassing cwd restrictions. // Resolve the path (handles relative paths and normalizes) - const resolvedPath = filePathIsAbsolute ? path.resolve(filePath) : path.resolve(cwd, filePath); + const resolvedPath = filePathIsAbsolute + ? path.resolve(expandedPath) + : path.resolve(cwd, expandedPath); const allowedRoots = [cwd, ...(filePathIsAbsolute ? trimmedExtraAllowedDirs : [])].map((dir) => path.resolve(dir)