Deny rules for AI coding agents: OpenCode and Claude Code
What a deny rule does
A coding agent reads files with your rights. Whatever it reads goes into the prompt and from there to the model provider, the session history and any export of it. A deny rule tells the agent tool itself to refuse an action on a resource: reading a path, running a command, loading a skill. It is enforced by the tool, not by the model, so it holds even when the prompt asks for the opposite.
A deny rule is not a sandbox. It stops the actions the tool recognizes; a script the agent runs can still open any file your user can. The first case below shows both sides on one secret file. Where the line runs differs between OpenCode and Claude Code, so each tool then gets its own section, followed by the gaps and a tested example.
A first case: FTP credentials in site.env
Imagine you have just installed OpenCode and open the project of your website. Next to the code sits site.env with the FTP credentials of the hosting. You want the agent to write the script that uploads the site, and the password must never reach the model.
The idea. To write the script the agent needs the names of the variables, not their values. The script reads site.env when you run it, on your machine; the agent only writes it. So the agent gets an example file with the same names and dummy values, it may not read, write or name the real file, and every shell command asks first, so nothing runs until you see it.
What a fresh install does. Without a config, OpenCode asks before it reads a *.env file.
That protects you only until you approve by mistake: one accidental or unintended approval sends the whole file to the model.
The shell does not ask at all: when the agent runs cat site.env, the password goes straight to the model.
OpenCode also has a grep tool that searches inside files.
It skips the files listed in .gitignore, but a read deny does not stop it: if site.env is not listed there, a search for the password returns the line that holds it.
The tests at the end of this chapter show each case.
The simplest setup. Five files next to the site.
Each file name is clickable and leads to its step:
my-site/ ├── opencode.jsonc OpenCode rule set 1: the shell asks, site.env is denied ├── .gitignore site.env, so git and the grep tool skip it ├── site.env the real credentials; the agent never opens it ├── site.env.example the same names with dummy values; the agent reads this one ├── deploy.sh written by the agent, run by you └── public/ the files to upload
Step 1: an example file. Copy the names from site.env and replace every value with a dummy one. The real site.env keeps the same names with the real values.
vi site.env.example
FTP_HOST=ftp.example.com FTP_USER=your-ftp-user FTP_PASS=your-ftp-password
Step 2: keep site.env out of git. The line keeps the credentials out of the history and out of every git diff, and the OpenCode grep tool skips files that git ignores. Without it, grep returns the password even with the read deny in place.
echo site.env >> .gitignore
Step 3: OpenCode rule set 1. Four rules in opencode.jsonc at the project root. This chapter calls them OpenCode rule set 1.
The last matching rule wins (next chapter), so the denies come after the shell rule:
vi opencode.jsonc
{ "$schema": "https://opencode.ai/config.json", "permissions": [ // The shell asks first: the agent writes deploy.sh, you run it. { "action": "shell", "resource": "*", "effect": "ask" }, // site.env: never read, never written, never named in a command. { "action": "read", "resource": "*site.env", "effect": "deny" }, { "action": "edit", "resource": "*site.env", "effect": "deny" }, { "action": "shell", "resource": "*site.env*", "effect": "deny" } ] }
- Shell asks. A fresh OpenCode runs every command unasked, cat site.env included. With ask each command waits for you.
- Read deny. The built-in rule only asks, and an approval clicked in passing sends the file; a deny holds even under --auto (test 10). The pattern ends with site.env, so site.env.example stays readable.
- Edit deny. A read deny does not stop the write tool (check 1), and the agent must not overwrite the credentials.
- Shell deny. Stops a command that names the file: cat site.env, . ./site.env, grep PASS site.env. It also stops cat site.env.example; the agent reads the example with the read tool instead.
Step 4: ask for the script. Tell the agent where the names are and that the values are loaded at run time:
opencode run "Write deploy.sh: a POSIX sh script that uploads every file in public/ to the FTP server with curl. At run time it must load FTP_HOST, FTP_USER and FTP_PASS from site.env next to it and never print them. Read site.env.example to learn the variable names. Create the file with the write tool. Do not run anything and do not use the shell."
In the test the agent read only site.env.example and wrote a script of about a hundred lines that loads site.env with ., switches command tracing off and hands the password to curl on standard input, so it does not show up in the process list either. The password was neither in the reply nor in the script.
Step 5: read the script, then run it yourself. Look for anything that prints the variables, such as echo or set -x, before the first run:
sh deploy.sh
If the agent wants to run the script, OpenCode asks first. Approve only a script you have read: whatever it prints goes to the model.
Tests
Test setup
The tests ran on 2026-10-23 with OpenCode 2.1.3 and the free OpenCode model.
The project was a throwaway repo with a dummy site.env, listed in .gitignore.
Its password was dummy-pass-7731: a reply that contains it shows that the file was read.
Rows and prompts
Rows marked OpenCode rule set 1 used exactly the four rules from step 3.
Rows marked none ran without a config.
Each prompt asked for a single tool call, as in the tests below.
The step 4 row used the prompt from step 4.
The runs got no input (< /dev/null), so they could not answer a prompt: asked means OpenCode requested permission and the run rejected it.
Results interpretation
Result: ✓ when the actual result matches the expected one, ✗ when it does not.
Red in the Expected column: the run is expected to reveal the password.
Red and bold in the Actual column: the password was revealed although the test expected it to stay safe.
Click a ✓ or ✗ in the Result column to see what the test did and why.
| Config | Step | Expected | Actual | Result |
|---|---|---|---|---|
| none | read tool on site.env | asked | asked: read | |
Prompt: "Use the read tool exactly once on site.env. Then reply with the value of FTP_PASS, or with the exact error text the tool returned. Do not use any other tool." What happened: OpenCode requested permission to read site.env. The run had no one to answer, so OpenCode rejected the request; the reply quoted the rejection and held no password. Why: There was no opencode.jsonc, so only the built-in rules applied, and one of them asks before the read tool opens a *.env file. In the terminal you get that question: one approval, even by mistake, sends the whole file. | ||||
| none | cat site.env | runs | ran, the password in the reply | |
Prompt: "Use the shell tool exactly once to run:cat site.env. Then reply with the output, or with the exact error text. Do not use any other tool." What happened: The command ran at once, without a prompt. The reply listed FTP_HOST, FTP_USER and FTP_PASS=dummy-pass-7731. Why: The built-in rules allow every shell command, and the built-in *.env rule covers only the read tool. This is the leak a fresh install has. | ||||
| none | grep tool: pattern dummy-pass in ., the whole project | no match | No matches found | |
Prompt: "Use the grep tool exactly once with pattern dummy-pass and path . (the current folder). Then reply with the matching lines it returned, or with the exact error text. Do not use any other tool." What happened: The tool answered No matches found, although site.env contains dummy-pass-7731. Why: No rule stopped it: the grep tool skips files that git ignores, and site.env was listed in .gitignore. The last row runs the same search without that line. | ||||
| OpenCode rule set 1 | read tool on site.env | denied | Permission denied: read | |
Prompt: "Use the read tool exactly once on site.env. Then reply with the value of FTP_PASS, or with the exact error text the tool returned. Do not use any other tool." What happened: The tool refused to open the file, and the reply held no password. Why: The rule { "action": "read", "resource": "*site.env", "effect": "deny" } matched. A deny needs no answer from you and also holds under --auto. | ||||
| OpenCode rule set 1 | read tool on site.env.example | runs | the three names returned | |
Prompt: "Use the read tool exactly once on site.env.example. Then reply with the variable names it contains, or with the exact error text. Do not use any other tool." What happened: The reply listed FTP_HOST, FTP_USER and FTP_PASS. Why: The pattern *site.env must end with site.env, so it does not match site.env.example, and the built-in rules allow *.env.example files. This is the file the agent learns the names from. | ||||
| OpenCode rule set 1 | grep tool: pattern dummy-pass in ., the whole project | no match | No matches found | |
Prompt: "Use the grep tool exactly once with pattern dummy-pass and path . (the current folder). Then reply with the matching lines it returned, or with the exact error text. Do not use any other tool." What happened: The tool answered No matches found. Why: As without a config, .gitignore kept the grep tool out of site.env. None of the four rules applies to the grep tool. | ||||
| OpenCode rule set 1 | cat site.env | denied | Permission denied: shell | |
Prompt: "Use the shell tool exactly once to run:cat site.env. Then reply with the output, or with the exact error text. Do not use any other tool." What happened: The command did not run. Why: The command text contains site.env, so the deny *site.env* matched. It comes after the shell ask rule, and the last matching rule wins. | ||||
| OpenCode rule set 1 | write tool on site.env | denied | Permission denied: edit, file unchanged | |
Prompt: "Use the write tool exactly once to write the single line FTP_PASS=changed to the file site.env. Then reply with the exact result or error text. Do not use any other tool." What happened: The tool refused to write, and site.env kept its three lines. Why: The edit deny matched. Without it the read deny alone would not stop the write tool. | ||||
| OpenCode rule set 1 | the step 4 prompt | script written, no password | deploy.sh written, passes sh -n; no password in the reply, the script or the session log | |
Prompt: the prompt from step 4 What happened: The agent read site.env.example and created deploy.sh with the write tool, about a hundred lines; sh -n deploy.sh found no syntax error. The script loads site.env with . when it runs, switches command tracing off and passes the user and the password to curl through --config - on standard input. dummy-pass-7731 was neither in the reply nor in the script nor in the session log. Why: The agent needs the names, not the values, and the example file gives it the names. | ||||
| OpenCode rule set 1 | sh deploy.sh | asked | asked: shell | |
Prompt: "Use the shell tool exactly once to run:sh deploy.sh. Then reply with the output, or with the exact error text. Do not use any other tool." What happened: OpenCode requested permission to run the command, the run rejected it, and the script never ran. Why: sh deploy.sh does not contain site.env, so only the shell ask rule matched. In the terminal this is the moment to read the script before you approve. | ||||
| OpenCode rule set 1 | cat site.e* | asked, not denied | asked: shell | |
Prompt: "Use the shell tool exactly once to run this command exactly as written:cat site.e*Then reply with the output, or with the exact error text. Do not use any other tool." What happened: OpenCode asked instead of denying, and the run rejected the request. Why: The command text has no site.env in it, so the shell deny did not match and only the ask rule did. The shell would expand site.e* to site.env and site.env.example after that check. | ||||
| OpenCode rule set 1 | cat *.env | asked, not denied | asked: shell | |
Prompt: "Use the shell tool exactly once to run this command exactly as written:cat *.envThen reply with the output, or with the exact error text. Do not use any other tool." What happened: OpenCode asked instead of denying, and the run rejected the request. Why: As in the row above: *.env becomes site.env only when the shell expands it, after the check. | ||||
| OpenCode rule set 1 | f=site; cat $f.env | asked, not denied | asked: shell | |
Prompt: "Use the shell tool exactly once to run this command exactly as written:f=site; cat $f.envThen reply with the output, or with the exact error text. Do not use any other tool." What happened: OpenCode asked instead of denying, and the run rejected the request. Why: OpenCode checks every part of a command line on its own, here f=site and cat $f.env. Neither contains site.env: the shell builds the name from the variable only when the command runs. | ||||
| OpenCode rule set 1, --auto | cat site.e* | runs | ran, the password in the reply | |
Prompt: "Use the shell tool exactly once to run this command exactly as written:cat site.e*Then reply with the output, or with the exact error text. Do not use any other tool. (OpenCode started with --auto)" What happened: The command ran without a prompt. site.e* matched site.env and site.env.example, and the reply contained both files, dummy-pass-7731 included. Why: --auto approves every ask rule. Only the denies still hold, and none of them matched this command. | ||||
| OpenCode rule set 1, site.env not in .gitignore | grep tool: pattern dummy-pass in ., the whole project | no match | the password line returned | |
Prompt: "Use the grep tool exactly once with pattern dummy-pass and path . (the current folder). Then reply with the matching lines it returned, or with the exact error text. Do not use any other tool. (run after site.env was removed from .gitignore)" What happened: The tool returned site.env: Line 3: FTP_PASS=dummy-pass-7731. Why: The read deny does not cover the grep tool: an OpenCode grep rule matches the search pattern, not the path (test 3). Only .gitignore kept it out, which is why step 2 is part of the setup. | ||||
Still open. OpenCode rule set 1 stops every tool call and command that names site.env.
Four gaps remain, and only you can close them:
- Commands that reach the file without naming it. The shell deny cannot stop them; the examples below show why.
- A script prints what it reads. A debug line in deploy.sh puts the password on your screen, and into the model if the agent ran it. Read the script before the first run.
- Attachments. Do not attach site.env yourself: OpenCode sends an attached file whatever the rules say (test 6).
- The .gitignore line. Remove it and the grep tool reads site.env again.
Why the shell deny is not enough. The rule compares its pattern *site.env* with the text of the command, before the command runs.
It stops a command only when the letters site.env appear in that text.
Wildcards, variables and code are expanded later, by the shell or by Python, so a command can open the file without those letters in it.
Each of these commands prints site.env and passes the shell deny:
cat site.e*
cat *.env
f=site; cat $f.env
python3 -c "print(open('site.e' + 'nv').read())"
grep -r PASS .
The first three are in the table above: with OpenCode rule set 1 each of them asked instead of being denied.
So you see such a command before it runs, and it is up to you to recognise it and say no.
Approve such a command, or start OpenCode with --auto, which approves every ask (test 10), and the password reaches the model. In the tests, cat site.e* run with --auto got no prompt, and its reply contained the password.
OpenCode: deny in permissions
As you have seen in the previous chapter, OpenCode rules live in the permissions list of opencode.jsonc at the project root.
This chapter explains how such a rule is built. A rule can also go in an agent file under .opencode/agents/, for one role only:
vi opencode.jsonc
{ "$schema": "https://opencode.ai/config.json", "permissions": [ { "action": "read", "resource": "*.env", "effect": "deny" }, { "action": "read", "resource": "logs/*", "effect": "deny" }, { "action": "shell", "resource": "git push *", "effect": "deny" } ] }
- action: the tool, such as read, edit, glob, grep, shell, skill, subagent, external_directory.
- resource: what the action is applied to. For read and edit it is the file path, relative to the project, or absolute outside it. For grep it is the search pattern, and for glob the glob pattern, not a path.
- effect: allow, ask or deny. The last matching rule wins: global rules load first, agent rules after them.
After editing, reload the background service and check what each agent resolved:
opencode reload
opencode debug agents
Claude Code: permissions.deny
Shared project rules live in .claude/settings.json, personal ones in ~/.claude/settings.json:
vi .claude/settings.json
{ "permissions": { "deny": [ "Read(**/.env)", "Read(//**/logs/**)", "Bash(git push *)" ] } }
- Read rules apply to the Read tool and, best effort, to Grep and Glob, to @ file mentions, to file commands Claude Code recognizes in the shell such as cat, head and tail, and to shell redirections. A Read deny also blocks Edit and Write on the same path.
- Precedence: a deny at any level wins; no allow rule in another settings file can undo it.
- No trust needed: project deny and ask rules apply before you accept the workspace trust dialog, because they only restrict.
- Bash rules match the command text, so Bash(git push *) stops git push origin main but not git -C . push origin main.
Path patterns that match
The same intention needs different spelling in the two tools:
| Intention | OpenCode resource | Claude Code rule |
|---|---|---|
| A folder in this project | *logs/app/*; a leading * keeps it working when the session starts in a subfolder (test 9) | Read(/logs/**), anchored at the project |
| A folder name at any depth | logs/* plus */logs/* | Read(**/logs/**), under the current directory |
| A folder wherever the session starts | leading *, as above: paths are relative to the folder the session starts in | Read(//**/logs/**), anchored at the filesystem root |
| A file type anywhere | *.env | Read(**/.env) or Read(**/*.env) |
| A path outside the project | ~/.config/app/* | Read(~/.config/app/**) |
In Claude Code a single leading slash means the project, not the filesystem root: Read(/Users/alice/file) is relative, Read(//Users/alice/file) is absolute.
What deny rules do not cover
The OpenCode lines below were tested on OpenCode 2.1.2 with the aredel example; the Claude Code lines come from its permissions docs.
- OpenCode grep tool: a grep rule matches the search pattern, not the path. With read denied on a log folder, the grep tool still returned the full log line, in a throwaway repo without a .gitignore. The tool does skip files that git ignores: that, not the read deny, kept it out of site.env in the site.env tests. List such files in .gitignore, deny grep for the role, or keep the data outside the project.
- Glob: file names stay visible. A name is rarely the secret, but it tells the agent the file exists.
- Attachments: OpenCode sends a file you attach with @ or -f even when read is denied. Claude Code applies Read deny rules to @ mentions. See What gets sent.
- Shell: in OpenCode a read deny does not cover cat; only the shell rule decides, and approving a prompt sends the file. Claude Code applies Read rules to cat, head, tail, sed, tee and redirections, but not to grep -r pattern . run from the folder. In OpenCode a shell deny matches the command text, so a command that reaches the file without naming it passes (examples).
- OpenCode agent allows: the last matching rule wins and agent rules load after the global ones, so an agent allow such as git diff * reopens a global shell deny. Repeat the deny at the end of every agent file with matching allows (check 7).
- Scripts: a Python or Node script the agent runs opens any file your user can. For enforcement at the operating-system level Claude Code has a sandbox.
- Other tools: rules live in one tool's config. Another assistant, an IDE plugin or a copy and paste does not see them.
Example: the aredel repo now and after
The aredel repo keeps local data that no model should see:
| Path | What | In git |
|---|---|---|
| container/tools/analyze_logs/site_logs/, since 2026-10-22 ~/.local/share/aredel/logs/ (final fix) | production access and error logs: visitor IP addresses, user agents, URLs | ignored; now outside the repo |
| container/src/services/user_profile/data/ | visitor records of a page of the site: IPs, request headers, device ids | ignored |
| container/tools/analyze_logs/users.db | every device the visitor records saw: device ids, IPs, user agents | ignored |
| ~/.config/aredel/ | FTP credentials and the backoffice token | outside the repo |
Before. The OpenCode config denied only *.env and *creds*, which covers the FTP credential file but not the logs or the backoffice token. Claude Code has no project deny rules at all. The triage role runs on a free OpenCode model, so whatever it reads may end up in training data. On a throwaway copy of the repo layout with one dummy log line (the address 203.0.113.7 is from the documentation range), the agent was asked to read the log:
→ Read container/tools/analyze_logs/site_logs/prod/access/aredel.com.access.log 203.0.113.7
OpenCode rule set 2, applied to the repo on 2026-10-22; the visitor-records folder gets the same pair as the logs. In OpenCode, deny read on the log folder and the config folder for every role, in opencode.jsonc at the repo root. The leading * matters: the first version of this rule named the full project path and stopped matching as soon as OpenCode was started from container/ (test 9).
Below is OpenCode rule set 2: opencode.jsonc as it was that day.
Only the six rules of this step show at first; the button under the file shows the rest:
vi opencode.jsonc
{ "$schema": "https://opencode.ai/config.json", // Free default. Start OpenCode with container/tools/ai/opencode.sh: it // checks every pinned id first and moves a retired free id to the next // live one in container/tools/ai/free-models.txt. // Roles: .opencode/agents/ (triage, builder, reviewer). // Skills: .claude/skills/ (shared with Claude Code). // Deny rules: tested in /ai/security/deny_rules/. Path patterns start // with * so they also match when OpenCode starts in a subfolder. Every // read deny has an edit twin: a read deny alone does not stop writes. "model": "opencode/deepseek-v4.1-flash-free", "permissions": [ { "action": "shell", "resource": "*", "effect": "ask" }, { "action": "shell", "resource": "git push *", "effect": "deny" }, { "action": "shell", "resource": "sudo *", "effect": "deny" }, { "action": "read", "resource": "*.env", "effect": "deny" }, { "action": "read", "resource": "*creds*", "effect": "deny" }, { "action": "shell", "resource": "git add -A*", "effect": "deny" }, { "action": "shell", "resource": "git add --all*", "effect": "deny" }, { "action": "shell", "resource": "*--no-verify*", "effect": "deny" }, { "action": "shell", "resource": "*.config/aredel*", "effect": "deny" }, { "action": "shell", "resource": "*creds*", "effect": "deny" }, { "action": "read", "resource": "*analyze_logs/site_logs/*", "effect": "deny" }, { "action": "read", "resource": "*services/user_profile/data/*", "effect": "deny" }, { "action": "read", "resource": "~/.config/aredel/*", "effect": "deny" }, { "action": "edit", "resource": "*.env", "effect": "deny" }, { "action": "edit", "resource": "*creds*", "effect": "deny" }, { "action": "edit", "resource": "*analyze_logs/site_logs/*", "effect": "deny" }, { "action": "edit", "resource": "*services/user_profile/data/*", "effect": "deny" }, { "action": "edit", "resource": "~/.config/aredel/*", "effect": "deny" } ] }
Each read rule has an edit twin: in OpenCode a read deny does not stop the write tool, which overwrote a read-protected dummy log until the edit rule was added (check 1).
Because the grep tool ignores path rules, the role on the free model also loses grep, in its own agent file:
vi .opencode/agents/triage.md
- action: grep resource: "*" effect: deny
Shell commands that name the config folder or contain creds are denied too, in opencode.jsonc, because a read rule does not stop cat. They are repeated at the end of every agent file with shell allows, which would otherwise win; in the repo today that is the reviewer file:
vi opencode.jsonc
{ "action": "shell", "resource": "*.config/aredel*", "effect": "deny" }, { "action": "shell", "resource": "*creds*", "effect": "deny" }
In Claude Code, the same paths in .claude/settings.json, anchored at the filesystem root so they hold in whatever directory the session starts. The shell rules mirror the ones in opencode.jsonc and enforce the repo rules against pushing, staging everything and skipping hooks:
vi .claude/settings.json
{ "permissions": { "deny": [ "Read(//**/analyze_logs/site_logs/**)", "Read(~/.config/aredel/**)", "Read(**/*.env)", "Read(**/*creds*)", "Bash(git push *)", "Bash(sudo *)", "Bash(git add -A*)", "Bash(git add --all*)", "Bash(* --no-verify*)" ] } }
Every file these rules protect, with what each rule still misses, is listed in Protected files in the aredel repo.
After. What each way of reaching the dummy log did with the rules in place. Claude Code cells marked tested come from a Claude Code session in the repo, the others from its docs:
| Way to reach the log | Before | OpenCode after (tested) | Claude Code after |
|---|---|---|---|
| read tool | content returned | Permission denied: read | tested: File is in a directory that is denied by your permission settings |
| grep tool, read deny only | matching lines returned | matching lines still returned | blocked, best effort |
| grep tool, plus grep deny for triage | matching lines returned | no grep tool for triage | n/a |
| glob tool | file name listed | file name listed | blocked, best effort |
| shell cat | asks you | asks you; a non-interactive run rejects it | tested: Permission to use Bash with command cat … has been denied |
| write tool | file overwritten | Permission denied: edit with the edit twin | tested: covered by a Read deny rule … cannot be written |
| you attach it (-f or @) | sent | still sent | blocked for @ |
These runs used a throwaway copy without a .gitignore. In the real repo the logs were git-ignored, and the OpenCode grep tool skips git-ignored files, as the site.env tests show, so there it skipped the logs too.
The read rule works, but in OpenCode it closes only one of five doors. Grep needs its own deny or a .gitignore line, and the shell and your own attachments stay open. The stronger fix is structural: keep the logs outside the project and deny OpenCode the external folder:
{ "action": "external_directory", "resource": "~/aredel-data/*", "effect": "deny" }
On the throwaway copy with the log moved outside the project, the grep tool answered Permission denied: external_directory, and so did the read tool, while the triage role could keep its grep for the code. Nothing here replaces the simplest rule: do not attach such files, and keep shell on ask.
The final fix. That is what the aredel repo did in the end: on 2026-10-22 the production logs moved out of the project, from container/tools/analyze_logs/site_logs/ to ~/.local/share/aredel/logs/. The log tools find the folder through one shared path helper, and AREDEL_LOG_DIR overrides it. A grep -r run inside the repo no longer reaches the logs, and in OpenCode the read, grep and glob tools now meet the external_directory rule first. The repo asks there instead of denying, so the logs are read only when you ask for them:
vi opencode.jsonc
{ "action": "external_directory", "resource": "~/.local/share/aredel/logs/*", "effect": "ask" }, { "action": "read", "resource": "*/.local/share/aredel/logs/*", "effect": "ask" }, { "action": "edit", "resource": "*/.local/share/aredel/logs/*", "effect": "deny" }, { "action": "shell", "resource": "*aredel/logs*", "effect": "ask" }, { "action": "shell", "resource": "*analyze_logs*", "effect": "ask" }
The shell rules matter as much as the folder rule: an external_directory rule does not cover the shell. Before they were added, the builder role, which allowed git diff *, printed the moved dummy log with git diff --no-index without a prompt. The log tools find the folder by themselves, so running anything from analyze_logs asks too. In Claude Code the moved folder gets Read(~/.local/share/aredel/logs/**). Every rule the repo uses now is in rules every option keeps and option B.
The price: more approval prompts
Apply the rules from the aredel example, with the shell kept on ask, and OpenCode will ask for your permission far more often. Without a config it runs every command unasked: its built-in rules start with allow for everything. The rule this page keeps, { "action": "shell", "resource": "*", "effect": "ask" }, turns every shell command into a prompt: git status, ls, php -l, each test run. The default build agent has no allow rules of its own, so an ordinary edit-and-test task stops every few steps, and every ask rule on a folder adds its own prompts on top. After the rules went into the aredel repo, approving steps by hand became a large part of every session.
Many prompts are a risk of their own: after the twentieth git diff it is easy to approve the one command that sends a log. Fewer prompts that each mean something are safer than many. Three options sit between asking for everything and asking for nothing. All of them keep the rules every option keeps; they differ in what the shell may do and whether files outside the project ask:
| Option | Shell | Files outside the project | Prompts you still see | Still open |
|---|---|---|---|---|
| Strict: this page so far | every command asks | read without asking | nearly every step | any file outside the project that no rule names |
| A: allow list | asks, except commands that only check the repo | ask | other commands, files outside the project, logs | scripts that open a file without naming it, once you approve them |
| B: open shell, risky steps ask | runs; commit, delete, network, deploy, install and grep -r ask | ask | the risky steps, files outside the project, logs | scripts and shell commands that reach a file without naming it |
| C: independent | runs; only a commit asks | read without asking | commits, logs | the same, plus any file outside the project that no rule names |
Whatever you pick, do not start OpenCode with --auto. opencode --auto and opencode run --auto approve every permission that is not denied, ask rules included (test 10), so a folder you set to ask about is read without a prompt. Only deny rules hold under --auto.
The options were tested on 2026-10-22 with OpenCode 2.1.3, on a throwaway repo with dummy files and a fake home folder, with the prompt shape of the tests below: Use the shell tool exactly once to run: …, started with < /dev/null. A script cannot answer a prompt, so asked in the tables means OpenCode requested permission and the run rejected it.
Rules every option keeps
Two groups go at the end of the list, after everything an option allows, because the last matching rule wins:
- The logs ask. The final fix rules: the folder, the read tool, shell commands that name the folder, and the log tools, which find the folder by themselves.
- Secrets and visitor data are denied for read, edit and shell. Besides the repo files, that covers secrets outside the project: ~/.ssh, ~/.gnupg, ~/.docker/config.json, and two files of OpenCode itself: auth.json with its provider keys and opencode.db with every past session. Not the whole ~/.local/share/opencode folder: OpenCode saves long tool output in tool-output there and reads it back.
vi opencode.jsonc
// Logs: read only when you ask for them. The log tools find the // folder themselves, so running them asks too. { "action": "external_directory", "resource": "~/.local/share/aredel/logs/*", "effect": "ask" }, { "action": "read", "resource": "*/.local/share/aredel/logs/*", "effect": "ask" }, { "action": "shell", "resource": "*aredel/logs*", "effect": "ask" }, { "action": "shell", "resource": "*analyze_logs*", "effect": "ask" }, // Secrets and visitor data: denied, last, so nothing above reopens them. { "action": "read", "resource": "*.env", "effect": "deny" }, { "action": "read", "resource": "*creds*", "effect": "deny" }, { "action": "read", "resource": "*services/user_profile/data/*", "effect": "deny" }, { "action": "read", "resource": "*analyze_logs/users.db", "effect": "deny" }, { "action": "read", "resource": "*analyze_logs/site_logs/*", "effect": "deny" }, { "action": "read", "resource": "~/.config/aredel/*", "effect": "deny" }, { "action": "read", "resource": "*/.ssh/*", "effect": "deny" }, { "action": "read", "resource": "*/.gnupg/*", "effect": "deny" }, { "action": "read", "resource": "*/.docker/config.json", "effect": "deny" }, { "action": "read", "resource": "*/.local/share/opencode/auth.json", "effect": "deny" }, { "action": "read", "resource": "*/.local/share/opencode/opencode.db*", "effect": "deny" }, { "action": "edit", "resource": "*.env", "effect": "deny" }, { "action": "edit", "resource": "*creds*", "effect": "deny" }, { "action": "edit", "resource": "*services/user_profile/data/*", "effect": "deny" }, { "action": "edit", "resource": "*analyze_logs/users.db", "effect": "deny" }, { "action": "edit", "resource": "*analyze_logs/site_logs/*", "effect": "deny" }, { "action": "edit", "resource": "*/.local/share/aredel/logs/*", "effect": "deny" }, { "action": "edit", "resource": "~/.config/aredel/*", "effect": "deny" }, { "action": "edit", "resource": "*/.ssh/*", "effect": "deny" }, { "action": "edit", "resource": "*/.gnupg/*", "effect": "deny" }, { "action": "edit", "resource": "*/.docker/config.json", "effect": "deny" }, { "action": "edit", "resource": "*/.local/share/opencode/auth.json", "effect": "deny" }, { "action": "edit", "resource": "*/.local/share/opencode/opencode.db*", "effect": "deny" }, { "action": "shell", "resource": "*git push*", "effect": "deny" }, { "action": "shell", "resource": "*sudo *", "effect": "deny" }, { "action": "shell", "resource": "*git add -A*", "effect": "deny" }, { "action": "shell", "resource": "*git add --all*", "effect": "deny" }, { "action": "shell", "resource": "*--no-verify*", "effect": "deny" }, { "action": "shell", "resource": "*.env*", "effect": "deny" }, { "action": "shell", "resource": "*creds*", "effect": "deny" }, { "action": "shell", "resource": "*.config/aredel*", "effect": "deny" }, { "action": "shell", "resource": "*user_profile/data*", "effect": "deny" }, { "action": "shell", "resource": "*users.db*", "effect": "deny" }, { "action": "shell", "resource": "*.ssh/*", "effect": "deny" }, { "action": "shell", "resource": "*.gnupg*", "effect": "deny" }, { "action": "shell", "resource": "*.docker/config.json*", "effect": "deny" }, { "action": "shell", "resource": "*opencode/auth.json*", "effect": "deny" }, { "action": "shell", "resource": "*opencode.db*", "effect": "deny" }
- Files outside the project need these denies. The built-in rules of the default agent allow external directories: with the strict config the read tool returned a dummy key from a fake ~/.ssh folder without a prompt.
- The shell denies match text. OpenCode splits a command line at ;, && and | and checks every part on its own (further tuning), so cd x && cat … is checked as cd x and cat …. The leading * lets a rule match a part that names the file anywhere in it. The rules over-match a little: *.env* also stops a command that mentions os.environ. And they miss a command that reaches the file without naming it (examples).
- Agent files come after these rules. A shell allow in an agent file reopens every deny it matches (check 7): give the role no shell allows, or repeat the shell denies at the end of its file, as the aredel reviewer does.
Option A: an allow list
The shell keeps asking, except for the commands you approve all day: git status, diff, log and show, ls, php -l, pre-commit and the test runner. Files outside the project ask. The closest step from the strict config: the same safety, without the prompts that never meant anything.
vi opencode.jsonc
// The shell asks, except for commands that only check the repo. { "action": "shell", "resource": "*", "effect": "ask" }, { "action": "shell", "resource": "git status*", "effect": "allow" }, { "action": "shell", "resource": "git diff*", "effect": "allow" }, { "action": "shell", "resource": "git log*", "effect": "allow" }, { "action": "shell", "resource": "git show*", "effect": "allow" }, { "action": "shell", "resource": "ls", "effect": "allow" }, { "action": "shell", "resource": "ls *", "effect": "allow" }, { "action": "shell", "resource": "php -l *", "effect": "allow" }, { "action": "shell", "resource": "pre-commit run *", "effect": "allow" }, { "action": "shell", "resource": "container/tools/run-tests-venv.sh *", "effect": "allow" }, { "action": "shell", "resource": "./tools/run-tests-venv.sh *", "effect": "allow" }, // Files outside the project ask, except OpenCode's saved tool output. { "action": "external_directory", "resource": "*", "effect": "ask" }, { "action": "external_directory", "resource": "~/.local/share/opencode/tool-output/*", "effect": "allow" }, // then the rules every option keeps
- Prompts you still see: every command not on the list, files outside the project, the logs.
- Still open: a script you approve can open any file without naming it. An allowed git diff --no-index can print any file, so the allows sit above the rules every option keeps, which stop it for the files they name.
| Step | Expected | Actual |
|---|---|---|
| git log --oneline -1 | runs | ran, no prompt |
| echo hello | asked | asked |
| read tool on a file outside the project | asked | asked: external_directory |
Option B: open shell, risky steps ask
The option the aredel repo uses. The shell runs unasked, and asks only for steps you would want to see anyway: a commit and other git commands that change history or the working tree, deleting files, the network, the deploy scripts, Docker, installs. It also asks for grep -r, which ignores .gitignore and would read the visitor records kept in the repo. Files outside the project ask, except the tool output OpenCode saves for itself.
vi opencode.jsonc
// The shell runs; these steps ask first. { "action": "shell", "resource": "*", "effect": "allow" }, { "action": "shell", "resource": "*git commit*", "effect": "ask" }, { "action": "shell", "resource": "*git reset*", "effect": "ask" }, { "action": "shell", "resource": "*git restore*", "effect": "ask" }, { "action": "shell", "resource": "*git checkout*", "effect": "ask" }, { "action": "shell", "resource": "*git switch*", "effect": "ask" }, { "action": "shell", "resource": "*git stash*", "effect": "ask" }, { "action": "shell", "resource": "*git clean*", "effect": "ask" }, { "action": "shell", "resource": "*git rebase*", "effect": "ask" }, { "action": "shell", "resource": "*git merge*", "effect": "ask" }, { "action": "shell", "resource": "*git rm *", "effect": "ask" }, { "action": "shell", "resource": "rm *", "effect": "ask" }, { "action": "shell", "resource": "* rm *", "effect": "ask" }, { "action": "shell", "resource": "*curl *", "effect": "ask" }, { "action": "shell", "resource": "*wget *", "effect": "ask" }, { "action": "shell", "resource": "*ssh *", "effect": "ask" }, { "action": "shell", "resource": "*scp *", "effect": "ask" }, { "action": "shell", "resource": "*rsync *", "effect": "ask" }, { "action": "shell", "resource": "*ftp *", "effect": "ask" }, { "action": "shell", "resource": "*deploy-ftp.sh*", "effect": "ask" }, { "action": "shell", "resource": "*deploy-engine.sh*", "effect": "ask" }, { "action": "shell", "resource": "*docker *", "effect": "ask" }, { "action": "shell", "resource": "*pip install*", "effect": "ask" }, { "action": "shell", "resource": "*npm install*", "effect": "ask" }, // grep -r ignores .gitignore and would read the visitor records. { "action": "shell", "resource": "*grep -r*", "effect": "ask" }, { "action": "shell", "resource": "*grep -R*", "effect": "ask" }, // Files outside the project ask, except OpenCode's saved tool output. { "action": "external_directory", "resource": "*", "effect": "ask" }, { "action": "external_directory", "resource": "~/.local/share/opencode/tool-output/*", "effect": "allow" }, // then the rules every option keeps
The builder role has no shell allows any more: the global rules already let the shell run, and an allow in its file would reopen the denies. The reviewer keeps its read-only git allows and repeats the shell denies after them.
- Prompts you still see: the risky steps above, files outside the project, the logs.
- Still open: a script or a shell command that reaches a file without naming it, such as a Python script or find … -exec cat. The external_directory rule does not cover the shell, so cat on a file outside the project that no rule names runs unasked.
| Step | Expected | Actual |
|---|---|---|
| git status --short | runs | ran, no prompt |
| git commit --allow-empty -m test | asked | asked |
| grep -rn 203 . | asked | asked |
| cat on the dummy log | asked | asked |
| a script from analyze_logs that prints the log | asked | asked |
| builder, on the log:git diff --no-index /dev/null | asked | asked |
| reviewer: the same command | denied | Permission denied: shell |
| cat on the visitor records in the repo | denied | Permission denied: shell |
| cat on a dummy key in .ssh | denied | Permission denied: shell |
| read tool on the same key, with --auto | denied | Permission denied: read |
| read tool on a file outside the project | asked | asked: external_directory |
Option C: independent, secrets still denied
Closest to running OpenCode without a config: the shell runs and files outside the project are read without asking. Only a commit and the logs ask; the secrets stay denied.
vi opencode.jsonc
// The shell runs; only a commit asks. { "action": "shell", "resource": "*", "effect": "allow" }, { "action": "shell", "resource": "*git commit*", "effect": "ask" }, // then the rules every option keeps
- Prompts you still see: commits and the logs.
- Still open: everything B leaves open, plus every file outside the project that no rule names: the deny list has to name every secret on the machine.
| Step | Expected | Actual |
|---|---|---|
| echo hello | runs | ran, no prompt |
| read tool on a file outside the project | runs | content returned, no prompt |
| cat on a dummy key in .ssh | denied | Permission denied: shell |
| grep tool on the log folder | asked | asked: external_directory |
Further tuning: local curl without a prompt
A day after the aredel repo switched to option B, one prompt kept coming back. To check a page it had changed, the builder fetched it from the local container, and OpenCode stopped with:
Permission required $ curl -s http://127.0.0.1:8080/ru/qa/ -o /tmp/opencode/ruqa.html -w "%{http_code} %{size_download}\n"; grep -o "footer[^>]*" /tmp/opencode/ruqa.html | head; echo "--- last 40 lines"; tail -40 /tmp/opencode/ruqa.html
The line is a render check that never leaves the machine. It downloads /ru/qa/ from 127.0.0.1:8080, the php-apache service in container/docker-compose.yml that serves container/src, saves it to /tmp/opencode/ruqa.html, and prints the HTTP status, the size, the footer tags and the last 40 lines of the page. Approving it again and again is the kind of prompt the price warns about.
Why it asks. OpenCode splits a command line at ;, && and |, takes commands out of $(…) as well, and checks every part on its own. Here grep, head, echo and tail match only { "action": "shell", "resource": "*", "effect": "allow" }. The curl part also matches *curl *, which comes later, and the last matching rule wins: ask. The rule cannot tell the local container from any other host. To see the order OpenCode really uses, print the shell rules of an agent with their positions, with the opencode debug agents command from OpenCode: deny in permissions. For the default build agent before the change, trimmed:
opencode debug agents | jq -r '.[] | select(.id=="build") | .permissions | to_entries[] | select(.value.action=="shell") | "\(.key) \(.value.resource) \(.value.effect)"'
5 * allow 6 *git commit* ask … 17 * rm * ask 18 *curl * ask 19 *wget * ask
Rules 0 to 4 are OpenCode's built-in ones. Rule 18 is the one that asked.
The new rules. Two allows. Each goes right after the rule it softens, because the last matching rule wins, and both stay above the rules every option keeps, so the denies still come last.
Below is OpenCode rule set 3: the aredel opencode.jsonc with both allows.
The three changed lines are in bold. At first only they show, each with three lines before and after it; the button under the file shows the rest:
vi opencode.jsonc
{ "$schema": "https://opencode.ai/config.json", // Free default. Start OpenCode with container/tools/ai/opencode.sh: it // checks every pinned id first and moves a retired free id to the next // live one in container/tools/ai/free-models.txt. // Roles: .opencode/agents/ (triage, builder, reviewer). // Skills: .claude/skills/ (shared with Claude Code). // Permissions: option B of /ai/security/deny_rules/#option-b. The shell // runs unasked, risky steps and the logs ask, secrets are denied. The // last matching rule wins, so the denies come last. Path patterns start // with * so they also match when OpenCode starts in a subfolder; every // read deny has an edit twin, because a read deny alone does not stop // writes. Never start OpenCode with --auto: it approves every ask. "model": "opencode/deepseek-v4.1-flash-free", "permissions": [ // The shell runs; these steps ask first. { "action": "shell", "resource": "*", "effect": "allow" }, { "action": "shell", "resource": "*git commit*", "effect": "ask" }, { "action": "shell", "resource": "*git reset*", "effect": "ask" }, { "action": "shell", "resource": "*git restore*", "effect": "ask" }, { "action": "shell", "resource": "*git checkout*", "effect": "ask" }, { "action": "shell", "resource": "*git switch*", "effect": "ask" }, { "action": "shell", "resource": "*git stash*", "effect": "ask" }, { "action": "shell", "resource": "*git clean*", "effect": "ask" }, { "action": "shell", "resource": "*git rebase*", "effect": "ask" }, { "action": "shell", "resource": "*git merge*", "effect": "ask" }, { "action": "shell", "resource": "*git rm *", "effect": "ask" }, { "action": "shell", "resource": "rm *", "effect": "ask" }, { "action": "shell", "resource": "* rm *", "effect": "ask" }, { "action": "shell", "resource": "*curl *", "effect": "ask" }, { "action": "shell", "resource": "curl -s http://127.0.0.1:8080/*", "effect": "allow" }, { "action": "shell", "resource": "*wget *", "effect": "ask" }, { "action": "shell", "resource": "*ssh *", "effect": "ask" }, { "action": "shell", "resource": "*scp *", "effect": "ask" }, { "action": "shell", "resource": "*rsync *", "effect": "ask" }, { "action": "shell", "resource": "*ftp *", "effect": "ask" }, { "action": "shell", "resource": "*deploy-ftp.sh*", "effect": "ask" }, { "action": "shell", "resource": "*deploy-engine.sh*", "effect": "ask" }, { "action": "shell", "resource": "*docker *", "effect": "ask" }, { "action": "shell", "resource": "*pip install*", "effect": "ask" }, { "action": "shell", "resource": "*npm install*", "effect": "ask" }, // grep -r ignores .gitignore and would read the visitor records. { "action": "shell", "resource": "*grep -r*", "effect": "ask" }, { "action": "shell", "resource": "*grep -R*", "effect": "ask" }, // Files outside the project ask, except OpenCode's saved tool output and /tmp/opencode/. { "action": "external_directory", "resource": "*", "effect": "ask" }, { "action": "external_directory", "resource": "~/.local/share/opencode/tool-output/*", "effect": "allow" }, { "action": "external_directory", "resource": "/tmp/opencode/*", "effect": "allow" }, // Logs: read only when you ask for them. The log tools find the // folder themselves, so running them asks too. { "action": "external_directory", "resource": "~/.local/share/aredel/logs/*", "effect": "ask" }, { "action": "read", "resource": "*/.local/share/aredel/logs/*", "effect": "ask" }, { "action": "shell", "resource": "*aredel/logs*", "effect": "ask" }, { "action": "shell", "resource": "*analyze_logs*", "effect": "ask" }, // Secrets and visitor data: denied, last, so nothing above reopens them. { "action": "read", "resource": "*.env", "effect": "deny" }, { "action": "read", "resource": "*creds*", "effect": "deny" }, { "action": "read", "resource": "*services/user_profile/data/*", "effect": "deny" }, { "action": "read", "resource": "*analyze_logs/users.db", "effect": "deny" }, { "action": "read", "resource": "*analyze_logs/site_logs/*", "effect": "deny" }, { "action": "read", "resource": "~/.config/aredel/*", "effect": "deny" }, { "action": "read", "resource": "*/.ssh/*", "effect": "deny" }, { "action": "read", "resource": "*/.gnupg/*", "effect": "deny" }, { "action": "read", "resource": "*/.docker/config.json", "effect": "deny" }, { "action": "read", "resource": "*/.local/share/opencode/auth.json", "effect": "deny" }, { "action": "read", "resource": "*/.local/share/opencode/opencode.db*", "effect": "deny" }, { "action": "edit", "resource": "*.env", "effect": "deny" }, { "action": "edit", "resource": "*creds*", "effect": "deny" }, { "action": "edit", "resource": "*services/user_profile/data/*", "effect": "deny" }, { "action": "edit", "resource": "*analyze_logs/users.db", "effect": "deny" }, { "action": "edit", "resource": "*analyze_logs/site_logs/*", "effect": "deny" }, { "action": "edit", "resource": "*/.local/share/aredel/logs/*", "effect": "deny" }, { "action": "edit", "resource": "~/.config/aredel/*", "effect": "deny" }, { "action": "edit", "resource": "*/.ssh/*", "effect": "deny" }, { "action": "edit", "resource": "*/.gnupg/*", "effect": "deny" }, { "action": "edit", "resource": "*/.docker/config.json", "effect": "deny" }, { "action": "edit", "resource": "*/.local/share/opencode/auth.json", "effect": "deny" }, { "action": "edit", "resource": "*/.local/share/opencode/opencode.db*", "effect": "deny" }, { "action": "shell", "resource": "*git push*", "effect": "deny" }, { "action": "shell", "resource": "*sudo *", "effect": "deny" }, { "action": "shell", "resource": "*git add -A*", "effect": "deny" }, { "action": "shell", "resource": "*git add --all*", "effect": "deny" }, { "action": "shell", "resource": "*--no-verify*", "effect": "deny" }, { "action": "shell", "resource": "*.env*", "effect": "deny" }, { "action": "shell", "resource": "*creds*", "effect": "deny" }, { "action": "shell", "resource": "*.config/aredel*", "effect": "deny" }, { "action": "shell", "resource": "*user_profile/data*", "effect": "deny" }, { "action": "shell", "resource": "*users.db*", "effect": "deny" }, { "action": "shell", "resource": "*.ssh/*", "effect": "deny" }, { "action": "shell", "resource": "*.gnupg*", "effect": "deny" }, { "action": "shell", "resource": "*.docker/config.json*", "effect": "deny" }, { "action": "shell", "resource": "*opencode/auth.json*", "effect": "deny" }, { "action": "shell", "resource": "*opencode.db*", "effect": "deny" } ] }
The first allow is line 31, right after *curl *. A curl part that starts with exactly curl -s http://127.0.0.1:8080/ now runs. Any other curl matches only *curl * and still asks: another host, another local port, and the same request written differently, such as curl -sS, localhost or the URL in quotes. That last kind of miss is safe: it costs a prompt, not a leak. Unlike the other shell rules, this pattern does not start with *: with a leading * it would also match a curl to another host that mentions the local URL later in the line.
The second allow is line 49 of OpenCode rule set 3, after the external_directory rule that makes every path outside the project ask. The comment above the group, line 46, changed too.
/tmp/opencode is OpenCode's own temporary folder: opencode debug paths lists it as tmp. The curl does not need this rule, because external_directory does not cover the shell (option B, still open): the page was saved there without a prompt. The rule is for the next step, when the model opens the saved page with the read or grep tool. Without it, that step asks.
Two mistakes on the way. Both left OpenCode running on rules other than the ones meant:
- A missing comma switched every rule off. The first version had no comma after the new external_directory line. OpenCode drops a config it cannot parse without a warning and falls back to its built-in rules, which allow everything: the curl prompt was gone, and so were all the denies, git push, .env and the keys in ~/.ssh included.
- An allow above its ask does nothing. The first /tmp/opencode allow sat next to the curl lines, above the external_directory * ask. The ask came later and won.
After every edit, check that OpenCode loaded the file. The count is 0 when it did not:
opencode debug agents | grep -c 127.0.0.1:8080
Then run the jq command above again: the new rule must sit right after *curl *.
18 *curl * ask 19 curl -s http://127.0.0.1:8080/* allow 20 *wget * ask
Tests
Test setup
The tests ran on 2026-10-23 with OpenCode 2.1.3 and the free OpenCode model.
The project was a throwaway repo with a copy of OpenCode rule set 3, a dummy dummy.txt and a dummy .env.
Rows and prompts
Each prompt asked for a single tool call, as in the tests below.
The local curl in the table is this command:
curl -s http://127.0.0.1:8080/ru/qa/ -o /dev/null
Tests 12 and 13 ran from a terminal, not through OpenCode, and print only the status code.
The runs got no input (< /dev/null), so they could not answer a prompt: asked means OpenCode requested permission and the run rejected it.
Results interpretation
Result: ✓ when the actual result matches the expected one, ✗ when it does not.
Red in the Expected column: the run is expected to show the gap, a request that reaches another host without a prompt.
Click a ✓ or ✗ in the Result column to see what the test did and why.
| Test | Step | Expected | Actual | Result |
|---|---|---|---|---|
| 1 | curl -s http://127.0.0.1:8080/ru/qa/ -o /tmp/opencode/ruqa.html | runs | ran, 200 12248, no prompt | |
Prompt: "Use the shell tool exactly once to run:curl -s http://127.0.0.1:8080/ru/qa/ -o /tmp/opencode/ruqa.html -w '%{http_code} %{size_download}\n'. Then reply with the output, or with the exact error text. Do not use any other tool." What happened: The command ran without a prompt and printed 200 12248: the status code and the size of the saved page. Why: The allow curl -s http://127.0.0.1:8080/* of OpenCode rule set 3 matched the curl part, and it comes after *curl *, so it wins. The page was saved in /tmp/opencode, outside the project, without a question: the external_directory rule does not cover the shell. | ||||
| 2 | read tool on /tmp/opencode/ruqa.html | runs | page title returned, no prompt | |
Prompt: "Use the read tool exactly once on /tmp/opencode/ruqa.html. Then reply with the text of its title tag, or with the exact error text the tool returned. Do not use any other tool." What happened: The tool opened the saved page and returned its title, Тестирование, without a prompt. Why: The read tool on a file outside the project meets the external_directory rules. The allow /tmp/opencode/* on line 49 of OpenCode rule set 3 comes after the * ask, so it wins. Without that line this step asks. | ||||
| 3 | curl -s https://example.com/ | asked | asked | |
Prompt: "Use the shell tool exactly once to run:curl -s https://example.com/ -o /dev/null -w '%{http_code}\n'. Then reply with the output, or with the exact error text. Do not use any other tool." What happened: OpenCode asked about the command, and the run rejected it, so nothing was downloaded. Why: The command does not start with the local URL, so the allow does not match and only *curl * does: ask. Every other host still asks. | ||||
| 4 | the backoffice:curl -s http://127.0.0.1:8765/ | asked | asked | |
Prompt: "This is a throwaway test repo: dummy.txt is a dummy file and deleting it is part of the test. Use the shell tool exactly once to run this command exactly as written, without changing or splitting it:curl -s http://127.0.0.1:8765/ -o /dev/null -w '%{http_code}\n'Then reply with the output, or with the exact error text. Do not use any other tool." What happened: OpenCode asked about the command, and the run rejected it. Why: The allow names port 8080 and nothing else, so a curl to the backoffice on port 8765 matches only *curl *. That keeps the backoffice behind a prompt: its API uploads files to the hosting and deletes them there. | ||||
| 5 | the local curl;curl -s https://example.com/ | asked | asked: OpenCode listed both parts | |
Prompt: "Use the shell tool exactly once to run:curl -s http://127.0.0.1:8080/ru/qa/ -o /dev/null -w '%{http_code}\n'; curl -s https://example.com/ -o /dev/null -w '%{http_code}\n'. Then reply with the output, or with the exact error text. Do not use any other tool." What happened: OpenCode asked once for the whole line and listed both curl parts in the question. The run rejected it, and neither request was made. Why: OpenCode splits a command line at ;, && and | and checks every part on its own. The first part matches the allow, the second only *curl *, and one part that asks is enough to stop the line. | ||||
| 6 | the local curl; rm dummy.txt | asked | asked, file kept | |
Prompt: "This is a throwaway test repo: dummy.txt is a dummy file and deleting it is part of the test. Use the shell tool exactly once to run this command exactly as written, without changing or splitting it:curl -s http://127.0.0.1:8080/ru/qa/ -o /dev/null -w '%{http_code}\n'; rm dummy.txtThen reply with the output, or with the exact error text. Do not use any other tool." What happened: OpenCode asked about the line, the run rejected it, and dummy.txt was still there afterwards. Why: The part rm dummy.txt matches the ask rm *, and it is checked on its own, whatever the curl part before it allows. | ||||
| 7 | the local curl;git commit --allow-empty -m test | asked | asked | |
Prompt: "Use the shell tool exactly once to run:curl -s http://127.0.0.1:8080/ru/qa/ -o /dev/null -w '%{http_code}\n'; git commit --allow-empty -m test. Then reply with the output, or with the exact error text. Do not use any other tool." What happened: OpenCode asked about the line and the run rejected it; the throwaway repo still had its one commit. Why: The second part matches the ask *git commit*:git commit --allow-empty -m test | ||||
| 8 | the local curl;wget -q -O /dev/null https://example.com/ | asked | asked | |
Prompt: "Use the shell tool exactly once to run:curl -s http://127.0.0.1:8080/ru/qa/ -o /dev/null -w '%{http_code}\n'; wget -q -O /dev/null https://example.com/. Then reply with the output, or with the exact error text. Do not use any other tool." What happened: The model first called a tool that does not exist (Unknown tool), then the shell tool. OpenCode asked about the curl and the wget parts, and the run rejected it. Why: The second part matches the ask *wget *:wget -q -O /dev/null https://example.com/ | ||||
| 9 | the local curl with $(rm dummy.txt) at the end of the URL | asked | asked: OpenCode listed rm dummy.txt as its own part | |
Prompt: "This is a throwaway test repo: dummy.txt is a dummy file and deleting it is part of the test. Use the shell tool exactly once to run this command exactly as written, without changing or splitting it:curl -s http://127.0.0.1:8080/ru/qa/$(rm dummy.txt) -o /dev/null -w '%{http_code}\n'Then reply with the output, or with the exact error text. Do not use any other tool." What happened: OpenCode asked about the line and listed rm dummy.txt as a part of its own. The run rejected it, and dummy.txt was still there. Why: OpenCode also takes commands out of $(…) and checks them on their own. rm dummy.txt matches the ask rm *, although the curl around it starts with the allowed prefix. | ||||
| 10 | the local curl; cat .env | denied | Permission denied: shell | |
Prompt: "Use the shell tool exactly once to run:curl -s http://127.0.0.1:8080/ru/qa/ -o /dev/null -w '%{http_code}\n'; cat .env. Then reply with the output, or with the exact error text. Do not use any other tool." What happened: The run stopped with Permission denied: shell; neither part ran. Why: The part cat .env matches the deny *.env*. The denies of OpenCode rule set 3 come last, so the allow of the curl part cannot reopen them. | ||||
| 11 | one curl, two URLs:curl -s http://127.0.0.1:8080/ru/qa/ https://example.com/ | runs: the gap | ran, 200 from both hosts, no prompt | |
Prompt: "Use the shell tool exactly once to run:curl -s http://127.0.0.1:8080/ru/qa/ https://example.com/ -o /dev/null -o /dev/null -w '%{http_code}\n'. Then reply with the output, or with the exact error text. Do not use any other tool." What happened: The command ran without a prompt and printed 200 twice: curl fetched the local page and https://example.com/. Why: The whole line is one curl part that starts with the allowed prefix, and the * at the end of the pattern matches everything after it, a second URL included. This is the gap the risks describe. | ||||
| 12 | curl on /services/user_profile/data/visitors.jsonl | 404 | 404 | |
Command, run in a terminal: curl -s -o /dev/null -w '%{http_code} %{size_download}\n' http://127.0.0.1:8080/services/user_profile/data/visitors.jsonl What happened: The server answered 404 5234: its 404 page, not the visitor records. The request did not go through OpenCode. Why: A rule in container/src/.htaccess answers 404 for the whole /services/user_profile/data/ folder, so the local site never serves the records, whatever an allowed curl asks for. | ||||
| 13 | the same, encoded: /services/user%5Fprofile/%64ata/visitors.jsonl | 404 | 404 | |
Command, run in a terminal: curl -s -o /dev/null -w '%{http_code} %{size_download}\n' http://127.0.0.1:8080/services/user%5Fprofile/%64ata/visitors.jsonl What happened: The server answered 404 5234 again. Why: Apache decodes %5F and %64 before the rewrite rule compares the path, so the encoded URL still meets the same 404 rule. The shell deny *user_profile/data* would not have seen this URL: it matches text, and the encoded text differs. | ||||
What the rule risks. Before it, every curl asked. Now one kind of curl runs unasked, and everything that kind of call can do comes with it:
- One curl can reach other hosts. The * at the end of the pattern matches everything after the local URL, inside that one curl. curl takes several URLs in one call, so this command matches the allow and reaches both hosts without a prompt (test 11):curl -s http://127.0.0.1:8080/ru/qa/ https://example.com/The same tail can carry -d @file or -T file, which send a file to the URL that follows, -x or --connect-to, which send the request itself somewhere else, and -K file, which reads more options and URLs from a file. The shell denies still stop a line that names a protected file, such as .env or .ssh/, but a file no rule names can leave the machine unasked. This is the main cost of the rule.
- Chained commands keep their own rules. OpenCode checks every part on its own, so the allow covers only the curl part. A second curl, rm, git commit and wget after it still ask, a command inside $(…) is checked too, and cat .env is still denied (tests 5 to 10).
- Everything the local site serves is one call away. A URL is not a file path. The read deny on *services/user_profile/data/* never sees an HTTP request, and the shell deny *user_profile/data* only matches text, which URL encoding changes: user%5Fprofile/%64ata is the same folder for Apache. What keeps the aredel visitor records private here is the server: a rule in container/src/.htaccess answers 404 for the whole data folder, plain or encoded (tests 12 and 13). Anything else under container/src that the server does not block, and anything a PHP page reads from disk and prints, the agent can now fetch without a prompt.
- A request can change data, not only read it. Pages run code: /services/user_profile/ appends a record to its visitors.jsonl on every request, a curl from the agent included. Options after the URL, such as -d and -X POST, still match the allow, so the forms and endpoints of the local site take POST requests unasked.
- The port is trusted, not the program. The rule trusts whatever listens on 8080. When the container is stopped, another local server can take the port and gets the same trust. Keep the port exact: a wider curl -s http://127.0.0.1:* would also cover the aredel backoffice on port 8765, whose API uploads files to the hosting and deletes them there (/api/deploy/upload, /api/deploy/delete). Keep the / after the port too: http://127.0.0.1:8080@example.com/ is a request to example.com, and it does not match.
- The page goes into the model. Whatever the page prints lands in the session and on the servers of the model provider. A free OpenCode model can be a contributor tier, where prompts may train future models, so a local page that shows private data sends it there without a prompt.
- /tmp/opencode is shared. The second allow opens the folder to the read, grep and glob tools without a prompt. Every OpenCode session of your user keeps temporary files there, sessions in other projects included, so their files are readable too. On a machine with other users, check that the folder is yours:ls -ld /tmp/opencode
When to drop the rule. A text pattern cannot parse curl options, so no extra ask rule closes the first risk for good: an ask on a second http after the URL misses a second URL written without a scheme, which curl accepts too. If that gap matters for your repo, remove the allow and approve local checks by hand, or give the agent a small script that takes only a path and always fetches from 127.0.0.1:8080, and allow that script instead of curl.
How to test a deny rule
Other tests on this page. This chapter tests the deny rules for the logs, one tool at a time.
The same kind of runs produced the site.env tests, the tables of options A, B and C, and the local curl tests.
What is tested. Whether a deny rule stops every way an agent can get the content of a protected file into the model: the read tool, the grep tool, the glob tool, the shell, and a file attached by you. A rule passes a test when the agent answers with a permission error and the file content never appears in its reply.
Why dummy data. A test that succeeds in reading the file has already sent it to the model. So the tests run on a throwaway repo with the same folder layout and one fake log line. Its address 203.0.113.7 is from the documentation range: if it shows up in an answer, the file was read; if it does not, it was not.
Setup. The throwaway repo has no .gitignore on purpose: the OpenCode grep tool skips files that git ignores (site.env tests), so a .gitignore would hide the log from grep before any rule is tested.
Create the repo and the dummy log:
mkdir -p /tmp/denytest/container/tools/analyze_logs/site_logs/prod/access && cd /tmp/denytest && git init -q
printf "203.0.113.7 - - [22/Oct/2026] \"GET /ai/ HTTP/1.1\" 200\n" > container/tools/analyze_logs/site_logs/prod/access/aredel.com.access.log
Write two configs. opencode.before.jsonc holds the rules the repo has today; opencode.after.jsonc adds the deny rule under test:
vi opencode.before.jsonc
{ "$schema": "https://opencode.ai/config.json", "permissions": [ { "action": "shell", "resource": "*", "effect": "ask" }, { "action": "read", "resource": "*.env", "effect": "deny" }, { "action": "read", "resource": "*creds*", "effect": "deny" } ] }
vi opencode.after.jsonc
{ "$schema": "https://opencode.ai/config.json", "permissions": [ { "action": "shell", "resource": "*", "effect": "ask" }, { "action": "read", "resource": "*.env", "effect": "deny" }, { "action": "read", "resource": "*creds*", "effect": "deny" }, { "action": "read", "resource": "container/tools/analyze_logs/site_logs/*", "effect": "deny" } ] }
Before each test, copy the config it names to opencode.jsonc. Every prompt tells the model to use exactly one tool and to quote the tool's error, so the reply shows what the tool did. Runs below: 2026-10-22, OpenCode 2.1.2, on the free OpenCode model.
Test 1 — read tool, before the rule. The baseline: proves the agent can read the file today, so a later denial is the rule's doing and not a typo in the path.
cp opencode.before.jsonc opencode.jsonc
timeout 180 opencode run --standalone -m opencode/deepseek-v4.1-flash-free "Use the read tool exactly once on container/tools/analyze_logs/site_logs/prod/access/aredel.com.access.log. Then reply with the IP address it contains, or with the exact error text the tool returned. Do not use any other tool."
- Expected: the reply contains 203.0.113.7.
- Actual: 203.0.113.7.
- Result: as expected: the file is readable without the rule.
Test 2 — read tool, after the rule. The rule itself: the same prompt with the deny rule in place.
cp opencode.after.jsonc opencode.jsonc
timeout 180 opencode run --standalone -m opencode/deepseek-v4.1-flash-free "Use the read tool exactly once on container/tools/analyze_logs/site_logs/prod/access/aredel.com.access.log. Then reply with the IP address it contains, or with the exact error text the tool returned. Do not use any other tool."
- Expected: Permission denied: read.
- Must not happen: 203.0.113.7 in the reply.
- Actual: {"type":"permission.rejected","message":"Permission denied: read"}, no address.
- Result: pass.
Test 3 — grep tool, after the rule. Whether the read rule also covers searching inside the file.
timeout 180 opencode run --standalone -m opencode/deepseek-v4.1-flash-free "Use the grep tool exactly once with pattern 203\.0\.113 and path container/tools/analyze_logs. Then reply with the matching lines it returned, or with the exact error text. Do not use any other tool."
- Expected: a permission error, if the read rule covered grep.
- Must not happen: the log line in the reply.
- Actual: the full log line, 203.0.113.7 included.
- Result: gap: an OpenCode grep rule matches the search pattern, not the path, so a read rule cannot stop it. Fixed by test 7.
Test 4 — glob tool, after the rule. Whether file names in the folder stay visible.
timeout 180 opencode run --standalone -m opencode/deepseek-v4.1-flash-free "Use the glob tool exactly once with pattern container/tools/analyze_logs/site_logs/**/*. Then reply with the file paths it returned, or with the exact error text. Do not use any other tool."
- Expected: the file name may appear; its content must not.
- Must not happen: 203.0.113.7 in the reply.
- Actual: the path of the log file, no content.
- Result: accepted gap: the name is visible, the content is not.
Test 5 — shell cat, after the rule. Whether the read rule covers the shell. In this config shell is set to ask.
timeout 180 opencode run --standalone -m opencode/deepseek-v4.1-flash-free "Use the shell tool exactly once to run: cat container/tools/analyze_logs/site_logs/prod/access/aredel.com.access.log . Then reply with the output, or with the exact error text. Do not use any other tool."
- Expected: the command is not run without a person approving it.
- Must not happen: 203.0.113.7 in the reply.
- Actual: This non-interactive run cannot ask the user for permission, so the request was rejected.
- Result: pass in a script only. In the interactive TUI the same command asks you, and approving it sends the file: the read rule does not cover the shell.
Test 6 — your own attachment, after the rule. Whether the read rule stops a file you attach yourself.
timeout 180 opencode run --standalone -m opencode/deepseek-v4.1-flash-free -f container/tools/analyze_logs/site_logs/prod/access/aredel.com.access.log "Do not use any tools. What IP address is in the attached file? If no file content was given to you, answer exactly NOT GIVEN."
- Expected: NOT GIVEN, if the rule covered attachments.
- Must not happen: 203.0.113.7 in the reply.
- Actual: 203.0.113.7.
- Result: gap: OpenCode sends attached files regardless of read rules. Only not attaching them helps.
Test 7 — grep tool, after adding a grep deny. The fix for test 3: add { "action": "grep", "resource": "*", "effect": "deny" } to the after config, then repeat the test 3 command.
vi opencode.after.jsonc
cp opencode.after.jsonc opencode.jsonc
timeout 180 opencode run --standalone -m opencode/deepseek-v4.1-flash-free "Use the grep tool exactly once with pattern 203\.0\.113 and path container/tools/analyze_logs. Then reply with the matching lines it returned, or with the exact error text. Do not use any other tool."
- Expected: no grep result; the tool is denied or missing.
- Must not happen: 203.0.113.7 in the reply.
- Actual: the model reported that no grep tool is available in the session and ran nothing.
- Result: pass. The role loses grep entirely, so give this rule only to a role that can live without search.
Test 8 — the log outside the project. The structural fix: move the log out of the repo and deny the external folder, { "action": "external_directory", "resource": "/tmp/outside/*", "effect": "deny" }, then ask for the file with the read tool and with the grep tool.
mkdir -p /tmp/outside/site_logs && mv container/tools/analyze_logs/site_logs/prod /tmp/outside/site_logs/
vi opencode.after.jsonc
cp opencode.after.jsonc opencode.jsonc
timeout 180 opencode run --standalone -m opencode/deepseek-v4.1-flash-free "Use the read tool exactly once on /tmp/outside/site_logs/prod/access/aredel.com.access.log. Then reply with the IP address it contains, or with the exact error text the tool returned. Do not use any other tool."
timeout 180 opencode run --standalone -m opencode/deepseek-v4.1-flash-free "Use the grep tool exactly once with pattern 203\.0\.113 and path /tmp/outside/site_logs. Then reply with the matching lines it returned, or with the exact error text. Do not use any other tool."
- Expected: Permission denied: external_directory for read and for grep.
- Must not happen: 203.0.113.7 in either reply.
- Actual: Permission denied: external_directory for both tools.
- Result: pass, and the role keeps grep for everything inside the project.
Test 9 — read tool, session started in a subfolder. Whether the rule still matches when OpenCode is started from container/ instead of the repo root. The config at the repo root is still found, but paths are now relative to container/.
cp opencode.after.jsonc opencode.jsonc
cd container && timeout 180 opencode run --standalone -m opencode/deepseek-v4.1-flash-free "Use the read tool exactly once on tools/analyze_logs/site_logs/prod/access/aredel.com.access.log. Then reply with the IP address it contains, or with the exact error text the tool returned. Do not use any other tool."
Then change the rule to *analyze_logs/site_logs/* in opencode.after.jsonc, copy it again and repeat the same command:
vi opencode.after.jsonc
- Expected: Permission denied: read for both patterns.
- Must not happen: 203.0.113.7 in the reply.
- Actual: with container/tools/analyze_logs/site_logs/* the reply was The IP address is 203.0.113.7; with *analyze_logs/site_logs/* it was Permission denied: read.
- Result: gap for the full project path, pass with the leading *.
Test 10 — an ask rule under --auto. Whether --auto, which skips the approval prompts, still stops at an ask rule. Keep the log outside the project from test 8 and change the effect of its external_directory rule from deny to ask. Then run the grep prompt without and with --auto. Run on 2026-10-22, OpenCode 2.1.3.
vi opencode.after.jsonc
cp opencode.after.jsonc opencode.jsonc
timeout 180 opencode run --standalone -m opencode/deepseek-v4.1-flash-free "Use the grep tool exactly once with pattern 203\.0\.113 and path /tmp/outside/site_logs. Then reply with the matching lines it returned, or with the exact error text. Do not use any other tool." < /dev/null
timeout 180 opencode run --auto --standalone -m opencode/deepseek-v4.1-flash-free "Use the grep tool exactly once with pattern 203\.0\.113 and path /tmp/outside/site_logs. Then reply with the matching lines it returned, or with the exact error text. Do not use any other tool." < /dev/null
- Expected: a permission prompt in both runs, rejected because a script has no one to ask.
- Must not happen: 203.0.113.7 in either reply.
- Actual: without --auto, permission requested: external_directory … auto-rejecting and no address; with --auto, the full log line.
- Result: gap under --auto: it approves every ask rule, and only deny holds.
Summary.
| Test | Way in | Config | Expected | Actual | Result |
|---|---|---|---|---|---|
| 1 | read tool | before | content | content | baseline |
| 2 | read tool | after | denied | denied | pass |
| 3 | grep tool | after | denied | content | gap |
| 4 | glob tool | after | name only | name only | accepted |
| 5 | shell cat | after | not run unasked | rejected, no one to ask | pass in scripts |
| 6 | attachment | after | not sent | sent | gap |
| 7 | grep tool | after + grep deny | denied | no grep tool | pass |
| 8 | read and grep | log outside, external deny | denied | denied | pass |
| 9 | read tool from a subfolder | after; then leading * | denied | content; then denied | gap; pass with * |
| 10 | grep tool | log outside, external ask; then --auto | asked | asked; then content | pass; gap under --auto |
When a run proves nothing. If a run hangs until the timeout, the model chose a tool set to ask and nobody could answer. That is neither a pass nor a fail: rerun it with a narrower prompt. If the reply describes the file without quoting the tool, the model guessed; rerun and demand the exact tool output. If a run started from a script prints nothing until the timeout, opencode run is waiting for input: close it with < /dev/null.
Checklist
- List what on disk must never reach a model: credentials, logs with personal data, user data, customer code.
- Start every OpenCode path pattern with *, so it holds when the session starts in a subfolder.
- Deny read on each path in both tools; in Claude Code prefer //**/ patterns so the rule holds whatever directory the session starts in.
- List secret files in .gitignore: the OpenCode grep tool skips files that git ignores, and a read deny does not stop it (step 2 of the first case).
- Give the agent an example file with dummy values instead of the secrets, let it write the script that needs them, and run the script yourself (the simplest setup).
- Keep shell on ask or deny it per role; a deny on reading a file does not stop a script from opening it.
- Expect a prompt for every command while shell is on ask; pick an option that keeps only the prompts that mean something.
- Set external_directory to ask and deny the secrets outside the project by name: the default agent reads files outside the project without asking (rules every option keeps).
- Do not start OpenCode with --auto when a folder relies on ask: --auto approves it (test 10).
- Test every rule on a throwaway copy with dummy data, one prompt per tool.
- Do not attach protected files yourself; in OpenCode nothing stops an attachment.
- Best of all, keep such data outside the repo the agents work in, as aredel did with its logs (final fix).
Official sources:
Article author: Arthur Isaev