defract › blog

configuring Claude Code for production: hooks and permissions

2026-07-09 8 min read

Two modes dominate how teams configure Claude Code for automated work. The first is to leave defaults in place: the agent asks permission before nearly every file write and command. That is tolerable for interactive sessions but untenable for any pipeline you want to run unattended. The second is to pass --dangerously-skip-permissions. The agent runs unconstrained — fine in a throwaway sandbox, reckless on a production codebase where a hard reset or a force push can land quietly without warning.

There is a middle path. Claude Code's settings.json supports typed permission lists and a hooks system that most teams have not fully explored. Here is what that configuration looks like in practice, grounded in the config we use to build defract itself.

the settings.json structure

Claude Code reads .claude/settings.json at the project level and ~/.claude/settings.json at the user level. The file has two top-level sections that matter for automation: permissions and hooks. They work in combination — permissions define what the agent may do without asking; hooks define what to run when it does.

A minimal skeleton:

{
  "hooks": {
    "PreToolUse": [ ... ],
    "PostToolUse": [ ... ]
  },
  "permissions": {
    "allow": [ ... ],
    "deny": [ ... ]
  }
}

the allow list

The allow list maps to Claude Code's tool + argument format: Tool(pattern:glob). Without entries here, the agent prompts for approval on every tool call that isn't already in the global user config. With entries, those calls proceed silently.

Ours approves the tools an agent legitimately needs to build software — and only those:

"allow": [
  "Bash(pnpm:*)",
  "Bash(cargo:*)",
  "Bash(node:*)",
  "Bash(wrangler:*)",
  "Bash(mkdir:*)", "Bash(cp:*)", "Bash(mv:*)", "Bash(touch:*)",
  "Bash(ls:*)", "Bash(cat:*)", "Bash(head:*)", "Bash(tail:*)",
  "Bash(git add:*)", "Bash(git commit:*)", "Bash(git fetch:*)",
  "Bash(git status:*)", "Bash(git log:*)", "Bash(git diff:*)",
  "Bash(git show:*)", "Bash(git rev-parse:*)",
  "Bash(git worktree:add*)", "Bash(git worktree:list*)",
  "Bash(git worktree:prune)", "Bash(git worktree:remove:*)",
  "Bash(git checkout:*)", "Bash(git checkout:-b*)",
  "Bash(git branch:--show-current)",
  "Read(.defract/**)", "Edit(.defract/**)"
]

A few notes on the structure. The git entries are granular by sub-command, not a blanket Bash(git:*). That means git status and git log are pre-approved; other git sub-commands are not. The Read and Edit entries on .defract/** let agents read and update the project's task and memory directories without prompting, which matters for a pipeline where agents write structured outputs between stages.

the deny list

The deny list blocks specific patterns regardless of the allow list — deny always wins. This is where you define the hard ceiling:

"deny": [
  "Bash(sudo:*)",
  "Bash(curl:*)",
  "Bash(wget:*)",
  "Bash(brew:*)",
  "Bash(rm:*)",
  "Bash(git reset:--hard*)",
  "Bash(git push:--force*)",
  "Bash(git push:-f*)",
  "Bash(git push --force-with-lease:*)",
  "Bash(git branch:-D*)",
  "Bash(git branch:--delete*)",
  "Bash(git clean:-f*)",
  "Bash(git clean:-d*)",
  "Bash(git checkout:--force*)",
  "Bash(git worktree:remove --force*)"
]

The rationale for each cluster:

  • sudo, brew — an agent that can escalate privileges or install system packages is an agent that can escape the repository boundary. These are never justified for building application code.
  • curl, wget — arbitrary network requests bypass code review and can pull unverified binaries into the build. All legitimate package installs should go through the declared package manager.
  • git reset --hard, git push --force, git branch -D, git clean -f — the destructive git operations. These are how you lose untracked work, overwrite remote history, or delete branches without recovery. An agent should never need them during normal development; a human should issue them explicitly when they are actually appropriate.
  • git worktree remove --force — worktrees are the isolation mechanism for parallel agent work. Forced removal can destroy in-progress agent output before it is merged. Only clean removal is allowed.

One intentional design note: rm:* appears in both the allow list and the deny list. Deny takes precedence — rm is effectively blocked. The duplicate allow entry is a historical artifact of an earlier config draft. It does not grant any access. The rule is simple: when deny and allow conflict, deny wins, full stop.

hooks: PreToolUse

Permissions define a gate at the "should this run" level. Hooks define behavior at the "before it runs" and "after it ran" levels. PreToolUse hooks fire before a matched tool call executes. If the hook exits non-zero, the tool call is blocked.

We use a PreToolUse hook on Bash to enforce our package manager:

"PreToolUse": [
  {
    "matcher": "Bash",
    "hooks": [{
      "type": "command",
      "command": "if echo \"$TOOL_INPUT_COMMAND\" | grep -qE '(^|&&|;|\\|)\\s*(npm install|npm i |yarn )'; then echo 'BLOCKED: Use pnpm, not npm or yarn' >&2; exit 2; fi"
    }]
  }
]

TOOL_INPUT_COMMAND is an environment variable Claude Code injects containing the full bash string the agent intends to run. The hook pattern matches common npm and yarn invocations — including cases where they appear after a chained && or ; — and exits with code 2, which Claude Code interprets as a blocked call. The agent receives the error message and corrects to pnpm on retry.

This matters more than it might appear. When agents generate commands based on training data, they default to npm. Without the hook, package manager drift compounds across multiple agents running in parallel — each agent installs via npm, generating a package-lock.json that conflicts with the project's pnpm-lock.yaml. The hook catches it at the source rather than during a post-task cleanup.

hooks: PostToolUse

PostToolUse hooks fire after a matched tool call completes. We run two on every Write:

"PostToolUse": [
  {
    "matcher": "Write",
    "hooks": [
      {
        "type": "command",
        "command": "bash scripts/validate-css-tokens.sh \"$TOOL_INPUT_FILE_PATH\" 2>/dev/null || true"
      },
      {
        "type": "command",
        "command": "bash .claude/hooks/validate-task-schema.sh \"$TOOL_INPUT_FILE_PATH\" 2>/dev/null || true"
      }
    ]
  }
]

The CSS token validator checks any written file for color and spacing values that come from outside the project's design token set — hardcoded hex values, pixel literals instead of token references. Agents do not reliably respect design systems without a signal at write time. The validator prints a warning immediately, giving the agent a correction opportunity before the value propagates to other files.

The task schema validator checks that any file written to the project's task directory matches the expected JSON structure. Agents write task state between pipeline stages; malformed output silently corrupts downstream stages that read it. Catching the error immediately on write keeps it local and recoverable — instead of surfacing as a confusing failure three stages later.

both PostToolUse hooks exit with || true. they are warnings, not blockers. the agent receives the warning output and can self-correct in the next write. a hard exit 2 on PostToolUse would interrupt mid-task rather than allowing in-task correction. choose whether a hook should block (exit 2) or warn (exit 0) based on whether the violation is recoverable within the same task.

what this configuration buys you

The combination creates an envelope: a set of operations the agent may take without asking, a set it cannot take regardless of instructions, and continuous validation against every matched tool call. The agent moves fast within the fence and cannot escape it.

For parallel agent work, the envelope matters in proportion to the number of agents running. A single agent hitting a package manager inconsistency is a minor annoyance. Multiple agents doing it simultaneously, each in its own git worktree, produces a coordination problem that compounds. The hooks catch it at the source; the deny list prevents the destructive recovery attempts that make the conflict worse.

The practical discipline that keeps the config effective: allow what agents genuinely need, deny what they should never need, and use hooks for things that require runtime context the permissions layer cannot see. A deny list that blocks git checkout broadly will stall worktree-based workflows. An allow list that permits sudo defeats the purpose of the config. Keep the lists narrow and revisit them when project tooling changes.

The config above is what defract uses to build defract. It has changed as the project has grown — wrangler was added when the landing site moved to Cloudflare Workers; the git worktree entries were added when parallel agent work became standard. It is a living artifact of how the project operates, not a one-time setup step.

If you are running Claude Code on a production codebase with either the full-interrupt default or --dangerously-skip-permissions, this configuration is worth building. See a structured pipeline for Claude Code for how settings and hooks fit into a broader development workflow, and why stage boundaries matter for what this governance approach is trying to protect.

defract is in open beta

a lifecycle orchestrator built on Claude Code — local-first, your own Anthropic account, no caps.