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.

The threat model is dumber than you think

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.

Pattern 1: allowlist roots, never denylist paths

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",
];

Enter fullscreen mode Exit fullscreen mode

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.

Pattern 2: deny dotfiles and secrets by default, even inside allowed roots

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.

Pattern 3: dry-run mode that prints the diff

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."

Pattern 4: read-only bind mounts for reference material

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

Enter fullscreen mode Exit fullscreen mode

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.

Pattern 5: the blast radius checklist

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

  1. What's the worst single call this tool can make?
  2. Is that worst case reversible? (git-tracked file: yes. rm outside the repo, or a pushed commit: no.)
  3. What does this tool get to read, and could any of it be secret?
  4. Can output from this tool influence a later, more dangerous call? (read tool feeding a write tool means injection through file contents is on the table)
  5. What's the narrowest scope that still does the job?

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.

A wrapper that enforces the policy

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}`;
}

Enter fullscreen mode Exit fullscreen mode

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?