In short: reading and clicking need no approval, anything that commits something or sends data somewhere pauses and asks, and a handful of things are refused whatever you say.
In the panel
When a run reaches a gated action, it stops and shows a card naming what it was about to do and why that is gated. The card has three buttons:
| Allow | Once. |
| Deny | Final. The agent is told to report it and stop, and not to find another route to the same effect. |
| Always on ‹host› | Allow, and stop asking for that action on that host. |
Always on ‹host› is for a prompt you keep clicking through, such as a captcha checkbox on a site
you use daily or Enter-to-submit on a search box. The grant is a pair, one action and one host,
keyed to the site the run started on. It is written to ~/.browsentic/approvals.json (mode 0600),
survives restarts, and only ever short-circuits a confirm. A deny stays denied, because
denies are the ones you are not meant to be able to click past. The button is hidden when a run has
no single site to attach a grant to.
A card the agent stops waiting for is withdrawn. If the agent's CLI gives up on the call, the run is stopped, or the agent moves on to something else, the card comes down and an answer after that does nothing: an approval never runs an action the agent is no longer waiting on. Cursor CLI gives up on any call after a minute, so there an unanswered card is handed back to the agent, which asks again under the same card until you decide.
browsentic approvals # what no longer asks
browsentic approvals clear # forget all of them
browsentic approvals clear a.com # forget one site's
The rules
The policy is a table of rules, each naming a condition and an effect. Every rule whose condition matches is collected and the most severe effect wins, so the outcome does not depend on declaration order.
| Rule id | Fires when | Default |
|---|---|---|
reserved-action |
An internal browsentic.* verb is called from outside |
deny |
non-http-navigation |
A javascript:, data: or file: URL dressed up as a navigation |
deny |
unreadable-navigation |
A URL that resolves to no destination Browsentic can check | deny |
raw-html-read |
page_extractText with format: "html" |
deny |
network-body-read |
page_readNetwork with includeBodies: true |
deny |
off-scope-navigation |
Navigating off the sites this run is about, however the URL is spelled | confirm |
url-payload |
A navigation whose query string or fragment exceeds urlPayloadBytes (512 by default) |
confirm |
form-submission |
Anything that commits a form, however spelled | confirm |
site-tool-call |
page_callSiteTool: running a tool the site registered through WebMCP |
confirm |
file-upload |
page_attachFile: putting one of your files into a page |
confirm |
file-download |
page_captureDownload: letting a page write a file to your disk |
confirm |
leaves-pinned-tab |
Moving to a tab the run was not pointed at | confirm |
captcha-solve |
page_solveCaptcha: ticking a site's "I am a human" box and answering its image challenge; one yes covers the rest of the run |
confirm |
code-injection |
page_injectCode: installing JavaScript the agent wrote into the page |
confirm |
external-code-injection |
page_injectCode called by an MCP client rather than the side panel |
deny |
external-code-execution |
page_runCode called by an MCP client rather than the side panel |
deny |
secret-in-url |
A saved secret placed in a navigation URL | deny |
secret-release |
A saved secret about to be typed into the page | confirm |
secret-off-scope |
…and it was read on a site outside this run's scope | confirm |
config-require-approval |
The action is named in your requireApproval list |
confirm |
Five of these need more explanation:
form-submission matches more than the submit tool. It also catches page_fillInput and
page_typeText with pressEnter: true, and page_pressKey with Enter, because those submit
forms too.
file-download only decides whether the capture happens. What may be kept is not a
preference: executables, files over 100 MB, and downloads from a host outside the run's scope are
refused whatever this rule is set to, and the file the browser already wrote is deleted. See
Files.
code-injection is the one prompt that shows the code, and the one capability that is off until
you switch it on. The composer's Live tool switch starts off, and while it is off
page_injectCode is refused outright, before this rule is consulted. With it on, approving means
running JavaScript the agent wrote in the page, with your session, so the panel puts a Review
button on the prompt that opens the full source before you decide. There is no "always on this
site" for it, because that would let later, unread code run on your approval of earlier code. One
approval covers every later page_runCode call into that toolkit, on that tab and that site,
which is what makes twenty repetitions cheap; a different script is a new prompt. See
Page actions.
raw-html-read is denied by default because outerHTML carries comments, aria-hidden nodes
and off-screen text: everything a page can hide from the person looking at it but still hand to the
model. The default rendered-text format drops those. Set it to allow if a run genuinely needs
markup.
network-body-read is denied by default because a response body is the richest credential
surface a page has: session tokens, API keys and other people's personal data, in bulk. Request and
response metadata (method, URL, status, timing) needs no approval, and headers come back
sanitized when asked for; between them they answer nearly every real
"why did that fail?". Reading the body goes well past diagnosing. Set it to allow if a run
genuinely needs payloads. See Diagnostics.
Scope: which sites a run may reach
A run's scope, which off-scope-navigation checks against, is derived once when the run starts,
from things you control:
- the host of the tab it started on,
- any host you named in your own instruction ("check the pricing on stripe.com"),
- anything in
guardrails.hostsin your config.
It never widens on its own, and nothing read from a page can widen it. A run that starts nowhere in particular (a blank tab, no host named) is unconfined, because failing closed there would block "search for X" on an empty tab.
example.com in scope covers www.example.com and app.example.com. ["*"] in
guardrails.hosts disables host confinement entirely.
Sites that are never in scope
The Bridge works out scope per run, and config.json can widen it. For a site the agent must never
touch at all, use Blocked sites instead: the extension enforces that
list itself, before any of these rules run, and nothing on the Bridge's side can read or change it.
A run started from a blocked tab does not send its address, so it begins unconfined, as from a
blank tab, but every action on the blocked site is still refused.
Callers with nobody to ask
A client on the optional MCP endpoint (MCP clients) has no approval channel. So
for an external caller, confirm resolves to deny, with a message telling the agent the action
is only available from the side panel, where you can see and answer it.
This is deliberate. The client's own permission prompts stop protecting you the moment someone allowlists the browsentic tools to stop being asked. To waive the denial anyway, and go back to the client's permissions being the only gate, set:
{ "guardrails": { "unattended": "allow" } }
Tools the user kept
A toolkit the user saved from the keep prompt runs from / with no approval at all: its code was
read and approved when it was kept, so asking again would ask about something already answered.
What holds it in place is the saved record itself, which pins the origin and the path segment it was
approved on and re-checks both before it installs anything.
The Bridge is not involved. The code lives in extension storage, the run goes from the side panel
straight to the tab, and the Bridge holds only a markdown note that the tool exists. There is no
action for it, so no MCP client can reach it, with or without unattended.
unattended does not waive a deny. That is why the two live-tool rules for external callers deny
rather than confirm: a client allowlisting the Browsentic tools is exactly the setup where
unattended: allow gets turned on, and a client's own prompt is in no position to judge code
written into your logged-in page.
Changing the policy in config.json
In ~/.browsentic/config.json:
Gate more actions. Add to requireApproval:
{ "requireApproval": ["page.submitForm", "page.closeTab"] }
Change a rule's effect. Set it by id; this applies to external clients as well:
{ "guardrails": { "rules": { "captcha-solve": "allow", "off-scope-navigation": "deny" } } }
Turn the form gate off completely. Set requireApproval: []. The legacy key owns that rule, so
an empty list means "gate nothing".
One caution against a long list: a prompt you see on every other tool call is a prompt you stop reading, and then the gate no longer tells you anything.
The settings page
These settings can be changed without opening a file, from two places that edit the same rows:
- The settings page, under Guardrails. Open it with the sliders button in the side panel's header or the popup, or right-click the toolbar icon → Options.
- The Mac app's Settings tab, in its Guardrails card.
A change made in one shows up in the other at once, and so does a hand edit of the file.
The screen is a list of overrides of the defaults Browsentic ships, not switches that turn protection on. Every row starts off, meaning "use the default", so a fresh install has an empty settings page and behaves as this page describes whether or not you ever open it.
Turning a row on reveals Allow / Ask / Block and writes that one line to
~/.browsentic/config.json. Turning it back off removes the line rather than writing a value equal
to the default, so your config file names only decisions you actually made, and a later change to a
shipped default still reaches you.
A run takes its policy when it starts, so a change applies from the next run, not one already going.
Four rows are shown but locked: reserved-action, non-http-navigation,
unreadable-navigation and secret-in-url. Allowing a javascript: URL, letting a page call an
internal verb, or letting a credential travel in a query string has no use worth a switch you could
hit by accident. Hand-editing config.json still works if you genuinely mean it.
Credential sealing is listed with no switch at all, because there is nothing to turn off: it is what keeps a plaintext password off the socket in the first place.
An agent run cannot use either screen's route to loosen its own rules: the Bridge refuses a
settings change from a connection that has acted for a run, with BLOCKED.
Secrets are sealed before you are asked
Before any rule runs, a deterministic sanitizer takes credentials out of what the browser hands back. A password, key, token, cookie or card number found in a page is replaced by a placeholder naming what it was and where it came from:
Your new password is ⟦password:7f3a@mail.example.com⟧
The agent never sees the value. It stays in the browser and becomes plaintext again at exactly one
moment: when the agent types it into a page field, which is when secret-release asks you first.
So the agent can carry a reset password from the mail page to the login form without ever being
able to read it, repeat it, or put it in a URL.
A ⟦…⟧ in the panel is a credential being handled, not an error.
How it works.
What this does not cover
The policy governs what an agent may do to a page. What the spawned CLI may do to your machine is a separate mechanism that never passes through these rules. See internals/guardrails.md § Spawn containment.
No gate makes an agent immune to prompt injection. A policy can make sure a successful injection has nowhere to send what it took.
See also
- Configuration: the keys
- internals/guardrails.md: how the policy is evaluated
- Limits: what Browsentic does not do