How I Actually Locked Down Claude Code (Without Breaking Everything)

I wrote recently about what's actually standing between AI coding tools and your SSH keys. That piece covered the threat landscape, the security layers, and a checklist of things you should be doing. What it didn't cover, because it would have doubled the length, was exactly how to do any of it.

This is the practical follow-up. I'm going to walk through the security configuration I've built for Claude Code, with every deny rule, hook script, and design decision. The examples are configurations you can adapt to your own setup. It's Claude Code-specific, but the principles (block what you can with built-in controls, guard everything else with fail-closed hooks, be honest about the gaps) apply to any tool that runs commands as your user account.

If you just want to understand why these protections matter, the companion article has the background. If you want to set them up, you're in the right place.

Key terms

If you're new to Claude Code, a few terms come up repeatedly in this guide 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. They're not the same as Unix file permissions, which 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, using 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. 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

Before configuring anything in Claude Code, it's worth understanding what the operating system already gives you. Claude Code runs as your user account, not as root, which means standard Unix permissions are the first line of defence.

What this protects: System files owned by root (/etc/shadow, /usr/bin/, system binaries) are already protected from modification. Your agent can't write to /etc/passwd or replace /usr/bin/ssh because your user account doesn't have write permission to those locations. This is the kernel enforcing access control, and no amount of prompt injection or agent reasoning can bypass it.

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.

On Linux, you can tighten this further. Set restrictive permissions on sensitive directories: chmod 700 ~/.ssh ~/.gnupg ~/.password-store ensures only your user can access them (which Claude Code still can, since it runs as your user, but it prevents access from other accounts). For stronger isolation, you could run Claude Code under a separate user account, which would give you real kernel-enforced separation, but that adds significant complexity and breaks most workflows that need access to your project files. In practice, the Claude Code-level controls described in this guide are a more practical approach.

On macOS, the same principles apply, with the addition of TCC (Transparency, Consent, and Control). macOS may prompt you for permission when a process tries to access certain protected directories (Desktop, Documents, Downloads) or resources (camera, microphone, contacts). If Claude Code triggers a TCC prompt for a directory it shouldn't need, denying it is free protection.

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. The deny permissions and hook registration work identically on both platforms. The hook script needs:

  • Python 3 (the hook shells out to Python for JSON parsing)
  • bash (the hook wrapper uses bash, not sh)
  • 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, readable 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 (sysctl kernel.unprivileged_userns_clone should return 1; most modern distros enable this 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 (if you don't need SSH)

Before I get into the hook-based approach, I should be clear: 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, it's kernel-enforced, and it handles both filesystem and network isolation.

Here's 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"
      ]
    },
    "network": {
      "allowedDomains": [
        "github.com", "raw.githubusercontent.com",
        "objects.githubusercontent.com",
        "registry.npmjs.org", "pypi.org", "files.pythonhosted.org"
      ]
    }
  }
}

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: a command failing inside the sandbox is the sandbox doing its job, and 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 needed. If a project needs to reach an API or a staging server over HTTPS, add that domain. The network allowlist only affects sandboxed Bash commands; WebFetch and WebSearch are controlled separately through the permissions layer.

You can still add deny permissions and hooks on top of the sandbox. They don't conflict, and 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). 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.

Why not the sandbox? (my situation)

Claude Code has a built-in sandbox using bubblewrap on Linux and Seatbelt on macOS. For a lot of workflows, it's the right choice, and if it works for yours, use it. It's simpler than what I'm about to describe and the isolation is kernel-enforced.

But I run AI agents that manage remote servers via SSH, rsync files between machines, and need to delete build artifacts. The sandbox blocks all of that. TCP networking is completely blocked (not just filtered, blocked), so SSH doesn't work. File deletion is blocked even on paths you've explicitly whitelisted. There's no way to allow SSH to one host while blocking another. It's all or nothing, and "nothing" wasn't an option.

So I replaced it with two layers that give me the protection I need without the restrictions I can't live with.

The design principle

The approach is simple enough to fit in a table:

Tool Protection Mechanism
Write / Edit Deny permissions in settings.json Built-in, 100% reliable, zero code
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 (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: don't use hooks where permissions work. Claude Code's deny permissions are enforced by the application itself, not by your code, so there's nothing to parse and nothing to get wrong. Hooks 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 system with allow, deny, and ask rules. The important thing about deny rules is that they're evaluated first and they're absolute. If you deny something at the global level (~/.claude/settings.json), no project-level configuration can override it. The application enforces this, not your code.

Here's an example deny block. Replace /home/youruser with your actual home directory (Claude Code requires absolute paths), 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/**)",
      "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/**)"
    ]
  }
}

That's every Write and Edit rule duplicated, which looks redundant until you remember they're different tools. An agent could use either one to modify a file, so 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 has no business writing to any of these.
  • Shell configs (.bashrc, .zshrc, .profile, .bash_profile) because a modified shell config runs on every new terminal session. If an agent writes something malicious into your .bashrc, it persists long 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; it doesn't need direct file access.
  • Mail configs (.msmtprc, .mbsyncrc) because these contain SMTP credentials and mail server access.

The allow list is the other half. 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 (ls, git, grep, cat, diff, and similar) to reduce approval fatigue. That's the practical trade-off: auto-allow the safe stuff so you're not clicking "yes" fifty times a day, deny the dangerous stuff absolutely, and let the hook handle the grey area.

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"
    ]
  }
}

The Bash(git -C /:*) pattern is worth explaining. 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 prompts, but only when they include an explicit path. Bash(git:*) without the -C would also match bare git commands, which you might or might not want.

Keep credentials out of context

Deny permissions protect files, but there's a broader principle: credentials should never appear in your agent's conversation context at all. If a secret shows up in a tool output or a file the agent reads, it's in the context window, and from there it could end up in a log, a commit message, or a memo to another project.

For SSH, this is straightforward: 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. Your deny rules on ~/.ssh/ are a backup; ssh-agent is the primary protection.

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

For API keys and cloud credentials, use 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 calls the API through a wrapper script that sources the credentials at runtime.

The pattern is always the same: the agent invokes a command that uses credentials, but the credentials are resolved at a layer the agent can't see.

The sudo convention

One more convention-level control. AI coding tools can't use sudo interactively because there's no terminal for password entry. That's actually a natural protection. But an agent that keeps trying will trigger pam_faillock on Linux, which locks the user account after repeated authentication failures. I learned this the hard way when an agent got into a retry loop and locked me out of my own machine.

The fix is an instruction in each agent's configuration file: "Never run sudo via the Bash tool. The Bash tool has no interactive terminal, so sudo cannot read a password. Each failed attempt counts against pam_faillock and can lock the user account. Instead, give the user the command to run themselves." This is a convention, not a technical control, so it depends on the model following instructions. But it works reliably in practice, and the alternative (passwordless sudo via NOPASSWD: ALL) is far worse because a compromised agent with passwordless sudo has full root access.

On macOS, the risk is the same: repeated failed sudo attempts won't lock the account by default (macOS doesn't use pam_faillock), but they will flood the security log and the agent will waste time retrying. The convention applies equally.

Layer 2: the Bash guard hook

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. That's what the hook is for.

Here's the full script. It's about 110 lines. Save it somewhere sensible (e.g. ~/.claude/hooks/guard-sensitive-paths.sh) and make it executable with chmod +x:

#!/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

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.
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.replace(HOME, "~") 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

It's a bash wrapper around a Python script, which looks odd until you know why. The hook receives JSON on stdin (Claude Code sends the tool name and command), and Python handles JSON parsing better than bash. The bash layer reads stdin with a 1MB size limit (anything larger gets blocked as a precaution), exports it as an environment variable, then hands off to Python.

The Python does four things: sets a 5-second timeout (if analysis hangs, block), builds the list of protected paths, checks if any of them appear as a substring in the command, and logs to syslog if it blocks something.

Why substring matching?

I should be honest about this, because the first version of this hook was about 230 lines long and I was quite proud of it. It used Python's shlex module to tokenise the command into segments, split on pipes and semicolons, classified each segment as read or write, detected redirects, and handled per-project exemptions through environment variables.

And it was fragile. shlex chokes on heredocs and ANSI-C quoting (the $'...' syntax). When it couldn't parse a command, it fell back to... a substring check. Which caught the same things the sophisticated parsing caught, just without the false confidence.

So I threw away 160 lines and kept the substring check. It's honest about what it is: a heuristic that catches the common cases and misses the edge cases. It won't catch cat "$HOME"'/.ssh/id_rsa' where the path is constructed across multiple shell tokens. It won't catch a symlink pointing to a protected path. But it catches cat ~/.ssh/id_rsa and rm -rf /etc/something and scp file user@host:/usr/local/bin/, which are the commands that actually come up. The limitations are stated right in the script comments, because I'd rather have a 70-line script that's honest about its gaps than a 230-line script that pretends it doesn't have any.

Per-project exemptions

There's one wrinkle. I run agents that manage remote servers, and they regularly need to run commands like 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.

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

["/etc/", "/usr/", "/boot/", "/sbin/", "/lib/"]

The hook reads this via $CLAUDE_PROJECT_DIR (an environment variable Claude Code makes available to hooks) and removes those paths from the check list for that project. But there's a safety limit: only the five system-directory paths can be exempted. The hook hardcodes this as the maximum set. Even if someone adds "~/.ssh/" to the config file, the hook ignores it. Home-directory protections (SSH keys, GPG keyring, credentials, shell configs) are always enforced, in every project, with no override.

This is safe because 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. And the projects that use this exemption are sysadmin agents whose entire purpose is remote server management; the chance of them accidentally running rm -rf /etc/something locally is negligible.

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"
          }
        ]
      }
    ]
  }
}

Multiple hooks on the same matcher run in order, and all of them must pass for the command to execute. You could add a second hook for email commands (blocking msmtp, sendmail, mail in Bash) on the same matcher. An email hook can use more sophisticated parsing (shlex tokenisation, subshell detection, variable assignment detection) because the attack surface for email evasion is specific enough to be worth covering precisely. The path guard doesn't need that sophistication, and that's fine. Different problems, different tools.

Testing it

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

  1. Try a command that references a protected path: cat /etc/passwd. It should be blocked with a message about the protected path.
  2. Check the audit log for the block entry. On Linux: journalctl -t python3 --since '5 minutes ago' (look for PATH_GUARD_BLOCK). On macOS: log show --predicate 'process == "python3"' --last 5m | grep PATH_GUARD.
  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.

If it blocks something it shouldn't (a false positive), you have two options: add the specific path to a per-project exemption if it's a system path, or adjust the PROTECTED list in the hook if you've decided a path doesn't need guarding. In practice, false positives are rare because legitimate development commands don't usually reference system paths or credential files.

What's next

This setup trades the sandbox's single-switch simplicity for more configuration. That's the honest trade-off. But the configuration is stable once it's in place, and the day-to-day experience is better than it was when I was either fighting the sandbox's restrictions or relying entirely on permission prompts.

What I'm planning next is per-project SSH host restrictions: a hook that checks which remote host an SSH command targets and only allows connections to hosts that project is authorised to manage. That would close the gap where SSH commands bypass the sandbox (and the path guard) entirely. But that's a separate problem for a separate article.

If you're running Claude Code and want to tighten things up, the deny permissions are a five-minute job and they're the highest-value change. The hook takes a bit longer but it's copy-paste. And if you're not sure what your current posture looks like, we're 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 uses Python 3 and bash, both of which ship with macOS (or are available via Homebrew). The only difference is how you read the audit log: use log show instead of journalctl (see the testing section above). 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.

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.


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.