Sibling of #189 (browser_upload_file), same root cause, opposite direction: that one is an arbitrary read, this is an arbitrary write.
The code
coworker/connectors/browser_automation.py:
def browser_screenshot(path: str = "") -> dict[str, Any]:
def run(page):
out = (Path(path).expanduser() if path
else Path(tempfile.gettempdir()) / "coworker-browser-screenshot.png")
out = out.resolve()
out.parent.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(out), full_page=True)
No containment. out.parent.mkdir(parents=True) then a write, anywhere the process can reach.
Why the permission engine doesn't catch it
PermissionEngine.evaluate path-scopes only when is_write, and is_write is RiskClass.WRITE_LOCAL, which classify() assigns by name from a fixed set:
WRITE_TOOLS = {"write_file", "replace_in_file", "apply_patch", "apply_unified_diff"}
Connector tools resolve to EXTERNAL or READ, never WRITE_LOCAL, so the roots check never runs for them. The scoping is also keyed to arguments["path"] specifically, so any write tool naming its argument differently is invisible too.
Impact
Arbitrary file overwrite (destroying ~/.ssh/id_rsa, config files) and arbitrary directory-tree creation. Content is a PNG, so this is destructive rather than a direct code-execution primitive.
Approval-gated in Interactive mode, so not a silent compromise there — but in Auto mode there's no prompt, and even with one the tool reaches files the user never granted the session, which is what the roots list exists to prevent. The mkdir also happened before any error, so a refused call still left directories behind.
Suggested fix
make_integration_tools already receives roots, with the comment:
Email needs the session roots: attachment downloads land in the primary scratch and outgoing attachments must resolve inside a granted directory.
The browser tools just aren't passed them. Reuse the confinement email_tools.py:640 already applies, requiring a writable root for the screenshot write.
PR #289 does this for both browser_screenshot and browser_upload_file (#189), and moves the check ahead of the browser call so a refusal no longer creates directories on its way to failing.
Found during a security review of the repo; drafted with AI assistance.
Sibling of #189 (
browser_upload_file), same root cause, opposite direction: that one is an arbitrary read, this is an arbitrary write.The code
coworker/connectors/browser_automation.py:No containment.
out.parent.mkdir(parents=True)then a write, anywhere the process can reach.Why the permission engine doesn't catch it
PermissionEngine.evaluatepath-scopes only whenis_write, andis_writeisRiskClass.WRITE_LOCAL, whichclassify()assigns by name from a fixed set:Connector tools resolve to
EXTERNALorREAD, neverWRITE_LOCAL, so the roots check never runs for them. The scoping is also keyed toarguments["path"]specifically, so any write tool naming its argument differently is invisible too.Impact
Arbitrary file overwrite (destroying
~/.ssh/id_rsa, config files) and arbitrary directory-tree creation. Content is a PNG, so this is destructive rather than a direct code-execution primitive.Approval-gated in Interactive mode, so not a silent compromise there — but in Auto mode there's no prompt, and even with one the tool reaches files the user never granted the session, which is what the roots list exists to prevent. The
mkdiralso happened before any error, so a refused call still left directories behind.Suggested fix
make_integration_toolsalready receivesroots, with the comment:The browser tools just aren't passed them. Reuse the confinement
email_tools.py:640already applies, requiring a writable root for the screenshot write.PR #289 does this for both
browser_screenshotandbrowser_upload_file(#189), and moves the check ahead of the browser call so a refusal no longer creates directories on its way to failing.Found during a security review of the repo; drafted with AI assistance.