Desktop and IPC Security
The desktop process boundary: Electron sandboxing across every renderer, the separate PTY, workspace and plugin host processes, how IPC is validated through the context bridge, and the rate limits on it.
Process sandboxing
Daintree turns on Electron's sandbox globally through app.enableSandbox(). Every renderer process runs restricted, with no direct access to Node.js APIs or the filesystem. The setting covers every window, current and future, with no per-window opt-in.
Work that needs real system access lives in separate processes: a PTY host for terminals, a workspace host for git and filesystem polling, an out-of-process worker for plugins, and a watchdog. Each is forked with a narrowed environment, and each is a separate failure domain: a crash in one does not take the app with it.
IPC isolation
Every message between the UI and the main process runs through a preload script built on Electron's contextBridge. The renderer never touches Node.js directly; it can only call the functions explicitly exposed to it.
Sender validation
Every IPC message is checked to confirm it came from Daintree's own window. A global patch runs before any handler is registered and wraps all three entry points: ipcMain.handle and ipcMain.handleOnce return a wrapped error to untrusted origins, and ipcMain.on silently drops them. In production the trusted origin is app://daintree. The check is automatic and needs no per-handler wiring.
Payload validation
All IPC payloads are validated against Zod schemas, and two structural gates run before the schema sees the payload at all.
The arg-count gate rejects any call carrying more than eight positional arguments. Realistic handlers take one structured object, so eight leaves roughly four times the headroom while still defeating prototype-poisoning and deep-spread variants that try to overwhelm a handler with thousands of arguments.
The byte-budget gate enforces per-category size limits, using JSON.stringify to estimate UTF-8 length:
| Category | Budget |
|---|---|
fileOps | 4 MiB |
artifactOps | 4 MiB |
gitOps | 512 KiB |
terminalSpawn | 256 KiB |
| Everything else | 1 MiB |
Terminal spawn gets a tighter budget than file operations because its payload is an environment dictionary, which can legitimately include base64 certificate bundles and an accumulated PATH, but never a file's contents.
Payloads carrying binary blobs (ArrayBuffer, typed arrays, Map, Set) skip the byte check and fall back to Chromium's 128 MiB transport ceiling, because JSON.stringify cannot size them meaningfully. Handlers that accept binary directly, such as clipboard writes, carry their own explicit caps instead.
Error envelopes leak nothing
Errors thrown inside a wrapped handler are normalized before serialization. stack, path, context, cause and properties are stripped from the envelope, and both message and userMessage pass through a path stripper and the secret scrubber before they reach the renderer. In packaged builds the envelope also carries a correlationId, which links a renderer error to its matching main-process breadcrumb when telemetry is on.
A schema rejection is deliberately even quieter. IPC validation failures no longer carry raw Zod detail across the wire. The error serializes as the bare string IPC validation failed: <channel>, and the individual issues are logged in-process only. This matters because Zod carries the offending value inline in its error message, so a validation failure on a payload containing a credential would otherwise hand that credential straight back to the renderer. It is the same reason MCP error messages are static and never interpolate rejected input.
Plugin invoke authentication
The plugin:invoke channel adds a second sender check at the handler level, on top of the global wrapper. Before any plugin dispatch runs, the handler re-validates the sender frame's URL and throws immediately if it is missing or untrusted. This is defense in depth: plugins are the one surface where a renderer-originated message reaches third-party extension code, so the trust check is restated at the point of dispatch rather than relied on once at the edge.
E2E backdoors are stripped from production builds
Daintree's end-to-end tests need bridges a production app must never have: a direct action-dispatch global, a first-run-dialog skip, fault injection, and a plugin sideload directory. These are handled at three layers rather than one runtime check.
- Build-time removal. Production builds replace every
DAINTREE_E2E_*environment read with an empty string via bundler defines, so the constant folds to false and the guarded blocks (including thecontextBridge.exposeInMainWorldcalls) are eliminated as dead code. - Runtime gating. Every flag is additionally gated on the build not being packaged. A packaged build can never set one, whatever environment or argv it is handed.
- A CI gate. After a production build, a check scans the compiled preload and every main-process bundle, including split chunks, for the exposed global names and the environment-variable names. Either surviving means the strip regressed, and the build fails.
The gate covers the main process deliberately, not just the preload: main-process flags influence file paths, crash-dump destinations and the plugin root, and an earlier version of this check looked only at the preload while those names survived in the main bundles.
IPC rate limiting
Expensive IPC channels are rate-limited per channel, so a runaway agent loop or a piece of automation cannot flood the system with file, git or forge requests. Two strategies are in use:
- Fail-fast sliding window. The call throws the moment the window's allowance is spent. Used where retrying is straightforward: CopyTree generation and file-tree reads at 5 calls per 10 seconds, file search and forge navigation at 20, PR review submissions at 3.
- Queue-based token bucket. Requests wait instead of failing. Worktree creation allows a burst of 30 after an idle stretch and then drains at one per second, with a 50-deep queue beyond which requests are rejected.
The worktree burst allowance is worth explaining, because it used to be the safety mechanism and no longer is. Concurrent git worktree add runs against one repository are now serialized structurally, by a per-repository create queue in the workspace host that runs creates strictly one at a time. With git safety no longer depending on wall-clock spacing, the rate limit exists purely as backpressure against runaway automation, which is why a 30-worktree bulk create now runs at real git speed instead of spending 30 seconds on pure pacing.
Session restore is exempt from rate-limit quotas, so reopening your previous workspace does not burn slots. The exemption applies to the rate limiter only, not to any other control on this page.