Permissions for AI Agents
Configure read-only investigation agents in OpenCode and Claude Code with narrow permission allowlists, separate writers and unattended approval rules.

I use a coding agent every day to investigate infrastructure: read manifests, query a cluster, search logs, and explain why a deployment is stuck. The agent is useful only if it can run reads without asking me every few seconds, and it is safe only if it can never run a write without me. A vague instruction such as "be careful in production" does not give you either property. A written allowlist does.
This guide shows how I configure an investigation agent in OpenCode and Claude Code. The ideas apply to any agent harness that has allow, ask and deny rules.
NOTE
Permission rules match text. They are not a semantic guarantee that a command is read-only. Pair them with read-only credentials and read-only Kubernetes RBAC for the identity the agent uses.
The layers
We will build four layers, from the most mechanical to the least:
- Harness rules: allow, ask and deny rules that the tool enforces before a call runs.
- Prompt rules: instructions in the agent definition for the cases a glob cannot express.
- A separate writer: fixes go to a different agent in a fresh git worktree.
- Code-level decisions: when nobody is at the keyboard, a program answers each permission prompt.
Know how your tool resolves rules
Before you write a single rule, check how the tool picks a winner when several rules match the same call. The two tools in this guide do it differently.
OpenCode evaluates rules in order, and the last matching rule wins. In the V2 configuration format, the permissions docs describe an ordered array of action, resource and effect objects, and say that if no rule matches, OpenCode uses ask. The V1 object format uses the same last-match-wins rule, with the "*" catch-all first.
Claude Code does not use order. Its permissions docs state that rules are evaluated as deny, then ask, then allow, and that rule specificity does not change this order. A matching ask rule prompts even when a narrower allow rule also matches.
The practical difference:
| OpenCode | Claude Code | |
|---|---|---|
| Who wins on overlap | The last matching rule | deny, then ask, then allow |
| How to make a write rule beat a read rule | Put it after the read rule | Put it in ask or deny; position does not matter |
| No rule matches | ask (V2) | Depends on the tool and the permission mode |
Step 1: default everything to "ask"
The first rule is a catch-all that asks. Everything after it is an exception.
In an OpenCode V2 agent file (.opencode/agents/investigator.md), the frontmatter looks like this:
---
description: Read-only infrastructure investigation
mode: all
permissions:
- { action: "*", resource: "*", effect: ask }
- { action: read, resource: "*", effect: allow }
- { action: read, resource: "*.env", effect: ask }
- { action: read, resource: "*.env.*", effect: ask }
- { action: glob, resource: "*", effect: allow }
- { action: grep, resource: "*", effect: allow }
- { action: external_directory, resource: "*", effect: ask }
- { action: external_directory, resource: "$HOME/src/*", effect: allow }
---
The important bits:
- The
"*"rule comes first, so every later rule overrides it. - File reads, glob and grep are allowed, but
.envfiles ask again. The later, more specific rule wins. external_directorycovers paths outside the directory where you started the agent. The V2 default allows it, so I set it back toaskand then allow only the directory that holds my repository clones.
Claude Code already asks before shell commands, file edits and most network calls in its default mode, and it runs file reads in the working directory and a built-in set of read-only commands without a prompt. You do not need a catch-all rule. You need to keep the mode at default, and never use bypassPermissions for this agent:
{
"permissions": {
"defaultMode": "default",
"disableBypassPermissionsMode": "disable",
"deny": ["Read(./.env)", "Read(./.env.*)"]
}
}
Step 2: allow MCP reads by verb, then let writes win
Model Context Protocol (MCP) servers expose many tools, and the tool names usually start or contain a verb: tracker_get_issue, chat_list_channels, docs_search_pages. That verb is the most useful signal you have, so allow by verb pattern.
Some names contain both a read verb and a write verb. chat_add_list_record contains _list_ and _add_. You want the write verb to win.
In OpenCode V2, MCP tools are checked with the action <server>_<tool> and the resource *. Put the read patterns first and the write patterns after them:
# Reads by verb
- { action: "*_get_*", resource: "*", effect: allow }
- { action: "*_list_*", resource: "*", effect: allow }
- { action: "*_search_*", resource: "*", effect: allow }
- { action: "*_read_*", resource: "*", effect: allow }
# Writes by verb: these come later, so they win on overlap
- { action: "*_add_*", resource: "*", effect: ask }
- { action: "*_create*", resource: "*", effect: ask }
- { action: "*_update*", resource: "*", effect: ask }
- { action: "*_delete*", resource: "*", effect: ask }
- { action: "*_send_*", resource: "*", effect: ask }
- { action: "*_set_*", resource: "*", effect: ask }
- { action: "*upload*", resource: "*", effect: ask }
chat_list_channelsmatches only*_list_*, so it runs.chat_add_list_recordmatches*_list_*and*_add_*. Theaskrule is later, so it asks.- A tool with no known verb matches only the catch-all and asks.
- Servers do not agree on separators. Some use
get_issue, some useget-issue, and some end with the verb (*_search). Add the hyphen and suffix forms for the servers you actually use, and check the tool list once.
In Claude Code, an allow rule can use a glob in the tool name only after a literal mcp__<server>__ prefix, as the MCP permission rules explain. Ask and deny rules can use globs across all servers. Because ask always beats allow, the order in the file does not matter:
{
"permissions": {
"allow": [
"mcp__tracker__get_*",
"mcp__tracker__list_*",
"mcp__tracker__search_*",
"mcp__chat__*_list_*",
"mcp__chat__*_read_*"
],
"ask": [
"mcp__*create*",
"mcp__*update*",
"mcp__*delete*",
"mcp__*send*",
"mcp__*add*",
"mcp__*upload*"
]
}
}
TIP
Verb patterns are a heuristic. Read the tool list of each new server once and look for writes with unusual names (archive, merge, approve, transition). Add them to the write rules.
Step 3: allow shell commands by prefix
Shell rules match the command text. Allow the read subcommands of each CLI as full prefixes:
- { action: shell, resource: "kubectl config current-context", effect: allow }
- { action: shell, resource: "kubectl get *", effect: allow }
- { action: shell, resource: "kubectl describe *", effect: allow }
- { action: shell, resource: "kubectl logs *", effect: allow }
- { action: shell, resource: "kubectl get secret*", effect: ask }
- { action: shell, resource: "gcloud compute instances list *", effect: allow }
- { action: shell, resource: "gcloud compute instances describe *", effect: allow }
- { action: shell, resource: "gcloud logging read *", effect: allow }
- { action: shell, resource: "git log *", effect: allow }
- { action: shell, resource: "git diff *", effect: allow }
The same rules in Claude Code:
{
"permissions": {
"allow": [
"Bash(kubectl get *)",
"Bash(kubectl describe *)",
"Bash(kubectl logs *)",
"Bash(gcloud compute instances list *)",
"Bash(gcloud compute instances describe *)",
"Bash(gcloud logging read *)"
],
"ask": [
"Bash(kubectl get secret*)"
]
}
}
kubectl get *is a read, butkubectl get secret my-secret -o yamlprints secret data. Theaskrule forkubectl get secret*catches it. In OpenCode it must come afterkubectl get *. In Claude Code it wins because it is anaskrule. Text matching still misses forms such askubectl get pods,secrets, so remove secret reads from the agent's RBAC role as well.- In Claude Code, a trailing
*with a space before it also matches the bare command, soBash(kubectl get *)matcheskubectl gettoo. The space matters:Bash(ls *)does not matchlsof. See wildcard patterns.
Be careful with a wildcard in the middle
It is tempting to write gcloud * list and gcloud * describe to cover every service at once. A * in the middle matches any text, including other verbs and flags. Claude Code's docs show that Bash(git * main) matches git push origin main. In the same way, gcloud * list matches any gcloud command whose last word is list, for example a write command that takes a positional argument named list. It also does not match gcloud compute instances list --format=json, because nothing follows list in the pattern.
My rule: use full prefixes for each service you need (gcloud compute instances list *), and accept a slightly longer file. The session review command below keeps that list up to date.
Step 4: check pipelines per command
Agents like to pipe: kubectl get pods -A | grep CrashLoop | head. Both tools split a compound command and check each part:
- Claude Code splits on
&&,||,;,|,|&,&and newlines, and every subcommand must be allowed for the whole command to run without a prompt. - OpenCode V2 uses a shell scanner that extracts each subcommand and applies the rules to every one. Commands it cannot analyze fail closed with a scanner error (docs).
So you need to allow the filters as well, and only the filters that cannot write:
- { action: shell, resource: "head *", effect: allow }
- { action: shell, resource: "tail *", effect: allow }
- { action: shell, resource: "wc *", effect: allow }
- { action: shell, resource: "grep *", effect: allow }
- { action: shell, resource: "sort *", effect: allow }
- { action: shell, resource: "cut *", effect: allow }
- { action: shell, resource: "jq *", effect: allow }
# Write-capable or never-ending forms: later, so they win
- { action: shell, resource: "sort *-o*", effect: ask }
- { action: shell, resource: "sort *--output*", effect: ask }
- { action: shell, resource: "tail -f*", effect: ask }
- { action: shell, resource: "tail -F*", effect: ask }
head,wc,grep,cutandjqonly read stdin or their file arguments and print to stdout.teecopies stdin to files. Never allow it in a read-only agent.sort -o FILEwrites the result to a file, so the-oand--outputforms ask.uniqtakes an optional second argument that is an output file. I leave it out.tail -fdoes not write, but it never exits and blocks the session. It asks.- I do not allow
sedorawkat all. Both can write files (sed -i,awkwithprint > file).
In Claude Code, head, tail, grep and wc are in the built-in read-only set, and Claude Code also checks the files a tee command writes against your Edit rules (redirections). An output redirect such as > out.txt is checked the same way.
Step 5: prompt rules that a glob cannot express
Some rules depend on meaning, not text. Put them in the agent definition, in a short section the agent reads on every turn. This is the block I use, made generic:
## Safety rules
- Never execute an operational mutation, even with user approval. You may
propose the exact command for the user to run. Label it "operator-only;
not executed by this agent", and state the target and blast radius.
- An approval prompt is a request for review, not permission to mutate.
Never request approval for a write. Before you request approval for a
read outside the allow rules, say why it is needed.
- If the user rejects a command, do not retry it or a reworded equivalent.
Suggest the closest allowed read, or recommend a narrow rule for review.
- In production clusters, run only namespace-scoped reads, even with
read-only credentials. Nodes, Namespaces, StorageClasses and webhooks are
cluster-scoped.
- Never run commands that change authentication or the active identity
(for example `gcloud auth login`, `gcloud config set account`,
`gh auth login`, `kubectl config use-context`). If authentication fails,
stop and ask the user to fix it.
- Never print tokens, auth headers or secret values.
Why each rule exists:
- Approval is not permission. Without this rule, the agent treats the prompt as the safety check: "I will ask, and the user will decide." People approve prompts quickly when they are busy. The agent must not put a write in front of a tired reviewer.
- No reworded retries. If I reject
kubectl delete pod my-app-123, I do not want to seekubectl delete pods -l app=my-appnext. The rejection applies to the intent, not the string. - Namespace-scoped reads in production. A cluster-wide list on a large cluster returns objects outside the question and adds load to the API server. You can list the cluster-scoped kinds with
kubectl api-resources --namespaced=false(kubectl reference) and name them in the rule, because a glob cannot tellkubectl get nodesfromkubectl get pods. - No auth changes. Switching the account or the kubeconfig context changes what every later command can do.
kubectl config use-contextalso writes to your kubeconfig file (docs).
WARNING
Prompt rules shape what the agent tries. They do not enforce anything. Keep every rule you can express as a pattern in the harness configuration, and use the prompt only for the rest.
Step 6: hand fixes to a separate writer
An investigation often ends with "the fix is a one-line change in the deployment repository". Do not give the read-only agent edit rights for that. Start a second agent with write access, in a fresh git worktree based on the latest default branch:
git -C ~/src/my-app fetch origin --prune
git -C ~/src/my-app worktree add -b fix/my-app-probe-timeout \
~/src/worktrees/my-app-probe-timeout origin/HEAD
origin/HEADpoints at the remote's default branch. If your clone does not have it, set it withgit remote set-head origin --auto.- The new worktree has its own branch, so the main checkout and any uncommitted work in it stay untouched.
Then start the writer in that directory with a self-contained prompt. The writer has none of the investigation context, so the prompt must include all of it:
Repository: ~/src/worktrees/my-app-probe-timeout (branch fix/my-app-probe-timeout)
Problem: my-app pods in team-a restart every few minutes. The liveness
probe times out after 1s while startup takes about 20s.
Evidence: kubectl describe pod shows "Liveness probe failed: timeout".
Change: in deploy/my-app/deployment.yaml, add a startupProbe and keep the
liveness probe as it is.
Verify: run the manifest validation used in CI.
Report back: the diff, the commands you ran, and anything you did not do.
Do not push or open a pull request.
When the report comes back, the read-only agent checks it with reads (git -C <worktree> diff, git -C <worktree> status --short) before it tells you the fix is ready.
Step 7: running unattended
I also run the same agent from a chat bridge, so teammates can ask it questions. Nobody watches the terminal there, so permission prompts need an answer from code:
- Allow the calls you would always approve: file reads, search, and writes to the agent's own memory directory.
- Reject everything else with a fixed message, so the agent knows to choose an allowed alternative.
- Check the sender against an allowlist of user IDs before the message reaches the agent.
With the OpenCode SDK, the bridge listens to the event stream and replies to each permission.asked event. This is a simplified version of mine, written against the V2 SDK:
const REJECT_MESSAGE =
"This bot runs unattended and allows only preapproved read-only actions.";
const AUTO_ALLOW = new Set(["read", "grep", "glob", "list"]);
function shouldAllow(request) {
if (AUTO_ALLOW.has(request.permission)) return true;
if (request.permission === "edit") {
// Every path must be inside the memory directory.
return request.patterns.length > 0 &&
request.patterns.every((p) => p.startsWith("memory/"));
}
return false;
}
for await (const event of events.stream) {
if (event.type !== "permission.asked") continue;
const allow = shouldAllow(event.properties);
await client.permission.reply({
requestID: event.properties.id,
reply: allow ? "once" : "reject",
message: allow ? "Approved by the bot allowlist." : REJECT_MESSAGE,
});
}
- Use
every, notsome, when a request carries several paths or command strings. One safe value must not approve the others. - Reply
once, neveralways. Analwaysreply widens the rules for the rest of the session. - The rejection message is fixed text. Do not echo the command back, and do not let the agent negotiate.
With the Claude Agent SDK, the simplest unattended setup is permissionMode: "dontAsk" with an explicit allowedTools list. Every call that would prompt is denied. If you need logic, use a canUseTool callback that returns { behavior: "deny", message } for anything you do not recognize. Note that tools approved by an allow rule never reach the callback, so do not put checks there that must run on every call. Use a PreToolUse hook for those.
Step 8: a command that proposes the narrowest rules
Even with good rules, some routine reads will still prompt. Instead of approving them with "always", I end the session with a command that reviews what needed approval. In OpenCode, it is a file in .opencode/commands/. In Claude Code, it is a skill in .claude/skills/review-permissions/SKILL.md.
---
description: Review permission prompts from this session and propose allow rules
---
Review the current session:
1. List the shell commands and tool calls that required permission.
2. Classify each one as read-only or not. A command is read-only only if it
cannot change state, write files, change authentication, or run
indefinitely.
3. For the read-only ones, propose the narrowest allow rules for the
investigation agent's configuration.
Constraints:
- Do not propose rules for mutating, auth-changing or write-capable commands.
- Prefer full prefixes (`gcloud compute routers list *`) over wildcards in
the middle (`gcloud * list`).
- Keep every existing safety rule and allow rule.
Summarize which commands prompted, how you classified them, and the exact
rules you propose.
Run this command in your editing agent, not in the read-only agent. The read-only agent must never edit its own definition. I review the proposed rules like a pull request, and the allowlist grows only by reviewed, narrow entries.
Checklist
- Know whether your tool uses last match wins (OpenCode) or deny, ask, allow (Claude Code).
- Start from "ask" and add exceptions.
- Allow MCP reads by verb, and make write verbs win on overlap.
- Allow shell reads by full prefix. Avoid wildcards in the middle.
- Allow only pipeline filters that cannot write. No
tee,sed,awkorsort -o. - Keep production reads namespace-scoped and auth commands off limits in the prompt.
- Send fixes to a writer agent in a fresh worktree with a self-contained prompt.
- Unattended: decide in code, reply
once, reject with a fixed message, and allowlist users. - Grow the allowlist from a reviewed list of what prompted.
References
- OpenCodehttps://opencode.ai/docs/
- Claude Codehttps://code.claude.com/docs/en/overview
- Kubernetes RBAChttps://kubernetes.io/docs/reference/access-authn-authz/rbac/
- permissions docshttps://opencode.ai/v2/docs/permissions/
- last-match-wins rulehttps://opencode.ai/docs/permissions/
- permissions docshttps://code.claude.com/docs/en/permissions#manage-permissions
- permission modehttps://code.claude.com/docs/en/permission-modes
- built-in set of read-only commandshttps://code.claude.com/docs/en/permissions#read-only-commands
- Model Context Protocolhttps://modelcontextprotocol.io/
- MCP permission ruleshttps://code.claude.com/docs/en/permissions#tool-name-wildcards
- wildcard patternshttps://code.claude.com/docs/en/permissions#wildcard-patterns
- every subcommand must be allowedhttps://code.claude.com/docs/en/permissions#compound-commands
- teehttps://man7.org/linux/man-pages/man1/tee.1.html
- sort -o FILEhttps://man7.org/linux/man-pages/man1/sort.1.html
- uniqhttps://man7.org/linux/man-pages/man1/uniq.1.html
- redirectionshttps://code.claude.com/docs/en/permissions#redirections
- kubectl referencehttps://kubernetes.io/docs/reference/kubectl/generated/kubectl_api-resources/
- docshttps://kubernetes.io/docs/reference/kubectl/generated/kubectl_config/kubectl_config_use-context/
- git worktreehttps://git-scm.com/docs/git-worktree
- git remote set-head origin --autohttps://git-scm.com/docs/git-remote
- Claude Agent SDKhttps://code.claude.com/docs/en/agent-sdk/permissions
- hookhttps://code.claude.com/docs/en/agent-sdk/hooks
- skillhttps://code.claude.com/docs/en/skills