Skip to content

Course · OpenCode · Permissions & isolation

Set permissions per tool, per agent

feedmill, the course’s illustrative Go feed aggregator, has parts you would protect very differently. The SQLite database that internal/feed/store.go opens (feedmill.db by default) holds the parsed reading queue - the accumulated history of every feed pulled so far, and the one part of the project that isn’t trivially regenerable if a command mangles it. A scratch script for probing one flaky feed source is the opposite: disposable by design. So the posture you want is a split - reads and searches run free, because they can’t hurt anything and prompting on them is pure friction, while file edits and shell commands stop at a checkpoint, at least for work anywhere near the storage layer. That split is the control principle in its plainest form - deciding, in advance, what runs without asking and what waits for you - and a per-tool permission map is the tool for writing that decision down.

In OpenCode, permissions attach to the agent you’re running. The built-in build agent (the default agent, which edits files and runs commands) has its own map, plan (the built-in agent that denies file edits) has another, and any agent you define carries its own. A top-level permission key in opencode.json sets rules for every agent; each agent’s map is merged over it, and the agent’s rules take precedence (permissions documentation, as of October 2026). So you don’t toggle a setting - you write down, once, what each tool is allowed to do for this agent: allow it to run silently, ask you before every use, or deny it outright.

The keys are the tools the agent actually calls. The documented list (permissions documentation, as of October 2026) has thirteen of them: read, edit, glob, grep, bash, task, skill, lsp, question, webfetch, websearch, external_directory, and doom_loop. The config schema accepts two more the doc page doesn’t list yet: list, directory listing - distinct from glob’s pattern-based search - and todowrite (permission schema, as of October 2026). Left unset, most keys default to allow; doom_loop (the same tool called repeatedly with identical input) and external_directory (tool calls on paths outside the project) default to ask. Three of these keys also answer a question a local-model setup raises: pointing OpenCode at a model on your own machine keeps only the model call local, and the agent’s own reach outward - the shell it runs, the pages it fetches, the searches it makes - is gated here, by bash, webfetch, and websearch (the local and custom providers page covers that setup). These keys gate tool calls, not network traffic: an allowed go build * can still download modules.

And one default is sharper than the rest, in a way worth internalizing: read is allow, but .env files are carved out. The shipped read permission is not a bare allow - it is a map over the files secrets live in. The permissions documentation lists it as allow-everything except "*.env": "deny" and "*.env.*": "deny", with "*.env.example": "allow" carved back out so example templates stay readable. The source sets those two .env rules to ask, not deny (as of October 2026, v1.18.32; agent.ts), so a stock agent stops and asks before it reads a .env file, and a headless opencode run rejects that ask. Either way, a default gate sitting inside an allow key is the permission layer quietly doing work you would otherwise have to remember to do yourself - and the carve-out only functions because of the ordering rule you’ll meet below: the last matching rule wins, and the .env.example allowance is listed after the rules it excepts.

The policy for feedmill reads almost like the English sentence you’d say out loud. Reads, globs, and greps run free; edits and bash stop and ask. Drop this into your project’s opencode.json, under the build agent. The map leaves read out on purpose: it already defaults to allow, and a bare "read": "allow" would become one rule for every file that sits after the default .env rules and wins, so .env reads would run without a prompt (permission/index.ts, as of October 2026, v1.18.32):

{
"agent": {
"build": {
"permission": {
"glob": "allow",
"grep": "allow",
"edit": "ask",
"bash": "ask"
}
}
}
}

That’s the whole policy. Now when you point the agent at the bug you’re chasing, the read beats fly past untouched and the write beat is where it pauses - an illustrative exchange:

> internal/dedupe lets the same entry through twice when a feed
re-serves it under a different id scheme. Find the cause.
▌ grep "func Key" internal/dedupe
▌ read internal/dedupe/hash.go
Key hashes the feed URL plus the entry's id as the feed serves it,
and this source swaps id schemes between polls, so each fetch of the
same article produces a new key and reads as new. Normalizing the id
before hashing fixes it.
▌ edit internal/dedupe/hash.go
Permission required
Edit internal/dedupe/hash.go
Allow once Allow always Reject

Every grep and read ran on its own - that’s allow doing its job, no prompts, no tax on the cheap stuff. The moment it reached for edit, the leash caught: OpenCode shows you the tool and target and waits. That pause is the map doing what you wrote it for - the agent can dig through the dedupe pass all it likes, but it can’t edit a file or run a command in feedmill without you saying so, and near the store code that checkpoint is the difference between an edit and an incident. (The prompt comes from the edit: "ask" you just wrote onto build. The map is per agent: it governs build and no other agent, including a subagent that build hands work to through the task tool, which runs under its own permissions (subagent permission source, as of October 2026). Set task to ask in this map if that path matters.)

The three choices are not equal. Allow once approves this one request. Allow always approves future requests that match the pattern OpenCode suggests, so for those requests the checkpoint is gone until you restart OpenCode. The approval is held in memory for the running OpenCode process, so it also covers other sessions in the same project until that restart (as of October 2026, v1.18.32, per permission/index.ts; the permissions documentation describes it as lasting for the current session). Reject refuses the request (permissions documentation, as of October 2026).

bash: "ask" is honest but blunt - it stops the agent before every command, including the ones you’d never think twice about. You don’t need a prompt to run the test suite; you very much want one before anything mutates state. OpenCode lets you swap the single value for a map of command patterns, and that’s where this gets precise. Replace the bash line in the same build map, and keep the other keys as they were, so the loop stays quiet on the commands you trust and only interrupts you on the rest:

opencode.json
{
"agent": {
"build": {
"permission": {
"glob": "allow",
"grep": "allow",
"edit": "ask",
"bash": {
"*": "ask",
"go test *": "allow",
"go build *": "allow",
"git status": "allow"
}
}
}
}
}

Two rules of the grammar make this work, and getting them wrong is the classic way these maps misfire.

First, the wildcards. These are flat command-string matches, not filesystem path globs: * matches zero or more of any character and ? matches exactly one. A trailing " *" (space then star) is special-cased so the argument part is optional - go test * covers go test ./internal/dedupe/..., go test -run TestDedupe, and bare go test alike. That reach cuts both ways: go test -exec <prog> and go build -toolexec <prog> run another program, and the allow covers them too, so these rules trust whatever those flags name. Add "go test *exec*": "ask" and "go build *toolexec*": "ask" after the allows if that matters to you. The * before the flag catches it wherever it sits, and *exec* also catches go test -toolexec. Don’t reach for a ** recursive glob the way you would in a .gitignore - it buys you nothing here. Under the hood OpenCode just turns the pattern into a regex (* becomes .*, ? becomes .), so a ** collapses to the very same match as a single *; it isn’t a separate “recursive” operator. (Path-style permission rules elsewhere in OpenCode config do use ** in directory globs - but bash command patterns are not paths.)

There’s history here worth knowing. An earlier OpenCode release let a flagged command slip past its pattern: "kill *" didn’t catch kill -9 PID (issue #6676 is exactly that repro). The cause the issue suggests, and marks unconfirmed, is that the parser dropped flag tokens when it extracted the command, so kill -9 PID was checked as bare kill. The release that fixed it isn’t pinned in the release notes. Current source checks your patterns against the full command text as written, flags included (shell tool source, as of October 2026). Its separate “arity” table, which counts a command’s subcommand words, only shapes the pattern that Allow always suggests. If you run a pinned older OpenCode, verify the behavior on your version instead of trusting the pattern.

Second - and this is the one that bites - when several patterns match the same command, the last matching rule wins. OpenCode’s permissions documentation explicitly recommends putting the catch-all "*" first and specific exceptions after it (the .env default above runs on the same logic). For this map, * matches every command, then go test * overrides it for test runs and git status overrides it for that one command. Reverse the lines and you reverse the result.

> run the tests, then push the fix
▌ go test ./...
ok github.com/example/feedmill/internal/dedupe 0.214s
▌ git push origin fix/dedupe-ids
Permission required
Shell command: $ git push origin fix/dedupe-ids
Allow once Allow always Reject

go test ran on its own - it matched both "*" and go test *, and go test * is the last matching rule because it sits after the catch-all. git push matched only "*": "ask", so it stopped for you. That’s the map behaving exactly as written: loud on the state-changing commands, silent on the ones you’ve already decided to trust.

The same map form works for edit, keyed by the file path relative to the repo root, and there * matches across directories. So "edit": { "*": "allow", "internal/feed/*": "ask" } lets edits to other files, such as the scratch script, land without a prompt and stops for any file under the store package (edit.ts, as of October 2026, v1.18.32).

You now have a build agent that reads and searches feedmill without interrupting you (a .env file still asks), asks before it edits, and asks before any bash command except the test-and-build cycle you explicitly trusted - a policy you wrote down once instead of a vigilance you sustain by hand. The map is precise because it’s per-tool, and it’s trusted because the dangerous tools stop at ask and you allow your way up from there, command by command. The .env gate covers the read tool only. grep also searches hidden files and skips a .env file only when .gitignore lists it, so with "grep": "allow" a search can return lines from an unignored .env without a prompt (grep.ts and ripgrep.ts, as of October 2026, v1.18.32).

But notice the policy is welded to the build agent. The instant you need a different posture - read everything, write nothing, for an audit pass over the sync server - you don’t want to hand-edit this map and remember to undo it. You want a second agent that already carries that policy, and you want to switch to it with a keystroke. That’s the next move: making the agent itself the unit of policy.