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_KEYorCLAUDE_CODE_OAUTH_TOKENin the workspace's environment — aclaude loginsubscription, whose credentials sit in the operator's own config dir — relocating would fail every turn with Not logged in. There the platform stays on~/.claudeand 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 ownenv:provider counts, not only a shell export. A line in the workspace's.envdoes 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 onclaude-clithe two answers disagreed — the key moved the config dir to a credential-freevar/claude/and the adapter then stripped it, so every turn failed Not logged in with an empty stderr while the banner reportedclaude auth: API key.Or trust the workspace, and keep the subscription.
createdoes 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-trustopts out. For a workspacecreatenever touched — one adopted by hand, or scaffolded before this existed — it is one key:// ~/.claude.json { "projects": { "/path/to/workspace": { "hasTrustDialogAccepted": true } } }Every
dev,serveandshowre-asks it as thethe workspace trusted where the runtime reads trustpreflight 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 ofCLAUDE_CONFIG_DIR, which is how it once printed~/.claudefor a child running onvar/claude.claude auth:saysAPI keyonly 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
denyentry 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
PreToolUsehook 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. Seepeople.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
- Start from the scaffolded settings file — reads plus
okf/writes — and add one pattern at a time. - Scope writes to a path (
Edit(okf/issues/**)), never bareEdit. - Name the script in a Bash grant (
Bash(python3 tools/stats.py:*)), not the interpreter. - Keep
.env,.env.keys,**/secrets/**, andvar/**denied — the scaffold does this already. - For anything irreversible, add a
PreToolUsehook or run the process with OS-level limits. - 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.