Back to Insights
21 Jun 20265 min readAI & LLMs

Your Agent Will Eventually Run git reset --hard. Stop It at the Door.

Give an agent a shell for long enough and it will, one day, run a command that can't be undone. The canonical case is anthropics/claude-code#34327: an agent ran git reset --hard origin/main within the first second of a session — twice — and destroyed 12 unpushed commits plus all uncommitted work. #46444 is the untracked-file version: an auto-cleanup rm-equivalent took out ~300 files that were never committed.

We've written before about getting that work back — and you often can, if it was committed. But the cheaper move is to not lose it. Claude Code has the exact mechanism for this built in, and almost nobody uses it: a PreToolUse hook. It runs before a tool call executes and can veto it. Sixty lines is enough to catch every irreversible git/rm command an agent might reach for.

The contract, in three lines

A PreToolUse hook is just a program. Claude Code pipes it JSON on stdin before running a Bash command:

{ "tool_name": "Bash", "tool_input": { "command": "git reset --hard origin/main" }, "...": "..." }

To block, you print a small JSON object and exit 0:

{ "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "…why, and what to do instead…"
} }

permissionDecision is one of allow / deny / ask / defer. To allow, exit 0 with no output and the normal permission flow continues. That's the whole API. (There's also a legacy "exit 2 = block" path, but the JSON form lets you hand a reason back to the model, which matters — more on that below.)

The guard

agent-guard reads that JSON and denies a curated list of commands that destroy work with no undo:

BlockedWhy
git reset --harddiscards all uncommitted changes; can strand commits
git clean -fpermanently deletes untracked files (never-staged work is unrecoverable)
git push --forcecan clobber commits on the remote
git branch -Dforce-deletes a branch, orphaning commits only it pointed to
git checkout/restore .throws away uncommitted edits in the working tree
git stash drop / cleardeletes stashed work with no undo
git worktree remove --forcedeletes a worktree with its uncommitted/committed work
rm -rfrecursive force-delete, no trash, no undo

When it fires, it doesn't just say "no" — it explains and points at the safe path:

$ echo '{"tool_name":"Bash","tool_input":{"command":"git reset --hard origin/main"}}' | agent-guard
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "agent-guard blocked a destructive command. `git reset --hard`
      discards every uncommitted change and can strand commits. Safer: commit/stash first, or
      `git reset --soft` to keep your changes staged. (set AGENT_GUARD_OFF=1 to override deliberately.)"
  }
}

That reason string goes back to the model. So the agent doesn't just hit a wall — it's told to commit first or use git reset --soft, and it can course-correct on its own. A guard that only blocks teaches nothing; a guard that redirects keeps the agent moving.

The part everyone gets wrong: what it must allow

A guard that cries wolf gets disabled within a day, and then you have no guard. The discipline is to block the irreversible command and wave through its safe twin:

  • git reset --hard → blocked, but git reset --soft (keeps your changes staged) → allowed
  • git push --force → blocked, but git push --force-with-lease (refuses if the remote moved) → allowed
  • git clean -f → blocked, but git clean -n (dry-run preview) → allowed
  • git branch -D → blocked, but git branch -d (refuses to delete unmerged work) → allowed
  • git restore . → blocked, but git restore --staged file (just unstages) → allowed
  • rm -rf build → blocked, but rm stale.log (single file) → allowed

Our test suite is 23 cases, and more than a third of them assert that a safe command is not blocked. That ratio is the point. The day the guard blocks something legitimate is the day someone runs AGENT_GUARD_OFF=1 permanently.

Fail safe, always

A security control that breaks your workflow when it breaks is worse than none. agent-guard only ever emits a deny; every other path is allow:

  • Garbage on stdin, a shape it doesn't recognize, a non-Bash tool → allow (exit 0, no output).
  • An explicit escape hatch: AGENT_GUARD_OFF=1 disables it for a deliberate cleanup.

It can never cause a failure — at worst it declines to protect you. That's the only acceptable failure mode for something sitting in front of every command your agent runs.

Install

Drop the script in .claude/hooks/agent-guard and wire it up in .claude/settings.json:

{ "hooks": { "PreToolUse": [ {
    "matcher": "Bash",
    "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/agent-guard" } ]
} ] } }

matcher: "Bash" means it only sees shell commands — zero overhead on Edit, Read, or anything else.

The honest limits

This is a seatbelt, not a vault. It matches on the command string, so a sufficiently creative invocation (a destructive command built from variables, or buried in a script it calls) can slip past — and a command that merely mentions reset --hard in an echo could trip a false deny. Both are acceptable for a guard whose job is to catch the obvious, high-frequency mistakes, not to be a sandbox. For true isolation you still want a real container or a non---dangerously-skip-permissions posture.

And the deepest fix is the one from the rescue post: make the agent commit early and often. A guard stops the catastrophic command; frequent commits make everything else recoverable. Prevention and cure are the same lesson from two ends — keep work on the recoverable side of the line.

We build and run autonomous agent fleets on Claude Code in production. If you're hardening a multi-agent setup, talk to us.


Get the tool → agent-guard in agent-ops — open source (MIT), a single dependency-free script, part of our toolkit for running coding agents in production.

AI agentssafetyhooksguardrailsClaude Code

Found this useful?