The Practical Guide to Locking Down Claude Code

We use Claude Code across dozens of projects at Another Cup of Coffee. It's genuinely changed how we work but these tools run as your user account with access to your entire home directory. Trusting them with full autonomy is a mistake. This guide covers the layered configuration I built to lock mine down.

I wrote recently about what's standing between AI coding tools and your SSH keys. That piece covered the threats, the security layers, and a checklist of things you should be doing. This is the practical follow-up where I walk through the security configuration I've built for Claude Code, and include examples that might be useful for your own setup. It's Claude Code-specific, but the principles apply to any AI tool that runs commands as your user account.

You can read the companion article for a background on why these protections matter, but if you want to set them up, this guide will help.

A word of caution. This is experimental work. The security tooling for AI coding agents is still immature and the configurations below are what's working for me right now, not a finished product. Test everything in your own environment before relying on it. If you find issues or improvements, I'd genuinely like to hear about them.

Table of Contents

Key terms for Claude Code security

Let's start by defining some key terms so we're clear about what we'll be referencing.

If you're new to Claude Code, a few terms come up repeatedly and they're worth defining upfront because some of them mean different things in different contexts.

Permissions (Claude Code level) are rules in ~/.claude/settings.json that control which tools the AI agent can use and which files it can access. These are enforced by the Claude Code application itself and to be clear, we're not talking about Unix file permissions. OS file permissions are enforced by the operating system kernel. Both matter, and they protect different things at different layers.

The sandbox is Claude Code's built-in OS-level isolation but it relies on operating system utilities, like bubblewrap on Linux and Seatbelt on macOS. When the sandbox is on, Bash commands run inside a restricted kernel namespace with limited filesystem and network access. The sandbox wraps Bash commands only; it doesn't affect Claude Code's own Write or Edit tools.

Hooks are scripts that Claude Code runs automatically before or after certain actions. A PreToolUse hook runs before a tool call executes, and it can block the call by returning a deny decision. This guide uses a PreToolUse hook on the Bash tool to inspect commands before they run. Hooks are your code, running on your machine, triggered by Claude Code's event system.

OS-level permissions are the standard Unix file permissions (owner, group, other) enforced by the Linux or macOS kernel. Claude Code runs as your user account, so it inherits whatever access your account has. This means it can read your SSH keys, your browser data, your shell config, and anything else your account owns, unless something else (sandbox, deny rules, hooks) blocks it first.

The OS layer you already have

It's important to understand what the operating system already gives you before configuring anything in Claude Code. Claude Code runs as your user account (whichever account you use to run it), which means standard Unix permissions are the first line of defence.

I run Claude Code under a dedicated user account, separate from my day-to-day login. This gives you real kernel-enforced isolation: the agent can't read your personal documents, SSH keys, or application configurations because those files belong to a different user. Unix permissions won't let it cross accounts and it's the strongest single thing you can do. The rest of this guide applies whether you do this or not. If you run Claude Code as your own user, the deny rules and hooks described below are doing more of the heavy lifting.

What this protects: System files owned by root are already protected from modification by your user account. Your agent can't write to /etc/passwd or replace /usr/bin/ssh because your account doesn't have write permission to those locations. Some system files are also protected from reading: /etc/shadow on Linux (which stores password hashes) is typically mode 000 or 640, so a non-root process can't read it at all. But most system files (like /etc/passwd, everything in /usr/bin/) are world-readable, just not world-writable. This is the kernel enforcing access control, and no amount of prompt injection or agent reasoning can bypass it. (macOS doesn't use /etc/shadow. Instead it stores credentials in a separate system database that's similarly protected.)

What this doesn't protect: Everything your user account owns. That includes ~/.ssh/ (your private keys), ~/.gnupg/ (your GPG keyring), ~/.aws/credentials, ~/.bashrc, your browser profile, your email, and everything in your home directory. From the kernel's perspective, Claude Code reading your SSH private key is identical to you reading it yourself, because it's running as you.

If you're running Claude Code as your own user, you can tighten things on Linux by setting restrictive permissions on sensitive directories: chmod 700 ~/.ssh ~/.gnupg ~/.password-store ensures only your user can access them. Claude Code still can (it's running as you, remember) but it limits exposure from other accounts on the machine.

On macOS, the same principles apply, with the addition of TCC (Transparency, Consent, and Control). macOS protects certain directories (Desktop, Documents, Downloads) behind a consent system. The first time a process tries to access one of these, macOS shows a prompt, but it's attributed to the terminal emulator (Terminal.app or iTerm2), not to the child process. Once you grant your terminal access to a protected folder (or grant it Full Disk Access), every process it spawns, including Claude Code and its scripts, inherits that access silently. If your terminal already has Full Disk Access, TCC won't provide any additional protection. If it doesn't, consider whether it needs it because granting FDA to your terminal is the same as granting it to every CLI tool you run.

The rest of this guide builds on top of this OS layer. The Claude Code permissions, sandbox, and hooks are all additional controls that restrict what the agent can do within the access your user account already has.

What you'll need

This guide assumes you're running Claude Code on Linux or macOS, and the deny permissions and hook registration work the same on both platforms. The hook script needs:

  • Python 3 (the hook shells out to Python for JSON parsing). On Linux, this is almost certainly already installed. On macOS, install via Xcode Command Line Tools (xcode-select --install) or Homebrew.
  • bash (the hook wrapper uses bash, not sh). It ships with both Linux and macOS.
  • syslog access for audit logging. On Linux, logs go to the system journal and you can read them with journalctl. On macOS, the Python syslog module writes to the unified log, which you can check with log show --predicate 'process == "python3"' --last 5m.

If you want to use the sandbox instead of, or alongside, the hook approach, you'll also need:

  • Linux: bubblewrap2 and socat. On Arch: pacman -S bubblewrap socat. On Debian/Ubuntu: apt install bubblewrap socat. Your kernel needs unprivileged user namespaces enabled, which most modern distros have by default.
  • macOS: Seatbelt is built into the OS. No additional packages needed. socat is not required on macOS because Claude Code uses Seatbelt's native network filtering.

The sandbox path

Before I get into the hook-based approach, I should be clear that if your workflow doesn't involve SSH, rsync, or deleting files outside your project directory, the sandbox is the better option. It's a single configuration block that's kernel-enforced, and it handles both filesystem and network isolation.

This is an example sandbox configuration for ~/.claude/settings.json. Adjust the paths and domains to match your setup:

{
  "sandbox": {
    "enabled": true,
    "autoAllowBashIfSandboxed": true,
    "allowUnsandboxedCommands": false,
    "filesystem": {
      "allowWrite": ["~/Projects", "//tmp"],
      "denyWrite": [
        "//etc", "//usr", "//boot",
        "~/.bashrc", "~/.zshrc", "~/.bash_profile", "~/.profile",
        "~/.claude/settings.json", "~/.msmtprc", "~/.mbsyncrc",
        "~/.gnupg", "~/.ssh"
      ],
      "denyRead": [
        "~/.ssh/id_*", "~/.ssh/*_rsa",
        "~/.gnupg/private-keys-v1.d",
        "~/.password-store", "~/.aws/credentials", "~/.kube/config",
        "~/.bash_history", "~/.zsh_history"
      ]
    },
    "network": {
      "allowedDomains": [
        "github.com", "raw.githubusercontent.com",
        "objects.githubusercontent.com",
        "registry.npmjs.org", "pypi.org", "files.pythonhosted.org"
      ]
    }
  }
}

Here are the key settings.

enabled: true turns on bubblewrap/Seatbelt isolation. autoAllowBashIfSandboxed: true means Bash commands inside the sandbox run without asking for approval. The sandbox is doing the enforcement, so the approval prompt is redundant. allowUnsandboxedCommands: false disables the escape hatch that lets agents retry failed commands without sandboxing. That last one matters because a command failing inside the sandbox means the sandbox is doing its job. The agent shouldn't be able to turn it off.

denyRead on SSH keys does not break SSH. ssh-agent handles authentication through a Unix socket, not by reading key files directly. Your agent can still ssh into things if the sandbox allows network access to that host. It just can't read the private key file itself.

Add project-specific domains to allowedDomains as you need them. For example, if a project needs to reach an API or a staging server over HTTPS, you should add that domain. The network allowlist only affects sandboxed Bash commands, and WebFetch and WebSearch are controlled separately through the permissions layer.

You can still layer on extra deny permissions and hooks on top of the sandbox because they don't conflict. In fact, the extra layers catch anything the sandbox doesn't cover, like a malicious Write tool call, which the sandbox doesn't see because it only wraps Bash. Personally, I'd recommend the deny permissions from Layer 1 below even if you're using the sandbox.

The rest of this article is for when the sandbox doesn't fit. If you need SSH, rsync, raw TCP, or file deletion outside your project directory, read on because the sandbox is going to make things unworkable for you.

Why not the sandbox for AI agent workflows?

A sandbox is a good choice for a lot of workflows like editing code from someone's git repo. You should use it if it works for yours because it's a simpler setup than what I'm about to describe.

A lot of my work involves running AI agents that manage remote servers. This requires logging in via SSH, rsync-ing files between machines, and deleting temporary artifacts. The sandbox is too restrictive because it blocks everything that makes these agents useful. For example: - TCP networking is completely blocked so SSH doesn't work; - file deletion is blocked even on paths you've explicitly whitelisted so files build up; - and there's no way to allow SSH to one host while blocking another.

I ended up having to replace the sandbox with two layers that offer the necessary protections.

The design principle: layered Claude Code security

The approach goes like this:

Tool Protection Mechanism
Write / Edit Deny permissions in settings.json Built-in, zero code (see caveat below)
Read Allow-list scoped to ~/Projects/** Built-in scoping
Bash PreToolUse hook (substring match) Heuristic, fail-closed, honest about its limits

These are all Claude Code-level controls, sitting on top of the OS-level protections described earlier. The OS layer, like Unix file permissions, protects system files from modification by your user account. The Claude Code layers protect everything your user account can access but the agent shouldn't.

The idea is that you don't use hooks where permissions work. Claude Code's deny permissions are enforced by the application itself. Hooks, on the other hand, are for Bash because the permission system can check the tool name but can't inspect what's inside the command.

Layer 1: deny permissions in settings.json

Claude Code has a permission system1 with allow, deny, and ask rules. The important thing about deny rules is that they're evaluated first and nothing can get around them. This means that if you deny something at the global level (~/.claude/settings.json), no project-level configuration can override it. This is enforced by Claude Code itself, not by the operating system.

See below for an example deny block. Make sure you replace /home/youruser with your actual home directory, and adjust the protected paths to match what's on your system:

{
  "permissions": {
    "deny": [
      "Bash(msmtp *)",
      "Bash(sendmail *)",
      "Bash(mail *)",
      "Write(//etc/**)",
      "Write(//usr/**)",
      "Write(//boot/**)",
      "Write(//sbin/**)",
      "Write(//lib/**)",
      "Write(//home/youruser/.bashrc)",
      "Write(//home/youruser/.zshrc)",
      "Write(//home/youruser/.profile)",
      "Write(//home/youruser/.bash_profile)",
      "Write(//home/youruser/.msmtprc)",
      "Write(//home/youruser/.mbsyncrc)",
      "Write(//home/youruser/.gnupg/**)",
      "Write(//home/youruser/.ssh/**)",
      "Write(//home/youruser/.claude/settings.json)",
      "Edit(//etc/**)",
      "Edit(//usr/**)",
      "Edit(//boot/**)",
      "Edit(//sbin/**)",
      "Edit(//lib/**)",
      "Edit(//home/youruser/.bashrc)",
      "Edit(//home/youruser/.zshrc)",
      "Edit(//home/youruser/.profile)",
      "Edit(//home/youruser/.bash_profile)",
      "Edit(//home/youruser/.msmtprc)",
      "Edit(//home/youruser/.mbsyncrc)",
      "Edit(//home/youruser/.gnupg/**)",
      "Edit(//home/youruser/.ssh/**)",
      "Edit(//home/youruser/.claude/settings.json)"
    ]
  }
}

You'll notice every Write and Edit rule is duplicated. This is important because they're actually different tools, so an agent could use either to modify a file. Both need blocking.

What's protected, and why:

  • System directories (/etc, /usr, /boot, /sbin, /lib) cover OS configuration, installed packages, bootloader, and system binaries. An agent shouldn't write to any of these, unless it's one specifically deployed for administering the environment.
  • Shell configs (.bashrc, .zshrc, .profile, .bash_profile) are needed because if an agent writes something malicious here, it persists after the session ends.
  • SSH and GPG directories (.ssh, .gnupg) protect your keys and trust chain. SSH still works fine for the agent via ssh-agent as it doesn't need direct file access.
  • Mail configs (.msmtprc, .mbsyncrc) because these contain SMTP credentials and mail server access.
  • Claude Code's own settings (.claude/settings.json) because if an agent can modify its own permission rules, a prompt injection could disable every other protection in this list.

A caveat on reliability. In theory, deny permissions are the strongest layer but there have been bugs where deny rules for Read, Write, and Edit were silently ignored.3 This is exactly why this guide layers hooks on top of permissions. The deny rules should work, but if they don't, the hook hopefully catches it. While you can't anticipate every problem, a defence in depth mindset protects you from most things.

The allow list is the other side of this setup. You can scope Read access to a specific directory (like ~/Projects/**) so agents can't browse the rest of your home directory, and pre-approve common read-only Bash commands (e.g. ls, git, grep, cat, diff) to reduce approval fatigue. I say reduce because approval fatigue is still a thing, unfortunately. The practical trade-off is to auto-allow the safe stuff so you're not clicking "yes" a hundred times a day...maybe just 99. Experienced Claude users know what I mean.

Here's an example allow block showing the pattern:

{
  "permissions": {
    "allow": [
      "Read(//home/youruser/Projects/**)",
      "Bash(ls:*)",
      "Bash(git -C /:*)",
      "Bash(grep:*)",
      "Bash(cat:*)",
      "Bash(diff:*)",
      "Bash(cp:*)",
      "Bash(mkdir:*)",
      "WebSearch",
      "WebFetch"
    ]
  }
}

You'll notice cat and cp are auto-approved here even though they could technically read or copy sensitive files.* This is where the layers work together: the hook script in Layer 2 catches any cat or cp command that references a protected path like ~/.ssh/, even if the permission system has already auto-approved it. The allow rule lets the command skip the approval prompt, but the hook still inspects it.

The Bash(git -C /:*) pattern is worth explaining because I came across commit approval problems early on when using git with my agents. The -C flag tells git to operate on a specific directory, so git -C /home/youruser/Projects/myproject status works without the agent needing to cd anywhere. Pre-approving this pattern means git commands run without prompting the user, but only when they include an explicit path. Bash(git:*) without the -C would also match bare git commands, making it run on whatever directory the agent happens to be in. This means git could act on a repo you didn't intend so the -C pattern forces it to be explicit.

Keep credentials out of context

There's also a broader principle we need to keep in mind. Credentials should almost never appear in your agent's conversation context because if a secret shows up in a tool output or a file the agent reads, it's in the context window. From there it could end up somewhere else, like a log or a commit message.

The solution for SSH is straightforward. Just use ssh-agent. The agent runs ssh myserver and ssh-agent handles authentication through a Unix socket. The private key never enters Claude Code's context. This makes your deny rules on ~/.ssh/ the backup, while ssh-agent is the primary protection.

For database credentials, you can store them server-side in standard locations. For example, MySQL/MariaDB reads from ~/.my.cnf on the server and PostgreSQL reads from ~/.pgpass. The agent runs ssh myserver "mysql -e 'SHOW DATABASES'" and the credentials are loaded by the server rather than by the agent.

For API keys and cloud credentials, it helps to have environment variables loaded from a protected file, like pass with GPG, or a .env file in a directory the agent can't read. The agent can then call the API through a wrapper script that sources the credentials at runtime.

In essence, wherever possible, you keep credentials somewhere outside of the agent's reach and instead, have the agent invoke a command that uses credentials indirectly. If you want to understand why this matters beyond convenience, the OpenClaw incident is a good case study in what happens when credentials end up where they shouldn't.

The sudo convention

There's one more convention-level control I can cover. AI coding tools can't use sudo interactively (unless you've configured passwordless sudo) because there's no terminal for password entry. That's a great natural protection, but an agent will indeed try, and keep trying. If you're on Linux, it will eventually trigger pam_faillock, and the repeated authentication failures end up locking you out of sudo until the timeout expires. I learned this the hard way when an agent silently got into a retry loop, and eventually locked me out of my own machine at a very inconvenient time.

The fix is an instruction in each agent's configuration file. Something like: "Never run sudo via the Bash tool. Instead, give the user the command to run themselves." Most experienced AI agent users will know "Never run..." is never really never. But that's the best you can do for now.

It's not so bad on macOS because repeated failed sudo attempts won't lock the account unless you've configured it to do so. However, an agent will waste time retrying and end up flooding the security log.

Layer 2: the Bash guard hook

This hook is a deterrent, not a security boundary. It catches common commands that reference protected paths but it can be bypassed by an agent that constructs paths indirectly. It's one layer in a defence-in-depth setup, not a standalone protection.

Deny permissions can block Write(//etc/**), but they can't block Bash(cat /etc/shadow). The permission system checks which tool is being called and (for Write/Edit) which file path is targeted. It doesn't inspect the contents of a Bash command though, so that's why we need the hook script.

Here's an example:

#!/usr/bin/env bash
# PreToolUse hook: Guard sensitive system paths and secrets from Bash commands.
# Write/Edit protection is handled by deny permissions in ~/.claude/settings.json.
#
# Input: JSON on stdin with .tool_input.command field
# Output: JSON with "deny" decision, or empty output to allow
#
# Approach: check if the command string contains any protected path.
# This is intentionally simple — a substring match is more reliable than
# trying to parse shell syntax. False positives are rare in practice
# (legitimate commands rarely reference system paths or secret files).
#
# Limitations:
#   - Cannot detect paths constructed at runtime via variable expansion
#   - Cannot detect symlinks pointing to protected paths
#   - Will false-positive if a protected path appears as a string literal

set -euo pipefail  # see known limitations

if ! command -v python3 &>/dev/null; then
  echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"python3 not found — blocking as precaution"}}'
  exit 0
fi

HOOK_INPUT=$(head -c 1048576)
if [[ ${#HOOK_INPUT} -ge 1048576 ]]; then
  echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Input too large — blocking as precaution"}}'
  exit 0
fi
export HOOK_INPUT

exec python3 - <<'PYTHON_EOF'
import json, sys, os, re, syslog, signal

signal.signal(signal.SIGALRM, lambda *_: deny("Command analysis timeout — blocking as precaution."))
signal.alarm(5)

HOME = os.path.expanduser("~")
command = ""

def deny(reason):
    try:
        syslog.syslog(syslog.LOG_WARNING, f"PATH_GUARD_BLOCK: {reason} | Command: {command[:200]}")
    except Exception:
        pass
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": reason,
        }
    }))
    sys.exit(0)

# Protected paths — if any of these appear in the command, block it.
# This list is not exhaustive — see known limitations.
PROTECTED = [
    "/etc/",
    "/usr/",
    "/boot/",
    "/sbin/",
    "/lib/",
    HOME + "/.bashrc",
    HOME + "/.zshrc",
    HOME + "/.profile",
    HOME + "/.bash_profile",
    HOME + "/.claude/settings.json",
    HOME + "/.msmtprc",
    HOME + "/.mbsyncrc",
    HOME + "/.gnupg/",
    HOME + "/.ssh/",
    # Secrets / credentials:
    HOME + "/.password-store/",
    HOME + "/.aws/credentials",
    HOME + "/.kube/config",
]

# Only these paths may be exempted by project-level config.
# Home-directory paths can never be exempted.
EXEMPTABLE = {"/etc/", "/usr/", "/boot/", "/sbin/", "/lib/"}

# Load project-level exemptions from .claude/guard-exempt-paths.json
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", "")
if project_dir:
    exempt_file = os.path.join(project_dir, ".claude", "guard-exempt-paths.json")
    try:
        with open(exempt_file) as f:
            requested = set(json.load(f))
        exemptions = requested & EXEMPTABLE
        if exemptions:
            PROTECTED = [p for p in PROTECTED if p not in exemptions]
            syslog.syslog(syslog.LOG_INFO,
                f"PATH_GUARD_EXEMPT: {sorted(exemptions)} for {project_dir}")
    except (FileNotFoundError, json.JSONDecodeError, TypeError):
        pass

# Also match ~/. shorthand versions
TILDE_PROTECTED = ["~/" + p[len(HOME)+1:] for p in PROTECTED if p.startswith(HOME + "/")]

# Parse input
raw_input = os.environ.get("HOOK_INPUT", "")
try:
    data = json.loads(raw_input)
    command = data.get("tool_input", {}).get("command", "")
except Exception:
    deny("Path-guard hook could not parse input — blocking as precaution.")

if not command:
    sys.exit(0)

# Check for protected paths in the command
for path in PROTECTED + TILDE_PROTECTED:
    if path in command:
        deny(f"Command references protected path: {path}")

# Allow
sys.exit(0)
PYTHON_EOF

Of course, you need to save it somewhere that makes sense for you, then make it executable with chmod +x.

The script works as two layers, the outer being Bash and inner being Python. Bash is needed first because that's what Claude Code's hook system expects. But it's tricky to parse JSON with Bash, whereas that's Python's thing. So, Bash reads the incoming data and hands it to Python for the actual inspection.

Why simple text matching for the hook script?

You might wonder why the script uses simple text matching. After all, it just looks for protected paths like /etc/ or ~/.ssh/ anywhere in the command text. That's obviously a blunt instrument and you may be tempted to go for a a more sophisticated approach, like separating out which parts are actual file paths, and which are arguments or text strings.

It might work for you but I tried that and it was too fragile because common command formats would trip up the parser. In the end, I settled on the simpler solution because it avoids that false sense of security. Yes, it can be fooled by a malicious agent, but at least there's no pretense of defending against edge-cases. The problems you know about are easier to deal with than the unknowns.

Per-project exemptions

There's one big problem, however. I run agents that manage remote servers which regularly need to run commands that contain a protected path. For example:

ssh myserver "docker exec caddy reload --config /etc/caddy/Caddyfile"

The /etc/caddy/Caddyfile path is inside a Docker container on a remote host, not on my local machine, but the substring match catches it anyway because it sees /etc/ in the command text.

The fix is per-project exemptions. A project that needs to reference system paths in remote commands can create a .claude/guard-exempt-paths.json file in its project root:

["/etc/"]

Only exempt what you actually need. The full set of exemptable paths is /etc/, /usr/, /boot/, /sbin/, and /lib/, but most projects only need one or two.

The hook knows which project it's running in because Claude Code passes that information through an environment variable. If it finds a guard-exempt-paths.json file in the project, it skips those paths when checking commands for that project.

The obvious risk is that a compromised project config could exempt everything and effectively disable the hook. So I built a hard limit into the script: only the five system-directory paths listed above can be exempted (you can see the EXEMPTABLE list in the code). Even if someone adds "~/.ssh/" to the exemption file, the script ignores it and the hook always checks for home directory paths regardless of any exemption file.

This is also where the layers work together. Layer 1 (deny permissions) still blocks Write and Edit to system paths unconditionally, regardless of any project-level configuration. The exemption only affects the Bash hook's substring check so even in a project with all five system paths exempted, an agent still can't write to /etc/ because the deny rules won't allow it.

It's important to be aware that this isn't a silver bullet because an agent could edit its own rules,* so you need to adjust permissions to match your situation. This honestly needs ongoing work as it isn't a solved problem and the tooling is still evolving. You do your best with what's available and be conscious that we don't yet have adequate solutions. Defence in depth is the key here.

Registering the hook

The hook goes in your ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/guard-sensitive-paths.sh"
          }
        ]
      }
    ]
  }
}

You're not limited to a single hook, either. Say you have other commands you want to guard against, like outbound email or database access. You can still add separate hooks for each area. Multiple hooks on the same matcher run in parallel, and all of them must pass for the command to execute. The command is blocked if any one returns a deny decision.

Testing your security setup

Once you've saved the settings and the hook script, test it:

  1. Try a command that references a protected path, like cat /etc/passwd. It should be blocked with a message telling you which path triggered it.
  2. Check your system log for a PATH_GUARD_BLOCK entry.
  3. Try a normal command like ls -la. It should work fine.
  4. If you've set up a per-project exemption, try the same /etc/ command from that project. It should be allowed.

You have two options if there's a false positive: either add the specific path to a per-project exemption if it's a system path, or adjust the PROTECTED list in the hook if the path doesn't need protecting.

What's next for locking down Claude Code

The sandbox is more secure but it wasn't workable for a lot of our projects. This setup is the trade-off I settled on: agents get the access they need for SSH, remote servers, and file management. Plus, the deny rules and hooks catch the things I don't want them touching. It's been running for a few months and it's stable.

I'm still not fully comfortable with where things are because the OpenClaw disclosures showed what happens when agent frameworks don't think about credential isolation. Shortly after publishing the first version of this article, a supply chain attack on LiteLLM4 deployed a credential harvester that swept SSH keys, cloud secrets, and API tokens from every environment it touched. It was live on PyPI for three hours.

These are real incidents affecting production systems because people jumped on the bandwagon too early without thinking of the implications.

I've been running Another Cup of Coffee for over twenty years and if there's one thing I've learned, it's that you don't adopt experimental technology without understanding where it risks your business. Our clients trust us with their infrastructure and their data. While we have the luxury of being able to move quickly, we're also careful not to break things for the people who trust us with their livelihoods. So we build what protections we can, we're honest about the gaps, and we keep watching. I'll write more as the tooling evolves.

If you're not sure where you stand with any of this, I'm happy to have a conversation about it.

Common Questions

Can I use this alongside the sandbox?

Yes. The deny permissions and hooks work whether the sandbox is on or off. If your workflow doesn't need SSH or file deletion, you could run the sandbox for its kernel-level isolation and still add deny permissions and hooks as additional layers. They don't conflict.

Does this work on macOS?

Yes. The deny permissions and hook registration are identical on macOS. The hook script needs Python 3 and bash. macOS ships bash but not Python 3; you'll need to install Python via Xcode Command Line Tools (xcode-select --install) or Homebrew. The only other difference is how you read the audit log: use log show instead of journalctl. If you're using the sandbox path, macOS uses Seatbelt instead of bubblewrap and doesn't need socat.

What happens if I get a false positive?

The hook blocks the command and tells you which path triggered it. You can either adjust the PROTECTED list in the hook, or if it's a sysadmin project that legitimately references system paths on remote servers, create a per-project exemption file. The deny permissions (Layer 1) never produce false positives because they only apply to Write and Edit, not Read or Bash.

What about Windows / WSL?

WSL2 runs a real Linux kernel, so everything in this guide works as-is. WSL1 does not support user namespaces, so the bubblewrap sandbox won't work there, but the deny permissions and hooks will. If you're on WSL1, the hook-based approach described here is your best option.

Do I need a dedicated user account?

No, but it's the strongest single thing you can do. Running Claude Code under a separate account means the agent can't access your personal files, SSH keys, or shell configuration because Unix permissions won't let it cross accounts. The rest of this guide works either way. If you run Claude Code as your own user, the deny rules and hooks are doing more of the heavy lifting.

Should I read the companion article first?

It helps, but you don't need to. That article covers the threat landscape and security layers conceptually. This one is the implementation. If you want to understand why these protections matter, read that first. If you just want to set them up, you're in the right place.

Known Limitations

This setup is experimental and actively evolving. The following gaps are ones I'm aware of and haven't yet resolved:

  • The hook script uses set -euo pipefail, which means it exits immediately on unexpected errors. I haven't yet confirmed whether Claude Code treats a crashed hook (non-zero exit, no output) as allow or deny. If it's allow, this is a fail-open vulnerability. I'm testing this and will update the script when I have a definitive answer.
  • No Read deny rules. The deny block only covers Write and Edit. Claude Code's own Read tool can still access sensitive files like ~/.ssh/ unless you've set up a dedicated user account. The hook catches Bash reads (like cat ~/.ssh/...) but not Read tool calls. For SSH keys specifically, ssh-agent is the primary protection.
  • The PROTECTED list is not exhaustive. Files like ~/.bash_history, ~/.zsh_history, browser profiles, and other sensitive data in your home directory are not covered by the hook. Add paths relevant to your setup.
  • Auto-approved commands have limits. Bash(cat:*) and Bash(cp:*) are caught by the hook when they reference protected paths, but only paths in the PROTECTED list. A cat on a sensitive file not in that list will pass both the allow rule and the hook.
  • Agents can create their own exemption files. Since agents can write to project directories, they can create or modify .claude/guard-exempt-paths.json to exempt system paths. The hard limit in the script (only system directories, never home paths) contains this, but it's worth being aware of.

The code in this article is shared from my own setup. Use it as a starting point, not a finished solution. If you find other gaps or have improvements, I'd like to hear about them.


This article is part of an ongoing series on how Another Cup of Coffee is adapting to AI. Explore all articles in this series.

You may also like

AI coding tool security and sandboxing

Trust, But Verify: What's Really Between Your AI Coding Tool and Your SSH Keys

AI coding tools run with your full user permissions. I looked at what's actually protecting developers, what isn't, and what you should do about it.

Red lobster on a white plate

What OpenClaw Teaches Us About AI Agent Security

OpenClaw's security crisis exposed real problems with how AI agents handle credentials, plugins, and system access. Here's what went wrong and how a convention-based approach avoids these risks entirely.

One person running dozens of projects with AI agents

I Run Dozens of Projects with AI. The Hard Part Isn't the AI.

One person, dozens of projects, four AI vendors. I spent a year building a coordination system for AI agents. The components are simple. Getting them right was not.

Footnotes

  1. Claude Code documentation, Settings and permissions.
  2. bubblewrap on GitHub. Unprivileged sandboxing tool using Linux kernel namespaces.
  3. GitHub issues #6631, #6699, and #27040 on the Claude Code repository document cases where deny rules were silently bypassed across multiple versions.
  4. LiteLLM, Security Update: Suspected Supply Chain Incident, March 2026. Malicious versions 1.82.7 and 1.82.8 were live on PyPI for approximately three hours before being quarantined.

Featured image: Photo by Patricia Prudente on Unsplash.