Guards
The guard hooks tool.execute.before and throws. A denied call cannot be reasoned past, which is the point: instructions inAGENTS.md asking an agent to be careful are not enforcement.
The rules
Always deniedbrowser_close
Tears down the shared browser. Every other instance attached to it loses their tabs mid-task, and there is no way to tell how much work that was.
Never the last tabbrowser_tabs close
The same failure by another route. Closing the final page makes the browser exit, so the guard refuses any index that resolves to it.
Explicit index requiredbrowser_tabs close
A bare close carries no intent. Without an index the guard cannot reason about which tab would go, so it declines rather than guess.
Denied while any foreign tab existsbrowser_tabs close
The MCP tab index does not map onto a CDP target, and close accepts no target ID — so ownership of one specific tab is unverifiable. Closing is allowed only when every live tab is claimed by this session or inert.
Claims expire after 15 mintool.execute.before
Ownership lives only in the agent process. A stale claim from a finished instance would otherwise block closes forever, so it ages out and the tab counts as unowned again.
Blank tabs are ignoredbrowser_tabs close
The browser always keeps one seed about:blank that no session claims. Treating it as foreign would deny every close permanently.
Fails closedbrowser_tabs close
If CDP cannot be reached, live tabs cannot be enumerated, so no close can be verified. Unverifiable means denied.
Own tabs are closablebrowser_tabs close
When every live tab belongs to this session, whichever one the index resolves to is ours by definition.
Why ownership cannot be checked per tab
The obvious implementation is: keep a list of tabs this session opened, then refuse to close anything not on it. That silently closes the wrong tab, because the MCP's tab index and CDP's target list are not the same ordering.
two tabs, two orderings
MCP reports CDP /json/list
[0] pricing [0] about
[1] about [1] pricingLooking an MCP index up in the CDP array validates whichever tab happens to sit at that position in the other list. Our first version did exactly this and closed an unowned about:blank while its own log reported closing an owned one.
browser_tabs close accepts no target ID, so there is no way to address one specific tab and prove it is yours. The guard's answer is to allow a close only when every live tab is either claimed by this session or inert — then whatever closes is ours by definition. Conservative, but never wrong.
What a denial looks like
BLOCKED by browser-guard: 2 tab(s) here are not owned by this
session (http://localhost:3000, http://localhost:3001), so they belong to
another opencode instance. The MCP's tab index does not map reliably onto a
CDP target, so a close could destroy another instance's work. Close tabs only
when you are the sole instance using this browser; otherwise leave your tabs
open for the user to tidy.Denials explain themselves rather than just refusing, because the legitimate response is usually to stop tidying up, not to retry.
Claim expiry
Ownership lives only in the agent process. A claim from a session that has since exited would otherwise block closes forever, with no way to clear it. So claims age out after BROWSER_GUARD_TTL_MS (15 minutes by default) and the tab counts as unowned again.
The direction is deliberate: an expired claim becomes foreign, notours. A stale claim must never be the justification for closing something.
Fails closed
If CDP cannot be reached, live tabs cannot be enumerated, so no close can be verified. The guard denies rather than allowing on a guess. Restart the browser with ~/bin/chrome-cdp-profile rather than working around it.
Audit log
Every allow and deny is appended to~/.local/share/opencode/mcp-logs/browser-guard.log with a timestamp and the session id.