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
- A malicious plugin author publishing a plugin that over-reaches.
- A compromised update of a benign plugin.
- A tampered package in transit or at rest (download mirror, shared file).
- Prompt injection through plugin-supplied skills, agent prompts or automation prompts, or through data a plugin feeds to an agent.
- 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 plugin | Runs where | Bounded by |
|---|---|---|
| Declarative contributions (skills, agents, automations, pipelines, settings) | No code — materialized by Cuttle | Grants + the installing user's rights + session caps |
Host code (contributes.host) | QuickJS/WASM runtime in a separate, permission-restricted Node process | Sandbox → Node permission model → scoped API bridge |
| API calls (from the host, or from sessions it starts) | Cuttle server | Scoped credential, route→capability map, default-deny |
MCP servers, runners (process.spawn) | Local OS process as the Cuttle user | Nothing but the OS. Critical, unsandboxed, per-device approval. |
Agent sessions (sessions.spawn) | Claude Code / ACP runner | Session 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
pluginsflag 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/denyControlTokenroute 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_TOKENonly whensecrets.readis 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_sourceisplugin:<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 inmintSessionControlTokenSidecar). 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.spawncap — 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, becauseMonitoralso 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 scopingCCC_SESSION_TOKENthere only limits what the token itself can do. Scoped PATs and plugin tokens are enforced in every mode, because they are parsed before thenoneshort-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, denyWebFetchas well asBashin a cap you want to hold.
Plugin host
Sandbox choice
| Option | Verdict |
|---|---|
node:vm / vm2 | Rejected. node:vm is not a security boundary; vm2 is deprecated with known escapes. |
| isolated-vm | Rejected 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 alone | Defense 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 subprocess | Viable, 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 process | Chosen. 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.mjswith--allow-fs-readfor 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-addonsor--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.
| Global | What it can do | Enforced by |
|---|---|---|
cuttle.api.request(method, path, body?) | Call the Cuttle API | The installation's scoped token → capability map |
cuttle.fetch(url, init?) | HTTP(S) to granted hosts | safeFetch (SSRF guard) + network: grants. Private, loopback, LAN and tailnet addresses are reachable only via an exact network:<host:port> grant. |
cuttle.storage.get/set/delete | Per-installation key/value store | 1 MB cap per installation |
cuttle.settings.get(key) | Read its own settings | Own non-secret settings only |
cuttle.secrets.get(key) | Read its own secret settings | Own 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, or403 plugin_inactiveonce 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
Bashis the OS user. It can read~/.ccc, the database and credentials. Asessions.spawngrant 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.