Guides

Claude Code Deleted My Files? The Layer That Stops It

A Claude Code delete guard is four layers that stop an agent deleting your files: an approval prompt, a deny rule, a PreToolUse hook and work that is committed and pushed. ConvOps adds isolated runs and a sign-off before merge for the runs nobody watches.

DM

David Marsa

Founder & CEO

Beginner30 min readPublished Oct 9, 2026

Last verified Oct 9, 2026

Tools and models covered:Claude CodeOpenCodeKimi K3
A file falls toward a shredder past four guards: a raised-hand prompt that unattended runs skip, a deny grille, a hook sensor gate, and a tray holding the pushed copy.

What you will be able to do

  • Know what comes back after a delete (committed work, archived files) and what does not.
  • Add a deny block to Claude Code settings and know which delete shapes it misses.
  • Write a PreToolUse hook that blocks deletes and tells the agent what to do instead, then test it with sample JSON.
  • Decide which layers protect your unattended runs, given that they skip prompts.
  • Apply the same guard shape in OpenCode and Kimi Code CLI.

Key takeaways

  • To stop Claude Code deleting your files, block deletes with a deny rule and a PreToolUse hook, and commit and push so a missed delete has a way back.
  • In our scratch runs under --dangerously-skip-permissions, a Claude Code deny rule and a PreToolUse hook each blocked rm; approval prompts never appear in unattended runs.
  • Deny rules match command text, so find -delete, /bin/rm and a Python one-liner slipped past them; our hook caught the first two and missed the third, because a hook blocks only what its patterns name.
  • A block message that names the safe alternative (move to the project's _archive/ folder) keeps the agent from improvising around the block.
  • A text-matching hook over-blocks harmless commands; test it with fixtures and accept the false positive over a missed delete.

If Claude Code deleted your files, only what exists somewhere else comes back. To stop Claude Code deleting files again, push your work, then add a deny rule and a hook. This guide sets up every layer for Claude Code in about 30 minutes and shows what each one misses.

We run a delete guard on every Claude Code session in our own repository. The examples use Marrowby Tiles, a made-up tile shop; the rules and patterns mirror ours.

Claude Code Deleted Files: What Comes Back

The way back is built before the delete, never after.

Comes backStays gone
Committed files (local or pushed); only pushed commits survive a lost or wiped folderUncommitted files removed by rm, find -delete or a script
Files a hook redirected into _archive/ instead of deletingUncommitted changes wiped by git checkout --, git reset --hard or git restore

Lost files just now? Run these in order:

  1. Look in the project's _archive/ folder.
  2. Run git status to list missing tracked files.
  3. Committed but not pushed? git restore --source=HEAD --staged --worktree <path> brings it back from your last local commit.
  4. Run git fetch, then git restore --source=origin/main <path> to get a file back as last pushed (swap in your branch name).
  5. If a commit removed the file, git log --diff-filter=D -- <path> names that commit.

Anything these miss is gone from disk.

Why Claude Code Deletes Files

A prompt protects the person at the keyboard. It does nothing for a run at 3 a.m.

Agents delete for ordinary reasons: asked to "start fresh", Claude Code clears the folder first, and in our scratch test it chose find -delete on its own. Git deletes too: git reset --hard, git clean, git restore, git checkout -- and git stash drop throw away uncommitted work, and a plain git stash hides it until you pop it.

The approval prompt is the weakest layer. People approve what they skim, and our scheduled agent runs use --dangerously-skip-permissions, so no prompt appears.

MetricValueSource
Dangerous commands caught by human reviewers in a controlled study13.6%Anthropic(opens in new tab), 2026
Dangerous commands caught by Claude Code's auto mode classifier, same study89%Anthropic(opens in new tab), 2026
Files Claude Code allegedly deleted for one developer48,000 in 103 secondsTechRadar(opens in new tab), 2026

See how ConvOps puts a sign-off step in front of every merge(opens in new tab), so a delete made during an unattended run never reaches main without a person's yes.

Which Layer Actually Stops a Delete

The deny list is a speed bump. The hook is a wall with known gaps. Pushed work is the floor.

What is a deny rule? A deny rule is a line in Claude Code's settings that refuses any Bash command whose text matches a pattern, such as Bash(rm *).

What is a PreToolUse hook? A PreToolUse hook is a script Claude Code runs before a tool call; when the script exits with code 2, the call is blocked and the agent reads the script's message.

Four layers sit between an agent's delete and your files, and only the approval prompt is skipped when nobody is watching.

A deny rule and a hook each blocked rm alone in our scratch runs. Neither stopped everything.

Each layer lets something through on its own, a find delete past the deny rule and a Python one-liner past the hook, and the next layer covers it.

Commit and Push Before an Agent Touches Anything

Unpushed work dies with the folder that holds it.

Before you start Claude Code, save everything to the remote:

git status
git add src/ data/products.json
git commit -m "Save work before the agent run"
git push

Start the agent only on a clean tree that is up to date with the remote. Our workflows push each step's output before advancing, and a stop hook blocks ending a session with unpushed work. Run risky work in a git worktree, such as ~/marrowby-shop-run-0412, or in an isolated run that starts from the remote.

Add Deny Rules to Claude Code

Deny rules match the command text, not what the command does.

This block goes in the project's .claude/settings.json:

{
  "permissions": {
    "deny": [
      "Bash(rm *)",
      "Bash(rmdir *)",
      "Bash(git rm *)",
      "Bash(git clean *)",
      "Bash(git reset *)",
      "Bash(git restore *)",
      "Bash(git checkout -- *)",
      "Bash(git stash drop *)",
      "Bash(git stash clear *)",
      "Bash(git push --force *)",
      "Bash(git push -f *)"
    ]
  }
}

Bash(rm *) and the older Bash(rm:*) behaved the same on 2.1.295, even under --dangerously-skip-permissions. Anthropic's permission modes docs(opens in new tab) state that deny rules "block in every mode, including bypassPermissions", and list auto mode as one of those modes. The permissions docs(opens in new tab) also say a deny rule "isn't a security boundary around the program". In our run, find -delete, /bin/rm and a python3 -c one-liner each deleted the file; sh -c 'rm ...' was caught.

Add a Hook That Blocks and Offers a Way Out

A block without an alternative teaches the agent to route around you.

The full .claude/settings.json, with a hooks key next to permissions (hooks guide(opens in new tab)):

{
  "permissions": {
    "deny": [
      "Bash(rm *)",
      "Bash(rmdir *)",
      "Bash(git rm *)",
      "Bash(git clean *)",
      "Bash(git reset *)",
      "Bash(git restore *)",
      "Bash(git checkout -- *)",
      "Bash(git stash drop *)",
      "Bash(git stash clear *)",
      "Bash(git push --force *)",
      "Bash(git push -f *)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/delete-guard.py"],
            "timeout": 5
          }
        ]
      }
    ]
  }
}

${CLAUDE_PROJECT_DIR} points at the project root, so the path holds after the agent changes folder. A wrong script path makes python3 exit 2, so every Bash call is blocked with a can't open file error. That fails closed, and the first command shows it.

And the script, .claude/hooks/delete-guard.py:

import json, re, sys

ARCHIVE = ("file deletion is denied, archive instead: move it to _archive/ "
           "in this project (mkdir -p _archive && mv <path> _archive/)")
PATTERNS = [
    (r"\brm\s+-rf\b", "rm -rf is irreversible recursive deletion, " + ARCHIVE),
    (r"(?<!-)\brm\b", ARCHIVE),
    (r"\brmdir\b", ARCHIVE),
    (r"\bfind\b.*\s-delete\b", ARCHIVE),
    (r"\bgit\s+checkout\s+--\s+", "git checkout -- reverts uncommitted work"),
    (r"\bgit\s+reset\b", "git reset can destroy commit history"),
    (r"\bgit\s+restore\b", "git restore reverts uncommitted work"),
    (r"\bgit\s+clean\b", "git clean deletes untracked files"),
    (r"\bgit\s+stash\b", "git stash can drop uncommitted work"),
    (r"\bgit\s+push\b.*\s(?:-f|--force)\b", "force push overwrites remote history"),
]
MESSAGE_ARG = re.compile(r"""(?:^|\s)(?:-m|--message)(?:=|\s+)(?:"[^"]*"|'[^']*'|\S+)""")

def main():
    try:
        command = json.load(sys.stdin)["tool_input"]["command"]
    except Exception:
        return 0
    scanned = MESSAGE_ARG.sub(" ", command)
    for pattern, reason in PATTERNS:
        if re.search(pattern, scanned):
            print(f"BLOCKED: Destructive command detected.\n  Reason: {reason}\n"
                  f"  Command: {command}", file=sys.stderr)
            return 2
    return 0

sys.exit(main())

Every line has a reason:

  • Exit code 2 blocks the call; the agent reads stderr.
  • (?<!-)\brm\b catches any rm token, /bin/rm included. The lookbehind keeps docker run --rm allowed.
  • Unparseable input exits 0: it never jams a session, and it goes unchecked.
  • No interpreter pattern. os.remove and shutil.rmtree one-liners pass: the hook blocks only what its patterns name.

A hook runs on every Bash call, whatever the agent was told (graph engineering). That is why a rule like this belongs in a hook, not in a CLAUDE.md that keeps growing.

Test the Hook Before You Trust It

An untested regex is a guess.

Pipe a sample payload into the script:

echo '{"tool_name":"Bash","tool_input":{"command":"find exports -mindepth 1 -delete"}}' \
  | python3 .claude/hooks/delete-guard.py; echo "exit $?"

Add one fixture per shape, including expected misses:

CommandExpected
rm -rf exports/exit 2
git checkout -- src/exit 2
docker run --rm alpine trueexit 0
git commit -m "Never rm -rf exports, archive instead"exit 0
python3 -c "import os; os.remove('exports/product-feed.csv')"exit 0, a known miss
git push -f origin mainexit 2
grep -rn "rm -rf" scripts/exit 2, a known over-block

Finish with one live block: in a scratch folder, ask Claude Code to delete a throwaway file.

One Delete Attempt, Traced

The best block is the one the agent recovers from on its own.

The hook blocks the agent's find delete with exit 2, its message names the project's _archive/ folder, and the agent moves the files there instead, so nothing is deleted.

  1. Trigger. Marrowby Tiles asks: "Regenerate the product feed export, start fresh." The agent runs find exports -mindepth 1 -delete (callout 1).
  2. Block. The hook matches \bfind\b.*\s-delete\b and exits 2; its message names _archive/ in this project (callout 2).
  3. Redirect. The agent runs mkdir -p _archive && mv exports/* _archive/ (callout 3).
  4. Verdict. 2 items moved, 0 deleted: _archive/product-feed.csv and _archive/old/ (callout 4).

What Goes Wrong With a Delete Guard

We would rather explain a false block than restore a folder.

A read-only search for "rm -rf" is blocked because the hook matches text, and we keep that false positive on purpose: a false block beats a missed delete.

Four failures we hit, each with its rule:

  • A search was blocked. A read-only grep -rn "rm -rf" scripts/ matched. Rule: keep the false positive (fail closed) and its fixture.
  • Commit messages fired the hook. "Never rm -rf exports" matched like a command. Rule: strip quoted -m and --message arguments first.
  • The archive path left the project. Our block message named a folder at our repository root, and a test agent in another project moved its files there. Rule: name _archive/ in this project.
  • A git command wiped work. git checkout -- on two folders erased uncommitted work. Rule: deny and hook git's delete commands too.

When Nobody Is Watching

Prompts are not a control for unattended runs. Gates and isolation are.

Our unattended runs skip prompts by design. Three controls carry them:

ControlWhat it doesWhat it does not do
Deny rules and the hookBlock the delete shapes they name in local runsCatch shapes they do not name
Isolated runRuns each job in its own environment, such as marrowby-run, from pushed commits, so a delete cannot reach your copyStop a command inside the run
Sign-off step before mergeA person approves before a change, a delete included, reaches mainStop a command during the run

Our workflows stop for a sign-off where the risk is (human in the loop), and our site repair loop runs in an isolated environment.

Copy the deny block and the hook today. Then run your unattended agent jobs as ConvOps tasks(opens in new tab), with a sign-off step before merge and an isolated run.

The Same Guard in OpenCode and Kimi Code CLI

One guard shape, three files.

We tested this guard in scratch projects; our day-to-day guard runs in Claude Code.

The same pre-run delete check lives in each tool's own file: a PreToolUse hook in Claude Code, a tool.execute.before plugin in OpenCode, and a hooks entry in Kimi Code CLI.

OpenCode 1.18.31

In OpenCode, deny rules in permission.bash stopped rm and find -delete, then the agent deleted with a Python one-liner. A plugin, .opencode/plugins/delete-guard.js, checks every bash command in tool.execute.before, with os.remove and shutil.rmtree among its patterns, and throws a message naming _archive/. It blocked all six shapes we tried, and the agent archived instead. opencode run --pure skips plugins, the guard included.

Kimi Code CLI 0.31.1

Kimi Code CLI (it runs models such as Kimi K3) takes a PreToolUse hook in ~/.kimi-code/config.toml:

[[hooks]]
event = "PreToolUse"
matcher = "Bash"
command = "node ~/.kimi-code/hooks/delete-guard.mjs"

The script exits 2 with the same message and, under yolo, blocked all six shapes. Hooks live only in the user config, never in the project. The native rule Bash(rm *) never matched a path with a slash; Bash(rm */**) does.

Whichever tool you run, the order is the same: push, deny, hook, test.

Steps

  1. Commit and push your work

    Run git status, commit everything and push before you start Claude Code. Run risky work in a git worktree or an isolated run that starts from the remote. Deliverable: nothing you care about exists only on your disk.

  2. Add deny rules to Claude Code

    Paste a permissions.deny block into the project's .claude/settings.json covering rm, rmdir, git rm, git clean, git reset, git restore, git checkout on a path, git stash drop and clear, and force push. Deliverable: Claude Code refuses those commands, even under --dangerously-skip-permissions.

  3. Add a PreToolUse hook that names the safe path

    Save delete-guard.py in .claude/hooks/ and wire it as a PreToolUse hook with the Bash matcher. Deliverable: a matching command exits 2 and the agent reads a message telling it to move the files to _archive/ in this project.

  4. Test the hook with sample JSON

    Pipe a sample payload for each delete shape into the script and check the exit code: 2 for deletes, 0 for docker run --rm and for commit messages. Then trigger one live block in a scratch folder. Deliverable: a fixture list that also records the known miss and the known over-block.

  5. Isolate unattended runs

    Run scheduled agent jobs in an isolated environment that starts from pushed commits, with a sign-off step before merge. Deliverable: a delete during a run reaches neither your copy nor main without a person's yes. Create a free ConvOps account at https://my.convops.app/register to run them as tasks.

Frequently asked questions

What stops Claude Code from deleting files?

Four layers stop Claude Code from deleting files: an approval prompt, a deny rule in .claude/settings.json, a PreToolUse hook that checks every Bash call, and a way back made of committed, pushed work and isolated runs. Only the last three work in unattended runs, which skip prompts. A deny rule matches command text, so find with -delete and /bin/rm slip past it, and a hook blocks only the patterns it names.

Who needs to stop Claude Code from deleting files?

Developers and teams who run Claude Code on real repositories need a delete guard, most of all when agent jobs run unattended or on a schedule. The outcome is that a delete is blocked, or the work still exists on the remote. ConvOps runs those jobs as tasks with an isolated run and a sign-off step before merge, so a delete made during a run reaches neither your local copy nor main without a person's yes.

Did Claude delete files?

To tell whether Claude Code deleted files, read the session's Bash tool calls for delete shapes: rm, rmdir, find with -delete, git checkout on a path, git reset --hard, git clean, or a python3 -c one-liner. Then run git status to see which tracked files are missing. A file a Bash command removed is gone from disk unless it was committed (pushed or local) or a hook moved it into an _archive/ folder instead.

How to prevent Claude from deleting files?

Commit and push your work before Claude Code starts. Then add deny rules for rm, rmdir and destructive git commands to .claude/settings.json, add a PreToolUse hook that exits with code 2 and tells the agent to move files to _archive/ instead, and test the hook by piping sample JSON into it. Finally, run unattended jobs in isolated runs. Deny rules match command text only, so the hook and pushed work cover what they miss.

Why did Claude delete my projects?

Claude Code deletes files when a task sounds like a reset: asked to start fresh, it clears the folder first, and in our test it chose find with -delete on its own. Git commands that revert work, such as git checkout on a path, git reset --hard or git clean, delete uncommitted changes too. Without a deny rule or a hook, nothing sits between the command and the disk.

Can Claude recover deleted files?

Claude Code gets back only what exists outside the working files: commits, local or pushed (only pushed ones survive a lost or wiped folder), and files a hook redirected into an _archive/ folder instead of deleting them. Uncommitted work removed by a Bash delete or a git revert stays gone. ConvOps isolated runs start from pushed commits in their own environment, so a delete inside one cannot reach your local copy.

Do deny rules still work in bypassPermissions mode?

Yes. In our scratch runs on Claude Code 2.1.295 with --dangerously-skip-permissions, the deny rule Bash(rm *) blocked rm, and a PreToolUse hook exiting with code 2 blocked it too, which matches Anthropic's permission modes docs. The constraint: the same deny rule let find with -delete, /bin/rm and a Python one-liner delete the file, because deny rules match command text, not what a command does.