Skip to content

[Security] read_file is confined to the granted roots; an allowlisted cat via run_shell is not #309

Description

@Saidheerajgollu

Summary

read_file refuses to read outside the granted roots. run_shell does not, so an allowlisted cat returns exactly what the file tool declines to hand over - with no approval prompt.

This is the same class as #189 (browser_upload_file can exfiltrate any readable path) and #293 (browser_screenshot writes outside the session roots), for the tool with the widest reach. #289's own docstring names the general shape of it:

The permission engine only path-scopes the built-in WRITE_TOOLS by inspecting arguments["path"], so a connector tool that touches the filesystem is invisible to it.

run_shell is invisible to it for the same reason: there is no arguments["path"] to inspect, only arguments["command"].

Reproduction

Workspace at /tmp/ws, a private key outside it, allowed_commands = ["cat", "grep", "ls"]:

read_file(path="<secret>/id_rsa")            -> {'error': 'path escapes the workspace'}
read_file(path="../../../<secret>/id_rsa")   -> {'error': 'path escapes the workspace'}

cat <secret>/id_rsa                          -> AUTO-RUN, no prompt
cat /etc/passwd                              -> AUTO-RUN, no prompt
grep -r BEGIN <secret>                       -> AUTO-RUN, no prompt
ls <secret>                                  -> AUTO-RUN, no prompt

Same bytes, same session, two different answers depending on which tool the model reaches for. read_file blocks the traversal spelling too, so this is not an oversight in one direction - the boundary is deliberate and enforced in coworker/tools/files.py:

target = (root / path).resolve()
try:
    target.relative_to(root)  # keep reads inside the workspace
except ValueError:
    return {"error": "path escapes the workspace"}

Where

coworker/permissions.py - PermissionEngine.evaluate

# Path scoping for writes that name a path (all modes): must land in a writable root.
if is_write:
    path = arguments.get("path")
    if path is not None and not self._under_writable_root(path):
        return Decision(False, f"path is not in a writable directory: {path}")

Writes are scoped. Reads are not scoped here at all - read_file is classified non-consequential and returns at Decision(True, "low risk") before any path check, relying entirely on its own confinement. And the shell reaches the allowlist branch, where being on the list says the program is safe to run unattended but says nothing about where it is pointed.

Worth noting: the engine already has the correct helper for this, resolving against every granted root rather than just the workspace -

def _under_root(self, path: str) -> bool:
    candidate = self._candidate(path)
    for rp, _ in self._resolved_roots():
        ...

rg '_under_root\b' returns exactly one hit: the definition. It has never been called. Only _under_writable_root is wired up, for writes. That reads less like a missing idea than an unfinished one.

Why it matters here specifically

The privacy claim in the README is that data does not leave the machine except through a provider or integration the user chooses. Anything the shell reads goes into the transcript and therefore to the model provider on the next turn. So an allowlisted cat is not only a local read - it is an egress path for ~/.ssh/id_rsa, ~/.aws/credentials, or the secret store itself, with no prompt at any point.

It also composes badly with the two paths that populate an allowlist (see #308): docs/config.example.toml recommends cat, grep, find and ls, and a trusted workspace's own .coworker/config.toml can append entries. A repository can propose cat, and after one coarse "trust this workspace" decision, reads anywhere on the disk are unattended.

Suggested fix

Withhold auto-run (not the command) when an allowlisted command names a path outside every granted root, by calling the _under_root helper that is already there. The user can still approve it, so a false positive costs one prompt.

#310 does this. 11/11 escape shapes stop auto-running, including the traversal spelling, a symlink planted in the workspace that resolves out of it, and a path hidden in --file=/etc/passwd; 16/16 ordinary reads are unaffected, including absolute paths inside the workspace and reads inside a second granted root. Patterns and subcommands ('*.py', status, install) are not treated as paths, which is what keeps the false-positive rate at zero on the benign set.

Independent of #307/#308 - that one is about which program may run unattended, this one is about where it may be pointed. Different axis, no overlap in the diff.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions