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.
Summary
read_filerefuses to read outside the granted roots.run_shelldoes not, so an allowlistedcatreturns exactly what the file tool declines to hand over - with no approval prompt.This is the same class as #189 (
browser_upload_filecan exfiltrate any readable path) and #293 (browser_screenshotwrites outside the session roots), for the tool with the widest reach. #289's own docstring names the general shape of it:run_shellis invisible to it for the same reason: there is noarguments["path"]to inspect, onlyarguments["command"].Reproduction
Workspace at
/tmp/ws, a private key outside it,allowed_commands = ["cat", "grep", "ls"]:Same bytes, same session, two different answers depending on which tool the model reaches for.
read_fileblocks the traversal spelling too, so this is not an oversight in one direction - the boundary is deliberate and enforced incoworker/tools/files.py:Where
coworker/permissions.py-PermissionEngine.evaluateWrites are scoped. Reads are not scoped here at all -
read_fileis classified non-consequential and returns atDecision(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 -
rg '_under_root\b'returns exactly one hit: the definition. It has never been called. Only_under_writable_rootis 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
catis 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.tomlrecommendscat,grep,findandls, and a trusted workspace's own.coworker/config.tomlcan append entries. A repository can proposecat, 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_roothelper 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.