The controls at a glance
| Control | What it enforces | Where it stops | Lock it with |
|---|---|---|---|
| Permission modes | What runs without a prompt | Auto mode is a classifier and does not guarantee safety; bypassPermissions approves everything | permissions.disableBypassPermissionsMode, permissions.disableAutoMode |
| Permission rules | Allow, ask or deny per tool and argument | Bash rules match command text; file rules miss subprocesses that open files themselves | Managed permissions, allowManagedPermissionRulesOnly |
| PreToolUse hooks | Your code inspects each call before it runs | A crash, missing script or timeout lets the call through unless onFailure is block | Managed hooks, allowManagedHooksOnly |
| Sandbox | OS-level file and network limits on shell commands and their child processes | Not file tools, MCP servers or hooks; not native Windows | Managed sandbox with failIfUnavailable and allowUnsandboxedCommands: false |
| MCP controls | Which MCP servers can load | serverName entries are labels, not controls | managed-mcp.json, or allowedMcpServers with allowManagedMcpServersOnly |
| Telemetry | Tool decisions and results as OpenTelemetry events | Off by default; command text only with OTEL_LOG_TOOL_DETAILS=1 | Managed 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/credentialsor SSH keys into context, or sending them out withcurlor 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.
| Mode | Runs without asking | Use it for |
|---|---|---|
default (Manual) | Reads only | Sensitive work, reviewing every action |
acceptEdits | Reads, file edits, and common filesystem commands such as mkdir, touch, mv and cp | Iterating on code you review |
plan | Reads and exploration; edits stay blocked until you approve the plan | Exploring before changing |
auto | Everything, with a classifier model reviewing actions in the background | Long tasks, fewer prompts |
dontAsk | Reads in the working directories and pre-approved tools; anything that would prompt is denied | CI and scripts |
bypassPermissions | Everything | Isolated 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 andterraform destroy. Entering auto mode also drops broad allow rules such asBash(*)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 inbypassPermissions, since everything is already allowed. - Some writes are never auto-approved. Writes to protected paths such as
.git,.claude,.vscode, shell rc files,.npmrcand.mcp.jsonare prompted, sent to the classifier or denied, depending on the mode, except inbypassPermissions..githubis 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. AdefaultModeofautoorbypassPermissionsin a repository's.claude/settings.jsonor.claude/settings.local.jsondoesn'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.
{
"$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 astimeoutandnohup. Still,Bash(rm *)doesn't stop/bin/rm -rf build/orbash -c 'rm -rf build/', andBash(git push *)doesn't stopgit -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 asnpxordocker execapproves whatever command follows it. - Read and Edit rules use gitignore-style paths.
./pathis relative to the working directory,~/pathto your home directory, and//pathis absolute. AReaddeny also blocks edits to the same path. Write rules useEdit(...);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
catandsed, and redirection targets. They don't cover a Python or Node script that opens the file itself, or agrep -rthat never names it. That is the sandbox's job. .claudeignoredoes nothing. Move its entries into Read deny rules.- MCP tools are named by server.
mcp__githubcovers every tool from thegithubserver andmcp__github__create_issuecovers 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:
- Managed settings, deployed by your organization. Nothing below overrides them.
- Command line:
--settings <file-or-json>, for one session. - Project local:
.claude/settings.local.json, a developer's personal settings for one repository. - Shared project:
.claude/settings.json, checked into the repository. - User:
~/.claude/settings.json.
Two details matter for security:
- Rule lists merge.
allow,askanddenycombine 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 setallowManagedPermissionRulesOnly, which ignores rules from every other source. - A repository can restrict before you trust it, but not grant. Project
allowrules andadditionalDirectoriesapply 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:
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:
| Mechanism | Where | When Claude Code reads it |
|---|---|---|
| Server-managed | The claude.ai admin console, or a Claude apps gateway | At startup, then polled hourly |
| MDM or OS policy | macOS: a profile for the com.anthropic.claudecode domain. Windows: a Settings value holding the JSON under HKLM\SOFTWARE\Policies\ClaudeCode | At startup, then every 30 minutes |
| File | managed-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-ins | At 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--settingsfiles 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 asonFailure.sandbox.network.allowManagedDomainsOnly: developers can't add sandbox network domains.
A baseline that combines the controls in this guide:
{
"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-codeis 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:
{
"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 result | Effect on the tool call |
|---|---|
| Exit 0, no output | No decision. The call continues through the normal permission flow. Nothing is approved. |
| Exit 0 with JSON | The 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 2 | Blocks 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 timeout | Non-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.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "terraform destroy is blocked; open a change request"
}
}onFailure: "block". A policy hook should exit 2, or print a deny and exit 0.A Bash guard
#!/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 appliesIt 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
#!/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 0Register the hooks
{
"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:
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=2Gaps to plan for:
- Windows: where the PowerShell tool is enabled, Claude routes shell commands through it, so a hook that matches only
Bashnever fires. MatchBash|PowerShelland 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 -pwithout a trust prompt. For a fleet, ship hooks in managed settings and setallowManagedHooksOnly.
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.
| Access | Default when enabled | Change it with |
|---|---|---|
| Writes | Working directory, a per-user temp directory, and directories you added | filesystem.allowWrite, filesystem.denyWrite |
| Reads | Most of the machine, including ~/.ssh and ~/.aws/credentials | filesystem.denyRead, credentials |
| Network | Only through a local proxy; the allowed-domain list starts empty | network.allowedDomains, network.deniedDomains |
| Environment | Inherited from Claude Code, secrets included | credentials.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.comis a path for data to leave. - Allowing a Unix socket such as
docker.sockcan 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:
| Goal | Configuration |
|---|---|
| A fixed set of servers, nothing else | managed-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 catalog | allowedMcpServers together with allowManagedMcpServersOnly: true |
| Block specific servers | deniedMcpServers, which merges from every scope and always wins |
| Hand everyone servers without locking MCP | managedMcpServers |
{
"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.
| Event | What it tells you |
|---|---|
claude_code.tool_decision | Each 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_result | Whether the tool succeeded, how long it ran, and where its permission decision came from. |
claude_code.safety_event | Which 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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Lock down MCP. Deploy managed-mcp.json for a fixed set, or allowedMcpServers with allowManagedMcpServersOnly. Match servers by serverUrl or serverCommand, never by serverName.
- Export telemetry. Set the OpenTelemetry variables, including the exporter selectors, in the managed env block, and alert on rejected tool decisions and safety events.
- 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.
- 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.shellorfile.write) and risk tags (such assecretsordestructive), 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_refand 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 anedge.export.v1bundle throughPOST /api/v1/edge/sessions/{session_id}/export.
Policies are YAML. This excerpt is from the production template in the Edge example pack:
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 claudeis a developer and demo launcher: it writes temporary hook settings for one session, and running plainclaudeskips it. Fleet enforcement means deploying the hook through managed settings withallowManagedHooksOnly, plus endpoint controls, trusted binaries and service or keychain bootstrap. - Install is source-only. You build
cordumctl,cordum-hookandcordum-agentdfrom the cordum repository withmake build SERVICE=<name>. There are no packaged release binaries. - Check the fail mode.
observeallows degraded actions. The wrapper turns on fail-closed forenforceandenterprise-strict, but a hand-wired hook defaults to fail-open, andcordumctl edge doctorwarns when a session is fail-open. At the Claude Code layer, the wrapper's generated hook entries set a 5-second timeout and noonFailure, so a hook that can't start or runs past the timeout doesn't block. SetonFailure: "block"on the PreToolUse entry you deploy.