AI engineering

Claude Code permissions: allowlists, deny rules and sandboxing

5 min read

Allow less, prompt less

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:

  1. 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.
  2. 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.
  3. Allow rules in a project’s .claude/settings.json only 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 only git log; Bash(git *) allows every git command, including push and -c options that run programs. A trailing * also matches the bare command, so Bash(ddev composer validate *) covers ddev composer validate as well.
  • Compound commands. Claude Code knows shell operators. Bash(git diff *) does not approve git 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 status and other read-only forms of git run without a prompt by default. You do not need allow rules for them; I keep git diff and git log in the list only to be explicit.
  • Secrets. Read(...) deny rules stop the Read tool and file commands that Claude Code recognises, like cat or head, 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

  • Bash or Bash(*). That is every command.
  • Bash(ddev exec *). The documentation explains it for runners like docker exec or npx: a prefix rule on a runner allows whatever comes after it. ddev exec is such a runner; ddev exec rm -rf public/fileadmin would match. Allow the inner commands one by one instead.
  • Bash(ddev composer *) or Bash(composer *). Composer runs scripts and plugins from the packages you install. require and update stay on ask.
  • Bash(curl *) and friends. Use the WebFetch tool with WebFetch(domain:docs.typo3.org) rules for documentation instead.
  • The bypassPermissions mode 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

  1. Start with a short allow list of read-only commands you see the agent run every day.
  2. When a prompt appears for the fifth time and it is harmless, add an exact rule, not a broader one.
  3. 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.
  4. Deny what must never happen, then back it with a hook if it really matters.
  5. Run /permissions from 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!

Further reading

Leave a comment

Your email address will not be published.