n4nAI

Guardrails for agents with filesystem write access

Practical guardrails for agents with filesystem write access: sandboxing, path restrictions, syscall filtering, and audit patterns to ship safely.

n4n Team4 min read848 words

Audio narration

Coming soon — every post will get a voice note here.

Shipping guardrails filesystem write access agents is non-negotiable the moment an LLM can create or modify files in your environment. A single unbounded write can corrupt application state, leak secrets through temp files, or become a privilege-escalation pivot. This guide gives an ordered, implementable path from container boundaries to runtime interception that keeps autonomous agents useful without handing them the keys to the host.

1. Define the trust boundary before writing code

Start by declaring exactly which directories an agent may touch. Everything else is off-limits, including parent traversal, symlinks, and hardlinks that point outside the allowlist.

Resolve and verify absolute paths

Never trust the path string the model emits. Canonicalize it and check containment on every call.

import os

ALLOWED_ROOTS = ["/srv/agent/work", "/tmp/agent"]

def safe_path(user_path: str) -> str:
    real = os.path.realpath(os.path.abspath(user_path))
    for root in ALLOWED_ROOTS:
        if real == root or real.startswith(root.rstrip("/") + "/"):
            return real
    raise PermissionError(f"Path {real} outside allowed roots")

Pitfall: os.path.abspath does not resolve symlinks. Always use os.path.realpath after anchoring. If you skip this, an agent can write a symlink /srv/agent/work/evil -> /etc and then open /srv/agent/work/evil/passwd. Treat the allowlist as a hardcoded constant, not a configuration file the agent can read and edit.

2. Run the agent in a minimal container

A namespace boundary is your first physical line of defense. Use a container with a read-only root filesystem, a small tmpfs for mandated writes, and all capabilities dropped.

docker run --rm \
  --read-only \
  --tmpfs /tmp/agent:size=64m,mode=1777 \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --mount type=bind,source=$(pwd)/work,target=/srv/agent/work,readonly=false \
  agent-image:latest

Tradeoff: bind mounts are convenient but a wrong :ro flag exposes host paths. Mount only the exact directory and verify with mount inside the container. For stronger isolation, add a seccomp profile that blocks mount, ptrace, and chmod on setuid bits. AppArmor profiles can additionally confine which executables the agent may spawn—critical if the model tries to shell out to dd or cp.

If your agent relies on an inference gateway (n4n.ai offers an OpenAI-compatible endpoint spanning 240+ models with automatic fallback when a provider is degraded), make file writes idempotent so a retried completion doesn’t append duplicate content after a fallback trigger.

3. Intercept filesystem calls with a proxy layer

Container boundaries stop escapes but not wasteful or malicious writes inside the allowed root. Hook the language runtime to enforce policy centrally.

Monkey-patch open in Python agents

If the agent is Python, wrap builtins.open at startup:

import builtins, os
from pathlib import Path

def guarded_open(file, mode='r', *args, **kwargs):
    if any(c in mode for c in ('w', 'a', 'x', '+')):
        p = safe_path(str(file))
        if Path(p).suffix not in {'.txt', '.json', '.md', '.csv'}:
            raise PermissionError("Extension not permitted")
    return builtins.__open_original(file, mode, *args, **kwargs)

builtins.__open_original = builtins.open
builtins.open = guarded_open

This catches naive open() calls but not subprocesses or C extensions. For those, use an LD_PRELOAD shim that overrides open, openat, and write. LD_PRELOAD is portable across languages but breaks under static binaries; eBPF requires CAP_BPF and kernel 5.4+, but gives syscall-level visibility without modifying the agent code.

LD_PRELOAD sketch

A minimal C shim compiles to a shared object and filters paths:

#define _GNU_SOURCE
#include <dlfcn.h>
#include <string.h>
#include <errno.h>

typedef int (*orig_open_t)(const char*, int, ...);

int open(const char* path, int flags, ...) {
    if (strstr(path, "/srv/agent/work") == NULL && strstr(path, "/tmp/agent") == NULL) {
        errno = EACCES;
        return -1;
    }
    orig_open_t orig = (orig_open_t)dlsym(RTLD_NEXT, "open");
    return orig(path, flags);
}

Build with gcc -shared -fPIC -o guard.so guard.c and launch the agent with LD_PRELOAD=./guard.so. Pitfall: the shim must handle open64 and openat separately on glibc.

4. Enforce write quotas and content limits

Autonomous loops can spam thousands of files. Apply per-run caps on file count and total bytes.

class QuotaEnforcer:
    def __init__(self, max_files=100, max_bytes=10_000_000):
        self.files = 0
        self.bytes = 0
        self.max_files = max_files
        self.max_bytes = max_bytes

    def check(self, size_hint: int):
        if self.files >= self.max_files:
            raise PermissionError("File count quota exceeded")
        if self.bytes + size_hint > self.max_bytes:
            raise PermissionError("Total write quota exceeded")
        self.files += 1
        self.bytes += size_hint

Call check() inside the guarded open before granting write. Pitfall: size_hint from stat is unreliable for streams; wrap the returned file object to count actual bytes written. A streaming wrapper also lets you scan content for secrets before flush.

class CountingWriter:
    def __init__(self, fh, enforcer):
        self.fh = fh
        self.enforcer = enforcer
    def write(self, data):
        self.enforcer.bytes += len(data)
        return self.fh.write(data)

5. Make writes auditable and immutable

Logs are useless if the agent can rewrite them. Ship each write event to an append-only sink with a hash chain.

{
  "ts": "2024-05-12T08:21:03Z",
  "agent_id": "research-7",
  "op": "write",
  "path": "/srv/agent/work/out.json",
  "bytes": 412,
  "prev_hash": "a1f3...",
  "hash": "9c2e..."
}

Store the sink on a separate mount or remote service with chattr +a (append-only) at the filesystem level. If you must keep logs in the same container, use a dedicated tmpfs and forward over TLS immediately; assume the agent can read but not modify the remote. Retention policy should match your incident-response window, not the agent’s session length.

6. Simulate adversarial behavior in CI

Guardrails filesystem write access agents need constant verification. Add tests that try to break out:

def test_path_escape():
    with pytest.raises(PermissionError):
        safe_path("/srv/agent/work/../../etc/passwd")

def test_symlink_attack(tmp_path):
    link = tmp_path / "evil"
    link.symlink_to("/etc")
    with pytest.raises(PermissionError):
        safe_path(str(link / "shadow"))

def test_quota_enforced():
    q = QuotaEnforcer(max_files=1)
    q.check(10)
    with pytest.raises(PermissionError):
        q.check(20)

Run these in the same container profile as production. A green unit test on a dev box with different mount semantics gives false confidence. Extend the suite with fuzz inputs from the model prompt history to catch unexpected path encodings like ..%2f.

7. Enforce idempotency on model-driven writes

LLM calls can retry. If your agent writes tool outputs directly, a duplicate completion creates duplicate files. Prefix filenames with a content hash or task UUID and overwrite rather than append.

import hashlib

def target_for(content: str) -> str:
    h = hashlib.sha256(content.encode()).hexdigest()[:16]
    return safe_path(f"/srv/agent/work/{h}.json")

This dovetails with gateway-level retries: an OpenAI-compatible route that honors client routing directives will not surprise you with silent provider switches if you pin the model per task. Idempotent paths also simplify cleanup—delete by hash instead of scanning directories.

8. Monitor runtime behavior and alert

Quotas and path checks reduce risk, but anomalies still slip through. Track write rate, unique paths, and extension diversity per agent instance. Alert when an agent writes more than 2× its rolling median in a minute.

from collections import deque

class RateMonitor:
    def __init__(self, window=60):
        self.writes = deque()
    def tick(self, now):
        self.writes.append(now)
        while self.writes and now - self.writes[0] > 60:
            self.writes.popleft()
        if len(self.writes) > 200:
            raise Alert("Write burst detected")

Common pitfall: teams implement path checks but forget /proc/self/cwd symlinks or O_TMPFILE anonymous writes. Cover those in seccomp or by blocking openat with O_TMPFILE flag.

9. Treat the agent like a junior engineer with sudo off

No guardrail replaces review. Rotate agent credentials hourly, scope them to the single work directory, and alert on any write outside business hours. The goal is not perfect prevention—it is making the blast radius small and the forensic trail complete.

Ship the container, the runtime hook, the quota enforcer, and the CI adversarial suite together. That combination turns guardrails filesystem write access agents from a theoretical concern into a deployed control you can defend in a postmortem.

Tagsfilesystem-accessai-agent-guardrailssecuritysandboxing

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All sandboxing & guardrails for autonomous agents posts →