cd /news/ai-agents/sandboxing-patterns-for-local-ai-age… · home topics ai-agents article
[ARTICLE · art-75637] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Sandboxing Patterns for Local AI Agents With Filesystem Access

A developer shares sandboxing patterns for local AI agents with filesystem access, emphasizing allowlists over denylists, path canonicalization, dry-run modes, and read-only mounts to prevent accidental damage from confident model mistakes.

read5 min views1 publishedJul 27, 2026

A while back I wrote about running local AI agents on your own code, and it became the most-read thing I've published. The most common follow-up question, by a wide margin: "okay, but you gave it write access to your disk, doesn't that terrify you?"

It should, a little. The first week I had a local agent with real filesystem tools, it "cleaned up" a directory by rewriting a config file I hadn't asked it to touch. Nothing was lost, git had my back, but I sat there looking at the diff thinking: this thing was three characters away from editing .env

instead. A 7b model doesn't need to be malicious to hurt you. It just needs to be confidently wrong once, with write permissions.

So this is the follow-up: the patterns I actually use to give local agents filesystem access without holding my breath. None of this is exotic. All of it is the same boring security thinking I apply to smart contracts, pointed inward.

With cloud agents people worry about prompt injection and exfiltration. Those matter locally too, especially if your agent reads untrusted files (a cloned repo can absolutely contain text aimed at your agent, I see variants of this in the wild through my repo-scanner work on Argus Lens). But for local agents the dominant risk is more mundane: the model misunderstands, and its misunderstanding is executed with your permissions.

Wrong file, right operation. Right file, too-broad operation. A path that resolves somewhere you didn't expect. Plan your defenses for confident stupidity first and malice second, and you'll cover most of both.

The instinct is to block dangerous places: not in /etc

, not in home config. Denylists fail the way they always fail, you forget a case. Invert it. The agent gets an explicit list of directories it may touch, and everything outside them is denied by default:

const ALLOWED_ROOTS = [
  "/home/pavel/projects/current-audit",
  "/tmp/agent-scratch",
];

Two roots is typical for me: the project under work, and a scratch directory the agent can mess up freely. That's it. The agent doesn't need your whole home directory any more than a contract needs an unrestricted delegatecall

.

The critical implementation detail: resolve paths before checking them. projects/current-audit/../../.ssh/id_ed25519

passes a naive prefix check. Canonicalize first, then compare, and treat symlinks with suspicion because a symlink inside an allowed root can point anywhere.

Inside an allowed project directory there are still files the agent has no business touching. My rule: anything starting with a dot, plus known secret-bearing names, is invisible to the agent unless I explicitly grant it per session.

.env

is the obvious one. Also .git

(an agent that writes into .git

can corrupt your repo or, worse, plant hooks), credentials files, key material. Deny reads too, not just writes: an agent that reads .env

will happily paste your API key into a generated file, a commit message, or a summary that later leaves your machine.

Every write tool in my setup has a mode where it doesn't write. It prints what it would do, as a unified diff, and stops. New agent, new prompt, new model version: dry-run stays on until I've watched enough proposed changes to trust the combination.

The diff format matters. "I will update config.ts" tells you nothing. Seeing the actual before-and-after lines is what let me catch that config rewrite in week one. Cheap to build, and it converts "trust me" into "check me."

Agents often need to read things they should never write: dependency sources, a reference repo, documentation trees. Instead of adding those to the allowlist and hoping, mount them read-only:

mkdir -p /home/pavel/agent-ro/reference-repo
sudo mount --bind -o ro /home/pavel/projects/reference-repo /home/pavel/agent-ro/reference-repo

Now enforcement lives in the kernel, not in my TypeScript. Even if my wrapper has a bug, a write to that tree fails at the OS level. Defense in depth means the second layer catches what the first one misses. If you'd rather go further, running the whole agent in a container with explicit volume mounts gets you the same property plus process isolation, but the bind mount is the eighty-percent version you can set up in a minute.

Before I enable any tool for an agent, I answer five questions in writing:

rm

outside the repo, or a pushed commit: no.)If question 2 comes back "irreversible," the tool either doesn't get enabled or gets a human-confirmation gate. This is exactly how I think about reviewing a contract's external calls, and it transfers cleanly: enumerate what can go wrong before it's live, not after.

Here's a trimmed version of the wrapper every filesystem tool goes through. The point is the shape: one choke point where policy lives, so individual tools stay policy-free.

import { realpath } from "node:fs/promises";
import path from "node:path";

interface FsPolicy {
  allowedRoots: string[];
  deniedPatterns: RegExp[];
  dryRun: boolean;
}

const policy: FsPolicy = {
  allowedRoots: ["/home/pavel/projects/current-audit", "/tmp/agent-scratch"],
  deniedPatterns: [
    /(^|\/)\.[^/]+/,          // any dotfile or dot-directory
    /(^|\/)\.env(\.|$)/,      // .env and variants, redundant on purpose
    /id_(rsa|ed25519)/,
    /\.(pem|key)$/,
  ],
  dryRun: true,
};

async function authorize(requested: string, mode: "read" | "write"): Promise<string> {
  const resolved = await realpath(path.resolve(requested)).catch(() => {
    throw new Error(`denied: cannot resolve ${requested}`);
  });

  const inRoot = policy.allowedRoots.some(
    (root) => resolved === root || resolved.startsWith(root + path.sep),
  );
  if (!inRoot) throw new Error(`denied (${mode}): ${resolved} outside allowed roots`);

  if (policy.deniedPatterns.some((p) => p.test(resolved))) {
    throw new Error(`denied (${mode}): ${resolved} matches denied pattern`);
  }
  return resolved;
}

async function writeFileTool(requested: string, content: string): Promise<string> {
  const target = await authorize(requested, "write");
  if (policy.dryRun) {
    return `DRY RUN, would write ${content.length} bytes to ${target}:\n` +
      renderDiff(await currentContent(target), content);
  }
  await backupThenWrite(target, content);
  return `wrote ${target}`;
}

Note that realpath

resolves symlinks before the root check, that denial errors go back to the model as tool results (models actually adapt when told "denied: outside allowed roots"), and that the real version backs up every file before writing because git doesn't cover untracked files.

None of this makes an agent safe in some absolute sense. What it does is bound the damage of any single bad decision to a space you've consciously chosen and can recover from. That's all sandboxing has ever been, and it's enough to let you use these tools without flinching.

Which tool in your agent setup has the biggest blast radius right now, and have you actually written it down?

── more in #ai-agents 4 stories · sorted by recency
── more on @argus lens 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/sandboxing-patterns-…] indexed:0 read:5min 2026-07-27 ·