Plugin docs
Plugin permission catalog
Every permission a plugin can ask for: its tier and scope, what it opens, and what is never grantable.
Status: v1 (plugin API 1.0.0). The catalog in code is PERMISSION_CATALOG in packages/types/src/plugins.ts (tier, one-line summary, scope kind). Enforcement is the server's route→capability map (apps/server/src/auth/capability-map.ts); the "Opens" column below is the contract that map implements.
Related: Manifest → Permissions, Security model.
How permissions work
- Declared. A plugin lists every permission it wants in
permissions.requiredorpermissions.optional, each with areason. Contributions imply permissions and the validator rejects a manifest that leaves one out (see Manifest → Implied permissions), so the install sheet cannot under-report. - Shown and chosen at install. The install sheet lists every permission, grouped by risk tier, critical first. Required rows are locked on; the install is blocked if the user will not grant them. Optional rows are toggles, off by default. The user picks the projects the plugin is enabled in; that set is the project scope of every project-scoped grant. Critical permissions need an explicit acknowledgement before Install enables.
- Enforced. Each installation acts through its own scoped credential. Every API request it makes is checked against the route→capability map and the installing user's own rights: a plugin can do at most what its user can do, intersected with what was granted. Unmapped routes are denied (default-deny). Grants are checked live on every request, so a revoke takes effect on the next call (
403 capability_denied); a disabled or uninstalled plugin, or plugins turned off on the server, is refused outright (403 plugin_inactive). - Changeable later. The plugin's Permissions tab toggles optional grants, narrows scopes, and revokes. An update that adds a permission, widens a scope or raises a tier is held as a pending update until the user approves it; the old version keeps running meanwhile (Updates).
Every grant, revoke and scope change is written to plugin_grant_log, which is exempt from retention.
Risk tiers
| Tier | Meaning | Install sheet |
|---|---|---|
| low | Read-only access to non-sensitive data in its projects. | Listed. |
| medium | Adds or starts things, spends provider usage, or accepts inbound requests. | Listed. |
| high | Changes your data, reads sensitive data, or influences what agents do. | Listed with a warning. |
| critical | Code execution as you, or a change that affects every user. | Red section, plain-language consequence, explicit acknowledgement. |
Scope
- Project-scoped permissions (scope kind
projects) apply only in the projects the plugin is enabled in. A request for another project is refused, and a scoped principal must name the project on list endpoints (?projectId=), so nothing is filtered after the fact. - Argument-scoped permissions carry their scope in the id:
providers.use:<runner>,secrets.read:<KEY>,network:<host[:port]>.*means any, and ranks at least as high as the named form. - Installation-wide permissions (
process.spawn,runners.contribute) are not project-scoped: a local process is a local process.
Catalog
"Its projects" = the projects the plugin is enabled in.
Low
| Permission | Install sheet says | Opens |
|---|---|---|
projects.read | See the names and details of projects it is enabled in | GET /api/projects (its projects only), GET /api/projects/:id |
events.read | Read the activity timeline of automations and sessions in its projects | GET /api/events for trigger:* / session:* / plugin:<self> scopes in its projects |
usage.read | Read usage and spend summaries for its projects | GET /api/usage/projects/:id, …/triggers/:id, …/intents/:id |
secrets.list | See the names (not values) of secrets in its projects | GET /api/projects/:id/secrets (names only) |
intents.read | Read intents in its projects | GET /api/intents* |
knowledge.read | Read the wiki and knowledge entries of its projects | GET /api/wiki*, GET /api/knowledge* |
automations.read | See automations and their run history in its projects | GET /api/triggers* |
pipelines.read | See pipelines and their runs in its projects | GET /api/pipelines*, pipeline runs |
Medium
| Permission | Install sheet says | Opens |
|---|---|---|
webhooks.receive | Receive web requests from the internet that start its automations | Public POST /api/webhooks/:token for its contributed webhook automations |
automations.contribute | Add its own automations to projects it is enabled in | No API — materializes contributes.automations |
pipelines.contribute | Add its own pipelines to projects it is enabled in | No API — materializes contributes.pipelines |
automations.run | Start automations in its projects | POST /api/triggers/:id/run, …/run-batch |
pipelines.run | Start pipeline runs in its projects | POST /api/pipelines/:id/run |
intents.write | Create and update intents in its projects | POST/PUT /api/intents* — every intent it creates is stamped source: plugin:<id> |
knowledge.write | Add wiki and knowledge entries to its projects | POST /api/knowledge/entries, wiki writes |
notifications.send | Post messages to your connected Discord and Slack | Project Discord/Slack post routes — not opened in v1 (below); declaring it grants nothing yet |
repos.read | Read repository details and git status in its projects | Repo list/detail, git status/log |
providers.use:<runner> / :* | Run agents on the named provider (uses your subscription or spend) | Which runners its sessions may use; checked at spawn |
network:<host[:port]> | Connect to the named host from its sandbox | cuttle.fetch in the plugin host to that host only. network:${config:key} binds to the host in that setting's current value. |
High
| Permission | Install sheet says | Opens |
|---|---|---|
network:* | Connect to any host on the internet or your network from its sandbox | cuttle.fetch to any public host. Private/LAN/tailnet addresses still need an exact network:<host:port> grant. |
automations.write | Create, change and delete automations in its projects | POST/PUT/DELETE /api/triggers* — created items are stamped source: plugin:<id> |
pipelines.write | Create, change and delete pipelines in its projects | POST/PUT/DELETE /api/pipelines* — stamped likewise |
sessions.read | Read session lists and transcripts, which may contain secrets | Session list, detail and transcript routes |
sessions.control | Send input to and stop sessions it started | POST /api/sessions/:id/input and DELETE /api/sessions/:id, only for sessions whose origin_source is this plugin (403 session_not_owned otherwise) |
skills.contribute | Add skills that can influence what agents do in its projects | No API — merges contributes.skills into sessions |
agents.contribute | Add agents that can influence what agents do in its projects | No API — merges contributes.agents into sessions |
skills.write | Change the skill files of its projects | Project skill file routes |
agents.write | Change the agent definitions of its projects | Project agents.json routes |
git.write | Commit and push to repositories in its projects | Commit / push routes |
files.read | Read files in its projects' repositories | Filesystem browse/read routes, bounded to its projects' repos |
files.write | Change files in its projects' repositories | Filesystem write routes, likewise bounded |
secrets.read:<KEY> / :* | Read secret values in its projects | GET /api/projects/:id/secrets/:key for the granted keys |
secrets.write | Set and delete secrets in its projects | POST/PUT/DELETE /api/projects/:id/secrets* |
mcp.contribute | Give agents in its projects new tools | No API — injects contributes.mcpServers into sessions |
Critical
| Permission | Install sheet says | Opens |
|---|---|---|
sessions.spawn | Start agent sessions that act as you, within the limits you set | Spawning sessions (directly or via its automations/pipelines), limited by the session cap below |
process.spawn | Run programs on this device as you, outside the sandbox | Starting its MCP servers and runners as local processes. Unsandboxed. |
runners.contribute | Add a new agent runner for everyone on this server | Adds contributes.runners to every user's runner list. Admin-only install. Unsandboxed. |
Token-only
Catalog entries marked tokenOnly: a personal access token can carry them, a plugin manifest cannot declare them (unknown_permission).
| Permission | Token sheet says | Opens |
|---|---|---|
inbox.produce | File items in the proposal inbox and read back their status | POST /api/inbox and GET /api/inbox/:id/status (only items the key filed), with the proposal inbox on (CCC_PROPOSAL_INBOX_ENABLED=1). A host admin mints it, alone, with projects: "*" (Settings → Security → Personal access tokens → Inbox producer, or cuttle api-keys create <name> --scope inbox.produce --all-projects); it works while its owner is a host admin. The server records each item as source: producer:<key id> (a body source is refused), files only owner_step, decision and label_packet items, never a canary, at most 60 per key per rolling hour and none while the workspace has 500 open items (429). The create answer and the status route return only { id, status, decision, decidedAt, actionState }: a producer never reads the queue, a proposal or the events, and never leans, decides, undoes, withdraws or reads the audit. |
The session cap (sessions.spawn)
An agent session with a shell can do anything the Cuttle OS user can, including reading ~/.ccc. So sessions.spawn is critical and always carries a cap, shown on the install sheet and tightenable (never loosenable) by the user.
Which sessions it covers. Every session whose origin_source is plugin:<id>: sessions its contributed automations and pipelines start, sessions of automations and pipelines it created through its credential, and sessions of any automation or pipeline its credential runs (automations.run / pipelines.run start the run as the plugin). Resumes of those sessions keep the cap. A plugin credential cannot start or resume a session directly (POST /api/sessions stays closed to it).
What is enforced, checked on every spawn against the owning installation's live grant:
- Runners. The session's runner must be in
cap.runners(__pty__for the Claude Code terminal; for a provider connection its connection id or the template it was made from, e.g.claude-agent-acp) and be covered by a grantedproviders.use. A run that names no runner uses the user's default runner and is refused if that one is not allowed; it never falls through to another runner. - Models. With
cap.models, the session must name one of them. - Permission mode. Terminal sessions launch with
--permission-mode <mode>instead of--dangerously-skip-permissions; provider sessions switch to that provider mode. The session then asks before what that mode asks for, and an unattended run waits for a person to answer in the session. Without a mode the lane's usual full-access mode is used, minus the tool rules. - Tools.
tools.allowbecomes--tools(the only built-in tools the session has) andtools.denybecomes--disallowedTools. Both remove the tools from the model's context, so they hold even in full-access mode (verified against Claude Code 2.1.199, including sub-agents). Denying any shell tool denies all of them —Bash,Monitor(which runs commands) andPowerShell.tools.allowgoverns built-in tools; MCP tools come from the servers attached to the session. On provider runners the Claude Code adapter gets the same lists (_meta.claudeCode.options) and the permission handler also refuses denied tools; other providers cannot apply tool rules, so a capped plugin session with tool rules is refused there. - Lanes that cannot apply a cap refuse. Ralph and BigRalph loops (automation run mode or pipeline node; plugin packages containing such pipeline nodes are refused at import), remote SSH hosts, runners on another machine and Remote Agentic Nodes.
A refused spawn fails its run (the automation's execution or the pipeline node) with the reason, answers 403 plugin_spawn_refused where an API call started it, and records a plugin.session_refused event on the plugin's timeline. It never runs uncapped.
Tightening. PUT /api/plugins/:id/session-cap with a cap object, or cuttle plugins cap <id> [--runner …] [--model …] [--mode …] [--allow …] [--deny …]. The server refuses anything wider than the cap in force (400 cap_widened): runners, models and allowed tools only shrink, denied tools only grow, and the mode only gets stricter (full access < acceptEdits < default < plan). To loosen a cap, reinstall and approve the plugin's request again. The change is logged (cap) in the grant log and applies to the next session.
A cap that denies Bash is the recommended default and is what both example plugins ship. Only a sessions.spawn grant without a Bash deny is treated as device-local by sync (see below).
Never grantable
These are refused for any scoped principal regardless of grants, so no permission can open them:
- every route guarded by
requireAdminordenyControlToken; - organization, membership, billing and workspace-settings routes;
- API keys, 2FA/TOTP, auth and session management;
- sensitive export / import of secrets;
- system settings, remote-host mutations, self-update, service control;
- the plugins API itself (a plugin cannot install, grant or configure plugins).
Scoped plugin credentials are also rejected on the Socket.io handshake in v1.
Enforcement
Three kinds of credential are scoped principals; everything else (browser logins, unscoped personal access tokens, ordinary session control tokens, AUTH_MODE=none) keeps its user's full rights and never touches this layer.
| Principal | Scopes come from |
|---|---|
| Scoped personal access token | api_keys.scopes, chosen at creation (Settings → Security → Personal access tokens → Limited, or cuttle api-keys create --scope … --project …) |
| Plugin credential (control token or PAT naming an installation) | The plugin registry, live on every request: the installation's granted permissions, limited to the projects it is enabled in. The token row only names the installation. An installation that is gone, not active, installed by another user, or behind CUTTLE_PLUGINS off answers 403 plugin_inactive. |
CCC_SESSION_TOKEN (user_sessions.scope = 'session-secrets') | Fixed: secrets.list + secrets.read, plus posting/reading run artifacts, for the project of the session it was minted for |
Scopes are { permissions: string[], projects: "*" | string[] } (CapabilityScopes in @ccc/types/plugins). A malformed stored value fails closed to "no permissions, no projects", never to unscoped.
For a scoped principal, middleware/capability.ts runs after express.json and before every router:
- The route must be in
CAPABILITY_MAP(apps/server/src/auth/capability-map.ts). Otherwise403 { code: "capability_unmapped" }— default-deny. The only routes open to any scoped credential are the public liveness checksGET /healthandGET /api/health. - The credential must hold one of the route's capabilities (for
secrets.read:<KEY>, that key). Otherwise403 { code: "capability_denied", permission }. - The route's project — from the path, or from the trigger, pipeline, run, execution, intent, session or repo it names — must be in scope. List routes need
?projectId=and create routes need a bodyprojectId(400 { code: "project_required" }). Any otherprojectIdin the query or body must be in scope too. Otherwise403 { code: "project_out_of_scope" }. A workspace-level route (the proposal inbox,inbox.produce) names no project, so only a credential withprojects: "*"passes. - The route's own authorization then runs as usual, so the grant is intersected with the user's live rights.
requireAdmin() and denyControlToken() refuse every scoped principal, a scoped credential cannot create API keys, and the Socket.io handshake refuses scoped PATs, expired PATs and CCC_SESSION_TOKEN. api_keys.expires_at is enforced (401 API key expired).
capability-map.guard.test.ts scans every router module the server mounts and fails the unit suite unless each route is mapped or explicitly listed in SCOPED_DENY_FILES / SCOPED_DENY_ROUTES with a reason, and unless PAT_GRANTABLE_PERMISSIONS equals exactly the permissions the map opens, less the plugin-only ones (PLUGIN_ONLY_PERMISSIONS).
What v1 opens
The map implements the Opens column above, with these v1 gaps (each stays denied to scoped credentials):
| Permission | v1 status |
|---|---|
projects.read | Project detail only. GET /api/projects is not project-filtered, so it stays closed; an installation knows its projects from its enablement set. |
events.read | trigger:<id> and session:<id> scopes in its projects, and plugin:<its own installation id>. |
knowledge.read / knowledge.write | Entries (GET/POST /api/knowledge/entries). The wiki routes are not project-filtered and stay closed. |
pipelines.read | Pipeline, its runs, run detail and events, and run artifacts. GET /api/pipeline-runs (all active runs) is not project-filtered and stays closed. |
sessions.read | Project session list, session detail and transcript. |
sessions.control | Input and stop for sessions the plugin started (sessions.origin_source = its source). Plugin credentials only: a personal access token starts no sessions, so it is not offered on PATs (PLUGIN_ONLY_PERMISSIONS). Direct spawn and resume stay closed: a plugin's sessions start only through automations and pipelines, where the session cap applies. |
skills.write / agents.write | Project skill files and agents.json (read and write). |
files.read / files.write / git.write / notifications.send | Not opened in v1. |
A scoped personal access token can carry only permissions that open at least one route (PAT_GRANTABLE_PERMISSIONS); contribution-only permissions such as mcp.contribute or process.spawn are refused at creation.
Updates
Installing another package of an installed plugin compares the new manifest with what the user approved (pluginUpdateDiff in @ccc/types/plugins):
| The new version… | Result |
|---|---|
| requires a permission that is not granted now (new, or optional-and-off made required) | added — needs approval |
asks for looser sessions.spawn limits than the current version did (capWithin) | widened cap — needs approval |
| adds optional permissions | listed, left off; switch them on during approval or later |
| no longer declares a permission | its grant is dropped with the update |
| anything else (same or fewer permissions, tighter limits) | applied at once |
Tiers are properties of permission ids, so a "raised tier" is always a new id (network:api.example.com → network:*) and shows up as added.
- Needs approval → the update is pending: the installed version keeps running (its automations fire, its grants stand) until the user approves the diff or dismisses it. Approving grants every added permission and may switch on new optional ones and narrow the new session limits.
- The user's own narrowing of
sessions.spawnis kept across an update that does not widen the plugin's request: the effective cap is the stricter of the two (intersectCap). - A downgrade, or a package with the installed version but different contents, is applied only with explicit confirmation and a warning.
- An unsatisfied
engines.cuttle, the org policy or a name clash refuses the update; the installed version stays. - Every step is in
plugin_grant_log:update_pending,update_dismissed,grant/drop/capfor each changed grant, thenupdate.
Revoking
A revoke (or disabling the plugin) takes effect on the plugin's next request and the next session spawn. Sessions already running keep what they started with — the plugin's MCP tools, and whatever a plugin-started session can do — until they end. The Permissions tab and the Enable switch list those sessions first (sessions the plugin started; plus every session started in its projects since install when its tool servers are involved) and offer Also stop these sessions. Only sessions the acting user may write are listed or stopped.
Grants across devices
The per-user sync lane carries install state, settings and grants to your other devices, except device-local grants, which each device approves on its own: process.spawn, runners.contribute, and sessions.spawn without a Bash deny (isDeviceLocalGrant in code). A plugin whose required device-local grant is not yet approved on a device stays inactive there, with an Approve on this device prompt.
Multi-user servers
- On a single-user or desktop install (
AUTH_MODE=none) the user can install anything. - On a multi-user server, admins install org-wide; members install personal plugins only in projects they can write, only within the org's
plugins_policy(open/allowlist/blocklist), and only up to itsmemberMaxTier(defaulthigh, so members cannot grant critical permissions unless an admin raises it).runners.contributeis always admin-only. - If the policy later blocks an installed plugin (allow/block list, or grants above
memberMaxTierfor a member's install) it becomesblockedwhen the policy is saved: its automations pause and its items hide, with settings, grants and history kept, and its owner is notified. When the policy allows it again it returns to the status it had. Admins see every blocked installation under Settings → Plugin policy and can uninstall it.