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. One class is named in configuration, the engine hands it to every session it spawns, and the session gets nothing the class does not 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 may write, relative to its own working directory

Schema v2 adds two fields, and adds nothing else

{
  "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: "workdir" joins value onto the session's own working directory — a per-session cache, for example.

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.

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 malformation is a refusal, not a default. An unrecognised field, an unknown env_mint mode, a version outside the accepted set — each one fails to load. Nothing is silently ignored, so a class that loads is the class you wrote.
  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 Security.

Where the class comes from at spawn time

One class is named in the workspace configuration and applies to every session the engine spawns there. ⚠ It is a single setting for the whole workspace today: changing it changes the surface of every session, not one. If a particular job needs a different toolchain, that is worth knowing before you edit the setting rather than after.