Now in early access, book a 30-minute demo →
← Back to blog Guide

Configuring Claude Code Permissions and Hooks: A Hardening Guide for Teams (2026)

TL;DR
  • Rules evaluate deny, then ask, then allow - first match wins and specificity never changes the order, so a deny anywhere in the stack is final.
  • The four path anchors decide reach: // is absolute, ~/ is home, / is the project root (not the filesystem root), and a bare path is the working directory.
  • A PreToolUse hook returns allow, deny, ask, or defer on each call and can block what a glob can't constrain - but it can never loosen a matching deny or ask rule.
  • Settings precedence runs managed → CLI → local project → shared project → user; a deny at any level cannot be overridden by a lower one.
  • bypassPermissions skips nearly every prompt, and the only org-wide block, disableBypassPermissionsMode, exists only on machines that actually received managed settings.
  • Anomity inventories Claude Code across the fleet, governs allow/deny/log at the hook, and writes every decision to a queryable 90-day audit trail.

Claude Code permissions hardening is a configuration job, not a one-time toggle, and the decision you face is where each rule should live: in an org-wide managed policy, in a shared project file checked into the repo, or in an individual engineer's settings. Get the layering right and a deny rule you write once protects every developer; get it wrong and the rule that looks like it blocks an absolute path actually scopes to a project directory and protects nothing. This guide walks the mechanics that decide which outcome you get - allow and deny rules, the four path anchors, a PreToolUse hook for deny/ask/defer, permission modes, and settings scope - then hands you a hardening checklist. The deeper evaluation model is covered in how Claude Code permissions actually work, and this guide builds on it.

Every behavior described here is taken from Claude Code's official permissions and hooks documentation, with one anchoring principle: permission rules are enforced by Claude Code, not by the model. Instructions in your prompt or CLAUDE.md shape what the agent tries to do, but only the permission layer decides what it is allowed to do. That distinction is the whole reason a config-level mistake can have outsized impact - the same gap between configured intent and actual enforcement that turned a local project file into code execution in the Claude Code project-file RCE and token exfiltration of CVE-2025-59536.

The hardening here is per-machine by design. None of these controls give security a view across more than one laptop, which is the fleet-level problem Anomity's inventory and runtime governance at the hook exist to solve. Configure the controls first; we close on making them verifiable everywhere.

How does Claude Code decide allow, ask, or deny?

Claude Code splits permission rules into three lists - allow, ask, and deny - and evaluates them in a fixed order: deny first, then ask, then allow. The first list that matches a tool call determines the outcome, and rule specificity does not change the order. A matching ask rule prompts you even when a more specific allow rule also matches the same call. This inverts the IAM-style intuition where the narrowest rule wins, and it is the single fact that most often surprises engineers hardening these settings.

The practical consequence is that deny is absolute. If a tool is denied at any settings level, no lower level can allow it. How you write the deny also sets its blast radius: a bare Bash removes the tool from the model's context entirely, while a scoped Bash(rm *) leaves Bash available and blocks only matching calls. Reach for deny on the boundaries you never want crossed - secrets, remote-mutating git operations, raw network tools - and use ask for the cases a human should look at.

ListEffect on a matching callUse it for
denyBlocks outright; cannot be overridden by a lower level or a hookSecrets, git push, raw network tools, destructive commands
askForces a confirmation prompt every timeOperations a human should eyeball before they run
allowRuns without a promptKnown-safe, high-frequency commands like test and build

How do you write allow and deny rules that hold?

Rules follow the format Tool or Tool(specifier). Bash specifiers support * wildcards at any position, and the space before a trailing * enforces a word boundary: Bash(ls *) matches ls -la but not lsof, while Bash(ls*) matches both. The :* suffix is an equivalent trailing wildcard, so Bash(npm run test:*) matches the same commands as Bash(npm run test *). Claude Code is aware of shell operators, so Bash(safe-cmd *) does not authorize safe-cmd && other-cmd - each subcommand of a compound command must match a rule independently.

Two sharp edges are worth memorizing. First, Bash patterns that try to constrain arguments are fragile: a rule meant to restrict curl to one domain does not survive options before the URL, a protocol switch, a redirect through a shortener, or a URL held in a variable. The documentation's own recommendation is to deny raw network tools like curl and wget and route allowed domains through WebFetch(domain:example.com) instead - or enforce at a PreToolUse hook. Second, environment runners such as docker exec, npx, and devbox run are not stripped like the built-in wrappers (timeout, time, nice, nohup, stdbuf), so Bash(devbox run *) would authorize devbox run rm -rf .. Write a specific rule per inner command instead.

{
  "permissions": {
    "defaultMode": "default",
    "deny": [
      "Read(.env)",
      "Read(~/.ssh/**)",
      "Bash(git push *)",
      "Bash(curl *)",
      "Bash(wget *)"
    ],
    "ask": [
      "Bash(rm *)",
      "Edit(/.github/workflows/**)"
    ],
    "allow": [
      "Bash(npm run test:*)",
      "Bash(npm run build)",
      "Bash(git commit *)",
      "WebFetch(domain:docs.internal.example)"
    ]
  }
}

What do the four path anchors mean for Read and Edit?

Read and Edit rules use gitignore-style matching, but the leading characters anchor the pattern to one of four roots, and choosing the wrong one is the difference between a deny rule that protects every secret on disk and one that protects nothing. The most common mistake is assuming a leading slash means an absolute path. It does not: /path is relative to the project root, and an absolute filesystem path needs a double slash. A bare filename follows gitignore semantics and matches at any depth, so Read(.env) is equivalent to Read(**/.env) and blocks any .env at or under the current directory - but not one in a parent directory or another project.

PatternAnchorExample
//pathAbsolute, from the filesystem rootRead(//Users/alice/secrets/**)
~/pathHome directoryRead(~/.ssh/**)
/pathProject root (NOT the filesystem root)Edit(/src/**/*.ts)
path or ./pathCurrent working directoryRead(*.env)

To block a file everywhere on the machine, use the absolute anchor: Read(//**/.env). Note the enforcement limits too - Read and Edit deny rules cover Claude's built-in file tools and the file commands it recognizes in Bash, such as cat, head, tail, and sed, but not an arbitrary Python or Node script that opens files itself. For OS-level coverage that stops every process, Claude Code's sandbox is the layer that does that. The brittleness of per-machine path rules is part of the broader configuration-drift problem we cover in securing AI coding agents and CLIs.

How do you write a PreToolUse hook for deny, ask, or defer?

A PreToolUse hook is a shell command Claude Code runs before the permission prompt on every matching tool call. It receives JSON on stdin - including session_id, cwd, permission_mode, tool_name, and tool_input - and returns a decision in hookSpecificOutput: a permissionDecision of allow, deny, ask, or defer, with a permissionDecisionReason shown to the user. It can also rewrite arguments via updatedInput. Because the hook sees the resolved command, it can inspect arguments a static glob can't reliably constrain. This runtime check before execution is the same control Anomity's governance at the hook is built around.

Flow control has two paths. With exit code 0, Claude Code parses JSON from stdout; with exit code 2, the hook blocks the call (stderr is fed back to Claude as the error) and that block is evaluated before permission rules, so it beats an allow rule. Any other exit code is a non-blocking error. The hook configuration lives under hooks.PreToolUse in a settings file, with a matcher on the tool name (Bash, Edit|Write, or a regex like mcp__.*__write.*) and a hooks array of command handlers with an optional timeout.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# .claude/hooks/guard.sh
CMD=$(jq -r '.tool_input.command')
if echo "$CMD" | grep -qE 'curl|wget|nc '; then
  jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",
    permissionDecision:"deny",
    permissionDecisionReason:"Raw network tool blocked; use WebFetch"}}'
else
  exit 0  # no decision; normal permission flow applies
fi

One ceiling to remember: the hook can tighten but never loosen. Deny and ask rules are evaluated regardless of what the hook returns, so a matching deny blocks the call even when the hook returned allow, and a matching ask still prompts. A hook is also only as trustworthy as the script it runs and the settings file that registers it - which means the hook itself is something to inventory and verify, not assume, the same trust-boundary lesson behind the GitHub Action bot-actor bypass in Claude Code.

Which permission mode should each context run in?

Modes set the baseline the rules then refine. You pin one with defaultMode in a settings file. The choice matters because the loosest modes remove the prompts that everything else depends on, and the only org-wide block on the loosest mode lives in managed settings - which a never-enrolled machine simply does not have.

ModeBehaviorWhere it fits
defaultPrompts on first use of each toolDay-to-day interactive work
planReads and runs read-only commands; no source editsReviewing an unfamiliar repo
acceptEditsAuto-accepts file edits and common filesystem commands in the working directoryTrusted iterative work in a known project
dontAskAuto-denies tools unless pre-approved via allow rulesStrict, allowlist-only execution
bypassPermissionsSkips nearly every prompt; only explicit ask rules and a circuit-breaker set remainIsolated containers or VMs only

Two governance notes. To prevent the loosest modes, set permissions.disableBypassPermissionsMode (and disableAutoMode) to disable; these are most useful in managed settings where they cannot be overridden. And auto mode - auto-approval with background safety checks - is documented as a research preview, so treat it as not-yet-stable for hardened fleets. The MCP layer compounds the picture, since stdio servers run as local subprocesses by design, a property we examined in the Anthropic MCP stdio-by-design RCE.

How should settings scope from org policy to personal?

Claude Code reads settings from five levels in strict precedence, highest first: managed settings, command-line arguments, local project (.claude/settings.local.json), shared project (.claude/settings.json), and user (~/.claude/settings.json). A deny at any level cannot be overridden by a lower one - a managed deny survives --allowedTools, and a user-level deny blocks a project-level allow because deny is evaluated before allow across all scopes. Put hard boundaries as high as you can and leave only ergonomics to the bottom.

ScopeOwnerPut here
Managed settingsSecurity / ITOrg-wide denies, disableBypassPermissionsMode, allowManagedPermissionRulesOnly
Shared project (.claude/settings.json)Repo maintainersProject denies, the PreToolUse hook, build/test allow rules - checked into version control
Local project (.claude/settings.local.json)Individual on this repoPersonal, un-committed convenience allows
User (~/.claude/settings.json)Individual, all reposPersonal home-path denies and broad allows

Managed-only keys are how a security team forecloses local loosening. allowManagedPermissionRulesOnly prevents user and project settings from defining any allow, ask, or deny rules so only managed rules apply; allowManagedHooksOnly blocks every non-managed hook; and strictPluginOnlyCustomization can lock skills, agents, hooks, and MCP servers to plugins or managed settings. These are strong - and they all share the same precondition: the machine has to have received managed settings in the first place.

What is the Claude Code hardening checklist?

Run this on every machine and repo where Claude Code operates. Each item maps to a control above.

  1. Deny secrets at the right anchor: Read(.env) plus an absolute Read(///.env) and Read(~/.ssh/) for home keys - verify against the anchor table, not intuition.
  2. Deny remote-mutating git: at minimum Bash(git push *), and consider tag/force-push variants.
  3. Deny raw network tools (curl, wget, nc) and route allowed hosts through WebFetch(domain:...).
  4. Write per-inner-command rules for environment runners (docker exec, npx, devbox run) instead of a blanket Bash(<runner> *).
  5. Pin defaultMode per context and never ship bypassPermissions or auto to interactive developer machines.
  6. Set disableBypassPermissionsMode and disableAutoMode to disable in managed settings.
  7. Register a PreToolUse hook for content-level checks globs can't enforce; confirm it actually returns deny and exits non-zero on the cases you care about.
  8. Treat the hook script and the settings file that registers it as audited artifacts - review changes, not just initial intent.
  9. Decide each deny deliberately: bare tool name (capability removal) versus scoped pattern (guardrail).
  10. Lock customization with allowManagedPermissionRulesOnly, allowManagedHooksOnly, or strictPluginOnlyCustomization where policy requires it.
  11. Confirm every machine actually received managed settings - an unenrolled laptop has none of the above.
  12. Verify the effective configuration across the whole fleet, not one machine at a time.

How Anomity governs Claude Code

Every control above is real and well-documented, and every one of them is per-machine. The rules live in ~/.claude/settings.json and a project's .claude/settings.json, the hook lives next to them, and the mode is whatever the local user or a managed policy set. There is no built-in answer to fleet-level questions - which laptops run in bypassPermissions, which projects ship a PreToolUse hook and whether it actually denies anything, which deny rules used a project-relative anchor where an engineer meant an absolute one, and which machines never received managed settings at all. That blind spot is what let a single poisoned configuration matter in the comment-and-control multi-agent prompt injection and credential theft case.

Anomity is the layer that makes it verifiable. The endpoint daemon inventories Claude Code alongside the other AI artifacts on each managed machine - agents, MCP servers, extensions, skills, plugins, secrets, hooks, and CLIs - classifies them, and surfaces the permission rules, hooks, and mode actually in effect. The flow is concrete: fleet inventory finds where Claude Code runs and how it is configured; where Claude Code exposes the PreToolUse hook, Anomity returns an allow, deny, or log decision at the hook on each tool call before it runs; and every decision is written to a queryable 90-day audit trail and routed to your SIEM, Slack, email, or Jira. It collects metadata only, with secret redaction on the endpoint, and is SOC 2 Type II. See how it works for the deployment shape and the comparison for where it sits next to Network, EDR, DLP, and GRC.

Anomity does not replace Claude Code's own enforcement - it gives security a fleet-wide picture of it, so the deny rule you hardened once becomes a policy you can confirm everywhere, on every endpoint, and prove on demand. If you want to verify this layer instead of guessing at it, request early access, or start with the agentic AI governance guide to frame the program around it.

Frequently asked questions

In what order does Claude Code evaluate permission rules?

Rules are split into three lists and evaluated deny first, then ask, then allow. The first list that matches a tool call determines the outcome, and rule specificity does not change that order. This is the opposite of the IAM or firewall intuition where the most specific rule wins: in Claude Code, a matching ask rule prompts you even when a more specific allow rule also matches the same call. Because of this, a deny rule at any settings level is effectively final - no lower-precedence allow can override it, which makes deny the right tool for hard boundaries like secrets and remote-mutating commands.

What is the difference between a bare tool deny and a scoped deny?

A bare tool name like Bash in the deny list removes the tool from the model's context entirely, so Claude never sees it and cannot attempt it. A scoped rule like Bash(rm *) leaves the tool available and only blocks calls that match the pattern. The two have very different blast radii. Denying WebFetch outright cuts off all web fetches; denying WebFetch(domain:internal.example) only blocks that one host. Decide deliberately: a bare deny is a capability removal, a scoped deny is a guardrail on an otherwise-available tool.

Can a PreToolUse hook override a deny rule to allow a command?

No. Hook decisions do not bypass permission rules. Deny and ask rules are evaluated regardless of what the hook returns, so a matching deny blocks the call even if the hook returned allow, and a matching ask still prompts even if the hook returned allow or ask. The hook can only tighten, not loosen. What a hook can do is the reverse: a hook that returns deny, or that exits with code 2, blocks a call that an allow rule would otherwise wave through, and a code-2 block is evaluated before permission rules. Use hooks to add checks, not to grant exceptions.

Why doesn't a leading slash mean an absolute path in Read and Edit rules?

Claude Code's Read and Edit patterns follow gitignore-style matching with four anchors, and a single leading slash anchors to the project root, not the filesystem root. So Edit(/src/) matches the project's src directory, not a top-level /src. To anchor at the real filesystem root you need a double slash: Read(//Users/alice/secrets/). This trips up engineers who expect Unix path semantics and write a deny rule they believe protects an absolute path when it actually scopes to the project. Verify every secret-protecting deny rule against the anchor table before trusting it.

How do I require a prompt for one command while auto-running everything else?

Add the broad tool to your allow list and use an ask or deny rule, or a PreToolUse hook, for the exceptions. Per the docs, the way to run all Bash commands without prompts except a few you want stopped is to add Bash to the allow list and register a PreToolUse hook that rejects those specific commands. Static globs are fragile against argument tricks - a rule restricting curl to one domain does not survive a protocol switch, a redirect, or a URL in a variable - so a hook that inspects the resolved command is the reliable place to enforce content-level rules.

What stops a developer from disabling all of this with bypassPermissions?

bypassPermissions mode skips nearly every prompt, leaving only explicit ask rules and a small circuit-breaker set such as rm -rf / and rm -rf ~. The only way to prevent it organization-wide is to set permissions.disableBypassPermissionsMode to disable in managed settings, which take precedence over every other level. The catch is that this control only exists on machines that actually received managed settings. A laptop that was never enrolled, or a fresh checkout, has no managed policy and can run in bypass mode freely - which is exactly why per-machine configuration needs fleet-wide verification.

Ask AI about Anomity
ChatGPT Claude Perplexity Google AI Grok