An AI coding assistant proposes commands quickly and confidently, and neither quality says anything about what happens when a command is wrong. The human’s side of the collaboration is making sure the worst case of any single command stays small.
Originally drafted in December 2025 as part of a longer best-practices guide; revised in August 2026: extracted as a standalone article, with incident examples generalized to their failure classes.
An AI coding assistant, asked to clean up a half-migrated JavaScript monorepo, proposes a tidy list:
# the assistant's proposed cleanup. Review before running anything.
rm -rf packages/
rm -f lerna.json tsconfig.json
rm -rf tests/ patches/ plan/ ~/
The first two commands are routine. The third ends with ~/. Approved as written, it deletes the home directory along with the leftover project folders: SSH keys, local repositories, credentials, dotfiles, every document the machine holds. Command sequences with this shape appear in real incident reports from AI coding sessions. Nothing about the session looks dangerous until one wrong path rides in on an otherwise reasonable list.
The lesson is not “review more carefully,” though you should. The lesson is a design principle. Every command has a blast radius: the maximum damage it can cause if something about it is wrong. Synthesis coding treats bounding that radius as engineering work, on equal footing with getting the command right in the first place.
Sizing the blast radius
I have used this lens before for calibrating code review to project risk. It applies one level down, to individual commands. The question to ask before approving anything an AI proposes is not “does this look correct?” It is “what is the worst this can do if it is not?”
| Command | Blast radius | Safer form |
|---|---|---|
rm -rf ~/ |
Your entire home directory | Never allowed, under any framing |
rm -rf . or rm -rf * |
Whatever directory you happen to be in | Move it to trash instead, and verify what landed there before anything is destroyed |
git clean -fdx |
Every untracked file, including work you have not yet added | Preview with git clean -fdxn first |
DROP DATABASE |
The database | Restore-tested backup first, explicit database name never a variable, and a connection that cannot reach production |
The second row is the one people argue with, so let me be plain about it: the safer form of a recursive delete is usually not a better-spelled recursive delete. It is not deleting yet. Move the target somewhere recoverable, look at what actually moved, then remove it. Spelling the path out helps, but it protects you only against typing the wrong path, and the failure I keep meeting is the path that reads correctly and resolves somewhere else.
A command whose worst case is one build directory deserves a glance. A command whose worst case is your home directory deserves a different mechanism, not a more careful glance.
Why capable assistants produce dangerous commands
Not malice, and mostly not incompetence either: pattern completion. Asked to clean up unused files, the assistant generates a list of things to delete, and without explicit constraints that list can grow to include parent directories, home shortcuts (~, $HOME), system paths, and glob patterns that expand past the project boundary. The model is completing a pattern. It is not weighing irreversibility.
Approval prompts help less than you would hope. A human approving a long cleanup list reads the first lines carefully and pattern-matches the rest, which is the same failure mode the assistant just exhibited. Vigilance is a consumable resource; design is not. So the defense has to live in mechanisms that keep working when attention lapses.
Prevention, confirmation, recovery
I think about safeguards in three tiers, ordered by preference.
Prevention moves the dangerous thing out of reach. For shell work, prevention is the safer forms in the table: paths anchored with ./ and spelled out in full, previews before destructive git operations, trash instead of rm where available. For tools you build that an assistant will operate, prevention starts with the tool validating paths before it deletes anything:
const fs = require('fs');
const os = require('os');
const path = require('path');
// Resolve through symlinks. For a path that doesn't exist yet, resolve the
// deepest ancestor that does and re-attach the rest.
function resolveReal(p) {
let current = path.resolve(p);
const trailing = [];
for (;;) {
try {
return path.join(fs.realpathSync(current), ...trailing.reverse());
} catch (err) {
if (err.code !== 'ENOENT') throw err;
const parent = path.dirname(current);
if (parent === current) return null;
trailing.push(path.basename(current));
current = parent;
}
}
}
function isSafeToDelete(targetPath, projectRoot) {
// The root has to exist already. A trust anchor you reconstruct from a
// missing path is not a trust anchor.
let root;
try {
root = fs.realpathSync(path.resolve(projectRoot));
} catch (err) {
return { safe: false, reason: `project root does not exist: ${projectRoot}` };
}
// And it has to be narrow enough to bound anything.
if (root === path.parse(root).root || root === os.homedir()) {
return { safe: false, reason: `project root is too broad: ${root}` };
}
const target = resolveReal(targetPath);
if (!target) {
return { safe: false, reason: `target does not resolve: ${targetPath}` };
}
if (target === root) {
return { safe: false, reason: 'refusing to delete project root' };
}
const rel = path.relative(root, target);
if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) {
return { safe: false, reason: `resolves outside project root: ${target}` };
}
return { safe: true };
}
Four details in there are the whole point, and I got every one of them wrong in some earlier version of this check.
path.resolve normalizes .. but does not follow symlinks, so a link sitting innocently inside your project and pointing anywhere on disk passes a purely lexical containment test. Only realpathSync collapses that. Comparing the resolved strings with startsWith is the classic sibling bug, where /project-backup passes a check anchored on /project; path.relative gives you containment without the string arithmetic. But rel.startsWith('..') reintroduces a smaller version of the same mistake in the other direction, because it cannot tell the parent segment ../ from an ordinary file that happens to be named ..cache, so the test has to be segment-aware. And the root itself needs checking before any of that matters: if you accept a root that does not exist, the nearest-ancestor logic will quietly invent one and every subsequent comparison is against a boundary you made up. If you accept / or a home directory as the root, the function still returns safe: true for almost everything, which is the most dangerous possible result from a function whose name promises otherwise.
Run this before every deletion and the obvious bad paths stop there: the traversals, the tilde that never expanded, the link pointing out of the tree. The safeguard lives below the conversation, where it cannot be talked out of.
Be precise about what that buys, because I have watched this exact function get described as more than it is. It constrains where, not what: a target resolving correctly inside your project can still be the one directory you needed, and this returns safe: true without hesitation. It trusts the root it is handed, so if a caller passes some ancestor that happens to contain both your project and its neighbors, everything under that ancestor looks internal. And the check and the deletion are two different moments — resolve a path, and someone or something can replace a directory in it with a symlink before the rm runs, so the thing you validated is not the thing you delete.
None of that makes the check worthless; a preflight that catches the ordinary cases is worth having. It does mean the honest name for it is a preflight rather than a guarantee.
Closing the last gap is a different kind of work, and it is worth knowing what it costs before you promise anyone a guarantee. The path-based filesystem calls most languages expose, including the ones above, take a path and resolve it at call time, which is what leaves the window open. POSIX has a family aimed at this: openat and unlinkat operate relative to an open directory descriptor rather than re-walking a path string.
Be precise about what that does and does not give you, because I got this wrong myself before checking. O_NOFOLLOW on openat refuses a symlink in the final component only, so a single call is not a safe traversal. The actual technique is to walk the path yourself, opening each directory component in turn with O_NOFOLLOW and O_DIRECTORY relative to the descriptor you already hold, until you are holding the parent of the thing you mean to remove, and then calling unlinkat with just the final name. Portable unlinkat has no no-follow flag of its own; it takes AT_REMOVEDIR for directories, and it does not need a no-follow flag because it removes a name in a directory you already have open rather than resolving a path. Some platforms add whole-path protections beyond this, and those are extensions rather than the POSIX contract.
So it is a design direction, not a drop-in fix: platform-specific, absent from most scripting runtimes, and it obliges you to write the traversal yourself. If your worst case justifies that, it is the shape to reach for. If it does not, keep the preflight and stop calling it a guarantee.
Confirmation requires an explicit human decision, with evidence. Some operations cannot be made impossible because sometimes you genuinely need them. For those, print exactly what will be removed, then require a typed confirmation rather than a y/n reflex tap. The preview is the point: a list of concrete paths gives the human a real decision instead of a ritual.
Recovery makes the mistake reversible after the first two tiers fail. Commit frequently, since git reflog recovers commits that look lost. Prefer trash over permanent deletion so there is an undo. Keep automated backups with version history running before you need them. Defense in depth means no single failure causes permanent loss.
The ordering matters. Teams reach for confirmation first because dialogs are easy to add, but confirmation degrades with repetition as approvals become reflexes. Prevention does not degrade.
The quiet variants
Home-directory deletion is the dramatic case. Three lower-drama patterns carry the same principle.
The first is the context-blind text edit. sed, awk, and shell heredocs operate on characters, not structure. I adopted a Python-only rule for complex file edits after watching a sed replacement match inside URLs and code blocks, not just the prose it was aimed at. A script that reads the file, edits in memory with awareness of what it is touching, and writes the result back has a smaller blast radius than a stream editor guessing at boundaries. Heredocs add their own failure class: they interact badly with backticks, ${}, and template literals, and they fail partway through with no error you will notice.
The second is the tilde trap. The shell expands ~ at the start of a word. Embedded inside an argument, it is not expanded:
node tool.js --output-dir=~/projects/site/content
Neither bash nor zsh expands that tilde. The program receives the literal string, and a typical tool then creates a directory named ~ inside the current one. Notice how the failure chains: a later cleanup pass will quite reasonably propose rm -rf ~ to remove the junk directory, and at word start the tilde now does expand, to your home. (The safe forms: rm -rf './~' deletes the junk; "$HOME" in shell commands where the home directory is genuinely meant.)
The third is the hardcoded absolute path. A generated command that works on your machine, with your username in the path, is a delayed failure on every other machine: a colleague’s laptop, the CI runner, your own second computer, every user of an open-source tool whose documentation inherits the path. This blast radius is spread across machines and time rather than concentrated in one directory, which makes it easy to underestimate. Use paths relative to the project root, and "$HOME" when the home directory is truly the target.
Write the rules where the assistant reads them
Correcting a dangerous command fixes one session. Persistent guidance works on the pattern. I keep destructive-command rules in the CLAUDE.md files my assistant loads at the start of every session; any persistent-instruction mechanism your tools support will do:
### Destructive command safeguards
Never include these in rm, mv, or any destructive command:
- Home: `~`, `$HOME`, `/Users/*`, `/home/*`
- Root and parents: `/`, `.`, `..`
- System paths: `/usr`, `/etc`, `/var`
- Globs that can expand past the project: `*`, `**`, `.*`
Before any destructive command: list the exact paths and resolve
each one fully before acting on it. A path that reads as project-local
can still be a symlink out, and an unresolved variable or an empty
one can turn a bounded target into a catastrophic one. Confirm each
resolved real path sits inside the project directory, refuse the
command if any target fails to resolve at all, and prefer `./`-anchored
forms. Move to trash rather than deleting, and verify what moved
before removing anything. Use Python, not sed or heredocs, for file
edits involving complex content.
Guidance does not guarantee compliance; it is the soft layer. It reduces how often dangerous commands get proposed, and when one appears anyway it gives you something concrete to point at in review. The hard layer, path validation inside the tools, catches what guidance misses.
An assistant that can execute in seconds what used to take an afternoon deserves an environment where the worst case of any single command is small. Building that environment is not overhead on the collaboration. It is the human half of it.
This article is one of a set drawn from my production best-practices notes. Its companions cover orthogonal verification (verifying results through a different mechanism than the one that produced them) and behavioral contracts for AI collaborators (recommend A, implement A).
This is part of a series on synthesis coding, the practice of building software through human-AI collaboration where the human provides direction, judgment, and domain expertise while the AI provides execution speed and breadth.
