Skip to content
Guide

Claude Code Security: Permissions, Hooks, and Guardrails

How to lock down Claude Code for a team: what each native control enforces, where it stops, and how to deploy it so a developer's own settings can't undo it.

Guide20 min readOct 2026

The short answer

Claude Code enforces security in the client, not in the model. Four native layers decide what a session can do: permission rules that allow, ask or deny each tool call; PreToolUse hooks that run your code before a call and can block it; an OS sandbox that confines shell commands and the processes they start; and managed settings that pin all three so nothing in a user or repository config can loosen them. Add an MCP server allowlist and OpenTelemetry export and you have a baseline you can defend in a review.

Each layer has a documented edge. Bash rules match command text, so Bash(rm *) doesn't catch /bin/rm. A hook that crashes or times out lets the call through unless you configure it not to. The sandbox doesn't cover Claude's file tools or MCP servers, and it doesn't run on native Windows. This guide covers each control with configs you can copy, then where an external policy layer such as Cordum Edge fits.

TL;DR
  • -Put the non-negotiables in deny rules, in managed settings. Deny wins at every level and in every permission mode, including bypassPermissions.
  • -Treat auto mode as prompt reduction, not a control. Anthropic's docs say it doesn't guarantee safety.
  • -A PreToolUse hook blocks with exit code 2 or a JSON deny. Exit 1, a crash or a timeout lets the call run unless the hook sets onFailure: "block".
  • -Of the native controls, only the sandbox stops a script from reading ~/.aws/credentials on its own, and only after you deny that path. Turn it on with failIfUnavailable and strict mode.
  • -Anthropic doesn't security-audit MCP servers. Allowlist them by URL or command, never by name.
  • -Without managed settings, every control on this page lives in a file the developer can edit.
Scope

For security and platform teams standardizing Claude Code across developer machines and CI. Behavior and settings keys are taken from Anthropic's Claude Code documentation; several controls depend on the client version, so check the linked pages against the version you deploy.

The controls at a glance

ControlWhat it enforcesWhere it stopsLock it with
Permission modesWhat runs without a promptAuto mode is a classifier and does not guarantee safety; bypassPermissions approves everythingpermissions.disableBypassPermissionsMode, permissions.disableAutoMode
Permission rulesAllow, ask or deny per tool and argumentBash rules match command text; file rules miss subprocesses that open files themselvesManaged permissions, allowManagedPermissionRulesOnly
PreToolUse hooksYour code inspects each call before it runsA crash, missing script or timeout lets the call through unless onFailure is blockManaged hooks, allowManagedHooksOnly
SandboxOS-level file and network limits on shell commands and their child processesNot file tools, MCP servers or hooks; not native WindowsManaged sandbox with failIfUnavailable and allowUnsandboxedCommands: false
MCP controlsWhich MCP servers can loadserverName entries are labels, not controlsmanaged-mcp.json, or allowedMcpServers with allowManagedMcpServersOnly
TelemetryTool decisions and results as OpenTelemetry eventsOff by default; command text only with OTEL_LOG_TOOL_DETAILS=1Managed env block

What you are protecting against

Claude Code acts with the developer's identity. Its shell inherits whatever the terminal has: cloud CLI sessions, kubeconfig contexts, git credentials, SSH keys, registry tokens. If the developer can do it from a terminal, a tool call can do it. That makes the risks concrete:

  • Destructive commands that look routine in context: terraform destroy, rm -rf, a force push, a migration against the wrong database.
  • Secret exposure: reading .env, ~/.aws/credentials or SSH keys into context, or sending them out with curl or a package script.
  • Prompt injection: instructions planted in a README, an issue, a web page or an MCP tool result that steer the agent toward one of the above.
  • Config tampering: a repository that ships its own .claude/settings.json, hooks or .mcp.json, or a developer who loosens rules to get rid of prompts.
  • Untrusted MCP servers: third-party tools with broad scopes, running with the developer's tokens.

This has already happened. In March 2026, Alexey Grigorev of DataTalks.Club published a post-mortem of a Terraform session with Claude Code: the agent unpacked an archived state file he had shared, one that described the production course platform, then ran terraform destroy. The VPC, the RDS database and its automated snapshots were deleted, and AWS support restored a snapshot about a day later. His fixes included deletion protection, remote state, backups that don't depend on Terraform, and no unattended command execution by the agent.

Nothing exotic was involved. The agent ran a destructive command with the developer's own cloud access, which is the class of action the controls below exist to stop or route to a person. For the harness internals behind these checks, see our analysis of the Claude Code source leak.

Permission modes: the baseline

A permission mode sets what runs without a prompt. Rules and hooks layer on top of it.

ModeRuns without askingUse it for
default (Manual)Reads onlySensitive work, reviewing every action
acceptEditsReads, file edits, and common filesystem commands such as mkdir, touch, mv and cpIterating on code you review
planReads and exploration; edits stay blocked until you approve the planExploring before changing
autoEverything, with a classifier model reviewing actions in the backgroundLong tasks, fewer prompts
dontAskReads in the working directories and pre-approved tools; anything that would prompt is deniedCI and scripts
bypassPermissionsEverythingIsolated containers and VMs only
  • Auto mode is the starting mode for interactive terminal and VS Code sessions from v2.1.283. A classifier model reviews actions and blocks a default list that includes curl | bash, sending sensitive data to external endpoints, production deploys and migrations, granting IAM or repo permissions, force pushes and terraform destroy. Entering auto mode also drops broad allow rules such as Bash(*) and wildcarded interpreters. It is a good answer to prompt fatigue. Anthropic states that it does not guarantee safety, so don't count it as a control.
  • Deny rules and hook denials apply in every mode, including bypassPermissions. Allow rules have no effect in bypassPermissions, since everything is already allowed.
  • Some writes are never auto-approved. Writes to protected paths such as .git, .claude, .vscode, shell rc files, .npmrc and .mcp.json are prompted, sent to the classifier or denied, depending on the mode, except in bypassPermissions. .github is not on the list, so CI workflow files need a rule of your own.
  • Lock the mode from managed settings. permissions.disableBypassPermissionsMode: "disable" removes bypass mode and makes Claude Code reject --dangerously-skip-permissions. permissions.disableAutoMode: "disable" removes auto mode. A defaultMode of auto or bypassPermissions in a repository's .claude/settings.json or .claude/settings.local.json doesn't take effect, so a cloned repository can't switch either one on.

Permission rules: allow, ask, deny

Rules take the form Tool or Tool(specifier) and live under permissions in any settings file. Claude Code checks deny rules first, then ask, then allow, and the first match wins no matter how specific the rule is, so a narrow allow can't carve an exception out of a broad deny. The docs put it plainly: rules are enforced by Claude Code, not by the model. An instruction in a prompt or CLAUDE.md doesn't change them.

.claude/settings.json
json
{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./secrets/**)",
      "Read(~/.ssh/**)",
      "Read(~/.aws/**)",
      "Bash(rm -rf *)",
      "Bash(terraform destroy *)",
      "Bash(curl *)",
      "Bash(wget *)"
    ],
    "ask": [
      "Bash(git push *)",
      "Bash(kubectl *)",
      "Bash(terraform apply *)"
    ],
    "allow": [
      "Bash(npm run test *)",
      "Bash(npm run lint)"
    ]
  }
}

What these rules catch, and what they miss:

  • Bash rules match the command as written. * is a wildcard, and a trailing * also matches the bare command. Claude Code splits compound commands on operators such as &&, ; and pipes: an allow must match every subcommand, while a deny or ask matches any of them, including commands inside $(). It strips wrappers such as timeout and nohup. Still, Bash(rm *) doesn't stop /bin/rm -rf build/ or bash -c 'rm -rf build/', and Bash(git push *) doesn't stop git -C . push. The docs are explicit that such a rule isn't a security boundary around the program. Allow rules have the mirror problem: a rule for a runner such as npx or docker exec approves whatever command follows it.
  • Read and Edit rules use gitignore-style paths. ./path is relative to the working directory, ~/path to your home directory, and //path is absolute. A Read deny also blocks edits to the same path. Write rules use Edit(...); Write(path) rules never match.
  • File rules don't follow subprocesses. Read and Edit denies cover Claude's file tools, Bash file commands Claude Code recognizes, such as cat and sed, and redirection targets. They don't cover a Python or Node script that opens the file itself, or a grep -r that never names it. That is the sandbox's job.
  • .claudeignore does nothing. Move its entries into Read deny rules.
  • MCP tools are named by server. mcp__github covers every tool from the github server and mcp__github__create_issue covers one. In deny and ask rules, mcp__* matches every MCP tool. A bare tool name in a deny rule removes that tool from Claude's context entirely.
  • Parameter rules catch risky options. Deny and ask rules can match a top-level parameter of a built-in tool: Bash(run_in_background:true) catches every background command.

Settings scopes and precedence

Claude Code reads settings from five levels. When a key appears in more than one, the highest level wins:

  1. Managed settings, deployed by your organization. Nothing below overrides them.
  2. Command line: --settings <file-or-json>, for one session.
  3. Project local: .claude/settings.local.json, a developer's personal settings for one repository.
  4. Shared project: .claude/settings.json, checked into the repository.
  5. User: ~/.claude/settings.json.

Two details matter for security:

  • Rule lists merge. allow, ask and deny combine across levels instead of replacing each other, and because deny is evaluated first, a deny at any level beats an allow at any other. The flip side: every level can also add allows, unless managed settings set allowManagedPermissionRulesOnly, which ignores rules from every other source.
  • A repository can restrict before you trust it, but not grant. Project allow rules and additionalDirectories apply only after the workspace trust dialog; deny and ask rules apply immediately. Choosing "Yes, and don't ask again" at a prompt saves an allow rule to .claude/settings.local.json.

To see what actually applies, run /status (its Setting sources line names the managed source in force), /permissions (every rule and the file it came from) and /hooks (every hook and its source). claude doctor reports settings entries it skipped as invalid.

Headless runs and CI

claude -p and Agent SDK runs never show the trust dialog. Project allow rules stay off, but the repository's hooks, its env block and the servers in its .mcp.json are used, and those MCP servers connect without asking. Before you run Claude Code headless against a repository you didn't write, pass --setting-sources user so it reads neither the project's settings files nor its .mcp.json. For the run itself, set dontAsk with an explicit allowlist, so anything not pre-approved is denied instead of waiting on a prompt nobody will answer:

CI job
bash
claude -p "run the test suite" \
  --permission-mode dontAsk \
  --allowedTools "Bash(npm test)" "Read"

Managed settings: make it stick

Until a control is in managed settings, it lives in a file the developer controls. Managed settings sit above every other level, and several keys only work there. There are three ways to deliver them:

MechanismWhereWhen Claude Code reads it
Server-managedThe claude.ai admin console, or a Claude apps gatewayAt startup, then polled hourly
MDM or OS policymacOS: a profile for the com.anthropic.claudecode domain. Windows: a Settings value holding the JSON under HKLM\SOFTWARE\Policies\ClaudeCodeAt startup, then every 30 minutes
Filemanaged-settings.json in /Library/Application Support/ClaudeCode/ (macOS), /etc/claude-code/ (Linux, WSL) or C:\Program Files\ClaudeCode\ (Windows), plus optional managed-settings.d/*.json drop-insAt startup, and when the file changes

By default the sources don't merge. Claude Code takes the highest-ranked source that sets any policy key, in the order server-managed, MDM, file, and ignores the others without a warning, so a file you deploy is silently skipped on a machine that also receives an MDM profile. /status names the source in use. Claude Code doesn't read the legacy C:\ProgramData\ClaudeCode path, and a managed source that isn't valid JSON stops Claude Code from starting at all.

Keys that only work in managed settings

  • allowManagedPermissionRulesOnly: ignore allow, ask and deny rules from user, project, local and --settings files and from --allowedTools, and hide the always-allow choices in prompts. The strictest option; expect more prompts.
  • allowManagedHooksOnly: run only managed hooks, plus Agent SDK hooks and hooks from plugins your managed settings force-enable. Repository and user hooks don't run.
  • allowManagedMcpServersOnly: make the managed MCP allowlist the only one that applies.
  • requiredMinimumVersion: refuse to start below a version, useful when you depend on a newer control such as onFailure.
  • sandbox.network.allowManagedDomainsOnly: developers can't add sandbox network domains.

A baseline that combines the controls in this guide:

managed-settings.json
json
{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./secrets/**)",
      "Read(~/.ssh/**)",
      "Read(~/.aws/**)",
      "Bash(terraform destroy *)",
      "Bash(git push --force *)"
    ],
    "ask": [
      "Bash(git push *)",
      "Bash(terraform apply *)"
    ],
    "disableBypassPermissionsMode": "disable"
  },
  "allowManagedHooksOnly": true,
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash",
            "args": ["/etc/claude-code/hooks/guard-bash.sh"],
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      }
    ]
  },
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true,
    "allowUnsandboxedCommands": false,
    "network": {
      "allowedDomains": ["registry.npmjs.org"]
    },
    "credentials": {
      "files": [
        { "path": "~/.aws/credentials", "mode": "deny" },
        { "path": "~/.ssh", "mode": "deny" }
      ],
      "envVars": [
        { "name": "GITHUB_TOKEN", "mode": "deny" }
      ]
    }
  },
  "allowManagedMcpServersOnly": true,
  "allowedMcpServers": [
    { "serverUrl": "https://mcp.internal.example.com/*" }
  ],
  "env": {
    "CLAUDE_CODE_ENABLE_TELEMETRY": "1",
    "OTEL_METRICS_EXPORTER": "otlp",
    "OTEL_LOGS_EXPORTER": "otlp",
    "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
    "OTEL_EXPORTER_OTLP_ENDPOINT": "http://otel-collector.internal.example.com:4317"
  },
  "requiredMinimumVersion": "2.1.295"
}

The hook script sits at a path only administrators can write (a Linux path is shown). A hook that points into the repository can be edited by anyone with commit access. Setting the exporter selectors here, not just the endpoint, keeps a repository from switching a telemetry signal off. The version floor matches the release that added onFailure. Denying ~/.ssh inside the sandbox also keeps sandboxed git from using SSH keys, so decide how pushes happen before you roll it out.

What managed settings can't do

  • A developer with local administrator rights can edit the managed file or profile itself, which is why MDM tools re-apply policy on a schedule. On WSL, /etc/claude-code is user-writable.
  • Managed settings bind Claude Code only. The same developer can call the API from another tool.
  • Their absence weakens other controls: on a machine with no managed settings and no Team or Enterprise login, an installed Claude Code mod can approve a call that a deny rule or a hook blocked.

PreToolUse hooks: block what patterns can't express

A PreToolUse hook runs before each matching tool call and receives the call as JSON on stdin. It can block the call, force a prompt, or stay silent and leave the decision to the normal flow. Handlers can be commands, HTTP endpoints, MCP tools, prompts or agents; command hooks are the usual choice for policy. This is what a hook sees for a Bash call:

PreToolUse input (stdin)
json
{
  "session_id": "abc123",
  "cwd": "/home/dev/payments-service",
  "permission_mode": "auto",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "terraform destroy -auto-approve",
    "description": "Tear down the stack",
    "timeout": 120000,
    "run_in_background": false
  },
  "tool_use_id": "toolu_01ABC123..."
}

For Write, tool_input carries file_path and content; for Edit, file_path, old_string and new_string. MCP tool names arrive as mcp__<server>__<tool>.

Exit codes and decisions

Hook resultEffect on the tool call
Exit 0, no outputNo decision. The call continues through the normal permission flow. Nothing is approved.
Exit 0 with JSONThe fields apply. "deny" blocks the call in every mode, including bypassPermissions. "ask" forces a prompt, even in auto mode. "allow" skips the prompt, but deny and ask rules are still evaluated.
Exit 2Blocks the call. stderr is the reason Claude sees. JSON on stdout can't override the block.
Any other exit code, a crash, a missing script, invalid JSON, a timeoutNon-blocking error: the call goes ahead, unless the hook sets onFailure: "block".

A JSON decision looks like this. When several hooks answer the same call, the most restrictive one wins: deny, then defer (headless runs only), then ask, then allow.

stdout, exit 0
json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "terraform destroy is blocked; open a change request"
  }
}
Exit 1 does not block. It is the conventional Unix failure code, and Claude Code treats it as a non-blocking error unless the hook sets onFailure: "block". A policy hook should exit 2, or print a deny and exit 0.

A Bash guard

.claude/hooks/guard-bash.sh
bash
#!/usr/bin/env bash
# PreToolUse hook for Bash. Exit 2 blocks the call; stderr is the reason Claude sees.
cmd=$(jq -r '.tool_input.command // empty') ||
  { echo "guard-bash: cannot read hook input" >&2; exit 2; }  # fail closed

deny=(
  'rm[[:space:]]+(-[[:alnum:]]+[[:space:]]+)*(-[[:alnum:]]*[rR]|--recursive)'  # rm -rf, rm -f -r, rm --recursive
  'terraform[[:space:]](.*[[:space:]])?-?destroy([[:space:]]|$)'                # destroy, apply -destroy
  'git[[:space:]](.*[[:space:]])?push[[:space:]].*(--force|-f([[:space:]]|$))'  # force push
  '(curl|wget)[^|]*[|][[:space:]]*(sudo[[:space:]]+)?(ba|z)?sh'                  # pipe to shell
)

for pattern in "${deny[@]}"; do
  if grep -Eq -- "$pattern" <<<"$cmd"; then
    echo "Blocked by security policy: matched '$pattern'" >&2
    exit 2
  fi
done

if grep -Eq 'git[[:space:]](.*[[:space:]])?push([[:space:]]|$)' <<<"$cmd"; then
  jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
    permissionDecision: "ask",
    permissionDecisionReason: "Pushes need a human review"}}'
  exit 0
fi

exit 0  # no decision: the normal permission flow applies

It blocks recursive deletes, Terraform destroys, force pushes and downloads piped into a shell, and it turns every other push into a prompt. Because it reads the full command string, it also catches /bin/rm -rf, bash -c 'rm -rf ...' and git -C . push --force, which the rules above miss. It fails closed: if jq is missing or the input isn't JSON, it exits 2. It is still pattern matching, and an encoded or obfuscated command can get past it. That is why the sandbox sits underneath.

A secrets guard for edits

.claude/hooks/no-secrets-in-edits.sh
bash
#!/usr/bin/env bash
# PreToolUse hook for Edit|Write. Blocks edits that add credential-shaped strings.
input=$(cat)
path=$(jq -r '.tool_input.file_path // empty' <<<"$input") &&
body=$(jq -r '.tool_input.content // .tool_input.new_string // empty' <<<"$input") ||
  { echo "no-secrets: cannot read hook input" >&2; exit 2; }  # fail closed

if grep -Eq 'AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----' <<<"$body"; then
  echo "Blocked: this edit to $path adds what looks like a credential" >&2
  exit 2
fi
exit 0

Register the hooks

.claude/settings.json
json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-bash.sh"],
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "bash",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/no-secrets-in-edits.sh"],
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

Setting args runs the hook in exec form: no shell, and ${CLAUDE_PROJECT_DIR} is substituted as a single argument, so paths with spaces are safe. onFailure: "block" (Claude Code v2.1.295 or later) turns a missing script, a crash or a timeout into a block instead of a silent pass. Test a hook from the shell before you rely on it:

terminal
bash
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build/"}}' \
  | bash .claude/hooks/guard-bash.sh; echo "exit=$?"
# stderr: Blocked by security policy: matched 'rm[[:space:]]+...'
# exit=2

Gaps to plan for:

  • Windows: where the PowerShell tool is enabled, Claude routes shell commands through it, so a hook that matches only Bash never fires. Match Bash|PowerShell and handle both.
  • @-mentions: a file the user @-mentions is added to the prompt without a Read tool call, so PreToolUse doesn't see it. Read deny rules do apply there.
  • Repository hooks are code from the repository. They run on the developer's machine, and in claude -p without a trust prompt. For a fleet, ship hooks in managed settings and set allowManagedHooksOnly.

Sandboxing: the OS-level backstop

Rules and hooks inspect the request. The sandbox limits what the command can reach once it runs: an operating-system boundary around Bash, PowerShell and Monitor commands and every process they start, built on Seatbelt on macOS and bubblewrap with socat on Linux and WSL2. Once you deny a path, it is what stops the Python script that opens ~/.aws/credentials on its own.

AccessDefault when enabledChange it with
WritesWorking directory, a per-user temp directory, and directories you addedfilesystem.allowWrite, filesystem.denyWrite
ReadsMost of the machine, including ~/.ssh and ~/.aws/credentialsfilesystem.denyRead, credentials
NetworkOnly through a local proxy; the allowed-domain list starts emptynetwork.allowedDomains, network.deniedDomains
EnvironmentInherited from Claude Code, secrets includedcredentials.envVars

Three defaults matter. The sandbox is off until you run /sandbox or set sandbox.enabled. If it can't start, Claude Code runs commands unsandboxed unless failIfUnavailable is true. And a command that fails inside the sandbox can be retried outside it unless allowUnsandboxedCommands is false, which the /sandbox menu calls strict mode. There is no built-in credential deny list: add sandbox.credentials entries yourself, as in the baseline above.

Limits to keep in mind:

  • It covers shell commands only. Claude's Read, Edit, Write and WebFetch tools follow permission rules instead, and hooks and local MCP servers run outside it with the developer's full access, as do commands that match excludedCommands. To put everything behind one boundary, run Claude Code itself in a container or VM.
  • Native Windows runs commands unsandboxed. With failIfUnavailable, those machines exit at startup, so target that policy at macOS, Linux and WSL2.
  • The proxy filters by hostname and doesn't inspect TLS by default. An allowed broad domain such as github.com is a path for data to leave.
  • Allowing a Unix socket such as docker.sock can hand a command a way around the sandbox.

MCP servers: decide what loads

Each MCP server adds tools that run with whatever tokens you give it. Anthropic reviews connectors for its directory but, per its security page, doesn't security-audit or manage any MCP server, and by default anyone can connect any server. Project .mcp.json servers ask before connecting in interactive sessions, but servers added in other scopes, by plugins or as claude.ai connectors don't appear in that file. The managed MCP controls:

GoalConfiguration
A fixed set of servers, nothing elsemanaged-mcp.json, in the same format as .mcp.json, in the managed settings directory. An empty server map disables MCP. It is a file, so server-managed settings can't deliver it.
An approved catalogallowedMcpServers together with allowManagedMcpServersOnly: true
Block specific serversdeniedMcpServers, which merges from every scope and always wins
Hand everyone servers without locking MCPmanagedMcpServers
managed-settings.json (MCP keys)
json
{
  "allowManagedMcpServersOnly": true,
  "allowedMcpServers": [
    { "serverUrl": "https://mcp.internal.example.com/*" },
    { "serverCommand": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/srv/docs"] }
  ],
  "deniedMcpServers": [
    { "serverUrl": "https://mcp.unvetted-vendor.example/*" }
  ]
}

Match on what actually runs: serverUrl, with * wildcards, for remote servers, and serverCommand, the exact command array, for stdio servers. A serverName entry is the label a user picks when adding a server, and anyone can call a server "github", so a name is not a security control. An unset allowlist allows everything; an empty one allows nothing. Tool-level rules still apply on top, so you can deny one dangerous tool on an approved server. For what to check before approving a server, see MCP security risks.

Logging and audit

Claude Code exports metrics and events over OpenTelemetry. Export is off by default; turn it on for the fleet with the managed env block in the baseline above. Claude Code ignores the exporter variables in a repository's settings files, so a repository can't turn telemetry on, redirect it or capture content.

EventWhat it tells you
claude_code.tool_decisionEach accept or reject, with its source: config, hook, user_permanent, user_temporary, user_reject or user_abort. Its tool_use_id matches the one your hooks receive.
claude_code.tool_resultWhether the tool succeeded, how long it ran, and where its permission decision came from.
claude_code.safety_eventWhich rule refused a call or forced a prompt, with a category such as managed_policy or permission_rule and the settings source of the rule.

Content is redacted by default. Bash commands, MCP server and tool names and tool input appear only with OTEL_LOG_TOOL_DETAILS=1; prompt text only with OTEL_LOG_USER_PROMPTS=1. Agree with your privacy team which you need. Command text is usually the one security wants.

A ConfigChange hook adds a tamper signal: it fires when user, project or local settings change during a session and can log or block the change. Changes to managed settings files can be logged but not blocked, and server-managed or MDM changes don't fire it.

This is telemetry: a record of what each client decided. It is not an approval record of who reviewed a specific risky action, against which policy, with evidence you can hand an auditor. That gap is covered in the last section.

Rollout checklist

  1. Inventory where Claude Code runs. List laptops, CI runners and cloud sessions, and the credentials each can reach: cloud CLIs, kubeconfig contexts, git and registry tokens. That list is your blast radius.
  2. Choose how managed settings reach each device. Use server-managed settings, an MDM or OS policy, or a managed-settings.json file. Sources don't merge by default, so give each machine one source and confirm it with /status.
  3. Set the mode floor. Set permissions.disableBypassPermissionsMode to "disable", decide whether auto mode stays available, and set requiredMinimumVersion so older clients without the controls you rely on can't start.
  4. Write deny and ask rules. Deny reads of .env files, secrets directories and credential paths, and deny destructive commands such as terraform destroy. Put pushes, deploys and infrastructure changes behind ask rules.
  5. Decide who can add rules. Keep rule lists merged so developers can add allows for their own workflow, or set allowManagedPermissionRulesOnly for a locked fleet and accept more prompts.
  6. Deploy PreToolUse hooks for what patterns miss. Ship hook scripts to a path only administrators can write, register them in managed settings with onFailure set to "block", and set allowManagedHooksOnly so repository and user hooks don't run.
  7. Turn on the sandbox. On macOS, Linux and WSL2, enable the sandbox with failIfUnavailable and allowUnsandboxedCommands set to false, deny credential files and secret environment variables, and keep allowedDomains narrow.
  8. Lock down MCP. Deploy managed-mcp.json for a fixed set, or allowedMcpServers with allowManagedMcpServersOnly. Match servers by serverUrl or serverCommand, never by serverName.
  9. Export telemetry. Set the OpenTelemetry variables, including the exporter selectors, in the managed env block, and alert on rejected tool decisions and safety events.
  10. Harden headless runs. Run claude -p in dontAsk mode with an explicit --allowedTools list, and pass --setting-sources user for repositories you don't control.
  11. Test the policy before rollout. In a canary repository, attempt each blocked action, check /permissions, /hooks and /status, run claude doctor, and confirm the events reach your collector.

Where Cordum Edge fits

The native controls answer one question per call: does this match a rule? Security teams usually need two more answers: who approved this specific risky action, and what evidence shows it. An ask rule prompts the developer driving the session, who is not a separate reviewer, and telemetry records decisions, not approvals. Cordum Edge adds that layer through the same PreToolUse hook mechanism described above.

  • Policy decisions. Each governed action is classified by capability (such as exec.shell or file.write) and risk tags (such as secrets or destructive), then evaluated by the Cordum Safety Kernel against a policy snapshot. The answer is ALLOW, DENY, REQUIRE_APPROVAL, THROTTLE or CONSTRAIN, which allows the call with modified input.
  • Approvals. REQUIRE_APPROVAL reaches Claude as a deny that carries an approval_ref and retry guidance. A reviewer approves or rejects in Cordum, and the person who requested the action can't approve it. The retry goes through only if it matches the approved action hash, input hash and policy snapshot, and each approval is consumed once. Destructive retries also need the resolved approval in the audit chain.
  • Redacted evidence. Edge events don't store raw prompts, tool payloads, transcripts, command output or keys. They store bounded descriptors such as shell:destructive/filesystem_delete, hashes and artifact pointers. A session exports as an edge.export.v1 bundle through POST /api/v1/edge/sessions/{session_id}/export.

Policies are YAML. This excerpt is from the production template in the Edge example pack:

policy.production.fragment.yaml (excerpt)
yaml
version: edge-policy-production-template-v0
default_decision: deny

rules:
  - id: claude-code.deny-secret-reads
    match:
      topics:
        - job.edge.action
      risk_tags:
        - secrets
      labels:
        "path.class": secret
    decision: deny
    reason: "Secret-like file access is denied; production deployments should use managed secret injection instead of agent reads."

  - id: claude-code.deny-destructive-shell
    match:
      topics:
        - job.edge.action
      capabilities:
        - exec.shell
      risk_tags:
        - destructive
      labels:
        "command.class": destructive
    decision: deny
    reason: "Destructive recursive shell operations are denied by the production-oriented template."

  - id: claude-code.require-approval-for-vcs-push
    match:
      topics:
        - job.edge.action
      capabilities:
        - exec.shell
      risk_tags:
        - git
      labels:
        "command.family": git_push
    decision: require_approval
    reason: "Publishing repository state with git push requires approval."

The production template denies by default. The pack's demo policy defaults to allow so the demo can show every verdict; it isn't a security posture, so start from the production template.

How it's wired

Claude Code runs cordum-hook as a command hook. The hook redacts and maps the payload and calls only a local cordum-agentd, which authenticates the hook, calls the Cordum Gateway's Edge evaluate endpoint and records session evidence. The Safety Kernel owns the decision, so Edge needs a running Cordum control plane (Gateway and Safety Kernel) behind the local pieces. The Claude Code page has the quickstart, and the Edge overview covers the rest of the product.

Limits to plan around

  • The wrapper is not an enforcement boundary. cordumctl edge claude is a developer and demo launcher: it writes temporary hook settings for one session, and running plain claude skips it. Fleet enforcement means deploying the hook through managed settings with allowManagedHooksOnly, plus endpoint controls, trusted binaries and service or keychain bootstrap.
  • Install is source-only. You build cordumctl, cordum-hook and cordum-agentd from the cordum repository with make build SERVICE=<name>. There are no packaged release binaries.
  • Check the fail mode. observe allows degraded actions. The wrapper turns on fail-closed for enforce and enterprise-strict, but a hand-wired hook defaults to fail-open, and cordumctl edge doctor warns when a session is fail-open. At the Claude Code layer, the wrapper's generated hook entries set a 5-second timeout and no onFailure, so a hook that can't start or runs past the timeout doesn't block. Set onFailure: "block" on the PreToolUse entry you deploy.

Frequently Asked Questions

Can Claude Code run commands without asking?
Yes, depending on the permission mode. In auto mode, the starting mode for interactive sessions in recent versions, a classifier approves actions instead of you. acceptEdits approves file edits and common filesystem commands, and bypassPermissions approves everything. Read-only commands such as ls and git status run without a prompt in every mode. Deny rules and PreToolUse hook denials still apply in every mode, and managed settings can remove bypass and auto mode.
How do I stop Claude Code from running rm -rf or terraform destroy?
Layer three controls. Add deny rules such as Bash(rm -rf *) and Bash(terraform destroy *) in managed settings. Add a PreToolUse hook that inspects the full command string and exits 2 on recursive deletes and destroys, because rules match the command as written and miss variants such as /bin/rm. Then enable the sandbox so shell commands can write only to the working directory and a temp directory. Claude Code also never auto-approves rm against critical paths such as your home directory, even in bypassPermissions mode, but its docs say that check can miss commands.
Where do Claude Code managed settings go?
Use server-managed settings from the claude.ai admin console, an MDM or OS policy (the com.anthropic.claudecode domain on macOS, or a Settings value under HKLM\SOFTWARE\Policies\ClaudeCode on Windows), or a managed-settings.json file in /Library/Application Support/ClaudeCode/ on macOS, /etc/claude-code/ on Linux and WSL, or C:\Program Files\ClaudeCode\ on Windows. By default only the highest-ranked source that sets a policy key applies. Run /status to see which source is in force.
Can developers override Claude Code managed settings?
Not from their own settings files, project files or the --settings flag: managed values sit above all of them. A developer with local administrator rights can edit the managed file or profile itself, which is why MDM tools re-apply policy on a schedule. Managed settings also bind only Claude Code, not other tools that call the API.
Does the Claude Code sandbox work on Windows?
Not on native Windows, where Claude Code runs commands unsandboxed. The sandbox runs on macOS, Linux and WSL2. If managed settings set sandbox.failIfUnavailable, native Windows machines exit at startup, so scope that policy to macOS, Linux and WSL2 hosts.
How do I restrict which MCP servers Claude Code can use?
Deploy managed-mcp.json for exclusive control over a fixed set of servers, or set allowedMcpServers together with allowManagedMcpServersOnly: true in managed settings. Match entries by serverUrl or serverCommand. A serverName entry is a label any user can reuse, so it is not a security control. deniedMcpServers always applies on top.
How do I audit what Claude Code did?
Turn on OpenTelemetry export in managed settings. The claude_code.tool_decision and claude_code.tool_result events record each decision and its source, and claude_code.safety_event names the rule involved. Command text and tool input are included only with OTEL_LOG_TOOL_DETAILS=1, and prompt text only with OTEL_LOG_USER_PROMPTS=1. For approval records bound to a specific action, you need a policy layer outside Claude Code.

Related reading

Add approvals and evidence to Claude Code

Keep the native controls as your floor. Cordum Edge adds policy decisions, separate-reviewer approvals and redacted evidence through the PreToolUse hook. See how it handles Claude Code, including what the local wrapper does and doesn't enforce.