Permissions

Five modes

ModeShown asBehaviour
defaultManualAsk before anything that changes state
acceptEditsAccept editsFile edits pass, everything else asks
planPlanRead-only; refuse every change
autoAutoEdits and routine commands pass, risky ones ask
bypassPermissionsBypassApprove 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.

Questions are not approvals

ask_user lets the agent put 1–4 multiple-choice questions to you and wait for the answer. It is read-only, so it never asks for approval itself and it still works in plan mode, where clarifying before a plan is most useful. To keep an agent from asking anything at all, deny it: "deny": ["ask_user"].

A question and an approval wait the same way: in the browser both survive a reload, and interrupting the turn takes either one down — an approval as a denial, a question as unanswered. Subagents are not given the tool; a question belongs to the conversation you are watching. Looking up the Cloud app catalog (cloud_app_tools) is read-only for the same reason; running an app's tool (cloud_app_execute) still asks.

Rules

Matched against both the tool name and a Tool(argument) form:

{
  "allow": ["Bash(npm run test:*)", "mcp__github__get_*", "cloud_app_execute(gmail:*)"],
  "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:

ScopeFileFor
user~/.oharness/settings.jsonA personal habit
project.oharness/settings.jsonBelongs to this repo, reviewable
local.oharness/settings.local.jsonPer-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(...), and approving one platform app tool — cloud_app_execute(gmail:send_message) — must not grant the rest of Gmail, let alone every connected app.

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.

Questions from ask_user reach the runtime audit sinks (an audit-jsonl plugin, for instance) as two events paired by toolCallId — phase: "asked" and phase: "settled" with its outcome and how long it waited. They carry how many questions there were and nothing of the wording or the answers, which belong to the transcript.