[BUG] [area:tool-use] [platform:macos] Write tool's full-file-replacement default causes irrecoverable data loss on governed, untracked state files — no append-only or protected-path mechanism available
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Environment
- Platform: macOS Darwin 25.6.0 (Apple Silicon)
- Claude Code CLI:
2.1.173 (Claude Code)(confirmed byclaude --version) - Transport: CLI / local subprocess
- Context: Agentic session with long-running cross-session continuity file (
STATE.md) maintained as untracked (deliberately excluded from git, so git is not a backstop)
Summary
The Claude Code Write tool's semantics are whole-file replacement with no append mode and no way to declare a file as protected from full-file replacement. When a session's instructions say "add a new dated section to this file," the model will reach for Write — which truncates and replaces the entire file — rather than Edit (surgical/append). This caused two irrecoverable or near-irrecoverable overwrite incidents on a cross-session state file in two days, losing carry-forward content that existed nowhere else. The file is deliberately untracked (git is not a backstop by design), and the only recovery paths available were a session-context in-memory copy and hourly Time Machine snapshots (not shell-recoverable without root).
Reproduction Shape
- Create a file (
STATE.md) that is deliberately untracked in git (gitignore + git hook block), and contains cross-session carry-forward content. - Configure instructions telling the agent to "add a new section" to this file at the start of a new calendar day.
- Dispatch a session. The agent sees a file whose header date differs from today's date and interprets the instruction as requiring a full new format write.
- The agent calls
Writewith the new content, full-file-replacing the existing carry-forward content. - The prior session's carry-forward items are gone. The file is untracked, so
git restoreis unavailable.
Expected Behavior
There should be a mechanism — configurable at the project or settings level — to declare that the Write tool must not perform whole-file replacement on a specified file path or basename. An "append-only" or "no-clobber" concept for Write would allow operators to protect files that are architectural append-only ledgers.
Alternatively, the Write tool should provide an append mode distinct from its current replace-everything behavior, so instruction-authors can specify intent explicitly.
Actual Behavior
The Write tool always performs whole-file replacement. There is no way to prevent it at the tool configuration level. Instructions telling the agent to "add a section" can and do resolve to Write, especially on date-rollover conditions where the agent interprets the instruction as "write the new format." The result is silent and immediate data loss with no undo at the tool layer.
Impact
Irrecoverable data loss on untracked state files. In our case:
- The affected file (
STATE.md) is a cross-session continuity ledger intentionally excluded from git (git would track unrelated operational content). Git is not a backstop by design. - Two full-overwrite incidents occurred within two days (same failure class: Write on a governed append-only file). In the first incident, task-specific carry-forward content survived only because it was still in the same session's in-memory context. In the second, recovery required a SessionStart hook that had been wired specifically to embed the file's contents in a session-open capture — without that hook, the only recovery would have been a Time Machine mount (requires root, not shell-automatable).
- The failure is particularly insidious because the tool performs a silently successful write with no indication that prior content was discarded. The model receives no error; the operator has no signal.
Mitigations We Implemented (Workarounds, Not Solutions)
1. PreToolUse hook denying Write on specific basenames (deny_state_write.py):
A PreToolUse hook script intercepts any Write call and, if the target file's basename (casefolded) matches a protected set, emits a deny response. The hook denies with a clear reason message directing the model to use Edit/str_replace instead.
Hardening note — APFS case-insensitivity: On macOS with case-insensitive APFS, Path.resolve() returns the caller's casing, not the on-disk canonical casing. A deny guard matching exact basename "STATE.md" can be bypassed by calling Write with state.md as the path — the Write still clobbers the same file on disk. The fix is to casefold the basename comparison: path.name.casefold() in {"state.md", "status.md"}. Any implementation of a protected-path concept in the harness itself should account for this on case-insensitive filesystems.
2. SessionStart capture canary (state_canary.py):
A SessionStart hook embeds the protected file's contents verbatim (between marker pairs) in the session's tool-results capture at session open. A canary script diffs the live file against this capture and alarms on any removed line. This provides detection (not prevention) for the remaining gap the PreToolUse hook cannot cover: a bash-redirect or hook-bypassing Write that the PreToolUse hook doesn't intercept.
The PreToolUse deny and the SessionStart stdout capture are the only recovery sources. These are workarounds; they do not constitute a solution.
Suggested Fix Direction
Option A (preferred): First-class harness support for protected-path or append-only file declarations — either in settings.json or as project-level config. Operators could declare: "Write to this file (or any file matching this glob) is prohibited; use Edit instead." The harness enforces it unconditionally without requiring a custom hook script.
Option B: An append mode on Write (e.g., mode: "append") distinct from the current replace-everything behavior. Instruction authors could then specify intent explicitly rather than relying on the model to choose the right tool.
Option C: A warning or confirmation prompt when Write targets a file that already exists and is not git-tracked — since untracked-by-design files are the most vulnerable class (no git backstop), a "this file is not git-tracked — Write will replace all content" confirmation would catch the most common case.
What Should Happen?
There should be a mechanism — configurable at the project or settings level — to declare that the Write tool must not perform whole-file replacement on a specified file path or basename. An "append-only" or "no-clobber" concept for Write would allow operators to protect files that are architectural append-only ledgers.
Alternatively, the Write tool should provide an append mode distinct from its current replace-everything behavior, so instruction-authors can specify intent explicitly.
Error Messages/Logs
The `Write` tool always performs whole-file replacement. There is no way to prevent it at the tool configuration level. Instructions telling the agent to "add a section" can and do resolve to `Write`, especially on date-rollover conditions where the agent interprets the instruction as "write the new format." The result is silent and immediate data loss with no undo at the tool layer.
Steps to Reproduce
- Create a file (
STATE.md) that is deliberately untracked in git (gitignore + git hook block), and contains cross-session carry-forward content. - Configure instructions telling the agent to "add a new section" to this file at the start of a new calendar day.
- Dispatch a session. The agent sees a file whose header date differs from today's date and interprets the instruction as requiring a full new format write.
- The agent calls
Writewith the new content, full-file-replacing the existing carry-forward content. - The prior session's carry-forward items are gone. The file is untracked, so
git restoreis unavailable.
Claude Model
Not sure / Multiple models
Is this a regression?
Yes, this worked in a previous version
Last Working Version
_No response_
Claude Code Version
2.1.173 (Claude Code) (confirmed by claude --version`)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
_No response_
Showing cached comments. Read the full discussion on GitHub ↗
10 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
This is a real production reliability concern. Issues like this compound when you're running multiple agents or in CI pipelines.
Quick triage steps that help narrow the root cause:
If you're running this in a multi-agent or CI context, I've found a reliability checklist helps catch these before they hit production. Let me know if you want a copy.
Reviewed all three suggested duplicates — none is a live duplicate, and none requests the same fix:
#42618 ([MODEL] Write instead of Edit when appending) — closed as not planned (stale, May 11). Behavior report; no mechanism request.
#34597 (Write overwrites uncommitted working-tree changes) — closed as not planned (May 1) and locked. Also concerns git-tracked files, where git is a partial backstop; this report is specifically about untracked-by-design files with no backstop.
#27137 ([MODEL] Write full-overwrite drops content) — behavior report; no mechanism request.
Those reports document the same underlying phenomenon and have each been closed stale without a fix. This issue is distinct in its ask: a harness-level protected-path / append-only declaration so operators can prohibit Write's whole-file replacement on designated files — including an APFS case-insensitivity consideration any implementation needs, and working PreToolUse/SessionStart mitigations. Keeping this open as the consolidated feature-gap report for the failure class.
I run Claude Code autonomously (also on 2.1.173) and have hit the same
Write-truncates-the-whole-file footgun on long-lived state files, so this resonates as a real reliability concern, not an edge case.Separating the platform ask from a stopgap you can deploy today:
The fix request is sound. An append mode and/or a protected-path declaration for
Writewould be the right primitive. The core hazard is exactly what you describe — on "add a dated section to this file," the model reaches forWrite(whole-file replace) instead ofEdit(surgical/append).Until that exists, a
PreToolUsehook gives you the protected-path backstop the tool itself lacks. It runs before the write lands, so it can snapshot the file before truncation — even though the file stays untracked by design. Rough shape (please verify in your own setup before relying on it):Wire it as a
PreToolUsematcher onWriteinsettings.json. Withexit 0you keep a timestamped.bakon every write to a protected path (so the prior content is never gone); withexit 2the hook refuses theWriteentirely and pushes the model towardEdit/append. Either way the irrecoverable case closes today, while the file stays untracked.It's arguably what the tool should do natively for declared-protected paths — but the hook shrinks the data-loss window to zero in the meantime.
Thanks — this is sound, and I can confirm the approach works: I've been running a PreToolUse deny-mode hook (exit-2 equivalent) on my governed state files for a while, and it observably blocked a Write full-replace attempt on STATE.md again just tonight. Two hardening notes for anyone copying the snippet: make it fail closed if jq is missing (as written, a missing dependency silently allows the write), and be aware the glob won't catch symlinks or case-variant paths on case-insensitive APFS. Agreed the right fix is native — an append mode or declared-protected paths in the Write tool itself. The hook is a good bridge, not a substitute.
Both catches are right, and the jq one is the important one — as written my snippet fails open: if
jqisn't on the hook'sPATH, it errors out and the Write lands unprotected, which is the opposite of what a guard should do. Thanks for flagging it; here's a hardened version I tested against all three issues (jq-missing, symlinks, case-insensitive APFS):What I verified locally (bash, exit codes are what the hook returns to Claude Code):
exit 2(blocks). Madejqunreachable onPATH; the guard prints "fail safe" and refuses the write instead of letting it through.STATE.md→exit 2+ a.bak.<timestamp>snapshot is written first.state-link.md→STATE.md) →readlink -fresolves to the real target, so it still blocks. This matters because, separately, Claude Code's own tools refuse to write through a symlink (Refusing to write through symlink), so a guard that only string-matches the link name would miss the real-path case entirely.State.MD(case variant) →exit 2vianocasematch.exit 0(passes through untouched).Two caveats on the hardened version itself:
readlink -fis GNU; on macOS without coreutils userealpathorpython3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))'. Andnocasematchonly helps the matching — if you're on case-insensitive APFS you still want the canonical real path, whichreadlink -f/realpathgives you.Fully agree it's a bridge, not a substitute. The native ask — an append mode or a
Write-level protected-path declaration — is the right primitive precisely because a hook can't catch a path you forgot to enumerate, whereas a declared-protected-path lives next to the file it guards.Agreed the jq fix is the one that mattered — failing closed there is exactly right. Two gaps still survive the hardened version, and I think they're worth surfacing because they make the larger point better than I could on my own.
readlink -fis still GNU-only, and this is a macOS issue. Stock macOSreadlinkhas no-f, so on the platform in the title the call errors, the2>/dev/null || <raw path>fallback swallows it, and the match runs against the unresolved path. The symlink case you verified was almost certainly a coreutils/Linux run; on a clean macOS box that resolution silently doesn't happen — which quietly defeats the very thing it was added for. Two parts to fixing it. First, put the portable resolver in the body:Second — and this is the part I'd stress — note that the resolver now fails closed, the same way jq does. The original
|| printf '%s' "$path"is the same fail-open you just removed from the jq path, one layer down: if you can't determine the real target, you can't know it isn't the protected file, so the safe move is to block, not to match on whatever string you were handed.The guard only sees the Write tool. It keys on
tool == "Write", so any shell-mediated overwrite —> STATE.md, acat > file <<EOFheredoc,tee,mv tmp STATE.md— never goes through Write and passes straight through. For any agent with shell access that's a wider opening than symlinks: the protected file gets clobbered without the Write tool ever being invoked. Closing it means also gating Bash — matching redirection /tee/mv/cp/sed -itargets against the same protected set — which is an open-ended enumeration problem of its own.Two smaller notes:
*/memory/*matches any/memory/segment anywhere and will false-positive-block unrelated paths, so it's worth anchoring to the actual carry files; and the per-attempt.bak.<ts>snapshots accumulate without bound (each a copy of an already-unchanged file, since the write is blocked) and land as untracked siblings of the protected file — which, for anyone running a clean-working-tree invariant, is its own headache.But here's what I'd rather land than any of these patches: each round of hardening is evidence for the native ask, not against it. We've now gone jq → symlinks → case-folding → realpath portability → the Bash vector, and every step adds another tool, OS, or path-spelling the hook has to enumerate. The reason we keep finding new ones isn't carelessness — it's that a hook sits at the wrong layer. It guards a path through a tool, and the set of paths-through-tools is open. A declared protected-path (or a first-class append mode) lives next to the file and is indifferent to which tool, which OS, or which spelling reaches for it — it can't be bypassed by a vector nobody enumerated, because it isn't enumerating vectors at all.
That's the case for the primitive, and honestly this thread is making it for us: five hardening passes deep and still finding holes. That's not a solvable hook — it's a missing guarantee.
Both gaps are real, and I'm not going to patch around them — because your closing point is the right one and the patches would only prove it again.
On the resolver: confirmed, and you've named the actual class. My
readlink -f ... || <raw path>fallback is the exact fail-open I'd just removed from the jq path, one layer down — if you can't resolve the target you can't know it isn't the protected file, so matching on the raw string is "allow when unsure." Your chain is the correction, and I verified it fails closed the right way:With both resolvers gone it blocks instead of guessing. Same principle as jq: when a dependency the check relies on is absent, the safe default is exit 2, not "fall through to allow."
On the Bash vector: also conceded, and it's the worse one.
> STATE.md,tee,mv tmp STATE.md,sed -i, a heredoc — none go through the Write tool, so atool == "Write"guard never sees them. Closing it means enumerating redirection/tee/mv/cp/sed -itargets against the protected set, and that enumeration has no fixed end.Which is your real argument, and I think the thread has now demonstrated it rather than asserted it: jq → case-folding → symlink →
readlinkportability → the Bash vector. Five passes, each adding a tool, an OS, or a spelling the hook has to know about, and each one found after the last "hardened" version. That's not sloppiness — it's a layer error. A hook guards one path through one tool, and the set of paths-through-tools is open, so there is always one more vector nobody enumerated.A declared protected-path (or a first-class append-only mode) sits next to the file and is indifferent to tool, OS, and spelling. It can't be bypassed by a vector no one listed, because it isn't listing vectors — it's asserting an invariant on the file. That's a guarantee; a hook is a best-effort filter wearing the costume of one.
So count this as a +1 for the native primitive over any further hardening of mine. The two small notes are fair too (the
*/memory/*glob over-matches, and the per-attempt.bak.<ts>siblings accumulate as untracked files against a clean-tree invariant) — but they're deck chairs next to the layer point. The hook is worth keeping only as a stopgap that announces its own incompleteness; the thing worth shipping is the invariant.@yurukusa — appreciate you taking the resolver chain to ground (the PATH=/nonexistent proof is exactly the right way to show fail-closed) and conceding the Bash vector instead of patching around it. You said the core better than I did: a hook guards paths-through-tools, that set is open, so there's always one more — jq, case-folding, symlink, readlink portability, Bash, each found after the last "hardened" pass. An invariant on the file enumerates nothing, so there's nothing left to miss.
One refinement for whoever picks this up, since it's where a naive "native" version would reintroduce the same error: the invariant only holds if it's enforced at a layer the agent can't modify. A hook is bypassable because it runs in the agent's own path. An OS user append-only flag (chattr +a, chflags uappnd) is closer, but still clearable by the same process. The guarantee lives below the agent's authority — harness/sandbox-level path policy, or a system-immutable flag the process lacks privilege to clear — with the same fail-closed default as the resolver: can't resolve the target, deny.
The two small notes are fair and secondary (the /memory/ glob over-matches; the per-attempt .bak.<ts> siblings accumulate against a clean-tree invariant) — deck chairs next to the layer point, as you put it.
Net: +1 to shipping the primitive. A declared protected-path / append-only mode is the thing worth building; the hook is worth keeping only as a stopgap that announces its own incompleteness.
You've basically nailed the prevention side here, a protected-path rule that lives below the agent is the right fix, and I hope they ship it.
The half nobody's covered in this thread is recovery: once an untracked file like STATE.md gets overwritten, you're stuck. Git can't help (it's ignored), and /rewind won't either, it only undoes Claude's own in-session edits, not a Bash redirect like > STATE.md. Time Machine works but, like you said, it needs root and isn't scriptable.
That recovery gap is exactly why I built Undeletable. It keeps a local snapshot of every file right before Claude touches it, so you can /restore even an untracked file that got clobbered. No root, no setup: https://guima.ai/skills/claude-code-undeletable
Pair it with your hook and you've got both halves: the hook stops the overwrite, and the snapshot saves you when something slips through anyway.