TL;DR
My autonomous coding agent broke something in prod-adjacent code, and I spent two hours reconstructing what it had touched by hand. So I built it a rollback system: every risky action gets a snapshot first, and undoing a bad move takes one command instead of an afternoon. Here's how it works, what it doesn't cover, and why I now trust the agent with more autonomy, not less.
The Problem
A few weeks into running my coding agent semi-autonomously, I gave it a fairly ordinary task: "refactor the config loader to support per-environment overrides." Reasonable scope. It had permission to edit files and run shell commands in the repo.
It did the refactor. It also, as a side effect of "cleaning up", deleted a directory of environment-specific fixtures that a completely unrelated test suite depended on, then committed the whole thing in one go because I'd approved a broad git commit permission earlier in the session.
Nothing was catastrophic — this wasn't production. But reconstructing what actually got deleted, versus what was an intentional part of the refactor, took me longer than the original task would have taken me to do by hand. I had to diff against an old branch, cross-reference which files were touched by which tool call, and manually restore the fixtures one by one.
That's when it clicked: guardrails and permission prompts are a prevention mechanism. They stop the agent from doing things you've decided are off-limits. But they don't help at all when the agent does something that's technically permitted, technically in-scope, and still wrong. You can't guardrail your way out of "the agent made a bad judgment call within its allowed permissions." What you actually need for that failure mode is reversibility — the ability to undo the last N actions cheaply, without having to reconstruct history by hand.
My first instinct was to just lock things down — require confirmation before every git commit, every rm, every multi-file edit. I tried that for about two days. It turned every routine refactor into a wall of approval prompts, and I started rubber-stamping them without reading them carefully, which defeats the purpose of a permission gate anyway. Tightening the front door doesn't help if you're too fatigued to actually check who's knocking.
So instead of tightening permissions further, I built a rollback layer underneath the existing permission system. The permission prompts stay as they are — a first line of defense against clearly-off-limits actions. The rollback layer is the second line, for everything a prompt would have waved through anyway.
How I Solved It
The core idea is simple: before any action that could destroy or overwrite state, snapshot the current state first. Not a full backup of the repo — a cheap, git-native checkpoint that costs almost nothing to create and can be thrown away if unused.
Snapshotting on risky actions
I used a PreToolUse hook in Claude Code that fires before Bash and Edit tool calls. The hook inspects the command or file path against a small set of "risky" patterns — deletions, git reset, mv, rm, multi-file edits, anything touching more than a handful of files at once — and if it matches, it creates a snapshot before the tool call is allowed to proceed.
#!/usr/bin/env bash
# pre-tool-snapshot.sh — runs before risky Bash/Edit calls
RISKY_PATTERN='rm |git reset|git checkout -- |mv |force'
TOOL_INPUT="$1"
if echo "$TOOL_INPUT" | grep -qE "$RISKY_PATTERN"; then
SNAPSHOT_REF="agent-snapshot/$(date -u +%Y%m%dT%H%M%SZ)"
git stash create -u > /tmp/last_stash_id 2>/dev/null
STASH_ID=$(cat /tmp/last_stash_id)
if [ -n "$STASH_ID" ]; then
git tag "$SNAPSHOT_REF" "$STASH_ID"
echo "snapshot created: $SNAPSHOT_REF" >> .agent-snapshots.log
fi
fi
The key detail: git stash create builds a stash-like commit object without touching the working tree or the stash list. It's a throwaway commit that only exists if you tag it. That means snapshotting costs one git tag per risky action — no copying files, no slowdown, nothing to clean up if the action turns out fine.
Rolling back
When something goes wrong, rollback is a single script:
#!/usr/bin/env bash
# rollback.sh <snapshot-ref> [path...]
SNAPSHOT_REF="$1"
shift
if [ "$#" -eq 0 ]; then
# full rollback: restore everything to the snapshot
git checkout "$SNAPSHOT_REF" -- .
else
# surgical rollback: restore only specific paths
git checkout "$SNAPSHOT_REF" -- "$@"
fi
echo "restored from $SNAPSHOT_REF"
I deliberately support both a full nuke (restore everything) and a surgical restore (just the files that got wrecked), because in practice you almost never want the full nuke — the agent usually did some useful work in between the good state and the mistake, and you don't want to throw that away too.
The part that actually matters: non-git state
Here's what I got wrong on the first pass. Git-based snapshotting only covers files tracked in the repo. It does nothing for:
- files deleted outside the repo (temp directories, generated assets in gitignored paths)
- state changes from commands the agent ran (a database migration, a config value written to a running service)
- anything the agent touched via an MCP tool that isn't a filesystem write
So the hook also writes a manifest — a plain log of every file path and command the agent touched since the last snapshot, independent of whether git can see it. A typical entry looks like this:
2026-07-30T13:04:11Z snapshot=agent-snapshot/20260730T130411Z
2026-07-30T13:04:12Z tool=Bash cmd="rm -rf fixtures/staging"
2026-07-30T13:04:15Z tool=Edit path="config/loader.py"
2026-07-30T13:04:19Z tool=Bash cmd="npm run db:migrate"
It's not automatic rollback for non-git state — I haven't found a general solution for "undo an arbitrary shell command", and I'm skeptical one exists in the general case. What it does give me is a fast, reliable answer to "what did it even touch since the last known-good point?" That question used to mean grepping through tool call transcripts; now it's cat .agent-snapshots.log. That alone cut my incident response time by more than the git rollback did, because most of the time spent on the original incident wasn't restoring files — it was figuring out what needed restoring.
Testing the safety net itself
The first time I needed rollback for real, the script failed — a path-quoting bug that had never been exercised because I'd only ever run it in a demo. Now the rollback script has its own small test suite that runs a scripted "make a mess, roll it back, verify clean state" cycle in CI. A rollback mechanism you've never tested is a hypothesis, not a safety net.
Lessons Learned
If you can't undo it in one command, you won't use it under pressure. The whole point evaporates if rollback requires you to remember flags or reconstruct which snapshot is the right one while you're stressed about what just broke.
Snapshot on every risky action, not just the ones you think are "big." My pattern-matching for "risky" missed things constantly in the first week — a
mvinside a build script, a multi-file find-and-replace. I loosened the trigger to be aggressive rather than precise; a wasted snapshot costs nothing, a missing one costs an afternoon.Rollback has to cover non-git state, or it's a false sense of safety. The manifest log turned out to matter as much as the git snapshots — knowing exactly what was touched is half of "undo."
Test the rollback path like any other critical path. It will fail exactly when you need it if you've never actually exercised it end to end.
Rollback and guardrails solve different problems — you need both. Guardrails stop actions you've pre-decided are off-limits. Rollback handles the much larger category of "technically allowed, still wrong." Tightening permissions to compensate for missing rollback just makes the agent less useful without actually fixing the underlying gap.
A safety net changes how much autonomy you're willing to grant, not just how you recover from mistakes. Once I trusted that any bad action was reversible in seconds, I started approving broader permissions upfront instead of micromanaging every tool call. Counterintuitively, building an "undo" button made the agent more autonomous day to day, not less — the fear of an unrecoverable mistake was what had been driving all that manual oversight in the first place.
What's Next
Right now snapshotting is trigger-based on pattern matching, which is crude — I want to move to a diff-size heuristic instead (snapshot when a tool call is about to touch more than N files or more than N lines, regardless of what command it runs). I'm also looking at a "dry run" mode where the agent can preview the blast radius of a risky command before committing to it, which would catch some of these cases before a snapshot is even needed.
And since I now run more than one agent session in parallel, snapshot refs need to be session-scoped so rollback in one session can't accidentally restore over another session's in-progress work. Right now the ref naming is timestamp-only, which was fine for a single session and is already showing cracks now that two sessions can snapshot within the same second. The fix is straightforward — namespace the ref by session ID — but it's a reminder that a safety mechanism built for one workflow doesn't automatically generalize to the next one, and it's worth re-testing every time the surrounding setup changes.
Wrap-up
If your agent has enough permissions to be genuinely useful, it has enough permissions to occasionally make a call you disagree with. Prevention alone doesn't fix that — reversibility does. Building the rollback layer took an afternoon; it's paid for itself many times over since.
If you've built something similar — or hit a failure mode rollback doesn't cover — I'd like to hear about it in the comments. And if you're experimenting with Claude Code hooks yourself, follow me here for more of this series; I'm writing up one lesson at a time as I keep pushing on how much autonomy I can safely hand this thing.
Top comments (0)