[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

Open 💬 10 comments Opened Jun 12, 2026 by jsoldner

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 by claude --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

  1. Create a file (STATE.md) that is deliberately untracked in git (gitignore + git hook block), and contains cross-session carry-forward content.
  2. Configure instructions telling the agent to "add a new section" to this file at the start of a new calendar day.
  3. 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.
  4. The agent calls Write with the new content, full-file-replacing the existing carry-forward content.
  5. The prior session's carry-forward items are gone. The file is untracked, so git restore is 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

  1. Create a file (STATE.md) that is deliberately untracked in git (gitignore + git hook block), and contains cross-session carry-forward content.
  2. Configure instructions telling the agent to "add a new section" to this file at the start of a new calendar day.
  3. 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.
  4. The agent calls Write with the new content, full-file-replacing the existing carry-forward content.
  5. The prior session's carry-forward items are gone. The file is untracked, so git restore is 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_

View original on GitHub ↗

This issue has 10 comments on GitHub. Read the full discussion on GitHub ↗