
Hi folks,
The first week with an AI coding agent goes like this: it asks before every command, you press Enter fifty times an hour, and on day three you start pressing Enter without reading. That is the worst of both worlds. You are interrupted all the time and you are not really reviewing anything.
The fix is not to switch everything off. The fix is a small, deliberate permission file per project: routine read-only commands run without asking, risky ones always ask, and a few things are simply forbidden. Here is how I set this up for a TYPO3 project in DDEV, based on the official Claude Code permission rules.
How the rules are evaluated
Permission rules live under permissions in a settings file, in three lists: allow, ask and deny. Three facts from the documentation shape everything else:
- The order is deny, then ask, then allow. The first match wins, and specificity does not matter. A broad deny like
Bash(git push *)cannot be opened by a narrow allow. - Deny rules from any scope win. If your user settings allow something and the project denies it, it is denied, and the other way round.
- Allow rules in a project’s
.claude/settings.jsononly apply after you accept the workspace trust dialog for that folder. Deny and ask rules apply anyway, because they only restrict.
Team rules go into .claude/settings.json (committed). Personal additions go into .claude/settings.local.json, which is not meant for git.
The project file
{
"permissions": {
"allow": [
"Bash(ddev describe)",
"Bash(ddev composer validate *)",
"Bash(ddev composer show *)",
"Bash(ddev composer audit *)",
"Bash(ddev exec vendor/bin/typo3 cache:flush)",
"Bash(ddev exec vendor/bin/typo3 upgrade:list *)",
"Bash(ddev exec vendor/bin/typo3 site:list)",
"Bash(git diff *)",
"Bash(git log *)",
"mcp__project-checks__*"
],
"ask": [
"Bash(ddev composer require *)",
"Bash(ddev composer update *)",
"Bash(ddev exec vendor/bin/typo3 extension:setup *)",
"Bash(ddev exec vendor/bin/typo3 upgrade:run *)",
"Bash(git commit *)"
],
"deny": [
"Bash(git push *)",
"Bash(ddev delete *)",
"Bash(ddev import-db *)",
"Read(./config/system/credentials.php)",
"Read(./public/blog/wp-config-production.php)",
"Read(./.project-migration/backups/**)"
]
}
}Some notes on the details, because the details are where these files go wrong:
- Where the
*goes. Claude Code matches everything before the first*literally.Bash(git log *)allows onlygit log;Bash(git *)allows every git command, includingpushand-coptions that run programs. A trailing*also matches the bare command, soBash(ddev composer validate *)coversddev composer validateas well. - Compound commands. Claude Code knows shell operators.
Bash(git diff *)does not approvegit diff && rm -rf var; every part must match on its own. Deny and ask rules match any part, even inside a subshell. - Read-only commands are already free.
ls,cat,grep,git statusand other read-only forms of git run without a prompt by default. You do not need allow rules for them; I keepgit diffandgit login the list only to be explicit. - Secrets.
Read(...)deny rules stop the Read tool and file commands that Claude Code recognises, likecatorhead, and redirections. The paths above are the real secret locations of hoja.in: production credentials and the WordPress production config are not in git, and database dumps with password hashes sit in a git-ignored backup folder.
What I never put into allow
BashorBash(*). That is every command.Bash(ddev exec *). The documentation explains it for runners likedocker execornpx: a prefix rule on a runner allows whatever comes after it.ddev execis such a runner;ddev exec rm -rf public/fileadminwould match. Allow the inner commands one by one instead.Bash(ddev composer *)orBash(composer *). Composer runs scripts and plugins from the packages you install.requireandupdatestay on ask.Bash(curl *)and friends. Use the WebFetch tool withWebFetch(domain:docs.typo3.org)rules for documentation instead.- The
bypassPermissionsmode outside a container or VM. The docs say exactly that, and I agree.
Deny rules are not a wall
This surprised me when I read it carefully: a Bash deny rule matches the command text Claude writes, not the program. Bash(git push *) stops git push origin main, but not git -C . push origin main or sh -c 'git push'. The documentation lists these cases openly. Deny rules catch the normal way the model writes a command. For a real boundary you need one of the next two layers.
Layer two: hooks
A PreToolUse hook is your own script that sees every tool call before it runs. It can allow, ask or deny, and a hook that exits with code 2 blocks the call even if an allow rule matches. A hook cannot open what a deny or ask rule closed, though; those are still evaluated. For hoja.in, the guard hook of my migration plugin denied database-writing TYPO3 commands unless a dump younger than 24 hours existed. No string rule can express “only if a fresh backup exists”, a script can.
Layer three: the sandbox
The Bash sandbox is enforced by the operating system (Seatbelt on macOS, bubblewrap on Linux and WSL2). Sandboxed commands can only write where you allow it and reach only allowed network hosts through a proxy, and that holds for child processes too. You switch it on with /sandbox or in settings:
{
"sandbox": {
"enabled": true,
"network": {
"allowedDomains": ["github.com", "packagist.org", "repo.packagist.org"]
}
}
}Now the honest part for DDEV users: the documentation says docker is incompatible with the sandbox, and DDEV is Docker. The documented way out is sandbox.excludedCommands, for example "ddev *", and excluded commands run with your full access. So in a DDEV project the sandbox protects host-side commands (git, npm, scripts), while every ddev command depends on permissions and hooks again. Know this before you rely on it. Also good to know: hooks and local MCP servers always run outside the sandbox, with your user’s rights.
A rhythm that works
- Start with a short allow list of read-only commands you see the agent run every day.
- When a prompt appears for the fifth time and it is harmless, add an exact rule, not a broader one.
- Put everything that changes the database, dependencies or git history on ask, and read those prompts properly. There are few of them now, so you can.
- Deny what must never happen, then back it with a hook if it really matters.
- Run
/permissionsfrom time to time; it shows every rule and the file it comes from.
With this, the prompts that remain are few, and each one is a real decision. Cheers!