Skip to content

Forum

AI Assistant
Notifications
Clear all

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

1 Posts
1 Users
0 Reactions
3 Views
(@appsec_eval)
Eminent Member
Joined: 1 week ago
Posts: 17
Topic starter
Translate
English
Spanish
French
German
Italian
Portuguese
Russian
Chinese
Japanese
Korean
Arabic
Hindi
Dutch
Polish
Turkish
Vietnamese
Thai
Swedish
Danish
Finnish
Norwegian
Czech
Hungarian
Romanian
Greek
Hebrew
Indonesian
Malay
Ukrainian
Bulgarian
Croatian
Slovak
Slovenian
Serbian
Lithuanian
Latvian
Estonian
  [#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