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",
];
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
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:
- What's the worst single call this tool can make?
- Is that worst case reversible? (git-tracked file: yes.
rmoutside the repo, or a pushed commit: no.) - What does this tool get to read, and could any of it be secret?
- 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)
- 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}`;
}
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?
Top comments (7)
I like the idea of treating the filesystem as something the agent has to earn access to, not something it gets by default.
As posted,
authorize()denies every write that creates a new file.fs.promises.realpathrejects withENOENTfor a target that does not exist yet, and the catch turns that ordinary create case intodenied: cannot resolve. The interesting part is the shape of the repair. The natural patch is torealpaththe parent directory, check that resolved parent against the roots, then rejoin the basename. That makes creates work, and it leaves the final component unresolved.That final component is where the guarantee leaks back out. If
current-audit/notes.mdis a symlink to~/.ssh/authorized_keys, the parent resolves inside the allowed root, the rejoined path passes the root check, and the write follows the symlink. Pattern 1 closes this hole for interior path components, then the create-file fix reopens it at the leaf. The fix is to stop authorizing a string and then acting on that same string later. Open the target withO_NOFOLLOW, for examplefs.openwithO_WRONLY | O_CREAT | O_NOFOLLOW, and write through that file descriptor. A symlink leaf fails withELOOP. It also removes the window betweenauthorize()andbackupThenWrite(), where anything that can write inside/tmp/agent-scratchcan swap path state after approval.Pattern 2 has the same category problem in a different place. It is a denylist inside the allowlist, which is the construct Pattern 1 rejects on the grounds that you always forget a case. The posted patterns already show the misses:
(^|\/)\.[^/]+only matches a dot-prefixed segment, soterraform.tfvars,serviceAccount.json,config/database.yml, and adocker-compose.ymlwith inline env values all sit plainly in the project root and read clean. A consistent version of the argument would use an explicit read manifest, where only named files are visible to the agent. The other consistent version accepts that every readable file under an allowed root may carry secrets, then moves the bound to where output is allowed to travel.Question 4 is the strongest one in the checklist, and this wrapper structurally cannot answer it.
authorize()is stateless. It sees one call at a time. The sequence read-project, write-scratch, read-scratch, write-project stays inside the two allowed roots and trips no pattern, while the composition still moves content across a boundary the policy plainly cares about. Per-call authorization cannot express a property of a call chain. Answering that question means the choke point has to carry session state about what this agent has already read, not only where the current call points.Not sure how local agents like aider deal with this so far, but at least Claude and Codex have to ask you the first time they need to access a new directory outside your working directory. It seems quite reasonable to restrict them to only the working directory and a tmp location.
As for reference repos, I just add them as submodules. I don’t mind if they start changing them, they won’t be allowed to commit any changes anyway as long as you don’t allow git commit and git push commands.
Sure.. there might be some commands that you think are harmless and allow them. Later you find out that .config or .cache is gone. I agree, for such cases an explicit directory permission allow list seems quite useful. But another option is to just treat your workstation as a throwaway machine and have it completely declaratively managed by nixos for example. Those situation won’t happen often and I don’t mind recreating my development machine every now and then, state lives on GitHub and nowhere else.
Filesystem access is where local agents become useful and dangerous at the same time. The sandbox needs to understand task scope, not just machine permissions.
I like patterns where the agent can read broadly enough to understand context, but writes are constrained, logged, and easy to review. The worst setup is unlimited write access plus vague instructions to be careful.
That near-miss with the .env file is terrifyingly relatable! When granting write access to local agents, do you rely mostly on OS-level containers/Docker, or do you enforce path-permission wrappers directly inside the agent tool definitions?
Once dry run is disabled the remaining writes run without review. How do you decide the cutoff?
Some comments may only be visible to logged-in visitors. Sign in to view all comments.