All docs

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

  1. Declared. A plugin lists every permission it wants in permissions.required or permissions.optional, each with a reason. Contributions imply permissions and the validator rejects a manifest that leaves one out (see Manifest → Implied permissions), so the install sheet cannot under-report.
  2. 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.
  3. 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).
  4. 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

TierMeaningInstall sheet
lowRead-only access to non-sensitive data in its projects.Listed.
mediumAdds or starts things, spends provider usage, or accepts inbound requests.Listed.
highChanges your data, reads sensitive data, or influences what agents do.Listed with a warning.
criticalCode 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

PermissionInstall sheet saysOpens
projects.readSee the names and details of projects it is enabled inGET /api/projects (its projects only), GET /api/projects/:id
events.readRead the activity timeline of automations and sessions in its projectsGET /api/events for trigger:* / session:* / plugin:<self> scopes in its projects
usage.readRead usage and spend summaries for its projectsGET /api/usage/projects/:id, …/triggers/:id, …/intents/:id
secrets.listSee the names (not values) of secrets in its projectsGET /api/projects/:id/secrets (names only)
intents.readRead intents in its projectsGET /api/intents*
knowledge.readRead the wiki and knowledge entries of its projectsGET /api/wiki*, GET /api/knowledge*
automations.readSee automations and their run history in its projectsGET /api/triggers*
pipelines.readSee pipelines and their runs in its projectsGET /api/pipelines*, pipeline runs

Medium

PermissionInstall sheet saysOpens
webhooks.receiveReceive web requests from the internet that start its automationsPublic POST /api/webhooks/:token for its contributed webhook automations
automations.contributeAdd its own automations to projects it is enabled inNo API — materializes contributes.automations
pipelines.contributeAdd its own pipelines to projects it is enabled inNo API — materializes contributes.pipelines
automations.runStart automations in its projectsPOST /api/triggers/:id/run, …/run-batch
pipelines.runStart pipeline runs in its projectsPOST /api/pipelines/:id/run
intents.writeCreate and update intents in its projectsPOST/PUT /api/intents* — every intent it creates is stamped source: plugin:<id>
knowledge.writeAdd wiki and knowledge entries to its projectsPOST /api/knowledge/entries, wiki writes
notifications.sendPost messages to your connected Discord and SlackProject Discord/Slack post routes — not opened in v1 (below); declaring it grants nothing yet
repos.readRead repository details and git status in its projectsRepo 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 sandboxcuttle.fetch in the plugin host to that host only. network:${config:key} binds to the host in that setting's current value.

High

PermissionInstall sheet saysOpens
network:*Connect to any host on the internet or your network from its sandboxcuttle.fetch to any public host. Private/LAN/tailnet addresses still need an exact network:<host:port> grant.
automations.writeCreate, change and delete automations in its projectsPOST/PUT/DELETE /api/triggers* — created items are stamped source: plugin:<id>
pipelines.writeCreate, change and delete pipelines in its projectsPOST/PUT/DELETE /api/pipelines* — stamped likewise
sessions.readRead session lists and transcripts, which may contain secretsSession list, detail and transcript routes
sessions.controlSend input to and stop sessions it startedPOST /api/sessions/:id/input and DELETE /api/sessions/:id, only for sessions whose origin_source is this plugin (403 session_not_owned otherwise)
skills.contributeAdd skills that can influence what agents do in its projectsNo API — merges contributes.skills into sessions
agents.contributeAdd agents that can influence what agents do in its projectsNo API — merges contributes.agents into sessions
skills.writeChange the skill files of its projectsProject skill file routes
agents.writeChange the agent definitions of its projectsProject agents.json routes
git.writeCommit and push to repositories in its projectsCommit / push routes
files.readRead files in its projects' repositoriesFilesystem browse/read routes, bounded to its projects' repos
files.writeChange files in its projects' repositoriesFilesystem write routes, likewise bounded
secrets.read:<KEY> / :*Read secret values in its projectsGET /api/projects/:id/secrets/:key for the granted keys
secrets.writeSet and delete secrets in its projectsPOST/PUT/DELETE /api/projects/:id/secrets*
mcp.contributeGive agents in its projects new toolsNo API — injects contributes.mcpServers into sessions

Critical

PermissionInstall sheet saysOpens
sessions.spawnStart agent sessions that act as you, within the limits you setSpawning sessions (directly or via its automations/pipelines), limited by the session cap below
process.spawnRun programs on this device as you, outside the sandboxStarting its MCP servers and runners as local processes. Unsandboxed.
runners.contributeAdd a new agent runner for everyone on this serverAdds 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).

PermissionToken sheet saysOpens
inbox.produceFile items in the proposal inbox and read back their statusPOST /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 granted providers.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.allow becomes --tools (the only built-in tools the session has) and tools.deny becomes --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) and PowerShell. tools.allow governs 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 requireAdmin or denyControlToken;
  • 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.

PrincipalScopes come from
Scoped personal access tokenapi_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:

  1. The route must be in CAPABILITY_MAP (apps/server/src/auth/capability-map.ts). Otherwise 403 { code: "capability_unmapped" } — default-deny. The only routes open to any scoped credential are the public liveness checks GET /health and GET /api/health.
  2. The credential must hold one of the route's capabilities (for secrets.read:<KEY>, that key). Otherwise 403 { code: "capability_denied", permission }.
  3. 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 body projectId (400 { code: "project_required" }). Any other projectId in the query or body must be in scope too. Otherwise 403 { code: "project_out_of_scope" }. A workspace-level route (the proposal inbox, inbox.produce) names no project, so only a credential with projects: "*" passes.
  4. 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):

Permissionv1 status
projects.readProject detail only. GET /api/projects is not project-filtered, so it stays closed; an installation knows its projects from its enablement set.
events.readtrigger:<id> and session:<id> scopes in its projects, and plugin:<its own installation id>.
knowledge.read / knowledge.writeEntries (GET/POST /api/knowledge/entries). The wiki routes are not project-filtered and stay closed.
pipelines.readPipeline, its runs, run detail and events, and run artifacts. GET /api/pipeline-runs (all active runs) is not project-filtered and stays closed.
sessions.readProject session list, session detail and transcript.
sessions.controlInput 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.writeProject skill files and agents.json (read and write).
files.read / files.write / git.write / notifications.sendNot 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 permissionslisted, left off; switch them on during approval or later
no longer declares a permissionits 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.spawn is 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 / cap for each changed grant, then update.

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 its memberMaxTier (default high, so members cannot grant critical permissions unless an admin raises it). runners.contribute is always admin-only.
  • If the policy later blocks an installed plugin (allow/block list, or grants above memberMaxTier for a member's install) it becomes blocked when 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.