security: confine the browser connector's file tools to the session roots - #289
security: confine the browser connector's file tools to the session roots#289Mr-Neutr0n wants to merge 2 commits into
Conversation
|
For whoever picks these up — I opened five security PRs today and want to be upfront about how they relate, since three of them touch overlapping files.
Two of the five have companion issues with the analysis written up (#293 for the screenshot half of this one, #292 for #290), in case you'd rather triage from there. I also filed #296, which is a design question rather than a bug and needs your call, not a patch. Happy to rebase, split, or squash any of these into whatever shape suits your review flow. |
…oots
The permission engine path-scopes only the built-in WRITE_TOOLS, and only by
reading arguments["path"] (PermissionEngine.evaluate). A connector tool that
touches the local filesystem is therefore invisible to it. Two of the browser
tools do, in both directions:
browser_upload_file(target, path)
Path(path).expanduser().resolve() with no containment, then hands the
bytes to whatever page is loaded. Any readable file on the machine can be
posted to a remote site: ~/.ssh/id_rsa, ~/.aws/credentials, or the
OpenWorker secret store itself. This is issue andrewyng#189.
browser_screenshot(path)
the same unconfined resolve, then out.parent.mkdir(parents=True) and a
write. That is an arbitrary file overwrite plus directory creation
anywhere the process can reach. Not previously reported.
Both are approval-gated in Interactive mode, so this is not a silent
compromise there. It is still the wrong boundary: in Auto mode there is no
prompt at all, and even with a prompt the tools reach files the user never
granted the session, which is exactly what the roots list exists to prevent.
The fix follows the confinement email_tools already applies to outgoing
attachments (email_tools.py:640): resolve, then require the result to sit
inside a granted root. Reads accept any root; the screenshot write requires a
writable one. make_integration_tools already receives roots for precisely this
reason - browser tools simply were not passed them.
Containment is now decided before the browser layer is touched, so a refused
screenshot no longer creates its parent directories on the way to failing, and
a refused upload does not stat the path (the error cannot be used to test for
the existence of ungranted files).
Behaviour is unchanged for paths inside the session, and the default
screenshot location (the temp file) is untouched.
Tests: tests/test_browser_path_confinement.py - traversal, symlink escape,
read-only root rejected for writes, no-roots denies, and refusal ordering.
9b7cc6a to
cd85b1e
Compare
browser_screenshot's default location was a fixed name in the shared temp dir:
Path(tempfile.gettempdir()) / "coworker-browser-screenshot.png"
Two problems on a multi-user host (Linux /tmp is shared; macOS gives each uid
its own, so this is platform-dependent):
- the path is predictable, so another local user can pre-create it as a
symlink and the screenshot write follows it
- the image is created at the umask default. A full-page screenshot of
whatever the agent is driving may show a logged-in inbox, a Slack channel or
a bank page, left group/world readable
tempfile.mkstemp gives a random name created 0600 with O_EXCL, so neither
holds. Callers that pass an explicit path are unaffected (that path is
root-confined by the preceding commit).
Stacked on security/browser-path-confinement (andrewyng#289) because it edits the same
function.
Tests: extends tests/test_browser_path_confinement.py - two calls get two
distinct files, both owner-only.
|
Rebased all five of these onto current The reason, in case the force-push looked arbitrary: New heads: #289 #297 is still exactly one commit on top of #289, so the stacking note above still holds. Each branch's own tests re-run green after the rebase (11 / 24 / 7 / 5 / 12). |
# Conflicts: # coworker/connectors/browser_automation.py
Fixes #189, and fixes a second instance of the same root cause that hadn't been reported.
The gap
PermissionEngine.evaluatepath-scopes only the built-inWRITE_TOOLS, and only by readingarguments["path"]:classify()resolves connector tools toEXTERNALorREAD, neverWRITE_LOCAL, so a connector tool that touches local files is invisible to the roots mechanism. Two browser tools do, in opposite directions:browser_upload_file(target, path)Path(path).expanduser().resolve(), no containment, then hands the bytes to whatever page is loadedbrowser_screenshot(path)out.parent.mkdir(parents=True)and a writeThe first is an arbitrary-read → exfiltration primitive (
~/.ssh/id_rsa,~/.aws/credentials, orsecrets.jsonitself). The second is an arbitrary file overwrite plus directory creation anywhere the process can reach.Scope, stated honestly: both are approval-gated in Interactive mode, so this is not a silent compromise there. It is still the wrong boundary. In Auto mode there is no prompt at all, and even with a prompt the tools reach files the user never granted the session — which is what the roots list exists to prevent. Given the agent's input is untrusted by design (it reads web pages, email, Slack), an approval dialog naming a plausible-looking path is thin protection.
The fix
make_integration_toolsalready receivesroots, with this comment:The browser tools simply weren't passed them. This threads
rootsthrough and reuses the confinementemail_toolsalready applies to outgoing attachments (email_tools.py:640): resolve first, then require the result inside a granted root. Reads accept any root; the screenshot write requires a writable one.Containment is now decided before the browser layer is touched, which fixes two ordering problems:
Paths inside the session behave exactly as before, and the default screenshot location (the temp file) is untouched.
Tests
tests/test_browser_path_confinement.py— 11 cases:..traversal, symlink escaping a root, write refused into a read-only root, no-roots denies everything, refusal ordering, and constructibility without roots for CLI callers.Verified against the unpatched tree: the only thing preventing the upload there was Playwright being absent, not any security check.
Full suite: 927 passed. The 14 failures on this branch reproduce identically on a clean checkout —
test_fake_slack.py(dead-port timeouts, being fixed in #252) andtest_bedrock_provider.py(the missingbedrockextra, #284/#285).Reviewed and tested locally; drafted with AI assistance.