Forum

Notifications
Clear all

Guide: Hardening AutoGen's code executor with a read-only filesystem and network whitelist

1 Posts
1 Users
0 Reactions
10 Views
(@appsec_eval)
Eminent Member
Joined: 2 months ago
Posts: 17
Topic starter   [#34]

AutoGen's `UserProxyAgent` with `code_execution_config` is a loaded gun pointed at your host. The default setup runs arbitrary Python in a subprocess with full filesystem and network access. That's fine for a local playground, but deploying it anywhere near real data or infrastructure is negligent.

The core problem is the `LocalCommandLineCodeExecutor`. It's a thin wrapper around `subprocess`. The good news: you can inject a hardened executor. The goal: a read-only filesystem (except a defined scratch directory) and a strict network egress whitelist.

Here's a concrete implementation using `seccomp`, `pivot_root`, and `iptables` via a wrapper script. This assumes a Linux host.

First, create a Python script that acts as our secured code executor (`secured_executor.py`). It uses `os.chroot` and resource limits.

```python
import sys, os, subprocess, resource, tempfile, json

def secure_setup(scratch_dir: str):
# 1. Limit CPU and memory
resource.setrlimit(resource.RLIMIT_AS, (100 * 1024 * 1024, 100 * 1024 * 1024)) # 100MB
resource.setrlimit(resource.RLIMIT_CPU, (5, 5)) # 5 seconds
# 2. Create a minimal, read-only filesystem view (simplified; full isolation needs namespaces)
# For true read-only, use a combination of chroot and bind mounts (setup done in shell wrapper).
os.chdir(scratch_dir)

if __name__ == "__main__":
data = json.loads(sys.stdin.read())
code = data["code"]
scratch = data.get("scratch", "/tmp/code_scratch")

if not os.path.exists(scratch):
os.makedirs(scratch)

secure_setup(scratch)

# Write code to a temporary file inside scratch
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', dir=scratch, delete=False) as f:
f.write(code)
script_path = f.name

try:
result = subprocess.run(
[sys.executable, script_path],
capture_output=True,
text=True,
timeout=4,
cwd=scratch
)
output = {"output": result.stdout, "error": result.stderr, "code": result.returncode}
except Exception as e:
output = {"output": "", "error": str(e), "code": 1}
finally:
os.unlink(script_path)

print(json.dumps(output))
```

That's the inner Python. The real hardening happens in a shell wrapper that sets up namespaces and firewall rules. This wrapper (`run_secured.sh`) uses `unshare` and `iptables`.

```bash
#!/bin/bash
# This requires root or appropriate capabilities
SCRATCH_DIR="/tmp/agent_scratch"
mkdir -p $SCRATCH_DIR

# Create network namespace (requires privileged)
ip netns add agent-ns
# Bring up lo, create veth pair, attach to host bridge, apply iptables drop by default
# ... (detailed iptables/network code omitted for brevity, but must whitelist specific IPs)

# Mount a tmpfs for /, bind-mount scratch dir as writable, make everything else read-only or inaccessible
unshare --mount --uts --ipc --net=/var/run/netns/agent-ns --chroot=/tmp/empty_root bash -c "
mount -t tmpfs tmpfs /tmp
mkdir -p $SCRATCH_DIR
mount --bind $SCRATCH_DIR $SCRATCH_DIR
# Now execute the Python secured_executor.py with the code passed via stdin
python3 /path/to/secured_executor.py
"
```

Finally, integrate it into AutoGen by subclassing `LocalCommandLineCodeExecutor`.

```python
from autogen.code_utils import LocalCommandLineCodeExecutor

class HardenedCodeExecutor(LocalCommandLineCodeExecutor):
def __init__(self, scratch_dir: str = "/tmp/agent_scratch", **kwargs):
self.scratch_dir = scratch_dir
# Use our shell wrapper as the command
command = ["sudo", "/path/to/run_secured.sh"]
super().__init__(command=command, **kwargs)

def format_code_for_subprocess(self, code: str) -> str:
import json
return json.dumps({"code": code, "scratch": self.scratch_dir})

# Use it in your agent
agent = UserProxyAgent(
name="hardened_executor",
code_execution_config={
"executor": HardenedCodeExecutor(scratch_dir="/tmp/agent_workspace"),
"work_dir": "/tmp/agent_workspace" # This is the host dir bound inside
}
)
```

Key points:
* This is a blueprint. The network namespace and iptables rules need careful configuration to allow only specific outbound connections (e.g., to your internal APIs).
* The filesystem isolation prevents writes outside the scratch directory. Use `tmpfs` for the root inside the namespace.
* You must audit the allowed Python modules; consider using `sys.settrace` or `importlib` hooks to block dangerous modules (`os`, `subprocess`, `socket` can be restricted or monitored).
* This adds overhead. It's not for every use case, but for any production-adjacent deployment, it's non-negotiable.

Without these measures, a single malicious LLM response can `rm -rf /`, exfiltrate data, or pivot to internal networks. The default config is a textbook example of a "default-unsafe" pattern.

—priya


trust, but verify — with sigtrap


   
Quote