Docs

ADR-0028: Automations reach the browser only through the host, never a debugging port

Status: Proposed · Date: 2026-10-11

References

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:

  1. Sandbox escape. A script whose header says only tabs can open the main window’s target and evaluate require("child_process"). Its Deno permissions stop mattering.
  2. Grants are advisory. forms without page-js, sites=, submit, and read= 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”.
  3. 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.js no longer appends remote-debugging-port by default.
  • It opens the port only when TRANQUIL_CDP_PORT is set explicitly and the app is not packaged (app.isPackaged is 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-net and stops passing TRANQUIL_CDP_PORT to scripts. A script that opens a socket to any port it was not granted is refused by Deno.

The host owns the debugger

  • TabsCap already 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 calls webContents.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.setAutoAttach with flatten), and exposed through tab.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.ts goes away: the host already knows which webContents a 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.

GrantThe host forwards
tabsnavigation (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
formsalso 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
submitalso lets a form submit; without it, the host keeps the submit guard armed in the page for the whole run
page-jsalso 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-js can 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-js can 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:

  1. Can webContents.debugger attach to a <webview> guest while that tab’s DevTools is open, and what happens to each when the other attaches?
  2. Do flattened sessions (Target.setAutoAttach, flatten: true) reach cross-origin iframes in a guest, and does Input.* with frame coordinates work on them?
  3. Does Page.createIsolatedWorld plus a host-injected bundle survive navigation, re-created on Runtime.executionContextCreated?
  4. Is a round trip renderer ↔ Deno over the runner bridge fast enough for typing and polling (fill, waitFor), or does waitFor need to run host-side?
  5. Does Page.navigate through the debugger behave like today’s (same target, same events)?
  6. Does DOM.setFileInputFiles accept 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 any Runtime.callFunctionOn with script code is page-js by another name, so forms would 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=, submit and 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:security rules gain one for the switch: remote-debugging-port may appear only behind app.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 --inspect or other debugging switches by a local program.fuses Related hardening, decided separately.

  1. Chrome DevTools Protocol, “Getting started”: the HTTP and WebSocket endpoints exposed by --remote-debugging-port. https://chromedevtools.github.io/devtools-protocol/↩
  2. Chrome for Developers, “Changes to remote debugging switches to improve security” (Chrome 136). https://developer.chrome.com/blog/remote-debugging-port↩
  3. Electron API, webContents.debugger: “An alternate transport for Chrome’s remote debugging protocol.” https://www.electronjs.org/docs/latest/api/debugger↩
  4. Electron, “Security”: the checklist for limiting what remote content and other processes can reach. https://www.electronjs.org/docs/latest/tutorial/security↩
  5. Electron, “Electron Fuses”: EnableNodeCliInspectArguments and related build-time switches. https://www.electronjs.org/docs/latest/tutorial/fuses↩