ADR-0028: Automations reach the browser only through the host, never a debugging port
Status: Proposed · Date: 2026-10-11
References
- ADR-0022: Automations run as Deno subprocesses: the runner, its permission header, and the token-authenticated bridge this ADR builds on
- ADR-0026: Automations fill forms through a small, stable SDK on trusted input:
the
tabs,forms,page-js,sites=andsubmitgrants, which it calls advisory until this ADR lands, and the custom parts maps the page runtime reads - ADR-0027: Automations are standalone scripts, and Tranquil supplies their context: rejected driving the app over the debugging port for the same reason
- ADR-0025: Debug automations in place: debug runs, which use the same browser path
Context
Tranquil’s rule for its own code is that nothing gets past the Deno sandbox, and nothing holds a permission it did not explicitly ask for. Browser automation breaks that rule today.
The port is always open. tranquil-client/src/main-process/start.js appends Chromium’s remote-debugging-port switch on every launch, packaged builds included, on port 9222 unless TRANQUIL_CDP_PORT says otherwise. Chromium’s DevTools protocol on that port has no
authentication.cdp
The port reaches more than web pages. Its target list includes Tranquil’s own windows, which
are Pulsar renderers with Node.js enabled, next to the browser tabs. The test suite depends on
exactly this: its harness reads process.env from the main window through the port
(evalInAtom). Anything that can talk to the port can therefore run Node.js code with the user’s
full privileges.
Scripts are handed the port. The runner adds 127.0.0.1:<port> to a script’s --allow-net whenever its header has any browser grant (lib/run-manager.js), and the SDK’s CDP client
(deno/cdp.ts) lists every target. To find its own tab, it even connects to each browser tab in
turn and reads a global (deno/tabs.ts).
Together these give three holes, in order of severity:
- Sandbox escape. A script whose header says only
tabscan open the main window’s target and evaluaterequire("child_process"). Its Deno permissions stop mattering. - Grants are advisory.
formswithoutpage-js,sites=,submit, andread=for uploads are checked inside the SDK, which a script can bypass by speaking CDP itself. ADR-0026 says so and defers the fix to “a filtered proxy”. - Any local program can drive the browser. Without a script at all, any process on the machine can read every signed-in session’s cookies and pages through the port. This is the pattern infostealer malware used against Chrome, which is why Chrome 136 stopped honouring the switch for the default profile.chrome136
Electron already offers the alternative. Each webContents has a debugger object that speaks the
same protocol in-process, with no port: attach(), sendCommand(method, params, sessionId), and message/detach events.debugger The host can use it on exactly the tabs it chooses. Electron’s
own security checklist treats every remote surface as something to narrow, not open.security
Decision
Tranquil stops opening the remote debugging port. Automations reach the browser only through
the host: the window’s tranquil-automations package attaches Electron’s in-process debugger to
the tabs a run was handed, and serves each grant’s operations over the runner’s existing
token-authenticated bridge. Every grant, sites=, submit and read= is checked by the host
before it forwards anything.
No port in shipped builds
start.jsno longer appendsremote-debugging-portby default.- It opens the port only when
TRANQUIL_CDP_PORTis set explicitly and the app is not packaged (app.isPackagedis false). The test suite already sets the variable for its isolated instances, and the development notes for agents (tranquil-llm,developers/tools/dev-app.md) gain the line that turns it on. - The runner stops adding the port to
--allow-netand stops passingTRANQUIL_CDP_PORTto scripts. A script that opens a socket to any port it was not granted is refused by Deno.
The host owns the debugger
TabsCapalready mints a handle for each tab a run opens or finds (tabs.open,tabs.active,tabs.find). On the first page operation for a handle, the host callswebContents.debugger.attach()on that tab’s guest, and detaches when the run ends.- Cross-origin frames are attached the same way, as flattened sessions under the tab
(
Target.setAutoAttachwithflatten), and exposed throughtab.frame()as today. - A run never sees a target list, a target id or a session it was not handed. The nonce lookup in
tabs.tsgoes away: the host already knows whichwebContentsa handle means. - Tranquil’s own windows are never attached for a run.
What each grant may do
The host forwards an operation only when the run’s grants allow it and the tab’s current URL
matches sites=. Everything not listed is refused with not_permitted.
| Grant | The host forwards |
|---|---|
tabs | navigation (the target URL must match sites=), the frame tree, screenshots, and the page runtime’s read operations: fields, clickables, text, attr, waitFor(target), and the read-only parts check and map drafting behind Check Custom Parts and Map Custom Parts |
forms | also trusted input to the tab (Input.insertText, Input.dispatchKeyEvent, Input.dispatchMouseEvent), file uploads (DOM.setFileInputFiles, each path inside the run’s read= list), and the runtime’s write operations |
submit | also lets a form submit; without it, the host keeps the submit guard armed in the page for the whole run |
page-js | also Runtime.evaluate and Runtime.callFunctionOn with the script’s own code, in the tab’s main world only |
No grant reaches Target, Browser, Storage, Network, Fetch, Debugger, IO, Tracing or SystemInfo methods, or any target other than the run’s own tabs and their frames.
The page runtime comes from the host
Today the SDK sends its page runtime (deno/forms/runtime.ts) as function source and calls it
with Runtime.callFunctionOn, passing the operation’s name, its arguments and the tab’s custom
parts maps as JSON. A host that forwarded arbitrary function source would make forms equal to page-js. Instead:
- The runtime is built once into a committed file,
deno/forms/runtime.bundle.js(deno task build:runtime), with a test that fails when it is stale. - The host injects that file into an isolated world itself, and exposes the runtime’s operations
by name. A script without
page-jscan call those operations with JSON arguments, never send code. - Custom parts maps stay call arguments: JSON selectors and a pattern that the runtime only
queries, so a script without
page-jscan still use one, and a map can never carry code. - The SDK keeps its public API unchanged (ADR-0026’s contract). Only its transport changes: page operations go to the host over the runner bridge instead of to a socket.
Spike checklist
Answered before the build, in the delivery note:
- Can
webContents.debuggerattach to a<webview>guest while that tab’s DevTools is open, and what happens to each when the other attaches? - Do flattened sessions (
Target.setAutoAttach,flatten: true) reach cross-origin iframes in a guest, and doesInput.*with frame coordinates work on them? - Does
Page.createIsolatedWorldplus a host-injected bundle survive navigation, re-created onRuntime.executionContextCreated? - Is a round trip renderer ↔ Deno over the runner bridge fast enough for typing and polling
(
fill,waitFor), or doeswaitForneed to run host-side? - Does
Page.navigatethrough the debugger behave like today’s (same target, same events)? - Does
DOM.setFileInputFilesaccept the host’s checked path list unchanged?
Options considered
- Keep the port and add authentication. Chromium’s remote debugging endpoint has no authentication to add, and a wrapper on another port would still be a network surface that lists targets. Rejected.
- A filtering proxy on a separate port (what ADR-0022 and ADR-0026 first sketched). It would fix hole 1 if it hid host targets, but not hole 3 while the real port stayed open, and it adds a second network listener. The in-process debugger needs no port at all. Rejected.
- A method allowlist on raw CDP, still forwarding script-supplied functions for
forms. It keeps the SDK unchanged, but anyRuntime.callFunctionOnwith script code ispage-jsby another name, soformswould stay advisory. Rejected in favour of a host-injected runtime. - Checking function source against a list of known hashes. It would let the SDK keep sending code, but hashes of transpiled TypeScript change with the Deno version, and the check is easy to get subtly wrong. A built, committed bundle is simpler to reason about. Rejected.
Consequences
- The sandbox holds. A script’s reach is its Deno permissions plus the operations its grants name, on the tabs it was handed. Running Node.js code in Tranquil’s window is no longer possible from a script.
- Grants become a boundary.
tabs,forms,page-js,sites=,submitand upload paths are enforced by the host. ADR-0026’s “advisory” caveat, and the follow-up it names, are closed by this ADR once it is implemented. - Local programs lose their way in. With no port, other processes on the machine cannot drive the browser or read its sessions.
- The test suite and development keep their port, opted into per launch and never in a packaged
build. The
lint:securityrules gain one for the switch:remote-debugging-portmay appear only behindapp.isPackaged. - Cost: the SDK’s transport and the host’s tab capability are rewritten, the page runtime gains a build step, and every page operation becomes a bridge round trip. The spike measures that last point before the build starts.
- Amends ADR-0022 and ADR-0026. ADR-0022’s runner grants the port no longer. ADR-0026’s advisory-grants consequence and its “filtered CDP proxy” follow-up point here.
Out of scope
- The release gate. No release ships until the port is closed by default. Tranquil is in developer preview, and its releases so far are source releases run from a checkout, so “ships” means the first native build. Holes 1 and 3 affect every build that opens the port. Closing it (no port, and scripts get no port) can land ahead of the rest; browser automations then stop working until the host path is in, which is the correct failure for a sandbox escape, and a release note says so.
- The Deno inspector for debug runs (ADR-0025) listens on a local port while a debug run is paused. It reaches only the script’s own Deno process, which stays inside its permissions, so it is not a sandbox escape. It still deserves the same scrutiny in its own review.
- Electron fuses that stop a packaged app from being relaunched with
--inspector other debugging switches by a local program.fuses Related hardening, decided separately.
- Chrome DevTools Protocol, “Getting started”: the HTTP and WebSocket endpoints exposed by
--remote-debugging-port. https://chromedevtools.github.io/devtools-protocol/↩ - Chrome for Developers, “Changes to remote debugging switches to improve security” (Chrome 136). https://developer.chrome.com/blog/remote-debugging-port↩
- Electron API,
webContents.debugger: “An alternate transport for Chrome’s remote debugging protocol.” https://www.electronjs.org/docs/latest/api/debugger↩ - Electron, “Security”: the checklist for limiting what remote content and other processes can reach. https://www.electronjs.org/docs/latest/tutorial/security↩
- Electron, “Electron Fuses”:
EnableNodeCliInspectArgumentsand related build-time switches. https://www.electronjs.org/docs/latest/tutorial/fuses↩