Restrict who can do what
Name the admins, grant the org, close what needs a grant, and give machines exactly what they hold.
You need: config.yaml, a way to make it live (restart; on Cloudflare deploy config then deploy restart), and the ids (config show prints yours).
grants says what each actor holds; restrict closes agents and repositories to the ungranted.
Name an admin
grants:
slack:U0123ADMIN:
actions: all
channels: all
repos: allWithout an all entry nobody is admin and every gated command is refused.
Everyone in the org
grants:
access:*: # everyone signed in through Access
actions: all
channels: all
repos: allAccess already decides who may log in, so access:* is the org, granted once; slack:*, http:*, mcp:* work the same. A person's own entry adds to it, never narrows it.
Close an agent
restrict:
agents: [coding] # closed unless granted …
grants:
slack:U0456DEV:
actions: [agent:run:coding] # … and the grantChecked when the run starts, against the resolved agent; unlisted agents stay open to humans. Credentials and jobs need explicit agent grants.
Close a repository
restrict:
repos: [acme/payments] # closed unless granted …
grants:
slack:U0456DEV:
repos: [acme/payments] # … on the `repos` axisUnlisted code repositories stay open to humans; credentials and jobs use only granted repositories. Slugs are case-insensitive. Open code access does not grant repository memory ownership.
Cap what a channel's runs may have
channels:
slack:C012345:
boundary:
maxMinutes: 45 # every run here is clipped to 45 minutes
maxIdentity: read # a preset that needs `write` (coding, ship) is refused by name
machines: [none, repo-resident] # a preset on another class is refusedOr from chat, with config:write: @switchboard config set channel --boundary.maxIdentity read.
A boundary is not a grant and not a restriction: restrict says who may run a preset, a boundary says how much any run in the scope may have — its wall-clock budget, the credential it acts as (none < read < write), the machine class its tools execute on. A budget above the cap is clipped and the card says so; an identity or class above the cap is refused before a card, a thread claim or an executor exists, naming the preset, the cap and whose boundary it is. Boundaries intersect across defaults, the channel and the user — the smallest budget, the lowest identity, the classes every layer allows — so nobody can widen one from below: config set me --boundary.… is open to everyone because it can only tighten.
A boundary's fourth field, confirm, is the front door's rather than a run's: it names the first blast-radius class a command the router bound from a plain sentence is handed back at (To run this: …) instead of run — write, the built-in, hands back every write and lets a read or a repository's own test run; destructive lets a write run at once and holds back only a destructive one. It intersects the same way, toward caution, so an installation's confirm: write is a floor no channel or user can loosen. exec and never are refused by name.
Allow channel configuration
grants:
slack:U0456DEV:
actions: [config:write]Gates channel config set/clear/instructions and channel-scope MCP servers; config set me is always open.
Allow repository management
grants:
slack:U0789OPS:
actions: [repo:write, friction:write] # friction propose files issuesGates repo onboard/offboard/reconfigure/rebuild (billable compute); repo list is open.
Grant the machine surfaces
grants:
access:jane@acme.com: # Access browser identity (its `sub`)
actions: [runs:write, friction:write]
channels: all
access:svc:ops-bot: # Access service token (its common_name)
actions: [runs:read, runs:write, friction:read]
channels: all
http:ci: # ingress token subject (`mcp:ci` over MCP)
actions: [dispatch, runs:read, agent:run:review]
codeRepos: [acme/payments] # code access; no repo memory ownership- An Access sign-in holds every group's
read; writes need a grant. - Service and ingress tokens hold exactly their entry, including agent actions and repositories;
dispatchalone grants neither. Unlisted ones cannotdispatch.SWITCHBOARD_INGRESS_TOKENSonly identifies.
Check it
@switchboard config showEvery refusal names the missing grant and who can give it.
A locked-down example
restrict:
agents: [coding, ship]
repos: [acme/payments]
grants:
slack:U0100FOUNDER:
actions: all
channels: all
repos: all
slack:U0456DEV:
actions: [agent:run:coding, agent:run:ship, config:write, repo:write, friction:write]
repos: [acme/payments]For humans, everything unnamed stays open: review, research, explore, conductor, general, other repositories. Add a boundary under a channel to cap what even the granted may have there. conductor starts child runs as the person who asked — each child passes these same gates as that person, so it grants nothing they lack — and the example config lists it under restrict.agents for a deployment that wants fan-out closed by default: uncomment one line.
Next
- Authorization: the policy table.
- Decision record: why one table.