openstation

Configure

Permissions

Set enforced tool permissions, approval gates, and spending limits.

What an agent is allowed to do, who decides, and how strong that decision is. This is the page to read carefully before you widen anything.

One gate: the settings file

The agent's permissions live in .claude/settings.<label>.json, bound to the agent by the manifest's claudeSettings: label. In a workspace with a roles.yaml this file is generated from the roles the agent holds — read on for what that changes, and what it doesn't:

agents:
  notes:
    claudeSettings: notes # -> .claude/settings.notes.json (the ENFORCED tool gate)
{
  "permissions": {
    "allow": ["Read", "Glob", "Grep", "Skill", "Edit(okf/**)", "Bash(python3 tools/stats.py:*)"],
    "deny": ["Read(.env)", "Read(**/.env)", "Read(.env.keys)", "Read(**/.env.keys)", "Read(**/secrets/**)", "Bash(rm:*)", "Read(var/**)"]
  }
}

Claude Code enforces this file on the two Claude executors. OpenStation does not. Those executors point Claude at it and let Claude decide:

Executor How the gate is passed
claude-cli --settings <file> plus --setting-sources project,local
claude-sdk extraArgs: { settings: <file> } plus settingSources: ["project", "local"]
pi-sdk experimental: OpenStation compiles permissions.allow/deny into a Pi tool-call gate

pi-sdk is not security-equivalent to Claude Code. It supports Read, Glob, Grep, Edit, Write, and Bash patterns, with deny winning. It refuses settings that declare hooks or sandboxing, does not apply settings env, does not translate MCP tools yet, and Pi itself is not a sandbox. It loads only platform-owned Pi state and .claude/skills, but unattended use still requires a dedicated OS user or container.

So the effective policy is the workspace's own .claude/settings.json and settings.local.json, layered with the turn's settings file.

user scope is never loaded. That's deliberate: an operator's personal ~/.claude — their skills, hooks, and permissions — must not leak into a workspace an agent is driving. A machine that runs serve for someone else's agent doesn't get to widen it by accident.

One exception is not ours to close: the operator's ~/.claude/CLAUDE.md is loaded into every turn, on both Claude executors. It resolves from the OS user record, so neither CLAUDE_CONFIG_DIR nor HOME moves it — only a dedicated user or a container does. Keep that file free of anything an agent shouldn't be told.

Workspace trust, or the gate silently isn't one

Claude refuses to honour a settings file from a directory nobody has vouched for. Untrusted, it drops that file's entire permissions.allow list and every additionalDirectories entry — and says so on stderr as a warning, not an error. The turn still runs, under a gate far narrower than the manifest declares, and only the denials reveal it.

So dev and serve keep a Claude config dir of their own per workspace, at var/claude/, with the workspace marked trusted in it, and point the child there with CLAUDE_CONFIG_DIR. Trust is the platform's to grant: the operator declared this workspace, and the settings file is the gate the platform is already pointing Claude at. Two consequences worth knowing:

  • Session transcripts move there too (var/claude/projects/), so a session id recorded before this existed no longer resumes. The stale-session retry covers it — the turn starts fresh.

  • Credentials live in a config dir. With no ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in the workspace's environment — a claude login subscription, whose credentials sit in the operator's own config dir — relocating would fail every turn with Not logged in. There the platform stays on ~/.claude and logs a warning instead. Two ways to get the gate back:

    Give the workspace a key or a token. Either variable being present is what authorizes the platform to own var/claude/, and it is read from the workspace's environment — so the workspace's own env: provider counts, not only a shell export. A line in the workspace's .env does it:

    # ~/openstation/notes/.env
    ANTHROPIC_API_KEY=sk-ant-...
    

    A key supplied that way also reaches the child, on both executor kinds: the key follows the config dir, from one decision in wire. It used to be decided twice, and on claude-cli the two answers disagreed — the key moved the config dir to a credential-free var/claude/ and the adapter then stripped it, so every turn failed Not logged in with an empty stderr while the banner reported claude auth: API key.

    Or trust the workspace, and keep the subscription. create does this for a workspace it scaffolds — the flag below, written into the config file the child reads, with everything else in that file preserved. --no-trust opts out. For a workspace create never touched — one adopted by hand, or scaffolded before this existed — it is one key:

    // ~/.claude.json
    { "projects": { "/path/to/workspace": { "hasTrustDialogAccepted": true } } }
    

    Every dev, serve and show re-asks it as the the workspace trusted where the runtime reads trust preflight check, which names the file and the exact key. It warns rather than refuses: an untrusted turn runs narrower than declared, never wider.

    The boot banner tells you which of the two you are on. claude home: names the config dir the child will actually run under — the wiring's own answer, not a second reading of CLAUDE_CONFIG_DIR, which is how it once printed ~/.claude for a child running on var/claude. claude auth: says API key only when a key actually reaches the child, so it can no longer claim one the child was never given.

Deny-by-default, and exactly how strong it is

An agent gets what its allow list grants and nothing else. The scaffold already grants reads plus Edit(okf/**)/Write(okf/**), not a blanket Edit; the example above narrows further, to its own two path patterns.

Now the part that matters more than any pattern syntax:

Enforcement lives inside Claude's decision loop, which makes a deny entry a strong default rather than a hard boundary.

It's the right default for the overwhelming majority of what an agent does, and it's genuinely load-bearing — ask the notes agent to read .env and it refuses, citing the settings file. But it is a model honoring a policy, not a kernel rejecting a syscall.

For anything that must never happen, put something underneath it:

  • a PreToolUse hook that inspects the call and exits non-zero
  • OS-level permissions on the process: a dedicated user, a read-only mount, a container
  • network isolation, if the concern is exfiltration rather than deletion

Treat the settings file as the policy and one of the above as the boundary.

What does not gate anything

Four other places mention tools or permissions. None of them is the gate, and conflating them is the most likely way to believe an agent is constrained when it isn't.

Where What it actually does
tools: in .claude/agents/<name>.md shapes what the agent reaches for. Not a boundary
tools: in .openstation/profiles/<role>.md a per-role ceiling, fed into the advisory pre-filter
roles: in openstation.yaml which roles compose this agent's gate. The gate is generated from them (see below)
--allowedTools / --disallowedTools an advisory pre-filter the platform passes; Claude still enforces the settings file regardless

About roles: — the gate file is still the only thing Claude enforces, but it is no longer hand-authored. openstation <ws> gates write compiles it from .openstation/roles.yaml, and every dev and serve boot refuses a gate file that has drifted from the roster, naming the file and the fix:

agent "writer": ~/my-space/.claude/settings.writer.json has drifted from the roles it is
compiled from (missing from the file: deny:Read(var/**)). Run
`openstation <ws> gates write` to regenerate it, or fix .openstation/roles.yaml if the
file is right and the roster is wrong. Nothing was rewritten — a generated gate is never
regenerated behind your back.

Boot refuses; it never regenerates. Silent regeneration would discard a hand-edited security fix, and silently honouring a hand edit would make the roster a lie.

A role marked required: true must be held or explicitly exempted with a reason, so a gate that omits a seal is a boot error rather than something the next person has to remember to copy. permissions: was removed on 2026-09-02: it never granted anything, and a manifest declaring it no longer loads.

Similarly, the platform's own role-based grant is empty in the CLI path: dev and serve build a role with no allowed tools, so the pre-filter contributes nothing beyond the artifact-zone write grants. All real policy is in the settings file.

Which settings file a turn gets

Not always the agent's: a channels: entry may name its own claudeSettings:, and that wins for every message in the spaces it selects. So one agent can run a wider gate in one conversation and a narrower one elsewhere, without a second agent.

The gate is chosen per turn, most specific first: the person's claudeSettings: in people.yaml, else the space's, else the agent's own.

Authority does not come from the profile. A profile carries behaviour — charter, tool ceiling, model — and writing claudeSettings: in one is a load error. The two resolve independently, so a space can widen what may be done without changing how the agent sounds, and a person can be given a different voice without a wider gate.

A role never selects either. A role decides admission; nothing else. That separation is what keeps a capability from following someone out of the room where it was granted — hn-agent's rule, "Channel mentions never get admin settings even for admins", now holds by construction rather than by remembering which field to use.

So: an admin-only power goes on the DM space; a power that genuinely should travel with a person goes on that person in people.yaml.

The field is called claudeSettings because that is its referent — a .claude/settings.<label>.json, which carries permissions and hooks and env. A hook in there outranks any allow list, so a narrower name would misdescribe what pointing at it grants. See openstation.yaml.

The five axes

Permission is one of five independent questions a channels: entry can answer. Keeping them apart is what stops any one of them from quietly implying another:

Field Question Enforced by Resolution
trigger does a turn happen at all? the platform space > agent
roles may this caller cause one here? the platform space only
profile how does the agent behave? you, in the charter person > space > agent
claudeSettings what may it do? Claude, from the settings file person > space > agent
budget what may the turn spend? the executor space > agent
silent does its reply reach the channel? the platform space > agent

Only claudeSettings is a security boundary. trigger and budget are cost controls, roles is admission, silent is delivery, and profile is behaviour — none of them restrains a tool call. Full field reference: openstation.yaml.

Roles

The CLI uses its startup role (--as, default member) as the default profile name only when the agent has no explicit profile. A caller's role is an admission label; it does not select that caller's profile or permission gate. Explicit person, space, and agent settings choose those independently.

A named profile must exist. Person roles named in a channel's admission list also need .openstation/profiles/<role>.md at load time. Agent-only permission roles need no profile.

Which role a caller gets depends on whether the workspace declares a roster:

  • No .openstation/people.yaml — identity is passthrough. The caller is whoever the connector says they are, and everyone in the process is the one role the daemon started with.
  • With a roster — each channel handle maps to a person and their own admission role. Profiles and settings change only when explicitly assigned. A contact that isn't listed gets defaultRole, or is refused outright when none is declared. See people.yaml.

The enforced gate remains the selected settings file. A person may explicitly name a claudeSettings override in people.yaml; that authority then follows them into every space. See roles and people for a complete setup.

The approval gate

For turns that shouldn't run unattended, OpenStation can hold one for a human decision, recording approval.ApprovalRequested and later approval.ApprovalResolved (approved / denied / expired, 24-hour TTL by default). A decision arrives as an ordinary inbound message — a button press on Slack, or typed text on a channel with no buttons — so it works on every connector.

While a turn is held, the caller is told "That's still waiting on approval — decide on it before sending more."

Two limits to know:

Turning it on is an approval: block in openstation.yaml:

approval:
  enabled: true
  ttlMs: 86400000   # optional; 24h default

That gives you the built-in predicate list. Custom predicates are not declarable — they are functions over a resolved turn, so a bespoke list still means embedding OpenStation and calling wire({ approval: { predicates: [...] } }). Note the gate cannot be combined with silent: true: a held turn asks the channel for a decision a silent space can never deliver, and the pairing is refused at boot.

Predicates match the prompt and the grant, not the tool call. The Bridge resolves a turn before Claude picks a tool, so a predicate can only see the text the user sent and the static grant the turn carries. It catches an explicit destructive ask, or an agent whose grant is destructive by nature — it cannot intercept the specific command Claude is about to run. For that, use a PreToolUse hook.

Spending controls

budget: caps what one turn may spend, on the agent or per space:

budget:
  maxUsd: 0.50    # Claude ceiling; pi-sdk post-request threshold
  maxTurns: 8     # claude-sdk and pi-sdk; claude-cli refuses it

A Claude turn that hits its native ceiling stops and reports a cost limit. Pi observes cost after each request and stops before the next request once maxUsd is reached, so the crossing request may overshoot; a final answer is still returned. There is no default: an unbudgeted agent runs under whatever the executor allows, and the scaffold declares none — see getting started for a starting maxUsd: 0.50 (roughly 3x the most expensive measured turn) to add by hand.

Time limits depend on the executor — claude-cli (the scaffold's default) kills a turn at 15 minutes; claude-sdk and pi-sdk have no wall-clock limit, which is a reason to give them a supported budget:. There is still nothing that reports per-tool spend; a CostMeter type exists with no producer feeding it, tracked in internal/roadmap.md.

A practical checklist

  1. Start from the scaffolded settings file — reads plus okf/ writes — and add one pattern at a time.
  2. Scope writes to a path (Edit(okf/issues/**)), never bare Edit.
  3. Name the script in a Bash grant (Bash(python3 tools/stats.py:*)), not the interpreter.
  4. Keep .env, .env.keys, **/secrets/**, and var/** denied — the scaffold does this already.
  5. For anything irreversible, add a PreToolUse hook or run the process with OS-level limits.
  6. Test the deny by asking for it. A refusal citing the settings file is the gate working; a silent success means you granted more than you meant to.
View Markdown source on GitHub ↗