Permissions
Five modes
| Mode | Shown as | Behaviour |
|---|---|---|
default | Manual | Ask before anything that changes state |
acceptEdits | Accept edits | File edits pass, everything else asks |
plan | Plan | Read-only; refuse every change |
auto | Auto | Edits and routine commands pass, risky ones ask |
bypassPermissions | Bypass | Approve everything |
The classifier behind auto is deliberately conservative: anything it does not recognise as routine still asks, because the failure that matters is approving a command that destroys something, not asking about one more ls. It measures blast radius — rm, sudo, git push, kubectl, npm publish, writing to an absolute path outside /tmp.
It is a judgement about cost, not a security check. The boundary you can rely on is a deny rule.
Rules
Matched against both the tool name and a Tool(argument) form:
{
"allow": ["Bash(npm run test:*)", "mcp__github__get_*"],
"ask": ["Bash(git push*)"],
"deny": ["Bash(rm -rf *)", "write(.env*)"]
}
deny beats everything, including bypass mode — an explicit refusal is the one thing a user can rely on absolutely.
Approvals persist
"Always allow" is written to a settings file, in one of three scopes:
| Scope | File | For |
|---|---|---|
user | ~/.oharness/settings.json | A personal habit |
project | .oharness/settings.json | Belongs to this repo, reviewable |
local | .oharness/settings.local.json | Per-checkout, gitignored |
Denials accumulate across scopes and are never overridden: a project must be able to forbid what a user allowed globally.
What gets written is the most specific pattern. Approving Bash(npm test) must not quietly grant every Bash(...).
Writable roots
workspaceMode is read-only, workspace-write (the default) or full-access. The check happens before the approval prompt: offering the user something policy already forbids invites them to authorise it.
This is a policy gate, not a sandbox. It inspects tool arguments and refuses paths outside the allowed roots. It does not confine the process — a shell command can reach any path you can, and
bash -c 'python -c "..."'defeats argument inspection entirely. Real confinement needs the operating system. Treat it as a guard against mistakes, not against a determined command.
Audit
Every permission decision is recorded with the session that made it, in storage/decision/<id>.jsonl beside the transcript. On unless you turn it off: the trail is a few short lines per turn, and the state where it is missing is the state where it turns out to have been wanted.
Two kinds of line, paired by id:
{"kind":"decision","id":"dec_9f2c","at":1754500000000,"tool":"bash","behavior":"ask","principal":"Bash(npm test)"}
{"kind":"answer","id":"dec_9f2c","at":1754500004120,"outcome":"allowed","remembered":"Bash(npm test)"}
A decision on its own is what the rules said; the answer is what the person said. An ask with no answer means the process stopped while the question was on screen. outcome: "unavailable" is the fail-closed case — nothing was able to ask, so the call was refused without anybody seeing it.
The trail stays on this machine: sync pushes transcripts and artifacts, and deliberately not this. It is deleted with its session.
The settings page has a switch, which takes effect on the sessions already open in both directions, and shows the trail inline. A project that sets audit in its own config has decided this for everyone working in that repository, and the switch says so rather than pretending to change it.
auditLog is separate and still supported: name a path and every decision is also appended there, session id included, for an unattended run whose record has to end up somewhere a collector reads. Writes to both sinks are fire-and-forget but serialised — recording must not delay or fail a tool call, and a half-written record is worse than none. That is a deliberate trade: a failed write is a warning in the log, not a refused tool call, so this trail describes what happened rather than gating it.