Skip to content

Set permissions per tool, per agent

You’ve been driving feedmill on whatever leash OpenCode shipped, and the work so far — reading parsers, grepping for where a feed’s <updated> gets normalized — has never once made you nervous. The next stretch does. You’re about to let the agent loose near internal/store, the package that owns the deduped reading queue: weeks of pulled-and-merged feed history that nothing in the repo regenerates for you. You don’t want to babysit reads and searches — those can’t hurt anything and the friction is pure tax. You do want a hard checkpoint before the agent writes a file or runs a shell command anywhere near that storage layer. That split — free on the safe tools, gated on the dangerous ones — is exactly what a per-tool permission map is for.

In OpenCode, permissions aren’t a global mode you flip; they’re a property of whichever agent you’re running. The built-in build agent has its own map, plan has another, and any agent you define carries its own. 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 tools you can gate are the ones the agent actually calls — read, edit, bash, glob, grep, list, task — and the policy you want for feedmill reads almost like the English sentence you’d say out loud. Reads, globs, greps, and directory lists run free; edits and bash stop and ask. Drop this into your config under the build agent:

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

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

> internal/store dedupes by feed GUID, but two entries from the same
Atom source are slipping through as duplicates. Find the cause.
▌ grep "GUID" internal/store
▌ read internal/store/dedupe.go, internal/store/store.go
The dedupe key is the raw <id> from the Atom feed, but this source
emits the same entry under two different id schemes across polls, so
each looks new. Normalizing the id before the dedupe lookup fixes it.
▌ edit internal/store/dedupe.go
Allow edit to internal/store/dedupe.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 on a file inside internal/store, the leash caught: OpenCode shows you the tool and target and waits. That pause is the entire point of the map — the agent can investigate your feed store all it likes, but it can’t change a byte of it without you saying so.

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. Tighten bash so the loop stays quiet on safe commands and only interrupts you on the rest:

"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/store/..., go test -run TestDedupe, and bare go test alike. (An earlier OpenCode release shipped a bug where flags after the command could slip past a " *" pattern — kill * didn’t catch kill -9 PID; that was fixed in v1.1.18, so on the pinned release flagged commands match as written.) 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.)

Second — and this is the one that bites — when several patterns match the same command, the most specific one wins. OpenCode doesn’t decide by the order you wrote the rules in; it sorts the patterns by length (longest, i.e. most specific, last) and lets the most specific match settle the outcome. For our map that means * matches every command but is the least specific, so go test * overrides it for test runs and git status overrides it for that one command — and it would land the same way no matter where in the block you put "*". Convention is still to write the catch-all "*" first and the specific rules under it, because that reads top-down the way you’d reason about it (broadest first, narrowest last) — but treat that as a readability habit, not a correctness requirement: specificity, not position, picks the winner.

> run the dedupe tests, then push the fix
▌ go test ./internal/store/...
ok github.com/you/feedmill/internal/store 0.214s
▌ git push origin fix/atom-dedupe
Allow bash: git push origin fix/atom-dedupe?
› Allow once Allow always Reject

go test ran on its own — it matched the more specific go test * allow, which outranks the catch-all. git push matched nothing more specific than "*": "ask", so it fell through to that and stopped for you. That’s the map behaving exactly as written: loud on the storage-touching commands, silent on the ones you’ve already decided to trust.

You now have a build agent that reads and searches feedmill without ever interrupting you, 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 safe because the dangerous tools default to ask and you allow your way up from there, command by command.

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.