One function decides
whether a tool runs.
Everything on this page is gateFor(tool, level, overrides) in lib/approvals.ts, 113 lines. It returns one of three words: auto, ask, deny. The chat engine and the browser routes both call it, and neither has a private exception any more.
They defend different things.
The session gate decides whether a request is you. The approval gate decides whether your agent may run a given tool. Passing the first says nothing about the second.
The session gate has two halves, and only one of them is a boundary. Middleware runs on the edge runtime, where there is no node:crypto and no filesystem, so all it can do is shape-check a cookie: 43 characters, base64url alphabet. A hand-forged string of the right shape walks straight through it. The real check is requireAuth() inside each route handler, which hashes the presented token and compares it against stored hashes with a timing-safe compare behind a length check.
Of the API routes in the tree, four do not call requireAuth(), and each has a stated reason: the Stripe webhook authenticates with an HMAC signature, the two iMessage routes use a hashed shared token, and the pairing enrol route grants no access at all. Security covers the last one.
Eleven names the gate knows.
Both lists are exported and served to the settings page, so what the permissions screen lists is what the gate actually decides on rather than a second copy that drifts.
| Set | Tools |
|---|---|
| Read only | browser.navigate · browser.screenshot · browser.snapshot · skills.search · files.read |
| Always ask | shell.exec · files.write · browser.submit · browser.fill · browser.evaluate · mcp.write |
Callers spell tools two ways. The chat engine passes wire names such as shell_exec; the browser routes pass display names such as browser.navigate. The gate normalises underscores to dots before the lookup. Before that normalisation existed, every wire-name call fell through both sets and came back as auto.
What each one permits.
Set per workspace. Two separate settings, one for tools and one for the browser arm, both defaulting to L2.
| Tool class | L0 | L1 | L2 | L3 |
|---|---|---|---|---|
| Read only, 5 names | auto | ask | ask | deny |
| Always ask, 6 names | ask | ask | ask | deny |
| Anything else | auto | auto | auto | deny |
Read the table before reading anything into the names.
L0 does not mean no approvals. The always-ask tools still ask at L0. All L0 does is free the five read-only names.
L1 and L2 behave identically. The level names describe "ask once" and "ask every time", and the code has no branch on L1. Nothing anywhere records that a tool was approved once, so there is no ask-once memory to consult. Choosing L1 today changes nothing relative to L2. This is a gap in the implementation, not a subtlety in the design.
L3 is a kill switch. It returns deny for every tool including the read-only ones, and the callers turn that into a 403 or a declined tool call. It never produces a prompt.
A per-tool override can be set for any tool: allow, ask, or deny. Deny beats everything, including L0. The full precedence, in source order: deny override, then L3, then an ask override, then an allow override, then the read-only set, then the always-ask set, then auto.
A tool in neither set runs on its own.
The last rule in that list is the one to keep in mind. A tool that appears in neither set returns auto at L0, L1 and L2. browser.click is exactly that: the always-ask set lists submit, fill and evaluate, and does not list click.
Two callers cover clicking today, and neither of them relies on the gate to do it. The browser action route creates an approval for click, fill and evaluate unconditionally, checking the gate only for deny. The chat engine falls back to a separate sensitive-tool list that does name click.
So the gap is contained rather than closed. A new caller that trusts the gate alone for browser.click gets auto. We would rather write that down than let someone discover it.
Two names in the always-ask set, browser.submit and mcp.write, appear nowhere else in the repository. No code path emits either one. They are reserved, not wired.
A record, and your edit wins.
When the gate says ask, a record is written with the session id, a kind, a title, the detail, the level and the full payload, and the turn stops. The chat session moves to awaiting approval and resumes once nothing is pending for it.
Nothing expires and nothing auto-resolves. A pending approval stays pending until a person answers it. There is no timeout that quietly turns into a yes.
You can edit before approving, and the edit is what runs. The browser execute route prefers your edited payload over the model's original, and the iMessage path replaces the queued reply text with the approver's version. Approving an edited action executes the edit.
Mutating browser actions never run inline, at any level. Clicking, filling and evaluating are executed only after a human approves, in a separate route that re-reads the approval by id and refuses a declined one with a 409.
Approval records survive a chat delete. The payload holds the thing you approved: the shell command, the file content, the text of a message. Clearing chat history does not touch store/approvals.json. Privacy lists everything else a delete does not reach.