Skip to main content

Trust & Capabilities

What a Daintree plugin's declared capabilities actually mean: the hybrid disclosure-plus-policy contract, all fifteen capability tokens, the compound lattice, scope attenuation, just-in-time consent, MCP tool consent and auditing, the blocklist, and the explicit non-guarantees of the 1.0 model.

Reviewed

A plugin declares what it needs in its manifest's capabilities array. This page is the full account of what that declaration does, and just as importantly, what it does not do. If you are deciding whether to install a plugin, or writing one and wondering how much friction a token buys, this is the page to read.

The contract in one line

Capabilities are disclosure-first with host-side policy effects. The host does not sandbox plugin code. The declaration is surfaced in the plugin manager, it drives host-derived danger classification on every action the plugin registers, it gates several host APIs at runtime, and it caps how dangerous a plugin's MCP tools are allowed to be. It is not an enforcement boundary against malicious code. It is an honest, machine-readable description of what a plugin claims to need, which the host uses to apply proportional friction at the points that matter.

That hybrid position is deliberate, and it comes from a constraint rather than a preference. Pure disclosure is the model VS Code, Obsidian, Cursor, and JetBrains all ship, and its known failure mode is the compound attack: an extension declares nothing individually alarming, then combines the implicit filesystem and network access every editor extension already gets into exfiltration. Pure disclosure has no surface on which to detect that, because nothing ever consults the declaration. The opposite extreme is no better here: pure runtime gating is what Zed gets from a WebAssembly boundary and Tauri gets from its IPC bridge, and Daintree has neither. Claiming enforcement that a plugin bypasses with one require call would be worse than admitting the limit.

So: be honest that Node is not sandboxed, and still make the declaration load-bearing everywhere the host can act on it.

The fifteen capabilities

Seven of them are high-risk. Holding any one raises every action the plugin registers to a confirm prompt, and lifts the ceiling on how dangerous its MCP tool surface may be.

TokenWhat it gates or disclosesHigh-risk
fs:project-readRead files in the current project worktree. Gates host.fs reads.
fs:project-writeWrite in the project worktree. Gates host.fs writes.yes
fs:user-data-readRead under the Daintree data directory or elsewhere in the home directory. Gates host.fs and host.system.
fs:user-data-writeWrite there.yes
network:fetchOutbound HTTP. Attenuated by a network scope.
agent:invokeSend prompts to agents from plugin code.yes
agent:readObserve agent state. Gates host.getAgentState and its subscription.
agent:registerRequired for contributes.agents: the schema rejects the array without it.yes
agent:inputhost.sendToActiveAgent. Just-in-time consent on first use.yes
git:readRead git state. Gates host.git.status and diff.
git:writeMutations. Gates host.git.add and commit.yes
clipboard:readhost.clipboard.readText. Text only: there is no image, HTML, or file-list read.
clipboard:writewriteText up to 8 MiB and writeImage up to 20 MiB.
shell:exechost.process.spawn. The one hard runtime gate: a spawn without it rejects outright.yes
socket:connectLocal Unix sockets and Windows named pipes, the Docker socket being the motivating case. Disclosure only.

socket:connect is deliberately outside the high-risk set even though what it reaches can be powerful. The host has no interception point for raw socket connections, so treating it as gated would imply enforcement that does not exist. It is disclosed so the manager can say "connects to the Docker socket" instead of showing a bare token.

The field is named capabilities. An older permissions key is not accepted.

{
  "capabilities": ["git:read", "network:fetch"],
  "scopes": {
    "network": { "allowedUrls": ["https://api.linear.app/"] },
    "fs": { "allowedPaths": ["${worktree}/.linear"] }
  }
}

What a declaration actually does

It raises action danger

When a manifest holds any of the seven high-risk tokens, every action that plugin registers is raised to a confirm classification, regardless of what the action itself declared. The host may only raise, never lower: a plugin cannot declare its way out of a prompt. That classification gates the confirm dialog, eligibility for the recently-used rail, and repeat-last-action.

This is host-side policy on Daintree's own action system. It does not stop the plugin executing code.

Raising every action for one capability used to be crude: a plugin needing shell:exec for a single command put a destructive confirmation on its "open the panel" command too. Per-action capability intent fixes that without the dishonest workaround of dropping the capability.

{
  "id": "open-panel",
  "title": "Open Panel",
  "description": "Opens the tools panel.",
  "category": "Flutter Tools",
  "kind": "command",
  "danger": "safe",
  "requires": []
}
  • Omit requires and nothing changes: the whole manifest is consulted, as before.
  • requires: [] declares that this command exercises no capability, so it stays one click even in a plugin holding shell:exec.
  • requires: ["git:read"] consults only those capabilities, for both the high-risk set and the compound lattice.

Three things it does not do. It grants no access: host APIs still gate on the manifest's capabilities at call time, so naming a token here neither adds nor removes runtime authority. It cannot lower a self-declared confirm. And every entry must appear in capabilities: naming one you did not declare fails the command's registration outright, so a typo surfaces at load rather than quietly reverting to the old behavior.

The compound lattice

Individually benign capabilities can be dangerous together, and that combination is precisely what pure disclosure cannot see. The host raises danger for two compound classes even when no single token triggers:

  • Exfiltration: a sensitive read paired with an unconstrained sink (shell:exec or network:fetch).
  • Remote-controlled mutation: network:fetch paired with a local write or a shell sink.

A tight network scope attenuates the elevation, because a fetch that can only reach one host cannot be remote-controlled. That is the intended incentive: declare narrowly and the friction drops.

Scopes, by how much they bind

Scopes live in a top-level scopes object, not per capability. They do not all bind equally, and the difference is worth knowing before you rely on one.

ScopeStrength
scopes.fs.allowedPathsRuntime-enforced for host.fs, host.git, and host.system. Every path argument is realpath-resolved and contained; a traversal or a symlink that escapes rejects. Supports project and worktree tokens with optional sub-path suffixes.
scopes.network.allowedUrlsLive but advisory. It suppresses lattice elevation. It does not block requests: there is no interception point for a plugin's own fetch.
scopes.socket.allowedPathsPurely advisory. It exists so the manager can name what the plugin connects to.

Wildcards are rejected at the schema boundary, as are credential-bearing and private-host URLs. A misspelled scope bucket is a manifest error rather than a silent no-op.

How enforcement surfaces

The host-mediated APIs fail with prefixed errors so a plugin (and the renderer hook wrapping it) can discriminate.

PERMISSION_REQUIRED:  the capability was never declared, or the user denied consent
PATH_NOT_ALLOWED:     the path resolved outside scopes.fs.allowedPaths

The honest limit: this gates the sanctioned path only. A plugin's main is un-sandboxed Node running in its worker, and it can call node:fs directly. host.fs gives a contained, audited route; it does not seal the un-mediated one.

Declaring a high-risk capability answers "may this plugin ever do X". It does not answer "has the user agreed to it doing X now". For the capabilities where the gap matters, the first call raises a prompt.

Four capabilities are gated this way: shell:exec, fs:project-write and fs:user-data-write, git:write, and agent:input. The prompt names the plugin, the capability, and the plugin's full declared capability list. Approving pins the grant, so later calls run silently; denying throws a permission error into the plugin. Concurrent first-use calls are coalesced onto one prompt, so a plugin firing several spawns at once raises one dialog rather than a stack. Built-in plugins skip the prompt: they are app-bundled first-party code.

Grants are revoked when a plugin updates. A same-id reinstall or upgrade purges both the capability grants and the MCP consent pins, so new code never silently inherits the previous version's approvals. Uninstall purges them too, which means reinstalling the same plugin name re-prompts rather than resuming where it left off. This matters because plugin ids are author-controlled and unsigned: without the purge, an approval would attach to a name rather than to code.

Plugin MCP tools

A plugin can contribute MCP servers, whose tools become callable by agents. That is the sharpest edge in the whole system (a tool surface an agent invokes on its own), so every call passes through three stages: consent, audit, rate limit.

Consent is trust on first use. Each tool is pinned by a fingerprint over its raw description bytes, its input schema, and its tier-influencing annotation hints. The first call to a tool prompts. Later calls run silently while the fingerprint matches, and re-prompt with "this tool changed" framing when it does not:

  • the raw description bytes mutated, which is the rug-pull case, flagged regardless of whether the rendered text looks identical;
  • the input schema mutated, so the call surface changed;
  • the annotation hints mutated, so the advertised danger surface changed.

The prompt shows an ANSI-stripped description and, at higher tiers, a redacted args preview. Raw description bytes never reach the renderer; they are hashed and discarded. A user can approve once without pinning, approve and pin, or reject. An abandoned prompt times out after five minutes and fails closed, recorded distinctly from a deliberate refusal so an operator can tell them apart.

The tier cap is where the manifest binds the tool surface. Calls are classified D0 (read-only, no dialog) through D3 (catastrophic, reserved). A plugin's declared capabilities cap the tier its tools may reach: a plugin that declared none of the seven high-risk tokens cannot have its server reach D2, "shared-state mutation", merely by advertising a destructive hint. A call above the cap is denied, not downgraded: a downgrade would let the model present a mutation as read-only and slip past the audit narrative.

Every dispatch is written to a per-record audit ring, and every server has its own token bucket: a burst of 20 with sustained refill of one per second, keyed per plugin and server so one plugin's tool spam cannot throttle another's.

The audit trail

Plugin activity is recorded in a structured, persisted ring buffer: 500 records by default, configurable between 50 and 5000. Three kinds of event land in it:

  • Action dispatch: a plugin-contributed action running through the action service, with its source and danger classification.
  • IPC invoke: a call into a plugin's own registered handler. Both success and failure are recorded at the dispatch boundary, so a benign-looking invoke cannot run unobserved, and a trust-check rejection on the raw channel is recorded too.
  • Decoration failure: a file-decoration provider that rejected or exceeded its budget. Successful pulls are not audited.

Writes through host.fs, git mutations, process spawns, and successful host.system calls are recorded as well. Privacy is the default: args are stored as a SHA-256 digest of the redacted summary, and a plaintext summary is written only when a developer explicitly opts in, capped so one oversized payload cannot bloat the store.

Scrubbed logs

Everything a plugin writes through host.logger is run through Daintree's secret scrubber before it reaches either sink: the per-plugin ring buffer that feeds shareable diagnostics, and the console mirror. Scrubbing happens before the line-length cap, so a secret straddling the truncation boundary is fully redacted rather than bisected into a fragment the scrubber would no longer match.

The blocklist

Daintree fetches a small remote blocklist at startup: plugins it refuses to load, matched by name and version range. It is a security response for a known-compromised plugin, not a deprecation mechanism.

The fetch has an eight-second ceiling so a hung endpoint cannot delay plugin loading, a six-hour freshness window so an entry propagates to running installs within hours rather than a day, and an on-disk cache so a stale list is still enforced offline. It fails open: a network or parse failure never blocks a user's plugins. A blocked plugin still appears in the manager, with the reason shown, rather than vanishing.

Asset containment

A plugin's static assets and view bundles are served over a dedicated plugin:// protocol rather than from disk paths or a bundled web server. The host segment of the URL is the plugin id; the path resolves against that plugin's installed directory, is realpath-contained, and rejects anything that escapes the root. There are no directory listings, and a request naming an unknown or disabled plugin returns a 404 without disclosing whether the id exists.

The scheme is registered as a hardened first-party scheme: standard, secure, and explicitly without CSP bypass. Because a plugin view is lazy-imported as a module, plugin: appears as a narrow allowance in the app's own content-security-policy script directive. That narrow directive expansion is the minimum surface that makes plugin views work; the alternative of exempting the scheme from CSP entirely was rejected outright.

Process isolation

Every user-installed plugin runs out of process, in a utility worker with its own module realm and OS-level crash isolation; only built-in plugins stay in-process. That buys three real things: a plugin crash cannot take the app down, unloading reclaims the entire module realm, and there is no state surviving a reload.

It does not buy a security boundary. The worker is a separate process, not a sandbox: the plugin's code still runs with the full privileges of your user account, and the host does not intercept its Node calls. Treat the isolation as a reliability property and the capability list as a disclosure property, and you have the model right.

What the 1.0 model does not guarantee

These are stated deliberately, in one place, so a plugin author or a security reviewer can read the whole contract without inferring it from scattered notes. None is an oversight.

  • No runtime sandbox. A plugin's main can call node:fs, spawn subprocesses, and open sockets regardless of what it declared. The capability list governs declared intent through host-side policy; it is not a kernel of enforcement against arbitrary code.
  • No signing and no publisher identity. Daintree verifies a plugin's integrity (a SHA-256 hash over the archive bytes, persisted in the provenance record and used for update detection) but not its authenticity. Archives carry no signature and there is no publisher-identity system. An archive downloaded from a URL you trust is exactly as trustworthy as that URL, and no more.
  • No install-time consent gate. Capabilities are surfaced in the plugin manager's detail pane after install, not in a dialog you must approve before it. A fresh install runs without enumerating capabilities; the only interstitial prompts are the plaintext-HTTP warning on a URL install and the update-preview confirm when re-fetching. The first per-capability consent most users see is the just-in-time prompt at first use.
  • Secrets use the OS keychain when there is one, and plaintext otherwise. Settings declared type: "secret" are encrypted at rest through the platform keychain and stored as a ciphertext envelope. Where no keychain backend exists (typically a headless Linux host) the value falls back to plaintext JSON with restrictive file permissions, and the settings UI says which tier is in use per field. Existing plaintext secrets migrate on their next write rather than being silently dropped. Two caveats remain: a keychain secret is still readable by anything running as your user, and a project-scope secret written on a plaintext-only host is committed in cleartext if the file is tracked.

Reading a plugin before you install it

Given all of the above, the practical questions are short:

  • Do the declared capabilities match what the plugin is for? A theme packager asking for shell:exec is suspicious. A forge integration asking for network:fetch is expected.
  • Is the network scope narrow? An unconstrained network:fetch paired with any read or write capability is the compound class worth pausing on.
  • Where did the archive come from? There is no signature, so provenance is entirely the URL. Prefer a release asset on a repository you can read.
  • Can you read the source? Sideloading from a repository you have inspected is the strongest position available in the current model.

Trust in a plugin's code is the user's responsibility. Install from sources you trust, and inspect plugins that request broad capabilities. Daintree's job is to make that judgment possible, not to make it unnecessary. See Security & Privacy for how the same reasoning applies to the rest of the app.