fix(edit-tool): return accurate error for paths outside workspace root#30753
fix(edit-tool): return accurate error for paths outside workspace root#30753kevinWangSheng wants to merge 3 commits intoopenclaw:mainfrom
Conversation
Fixes issue openclaw#30669 - The Edit tool was not expanding ~ (tilde) in file_path/path parameters, causing 'File not found' errors when targeting files like ~/.codex/config.toml. This fix adds tilde expansion in the normalizeToolParams function, which is called before passing parameters to the underlying edit tool.
Fixes openclaw#30724 The Edit tool returned a misleading 'File not found' error when given an absolute path outside the agent's workspace root. The root cause was that toRelativePathInRoot() throws 'Path escapes workspace root' before the access() try/catch, which gets swallowed by pi-coding-agent wrapper. Changes: - In createHostEditOperations access(), catch the toRelativePathInRoot() throw for direct outside-workspace paths - Do a plain fs.access() existence check and return successfully - Let readFile() handle workspace enforcement (it already calls toRelativePathInRoot() and will throw the proper error) - Add regression test verifying access() resolves and readFile() rejects Test plan: pnpm vitest run src/agents/pi-tools.read.host-edit-access.test.ts
🔒 Aisle Security AnalysisWe found 1 potential security issue(s) in this PR:
1. 🟡 Workspace boundary bypass via host edit
|
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-200 |
| Location | src/agents/pi-tools.read.ts:863-874 |
Description
The host edit operations are intended to enforce a workspace root when workspaceOnly: true. However, the updated access() implementation intentionally falls back to fs.access(absolutePath) when toRelativePathInRoot() throws for a direct outside-workspace path.
This creates an information disclosure primitive:
- Input:
absolutePathis controllable by the tool caller (e.g., the agent/user invoking the edit tool) - Guard bypass:
toRelativePathInRoot(root, absolutePath)throws for outside-root paths, but this is caught and treated as a signal to perform a direct host filesystem probe - Sink:
await fs.access(absolutePath)reveals whether arbitrary host paths exist and whether the process has permissions (different error codes/timing)
Even if readFile() still rejects outside-root paths, access() can be used to enumerate sensitive paths (e.g., ~/.ssh/*, /etc/*, CI secrets paths), which may break the security assumption of “workspaceOnly”.
Vulnerable code:
try {
relative = toRelativePathInRoot(root, absolutePath);
} catch (error) {
if (error instanceof Error && error.message.startsWith("Path escapes workspace root:")) {
await fs.access(absolutePath);
return;
}
throw error;
}Recommendation
If workspaceOnly: true, keep access() within the same workspace boundary as readFile()/writeFile().
Recommended fix (strict): remove the outside-root fallback and map outside-root attempts to EACCES (or a generic denial) without touching the host FS:
access: async (absolutePath: string) => {
try {
const relative = toRelativePathInRoot(root, absolutePath);
const opened = await openFileWithinRoot({ rootDir: root, relativePath: relative });
await opened.handle.close().catch(() => {});
} catch (err) {
// outside-workspace or other invalid-path cases
throw createFsAccessError("EACCES", absolutePath);
}
}If product requirements truly need outside-workspace existence checks, add guardrails:
- make it opt-in via a separate flag (e.g.,
allowOutsideWorkspaceAccessProbe) defaulting tofalse - restrict checks to an allowlist of directories (e.g., specific temp dirs) rather than arbitrary absolute paths
- return a uniform error (avoid distinguishing ENOENT vs EACCES) to reduce probing value
Analyzed PR: #30753 at commit 77c0f8f
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 77c0f8f861
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| await fs.access(absolutePath); | ||
| return; |
There was a problem hiding this comment.
Reject out-of-root edits before probing host file existence
In createHostEditOperations.access, the new fs.access(absolutePath) fallback for Path escapes workspace root makes behavior depend on whether the out-of-root target exists/readable on disk: existing paths pass access and later surface the boundary error, but missing/unreadable paths still fail in access and get rewritten upstream as File not found. This means outside-workspace violations are no longer handled uniformly and now expose host path existence as a side channel; the boundary error should be returned directly without checking host filesystem state.
Useful? React with 👍 / 👎.
Greptile Summaryfixes the misleading "File not found" error by deferring workspace boundary enforcement from Key changes:
Notes:
Confidence Score: 4/5
Last reviewed commit: 77c0f8f |
| function expandHomeDir(filePath: unknown): unknown { | ||
| if (typeof filePath !== "string") { | ||
| return filePath; | ||
| } | ||
| if (filePath === "~") { | ||
| return os.homedir(); | ||
| } | ||
| if (filePath.startsWith("~/")) { | ||
| return os.homedir() + filePath.slice(1); | ||
| } | ||
| return filePath; | ||
| } |
There was a problem hiding this comment.
this expandHomeDir function duplicates code from src/media-understanding/runner.ts:114 and uses a different implementation (string concatenation vs path.join). the existing implementation is more robust. also, this home directory expansion feature isn't mentioned in the PR description at all.
| function expandHomeDir(filePath: unknown): unknown { | |
| if (typeof filePath !== "string") { | |
| return filePath; | |
| } | |
| if (filePath === "~") { | |
| return os.homedir(); | |
| } | |
| if (filePath.startsWith("~/")) { | |
| return os.homedir() + filePath.slice(1); | |
| } | |
| return filePath; | |
| } | |
| // TODO: Extract to shared utility and use path.join for robustness | |
| // See existing implementation in src/media-understanding/runner.ts:114 | |
| function expandHomeDir(filePath: unknown): unknown { | |
| if (typeof filePath !== "string") { | |
| return filePath; | |
| } | |
| if (filePath === "~") { | |
| return os.homedir(); | |
| } | |
| if (filePath.startsWith("~/")) { | |
| return path.join(os.homedir(), filePath.slice(2)); | |
| } | |
| return filePath; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-tools.read.ts
Line: 417-428
Comment:
this `expandHomeDir` function duplicates code from `src/media-understanding/runner.ts:114` and uses a different implementation (string concatenation vs `path.join`). the existing implementation is more robust. also, this home directory expansion feature isn't mentioned in the PR description at all.
```suggestion
// TODO: Extract to shared utility and use path.join for robustness
// See existing implementation in src/media-understanding/runner.ts:114
function expandHomeDir(filePath: unknown): unknown {
if (typeof filePath !== "string") {
return filePath;
}
if (filePath === "~") {
return os.homedir();
}
if (filePath.startsWith("~/")) {
return path.join(os.homedir(), filePath.slice(2));
}
return filePath;
}
```
How can I resolve this? If you propose a fix, please make it concise.path.resolve() treats ~ as a literal directory name, so ~/file.txt resolved to <cwd>/~/file.txt instead of /home/user/file.txt. Add expandHomePrefix() before path.resolve() in all affected tool path resolution points: - Host write operations (mkdir, writeFile) in non-workspace mode - Host edit operations (readFile, writeFile, access) in non-workspace mode - toRelativePathInRoot() workspace boundary validator (affects all tools) - resolveWorkdir() for non-sandbox exec/bash working directory - parseSandboxBindMount() for Docker bind mount host paths The host read tool and sandbox tools already handled tilde via the upstream library's expandPath(). Closes openclaw#30669 Related: openclaw#30782, openclaw#30788, openclaw#30744, openclaw#30770, openclaw#30756, openclaw#30753, openclaw#30752, openclaw#30747
path.resolve() treats ~ as a literal directory name, so ~/file.txt resolved to <cwd>/~/file.txt instead of /home/user/file.txt. Add expandHomePrefix() before path.resolve() in all affected tool path resolution points: - Host write operations (mkdir, writeFile) in non-workspace mode - Host edit operations (readFile, writeFile, access) in non-workspace mode - toRelativePathInRoot() workspace boundary validator (affects all tools) - resolveWorkdir() for non-sandbox exec/bash working directory - parseSandboxBindMount() for Docker bind mount host paths The host read tool and sandbox tools already handled tilde via the upstream library's expandPath(). Closes openclaw#30669 Related: openclaw#30782, openclaw#30788, openclaw#30744, openclaw#30770, openclaw#30756, openclaw#30753, openclaw#30752, openclaw#30747
path.resolve() treats ~ as a literal directory name, so ~/file.txt resolved to <cwd>/~/file.txt instead of /home/user/file.txt. Add expandHomePrefix() before path.resolve() in all affected tool path resolution points: - Host write operations (mkdir, writeFile) in non-workspace mode - Host edit operations (readFile, writeFile, access) in non-workspace mode - toRelativePathInRoot() workspace boundary validator (affects all tools) - resolveWorkdir() for non-sandbox exec/bash working directory - parseSandboxBindMount() for Docker bind mount host paths The host read tool and sandbox tools already handled tilde via the upstream library's expandPath(). Closes openclaw#30669 Related: openclaw#30782, openclaw#30788, openclaw#30744, openclaw#30770, openclaw#30756, openclaw#30753, openclaw#30752, openclaw#30747
path.resolve() treats ~ as a literal directory name, so ~/file.txt resolved to <cwd>/~/file.txt instead of /home/user/file.txt. Add expandHomePrefix() before path.resolve() in all affected tool path resolution points: - Host write operations (mkdir, writeFile) in non-workspace mode - Host edit operations (readFile, writeFile, access) in non-workspace mode - toRelativePathInRoot() workspace boundary validator (affects all tools) - resolveWorkdir() for non-sandbox exec/bash working directory - parseSandboxBindMount() for Docker bind mount host paths The host read tool and sandbox tools already handled tilde via the upstream library's expandPath(). Closes openclaw#30669 Related: openclaw#30782, openclaw#30788, openclaw#30744, openclaw#30770, openclaw#30756, openclaw#30753, openclaw#30752, openclaw#30747
path.resolve() treats ~ as a literal directory name, so ~/file.txt resolved to <cwd>/~/file.txt instead of /home/user/file.txt. Add expandHomePrefix() before path.resolve() in all affected tool path resolution points: - Host write operations (mkdir, writeFile) in non-workspace mode - Host edit operations (readFile, writeFile, access) in non-workspace mode - toRelativePathInRoot() workspace boundary validator (affects all tools) - resolveWorkdir() for non-sandbox exec/bash working directory - parseSandboxBindMount() for Docker bind mount host paths The host read tool and sandbox tools already handled tilde via the upstream library's expandPath(). Closes openclaw#30669 Related: openclaw#30782, openclaw#30788, openclaw#30744, openclaw#30770, openclaw#30756, openclaw#30753, openclaw#30752, openclaw#30747
path.resolve() treats ~ as a literal directory name, so ~/file.txt resolved to <cwd>/~/file.txt instead of /home/user/file.txt. Add expandHomePrefix() before path.resolve() in all affected tool path resolution points: - Host write operations (mkdir, writeFile) in non-workspace mode - Host edit operations (readFile, writeFile, access) in non-workspace mode - toRelativePathInRoot() workspace boundary validator (affects all tools) - resolveWorkdir() for non-sandbox exec/bash working directory - parseSandboxBindMount() for Docker bind mount host paths The host read tool and sandbox tools already handled tilde via the upstream library's expandPath(). Closes openclaw#30669 Related: openclaw#30782, openclaw#30788, openclaw#30744, openclaw#30770, openclaw#30756, openclaw#30753, openclaw#30752, openclaw#30747
|
Superseded by a landed fix on .
Closing this PR as duplicate/superseded to keep one canonical patch path. |
|
Correction with exact refs:
|
path.resolve() treats ~ as a literal directory name, so ~/file.txt resolved to <cwd>/~/file.txt instead of /home/user/file.txt. Add expandHomePrefix() before path.resolve() in all affected tool path resolution points: - Host write operations (mkdir, writeFile) in non-workspace mode - Host edit operations (readFile, writeFile, access) in non-workspace mode - toRelativePathInRoot() workspace boundary validator (affects all tools) - resolveWorkdir() for non-sandbox exec/bash working directory - parseSandboxBindMount() for Docker bind mount host paths The host read tool and sandbox tools already handled tilde via the upstream library's expandPath(). Closes openclaw#30669 Related: openclaw#30782, openclaw#30788, openclaw#30744, openclaw#30770, openclaw#30756, openclaw#30753, openclaw#30752, openclaw#30747
path.resolve() treats ~ as a literal directory name, so ~/file.txt resolved to <cwd>/~/file.txt instead of /home/user/file.txt. Add expandHomePrefix() before path.resolve() in all affected tool path resolution points: - Host write operations (mkdir, writeFile) in non-workspace mode - Host edit operations (readFile, writeFile, access) in non-workspace mode - toRelativePathInRoot() workspace boundary validator (affects all tools) - resolveWorkdir() for non-sandbox exec/bash working directory - parseSandboxBindMount() for Docker bind mount host paths The host read tool and sandbox tools already handled tilde via the upstream library's expandPath(). Closes openclaw#30669 Related: openclaw#30782, openclaw#30788, openclaw#30744, openclaw#30770, openclaw#30756, openclaw#30753, openclaw#30752, openclaw#30747
Summary
Fixes #30724
The Edit tool returned a misleading 'File not found' error when given an absolute path outside the agent's workspace root (e.g. an orchestrator agent editing a file in a sub-agent workspace). The Write tool correctly emits 'Path escapes workspace root' in the same situation.
Root Cause
pi-coding-agent'screateEditToolwraps any error thrown fromops.access()asFile not found:In the workspace-only path of
createHostEditOperations,access()calledtoRelativePathInRoot()before its own try/catch. For outside-workspace paths,toRelativePathInRoot()throws "Path escapes workspace root: …" — which propagated out ofaccess()and got silently swallowed by the pi-coding-agent wrapper into "File not found".Fix
When
toRelativePathInRoot()throws (direct outside-workspace path),access()now only verifies the file exists on disk via plainfs.access()and returns successfully. The next step —readFile()— callstoRelativePathInRoot()itself, which throws "Path escapes workspace root". The pi-coding-agent outer catch handler propagates this error verbatim.Symlink-escape detection (where
toRelativePathInRoot()succeeds butopenFileWithinRoot()detects an outside-workspace symlink) is unchanged —access()still throws EACCES for that case.Changes
src/agents/pi-tools.read.ts: IncreateHostEditOperations, catch thetoRelativePathInRoot()throw inaccess(), do a plain existence check, and defer workspace enforcement toreadFile().src/agents/pi-tools.read.host-edit-access.test.ts: Add regression test verifyingaccess()resolves andreadFile()rejects with "Path escapes workspace root" for a direct outside-workspace path.Testing
Change Type
Scope
User-visible / Behavior Changes
Edit tool now returns "Path escapes workspace root" error instead of misleading "File not found" when given paths outside the workspace root.
Compatibility / Migration