openstation

Reference

roles.yaml

Declare scoped roles and compile permission gates.

The role roster, at .openstation/roles.yaml. It answers two questions nothing else can: which roles exist, and what each one grants.

A role is one named object with two sides. Which side applies depends on who names it:

  • admission — a person holds a role (people.yaml), and a space admits roles (channels[].roles). This is the older half, and scope: person is what keeps a role usable here.
  • authority — an agent lists roles (agents.<name>.roles), and they compile into the gate Claude Code enforces. scope: agent is what keeps a role usable here.

scope is required, never defaulted: a role usable by both consumers must be a deliberate statement, not what omission gives you.

The file is optional. Without it, gate files stay hand-authored and every check on this page is skipped, so an existing workspace keeps booting unchanged. Creating it is what makes the gates generated — after which openstation <ws> gates write owns those files.

Shape

roles:
  var-sealed:
    scope: agent
    required: true
    description: Platform state is not agent-readable.
    deny:
      - Read(var/**)
      - Read(//Users/leonid/openstation/hn-support/var/**)

  clone-git:
    scope: agent
    description: >
      Git in the code-improver's own clone. Push is pinned to improve/* — that pin
      is what stops a push to main, so never widen this to a bare git allow.
    allow:
      - Bash(git -C /Users/leonid/openstation/hn-support-code/openstation status:*)
      - Bash(git -C /Users/leonid/openstation/hn-support-code/openstation push -u origin improve/*)

  identity-probe:
    scope: agent
    description: Confirm which GitHub account this agent is, without reading the token.
    hooks:
      PreToolUse:
        - matcher: Bash
          command: python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/allow_identity_probe.py"

  admin:
    scope: person
    description: The operator. Admitted to the admin DM.

Fields

Field Required Default Meaning
scope yes person | agent | both. Which consumers may name this role
allow no [] Claude permission patterns, verbatim, composed into the gate's allow list
deny no [] Same, for the deny list. Deny always wins — that is Claude's own precedence, not ours
required no false Boot refuses an agent that neither holds nor exempts this role
description no Re-emitted as _comment_<role> in the generated gate, so why a rule exists survives the file becoming machine-owned
hooks no { <event>: [{ matcher, command }] }, scoping a hook to the roles that need it

Role names obey the same rule roster tokens do — ^[A-Za-z0-9][A-Za-z0-9_-]*$ — because a person-scoped role becomes a .openstation/profiles/<role>.md path segment.

Attaching roles

agents:
  code-improver:
    claudeSettings: code-improver
    roles: [var-sealed, clone-git, identity-probe]
    allow: [Edit(//Users/leonid/openstation/hn-support-code/**)]   # one-off, this agent only
    deny:  [Bash(gh pr merge:*)]

  verifier:
    claudeSettings: verifier
    roles: [var-sealed]
    exempt:
      eval-zone-sealed: "the verifier IS the eval scorer — reads answers/, writes results/"

Rules union: every role's, in declaration order, then the agent's inline allow/deny. Exact duplicates collapse. Nothing is resolved here — both lists are emitted and Claude applies its own deny-wins precedence.

Adding a role can grant capabilities that are not already denied, so review its allows. A role granting Bash(git …) cannot override another role's deny; existing denies still win.

Two directories, one roster

The part most easily misread. .openstation/profiles/ already holds two unrelated kinds of file under one naming convention:

File What it is
admin.md, member.md behaviour for a person holding that role
triage.md, verifier.md behaviour for an agent

Nothing in that directory distinguishes them, and requireProfile asserts exactly one thing: that a role named at an admission site has a .md to run under. That is not a roster — it cannot tell you which roles exist, and it never sees people.yaml.

roles.yaml is the roster. requireProfile keeps its narrower assertion. Two claims about the same name, neither redundant:

roles.yaml          does `admin` exist? what does it grant? which consumers may name it?
profiles/admin.md   what does a person holding `admin` behave like?

So adding a role means up to two files, and which two depends on scope:

Adding roles.yaml entry profiles/<role>.md
A person-role admitted at a channel required required — requireProfile refuses without it
An agent-role composing a gate required never — it is not behaviour, and no admission site names it
scope: both required required, because an admission site may name it

A role never selects a profile, and roles.yaml has no profile: field for that reason. See people.yaml: a role that implied a profile would carry privileged behaviour into every space its holder stands in. Behaviour stays in profiles/; authority lives here.

Load-time errors

All of these refuse the boot, naming the file and the field path.

Error Why it is worth refusing
A role named anywhere is absent from the roster A typo otherwise costs a capability or an admission in silence
scope mismatch for the slot An agent granted admin, or a person granted var-sealed
A people.yaml role absent from the roster Previously checked for name pattern only, so role: admni silently dropped that person to defaultRole and admitted them nowhere
A required role neither held nor exempted The omitted-deny class of privilege escalation. This was prose in a workspace README before it was a check
exempt: with an empty reason An undocumented hole
exempt: naming a role that is not required A typo that reads as protection while granting nothing
A Write(...) entry in an allow list Inert. An Edit(...) allow already grants both the Edit and the Write tool, so this grants nothing while looking like it does
A single-leading-slash absolute path Matches nothing, ever — it reads as a project-root-anchored gitignore pattern. Use //-absolute
A gate file that has drifted from the roster The lockfile guarantee. Boot refuses; it never regenerates behind you

Generated gate files

openstation <ws> gates write compiles the roster into each .claude/settings.<label>.json, which carries a _generated marker and one _comment_<role> per role with a description.

gates check compares them again and exits non-zero on drift — by rule set, not bytes, so a reordered emit or an edited comment is not drift.

write is operator-run by construction, not by convention: Claude Code write-protects .claude/** from headless agents regardless of permission rules, so an agent invoking it fails on the write itself. Agents and boot use the read-only paths.

What this is not

Not the enforcer. Claude Code enforces the gate file; the roster is how that file gets written. roles.yaml grants nothing the compiled file does not contain.

Not a tool ceiling. The advisory verb-level ceiling is separate and lives with the profile — see permissions. The gate narrows by path; a ceiling narrows by verb. Conflating the two once emptied the ceiling∩grant intersection and failed every turn.

View Markdown source on GitHub ↗