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 project trust, archive previews, and the runtime isolation limits.
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.
The fifteen capabilities
Seven are classified as high-risk. They can raise action danger to confirmation and allow a higher ceiling on plugin MCP tool danger. Per-command requires narrows which declared capabilities inform that action classification.
| Token | What it gates or discloses | High-risk |
|---|---|---|
fs:project-read | Read files in the current project worktree. Gates host.fs reads. | |
fs:project-write | Write in the project worktree. Gates host.fs writes. | yes |
fs:user-data-read | Read under the Daintree data directory or elsewhere in the home directory. Gates host.fs and host.system. | |
fs:user-data-write | Write there. | yes |
network:fetch | Outbound HTTP. Attenuated by a network scope. | |
agent:invoke | Send prompts to agents from plugin code. | yes |
agent:read | Observe agent state. Gates host.getAgentState and its subscription. | |
agent:register | Required for contributes.agents: the schema rejects the array without it. | yes |
agent:input | host.sendToActiveAgent. Just-in-time consent on first use. | yes |
git:read | Read git state. Gates host.git.status and diff. | |
git:write | Mutations. Gates host.git.add and commit. | yes |
clipboard:read | host.clipboard.readText. Text only: there is no image, HTML, or file-list read. | |
clipboard:write | writeText up to 8 MiB and writeImage up to 20 MiB. | |
shell:exec | host.process.spawn. A managed spawn without the declaration rejects; first use also requires consent. | yes |
socket:connect | Local 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
By default, when a manifest holds any of the seven high-risk tokens, its actions are 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
requiresand 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 holdingshell: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:execornetwork:fetch). - Remote-controlled mutation:
network:fetchpaired with a local write or a shell sink.
A non-empty network allowlist attenuates the compound elevation based on declared intent. It is not proof that requests only reach that host: the host does not intercept raw network calls. Filesystem scope does not suppress the high-risk classification of writes.
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.
| Scope | Strength |
|---|---|
scopes.fs.allowedPaths | Runtime-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.allowedUrls | Live but advisory. It suppresses lattice elevation. It does not block requests: there is no interception point for a plugin's own fetch. |
scopes.socket.allowedPaths | Purely 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.
Just-in-time consent
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.
Five capability tokens 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.
Archive replacement and uninstall revoke grants. 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. Known project plugins follow a different update contract: source and build changes reload without a new folder approval. Revoking project trust purges that project's instance grants; an individual mute preserves them. See Project Plugin Trust & Management.
Plugin MCP tools
An installed or builtin plugin can contribute MCP servers; project manifests cannot. Their tools become callable through the plugin MCP surface. 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 prompts even for a D0 read-only tool. Remembered approvals are reused 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) 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. A failed fetch retains cached entries; with no usable cache, it fails open. Failure to refresh does not clear an existing block. 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 authority is an opaque per-load value minted by Daintree; 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
User-installed and project-plugin main entries run in utility workers with separate module realms and process crash isolation. Builtin entries activate in the main process. React view components are imported into Daintree's renderer, outside the worker, and use the host's React instance. The worker boundary does not isolate every part of a plugin or guarantee that a view cannot affect its renderer. Unloading discards worker memory; settings, storage, and saved panel state can survive.
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.
Runtime limits
Read these limits alongside the specific host API gates above.
- No runtime sandbox. A plugin's
maincan callnode: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. Verify the source as well as the transport; a hash alone does not authenticate it.
- Installation prompts depend on the route. OS-opened and dropped
.dntrarchives have a pre-install manifest preview with capabilities and recipe names. File-picker, URL, CLI, and sideload routes are not one universal consent gate; the URL UI warns about HTTP, but the shared fetch guard still requires HTTPS. Project plugins have their own folder trust banner. None of these previews authenticates the publisher or sandboxes the code. - 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:execis suspicious. A forge integration asking fornetwork:fetchis expected. - Is the network scope narrow? An unconstrained
network:fetchpaired with any read or write capability is the compound class worth pausing on. - Where did the archive come from? Review the recorded file or URL source and the corresponding repository. A local file also needs a trustworthy origin.
- 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.
Audited implementation: high-risk tokens, danger derivation, capability consent, worker activation, and archive preview. Links are pinned to September 5, 2026.