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.
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.handleandipcMain.handleOncechannels return a wrapped error to untrusted originsipcMain.onchannels 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:
| Category | Budget |
|---|---|
fileOps | 4 MiB |
artifactOps | 4 MiB |
gitOps | 512 KiB |
terminalSpawn | 256 KiB |
| Default | 1 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.
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.
| Category | Limit | Window | Strategy |
|---|---|---|---|
| File operations | 5 calls | 10 s | Fail-fast |
| Git operations | 10 calls | 10 s | Fail-fast |
| Terminal spawn | 10 calls | 30 s | Queue |
| Worktree creation | 20 calls | 30 s | Queue |
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-originCross-Origin-Embedder-Policy: credentiallessX-Content-Type-Options: nosniffCross-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 throughcreateTrustedHTML(html). Every string that reaches it comes from a compile-time SVG constant or from the upstreamsanitizeSvgvalidator, 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 adefaultpolicy installed, those writes would throwTypeError: This document requires 'TrustedHTML' assignmentand 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.
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
nodeIntegrationdisabled,contextIsolationenabled, andsandboxenforced - Have their own storage partitions, so cookies and localStorage stay isolated
- Cannot reach Daintree's internal APIs
- Have
navigateOnDragDropdisabled, which closes drag-and-drop navigation hijacking - Disable the Blink
Auxclickfeature 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:
- Method check. Only
GETandHEADare accepted; everything else returns 405. - Null-byte check on both
pathandroot. Reject if either contains\0. - Absolute-path check. Both must be absolute. Relative paths are rejected without resolution.
path.normalize()on both, collapsing..segments.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.- Relative-containment check. The realpath'd
pathmust be a descendant of the realpath'droot. - Size cap. Files larger than 512 KiB return 413 Payload Too Large.
fs.open()withO_NOFOLLOWon the user-supplied path. This closes the TOCTOU gap betweenrealpathand 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-Typepicked from an extension allowlist, not from content sniffingContent-Lengthfrom the actual buffer length, in case the file grew betweenstatandreadContent-Security-Policy: sandbox; default-src 'none'so the served file can't navigate, script, or fetch, whatever MIME type it declaresCross-Origin-Resource-Policy: cross-originso the renderer running onapp://daintreecan load it; asame-originvalue would block legitimate loads, since the schemes differX-Content-Type-Options: nosniffCache-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:
| Override | Threat neutralized |
|---|---|
core.fsmonitor=false | Prevents arbitrary code execution via fsmonitor hook |
core.hooksPath= | Prevents malicious hooks from running |
core.pager=cat | Prevents 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=never | Prevents RCE via ext:: protocol URLs |
core.untrackedCache=false | Disables 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.
| Platform | Escaping applied |
|---|---|
| macOS, Linux | Wrapped in single quotes. Internal ' becomes '\''. For example, $(whoami) substitutes as '$(whoami)' and is passed to the shell as a literal string. |
| Windows | Wrapped 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.
git checkout "{{branch}}" produces git checkout "'my-branch'" after substitution, which double-quotes the value and breaks the command. Keep placeholders bare and let Daintree handle the quoting.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:
| Category | Examples |
|---|---|
| Source hosts | GitHub PATs (ghp_, github_pat_, ghs_, ghu_, gho_), GitLab tokens (glpat-, gldt-) |
| AI providers | Anthropic (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 infra | AWS 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 SaaS | Vercel (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) |
| Messaging | Slack (xoxb-, xoxp-, xapp-, xoxe-), SendGrid (SG.), Telegram (context-anchored), Datadog (context-anchored) |
| Generic shapes | PEM 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
beforeSendhook inTelemetryService DiagnosticsCollectorstring outputLoggerfile-write and console-mirror paths (emitandemitError)- Main-process emergency crash log
- PTY-host emergency crash log
- IPC error envelopes (both
messageanduserMessage) WorktreeLifecycleServicetail-output scrub before renderer deliveryAgentInstallServiceprogress outputAgentHelpServicecommand outputAgentVersionServiceerror messagesWorktreeLifecycleServiceteardown 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.
| Platform | Allowed keys |
|---|---|
| POSIX (macOS, Linux) | PATH, HOME, LANG, LANGUAGE |
| Windows | PATH, 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.
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.
| Session | Allowed permissions |
|---|---|
| App renderer (default) | Clipboard read, clipboard write, media (camera, microphone) |
persist:daintree | Clipboard read, clipboard write, media (camera, microphone) |
persist:portal | Clipboard write only |
persist:browser | None |
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.
~/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.
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) orupdates.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.
Telemetry Levels
You pick one of three levels. Each one sets what Daintree sends, or doesn't send, to Sentry, the crash reporting service.
| Level | What is sent |
|---|---|
| Off (default) | Nothing. No crash reports, no analytics, no network requests to Sentry. |
| Errors Only | Crash reports and error stack traces. No usage analytics. |
| Full Usage | Crash 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_TOKENfrom 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.
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.