Skip to main content

Git, Process and Environment Safety

The boundaries around commands Daintree runs on your behalf: git command and pathspec hardening, lifecycle script injection, secret scrubbing before anything is logged, environment variable filtering, and what skip-permissions turns off.

Reviewed

What an agent can reach

Start here, because it frames everything below. An agent Daintree launches is a CLI running as a normal child process under your OS user account. It has the file access, the network access and the credentials that account has. A worktree gives it a separate checkout so two agents do not edit the same working copy; it is not a sandbox, and nothing stops an agent reading or writing a path outside the project.

That is a deliberate trade. The agents Daintree hosts are the same CLIs you would run in a terminal, and confining them would break most of what they are for. Remote Compute is the supported way to move that execution off your machine when the trade is not one you want to make. Everything on this page is about narrowing what Daintree itself does on your behalf, inside that boundary.

Git hardening

A malicious repository can carry git config directives that run arbitrary code the moment git runs inside it. This is a real supply-chain vector: a cloned repo can point core.fsmonitor, core.pager or protocol.ext.allow at a command on your machine.

Daintree routes every git call through a hardened wrapper that forces per-invocation -c overrides. They take precedence over anything in the repository's own config, they apply only to git processes Daintree spawns, and they do not touch your system git configuration or the git commands you run yourself in a terminal.

There are three profiles, not one, because a wrapper that can never authenticate cannot push.

OverrideProfileWhat it neutralizes
core.fsmonitor=falseAllCode execution through the fsmonitor hook.
core.pager=catAllPager injection.
protocol.ext.allow=neverAllExecution through ext:: protocol URLs.
core.gitProxy=AllProxy command injection.
core.untrackedCache=keepAllReads an existing untracked cache without creating one or stripping it on the next index write.
core.quotepath=falseAllNot a threat: correctness. Emits literal UTF-8 so a non-ASCII filename is not returned octal-escaped.
core.precomposeunicode=trueAllAlso correctness. Pins macOS decomposed filenames to composed form so paths from different sources compare equal.
core.askpass=Hardened onlyCredential hijacking through askpass.
credential.helper=Hardened onlyCredential store hijacking.
core.sshCommand=Hardened onlySSH command injection.

The hardened profile is the default and blocks credentials outright. The authenticated profile omits those last three so a push or fetch can actually reach a remote. The safe base is what both are built from.

core.hooksPath is no longer blank. It is appended per call as an app-owned absolute directory under Daintree's user-data folder, so repository hooks stay blocked while Daintree can still run its own small allowlisted set, git-lfs's pre-push hook being the one that matters. A WSL variant resolves the same directory inside the distro, because a Windows-side path is meaningless to git running under Linux.

The inherited environment is sanitized too. GIT_CONFIG, GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM, the numbered GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n pairs, GIT_EXEC_PATH, GIT_EXTERNAL_DIFF, GIT_SSH, GIT_SSH_COMMAND, GIT_PROXY_COMMAND, GIT_TEMPLATE_DIR, the pagers and the editors are all stripped before the spawn. GIT_TERMINAL_PROMPT=0 and a POSIX GIT_ASKPASS that exits immediately block the interactive credential prompt that would otherwise hang the main process forever.

Working directories are checked before any git call runs (only absolute paths are accepted, which blocks relative-path injection from the renderer), and a timeout keeps a single operation from hanging indefinitely.

File paths reach git as literal pathspecs

This one is worth stating plainly, because it was a data-loss class rather than a hardening nicety, and v0.32 closed it.

Every path Daintree hands to git is meant as a literal filename: a name read out of a git file list, a directory derived from a search root. Git does not read them that way by default: it parses them as pathspecs, where wildmatch metacharacters are matched, not taken literally. A perfectly legal filename can therefore select a different file. A Next.js route named pages/[...slug].tsx is a character class matching one of ., s, l, u, g, so it resolves to siblings like pages/s.tsx and not to the file itself.

That mis-selection reached destructive commands. Discarding one file's uncommitted changes could silently discard a different file's instead. A trailing -- does not help; it only stops a leading dash being read as an option.

The hardened environment now sets GIT_LITERAL_PATHSPECS=1 globally, so no future call site can reintroduce the class one command at a time. The three competing global pathspec modes are stripped from the inherited environment, because git hard-fails with exit 128 when the glob or case-insensitive mode is set alongside literal, which would otherwise turn every path-bearing command into a hard failure for anyone who exports one in their shell.

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 substituted into its placeholders. Branch names come from git, so a collaborator pushing a branch named feature/$(whoami) would inject shell into any lifecycle command that substitutes {{branch}} on checkout.

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

PlatformEscaping applied
macOS, LinuxWrapped in single quotes; an internal ' becomes '\''. So $(whoami) substitutes as '$(whoami)' and reaches the shell as a literal string.
WindowsWrapped in double quotes; an internal % becomes %% so environment expansion does not fire, and an internal " becomes "".

One placeholder is deliberately left unquoted: {branch-slug}. Its value is sanitized to [a-z0-9-] at generation time, so the character set itself proves it cannot contain anything the shell would interpret. If a value ever fails that check, it falls back to the same quoting as everything else.

For the full variable list, see Remote Compute > Template variables.

Secret scrubbing

Anything that leaves the main process as a string (log lines, error envelopes, crash report fields, agent probe output, plugin output, MCP argument summaries) passes through one scrubSecrets() utility. It runs 61 regex patterns against the input and replaces every match with the literal string [REDACTED]. It is idempotent, so callers can wrap defensively without producing double-redaction artifacts, and the patterns use bounded quantifiers throughout, so a multi-megabyte log dump still finishes in linear time.

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

CategoryExamples
Source hostsGitHub tokens (ghp_, github_pat_, ghs_, ghu_, gho_), GitLab (glpat-, gldt-)
AI providersAnthropic (sk-ant-), OpenAI (sk-, sk-proj-, sk-svcacct-, sk-admin-), OpenRouter, Perplexity, xAI, Together, Groq, Replicate, Hugging Face
Cloud and infrastructureAWS access keys (AKIA, ASIA, ABIA) and context-anchored secret keys, Google API keys, Azure connection strings, DigitalOcean, Cloudflare
Deploy and SaaSVercel, Heroku, Resend, Stripe live and test keys, Supabase, Linear, Notion, Atlassian
MessagingSlack (xoxb-, xoxp-, xapp-, xoxe-), SendGrid, and context-anchored Telegram and Datadog
Generic shapesPEM private key blocks, JWTs, Bearer tokens, OAuth access_token / refresh_token / client_secret query parameters, https://user:pass@host basic-auth URLs, and env-shaped fallbacks for credential-named variables

Context-anchored means the pattern looks for the surrounding key name rather than the token shape, because the value itself is a bare alphanumeric string nothing else can distinguish from an arbitrary identifier.

The scrubber is wired into roughly twenty modules across the main process, the workspace host, the PTY host and the shared layer. In broad strokes:

  • Logging and crash paths: the logger's file-write and console-mirror, both emergency crash logs, the diagnostics collector, and the telemetry beforeSend hook.
  • IPC and error surfaces: error envelopes, both message and userMessage.
  • Agent surfaces: install progress, help output, version-probe errors, and worktree lifecycle tail output before it reaches the renderer.
  • Newer surfaces: project check output, git clone output, forge audit records, the MCP HTTP lifecycle, and MCP argument summaries.
  • Plugin surfaces: plugin log output is scrubbed before it reaches the log buffer and console mirror, as are plugin diagnostics. A plugin that logs a credential does not get it into your log file.

Anything outside that set which emits user-shaped strings has to call scrubSecrets() explicitly.

Environment variable filtering

When Daintree spawns a terminal or agent process, it filters the inherited environment so credentials do not leak. Without the filter, a process in the terminal would inherit your shell's API keys, database passwords and cloud credentials by default.

Blocklist

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. On top of that, a boundary-aware pattern 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. MY_SECRET_VALUE matches; SECRETARIAT does not.

DAINTREE_* anti-spoofing

Every inherited DAINTREE_* variable is stripped before each spawn, and Daintree then injects fresh, known-safe values for its own metadata. A project config or an external tool cannot spoof Daintree's internal environment into a child process.

Agent CLI probe sandboxing

Daintree discovers installed agents by probing them: --version to detect what is installed, --help to pull usage text. These probes need almost nothing from the parent, so they are spawned with an allowlist rather than the blocklist used for real terminals. For a process that needs four variables, an allowlist is the only structurally sound choice; the set of credential names is unbounded, but the set of things a probe needs is not.

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

Both platforms also pass through LC_* locale extensions and Daintree's own namespace, and always set TERM=dumb so a probe cannot emit cursor escapes or start paging. Everything else is dropped, including LD_PRELOAD and DYLD_INSERT_LIBRARIES (library injection), NODE_OPTIONS (debug-protocol injection) and ELECTRON_RUN_AS_NODE, which would turn Daintree's own binary into a Node interpreter and defeat the sandbox. Nothing is explicitly denied: anything off the list is simply absent.

Probe stdout and stderr both run through secret scrubbing before reaching the renderer, so even an 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 does not touch the project environment variables you define in Project Settings: those are injected at spawn time, after filtering, so the keys you set on purpose still reach the agent.

Skip permissions and DangerousMode

Coding agents prompt before they act. Bypassing those prompts is the single most security-relevant setting Daintree exposes, so it is worth being exact about what it does.

Turning it on appends the agent's own bypass flag to its launch command: --dangerously-skip-permissions for Claude, --dangerously-bypass-approvals-and-sandbox for Codex, --yolo, --force, --trust-all-tools and others for the rest of the roster. Daintree is not the thing granting permission; it is passing a flag to a CLI that then stops asking. Every guarantee after that point belongs to the agent, not to Daintree. Nothing on this page contains an agent that has been told not to ask.

The setting is a tri-state (inherit, on, off) resolved through a chain rather than a single boolean, and modeled on explicit-deny precedence: a more specific off always vetoes a broader on.

LevelEffect
PresetAn explicit on or off wins over the agent's own setting. A preset off is a true veto: it beats the global switch.
AgentAn explicit on force-enables for that agent; off force-disables it.
GlobalSkip permission prompts for agents, off by default. Only reached when both levels above say inherit.

The global switch is a live override rather than a default: it is combined into the decision at flag-generation time and never mutates any per-agent setting, so turning it off immediately stops injecting bypass flags on future spawns, restarts and resumes. That also closes the resume trap: a session captured under one setting does not carry a stale bypass flag forward when you flip the setting and restart it.

The Daintree Assistant keeps its own independent bypass setting; the global switch is scoped to normal agent terminals. See AI Agents for per-agent configuration and Global Settings for where the switch lives.