Skip to content

[Bug]: Built-in file tools (read/write/edit) are not contained to the workspace root by default #310

Description

@Jiangrong-W

What happened?

The built-in file tools resolve a model-supplied path to an absolute host path and then read or write it directly, without any check that the resolved path stays inside the workspace (repository) root. This is a missing defense-in-depth boundary rather than a sandbox escape: workspace containment is only enforced inside the is_docker_sandbox_active() branch (which calls validate_sandbox_path), and the Docker sandbox is disabled by default (SandboxSettings.enabled = False). So in the default, unsandboxed configuration there is no fail-safe boundary keeping these tools inside the repository, and a model that supplies an absolute path or a ../ traversal can be steered (e.g. by prompt injection or a simple mistake) into reaching files outside the intended working directory.

Affected component: the file-tool family in src/openharness/tools/. Decisive sinks, one per path:

  • read_filesrc/openharness/tools/file_read_tool.py, the path.read_bytes() sink (around line 52). Read-only tools are auto-allowed by PermissionChecker.evaluate after only the credential denylist / optional path rules, so an out-of-workspace read returns the file body with no prompt.
  • write_filesrc/openharness/tools/file_write_tool.py, the path.write_text(...) sink (around line 59).
  • edit_filesrc/openharness/tools/file_edit_tool.py, the path.write_text(updated, ...) sink (present in both the sandbox and non-sandbox branches).

For write_file / edit_file, the interactive UI does gate writes behind a permission prompt and an edit-diff approval. However, the shipped non-interactive runners — run_task_worker (used for background teammates, which SubprocessBackend.spawn_teammate launches with --task-worker) and run_print_mode — install a permission callback that always returns True and pass no edit-approval prompt. On that auto-approved path the prompt/approval no longer stands between the tool and the sink, so the missing workspace boundary is reachable for writes/edits as well, not just reads.

Expected behavior: by default, read_file / write_file / edit_file should be contained to the workspace root, so a resolved path that lands outside the repository (via an absolute path, ../ traversal, or a symlink that escapes) is refused with a clear error before any read or write happens — independent of whether the Docker sandbox is active and independent of the approval path. Operators who legitimately need to reach outside the repo should be able to opt out or widen the allowed roots explicitly, rather than that being the silent default.

Steps to reproduce

This is the general shape of the gap (kept deliberately non-weaponized — no specific target files):

  1. Run OpenHarness in its default configuration (Docker sandbox disabled, SandboxSettings.enabled = False).
  2. Read path: have the agent call read_file with a path that resolves outside the workspace root — e.g. an absolute path, or a relative path containing enough ../ segments to climb above the repository. Because read-only tools are auto-allowed, the call returns the out-of-workspace file's contents with no prompt.
  3. Write/edit path: run a flow that uses a non-interactive runner (for example a background teammate spawned with --task-worker, or print mode), and have the agent call write_file / edit_file with a path that resolves outside the workspace root. Because those runners auto-approve, the out-of-workspace write/edit succeeds.

In all three cases the operation completes (the tool reports success) even though the target is outside the intended workspace. The expectation is that it should be refused by default.

Environment

  • OpenHarness: current main (reproduced against commit 9b2efd795c6aa09f88b0c257d269a9e518da6ae7).
  • OS / Python: not environment-specific. The gap is in the path-resolution logic of the file tools, not tied to a particular OS or Python version. I observed it on macOS with the repo's uv sync --extra dev toolchain; it should reproduce anywhere the tools run, since the missing check is platform-independent.
  • Provider / base URL: not relevant — the issue is in tool path handling, independent of the model provider.
  • Install method: git clone + uv sync --extra dev per CONTRIBUTING.

Note: I'm filing this as a design/defense-in-depth gap, so a single exact "works only on my box" environment isn't the point — the missing containment check is the same regardless of OS/Python/provider. Flagging that the environment fields above are best-effort rather than a narrow reproduction box.

Relevant logs or screenshots

# No stack trace or error is produced — that is precisely the problem:
# the out-of-workspace read / write / edit completes "successfully"
# (the tool reports is_error=false) instead of being refused.
#
# Suggested hardening (direction only):
#   - Add an always-on workspace-containment check for read_file / write_file /
#     edit_file, mirroring the existing validate_sandbox_path boundary, applied
#     before the read/write sink and before the approval/auto-approve branch
#     (so it fails closed even on the non-interactive auto-approved runners).
#   - Reuse the repo's existing real-path-resolution + ancestor check, which
#     already rejects absolute-outside paths, ".." escapes, and symlink escapes.
#   - Keep it on by default, with an explicit operator opt-out / allow-list for
#     reaching outside the repository when intentionally needed.
#
# I've prepared a fix along these lines and can open a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions