DEV Community

~K¹yle Million
~K¹yle Million

Posted on

Production Agent Security Hardening: 9 Controls Most Claude Code Setups Are Missing

Production Agent Security Hardening: 9 Controls Most Claude Code Setups Are Missing

Claude Code runs real shell commands on your real machine. When you approve Bash(*) in your settings, you're giving an LLM process broad shell access — which is exactly what you need for automation, and exactly what attackers look for in a target.

Most Claude Code setups have zero explicit security controls. Not because the developers don't care, but because when you're moving fast and it works, security is the thing you add later. Later is now.

This post covers the 9 controls that production agent setups need. They're not theoretical — each one maps to a failure mode I've seen in real deployments.


Control 1: Tool Allowlist Scope

The single most impactful control. When you spin up a Claude Code agent, specify exactly what tools it needs:

# Instead of this:
claude -p "analyze my codebase" --allowedTools "Bash(*),Read(*),Write(*)"

# Do this:
claude -p "analyze files in ~/project/src/" --allowedTools "Read(~/project/src/**),Bash(grep,find,wc)"
Enter fullscreen mode Exit fullscreen mode

Bash(*) gives the agent access to rm -rf, curl, ssh, sudo, credential-reading commands, and anything else on your PATH. Bash(grep,find,wc) gives it exactly what a read-only analysis task needs.

If you write CLAUDE.md with broad tool permissions because it's convenient, you've made a tradeoff you may not have intended. The tool scope should be sized to the task, not the convenience of the author.


Control 2: Credential Isolation

Agents should never see production credentials. If your shell environment has AWS_SECRET_ACCESS_KEY exported, any agent you spawn can exfiltrate it with a single Bash call.

The production pattern is environment isolation. Strip credentials from the agent environment before spawning:

# Spawn with stripped environment
env -i HOME="$HOME" PATH="/usr/local/bin:/usr/bin:/bin" \
    claude -p "$PROMPT" --allowedTools "$TOOLS" --output-format text

# Or source only a non-sensitive env file
env $(cat ~/.agent-env | xargs) claude -p "$PROMPT" ...
Enter fullscreen mode Exit fullscreen mode

For Claude Code agents that legitimately need credentials (API calls, database writes), pass them through the task spec file with explicit scope documentation, not through environment inheritance.


Control 3: Dangerous Pattern Blocking

A pre-execution check that scans generated commands before they run. Catches prompt injection attempts and edge cases where the agent generates a destructive command it wasn't asked for.

import re

DANGEROUS_PATTERNS = [
    r'rm\s+-rf\s+[~/]',
    r'>\s*/etc/',
    r'curl\s+.*\|\s*(bash|sh)',
    r'eval\s+\$\(',
    r'base64\s+--decode.*\|',
    r'chmod\s+777',
    r'ssh\s+.*-o\s+StrictHostKeyChecking',
    r'ANTHROPIC_API_KEY|AWS_SECRET|STRIPE_SECRET',
]

def is_dangerous(cmd: str) -> bool:
    return any(re.search(p, cmd) for p in DANGEROUS_PATTERNS)
Enter fullscreen mode Exit fullscreen mode

When a match fires, log the attempted command, stop execution, and alert. Don't just silently drop — you want to know this happened.


Control 4: Output Sanitization

Agents write files. Those files get read by other processes. If an agent can be prompted to write a file containing shell metacharacters, you have a second-order injection vector.

import re

def sanitize_agent_output(content: str) -> str:
    content = content.replace('\x00', '')  # Strip null bytes
    content = re.sub(r'\x1b\[[0-9;]*m', '', content)  # Remove ANSI escapes
    lines = content.split('\n')
    return '\n'.join(line[:10000] for line in lines[:50000])
Enter fullscreen mode Exit fullscreen mode

Production systems that use agent output in SQL queries, shell commands, or HTML responses need domain-specific sanitization on top of this floor.


Control 5: Filesystem Boundary Enforcement

Define a root directory. Reject any path that resolves outside it.

import os

AGENT_ROOT = os.path.expanduser('~/intuitek/')

def validate_path(path: str) -> bool:
    resolved = os.path.realpath(os.path.expanduser(path))
    return resolved.startswith(AGENT_ROOT)
Enter fullscreen mode Exit fullscreen mode

Use os.path.realpath, not os.path.abspath. realpath resolves symlinks. abspath does not — which means a symlink inside AGENT_ROOT pointing outside it will bypass abspath-based checks.


Control 6: Execution Rate Limiting

An agent executing 50 shell commands per minute is either stuck in a loop or doing something you didn't ask for.

from collections import deque
import time

class RateLimiter:
    def __init__(self, max_calls: int, window_seconds: int):
        self.max_calls = max_calls
        self.window = window_seconds
        self.calls = deque()

    def check(self) -> bool:
        now = time.time()
        while self.calls and self.calls[0] < now - self.window:
            self.calls.popleft()
        if len(self.calls) >= self.max_calls:
            return False
        self.calls.append(now)
        return True

# 30 Bash calls per 60 seconds
bash_limiter = RateLimiter(30, 60)
Enter fullscreen mode Exit fullscreen mode

When the rate limit fires, pause and log. Don't terminate — the agent may be in the middle of a legitimate complex task.


Control 7: Immutable Audit Log

Every shell command an agent executes should be logged before execution (not after — if the command crashes the process, you still want the record).

log_and_execute() {
    local cmd="$1"
    local timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    echo "${timestamp} CMD: ${cmd}" >> ~/intuitek/logs/audit.log
    eval "$cmd"
}
Enter fullscreen mode Exit fullscreen mode

On Linux: chattr +a ~/intuitek/logs/audit.log makes the file append-only at the filesystem level. Not a perfect control (a root process can remove the attribute), but raises the bar considerably.


Control 8: Network Egress Control

If your agent only needs local operations, block outbound network calls entirely:

# Spawn with no network access (requires firejail)
firejail --net=none claude -p "$LOCAL_TASK_PROMPT" ...
Enter fullscreen mode Exit fullscreen mode

The pragmatic version without firejail: don't include Bash in --allowedTools unless the task explicitly requires shell calls. --allowedTools "Read(*),Write(*)" eliminates most network vectors without any firewall configuration.


Control 9: Session Scope Boundaries

An agent session that runs indefinitely can accumulate permissions beyond what the original task required. Define explicit session boundaries in CLAUDE.md:

## SESSION BOUNDARY

Every session starts fresh. Persistent state lives only in:
- ~/intuitek/memory/ (read on start, write on clean exit only)
- ~/intuitek/logs/ (append only)

Sessions do not chain without explicit coordinator handoff.
Enter fullscreen mode Exit fullscreen mode

Headless claude -p invocations (which terminate on task completion) are safer than long-running interactive sessions for automated work. The subprocess terminates; credentials and execution context go with it.


The Priority Order

Implement these in order — each closes a real attack surface:

  1. Tool allowlist scope — biggest blast radius reduction per line of config
  2. Credential isolation — prevents the most damaging exfiltration
  3. Dangerous pattern blocking — catches prompt injection before execution
  4. Filesystem boundary enforcement — stops path traversal
  5. Immutable audit log — forensics when something gets through

Controls 6–9 are defense-in-depth once 1–5 are in place. Don't implement all nine at once. Start with the first two, verify them, then add.


I packaged the complete toolkit — the dangerous pattern blocklist (200+ patterns covering OWASP top 10 for agent contexts), the path validator, the rate limiter, the audit logger, and a production CLAUDE.md security template — as a ClawMart skill. Link in the first comment.

Anything I missed? These controls are from production deployments — real failure modes, not theoretical attack trees. If you've seen a vector these don't cover, put it in the comments.

Top comments (0)