Skip to content
← All posts

Safety Nets for Claude Code with --dangerously-skip-permissions

The flag skips permission prompts, not all safety controls. Hooks can block tool calls before execution. Deny rules still apply. Sandboxing still restricts filesystem and network. Here is how to layer them.

· 9 min read · Sylvester Damgaard
Safety Nets for Claude Code with --dangerously-skip-permissions

Updated 25 September 2026. The original version said ask rules turn into allow under the flag. They don't, and that was the post's worst mistake. The hook syntax, the sandbox and managed settings keys and the SDK section have been corrected against the current docs too.

TLDR: copy this into your Laravel project

If you just want Claude Code to stop nuking your database and pushing broken code to production, paste this into .claude/settings.json and move on. The rest of the post explains why.

.claude/settings.json
json
{
  "permissions": {
    "deny": [
      "Bash(php artisan db:wipe *)",
      "Bash(php artisan db:wipe)",
      "Bash(php artisan migrate:fresh *)",
      "Bash(php artisan migrate:fresh)",
      "Bash(php artisan migrate:reset *)",
      "Bash(php artisan migrate:reset)",
      "Bash(php artisan migrate:rollback *)",
      "Bash(php artisan migrate:rollback)",
      "Bash(php artisan key:generate *)",
      "Bash(php artisan key:generate)",
      "Bash(php artisan down *)",
      "Bash(php artisan down)",
      "Bash(rm -rf *)",
      "Bash(rm -r *)",
      "Bash(curl *)",
      "Bash(wget *)",
      "Bash(git push *)",
      "Bash(git push)",
      "Bash(git reset --hard *)",
      "Bash(git checkout -- *)",
      "Bash(git clean *)",
      "Bash(docker *)",
      "Bash(sudo *)",
      "Bash(chmod 777 *)",
      "Bash(kill *)",
      "Bash(pkill *)",
      "Bash(npm publish *)",
      "Bash(composer global *)",
      "Read(.env*)",
      "Read(./secrets/**)",
      "Read(//**/.env)",
      "Edit(.env*)",
      "Edit(./secrets/**)",
      "WebFetch"
    ],
    "allow": [
      "Read",
      "Glob",
      "Grep",
      "Bash(php artisan *)",
      "Bash(php artisan test *)",
      "Bash(php artisan make:* *)",
      "Bash(php artisan route:list *)",
      "Bash(php artisan tinker --execute *)",
      "Bash(php artisan migrate --no-interaction *)",
      "Bash(php artisan migrate)",
      "Bash(composer require *)",
      "Bash(composer update *)",
      "Bash(composer install *)",
      "Bash(npm run *)",
      "Bash(npm install *)",
      "Bash(npx *)",
      "Bash(git status)",
      "Bash(git diff *)",
      "Bash(git log *)",
      "Bash(git add *)",
      "Bash(git commit *)",
      "Bash(git branch *)",
      "Bash(git checkout -b *)",
      "Bash(git stash *)",
      "Bash(vendor/bin/pint *)",
      "Bash(vendor/bin/phpstan *)",
      "Bash(vendor/bin/pest *)",
      "Bash(cat *)",
      "Bash(ls *)",
      "Bash(find *)",
      "Bash(grep *)",
      "Bash(head *)",
      "Bash(tail *)",
      "Bash(wc *)",
      "Bash(mkdir *)"
    ]
  }
}

For permission matching, deny rules are evaluated first and override ask and allow, also when --dangerously-skip-permissions. The agent can run php artisan migrate but not migrate:fresh. It can git commit but not git push. It can read your code but not your .env.


Claude Code is an agent. It reads files, edits code, runs shell commands, and makes HTTP requests. The permission system asks you before each action. --dangerously-skip-permissions turns those prompts off.

Some people use it anyway. CI pipelines, automated workflows, or just impatience. This post isn't about whether you should. It's about what you can do if you do.

The flag doesn't disable everything. Deny rules still apply. Hooks still run. Sandboxing still works. You can layer these controls so that even with bypass mode active, the agent can't touch secrets, push to production, or reach the internet unsupervised.

What the flag actually skips

It skips interactive permission prompts. That's it. Everything else still runs:

  • deny rules still block matched commands

  • ask rules are still honoured. Bypass mode does not auto-approve them

  • All hooks execute (PreToolUse, PostToolUse, etc.)

  • A short list of actions is never auto-approved in any mode, including tools that need a human answer and an rm or rmdir aimed at a critical path

  • Sandbox restrictions remain active

The ask rule is the interesting one, and what it does depends on how you run. In an interactive session it stops the agent and waits for you, flag or no flag. In a headless run there is nobody to answer, so the call is denied instead. Either way the command does not go through unsupervised, which makes ask a real layer rather than a softer deny.

What bypass mode does drop is the protection on paths like .git and .claude. Those are written without a prompt, so don't count on them holding.

Note: hooks didn't always run in bypass mode. Anthropic fixed this as a bug. If you tested this before that fix, your experience is outdated.

Layer 1: Deny rules

The simplest and fastest control. Only hooks run earlier, and deny is checked before the permission mode, so it blocks in bypass mode too. First match wins. The TLDR config above covers a solid Laravel setup. Put it in .claude/settings.json (shared with team) or .claude/settings.local.json (personal).

The full order is hooks, deny, ask, permission mode, allow. A tool call matching a deny rule is blocked, with no override and no prompt. Allow rules are the part bypass mode ignores: it approves everything reaching the mode step anyway, so the allow list above earns its keep in the sessions you run without the flag.

Watch the wildcard syntax. Bash(rm *) (space before *) enforces a word boundary, so it matches rm -rf / but not rmdir. Bash(rm*) (no space) would match both.

And know what a Bash rule does not cover. It matches the command text the agent writes, after Claude Code has split compound commands and stripped wrappers. It does not follow the same binary reached another way, so a rule catches the form the agent normally produces and nothing beyond it:

text
Bash(curl *)       stops  curl https://example.com
                   misses /usr/bin/curl https://example.com
                          sh -c 'curl https://example.com'

Bash(rm *)         stops  rm -rf build/
                   misses /bin/rm -rf build/
                          bash -c 'rm -rf build/'

Bash(git push *)   stops  git push origin main
                   misses git -C . push origin main
                          git -c push.default=current push origin main
                          git 'push' origin main

That is not a reason to skip the deny list. An agent that reaches for /bin/rm because rm was refused is a different problem. But it means a deny rule is a guard rail around an invocation, not a wall around a program, and the next two layers are where that distinction gets settled.

Layer 2: PreToolUse hooks

Hooks run code before or after a tool call. PreToolUse is the important one. It runs before the tool executes and can block it by returning a deny decision.

The hook receives JSON on stdin with the tool name and input. To block with a structured permission decision, print JSON to stdout and exit 0. To block with a plain error message instead, write to stderr and exit 2.

.claude/hooks/guard.sh
bash
#!/bin/bash
# Block destructive bash commands and network access
# Receives JSON on stdin: { "tool_name": "Bash", "tool_input": { "command": "..." } }

INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')

if [ "$TOOL" = "Bash" ]; then
  CMD=$(echo "$INPUT" | jq -r '.tool_input.command')

  # Block patterns that deny rules might miss
  if echo "$CMD" | grep -qEi 'rm -rf /|mkfs|dd if=|:()\{|fork bomb|\| base64|> /dev/sd|shutdown|reboot|passwd'; then
    echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Destructive or dangerous command blocked by hook"}}'
    exit 0
  fi

  # Block exfiltration patterns
  if echo "$CMD" | grep -qEi 'curl.*-d|wget.*--post|nc -|ncat|socat|ssh.*@|scp '; then
    echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Data exfiltration pattern blocked by hook"}}'
    exit 0
  fi
fi

# Allow everything else
exit 0

Make it executable and wire it up in settings:

bash
chmod +x .claude/hooks/guard.sh
.claude/settings.json (hooks section)
json
{
  "hooks": {
    "PreToolUse": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh"
          }
        ]
      }
    ]
  }
}

Each entry in the event array is a matcher group with its own list of hooks. Leave the matcher out, as above, and the group runs for every tool. Add one to scope it. It is a regex over tool names, not a list of objects:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|WebFetch",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh"
          }
        ]
      }
    ]
  }
}

Hooks and deny rules protect against different things. Deny rules match exact tool names and patterns. Hooks can inspect the full command string, check environment variables, call external APIs, or apply arbitrary logic. Use both.

Layer 3: Sandboxing

Sandboxing restricts what bash commands can access at the OS level. On macOS it uses Seatbelt and works out of the box. On Linux you need bubblewrap and socat installed. Configure what the sandbox allows:

.claude/settings.json (sandbox section)
json
{
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true,
    "autoAllowBashIfSandboxed": true,
    "allowUnsandboxedCommands": false,
    "filesystem": {
      "allowWrite": ["/tmp/build"],
      "allowRead": ["."]
    },
    "credentials": {
      "files": [
        { "path": "~/.aws/credentials", "mode": "deny" },
        { "path": "~/.ssh", "mode": "deny" }
      ],
      "envVars": [
        { "name": "GITHUB_TOKEN", "mode": "deny" },
        { "name": "NPM_TOKEN", "mode": "deny" }
      ]
    },
    "network": {
      "allowedDomains": ["github.com", "*.npmjs.org", "packagist.org"]
    }
  }
}

The important setting is allowUnsandboxedCommands: false. Without it, commands may be allowed to run outside the sandbox. Set it to false to prevent unsandboxed execution except where you have explicitly excluded commands.

Pair it with failIfUnavailable: true. When the sandbox cannot start, because bubblewrap is missing on Linux or the platform doesn't support it, the default is a warning and then commands run unsandboxed anyway. That is the wrong default for something you are relying on. With this set, Claude Code refuses to start instead.

The credentials block is the better home for secrets than a filesystem rule. File entries are denied for reads the same way filesystem.denyRead does it, and the environment variables are unset before each sandboxed command runs, which no path rule can do.

Layer 4: Managed settings (teams)

If you run a team, you can disable bypass mode entirely so nobody can use the flag. Managed settings are deployed to a system-level path that individual users can't override.

macOS: /Library/Application Support/ClaudeCode/managed-settings.json
json
{
  "allowManagedPermissionRulesOnly": true,
  "allowManagedHooksOnly": true,
  "permissions": {
    "disableBypassPermissionsMode": "disable",
    "deny": [
      "Bash(curl *)",
      "Bash(wget *)",
      "Read(.env*)",
      "Read(./secrets/**)",
      "WebFetch"
    ],
    "allow": [
      "Read",
      "Glob",
      "Grep",
      "Bash(npm run *)",
      "Bash(git status)",
      "Bash(git diff *)"
    ]
  }
}

File locations by platform:

  • macOS: /Library/Application Support/ClaudeCode/managed-settings.json

  • Linux: /etc/claude-code/managed-settings.json

  • Windows: C:\Program Files\ClaudeCode\managed-settings.json

Deploy via MDM (Jamf, Intune, Kandji) or just drop the file. allowManagedPermissionRulesOnly means users can't add their own allow rules. allowManagedHooksOnly means they can't register their own hooks. The managed config is the only config.

Layer 5: SDK-level controls

If you embed Claude Code via the Agent SDK, you can enforce tool restrictions in code. disallowedTools wins over everything, including bypass mode:

typescript
import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const message of query({
  prompt: "Help me refactor this codebase",
  options: {
    disallowedTools: ["Bash", "WebFetch"],
    allowedTools: ["Read", "Glob", "Grep", "Edit", "Write"],
    permissionMode: "dontAsk"
  }
})) {
  if ("result" in message) {
    console.log(message.result);
  }
}

With permissionMode: "dontAsk" and an explicit allowedTools list, every call that would otherwise prompt is denied instead. That is close to a whitelist, but not one.

allowedTools pre-approves the tools you name. It does not remove the rest, and anything needing no approval in the first place still runs: read-only Bash commands, reads inside the working directory, tools that never ask. Put the same list next to permissionMode: "bypassPermissions" and it does nothing at all, because bypass approves every tool that reaches it whether you listed it or not.

To put a tool out of the model's reach, name it in disallowedTools. A bare tool name there strips the definition out of the request, so Claude never sees the tool and cannot attempt it.

What this doesn't fix

None of this makes bypass mode safe. It reduces risk. The agent can still write to any file in your project, still create new files, still run any command that isn't explicitly denied. If the deny list misses something, it goes through.

Subagents inherit the parent's permission mode. If you run with bypass, every subagent spawned during the session also runs with bypass. Your deny rules and hooks still apply to them, but they get the same level of autonomy as the parent. One more reason to keep the deny list tight. It's the shared boundary for every agent in the tree.

The point is defense in depth. Deny rules catch the obvious patterns. Hooks catch the non-obvious ones. Sandboxing limits the blast radius at the OS level. Managed settings prevent bypass mode entirely when you need that guarantee.

It is worth being precise about which of these give you that. A deny rule makes one spelling of a command impossible. The sandbox makes a path or a domain unreachable whatever the command says. A bare name in disallowedTools makes a tool non-existent. The last two are boundaries. The deny list is a guard rail, and worth having for what it catches.

Security isn't about whether the agent is smart. It's about which actions are technically impossible.

Sylvester Damgaard
Sylvester Damgaard

Founder of Cbox and Principal Software Engineer at Geocodio. CTO and architect experience across software and infrastructure, from business and product problems to production. Building and operating systems since 2000.