Course · OpenCode · Permissions & isolation
Switch agents to switch policy
On feedmill - the course’s illustrative Go feed aggregator, a Go CLI plus a sync server pulling dozens of differently-shaped RSS, Atom, and JSON feeds into one deduped reading queue - one source keeps re-syndicating the same article with different tracking parameters on its link, and the queue lists the article twice. Before you change a line of the dedupe pass, you want to understand it: trace how an entry becomes “new”, read the key logic, walk the sync path. That’s a read-heavy investigation, and the last thing you want during it is the agent “helpfully” rewriting the code you were only trying to read. You want a posture: eyes on, hands off.
Then the investigation ends and the work begins - fix the key logic, add the test, wire the new source - and that no-edits leash is just friction. You want the opposite posture: a free hand.
In a tool with saved permission profiles you’d be editing a settings file to flip between those two stances. OpenCode puts the policy somewhere you can reach without leaving the prompt: on the primary agent itself. A primary agent - the kind you talk to directly, like the built-in build and plan - carries its own permission map, so you don’t edit a map to go from auditing to building. You switch which agent you’re talking to.
The two agents are two policies
Section titled “The two agents are two policies”OpenCode ships two primary agents, and the interesting difference between them here is what they’re allowed to do:
plan- restricted. Its permission map denies the edit tools (it can still write its own plan files under.opencode/plans/): the agent can read, grep, and reason freely, but it cannot change your code. It does not gate bash unless you add a rule, so shell commands still run (as of October 2026, v1.18.32, per theplandefinition inagent.ts; the agents documentation describes edits and bash as defaulting toask).build- the default, with all tools enabled. Out of the box, edits and bash run end to end, no checkpoint (agents documentation, as of October 2026).
Switch between them with the Tab key, right at the prompt - no command, no file, no restart (the default binding for the agent_cycle keybind; shift+tab cycles back as agent_cycle_reverse; keybinds documentation, as of October 2026). The active agent is surfaced in the TUI, OpenCode’s full-screen terminal interface, so you can see which posture you’re in - your permission policy stays visible instead of buried in a config you have to remember the state of.
Start the investigation in plan. Tab until the active agent shows plan, then ask the read-only questions - an illustrative exchange:
plan ▸ how does the dedupe pass decide an entry is new? walk me through the key, and where a tracking-param'd link would slip past it.
⏵ read internal/dedupe/hash.go, internal/adapter/normalise.go
Key hashes the feed URL plus whatever entry ID it is given. The Atom adapter in internal/adapter/normalise.go keeps no entry ID, only the link, so the raw link is the only identity a caller can pass - and a source that re-syndicates with new tracking parameters hashes to a new key on every fetch. That's your duplicate.That landed a real finding, and reading never tripped a gate. Now suppose, mid-trace, you reflexively ask it to fix what it just found (illustrative again):
plan ▸ ok, fix it - normalize the link before it becomes the key.
✗ edit denied (agent: plan) internal/dedupe/hash.goThat refusal is the point. In plan, an edit to your code doesn’t happen - the permission map denies it, and there is no prompt to approve. The policy isn’t advice you’re trusting the model to honor; the agent is configured to refuse every edit outside its plan files. That’s enforcement rather than guidance - a distinction the plugin hooks page develops further when code, not config, starts doing the gating. On code you were only trying to understand, that refusal is the difference between an audit and an accident. Its limit is the shell: plan leaves bash at allow, so a command that writes a file, such as gofmt -w, still runs. (If you want a wall that covers the shell too, see the custom agent below.)
Which posture a piece of work deserves is a call you can make before you touch Tab, and the stakes side of it rests on two properties of the work, not on how hard it is: how hard the worst single wrong action would be to undo, and how far its consequences would reach. Sizing the leash by those two properties - deciding, before the run, how much the agent may do without asking and where it must stop for you - is the control principle, and the dial below runs on it. Set it for this page’s work first. The trace and the fix both touch git-tracked files in your own repo (undone with a git checkout, reaching only your working copy), so the dial puts both on the same rung: run free inside a fence. This page has no fence yet, so the fix below runs tighter than that rung, near ask-before-acting, in build with edits gated. The dial measures stakes, not intent. You pick plan for the trace for a reason it doesn’t score: while you’re only reading, you want no edits at all. Now set the worst action to damage to the reading store, which takes real work to rebuild, and the rung tightens to auto-apply edits, gate the rest. plan sits on no single rung: it refuses edits like the read-and-propose-only rung but runs shell commands without asking. A build agent with edit: "allow" and bash: "ask" fits the auto-apply-edits-gate-the-rest rung; feedmill’s build from the per-tool page, which asks before edits and before any command outside its short bash allowlist, sits closer to ask-before-acting (the map is on the per-tool page). The read-and-propose-only rung is the custom audit agent you’ll write below, and the run-free-inside-a-fence rung is what the container page builds:
Tab carries the thread across the policy line
Section titled “Tab carries the thread across the policy line”Here’s what makes this better than two separate settings files: switching agents doesn’t switch sessions. The conversation comes with you. So the natural move is to do the whole investigation in plan, let it propose an approach in words, and only then cross into build to execute - same thread, same context, restricted-then-open policy.
One note before the transcript, because a stock config would show something slightly different: out of the box, build’s edits land without asking. This run is against feedmill’s opencode.json, the project config file at the repo root, which sets agent.build.permission.edit to ask - the gate the per-tool map page writes onto build - so the edits below arrive behind your approval. The excerpt shows only that key; the per-tool page’s bash map, which lets go test run without a prompt, is left out here:
{ "agent": { "build": { "permission": { "edit": "ask" } } }}That setting belongs only to build. The separate plan agent refuses edits because that is its built-in default, not because it inherits build’s map. That default sits under your global config: a top-level permission rule for edit, such as "edit": "allow", is applied after it and overrides the deny. To keep the wall, set agent.plan.permission.edit to deny (agent.ts, as of October 2026, v1.18.32). An illustrative run:
plan ▸ outline the fix without writing anything yet.
Plan: 1. Add a CanonicalLink helper in hash.go that strips utm_* and fbclid params and lowercases the host, and hash its result. 2. Add a table test in hash_test.go covering the tracking-param case.
(Plan can't apply this - Tab to build when ready.)
▸ [Tab] agent: build
build ▸ do it.
⏵ edit internal/dedupe/hash.go → approved ⏵ write internal/dedupe/hash_test.go → approved ⏵ bash go test ./internal/dedupe/ ok github.com/example/feedmill/internal/dedupe 0.142sEdits were refused in plan and waited for your approval in build, the test command ran without a prompt, and you never left the prompt to do it. The plan agent read and proposed; the build agent edited and ran the test - and because it’s one thread, build already knew exactly what the plan was.
When two policies aren’t enough, write a third
Section titled “When two policies aren’t enough, write a third”plan walls off edits but leaves the shell open, and build is wide open, but the read-only pass wanted something sharper than both: a hard wall on edits and on the shell, with only a narrow set of read-only commands allowed to run without prompting (git log, git blame, go vet). Neither shipped agent is exactly that.
This is where a tool with saved permission profiles would send you into its settings menu. OpenCode’s out-of-the-box answer is the agent itself: a custom primary agent is the saved posture, and per-agent policy is the first-class knob. You author one once, and from then on it’s just another agent on the Tab cycle. The fastest way is opencode agent create, which runs interactively - asks where to save it and what it’s for, has you pick a starting permission posture, then generates the identifier and the system prompt and writes the file for you (as of October 2026; agents documentation). Or hand-write it - a primary agent is a markdown file in .opencode/agents/ (this repo only) or ~/.config/opencode/agents/ (every project on your machine), frontmatter on top, system prompt in the body:
---description: Read-only auditor - reads code and runs read-only git/vet, never editsmode: primarypermission: edit: deny task: deny bash: "*": deny "git log*": allow "git blame*": allow "go vet ./...": allow "*--output*": deny "*>*": deny---You are auditing feedmill's pipeline. Read, trace, and report findings.Never propose edits unless asked; when asked, describe the change inwords and tell the user to switch to build to apply it.The mode: primary line is what puts audit on the Tab cycle alongside build and plan. The permission block is the posture: edit: deny is the same hard wall plan puts on edits, with no approve button to fat-finger. task: deny closes the side door: a subagent that audit launched through the task tool would run under its own permissions, not audit’s (subagent permission source, as of October 2026). The bash map uses OpenCode’s last-match-wins evaluation, so the catch-all "*": deny comes first and the specific allowances after, letting git blame run while rm -rf never does. The last two lines close two ways an allowed command can still write a file: *--output* denies git log --output=<file>, and *>* denies a shell redirect such as git log > notes.txt, which OpenCode checks as the full statement text and which git log* would otherwise allow. The rule also blocks harmless redirects such as 2>&1 (shell tool source, as of October 2026, v1.18.32). An allowlist is only as tight as its patterns, so test each one before you rely on it.
One limit on “read-only”: every agent starts from the default "*": "allow", so a tool the map does not name keeps allow. That covers webfetch and any MCP or plugin tool, and an MCP tool can write to the system it reaches, so deny those tools here too if the auditor must not touch them (as of October 2026, v1.18.32, per agent.ts).
Permission values are allow, ask, or deny, and the keys go far beyond edit and bash - read, webfetch, external_directory, and the rest of the documented keys are all settable per agent, with the agent’s rules taking precedence over your global config (permissions documentation, as of October 2026). The full list, the shipped defaults (including the .env read rule), and the bash pattern grammar live in the per-tool reference page.
Now the read-only pass is one keystroke away. Tab to audit to investigate with exactly the latitude you want, Tab to build when it’s time to fix, Tab to plan when you want edits refused but commands still available. The policy isn’t something you configure each time you need it - it’s something you select, the same way you select which agent does the thinking.
That’s the coarse-grained version: a whole posture per agent, enforced at the agent boundary. But an agent that can’t edit can still read your code and shell out to whatever its bash rules allow, as your user, on your machine. When read-only isn’t a strong enough wall - auditing genuinely untrusted code, or letting a build agent run wide while you sleep - you stop relying on policy and put a real boundary around the whole process. Next: isolate by running in a container.