Skip to main content

Security & Privacy

How Daintree sandboxes processes, hardens IPC, enforces Trusted Types, scrubs secrets, isolates the daintree-file protocol, protects git, escapes lifecycle commands, sandboxes agent probes, locks down permissions, and stores your data.

Updated
Reviewed

Process Sandboxing

Daintree turns on Electron's sandbox mode globally via app.enableSandbox(). Every renderer process runs restricted, with no direct access to Node.js APIs or the file system. The setting covers every window, current and future, with no per-window opt-in.

IPC Isolation

Every message between the UI and the main process runs through a preload script built on Electron's contextBridge API. The renderer never touches Node.js directly. It can only call the functions that are 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 handlers are registered, wrapping all three IPC entry points:

  • ipcMain.handle and ipcMain.handleOnce channels return a wrapped error to untrusted origins
  • ipcMain.on channels silently drop messages from untrusted origins

In production, the trusted origin is app://daintree. The check is automatic and covers every handler, fire-and-forget channels included. No per-handler wiring is needed.

Error messages returned to the renderer are scrubbed in production builds too. Home directory paths, Windows user paths, and UNC paths are stripped before serialization, so an error response can't leak them.

Payload Validation

All IPC payloads are validated against Zod schemas. Malformed or unexpected data is rejected at the handler boundary.

Two structural gates run before the schema sees the payload at all. The arg-count gate rejects any call carrying more than MAX_IPC_ARG_COUNT = 8 positional arguments. The byte-budget gate enforces per-category size limits, using JSON.stringify to estimate UTF-8 length:

CategoryBudget
fileOps4 MiB
artifactOps4 MiB
gitOps512 KiB
terminalSpawn256 KiB
Default1 MiB

Payloads carrying binary blobs (ArrayBuffer, typed arrays, Map, Set) skip the byte check and fall back to Mojo's 128 MiB ceiling, since JSON.stringify can't size them in any meaningful way.

Errors thrown inside a wrapped handler are normalized before serialization. stack, path, context, cause, and properties are stripped from the envelope. 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 from TelemetryService.getCurrentCorrelationId(). That links the renderer error to its matching main-process Sentry breadcrumb when telemetry is on.

Note
The correlationId field appears only in production error envelopes, and only when telemetry has minted one. Dev builds drop it, so error responses stay clean without correlation noise.

Plugin Invoke Authentication

The plugin:invoke channel adds a second sender check at the handler level, on top of the global wrapper above. Before any plugin dispatch runs, the handler calls isTrustedRendererUrl(event.senderFrame?.url) and throws immediately if the sender URL is missing or untrusted.

This is defense-in-depth. The global wrapper already blocks untrusted origins on every channel, but plugins are the one surface where a renderer-originated message reaches arbitrary extension code, so the handler restates the trust check at the point of dispatch. If the global wrapper is ever bypassed or misconfigured, this per-handler guard still holds.

E2E Dispatch Gate

The window.__daintreeDispatchAction global is attached only when window.__DAINTREE_E2E_MODE__ === true (strict equality). That flag is set by Daintree's E2E test launcher and nothing else. In production sessions the flag is undefined, so the global is never exposed and untrusted renderer content can't invoke it to skip the agent-confirmation guard. The check runs at runtime rather than at build time because E2E tests run against a production Vite build.

IPC Rate Limiting

Daintree rate-limits IPC channels by category whenever they trigger expensive operations. This keeps a runaway agent loop or a piece of automation from flooding the system with rapid file, git, or terminal requests.

Two enforcement strategies are used, depending on the operation:

  • Fail-fast: throws the moment the rate limit is exceeded. Used for read and delete operations, where retrying is straightforward.
  • Queue-based: holds requests in a queue, up to 50 deep, and releases them as the rate window expires. Used for create operations like terminal spawns and worktree creation, so a burst waits instead of failing.
Tip
Terminal and worktree creation use the queue-based strategy. Launch several terminals at once and the requests queue up and run in order rather than failing.
CategoryLimitWindowStrategy
File operations5 calls10 sFail-fast
Git operations10 calls10 sFail-fast
Terminal spawn10 calls30 sQueue
Worktree creation20 calls30 sQueue

Session restore is exempt from rate-limit quotas, so reopening your previous workspace doesn't burn slots. The exemption applies to the rate limiter only, not to any other security control.

Content Security Policy

Daintree enforces strict Content Security Policy headers across several surfaces, limiting what scripts, styles, and resources each context can load.

Response Headers

Every response served through the app:// protocol carries these security headers:

  • Cross-Origin-Opener-Policy: same-origin
  • Cross-Origin-Embedder-Policy: credentialless
  • X-Content-Type-Options: nosniff
  • Cross-Origin-Resource-Policy: same-origin

These headers block cross-origin data leaks and MIME type confusion attacks on every asset the app serves.

Webview CSP

Browser panel and dev preview sessions get a per-session CSP applied through session.webRequest.onHeadersReceived. The policy restricts scripts, connections, and media to localhost origins (localhost:*, 127.0.0.1:*, [::1]:*) and blocks object embeds and base URI manipulation. The Portal sidebar is exempt from the localhost restriction because it loads external AI services (Claude, ChatGPT, Gemini). Portal isolation leans on partition separation and permission lockdown instead.

Shared persist:daintree Session

Every project worktree opened in a window shares one Electron session, persist:daintree. The shared session lets V8 reuse its compiled-code cache across worktree switches, which is the dominant cost of opening a new worktree. CSP is enforced at the session level rather than the window level, so every renderer load served through app:// picks up the locked-down policy with no per-worktree wiring.

The policy is applied in two layers. setupWebviewCSP() attaches a webRequest.onHeadersReceived listener on the shared session that overlays the production CSP onto every response. A matching <meta http-equiv="Content-Security-Policy"> tag is baked into index.html at build time. If the two layers ever drift, Chromium intersects them into the stricter effective policy. The HTTP header is the source of truth, and the meta tag is the fallback for the case where headers fail to attach.

The production CSP includes require-trusted-types-for 'script' and trusted-types daintree-svg default 'allow-duplicates'. That directive is what turns on the Trusted Types enforcement covered next.

Trusted Types

Production Daintree runs Trusted Types in enforce mode, not report-only. Any DOM sink write (innerHTML, outerHTML, document.write, the Worker constructor URL, and the rest) that isn't a TrustedHTML or TrustedScript value throws a TypeError at runtime. The directive that switches this on is require-trusted-types-for 'script' in the CSP above.

Two policies are registered in src/lib/trustedTypesPolicy.ts:

  • daintree-svg: a named policy called through createTrustedHTML(html). Every string that reaches it comes from a compile-time SVG constant or from the upstream sanitizeSvg validator, so the policy is a checkpoint, not a sanitizer. Re-sanitizing here would only mask regressions in the upstream validator.
  • default: a pass-through policy that exists because React DOM and Radix Popper inject inline-style strings into DOM sinks the renderer doesn't control directly. Without a default policy installed, those writes would throw TypeError: This document requires 'TrustedHTML' assignment and the app wouldn't boot.

The 'allow-duplicates' flag is there so Vite HMR can re-evaluate the policy module on hot reload during development. It changes nothing in production, since the renderer loads the policy once.

Trusted Types enforcement is renderer-only. Embedded webviews get their own narrower CSPs per partition (see Webview CSP above), so the enforcement doesn't propagate into Portal or dev preview surfaces. The renderer also throws hard if window.trustedTypes is unavailable rather than degrading silently, so a missing sink check can't hide behind a graceful fallback.

Tip
Trusted Types runs in enforce mode. Any new code path that writes HTML or scripts to a DOM sink throws at runtime unless it goes through createTrustedHTML(). A runtime error during development is the cheapest review you can get for a DOM XSS surface.

Embedded Browser Isolation

The portal and dev preview panels run on isolated embedded web surfaces, each in its own partition. These embedded browsers:

  • Run with nodeIntegration disabled, contextIsolation enabled, and sandbox enforced
  • Have their own storage partitions, so cookies and localStorage stay isolated
  • Cannot reach Daintree's internal APIs
  • Have navigateOnDragDrop disabled, which closes drag-and-drop navigation hijacking
  • Disable the Blink Auxclick feature to close the middle-click navigation bypass
  • Have preload scripts stripped, so they can't inherit the main window's bridge

Navigation is locked down through will-navigate and will-redirect event handlers. Dev preview sessions allow localhost URLs only. Browser panel sessions block unsafe URLs. Any window.open() call from a webview is denied, and if the URL is safe it's routed to the OS browser instead.

The daintree-file:// Protocol

The daintree-file:// custom protocol serves user-supplied files into the renderer for previews and attachments. Both the file path and the root it must live under come from the caller (?path=<absolute>&root=<absolute>), so the handler treats the caller as the attacker and validates end to end.

Every request runs through a fixed containment chain, in order:

  1. Method check. Only GET and HEAD are accepted; everything else returns 405.
  2. Null-byte check on both path and root. Reject if either contains \0.
  3. Absolute-path check. Both must be absolute. Relative paths are rejected without resolution.
  4. path.normalize() on both, collapsing .. segments.
  5. fs.realpath() on both. This resolves symlinks before the containment check, so a symlink-to-outside that sits inside the root no longer counts as "inside" the root.
  6. Relative-containment check. The realpath'd path must be a descendant of the realpath'd root.
  7. Size cap. Files larger than 512 KiB return 413 Payload Too Large.
  8. fs.open() with O_NOFOLLOW on the user-supplied path. This closes the TOCTOU gap between realpath and the actual read, where a symlink could be swapped in as the final component. On Windows the flag is a no-op, and the realpath containment carries the weight there.

Successful responses carry a hardened header set:

  • Content-Type picked from an extension allowlist, not from content sniffing
  • Content-Length from the actual buffer length, in case the file grew between stat and read
  • Content-Security-Policy: sandbox; default-src 'none' so the served file can't navigate, script, or fetch, whatever MIME type it declares
  • Cross-Origin-Resource-Policy: cross-origin so the renderer running on app://daintree can load it; a same-origin value would block legitimate loads, since the schemes differ
  • X-Content-Type-Options: nosniff
  • Cache-Control: no-store

Containment failures (steps 2 through 6) return 404 Not Found, not 403. Failing closed without telegraphing whether the path or the root was the problem keeps probe responses uniform. The other two failure codes are narrower: 405 from the method check (step 1) and 413 from the size cap (step 7), the latter only when the file is reachable but oversized.

This handler addresses CVE-2025-53109 and CVE-2025-54794 (in-root symlinks resolving outside the root), along with the broader CWE-367 (TOCTOU file access) and CWE-61 (UNIX symlink following) classes.

The simpler app:// protocol that serves the renderer's own bundle runs a narrower path check: resolved paths must fall under the bundled dist directory, and containment failures return 403 Forbidden. The app:// handler serves built artifacts only, so the wider TOCTOU surface from daintree-file:// doesn't apply.

Git RCE Protection

A malicious repository can carry git config directives that run arbitrary code when git runs. This is a real supply-chain attack vector: a cloned repo can set core.fsmonitor, core.pager, or protocol.ext.allow to run commands on your machine.

Daintree shuts this down by routing every git call through a hardened wrapper that forces nine -c config overrides. The overrides take precedence over anything set in the repository's .git/config:

OverrideThreat neutralized
core.fsmonitor=falsePrevents arbitrary code execution via fsmonitor hook
core.hooksPath=Prevents malicious hooks from running
core.pager=catPrevents pager injection
core.askpass=Blocks credential hijacking via askpass
credential.helper=Blocks credential store hijacking
core.sshCommand=Blocks SSH command injection
core.gitProxy=Blocks proxy injection
protocol.ext.allow=neverPrevents RCE via ext:: protocol URLs
core.untrackedCache=falseDisables untracked cache (can trigger hooks)

The overrides are per-invocation -c flags, applied only to git processes Daintree spawns internally. They don't touch your system git configuration and don't affect git commands you run outside Daintree.

Working directories are checked before any git call runs. Only absolute paths are accepted, which blocks relative path injection from the renderer. A 30-second timeout keeps any git operation from hanging indefinitely.

Lifecycle Command Injection

Lifecycle commands in .daintree/config.json run with shell: true and support {{variable}} and {variable} placeholders, substituted before the command string is assembled. The trust boundary sits around the command text the config defines, not the runtime values that get substituted into its placeholders. Branch names come from git, so a collaborator pushing a branch like feature/$(whoami) would inject shell into any lifecycle command that substitutes {{branch}} on checkout.

Daintree closes this path by wrapping every substituted value in shell-safe quoting before it reaches the command string. The escaping runs at every substitution site, not just for one variable, so placeholders like {{branch}}, {{worktree_path}}, {{worktree_name}}, {{project_root}}, {{endpoint}}, {repo-name}, {base-folder}, and {parent-dir} all get the same treatment. A value with spaces, quotes, dollar signs, backticks, semicolons, or pipes can't break out of its argument.

PlatformEscaping applied
macOS, LinuxWrapped in single quotes. Internal ' becomes '\''. For example, $(whoami) substitutes as '$(whoami)' and is passed to the shell as a literal string.
WindowsWrapped in double quotes. Internal % becomes %% (preventing env var expansion) and internal " becomes "". For example, %PATH% substitutes as "%%PATH%%".

One placeholder is left unquoted on purpose: {branch-slug}. Its value is sanitized to [a-z0-9-] at generation time (lowercased, non-alphanumeric runs collapsed to -, leading and trailing dashes stripped), so the character set itself proves it can't contain anything the shell would interpret. If a value ever fails the charset check, it falls back to the same quoting as the other variables.

For the full list of variables available in lifecycle commands, see Remote Compute > Template Variables.

Secret Scrubbing

Anything that leaves the main process as a string (log lines, error messages, crash report fields, agent probe output) passes through a single scrubSecrets() utility. The function runs roughly 53 regex patterns against the input and replaces every match with [REDACTED]. It's idempotent, so callers can wrap it defensively without producing double-redaction artifacts.

Patterns are grouped by category. The table below is representative, not exhaustive:

CategoryExamples
Source hostsGitHub PATs (ghp_, github_pat_, ghs_, ghu_, gho_), GitLab tokens (glpat-, gldt-)
AI providersAnthropic (sk-ant-), OpenAI (sk-, sk-proj-, sk-svcacct-, sk-admin-), OpenRouter (sk-or-v1-), Perplexity (pplx-), xAI (xai-), Together (tgp_v1_), Groq (gsk_), Replicate (r8_), Hugging Face (hf_, api_org_)
Cloud and infraAWS access keys (AKIA, ASIA, ABIA), AWS secret keys (context-anchored), Google API (AIza), Azure connection strings, DigitalOcean (dop_v1_, doo_v1_, dor_v1_), Cloudflare tokens
Deploy and SaaSVercel (vcp_, vci_, vca_, vcr_, vck_), Heroku (HRKU-), Resend (re_), Stripe (sk_live_, sk_test_, rk_live_, rk_test_), Supabase (sb_publishable_, sb_secret_), Linear (lin_api_), Notion (ntn_), Atlassian (ATATT3x, ATCTT3x)
MessagingSlack (xoxb-, xoxp-, xapp-, xoxe-), SendGrid (SG.), Telegram (context-anchored), Datadog (context-anchored)
Generic shapesPEM private key blocks, JWTs, Bearer tokens, OAuth access_token, refresh_token, and client_secret query params, https://user:pass@host basic-auth URLs, .env-shaped fallbacks for credential-named variables

The v0.8.0 catch-up batch added Vercel, Perplexity, xAI, Together, Resend, Heroku, and the context-anchored Telegram and Datadog patterns. Context-anchored means the regex looks for the surrounding key name (for example DD_API_KEY or telegram_bot_token) rather than the token shape on its own. The underlying values are bare alphanumeric strings that nothing else can tell apart from arbitrary identifiers.

The scrubber is wired into eleven approved call sites. Anything outside this list that emits user-shaped strings has to call scrubSecrets() explicitly:

  • Sentry beforeSend hook in TelemetryService
  • DiagnosticsCollector string output
  • Logger file-write and console-mirror paths (emit and emitError)
  • Main-process emergency crash log
  • PTY-host emergency crash log
  • IPC error envelopes (both message and userMessage)
  • WorktreeLifecycleService tail-output scrub before renderer delivery
  • AgentInstallService progress output
  • AgentHelpService command output
  • AgentVersionService error messages
  • WorktreeLifecycleService teardown log writer

The patterns use bounded quantifiers throughout, so even a multi-megabyte log dump finishes in linear time. The placeholder is the literal string [REDACTED], which carries no token sigils, so running scrubSecrets() on already-scrubbed output is safe and changes nothing further.

Environment Variable Filtering

When Daintree spawns a terminal or agent process, it filters the inherited environment so credentials don't leak. Without the filter, a rogue process in the terminal could inherit your shell's API keys, database passwords, and cloud credentials.

Blocklist

The filter works at two levels. First, an exact blocklist strips 35 known credential variables, including DATABASE_URL, AWS_SECRET_ACCESS_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, GITHUB_TOKEN, and STRIPE_SECRET_KEY.

Second, a regex catches any variable whose name contains SECRET, PASSWORD, TOKEN, CREDENTIAL, PRIVATE_KEY, API_KEY, ACCESS_KEY, SIGNING_KEY, or ENCRYPTION_KEY as a word segment. The match is boundary-aware: SECRETARIAT doesn't match, but MY_SECRET_VALUE does.

DAINTREE_* Anti-Spoofing

Every inherited DAINTREE_* environment variable is stripped before each spawn. Daintree then injects fresh, known-safe values for its own metadata variables (DAINTREE_PANE_ID, DAINTREE_CWD, DAINTREE_PROJECT_ID, DAINTREE_WORKTREE_ID). This stops a project config or an external tool from spoofing Daintree's internal environment.

Agent CLI Probe Sandboxing

Daintree discovers installed agents by running probes against them. AgentVersionService calls --version to detect what's installed; AgentHelpService calls --help to pull built-in usage text. These probes don't need a real environment, so they're spawned with an allowlist rather than the blocklist used for normal terminal and agent spawns. The set of credential variable names is unbounded, so for a process that needs almost nothing from the parent, an allowlist is the only structurally sound choice.

PlatformAllowed keys
POSIX (macOS, Linux)PATH, HOME, LANG, LANGUAGE
WindowsPATH, SYSTEMROOT, USERPROFILE, TEMP, TMP, PATHEXT, LANG, LANGUAGE

Both platforms also pass through variables whose names start with LC_ (locale extensions like LC_ALL) or DAINTREE_ (the app's own namespace). TERM=dumb is always set, which keeps agents from emitting ANSI cursor escapes or paging output during a probe.

Variables outside the allowlist never reach the child process. That includes LD_PRELOAD and DYLD_INSERT_LIBRARIES (library injection), NODE_OPTIONS (Node debug-protocol injection), ELECTRON_RUN_AS_NODE (which would break the sandbox by turning Daintree's own binary into a Node interpreter), and every credential-shaped variable the parent shell happens to carry. Nothing is explicitly denied; anything outside the allowlist is just dropped.

Probe stdout and stderr both run through secret scrubbing before reaching the renderer, so even a badly behaved agent that echoed an environment value into its help text would have it redacted at the boundary.

Note
This filtering covers agent and terminal spawns. It doesn't touch the project environment variables you define in Settings > Project. Those are injected at spawn time, after filtering, so the keys you set on purpose still reach the agent.

Permission Lockdown

Electron's permission system lets web content request access to browser APIs like the clipboard, camera, and microphone. Daintree enforces per-session allowlists, so each context gets only the permissions it actually needs.

SessionAllowed permissions
App renderer (default)Clipboard read, clipboard write, media (camera, microphone)
persist:daintreeClipboard read, clipboard write, media (camera, microphone)
persist:portalClipboard write only
persist:browserNone
persist:dev-preview-*None

Any permission not in a session's allowlist is silently denied. A session-created event handler catches sessions created at runtime, like new dev preview partitions, and applies the same restrictions to them automatically.

macOS Folder Access

This section is macOS only. The first time a CLI agent running inside Daintree tries to read or write files in certain protected folders, macOS shows a dialog along the lines of "Daintree" would like to access files in your Documents folder. The request came from your agent, not from Daintree itself, but macOS attributes it to the app that launched the agent. Granting access is safe; it lets through exactly what the agent was already trying to do.

Which folders trigger a prompt

macOS gates a specific set of user folders and volumes behind Files and Folders permissions. The set was introduced in macOS Catalina and hasn't changed through current releases. You'll usually see a prompt the first time an agent touches any of these:

  • Desktop, Documents, and Downloads
  • Removable volumes (USB drives, SD cards)
  • Network volumes (mounted SMB, AFP, or NFS shares)
  • iCloud Drive and third-party cloud storage served through the File Provider API (Dropbox, OneDrive, Google Drive)

A project working directory stored outside these locations won't trigger prompts on its own, so day-to-day agent work inside a worktree stays quiet. You'll normally see the dialog only when an agent reaches outside the project, for example to read a reference file in ~/Documents or scan an attached drive. The exception is a project that lives inside a protected location. A repo cloned into ~/Documents, ~/Desktop, or iCloud Drive triggers prompts even during ordinary in-project work. Keeping repos in cloud-synced folders also causes git and terminal issues on top of the permission prompts; see Cloud-Synced Folder Warning in troubleshooting for the full picture.

Why Daintree's name appears

Daintree runs CLI agents inside its own process tree. The spawn chain is Daintree's main Electron process, a sandboxed PTY host utility process, node-pty, your shell, and finally the agent (claude, gemini, codex, and so on). macOS tracks a file access request back to the root signed app bundle in that chain, which is org.daintree.app. Every child process inherits Daintree's identity for permission purposes.

That's why the dialog names Daintree even when Claude Code or Gemini CLI is the one asking for the file. It's standard macOS behavior for any app that spawns sub-processes, not a Daintree design choice, and an Electron app has no way to re-attribute the request to the child process.

Note
When Claude Code, or any other agent, reads a file in ~/Documents, macOS shows the dialog under Daintree's name because Daintree is the signed app bundle that launched the agent. Clicking Allow grants access for exactly what the agent requested, nothing more. Daintree itself doesn't reach into these folders on its own.

Granting and revoking access

The simplest path is to click Allow when the dialog appears. That grants per-folder access on demand, which keeps the permission surface as tight as it can be. macOS remembers the choice, so you won't be asked again for that folder.

To review or revoke access later, open System Settings > Privacy & Security > Files and Folders, find Daintree in the list, and toggle individual folders on or off. If you clicked Don't Allow by mistake and want the dialog back, the same panel lets you re-enable the folder. You can also reset permissions from the command line using Daintree's bundle identifier:

# Reset a single folder (Documents shown here)
tccutil reset SystemPolicyDocumentsFolder org.daintree.app

# Or other protected folders
tccutil reset SystemPolicyDesktopFolder org.daintree.app
tccutil reset SystemPolicyDownloadsFolder org.daintree.app

# Reset every TCC privacy grant for Daintree at once.
# This also clears microphone, camera, and any other approvals,
# so Voice Input and similar features will re-prompt next time.
tccutil reset All org.daintree.app

After a reset, the next agent access to that folder triggers the dialog again. Prefer the per-service commands unless you want to wipe every privacy grant at once.

Tip
If your agents routinely work across many protected folders, granting Daintree Full Disk Access is the lowest-friction option. Open System Settings > Privacy & Security > Full Disk Access and add Daintree to the list. That replaces every per-folder prompt with a single one-time grant. It's broader than per-folder permissions, so only enable it if that trade-off suits your workflow.

Data Storage

GitHub Token

Your GitHub personal access token is stored in Daintree's electron-store config file as plain text. That's the same security model as ~/.gitconfig or a .env file, not encryption in the OS keychain. On macOS and Linux the config file's permissions are tightened to 0o600, readable and writable only by your user account. The token is reachable only from the main process; it never goes to the renderer or to Daintree.

Project Environment Variables

Sensitive environment variable values, the ones whose names contain KEY, SECRET, TOKEN, or PASSWORD, are stored in electron-store, separate from the plaintext settings.json. This is file-level isolation, equivalent in security to a .env file, not OS keychain encryption. Standard env vars are stored in plain text in the project's settings.json.

Local Data

Everything else (settings, recipes, project state) is stored locally in Daintree's app data directory through electron-store. No data leaves your machine except:

  • GitHub API: when GitHub is configured in Code Forge, and only with your token
  • Update server: version checks to updates.daintree.org (Daintree builds) or updates.canopyide.com (legacy Canopy builds)
  • Sentry: crash reports and optional usage analytics, only if you enable telemetry

Telemetry & Privacy

Daintree has an opt-in telemetry system for crash reporting and anonymous usage analytics. Telemetry is off by default and stays off until you explicitly pick a level.

Note
No telemetry data leaves your machine unless you turn it on in Settings > Privacy & Data. The default is off, and you can change your choice at any time.

Telemetry Levels

You pick one of three levels. Each one sets what Daintree sends, or doesn't send, to Sentry, the crash reporting service.

LevelWhat is sent
Off (default)Nothing. No crash reports, no analytics, no network requests to Sentry.
Errors OnlyCrash reports and error stack traces. No usage analytics.
Full UsageCrash reports plus a small set of anonymous usage analytics events.

A change to your telemetry level takes effect on the next app restart.

What Is Never Collected

Whatever your telemetry level, Daintree never collects:

  • Source code or file contents
  • Agent prompts, outputs, or conversation history
  • API keys or credentials
  • File names or directory structures from your projects (note: sanitized partial paths may appear in crash report stack traces if telemetry is enabled: the home directory prefix is stripped, but not every path segment)
  • Personally identifiable information

Path Sanitization

Before any crash report reaches Sentry, a beforeSend hook strips your home directory from every stack trace, error message, and file path. Your system username is replaced with ~ on macOS and Linux, or USER on Windows. This runs automatically and can't be bypassed.

Crash Report Env Suppression

Node.js's diagnostic-report feature (process.report.getReport() and the various crash-triggered variants) includes a top-level environmentVariables field by default. Even after path-stripping and secret scrubbing, that field is a known PII vector: it carries arbitrary user-set variables that no pattern set will catch in full.

Daintree handles this two ways, depending on where the report comes from. The three utility processes Daintree forks (MainProcessWatchdogClient, WorkspaceHostProcess, and PtyHostLifecycle) are launched with --report-exclude-env in their execArgv. The flag landed in Node 22.13.0 and 23.3.0 and tells the diagnostic-report machinery to omit environmentVariables entirely, so the field never reaches the report payload from those processes.

The main Electron process can't take the flag the same way, since execArgv on the main process is owned by Electron's bootstrap. Instead, DiagnosticsCollector.collectMainNodeReport() calls process.report.getReport() and rebuilds the result from a field allowlist, dropping environmentVariables at construction time. Different mechanism, same outcome.

Pre-Consent Buffering

During first launch, before you make a telemetry choice in the onboarding flow, up to 100 usage events are buffered in memory. None of them are sent anywhere yet.

  • If you choose Full Usage, the buffer is flushed to Sentry.
  • If you choose Errors Only or Off, the buffer is discarded. Those events are gone for good.

Full Usage can only be selected in Settings, after onboarding. The first-run onboarding flow offers a binary toggle: crash reports on, which means Errors Only, or off. So in practice the pre-consent buffer is always discarded once onboarding completes, unless you later switch to Full Usage in Settings on a fresh install.

First-Run Consent

When you first launch Daintree, the onboarding flow shows a "Help improve Daintree" toggle. Turning it on sets your level to Errors Only. Leaving it off, or clicking Skip, sets telemetry to Off. You can change this later in Settings > Privacy & Data.

Changing Your Level

Open Settings > Privacy & Data > Telemetry (Cmd+,) and pick your level. The change applies after you restart Daintree. For a full walkthrough of the settings tab, see Settings > Privacy & Data.

MCP Server

Daintree exposes an opt-in local MCP server for external tools, and it also provisions per-help-session MCP wiring for the agents it launches itself. The two surfaces have separate trust boundaries.

Network and Auth

The local MCP server is disabled by default and binds to 127.0.0.1 only, so it's never reachable from the network. On first start the server generates a bearer API key in the format daintree_<32-hex>, and every connecting client has to pass it in an Authorization: Bearer header. Rotate or re-copy the key from Settings > MCP Server; rotating it invalidates every external client still holding the previous key.

Cross-Agent Token Isolation

When Daintree launches an agent that needs MCP wiring (the Help Agent, code search, and similar internal tools), it mints a per-help-session token scoped to a single agent type. A token issued for a Claude help session isn't valid for a Codex, Gemini, or Copilot spawn, and the reverse holds too. Token reuse across agent types is the attack pattern this guards against.

Enforcement runs at the spawn-argument builder. getCodexLaunchArgs(), getGeminiLaunchArgs(), and getCopilotLaunchArgs() each return null when handed a token from a different agent type, and the caller in lifecycle.ts throws an explicit error: Daintree Assistant help token does not belong to a Codex session; refusing to spawn, with equivalent messages for Gemini and Copilot. This is a hard spawn failure. The process never starts.

Token transport is agent-shaped too, never a literal command-line argument:

  • Codex reads DAINTREE_MCP_TOKEN from its PTY environment.
  • Gemini picks the token up from <sessionPath>/.gemini/settings.json.
  • Copilot picks it up from <sessionPath>/.mcp.json.

Because the token never appears in argv, it can't leak through ps output, shell history, or a stray log line.

Note
A mismatched token is a hard spawn failure with an explicit error, not a silent downgrade. If you see a refusing to spawn error in the logs, the token-to-agent mapping has been corrupted; restart the agent session to re-mint it.

Single Instance

Daintree enforces a single-instance lock. Only one copy of the app runs at a time. Opening a second instance just focuses the existing window. This avoids conflicts with file locks and terminal processes.

Window Close Protection

Cmd+W closes the focused terminal panel, not the application window. That keeps you from quitting by accident and losing running agent sessions.