All docs

Plugin docs

Plugin security model

What a plugin can and cannot do, how the sandbox and scoped credentials enforce it, and the risks that remain.

Status: v1. This page is what we promise users about plugins and what we deliberately do not promise. It is also the checklist every plugins PR is reviewed against.

Related: Permissions, Manifest.

The promise, in one paragraph

A Cuttle plugin can do only what its manifest declares, what the user granted at install, and what the installing user could do anyway. Declarative contributions contain no code. Plugin code runs in a sandbox with no ambient authority; everything it touches goes through a Cuttle API that checks its grants on every call. Anything that cannot be sandboxed — local processes (MCP servers, runners) and agent sessions with a shell — is labelled critical and unsandboxed on the install sheet, is never granted silently, and is approved separately on each device.

Assets

  • The Cuttle database, master key and data directory (~/.ccc).
  • Provider logins and API keys, and the spend they authorize.
  • Project secrets and the git credentials used for repos.
  • Repositories and their working trees.
  • The ability to spawn agent sessions — which is code execution as the Cuttle OS user.
  • The server's network position: it can reach the LAN, the tailnet and loopback services the internet cannot (SSRF).
  • The integrity of what agents are told (skills, agent prompts) in each project.

Adversaries

  1. A malicious plugin author publishing a plugin that over-reaches.
  2. A compromised update of a benign plugin.
  3. A tampered package in transit or at rest (download mirror, shared file).
  4. Prompt injection through plugin-supplied skills, agent prompts or automation prompts, or through data a plugin feeds to an agent.
  5. A malicious org member on a multi-user server installing personal plugins.

Out of scope: a local OS user who can already write ~/.ccc (they own the box), and a compromised Cuttle server binary.

Boundaries

Part of a pluginRuns whereBounded by
Declarative contributions (skills, agents, automations, pipelines, settings)No code — materialized by CuttleGrants + the installing user's rights + session caps
Host code (contributes.host)QuickJS/WASM runtime in a separate, permission-restricted Node processSandbox → Node permission model → scoped API bridge
API calls (from the host, or from sessions it starts)Cuttle serverScoped credential, route→capability map, default-deny
MCP servers, runners (process.spawn)Local OS process as the Cuttle userNothing but the OS. Critical, unsandboxed, per-device approval.
Agent sessions (sessions.spawn)Claude Code / ACP runnerSession cap: runners, models, permission mode, tool allow/deny. With Bash, equivalent to the OS user.

Scoped credentials

Every installation acts as its own principal, never as the user's browser session:

  • API calls use a scoped token bound to the installation. Its grants are looked up live on every request, so revoking a permission, disabling the plugin, a policy block, or turning the plugins flag off takes effect on the next call.
  • Capabilities intersect with the user's own authorization. A scoped token can never do more than its user, and every requireAdmin / denyControlToken route is automatically unreachable (see Permissions → Never grantable).
  • The route→capability map is default-deny. A static drift test fails the unit suite when a new route is neither mapped nor explicitly denied, so a new endpoint cannot become silently reachable.
  • Sessions a plugin starts get a scoped control token, and receive CCC_SESSION_TOKEN only when secrets.read is granted. That token is itself restricted to the secrets and run-artifact routes of its bound session's project, so a session cannot lift a browser-equivalent credential from its own environment.
  • Scoped credentials are refused on the Socket.io handshake in v1.

Two pre-existing gaps were closed as part of this layer: api_keys.expires_at is enforced, and the session-secrets scope on user_sessions is honoured — on HTTP, on the Socket.io handshake, and when the JWT is presented as a cookie on a public path.

Plugin sessions

  • Credential. Every session whose origin_source is plugin:<id> — started by a contributed automation or pipeline, by an item the plugin created, or by a run its credential started — gets a control token naming the owning installation, as the user who installed it, whatever lane spawned it (the rule lives in mintSessionControlTokenSidecar). Its grants are read live. With no owning installation, or on a PTY daemon re-mint whose token row is gone, the token opens nothing, never an unscoped one.
  • Session cap. The same sessions run inside the sessions.spawn cap — runners, models, permission mode, tool allow/deny — or do not run at all (Permissions → The session cap). Tool rules remove the tools from the model's context rather than asking, so they hold in full-access mode; denying one shell tool denies every shell tool, because Monitor also runs commands. Ralph loops, remote hosts and runners on other machines cannot apply a cap, so plugin work there is refused.
  • AUTH_MODE=none. Every request without a credential is the system user, so scoping CCC_SESSION_TOKEN there only limits what the token itself can do. Scoped PATs and plugin tokens are enforced in every mode, because they are parsed before the none short-circuit — but any local process that can reach the server port without a credential, including a plugin's MCP server or a session tool that makes HTTP requests, acts as the system user. On such a desktop install, deny WebFetch as well as Bash in a cap you want to hold.

Plugin host

Sandbox choice

OptionVerdict
node:vm / vm2Rejected. node:vm is not a security boundary; vm2 is deprecated with known escapes.
isolated-vmRejected for v1. Good isolation, but a native addon: a prebuild per Node ABI × OS × arch for the desktop app is a large packaging risk.
Node permission model aloneDefense in depth only. It restricts fs, child processes, workers and addons, but in Node 22 not network, and it is process-wide, not per plugin.
Deno subprocessViable, strong permission model, but ~100 MB extra per desktop build. Deferred.
WASM/WASI (Extism)The right answer for multi-language plugins. Phase 2.
QuickJS compiled to WASM (quickjs-emscripten) in a dedicated child processChosen. Pure WASM (no native build), zero ambient authority (no require, process, fetch, fs or timers unless injected), hard memory limit, CPU deadline via the interrupt handler, async bridge via the asyncify build.

Layout

Behind its own switch: CUTTLE_PLUGIN_HOST=1 (on top of CUTTLE_PLUGINS=1).

  • One host process: node --permission runtime-entry.mjs with --allow-fs-read for exactly the plugins dir, the host's own code and the QuickJS packages it loads (at their resolver and real paths) — not the data dir, not the home dir. No --allow-fs-write, --allow-child-process, --allow-worker, --allow-addons or --allow-wasi, and an empty environment (no server keys or tokens). It talks to the server over stdio (one JSON message per line), starts on the first activation, and is stopped when no active plugin has host code or the host switch is off. A crash fails only the activations in flight; the next activation restarts it, after a backoff that doubles per quick crash (1 s … 60 s). A host that stops answering is killed.
  • One QuickJS runtime in its own WASM instance per installation, so one plugin never shares a heap with another. Limits per activation: 5 s of CPU (execution time, summed across every await), 30 s wall time, 20 bridge calls in flight; per installation: 64 MB of memory. Memory is enforced twice — QuickJS's allocator limit, and a hard maximum on the instance's WASM memory, because some QuickJS allocation paths (fast arrays, string ropes) are not counted by the first. A runaway loop is interrupted (uncatchable inside the plugin); after any limit the instance is dropped and the plugin starts afresh on its next activation. Activations of one installation run one at a time.
  • The host is event-driven (onWebhook:, onAction:). It has no timers and no long-running background loop in v1. Nothing runs until a person presses an action or a verified webhook delivery arrives.

The bridge — the only way out

The server executes every bridge call; the sandbox only sends requests.

GlobalWhat it can doEnforced by
cuttle.api.request(method, path, body?)Call the Cuttle APIThe installation's scoped token → capability map
cuttle.fetch(url, init?)HTTP(S) to granted hostssafeFetch (SSRF guard) + network: grants. Private, loopback, LAN and tailnet addresses are reachable only via an exact network:<host:port> grant.
cuttle.storage.get/set/deletePer-installation key/value store1 MB cap per installation
cuttle.settings.get(key)Read its own settingsOwn non-secret settings only
cuttle.secrets.get(key)Read its own secret settingsOwn secret settings only — never project secrets (that is secrets.read via the API)
cuttle.log(level, …)Log to the plugin's timeline (server_events, plugin.host_log)Redacted, ≤ 20 lines of ≤ 1,000 characters per run

Each bridge call is served only for an activation still in flight, for that activation's installation, and re-checks that the installation is active and the host is on — so disabling, uninstalling, a policy block or revoking a grant takes effect on the plugin's very next call. api.request uses a control token naming the installation, as the user who installed it (the credential its sessions get), so the capability map and the installing user's own rights both apply; the plugins API itself is never reachable. fetch follows no redirects (a redirect could leave the granted host) and reads at most 1 MB. Webhook payloads reach the plugin without authorization, cookie or signature headers.

Supply chain and integrity

  • The package's SHA-256 is its identity: recorded at import, re-verified on boot, and compared against every sha that travels with it (sync doc, Browse index, export file). A mismatch is refused as tampered; a stored file that no longer matches puts the installation into missing_package.
  • Extraction rejects path traversal, absolute paths, symlinks, special files, case-colliding names and encrypted entries, and enforces size, entry-count and compression-ratio limits on actual inflated bytes (zip-bomb safe). The extracted files are read-only (against accidental edits; the same OS user could change them back, which is out of scope as above).
  • No signing in v1. Every plugin shows Unverified publisher. The Browse index pins each package's sha, which protects against a swapped download but not against a malicious index maintainer. Publisher signing ships with the store.
  • Updates cannot escalate silently. An update that adds a permission, widens a scope or raises a tier is held as a pending update until the user approves the diff; the previous version keeps running. Downgrades (and a different package with the same version) require explicit confirmation and show a warning (Permissions → Updates).

Provenance

Everything a plugin adds or touches is labelled with its source (user / builtin:<feature> / plugin:<id>): automations, pipelines, sessions, agents, skills, MCP servers, runners and intents. Items created through the API by a plugin's credential are stamped automatically, so "a plugin did this" is visible and filterable on every surface, not self-reported. Plugin items are shown by default with a source chip; built-in feature items stay hidden by default as today. Audit events use the plugin:<installationId> scope in the server_events timeline, and grant history lives in plugin_grant_log, which retention never prunes.

Lifecycle guarantees

  • Disable pauses its automations and hides its items without touching their own enabled state; re-enabling restores exactly the prior state. Running sessions continue unless the user asks to stop them.
  • Revoke while running. The next API call fails (403 capability_denied, or 403 plugin_inactive once the plugin is disabled or uninstalled). MCP servers and tools already attached to a running session persist until that session ends; the revoke dialog says so and offers to stop affected sessions.
  • Uninstall never deletes run history. Contributed automations and pipelines are archived, or converted to the user's own copies if the user chooses Keep copies. Secret values are never copied into kept items.
  • Feature flag off (CUTTLE_PLUGINS): plugin routes 404, every plugin is inactive, the host stops and plugin credentials fail. Data is retained.

Residual risks (accepted, stated plainly)

  • An agent session with Bash is the OS user. It can read ~/.ccc, the database and credentials. A sessions.spawn grant without a Bash deny is critical, per-device, and needs explicit acknowledgement. Both example plugins deny Bash.
  • Unsandboxed processes. MCP servers and runners (process.spawn) run as the Cuttle user with full filesystem and network access. network: grants are informational for them, not enforced.
  • Skills and prompts steer agents. A contributed skill is prompt text and can instruct an agent to misuse whatever tools it has. The real mitigation is the session cap; the install sheet says "can influence what agents do".
  • Sandbox escape. A QuickJS escape lands in a Node process whose permission model blocks fs writes, child processes, workers and addons — but Node 22 has no network permission, so an escaped plugin could open sockets.
  • Unverified publishers until signing ships.
  • Prompt injection from data. Webhook payloads and fetched content that a plugin forwards into an agent prompt are attacker-controlled. They are wrapped with Cuttle's untrusted-context wrapper (see docs/security-and-research.md), which reduces but does not eliminate the risk.