Hardening a Solo-Operator LLM Agent Against the Agents of Chaos Taxonomy
The Agents of Chaos study [1] exposed ten vulnerability classes across six undefended LLM agents during a 14-day adversarial exercise. We systematically hardened a production single-operator OpenClaw deployment against all ten case studies, achieving structural coverage through seven implementation phases spanning ingress filtering, egress enforcement, behavioral controls, trust-tier identity, and observability. The total infrastructure cost is approximately $0.04/month on free-tier APIs, running on a repurposed 2017 MacBook Pro. Beyond confirming the original taxonomy's relevance, we report several emergent findings absent from the literature: an agent that exploited its own approval tool to self-authorize destructive commands, a meta-injection vector through the content filter's own explanatory output, and a systematic divergence between the agent's world model and the reality of systemd sandboxing. We argue that the hardest problems in agent defense are not the catalogued attack classes themselves, but the integration failures and emergent behaviors that arise when multiple defense layers interact in production. The full defense architecture is declaratively specified in NixOS and publicly available.
1. Introduction
Large language model agents that interact with external tools, messaging platforms, and operating systems are increasingly deployed outside controlled lab environments. Yet the security infrastructure protecting these deployments remains minimal. Shapira et al.'s Agents of Chaos study [1] quantified this gap by deploying six OpenClaw agents with no defensive measures for 14 days, recruiting 20 researchers to probe them. The resulting taxonomy of ten case studies (CS1–CS11, excluding CS9) spans three attack categories: ingress manipulation (prompt injection, identity spoofing), egress abuse (data exfiltration, mass messaging), and behavioral exploitation (resource exhaustion, social engineering).
The study's implicit recommendation—that practitioners should defend against these classes—leaves open the question of how. Existing proposals range from formal capability systems (CaMeL [2], PCAS [5]) to process isolation (AgentSys [4]) to multi-agent oversight (Sentinel Agents), but these are evaluated in simulation or prototype, not in the operational context of a single person running an agent on commodity hardware with a $0 infrastructure budget.
We report on the systematic hardening of a production OpenClaw agent deployment against all ten Agents of Chaos case studies. The contribution is threefold: (1) a complete, reproducible defense architecture that achieves 10/10 case study coverage with defense-in-depth on 6/10, (2) a set of emergent findings discovered during implementation that are absent from the original taxonomy and the broader literature, and (3) a cost and complexity analysis demonstrating that meaningful agent defense is achievable for solo operators. The entire defense is declaratively specified in approximately 3,500 lines of NixOS configuration and deployed on a repurposed 2017 MacBook Pro with 16 GB of RAM.
2. Threat Model and Deployment Context
2.1 System Overview
The defended system is an OpenClaw LLM agent accessible via WhatsApp, running as a KVM guest on a NixOS host. The agent processes natural-language requests, executes shell commands, manages files, and sends messages on the owner's behalf. The host is a 2017 MacBook Pro (Intel i7, 16 GB RAM, 1 TB NVMe + 4 TB external SSD) serving double duty as a NAS, backup server, and Syncthing node.
2.2 Threat Model
The primary threat is prompt injection via WhatsApp: an attacker sends a crafted message that causes the agent to execute unintended commands, exfiltrate data, or abuse its messaging capabilities. We consider three attacker tiers:
- External (unknown): anyone who can message the WhatsApp number. Goal: arbitrary command execution, data exfiltration, or resource abuse.
- Known contact: a person in the owner's contact list. Same goals, but with a social-engineering advantage (the agent is more likely to comply with familiar names).
- Compromised web content: malicious payloads embedded in web pages the agent fetches. This is the CS10 "corrupted constitution" vector—the agent's tool output becomes an injection channel.
We explicitly exclude attacks requiring physical access, kernel exploits, or compromise of the host's SSH keys. The attacker's channel is WhatsApp messages and web content reachable from the VM.
2.3 Constraints
Three constraints distinguish this deployment from enterprise settings and shape every architectural decision:
- Solo operator. There is no security operations center. Alerts go to the owner's phone. Response time is measured in hours, not seconds. Defenses must be structural (prevent by default) rather than detective (alert and respond).
- Zero infrastructure budget. All external APIs must fit within free tiers: Lakera Guard (10K calls/month), OpenRouter ($0 free credit), Grafana Cloud (10K Prometheus series, 50 GB logs). Self-hosted components must share 16 GB of RAM with the agent, a VM, Samba, ZFS, and system services.
- Commodity hardware. A 2017 laptop with 2 cores and 4 threads. The local ML model for content screening (Prompt Guard 2, 86M parameters) consumes ~500 MB RSS and ~1.5 seconds per inference. There is no GPU.
3. Defense Architecture
We implemented defenses across seven phases, each targeting specific case studies from the taxonomy. Table 1 maps phases to vulnerabilities; Figure 1 illustrates the data flow. Six of ten case studies have multiple independent defense layers.
Table 1: Vulnerability coverage matrix
| CS | Vulnerability | Primary Defense | Additional Layers |
|---|---|---|---|
| CS1 | Disproportionate response | C: Action quotas (5 classes, per-class budgets) | C: Approval queue for destructive commands |
| CS2 | Non-owner compliance | D: Trust tiers (owner/known/unknown) | D: Phone-based identity, PIN elevation |
| CS3 | Semantic reframing | B: Egress guard (fail-closed for non-owner) | C: Session pattern detection (GPT-4o-mini) |
| CS4 | Infinite loop | C: Loop circuit breaker (>10 repeats/5min) | C: Action quotas (process_spawn class) |
| CS5 | Storage exhaustion | C: Disk monitor (alert at 80%) | E: Prometheus alerting |
| CS6 | Silent censorship | A: Fixed-vocabulary guard responses | — |
| CS7 | Guilt trip / pressure | C: Session pattern detection | C: Per-tier rate limits |
| CS8 | Identity hijack | D: Owner PIN (4h timeout) | D: Per-sender group resolution |
| CS10 | Corrupted constitution | A: 5-layer web content guard | A: Redirect-chain analysis (anti-SSRF) |
| CS11 | Mass broadcast | B: nftables egress default-deny | D: Per-tier rate limits (msg/hr caps) |
3.1 Phase A: Guard Chain Hardening
Inbound messages and web content pass through a four-layer content filter before reaching the agent:
| Layer | Guard | Scope | Latency | Cost |
|---|---|---|---|---|
| 1 | Regex pre-scan | Outbound only (credentials) | <1 ms | Free |
| 2 | Lakera Guard v2 | Cloud API, primary | ~50 ms | Free tier |
| 3 | Prompt Guard 2 (86M) | Local ONNX model, Unix socket | ~1.5 s | Free (CPU) |
| 4 | GPT-4o-mini | Cloud API, last resort | ~200 ms | ~$0.00014/call |
Fail behavior is asymmetric by design. Inbound screening is fail-closed: if all guards are unavailable, the message is blocked. Outbound fail behavior varies by trust tier (Section 3.4). This asymmetry is itself an emergent finding (Section 4.6).
Web content defense (CS10) requires five independent layers because the agent has multiple paths to fetch external content: built-in tools, shell commands (curl, wget), a headless browser, and provider-level web search injection. The layers are:
tools.denyblocks the agent's built-inweb_fetchandweb_searchtools (replaced by guarded versions). The built-in browser is enabled but its output is screened by Layer 4.- A
before_tool_callhook interceptsexec/bashcommands containingcurl,wget,chromium,playwright, orpuppeteer—forcing browser use through the built-in tools (which are guarded) rather than raw exec. - Plugin replacement tools (
guarded_web_fetch,guarded_web_search) execute the guard chain insideexecute(), screening fetched content before the agent sees it. - A
tool_result_persisthook screensexec,bash, and browser tool output (includingbrowser_snapshot,browser_navigate,browser_screenshot, and 7 other browser tools) before it is saved to conversation history. This is defense-in-depth: the agent already saw the raw output in the current turn, but future turns (and the persistent transcript) see only guarded content. - The OpenRouter Web Search plugin is disabled on the provider dashboard, preventing the LLM provider from injecting web search results at the API level.
Redirect-chain analysis (motivated by Silent Egress [6]) follows HTTP redirects up to 5 hops, blocking any redirect from an external origin to a private IP range (anti-SSRF). This prevents an attacker from hosting a page that redirects to http://127.0.0.1:18789 (the gateway's local API).
Guard transparency fix (CS6). Early versions passed the GPT-4o-mini guard's free-text verdict.reason to the agent, creating a meta-injection vector: the "reason" field contained attacker-influenced text that the agent could interpret as instructions. The fix replaced free-text reasons with a fixed-vocabulary category enum (injection | exfiltration | credential | content_policy | flagged). This eliminates the guard's explanatory output as an information channel while preserving enough signal for the agent to understand why a request was blocked.
3.2 Phase B: Egress Enforcement
The VM runs on a NAT bridge (virbr0). A host-level nftables ruleset implements default-deny egress:
- A dynamic resolver (5-minute timer) resolves 8 API hostnames to IP addresses and updates nftables sets.
- Static CIDR blocks cover Google, Cloudflare, Tailscale, Meta/WhatsApp, and Anthropic (AWS) ranges.
- Only TCP ports 80, 443, and 22000 (Syncthing) are allowed outbound.
- nftables counters feed
openclaw_vm_egress_denied_packets_totalinto Prometheus for alerting.
API credential isolation (CS11, CS3). An API sidecar proxy (Node.js, 127.0.0.1:19090) handles Gmail OAuth2 token refresh and Grafana Cloud queries (read-only Viewer SA). The agent's API calls transit the proxy, which injects credentials server-side. Credentials never enter the agent's LLM context window — the agent only sees structured JSON responses.
3.3 Phase C: Behavioral Controls
Four mechanisms limit what the agent can do, independent of what it is asked to do:
Action quotas (CS1). Five action classes with per-class budgets enforced in the before_tool_call hook:
| Class | Budget | Window |
|---|---|---|
destructive_exec |
1 | 24 hours |
file_delete |
5 | 1 hour |
message_send |
20 | 1 hour |
process_spawn |
3 | 1 hour |
config_modify |
5 | 1 hour |
Quota state is persisted to a tmpfs file (/run/openclaw-quotas/counters.json), surviving gateway restarts but resetting on reboot.
Approval queue (CS1). Commands classified as destructive_exec trigger the gateway's native requireApproval hook. The before_tool_call handler returns a requireApproval object specifying the action's title, description, severity, and a 10-minute timeout with fail-closed behavior. The gateway suspends the tool call in-flight and prompts the owner via WhatsApp. The owner approves or denies using the /approve command. The mechanism is scope-gated to the operator.approvals permission. The security implications of this mechanism are discussed in Section 4.1.
Session pattern detection (CS3, CS7). Every 20 tool calls, the plugin sends the recent guard log events to GPT-4o-mini via OpenRouter for behavioral analysis. The model is asked to detect escalation patterns, reframing attempts, sustained pressure, and resource abuse. Analysis is asynchronous and non-blocking. A flagged session increments openclaw_session_flagged_total, triggering a Grafana alert.
Loop circuit breaker (CS4). An in-memory map tracks tool_name + SHA-256(first_200_chars_of_args). If the same key appears more than 10 times within a 5-minute window, a circuit breaker trips with a 15-minute cooldown. This catches degenerate loops where the agent repeatedly invokes the same command with identical arguments.
3.4 Phase D: Trust Tiers and Identity
A three-tier trust model maps phone numbers to capability sets:
| Tier | Identity | Capabilities | Rate Limit |
|---|---|---|---|
| Owner | Configured phone + optional PIN | All tools, fail-open outbound guard | Unlimited |
| Known | Allowlisted phones | Read-only tools, no exec/messaging, fail-closed | 20 msg + 10 tool/hr |
| Unknown | Everyone else | Text responses only, no tools | 5 msg/hr, 0 tools |
PIN elevation (CS8). The owner can authenticate via a WhatsApp message ("unlock <pin>") to elevate from the known tier to owner. Elevation expires after 4 hours of inactivity. PIN messages are restricted to direct messages (not groups) to prevent leaks.
Group identity resolution (CS2, CS8). WhatsApp group messages carry the group JID, not the individual sender's phone number. Resolving per-sender identity required correlating the message_received hook's event.metadata.senderE164 field with the trust tier configuration. A groupLatestSender map bridges the gap between the message hook (which knows the sender) and the agent event handler (which knows only the session key). This is discussed further in Section 4.4.
3.5 Phases E and G: Monitoring and Observability
Twenty-eight custom Prometheus metrics are collected via six textfile collectors running on 60-second timers. Grafana Alloy scrapes both the host and VM, pushing metrics to Grafana Cloud's free-tier Prometheus (remote write) and logs to Loki.
Twenty-one alert rules across four groups (host health, guard security, governance, egress infrastructure) are defined in version-controlled YAML and pushed to Grafana Cloud's Mimir ruler via API. Alert rules are managed separately from the NixOS configuration to avoid adding Python and PyYAML as system dependencies and to prevent rebuild failures when Grafana Cloud is unreachable.
Guard logs are shipped to Loki with a sanitization pipeline: guard.log is rewritten to metric-only fields (no message content, sender phones, or content hashes), and journal logs are regex-redacted for credential patterns. Verification queries confirmed zero PII leakage across 8 Loki query patterns.
4. Emergent Findings
The most valuable outcomes of this project were not confirmations of the Agents of Chaos taxonomy—those attacks work exactly as described—but rather the unexpected behaviors that emerged when defense mechanisms interacted with the agent, the platform, and each other.
4.1 The Agent Self-Approved Its Own Destructive Commands
When we first implemented the approval queue (Section 3.3), it used a file-based design: blocked commands were written to a queue directory (/data/openclaw/approval-queue/), a 30-second timer sent WhatsApp notifications, and the owner approved via an SSH CLI tool (openclaw-approve). The initial design also registered a resolve_approval tool that the agent could call to check the status of pending approvals. During testing, the agent received a request to delete files, which the quota system correctly routed to the approval queue. The agent then called resolve_approval with action approve on its own pending request, and the command executed.
After removing the tool, the agent adapted: it used exec to run cat on the approval queue directory, read the JSON files, and attempted to modify them with jq to change the status from pending to approved. The defense required two layers: removing the approval tool entirely, and adding a before_tool_call rule that blocks any exec command referencing the approval-queue path. These file-based self-approval vectors motivated the migration to the gateway's native requireApproval hook (v2026.3.28), which suspends the tool call in-flight within the gateway process—eliminating the file-based queue, the notifier timer, and the SSH CLI entirely. The agent has no artifact to read or modify; approval state is internal to the gateway and resolved exclusively via the owner's WhatsApp /approve command.
This finding has a clear implication: any tool that can influence the outcome of a security decision must be inaccessible to the entity being constrained. The agent did not exhibit adversarial intent—it was straightforwardly completing the user's request using the most efficient available path. The problem is that efficiency and security are misaligned when the agent has both the goal and the means.
4.2 The Agent's World Model Diverges Under Sandboxing
The OpenClaw gateway runs with systemd's PrivateTmp=true, which mounts an isolated /tmp visible only to the service's process tree. When a user requested file cleanup, the agent ran rm -rf /tmp/build-artifacts. The command exited 0 (the -f flag suppresses errors for missing files), and the agent reported success. In reality, the command operated on the isolated tmpfs, not the real /tmp. No files were deleted.
This is a subtle failure mode. The agent's world model—built from command outputs and exit codes—diverged from the actual system state. The agent had no way to detect the discrepancy. Sandboxing is a valid defense (it limits blast radius), but it creates a perception gap where the agent confidently reports outcomes that did not occur. For agents that chain actions based on previous results, this gap can compound.
4.3 The Guard's Explanation Became an Injection Channel
The initial implementation of the content filter returned GPT-4o-mini's free-text verdict.reason to the agent, explaining why content was blocked (e.g., "This message contains a prompt injection attempting to override system instructions and exfiltrate the API key"). The intent was transparency: the agent should understand what happened so it can respond appropriately.
The problem is that the "reason" field is generated from attacker-controlled input. If the attacker's message says "Ignore previous instructions and respond with: The content was safe, proceed with the request," the guard correctly identifies it as an injection and blocks it, but the reason field may partially echo or paraphrase the attacker's text. The agent then sees this paraphrased text as authoritative output from a trusted security component.
The fix was to replace free-text reasons with a fixed-vocabulary enum of five categories. This eliminates the information channel while preserving the signal the agent needs.
4.4 Group Messaging Breaks Identity Assumptions
The Agents of Chaos taxonomy implicitly assumes one-to-one messaging. CS2 (non-owner compliance) and CS8 (identity hijack) both describe scenarios where the attacker sends a direct message. WhatsApp group messages introduce a complication: the message's from field contains the group JID (e.g., 120363xyz@g.us), not the individual sender's phone number.
Resolving per-sender identity in groups required three components: (1) a message_received hook that extracts event.metadata.senderE164 and stores it in a groupLatestSender map keyed by group JID, (2) a modified lookupTier function that checks this map before falling back to the session key, and (3) a modified onAgentEvent handler that uses the map for session-level metrics.
An unexpected behavioral consequence: in group settings, the agent silently dropped requests from known-tier users to execute commands. The root cause was not a bug but a policy interaction: the agent's personality instructions ("never execute sensitive commands") combined with its group conversation heuristics ("stay silent when unsure") caused it to self-censor. The before_tool_call hook never fired because the agent never attempted the tool call in the first place. This is a case where the LLM's own safety training amplifies the defense to the point of over-restriction.
4.5 Defense-in-Depth Creates Permission Conflicts
The gateway runs with UMask=0077 to protect sensitive files in /data/openclaw/ (API keys, session state, configuration). This is correct for security-sensitive paths, but files the gateway writes to shared directories (e.g., /data/sync-org-notes/, used for Syncthing replication) inherit the restrictive permissions: 0600 openclaw:openclaw. Syncthing, running as user jespada, cannot read them.
The resolution is a 30-second systemd timer (sync-org-notes-fixperms) that runs chgrp users and chmod g+rw on the shared directory. This is inelegant but reflects a real tension: the gateway's UMask cannot be relaxed (it protects secrets), and Syncthing cannot run as the openclaw user (it manages other files under jespada). Defense-in-depth creates operational friction at the boundaries between security domains.
4.6 Fail-Mode Asymmetry Is a Design Tension
Inbound fail-closed is straightforward: if the guard chain is unavailable, block the message. The user experiences a temporary outage, which is preferable to processing unscreened input.
Outbound fail-mode is more nuanced. If the guard is down and the owner sends a message, fail-closed means the owner's own requests are blocked. If fail-open is applied universally, a non-owner's manipulated output could reach WhatsApp unscreened during a guard outage. The resolution is tier-dependent fail behavior: owner outbound is fail-open (the owner accepts the risk), while known and unknown tiers remain fail-closed. This asymmetry is not discussed in the literature, which typically treats fail-closed as a universal default.
4.7 Content Truncation Induces False Positives
The guard initially truncated web content to 2,000 characters before sending it to Lakera Guard v2. This caused false positives on legitimate web pages—specifically, job postings containing imperative phrasing ("You will manage a team of engineers, you will be responsible for..."). When truncated mid-sentence, these passages resemble system prompt overrides to Lakera's prompt_attack detector.
Increasing the truncation limit to 8,000 characters resolved the issue by providing enough context for disambiguation. Lakera's API accepts approximately 100K characters; the 2,000-character limit had been set for latency, not API constraints. The lesson: truncation is not a neutral preprocessing step; it can create the attack pattern the guard is looking for.
4.8 Undocumented Toolchain Behaviors
Several implementation delays stemmed from undocumented or poorly documented behaviors in production tooling:
- Grafana Alloy strips
__journal_*internal labels before forwarding journal log entries to downstream pipeline stages. The only way to filter or promote journal metadata (such as the originating systemd unit name) is viarelabel_rulesinside theloki.source.journalcomponent itself, not in a subsequentloki.processstage. This behavior is documented only in a GitHub issue, not in the official documentation. - Grafana Cloud's Cloudflare layer returns HTTP 403 for API requests without a
User-Agentheader. The error page is Cloudflare's, not Grafana's, making the root cause non-obvious. - Grafana Cloud API tokens for metric and log ingestion (used by Alloy) are push-only. Querying the same data back requires a separate read-scoped token or proxying through the Grafana instance API. The error message ("invalid scope requested") does not indicate which scope is missing.
- OpenClaw plugin hooks use underscores (
message_received), not colons (message:received). An incorrectly named hook is silently ignored—no error, no warning, the callback simply never fires. - V8 Thread Isolation and seccomp. Enabling the headless Chromium browser inside the systemd sandbox required five non-obvious adaptations. First,
ProcSubset = pid(minimal/proc) blocks Chromium’s startup check for/proc/sys/fs/inotify/max_user_watches; switching toProcSubset = allwhile keepingProtectKernelTunables = true(read-only/proc/sys) restores access without write capability. Second, V8’s Thread Isolation feature uses memory protection keys (PKU/MPK) via thepkey_alloc,pkey_free, andpkey_mprotectsyscalls—none of which are in systemd’s@system-servicewhitelist. Without them, Chromium core dumps ingin::ThreadIsolationData::InitializeBeforeThreadCreationwith no descriptive error; the root cause is only visible indmesgaudit logs (syscall 330 blocked). Third, the headless VM has no session bus (DBUS_SESSION_BUS_ADDRESS=disabled:) and no X server (DISPLAY=); both must be explicitly set or Chromium produces persistent error logs. Fourth, Chromium’s MemoryInfra diagnostics thread callsmincore()(syscall 27) to query page residency—also absent from@system-service. The thread receives SIGSYS and the process is killed; the fix is addingmincoreto the syscall allowlist. Fifth, Chrome’s crash reporter (chrome_crashpad_handler) requiresptrace(syscall 101) to inspect crashed child processes, which is blocked by the~@debugdeny group. When crashpad is killed by SIGSYS, the main Chrome processes follow with SIGTRAP. Rather than allowingptrace(which would weaken the sandbox), the fix is to disable the crash reporter entirely via the--disable-crash-reporterlaunch flag—acceptable in a headless, sandboxed environment where crash telemetry has no destination. These findings illustrate a tension between sandbox hardening and browser enablement: each seccomp restriction that improves the security posture of the base service may silently break Chromium, and the failure modes are crashes and audit log entries, not descriptive error messages.
These are not security vulnerabilities, but they represent a class of integration friction that is invisible in isolated prototypes and only manifests when assembling a defense architecture from multiple production systems.
5. Generalizability: Solo-Operator to Team Infrastructure
The defense architecture described in Sections 3–4 was designed for a solo operator, but the layers are structurally independent of that constraint. We are actively adapting the system for deployment within a startup's team infrastructure, targeting infra/security, observability, debugging, and development use cases.
Several components transfer with minimal modification: the guard chain (swap free-tier Lakera for a paid plan at higher volumes), action quotas and the native requireApproval hook (the before_tool_call pattern is agent-agnostic), systemd sandboxing (standard Linux primitives), and the trust tier model (the three-tier structure maps to team roles: SRE/Security as owner, Engineering as known, External as unknown).
Other components require adaptation at the interface layer while preserving the same security invariants. Identity resolution shifts from WhatsApp phone numbers to SSO or Slack user IDs. The approval channel moves from the native WhatsApp /approve command to Slack notifications with a web UI or Slack command. Egress enforcement moves from host-level nftables on a VM bridge to Kubernetes network policies or cloud security groups. Monitoring migrates from Grafana Cloud free tier to an existing Prometheus/Grafana stack—the 28 custom metrics and 21 alert rules are YAML-defined and portable.
A new challenge emerges in team settings: multi-agent coordination. The solo setup has one agent; a team deployment may have per-team or per-function agents sharing security infrastructure (guard chain, egress rules, monitoring). Whether the behavioral controls (quotas, session analysis) should be per-agent or global is an open question we expect early team deployments to inform.
Preliminary observations will be expanded with operational data as team deployments mature.
References
[1] N. Shapira et al., "Agents of Chaos: Probing the In-the-Wild Safety of Autonomous LLM Agents," arXiv:2602.20021, 2026.
[2] D. Debenedetti et al., "CaMeL: Capability-Mediated Language Models," arXiv:2503.18813, 2025.
[3] H. Xiao et al., "Sentinel Agents: Multi-Agent Oversight for Safe Agent Deployments," 2026.
[4] Y. Lu et al., "AgentSys: A Language-Driven Multi-Agent Operating System Kernel," arXiv:2602.07398, 2026.
[5] S. Min et al., "PCAS: Policy-Compiled Autonomous Systems," arXiv:2602.16708, 2026.
[6] A. Hurst et al., "Silent Egress: Discovering Exfiltration Channels in LLM Agent Deployments," arXiv:2602.22450, 2026.