Late at night on October 7, 2026, while I had Claude Code working on my project, a large number of files inside my Mac's home directory were deleted.
The checks Claude Code runs before executing a command, and the hooks I wrote myself, all look at the one command line Claude types. They can't see a path that gets passed to rm -rf inside a script that command runs. That's why my main defenses are now the sandbox, which restricts where processes can write at the OS level, and backups.
About me: I work as a PM at an IT company and build three iOS apps with Claude Code. The one available in English is LifeCounter: one question a day, and the time you have with your parents.
TL;DR
- Cause (my best guess): during a task to add a guard to a script that stops it when its argument is the home directory, a subagent ran the test before writing the guard, and the test handed my real home directory to the script
- Damage: about 9 days of unpushed commits were lost along with the local
.git. I recovered 1,004 files from the conversation transcripts, but anything the subagents wrote was not in the transcripts and could not be recovered - Main defenses: the sandbox, which restricts write locations at the OS level, and a backup off the Mac. Deny rules and hooks are secondary
- Three things to do first (how to verify each is in "Things to check in your own setup"):
- In
~/.claude/settings.json, setsandbox.enabled: trueandallowUnsandboxedCommands: false. The OS restricts where commands can write, and Claude can no longer retry a blocked command outside the sandbox. Commands that don't work inside the sandbox are listed by name inexcludedCommandsto run outside it - Back up off the Mac with Time Machine or similar, and push commits right after you approve them. Even inside the sandbox, files in the working directory can be deleted
- Run tests for deletion logic only inside a per-test temporary directory. If you pass a real path, the real thing gets deleted
- In
- What runs outside the sandbox: Xcode tests and builds,
xcrun simctl,gh, andgit push. I keep this list short, with a reason and a compensating control for each - Settings, hooks, and 68 tests: https://github.com/ryodevapp/claude-code-guards
This reflects my setup as of October 19, 2026 (macOS, Claude Code CLI 2.1.292 and VS Code extension 2.1.301).
What happened
I had Claude Code developing an iOS app overnight in auto mode. Auto mode hands the per-action permission prompts to a classifier (a separate model) instead of a human.
Me --> Main session --> Worker subagents x3 --> Review subagent
|
| passes
v
Next task
(no check by me)
At the time:
- I had neither the sandbox nor Time Machine set up
- Every instruction to a subagent included "do not push"
sign_macos27.sh is a shell script I use to sign and verify files for the Shortcuts app. It runs rm -rf on a directory it receives as an argument. "RED" in the table below is the TDD step where you run the test before implementing, to confirm it fails.
| Time (JST) | What happened |
|---|---|
| 01:58 | The review subagent proposed adding a guard to sign_macos27.sh that stops it if the argument is the home directory. The main session asked a worker subagent to do it |
| 02:01 | The subagent's last log entry: "Add guard/select tests and run RED" |
| 02:04 | The main session reported "files have been deleted en masse" and "we don't think this was caused by our work" |
| 08:02 | I read the report and replied: "What?" |
| 08:04 | The main session corrected its 02:04 "this wasn't our work" as "a claim with no evidence" |
When I read the report in the morning, I blurted out, "You've got to be kidding me!" About 9 days of commits I hadn't pushed to GitHub were gone, together with the local .git.
The subagent's logs were inside the deleted directory, and a macOS update had also run that same night, so I can't say for certain what deleted the files.
Why the guards didn't stop it
Before executing a command, Claude Code reads the one command line Claude typed and decides whether to allow or block it. The exact line from that night isn't in the records, but based on the subagent's last log entry, I believe it was a line that ran the tests. I think the deletion happened outside that line, inside the script the test called.
+------------------------------------------------------------------+
| What the pre-execution checks see: the one line Claude types |
| |
| Command that runs the tests (my guess) |
| - no rm, no home directory path in it |
+------------------------------------------------------------------+
|
v
Pre-execution checks
(deny rules / critical path protection / auto mode classifier)
|
| nothing dangerous in the text -> allowed (my guess)
v
+------------------------------------------------------------------+
| What they don't see: what that command runs |
| |
| Test runs in RED (guard not written yet) |
| -> passes the real home directory to sign_macos27.sh |
| -> rm -rf deletes the contents of the home directory <---+ |
+---------------------------------------------------------------|--+
|
Sandbox (not set up): if present, the OS would have denied ---+
writes outside the working directory |
Backup (none): if present, I could have restored from it -----+
The upper box is what the pre-execution checks can see, the lower box is what they can't, and the two layers at the bottom are the ones I didn't have at the time. The main session asked for a guard to be added, and the subagent's last log entry was "Add guard/select tests and run RED." I read that as: add the guard and its tests, then run RED. I believe the test was meant to confirm that sign_macos27.sh stops when given the home directory. In the RED step the guard hadn't been written yet, so I believe sign_macos27.sh ran rm -rf on the real home directory it was handed. All the pre-execution checks could see was a single line that "runs the tests," and there should have been no reason to block it.
The official docs also say that a Bash rule looks at the line Claude writes:
A Bash rule matches the command text Claude writes, after Claude Code splits compound commands and strips wrappers. It doesn't match the same program invoked in a different form, so a deny or ask rule covers the invocation Claude usually produces and isn't a security boundary around the program.
There are three checks that run before a command executes. In this post I call them together "the pre-execution checks."
| Name | What it does | Why it didn't stop this |
|---|---|---|
| Deny rules | Deny commands and tool calls that match configured patterns | A line that runs the tests doesn't match any pattern |
| Critical path protection |
rm and rmdir targeting the filesystem root, the home directory, the working directory, and similar are never auto-approved by allow rules or hooks (docs) |
It covers rm and rmdir that Claude types. The docs don't say it covers an rm inside a script file called from a test |
| Auto mode classifier | Blocks, by default, irreversibly deleting files that existed before the session | The classifier receives user messages, tool calls, and CLAUDE.md, but not tool results (such as the contents of a script read with Read) (docs) |
If Claude had typed rm -rf ~ directly, critical path protection should have denied it. The hooks I added afterwards receive that same single line, too.
Details of how critical path protection behaves
rm and rmdir targeting a critical path show a prompt if you're in a terminal, and are denied if you don't answer within 2 minutes. Outside a terminal, they're denied without a prompt (v2.1.281 and later)bash -c. Script files aren't mentioned
The overall approach
Without the sandbox, a command you allow can write anywhere your user account can write. The sandbox is enforced by the OS on the running process, so it holds no matter what the model runs and even when an allowed command does more than its name suggests (docs). So I made the sandbox and backups my main defenses, and use deny rules and hooks as a secondary layer that stops common dangerous patterns early.
| Layer | What it can't prevent |
|---|---|
| Sandbox (main). Restricts where shell commands and their child processes can write and connect, at the OS level | Deletions inside the working directory (including .git and unpushed commits). Repositories under a parent directory when you start Claude Code in that parent. Anything that runs outside the sandbox (tools like Read, Edit, and Write, MCP servers, hooks, and commands moved outside via excludedCommands) |
| Backups (main). Time Machine, plus pushing commits right after I approve them | The deletion itself |
| Deny rules (secondary). Deny calls that match fixed patterns | Other ways of writing the same thing, like /bin/rm … or bash -c '…'
|
| Hooks (secondary). Check delete and write targets before a tool runs | Paths assembled inside a script. Deletions written in a different form. By default, when a hook times out or fails to start, the action proceeds, so I block it with "onFailure": "block" (effective in the CLI from 2.1.295) (docs) |
I recovered 1,004 of the deleted files from the conversation transcripts, but anything the subagents wrote wasn't in the transcripts, so they were no substitute for a backup.
Other official guidance points in the same direction:
- OpenAI's Codex also, in its CLI and IDE extension, has no network access and limits writes to the workspace by default
- OWASP LLM06:2025 Excessive Agency also recommends limiting the permissions granted to tools and extensions connected to an LLM to the minimum necessary
Configuration details
The full setup is at https://github.com/ryodevapp/claude-code-guards.
In The three On macOS, managed (administrator) settings go in On Linux and WSL2, Sandbox settings
~/.claude/settings.json (excerpt):
{
"sandbox": {
"enabled": true,
"allowUnsandboxedCommands": false,
"failIfUnavailable": true,
"excludedCommands": ["xcodebuild *", "xcrun simctl *", "gh *", "git push *", "Tools/test.sh", "Tools/test.sh *"],
"network": {
"allowLocalBinding": true,
"allowAllUnixSockets": true,
"allowMachLookup": ["*"]
},
"filesystem": {
"allowWrite": ["~/Library/Developer", "/opt/homebrew/share/flutter/bin/cache", "~/.pub-cache",
"~/.dartServer", "~/.dart-tool", "~/.flutter", "~/.config/flutter",
"~/.cocoapods", "~/Library/Caches/CocoaPods", "~/Library/Caches/com.charcoaldesign.swiftformat",
"~/Library/Caches/SwiftLint", "~/Library/Caches/org.swift.swiftpm", "~/Library/Logs/CoreSimulator"],
"denyRead": ["~/.ssh", "~/.aws", "~/**/.env", "~/**/*.p8"],
"denyWrite": ["~/**/Tools/test.sh", "~/**/.githooks",
"~/Projects/*/.git/hooks", "~/Projects/*/.git/config", "~/Projects/*/.claude"]
},
"credentials": {
"files": [
{ "path": "~/.ssh", "mode": "deny" },
{ "path": "~/.aws", "mode": "deny" },
{ "path": "~/.config/gh", "mode": "deny" },
{ "path": "~/.netrc", "mode": "deny" },
{ "path": "~/.git-credentials", "mode": "deny" },
{ "path": "~/.npmrc", "mode": "deny" }
],
"envVars": [{ "name": "ANTHROPIC_API_KEY", "mode": "deny" }]
}
}
}
allowWrite, I added the places where Flutter, CocoaPods, SwiftLint, and others write their caches. Instead of moving those tools outside the sandbox with excludedCommands, I add their write locations so they can run inside it.network settings weaken the sandbox. I added them for the simulator's XPC and for flutter test listening on localhost. allowAllUnixSockets is there for the sockets Xcode's tests use. Tightening these two (allowMachLookup and allowAllUnixSockets) is on my list under "Reducing what runs outside the sandbox." The official docs list allowing Unix sockets and allowing writes to locations where executables live as possible ways out of the sandbox./Library/Application Support/ClaudeCode/managed-settings.json (requires sudo).managed-settings.json:
{
"sandbox": {
"enabled": true,
"failIfUnavailable": true,
"allowUnsandboxedCommands": false
}
}
Key
What it does
allowUnsandboxedCommands: falseCommands blocked by the sandbox can't be re-run without it
allowUnsandboxedCommands: false in managed settingsThe sandbox becomes admin-required, and loosening settings like
excludedCommands and allowWrite written in a repository's .claude/settings.json or .claude/settings.local.json are ignored. Setting it only in user settings doesn't do this (docs)
failIfUnavailable: trueStops at startup in environments where the sandbox isn't available
excludedCommandsCommands that run outside the sandbox. A call runs outside the sandbox only when every command in it matches
~/Projects/*/… in denyWrite
When Claude Code is started in the parent
~/Projects, blocks writes to .git/hooks, .git/config, and .claude in the repositories under it
credentialsMakes credentials unreadable from commands inside the sandbox. Doesn't apply to commands in
excludedCommands
allowWrite and denyWrite entries (including Edit(...) rules merged into the sandbox) that contain *, ?, or [ don't take effect. I've only verified this setup on macOS.
Deny rules
~/.claude/settings.json (excerpt):
{
"permissions": {
"deny": [
"Bash(git push --force*)", "Bash(git push -f*)", "Bash(git push * --force*)", "Bash(git push * -f*)",
"Bash(git push * +*)", "Bash(git push * :*)", "Bash(git push * -d *)",
"Bash(git push *--delete*)", "Bash(git push *--mirror*)",
"Bash(git reset --hard*)", "Bash(git clean -f*)", "Bash(git branch -D*)",
"Bash(gh alias *)", "Bash(gh extension *)", "Bash(gh repo delete*)", "Bash(gh release delete*)",
"Bash(gh repo edit*)", "Bash(gh gist *)", "Bash(gh secret set*)", "Bash(gh auth token*)",
"Bash(gh api *-X*)", "Bash(gh api *--method*)", "Bash(gh api *-f*)", "Bash(gh api *-F*)", "Bash(gh api *--input*)",
"Read(~/.ssh/**)", "Read(~/**/.env)",
"Edit(~/**/Tools/test.sh)", "Edit(~/**/.githooks/**)",
"Edit(~/.claude/hooks/**)", "Edit(~/.claude/settings.json)",
"mcp__xcode"
]
}
}
Edit(...). They apply to every built-in file-editing tool, such as Edit and Write. Read(...) and Edit(...) deny rules are also merged into the sandbox settings, so they apply to Bash inside the sandbox too (but not to commands in excludedCommands)Edit on the hook files and settings.json keeps Claude from rewriting the hooks. The Xcode MCP server, which runs outside the sandbox, has all of its tools denied by server name (I had never called it)gh and git push, which run outside the sandbox. A broad pattern like gh * delete* would also match the body of gh pr create, so I list subcommands instead
Warning: When you use Delete hook (Bash, Monitor) Write hook (Edit, Write) I had a review subagent go over the hooks four times, and each round found bypasses. I fixed all of them and added them to the tests. The three below are examples I picked from those bypasses so that anyone writing their own hooks can check for them in their tests. All of them are blocked now. Since the hooks block whenever they can't decide, there are false positives. They've blocked commands that delete nothing, such as a Python script for editing an article that contained the string Overwrites through redirection, The full list of checks is in the README.Hook checks
~/.claude/settings.json (excerpt):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Monitor",
"hooks": [
{ "type": "command", "command": "/bin/bash", "args": ["/Users/YOU/.claude/hooks/guard-delete.sh"], "timeout": 10, "onFailure": "block" }
]
},
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
{ "type": "command", "command": "/bin/bash", "args": ["/Users/YOU/.claude/hooks/guard-write.sh"], "timeout": 10, "onFailure": "block" }
]
}
]
}
}
args, the hook command is started without a shell. ~ isn't expanded, and in my environment the hook exited with code 127. With onFailure: block, 2.1.295 and later treat this as a failure and block. In earlier versions, 127 isn't the blocking exit code (2), so the command goes through. Replace YOU with your own username, and confirm that grep -n '/Users/YOU' ~/.claude/settings.json prints nothing.
.git), and inside temporary directoriesbash -c, eval, heredocs, | bash, python -c, and similar are parsed down to their contents. perl -e, node -e, ruby -e, awk, swift -e, and similar aren't parsed; they're blocked if they contain words that delete or move things, like rmtree, rmSync, unlink, or system
os.path.join(os.sep, …) or string concatenation, are blocked. Anything that can't be resolved, like unknown variables or $(...), is also blocked. Symlinks are judged by their targetgit push, xcodebuild, Tools/test.sh, and other commands that run outside the sandbox are allowed only inside repositories directly under ~/Projects
"onFailure": "block" blocks the action. It takes effect in the CLI from 2.1.295; Homebrew's 2.1.292 ignores it as an unknown key
~/Projects, inside temporary directories, and in a few specific places such as Claude's memory and plan files.git and config files that could add things running outside the sandbox (.claude/, .mcp.json, .githooks/, and so on) are blocked.git." /opt/homebrew and ~/.oh-my-zsh are git repositories too, and being able to write there means being able to hijack PATH
"r""m" -rf ~/x
$C -rf ~
git push to a repository created in a temporary directory, so that the destination's hooks run outside the sandboxrm -rf ~, and backticks in a grep search term. I added each one I found to the tests and fixed it.cp, sed -i, or truncate are part of normal editing, so the hooks don't block them. The sandbox stops writes outside the repository, and inside the repository I recover with commits and pushes.
The tests pass a fake home directory (Test example
/fake/home) to the decision function. They never run any deletion outside a temporary directory. There are 68 tests across the two hooks. The excerpt omits the definitions of ENV and fake_repo_of.tests/test_guard_delete.py (excerpt):
HOME = "/fake/home"
PROJ = "/fake/home/proj"
TMP = "/fake/tmp"
def problems(cmd, cwd=PROJ):
return g.judge(cmd, cwd, None, HOME, [TMP], ENV, repo_of=fake_repo_of)
class BlocksOutside(unittest.TestCase):
def test_home_and_root(self):
for cmd in ["rm -rf ~", "rm -rf ~/Projects", "rm -rf $HOME/.claude", "rm -rf /",
"rm -rf ../other-project", "rmdir ~/Desktop", "unlink ~/.zshrc"]:
with self.subTest(cmd=cmd):
self.assertTrue(problems(cmd), cmd)
What runs outside the sandbox
Commands that don't work inside the sandbox, and commands that need credentials I've made unreadable from inside it, are listed in excludedCommands and run outside. The first four rows are commands I deliberately run outside the sandbox, and each has a compensating control. MCP servers run outside the sandbox regardless of settings, so I list them in the last row.
| Runs outside | Why it runs outside | Compensating control |
|---|---|---|
xcodebuild, Tools/test.sh (my own script that runs tests with xcodebuild) |
Inside the sandbox it can't open a pseudo-terminal, and not a single Xcode test runs |
Tools/test.sh can't be modified, thanks to denyWrite and the Edit deny rule. The hook allows it only inside repositories directly under ~/Projects. What the tests and builds do isn't restricted, so tests for deletion logic run only with fake paths or temporary directories |
xcrun simctl |
Used to boot the simulator, reinstall the app, and take screenshots | The hook blocks simctl spawn, which can run arbitrary programs, and exports to locations outside the repository. Reinstalling and launching the app are allowed |
gh |
Its credentials, ~/.config/gh, are made unreadable from inside the sandbox |
Deny rules block the main subcommands that delete or modify things |
git push |
Push authentication goes through gh, which uses the same ~/.config/gh
|
.githooks and .git/hooks aren't writable. The hook allows it only inside repositories directly under ~/Projects, and blocks pushes to local paths |
| Xcode MCP server (only in the repository of one of my apps) | MCP servers run outside the sandbox by design, with my permissions | Deny rules deny all of its tools. I had never called it |
All of these are calls outside the sandbox, so they're also evaluated by the auto mode classifier (docs).
The official docs warn about what to put in excludedCommands:
An excluded command runs with your full access. … If you write a pattern that covers an interpreter, a script inside your working directory, … Claude can write that file and then run it outside the sandbox.
Claude writes the code for the Xcode tests, so xcodebuild falls into this pattern. That's why, on top of the controls above, I keep pushing and running Time Machine so that anything deleted can be restored.
Reducing what runs outside the sandbox
- Done: added
"onFailure": "block"to both hooks. Commands are now blocked even when a hook ends in a timeout or fails to start. It's in effect in the VS Code extension I normally use (2.1.301). It takes effect in the CLI from 2.1.295 - Try whether Xcode tests can run inside the sandbox. Run a probe test that only creates one file outside a temporary directory; if at least one test runs and the file isn't created, remove
xcodebuildfromexcludedCommands - Narrow the sandbox's
networkallowMachLookup: ["*"]down to the service names actually needed, and run withoutallowAllUnixSockets, allowing only the sockets that get blocked
My permission mode is still auto. Commands inside the sandbox have their write locations restricted by the OS, so by default they're allowed without going through the classifier (docs). For deletions inside the working directory, the hooks are what helps, and anything that still gets deleted can be restored with pushes and Time Machine. Only the things in the table run outside, so rather than stopping to approve every action, I delegate with this setup.
Things to check in your own setup
- Do you have commits you haven't pushed yet? If your subagent instructions say "do not push," do you really need that?
- Do you have a backup off your Mac? Time Machine or the cloud, either is fine
- Is
sandbox.enabledset totruein~/.claude/settings.json? Have Claude runtouch ~/sandbox-probeand check that it fails withOperation not permitted. Have you put an interpreter or a script inside your working directory inexcludedCommands? - Do your tests for deletion logic use real paths? With pytest, use a per-test temporary directory like
tmp_path. If you create a temporary directory in shell, is it written so it stops as soon as creation fails? - Does
.claude/settings.local.jsonalso containexcludedCommands,allowWrite, oradditionalDirectories? The mode you pick in the/sandboxscreen is saved to this file and applies only to that project. In my environment, this file in another repository held only allow rules such asBash(dart run:*). If you make the sandbox admin-required through managed settings,excludedCommandsandallowWriteentries in this file are ignored, but allow rules are not
For item 4, here's one way to write it. ${D:?} stops if the variable is empty. In my environment, mktemp -d once failed, D stayed empty, cd $D ran, and I ended up in my home directory (the sandbox denied the writes that followed).
set -eu
D=$(mktemp -d)
cd "${D:?}"
Wrapping up
As far as I can tell, the hole wasn't in the one command line Claude typed, but inside the tests and scripts that command ran. Checks that look at that one line can't reach that far, so I rely mainly on the OS-enforced sandbox and backups, and keep what runs outside the sandbox to a minimum.
What do you have in place to keep Claude Code from running destructive operations you didn't intend, and for when it does?
Related links
- The iOS app I build with this setup: LifeCounter (App Store)
- Settings, hooks, and tests: https://github.com/ryodevapp/claude-code-guards
- A post about the same incident, focused on the mindset rather than the settings (in Japanese): note
Top comments (0)