Skip to content

disk-hygiene: cloud-sync placeholders are unprotected, unhinted, and counted as reclaimable bytes — deleting one destroys the org's cloud copy #1804

Description

@kyle-sexton

Filed by an agent session after a live /disk-hygiene:clean run on Windows 11. Every measurement below was taken on the audit host; the reproduction script is included.

Summary

The engine's only structural defense against cloud-sync content is is_linkish() (skills/clean/scripts/hygiene.py:116-124), which treats a Windows reparse point as protected. Measured on this host, the dominant OneDrive dehydrated-placeholder class carries no reparse bit when read through the interpreter the engine actually runs, so the whole subtree is walked, every placeholder is recorded as an ordinary file with protected_reasons: [], and its logical_size is the full remote byte count while local occupancy is roughly zero.

Combined with name protection being exact-match, that makes a tenant OneDrive tree the single most attractive target in a home audit while reclaiming nothing — and deleting a placeholder propagates the delete to the organisation's cloud copy.

Measured

Measured Value
Tenant sync root C:\Users\<user>\OneDrive - <Org>
Root is_linkish() False — subtree is walked
Files walked 1101
Dehydrated placeholders 872
Their logical_size total 13,770,936,008 bytes
Of those, is_linkish() == True 0 of 872

Byte totals reconcile exactly: 13,770,936,008 dehydrated plus 236,643,717 hydrated equals 14,007,579,725.

Read this part before judging — the attribute depends on the reader

This is the detail that will otherwise make the report look wrong.

  • Through CPython os.lstat, and through raw GetFileAttributesW via ctypes: st_file_attributes = 0x400020FILE_ATTRIBUTE_ARCHIVE (0x20) plus FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS (0x400000). FILE_ATTRIBUTE_REPARSE_POINT (0x400) is clear.
  • Through .NET — both Get-ChildItem enumeration and System.IO.File::GetAttributes — the same files by path report 0x401620, with the reparse bit set.
  • The 227 hydrated files split the same way: 0x20 versus 0x420.

So if you verify this in PowerShell you will see the reparse bit set and conclude is_linkish() already protects the tree. It does not, through the reader the engine uses. Please verify with os.lstat on the engine's own interpreter, not through a shell.

We are deliberately not claiming the reader is the discriminator — it could equally be a per-process cloud-files awareness difference. We have not proven a general API rule and do not want to assert one.

Reproduction, run against any OneDrive tenant folder:

import os, ctypes
root = r"C:\Users\<user>\OneDrive - <Org>"
RECALL = 0x400000
for dirpath, dirnames, filenames in os.walk(root):
    for name in filenames:
        p = os.path.join(dirpath, name)
        st = os.lstat(p)
        attrs = getattr(st, "st_file_attributes", 0)
        win = ctypes.windll.kernel32.GetFileAttributesW(ctypes.c_wchar_p(p))
        if attrs & RECALL:
            print(hex(attrs), hex(win), st.st_size, p)

Name protection reaches no cloud root but one literal

has_protected_name() (hygiene.py:201-205) is casefolded exact equality, and the baseline protects the literal name OneDrive only. Verified by script:

  • OneDrive — PROTECTED
  • OneDrive - Melodic Software — NOT PROTECTED
  • OneDrive - Contoso — NOT PROTECTED
  • iCloudDrive — NOT PROTECTED
  • Dropbox — NOT PROTECTED

Microsoft documents the OneDrive for Business sync root as C:\users\<username>\OneDrive - <organization name> and states the tenant display name portion cannot supportedly be changed, so this is the standard shape, not an edge case.

Why the byte accounting makes it worse

preview sums logical_size into logical_bytes (hygiene.py:1419-1423) and an apply report would claim those bytes as logical_bytes_removed (hygiene.py:1848-1852). A report can therefore honestly claim ~13.8 GB removed while the observed free-space delta is ~0 — and the skill's own summary caveat about "concurrent disk activity, sparse files, hard links, compression" explains that away instead of surfacing it.

Meanwhile SKILL.md step 2's positional-triage rule points straight at the directory: it instructs treating "any loose root-level entry that is not in protected_exact_names and does not belong to a recognizable app/config convention" as suspicious. OneDrive - <Tenant> is exactly that entry.

The only remaining defense is model judgment — step 2's "could this be a shell/cloud-sync folder? If uncertain, keep it." That is a process control standing in for a deterministic one that is available one line from code already reading the attribute.

Suggested fix, root cause first

  1. Treat RECALL_ON_DATA_ACCESS | RECALL_ON_OPEN | OFFLINE as a hard protection in hard_protection(), contributing a cloud-placeholder reason. Closes OneDrive, iCloud eviction, and Dropbox online-only in one predicate, using an attribute is_linkish() already reads.

    Validation warning: in the Python-visible attributes for these files, OFFLINE (0x1000) and SPARSE (0x200) are not present — only the RECALL bit is. If you validate the OFFLINE clause against a OneDrive placeholder it will never trip, and dropping it would silently lose the iCloud and Dropbox eviction states it exists for. Validate through os.lstat.

  2. Emit a per-entry size_qualifiers list so a placeholder's logical_size can never be read as reclaimable local bytes.

  3. Secondary, not a substitute: add cloud-sync roots to the baseline as name globs (OneDrive - *, iCloudDrive, Dropbox, Box, Google Drive). Note consumers cannot self-remediate this today: protected_exact_names is not overlay-extensible, and an overlay may only add additional_protected_path_globs (hygiene.py:416-455), which are matched relative to the scan target — so a standing ~/.claude/disk-hygiene.json protects these only when the target happens to be home. The baseline has to ship it.

Scope of verification

Single host, Windows 11, one tenant. iCloudDrive and Dropbox were shown unprotected by name; their attributes were not measured. macOS untested.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions