Saphan StudioDocs
Configuration

Permission classes: what an agent may run

How a permission class bounds an agent's tools, what the file looks like, and how to write your own.

A permission class is a small JSON file that says what an agent session may do on the machine that hosts it. A class is named in configuration and the engine delivers it to the sessions it spawns — for a coding agent, only where that agent reads such a document (see Which backends receive a grant).

⚠ A class only ever NARROWS. It cannot grant a capability the agent would not otherwise have — deny is the default and a class is a subtraction from what the host already permits.

First: there are TWO class formats, and which one you write depends on the agent

This is the thing to settle before you write a line, because the two files look nothing alike and each is refused by the wrong reader.

you are boundingthe file you writewho reads it
a substrate process — an ordinary executable the engine runs under mandatethe engine's own class schema (below)the engine itself, before it spawns
a coding-agent session — the common casethe agent vendor's settings document, which the engine provisions into the session's working directorythe agent's own permission layer

Why it is split this way, in one sentence: the engine can enforce a policy on a process it launches itself, but a coding agent enforces its own tool permissions — so for those the engine's job is to deliver the right document, not to police the tools. ⚠ Not every agent has such a document. Where one does not exist, what bounds the session is the confinement around it, not a class — and the product says so per agent rather than implying a policy it cannot write.

The agent-session class, which is the one most readers want

{
  "permissions": {
    "allow": [
      "Bash(go:*)",
      "Bash(gofmt:*)",
      "Bash(/usr/local/go/bin/go:*)",
      "Bash(git:*)",
      "Bash(sed:*)",
      "Bash(tee:*)"
    ],
    "deny": [
      "Bash(git push:*)"
    ]
  },
  "env": {
    "PATH": "/usr/local/go/bin:/opt/homebrew/bin:/usr/bin:/bin",
    "GOROOT": "/usr/local/go"
  }
}
  • allow lists tool patterns the session may use. Both the bare name and the absolute path are worth listing: they are different strings to the matcher, and a session that invokes the absolute path is refused by a class that only allowed the bare name.
  • deny subtracts. ⚠ Read the next section before trusting a deny line.
  • env is what the session inherits. A toolchain that needs PATH or a root variable gets it here; without it, a tool that exists on the host is not on the session's path.

The engine's own class schema, for substrate processes

{
  "version": 1,
  "name": "build-and-test",
  "commands_allow": [
    { "argv0": "/usr/local/go/bin/go", "subcommands": ["build", "test", "vet"] },
    { "argv0": "/usr/bin/git", "subcommands": ["status", "diff", "log", "add", "commit"] }
  ],
  "write_scope": ["."]
}
fieldwhat it does
versionthe schema this file is written against. The accepted set is closed — a file naming a version outside it fails to load rather than being coerced, so nobody ever runs under a policy other than the one they named
namea human label, for the record and for your own reading
commands_allowthe executables a session may run, each with the subcommands it may pass. An entry names an absolute argv0
write_scopewhere the session is meant to write, relative to its own working directory. ⚠ It records intent and is not enforced by the class; what bounds writes is the confinement around the run

Schema v2 adds two per-command fields

{
  "version": 2,
  "name": "build-and-test-with-a-cache",
  "commands_allow": [
    { "argv0": "/usr/local/go/bin/go", "subcommands": ["build", "test"],
      "path": "/usr/local/go/bin",
      "env_mint": [{ "name": "GOCACHE", "mode": "workdir", "value": ".cache/go" }] }
  ],
  "write_scope": ["."]
}
  • path puts a directory on the session's PATH.
  • env_mint declares an environment variable whose value the engine mints at spawn, never a value read from the file. mode is one of workdir (joins value onto the session's own working directory — a per-session cache, for example), artifact-run-dir or host-go-cache.

⚠ v2 is additive, never a redefinition. A v1 file may not carry v2 fields: it is refused rather than accepted-and-ignored, so "version 1" means the same thing in every build.

Fields for the container posture

Both versions also accept fields that matter only when a run is placed in a container (run.confinement = container, see Sandboxing):

fieldwhat it does
networknone (the default) or allowed. Under the container posture it is the mechanism: none gives the container no network. Under every other posture it is a label in the record only — nothing restricts traffic by it; egress control does that.
container_cpus, container_memory_bytes, container_pids_limitthe container's resource caps — declare all three or none. Inert on every other posture.
container_userthe non-root user the container runs as; root is refused.

Writing your own — five things worth knowing before you do

  1. Name absolute paths. An entry whose argv0 is a bare name depends on whatever the session's PATH happens to be, which is not a policy.
  2. A shell in commands_allow grants everything reachable from a shell. If a class admits a shell — Bash(bash:*), Bash(sh:*), or a shell in commands_allow — then every tool that class denies by name may still be reachable through it, and the class's own deny lines become decoration. This is the single easiest way to write a class that reads strict and is not. ⇒ If a session genuinely needs a shell, grant it deliberately and stop treating the rest of the list as a boundary — and put the real boundary somewhere that a shell cannot step around.
  3. A malformed value is a refusal, not a default — an unknown env_mint mode, a version outside the accepted set, v2 fields in a v1 file each fail to load. ⚠ A misspelled field NAME is not caught: the engine's class reader skips a key it does not know, so a typo such as comands_allow loads as a class with no commands at all. Check every field name against this page before relying on a class.
  4. Write the narrow class first, then widen it when something is refused by name. The refusal tells you what to add; guessing forward tells you nothing and usually grants more than the work needs.
  5. A class is not a sandbox. It bounds which commands a session may invoke. The filesystem and network boundary around the session is a separate mechanism, configured separately — see Sandboxing and Egress control.

Where the class comes from at spawn time

workspace.worktree_grant_class names the class for every session the engine spawns in the workspace, and workspace.grant_class.<role> overrides it for one fleet role. Changing the workspace-wide class changes the surface of every session that has no role-specific class — worth knowing before you edit the setting rather than after.