Skip to content

fix(disk-hygiene): hard-protect cloud-sync placeholders and tenant sync roots - #1818

Merged
kyle-sexton merged 5 commits into
mainfrom
fix/1804-cloud-placeholder-protection
Jul 30, 2026
Merged

fix(disk-hygiene): hard-protect cloud-sync placeholders and tenant sync roots#1818
kyle-sexton merged 5 commits into
mainfrom
fix/1804-cloud-placeholder-protection

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

A OneDrive for Business sync root was the single most attractive deletion target in a home audit while reclaiming nothing, and deleting an entry inside it propagates the delete to the organisation's cloud copy. The engine's only structural defense against cloud-sync content was is_linkish(), and the dominant dehydrated-placeholder class carries no reparse bit when read through os.lstat — so the whole subtree was walked and every placeholder was recorded as an ordinary file with protected_reasons: [], carrying its remote byte count as if it were reclaimable local bytes.

Verified on the reporting host before any change: 1,101 files walked, 872 dehydrated placeholders totalling 13,770,936,008 bytes, 0 of 872 flagged by is_linkish(). Full reproduction, including the attribute histogram and the GetFileAttributesW cross-check, is in the verification comment.

Fix

1. hard_protection() contributes a cloud-placeholder reason from FILE_ATTRIBUTE_OFFLINE | FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS. Placing it there means one predicate covers scan, preview, handoff-verify, and apply's pre-removal recheck at once. It is deliberately independent of the reparse test rather than folded into it — this is precisely the class a reparse test cannot see. Both flags are derived from a single lstat per ancestor (link_and_cloud_state), so the walk's stat load is unchanged on a scan bounded at 250,000 entries.

FILE_ATTRIBUTE_RECALL_ON_OPEN is deliberately excluded, correcting the issue's own suggested predicate. Its value 0x00040000 is the same number as FILE_ATTRIBUTE_EA, and Microsoft documents RECALL_ON_OPEN as appearing "only in directory enumeration classes" while every attribute read here comes from lstat (File Attribute Constants). Read through lstat the bit means "has extended attributes" — see the measured false positives below.

2. The baseline gains protected_name_globs, holding OneDrive - *, matched casefolded through fnmatchcase so the verdict does not depend on the host platform's case rules. This half is not optional: probing the same sync root showed the directories carry no cloud attribute at all (the root reads 0x31, all 99 subdirectories read plain 0x10), so an attribute predicate protects placeholder files only and a fully hydrated tenant folder would have no protected descendant and stay deletable. The glob Dropbox (*) and the exact names Dropbox and iCloudDrive ship alongside it.

The list is deliberately short, because a protected name applies at every depth — a protected directory is never traversed and reports logical_size: 0, byte-identical to a genuinely empty directory. Over-protection is not free in a reclamation tool; it silently under-reports. Two candidates named in the issue were rejected after checking them: Box is a common enough directory name in source trees that protecting it at every depth would make ordinary directories untraversable and silently zero-sized, and Google Drive is a legacy Backup-and-Sync name — current Google Drive for desktop streams to a virtual drive letter (G: by default on Windows, Drive for desktop settings) rather than a profile folder. Dropbox documents both Dropbox (Personal) and Dropbox (<business name>) as folder names, which is why the bare exact name alone was not enough.

Consumers could not have closed this themselves: protected_exact_names is not overlay-extensible, and an overlay's additional_protected_path_globs are matched against a path relative to the scan target, so a standing policy protects such a root only when the target happens to be its parent. Reading the globs from the bundled baseline rather than from the snapshot's policy also means a stale or forged snapshot cannot weaken this protection.

3. Every entry records file_attributes and a size_qualifiers list, so a placeholder's remote logical_size can never be read as reclaimable local bytes. Additive per-entry trace only — no aggregate's definition changes here, since #1806 asks for the reclaimable-bytes figure specifically and the two should not fight.

4. SKILL.md step 2's positional-triage rule now reads an entry's own protected_reasons instead of testing membership of protected_exact_names. As written it would have walked straight past a tenant sync root even after this fix.

Verification

End-to-end, same tenant tree, engine before vs. after:

Before After
Files with protected_reasons: [] 1,071 (13,893,811,832 bytes) 229 (236,643,717 bytes — the genuinely local, hydrated files)
Entries carrying cloud-placeholder 0 842 (13,657,168,115 bytes)

842 rather than 872 because 30 placeholders sit under two Music subtrees an existing name protection already truncates.

Depth-1 scan of the user home — the scenario in the report — OneDrive - <Org> moves from protected_reasons: [] to baseline-protected-name, while its siblings are unchanged. Pointing the engine directly at the tenant root now returns invalid-or-blocked ("protected shell-folder and profile-hive roots are not valid audit targets") instead of scanning it.

Negative control, 412,270 entries across three non-cloud trees (a repo checkout, AppData\Local\Temp, ~\.claude):

Predicate False positives
With RECALL_ON_OPEN included (as the issue suggested) 1,552 — .NET build output and temp .node files
As merged 0

Those 1,552 entries are fully present on disk; protecting them would block exactly the artifacts this engine exists to reclaim.

Gates:

  • bash plugins/disk-hygiene/skills/clean/scripts/hygiene.test.sh — 230 tests, OK (4 skipped); 7 new tests added.
  • bash scripts/check-changelog-parity.sh --check and --check-order — pass.
  • check-skill.sh clean — PASS, 0 errors (the one warning is the pre-existing SKILL.md length soft target).
  • markdownlint-cli2 on both changed markdown files — 0 errors.
  • ruff check on both changed Python files — clean.

No cloud-sync placeholder was deleted or hydrated at any point; every probe used os.walk / lstat only, and only scan was ever run against the tenant tree.

Residual, stated honestly: single Windows 11 host, one tenant. The four non-OneDrive sync roots were confirmed unprotected by name but their file attributes were never sampled, so they are protected on name alone and their placeholder behaviour is unverified. macOS untested.

Related

Merge this PR first. Three PRs from the same audit are open against the disk-hygiene manifest and each claims the next version, so they must merge in issue order or the changelog and manifest disagree:

Order PR Issue Version
1 #1818 (this one) #1804 0.11.0
2 #1819 #1805 0.12.0
3 #1820 #1806 0.13.0

Merged in that order the changelog reads contiguously and each conflict is a trivial keep-both-in-order in CHANGELOG.md. Merged out of order, a later version lands above a gap and the earlier PRs conflict in a way that looks like an authoring error rather than an ordering one.

Fixes #1804

🤖 Generated with Claude Code

https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C

…nc roots

The engine's only structural defense against cloud-sync content was
is_linkish(), which treats a Windows reparse point as protected. The dominant
OneDrive dehydrated-placeholder class carries no reparse bit when read through
os.lstat, so the whole subtree was walked and every placeholder was recorded as
an ordinary file with empty protected_reasons. Its logical_size is the remote
byte count while local occupancy is roughly zero, so the tree also looked like
the largest reclaimable win on the volume — and deleting a placeholder
propagates the delete to the provider, which for a tenant sync root is the
organisation's only copy.

Measured before the fix: 1,101 files walked, 872 dehydrated placeholders
totalling 13,770,936,008 bytes, 0 of 872 flagged by is_linkish().

hard_protection() now contributes a cloud-placeholder reason from the file
attributes it already reads, so the protection reaches scan, preview,
handoff-verify, and apply's pre-removal recheck through one predicate. Both
flags come from a single lstat per ancestor, so the walk's stat load is
unchanged.

FILE_ATTRIBUTE_RECALL_ON_OPEN is deliberately excluded, against the obvious
reading of the attribute names. Its value 0x00040000 is the same number as
FILE_ATTRIBUTE_EA, and RECALL_ON_OPEN appears only in directory enumeration
classes while every read here comes from lstat, so through lstat the bit means
"has extended attributes". A 412,270-entry sweep of three non-cloud trees found
1,552 fully-local files carrying it — .NET build output and temp .node files —
which the engine exists to reclaim. Excluding it leaves zero false positives
while the tenant tree still flags correctly.

Attribute protection alone is not enough: measurement showed the containing
directories carry no cloud attribute at all (the sync root reads 0x31, all 99
subdirectories read plain 0x10), so a fully hydrated sync root would have no
protected descendant and stay deletable. Name protection was exact-match and
shipped the literal OneDrive only, so OneDrive - <Organization> matched nothing.
The baseline gains protected_name_globs holding "OneDrive - *", matched
casefolded so the verdict does not depend on the host's case rules, plus Box,
Dropbox, Google Drive, and iCloudDrive as exact names. Consumers could not have
closed this themselves: additional_protected_path_globs are matched relative to
the scan target, so a standing overlay protects such a root only when the target
happens to be its parent.

Every entry also records file_attributes and a size_qualifiers list, so a
placeholder's remote logical_size can never be read as reclaimable local bytes.
Additive per-entry trace only; no aggregate's definition changes here.

The clean skill's positional-triage rule now reads an entry's own
protected_reasons instead of testing membership of protected_exact_names, which
would have walked straight past this entry even after the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 5m 15s —— View job


PR Review: fix(disk-hygiene): hard-protect cloud-sync placeholders

  • Gather context and understand the request
  • Read changed files
  • Review hygiene.py — core logic changes
  • Review test_hygiene.py — new test coverage
  • Review baseline-policy.json — new name globs
  • Review SKILL.md — documentation update
  • Review plugin.json — version bump
  • Review CHANGELOG.md — release notes
  • Post findings

Summary

This is a well-engineered fix for a genuine critical safety failure — 872 dehydrated OneDrive placeholders totalling ~13 GB were recorded as protected_reasons: [] with zero caught by the previous is_linkish() guard. The design choices are sound, the new code is clear, and the test coverage is thorough. A few findings below, mostly minor.


Findings

1. has_protected_name has a hidden glob dependency that bypasses the exact_names parameter — correctness, low

hygiene.py L261–270

has_protected_name(path, exact_names) always calls baseline_protected_name_globs() regardless of what's in exact_names. This means glob protection cannot be suppressed by the caller — which is the intended security property — but it's not expressed anywhere in the signature or callsite. Every call site passes an exact_names set, and none of them document that glob protection fires unconditionally alongside it.

The places this matters most are in resolve_snapshot_target (L1430) and the scan target gate (L2041) — both pass only baseline_protected_names() (exact names) but silently also check globs. The behaviour is correct; a maintenance reader would just not expect it. A one-line comment on has_protected_name or baseline_protected_name_globs that says "always active; callers cannot suppress this via exact_names" would close the gap.

2. current_descendants does not short-circuit on cloud-placeholder directories — low, pre-existing

hygiene.py L1101–1120

current_descendants stops descent on links, non-directories, and mounts, but not on entries whose st_file_attributes carry the cloud bits. In preview, current_descendants is called even when blockers already contains "cloud-placeholder" — the short-circuit on "truncated-not-inventoried" doesn't cover this case. For OneDrive in practice this is safe (the PR correctly observes that directories carry 0x10 with no cloud attribute), but a sync client that dehydrates a directory node rather than its files would trigger os.scandir on a cloud-resident directory during preview, potentially initiating a download.

This is not introduced by this PR and the failure mode is benign (OSError → filesystem-state-unverified blocker, candidate stays blocked), but it is worth noting for a future hardening pass: the same is_cloud_placeholder_stat guard already used in hard_protection could be added to current_descendants's descent check alongside the existing is_linkish guard.

3. iCloudDrive may not match the actual macOS folder name — low, acknowledged residual

baseline-policy.json L17

On modern macOS the iCloud Drive sync root lives under ~/Library/CloudStorage/ with per-provider subdirectory names, and older macOS surfaces it as iCloud Drive (with a space) via Finder. The exact string iCloudDrive (no space, no articles) would match neither. The CHANGELOG already acknowledges "macOS untested", so this is a known residual, but the name as written is unlikely to protect anything on macOS today. If a follow-up issue tracks macOS coverage, it should revisit this alongside the Google Drive and Box entries, whose actual sync root paths also differ between old and new CloudStorage layout.

4. Extra blank line between test methods — style

test_hygiene.py L1125–1127

Two blank lines between test_nested_directory_candidate_removes_only_snapshotted_fixture and test_scan_max_depth_truncates_and_preview_blocks_planning. PEP 8 uses one blank line between methods in a class.


What looks correct

  • CLOUD_PLACEHOLDER_ATTRIBUTES mask: OFFLINE | RECALL_ON_DATA_ACCESS correctly covers both the HSM/eviction states and the Cloud Files API dehydrated state. Excluding RECALL_ON_OPEN (whose lstat value is the same as FILE_ATTRIBUTE_EA) is correct and the negative-control test and bench data confirm 0 false positives vs 1,552 with the bit included. This is one of the more important judgement calls in the PR and it's right.

  • link_and_cloud_state single-lstat optimization: Extracts the stat once and feeds both is_linkish_stat and is_cloud_placeholder_stat. The pre-existing double-stat in the scan walk (is_linkish then metadata) isn't made worse — only the ancestor walk in hard_protection was improved.

  • baseline_protected_name_globs caching: lru_cache(maxsize=1) is appropriate here. The function reads the bundled baseline file, which is a build-time constant. No test patches baseline_policy directly, so the cache causes no test-isolation issues in the current suite.

  • size_qualifiers field: Additive, non-breaking, present on every entry (not just protected ones), which is the right design — it stops the byte-accounting misread whether or not the protection happens to fire.

  • apply and preview pre-removal rechecks: Both call hard_protection, which calls link_and_cloud_state, which checks is_cloud_placeholder_stat for every ancestor. A cloud placeholder that appears between scan and apply will be caught at every gate.

  • SKILL.md step 2 fix: Reading protected_reasons directly instead of membership in protected_exact_names is the only correct approach now that protection comes from attribute predicates and glob patterns in addition to exact names. The original rule would have walked straight past a OneDrive - * root even after this fix.

  • Test test_cloud_placeholder_is_invisible_to_the_reparse_test: Pins the key invariant — the class identified by this PR is precisely the one the existing is_linkish guard cannot see. Good regression anchor.

  • Version bump: 0.10.2 → 0.11.0 is correct for a feature addition + bug fix that changes the snapshot schema (new file_attributes and size_qualifiers fields) and the baseline policy (new protected_name_globs key).


Verdict

The core fix is correct and addresses a real critical gap. Findings 1–3 are low-severity. Finding 4 is cosmetic. I'd suggest a follow-up issue for macOS sync-root naming before the macOS placeholder behaviour is claimed as protected, and a note in has_protected_name's docstring about the unconditional glob check. Neither blocks merge.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 4m 33s —— View job


Security Review — PR #1818

  • Read PR context and understand scope
  • Fetch PR diff
  • Read changed Python files (hygiene.py, test_hygiene.py)
  • Read changed policy/config files (baseline-policy.json, plugin.json)
  • Analyze for security vulnerabilities
  • Post findings

Scope: hygiene.py, test_hygiene.py, baseline-policy.json, SKILL.md, plugin.json. Changes introduce Windows file-attribute–based cloud-placeholder detection, glob-based name protection, and two new per-entry snapshot fields. No workflow files touched.


No critical or important security findings

The new code paths are purely additive to the protection predicate — they add reasons for blocking deletion, never remove them. None of the additions introduce command execution, subprocess calls, external network access, or user-controlled input that reaches fnmatch or the filesystem in an exploitable way.

The following points were examined and found acceptable:

Bitmask correctness and OR semantics

CLOUD_PLACEHOLDER_ATTRIBUTES = FILE_ATTRIBUTE_OFFLINE | FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS = 0x00401000. The check is:

return bool(getattr(info, "st_file_attributes", 0) & CLOUD_PLACEHOLDER_ATTRIBUTES)

This fires if either flag is set — deliberately broad for a protection predicate (over-protecting is safe; under-protecting is not). Both hex values match Microsoft's documented constants. The excluded FILE_ATTRIBUTE_RECALL_ON_OPEN = 0x00040000 alias collision with FILE_ATTRIBUTE_EA is documented in the source and confirmed in the PR body. No issue.

Glob pattern injection surface

The baseline_protected_name_globs() function reads exclusively from the bundled BASELINE_POLICY path (Path(__file__).resolve().parents[1] / "reference" / "baseline-policy.json"). The apply_policy_overlay() allowlist does not include protected_name_globs, so no consumer overlay can inject or override glob patterns. fnmatch.fnmatchcase() is applied to a name component only (path.name.casefold()), never to a full path string, so there is no path-traversal ambiguity.

link_and_cloud_state() fail-open on OSError

except OSError:
    return False, False

An unreadable path is treated as neither a link nor a cloud placeholder. This is the same behaviour the original is_linkish() exhibited. In practice, a file whose stat raises OSError also cannot be reached by metadata() in scan_tree, so it lands in errors, not entries, and can never be proposed for deletion. Acceptable.

lru_cache on baseline_protected_name_globs()

The cache is correct in production: the bundled JSON is a build-time constant and should not change while the process runs. The cache does create a test-isolation hazard when a test mocks baseline_policy without clearing baseline_protected_name_globs.cache_clear() — but this is a test-correctness concern, not a security vulnerability. The current new tests call baseline_protected_name_globs() only through un-mocked code paths that read the real bundled file.

file_attributes field in snapshot output

hygiene.py:622 adds the raw Windows attribute word to every snapshot entry. On non-Windows this is always 0. The snapshot already contains device ID, inode, mode, size, and mtime — the attribute word is not meaningfully more sensitive. The snapshot file is written to CLAUDE_PLUGIN_DATA, which is under user control. No issue.

TOCTOU between scan and apply for cloud placeholder state

If a file is hydrated (downloaded from cloud to local) after scan and before apply, link_and_cloud_state() would no longer see the RECALL_ON_DATA_ACCESS attribute. However: a cloud placeholder is flagged in protected_reasons during scan, which means it cannot appear in an approved plan that clears preview(). The approval_token binds a specific plan and snapshot digest, so a token issued when the file was blocked cannot be recycled to approve a different plan. And for the file to be deletable at apply time, preview() must first pass — which would have already caught a placeholder by attribute at that point.

Casefold platform independence

Both sides of the fnmatch.fnmatchcase() call are casefolded (name.casefold() against pattern.casefold()). Python's str.casefold() is Unicode's full case-folding algorithm and is locale-independent, so protection verdicts are stable across platforms. No issue.


Conclusion

No security vulnerabilities found. The changes are defensively structured: all new predicate paths are strictly additive to protection, user-controlled data never reaches glob compilation or command execution, and the bitmask and glob-matching choices are well-documented and correctly implemented.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b221922ff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/disk-hygiene/skills/clean/reference/baseline-policy.json
…x variants

A protected name applies at every depth, and a protected directory is never
traversed and reports logical_size: 0 — byte-identical to a genuinely empty
directory. Over-protection is therefore not free in a reclamation tool: it
silently under-reports. The first pass took four sync-root names from the report
without weighing that.

Box is a common enough directory name in source trees that protecting it at
every depth would make ordinary directories untraversable and silently
zero-sized. Google Drive is a legacy Backup-and-Sync name: current Google Drive
for desktop streams to a virtual drive letter, G: by default on Windows, not to
a folder under the user profile
(https://support.google.com/drive/answer/13470231). Both are dropped.

Dropbox documents its folder as "Dropbox (Personal)" or "Dropbox (<business
name>)" as well as the bare name, so the bare exact name alone would miss the
documented shapes; the glob "Dropbox (*)" is added alongside it.

iCloudDrive is kept on name alone. Its exact default folder name could not be
confirmed from an official Apple page in two attempts, and it is recorded as
unverified rather than asserted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 12s —— View job


Review — PR #1818 incremental commit (e081e5e)

  • Read the incremental diff
  • Review updated baseline-policy.json
  • Check test coverage for Dropbox variants
  • Verify prior review findings are addressed
  • Post findings

What this commit does

The Codex review flagged that the exact name Dropbox misses the documented Dropbox (Personal) and Dropbox (<business name>) folder shapes. This commit responds by adding "Dropbox (*)" to protected_name_globs, and separately drops Box and Google Drive from protected_exact_names after judging them over-broad.


Findings

1. No test for Dropbox (*) glob — low, worth fixing before merge

The test test_tenant_cloud_sync_root_name_is_protected (test_hygiene.py:252) covers the OneDrive - * glob with both a matched and a non-matched spelling, pinning the key invariant for that class. The new Dropbox (*) glob — the main safety change in this commit — has no equivalent. It is the primary mechanism that protects documented Dropbox variant roots, and the exact-name Dropbox would not fire for Dropbox (Personal) or Dropbox (Acme Corp).

Given that the Codex finding was raised specifically about this gap, a test asserting that has_protected_name(Path("Dropbox (Personal)"), names) is True and has_protected_name(Path("Dropbox"), names) is True (bare exact name path still works) would close the loop. Without it, a future refactor of baseline_protected_name_globs could silently regress the Dropbox variant protection with no failing test.

Fix this →

2. Dropbox (*) pattern semantics are correct — confirms no issue

fnmatchcase(name.casefold(), "dropbox (*)") translates internally to regex (?s:dropbox\ \(.*\)\Z). The string must begin with dropbox ( and end with ) — no suffix allowed. This correctly matches:

Name Matches Expected
Dropbox (Personal)
Dropbox (Acme Inc)
Dropbox (Personal) backup ✗ (not a sync root)
Dropbox via exact name

The bare Dropbox exact name continues to fire on the original install shape. The glob and the exact name are complementary and cover the documented naming space without overlap issues.

3. Box and Google Drive removal — correct judgment

The commit message cites two concrete sources for these removals:

  • Box: Over-broad because Box is a common directory name in source trees. Box.com's sync client would need the cloud-attribute predicate (already in hard_protection) to catch its placeholders on Windows, not a name protection. Removing it avoids silently zeroing-out legitimate directories named Box.
  • Google Drive: Current Google Drive for desktop on Windows mounts via a virtual drive letter (G: by default), documented at the linked support page. A Google Drive folder in the user profile belongs to the legacy Backup-and-Sync client, which is no longer distributed.

Both removals are defensible and the CHANGELOG documents the rationale transparently. The note that Box and non-OneDrive roots are covered only by the attribute predicate on Windows (and not at all on Linux/macOS) is honest about residual exposure.

4. iCloudDrive name uncertainty — acknowledged, not worsened

The commit explicitly records that the exact folder name could not be confirmed from an official Apple source. iCloudDrive was already present before this commit; this change does not regress it. A follow-up issue to pin the macOS CloudStorage path (per the prior review's finding 3) is appropriate.


What looks correct

  • Glob-in-protected_name_globs is the right fix surface: Callers of has_protected_name cannot suppress the glob check (it fires unconditionally from baseline_protected_name_globs()), so adding Dropbox (*) here gives the same unconditional protection as the earlier OneDrive - * glob. A consumer overlay cannot override it.
  • CHANGELOG is exemplary: Explicitly records what was rejected and why, cites sources, and states residuals. This is the correct level of transparency for a safety-critical baseline.
  • No logic changes in hygiene.py: This commit touches only the policy JSON and the changelog. The matching logic implemented in e081e5e is identical to what was already tested and confirmed for OneDrive - *; only the policy data changes.

Summary

The Codex finding is correctly addressed. The Box and Google Drive removals are well-reasoned. The only gap worth addressing before merge is a test exercising the Dropbox (*) glob — without it the new protection has no regression anchor. Finding 1 is the one I'd action; findings 2–4 are observations, not blockers.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 13s —— View job


Security Review — PR #1818 (Incremental Pass)

  • Read PR diff (both commits) and changed files
  • Analyze hygiene.py changes for security issues
  • Analyze baseline-policy.json for policy bypass risks
  • Analyze second commit's removals (Box, Google Drive from exact names)
  • Post findings

This pass covers the incremental changes on top of the prior security review, which already verified bitmask correctness, glob-injection surfaces, lru_cache semantics, and the TOCTOU window between scan and apply. No repetition of those findings below.


No new critical or important security findings

The second commit (e081e5e) removes Box and Google Drive from protected_exact_names. This is a deliberate scope reduction, not a regression — both were added and then removed within this PR and neither appeared in the pre-PR baseline. The commit message is candid that their placeholder attributes were never sampled on the audit host, so protecting them on attribute alone is not possible, and the exact-name strings are too general (a user could legitimately have an unrelated folder named "Box"). The residual is stated in the PR body.


Findings

SUGGESTION · low confidence — Dropbox (*) pattern requires a trailing ) and so does not match names with a trailing space or extra suffix after the closing paren

baseline-policy.json L21

The pattern Dropbox (*) translates to the regex (?s:Dropbox\ \(.*\))\Z via fnmatch.translate. It requires the name to end with ). The exact Dropbox naming for linked accounts is Dropbox (Personal) and Dropbox (<TeamName>), so this is correct for the documented case. No injection risk — both sides of the match are casefolded strings from the bundled baseline; no user input reaches fnmatchcase.

This is worth noting only because a sync client that appended a space, a home-directory locale string, or a version suffix after the closing paren would produce a name the pattern misses. This is speculative; no evidence it occurs in practice.

SUGGESTION · medium confidence — metadata() and hard_protection() each call lstat() independently, creating a narrow internal consistency window

hygiene.py L610 and hygiene.py L407

Within scan_tree, hard_protection calls link_and_cloud_state(path) (one lstat) and metadata calls path.lstat() separately. If a cloud-sync client dehydrates a file between those two calls, the entry could end up with cloud-placeholder absent from protected_reasons but present in size_qualifiers, or the reverse. The dangerous direction — protection missed but qualifier recorded — would leave the file unprotected despite reporting a cloud qualifier. In practice this requires a sync client to dehydrate a specific file within a millisecond-scale window during an active scan, and the entry would still be rechecked by hard_protection at both preview and apply. This is a pre-existing TOCTOU shape (the old is_linkish had the same structure) that the new code does not meaningfully worsen, but the two reads are now conceptually separate predicates rather than one, which makes the divergence more visible.

No change recommended for this PR; noted for a future hardening pass alongside the current_descendants observation in the prior review.


What was re-verified

  • apply_policy_overlay allowlist excludes protected_name_globs: user overlays cannot inject or extend the baseline glob list. The only overlay-accessible path-glob field is additional_protected_path_globs, which is matched against a relative path only and cannot reach a parent of the scan target. Verified: hygiene.py L515–520.

  • fnmatchcase receives only path.name (no separators): the glob is applied to the filename component only, so neither .. nor / in a pattern could produce path traversal. Verified: hygiene.py L264.

  • file_attributes field value: int(getattr(info, "st_file_attributes", 0)) — the int() cast prevents an unexpected non-integer from reaching the snapshot JSON. The value is stored in user-controlled CLAUDE_PLUGIN_DATA alongside the existing mode bits, device, and inode; no new cross-trust boundary disclosure.

  • Second commit net effect on baseline: Box and Google Drive removed (never in pre-PR baseline; their removal leaves the post-PR baseline no weaker than before this PR). Dropbox (*) and OneDrive - * added to protected_name_globs. Dropbox, iCloudDrive, and OneDrive remain as exact names. No policy strength regression relative to the pre-PR state.


Conclusion

No security vulnerabilities found in this incremental pass. The two SUGGESTION-level observations are low-severity informational items about edge cases in glob coverage and a pre-existing stat-timing shape; neither blocks merge.

…lity

Two properties of the new protection were argued rather than checked, and both
are the kind that fail silently.

A forged or older snapshot must not be able to weaken name-pattern protection.
The patterns are read from the bundled baseline rather than from the snapshot's
own policy, so clearing both the entries' protected_reasons and the snapshot
policy's protected_name_globs still leaves preview blocking the candidate with
baseline-protected-name and withholding the approval token. The exact-name
equivalent was already pinned; the pattern path needs its own, because it reads
from a different source.

A snapshot written by a previous engine carries neither size_qualifiers nor
file_attributes. Cached plugin versions linger well past their documented
two-week cleanup window, so such a snapshot has to stay previewable rather than
raising on a missing key. Stripping both fields still previews clean and still
mints a token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 15s —— View job


Review — PR #1818 (incremental pass: e081e5e + 0932d47)

  • Gather context from prior reviews
  • Read incremental diff (e081e5e + 0932d47)
  • Review baseline-policy.json changes (Dropbox variants)
  • Review new/updated tests
  • Check prior findings are addressed
  • Post findings

What changed

e081e5e — Drops Box and Google Drive from protected_exact_names; adds Dropbox (*) to protected_name_globs alongside the bare exact name. This addresses the Codex review comment and corrects the first-pass policy.

0932d47 — Adds two new tests:

  • test_forged_snapshot_cannot_hide_a_tenant_sync_root: verifies that zeroing both entry["protected_reasons"] and snapshot["policy"]["protected_name_globs"] still leaves preview blocked and approval_token is None
  • test_preview_accepts_a_snapshot_lacking_the_new_entry_fields: verifies that a snapshot missing size_qualifiers and file_attributes (written by an older engine) is still previewable without raising

Findings

1. Dropbox (*) glob has no test pinning its match/no-match boundary — low

baseline-policy.json L21

Dropbox (*) was added to protected_name_globs alongside the bare Dropbox exact name, covering the documented Dropbox (Personal) and Dropbox (<business name>) folder shapes. But the test suite has no assertion for it: test_tenant_cloud_sync_root_name_is_protected (L252) covers OneDrive glob variants with two spellings and a non-matching control; there's no parallel for Dropbox. The logic path is identical, so the protection will work, but the invariant isn't pinned.

The missing assertions are straightforward — extending test_tenant_cloud_sync_root_name_is_protected or adding a sibling:

for spelling in ("Dropbox (Personal)", "Dropbox (Contoso Ltd)", "dropbox (personal)"):
    self.assertTrue(hygiene.has_protected_name(Path(spelling), names))
self.assertFalse(hygiene.has_protected_name(Path("Dropbox-tools"), names))

Fix this →

2. Old-snapshot test doesn't exercise what it says — very low, for audit clarity

test_hygiene.py L852

test_preview_accepts_a_snapshot_lacking_the_new_entry_fields removes size_qualifiers and file_attributes then verifies preview() completes and mints a token. The test is correct and useful as a non-regression pin. However, preview() never reads those fields from snapshot entries at all — it re-reads the live filesystem for every identity check. The test therefore confirms "absent fields don't crash something unexpected in the call path" rather than "preview has explicit .get(field, default) guards for these keys." Not incorrect, just worth noting for a future reader who wonders what the backward-compat risk actually is: apply_plan() should be checked if it ever starts reading these fields from the snapshot rather than re-probing the FS.


What looks correct

Box and Google Drive removal — Both decisions are sound and documented. Box at every depth would silently zero-size source-tree directories with that name; current Google Drive uses a virtual drive letter rather than a profile folder. The commit message cites the Google Drive docs URL.

Dropbox (*) alongside bare Dropbox — Dropbox's own documentation names both shapes; neither alone is sufficient. The glob fires through fnmatch.fnmatchcase on casefolded names, consistent with the OneDrive pattern.

test_forged_snapshot_cannot_hide_a_tenant_sync_root — Correctly zeros both entry["protected_reasons"] and snapshot["policy"]["protected_name_globs"] (which preview() reads at L1504), then verifies the baseline still fires and the token is withheld. The dual assertion (blocker AND approval_token is None) is stronger than the prior exact-name equivalent at L798, which checks only the blocker.

Policy baseline as the trust anchor — The forgery test confirms the key invariant: baseline_protected_name_globs() reads from BASELINE_POLICY (the bundled file path), not from the snapshot's policy dict. Clearing the snapshot can't strip glob protection.


Prior findings from first pass

  • Finding 1 (hidden glob dependency in has_protected_name) — unchanged; the glob path is still unconditional. Still low severity.
  • Finding 2 (current_descendants no cloud short-circuit) — unchanged; pre-existing, benign.
  • Finding 3 (iCloudDrive name accuracy) — unchanged; still acknowledged as unverified.
  • Finding 4 (extra blank line) — unchanged; cosmetic.

Verdict on the incremental commits

Both commits are well-targeted. e081e5e closes the Codex-flagged Dropbox gap and tightens the over-broad first pass; 0932d47 adds the two most important properties to the test record (forgery resistance for glob path, backward compat for old snapshots). Finding 1 (no Dropbox glob test) is the one gap worth closing before merge. Finding 2 is informational only. Neither blocks.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 5m 13s —— View job


Security Review — PR #1818 (final incremental pass: 0932d47)

  • Gather context and understand the request
  • Fetch PR diff and read changed files
  • Analyze security: logic flaws, authorization gaps, injection surfaces
  • Cross-check prior review findings to avoid duplication
  • Post findings

This pass covers the third commit (0932d47) on top of the two prior security reviews (first pass, second pass), which between them already verified bitmask correctness, glob-injection surfaces, the lru_cache test-isolation hazard, fnmatchcase path-component scoping, apply_policy_overlay exclusion of protected_name_globs, the TOCTOU window between metadata() and hard_protection(), casefold independence, file_attributes snapshot disclosure, and the Dropbox (*) semantic.


No new security findings

The third commit adds new tests only (test_hygiene.py). No production code changes. The security surface is identical to what the prior two passes examined.


What was examined and found correct across the full diff

Protection is never computed from snapshot-cached data. Both preview and apply_plan call hard_protection against live filesystem state. The snapshot's protected_reasons, file_attributes, and size_qualifiers fields are informational; the actual block decisions re-derive from fresh lstat calls and the bundled baseline_protected_name_globs(). The forgery test (test_forged_snapshot_cannot_hide_a_tenant_sync_root) pins this invariant.

has_protected_path_component now also enforces glob patterns via the unconditional baseline_protected_name_globs() call inside has_protected_name. The scan-entry gate at hygiene.py:2041 and the preview/apply re-check at hygiene.py:1430 both delegate to has_protected_name, which means OneDrive - * glob protection fires at every path-component check even though neither callsite names the glob explicitly. The tenant sync root cannot be used as a scan target and cannot be the ancestor of a deletable candidate.

The cloud-placeholder check cannot be bypassed through the apply path. The apply lane runs preview twice — once in main() at hygiene.py:2111 (approval token check) and again inside apply_plan at hygiene.py:1836 (live re-validation). Per-entry hard_protection fires a third time at the per-file removal loop at hygiene.py:1907. A file dehydrated to a cloud placeholder at any point before the final os.unlink is caught by one of these three independent link_and_cloud_state calls.

The is_linkish(path) early guard at hygiene.py:1894 does not check cloud placeholders, but this is not a bypass. A file that reaches this line and passes the is_linkish test still goes through hard_protection at L1907 before any deletion attempt. The missing check at L1894 means a dehydrated file reaches L1907 rather than exiting early, but L1907 catches it. This asymmetry was already noted in the prior code review (Finding 2 — pre-existing, not introduced by this PR).

New test coverage directly verifies the security-critical properties. test_forged_snapshot_cannot_hide_a_tenant_sync_root exercises the case that motivated this review: a snapshot edited to strip protected_reasons and protected_name_globs still cannot produce a plan that clears preview. test_preview_accepts_a_snapshot_lacking_the_new_entry_fields confirms backward-compatible snapshots remain previewable without opening a downgrade path — old-snapshot handling does not relax any protection predicate.


Conclusion

No security vulnerabilities found in this or either prior pass. The three new commits together are defensively structured: all protection predicates are additive-only, they re-derive from live state rather than snapshot-cached values, user-controlled data cannot reach glob compilation or the name-glob allow-list, and the TOCTOU windows are either pre-existing or already documented in prior passes. Safe to merge on security grounds.

…holder

hard_protection's ancestor walk runs up to and INCLUDING the target, so a scan
target that itself carries a recall/offline bit marked EVERY entry
cloud-placeholder — and scan_tree truncates any directory with protections, so
the whole walk collapsed with no diagnostic. That is the same failure the
mount-point branch already guards against, for the same reason, so it takes the
same exemption: ancestors below the target still contribute, and the target's
own entry still reports the reason, so the condition stays visible instead of
being swallowed.

Also corrects two things the fix's own reporting surface and name list got
wrong:

SKILL.md section 3 never mentioned size_qualifiers, so an agent could still
total a qualified entry's logical_size as reclaimable — the exact misreading the
field was added to prevent. It now states that logical_size is reclaimable local
bytes only when size_qualifiers is empty, and that qualified bytes are reported
separately with their reasons.

The iCloud Drive folder name is "iCloud Drive" with a space, per Apple's
documented Windows path C:\Users\[username]\iCloud Drive, not "iCloudDrive" —
verified against Apple's iCloud for Windows guide rather than assumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 49s —— View job


Code Review — PR #1818 (latest commit f2f7eb7)

  • Gather context (prior reviews already cover commits through 0932d47)
  • Review latest commit f2f7eb7 diff
  • Check alignment with CLAUDE.md/AGENTS.md guidelines
  • Post findings

This pass covers only the newest commit, f2f7eb7, on top of the five prior review passes on this PR (code review + security review, each run twice across earlier commits). Those already covered hard_protection's bitmask, glob matching, apply_policy_overlay isolation, and the forgery/backward-compat tests — not repeated here.

Note: test execution (hygiene.test.sh, python3 -m unittest) was not permitted in this sandboxed session, so the analysis below is static (diff reading + tracing call sites), not test-run-verified. The new test test_a_cloud_placeholder_target_does_not_blanket_mark_its_tree was read and reasoned through by hand rather than executed.


What this commit does

Three independent fixes bundled together:

  1. hard_protection exempts the target's own ancestor-walk iteration from cloud-placeholder (hygiene.py L410) — a scan target that itself carries the recall/offline bit no longer taints every descendant.
  2. SKILL.md §3 documents size_qualifiers (SKILL.md L179–183) — closes the gap where an agent reading the doc could still sum a placeholder's remote logical_size as reclaimable.
  3. iCloudDriveiCloud Drive (baseline-policy.json L14) — corrects the exact name against Apple's documented Windows path, replacing an unverified guess from an earlier commit.

Correctness of the ancestor-walk fix

Traced this by hand against hard_protection's loop (hygiene.py L400–437):

  • target is invariant across a whole scan_tree walk — visit() recurses with a fixed target, and hard_protection(path, target, ...) is always called with path a strict descendant (scan_tree never calls it with path == target, since only children of a directory are visited, never the directory being visited itself). So cloud_placeholder and (current != target or path == target) reduces, for every scan_tree entry, to just current != target — the target's own placeholder state is now invisible to every descendant's hard_protection call, while a genuinely-placeholder intermediate ancestor (current != target) still fires normally.
  • The other six call sites (preview, apply_plan's two checks, etc., grep confirms hard_protection( at L829/1470/1508/1622/1673/1877/1915) all pass a path derived from a plan candidate's relative path joined onto target — same shape, no path == target case reachable there either in the normal flow.
  • The new test correctly isolates the change: it mocks is_volume_root → False and mount_state → (False, None) so the pre-existing mount-point exemption path (a structurally different mechanism — see below) can't interfere, and mocks link_and_cloud_state to key placeholder-ness purely off path == target. That isolates exactly the condition being changed.

One nit on the commit message rather than the code: it describes this as "the same exemption the mount-point branch already guards against, for the same reason." The mechanism isn't quite the same — the mount-point branch (L426–436) exempts volume-root targets globally from ever being flagged target-is-mount-point (a static, target-only property), whereas this fix exempts the target's contribution to each descendant's own ancestor walk while still recording the state on the target's own entry. A non-volume-root target that's genuinely mounted still propagates target-is-mount-point to every child today, which is the same "collapses silently" shape this commit fixes for cloud-placeholder — just not fixed there. Not a defect in this PR (out of scope, and apparently relies on an earlier admission gate to avoid that state), but worth a follow-up note so the analogy in the commit message isn't read as "already handled elsewhere too."

Test coverage

test_a_cloud_placeholder_target_does_not_blanket_mark_its_tree (test_hygiene.py L221–244) pins both halves of the fix: child_reasons excludes cloud-placeholder when the target is the placeholder, and target_reasons (calling hard_protection(target, target, ...)) still includes it. Good — asymmetric behavior like this is exactly what needs a direct regression pin, not just an integration test.

Documentation and policy changes

Verdict

The fix is correctly scoped, the exemption condition is provably equivalent to the simpler "skip only when re-visiting the target as someone else's ancestor" rule its comment describes, and the new test isolates the right variable. The two doc/policy corrections are both straightforward and verified against source (Apple's docs, the field's own established semantics). Nothing here blocks merge; the mount-point analogy nit is informational only.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 2m 55s —— View job


Security Review — PR #1818 (incremental pass: f2f7eb7)

  • Gather context from prior reviews
  • Read incremental diff since last security pass (f2f7eb7)
  • Analyze for security vulnerabilities
  • Post findings

This pass covers the fourth commit (f2f7eb7) on top of the three prior security reviews (pass 1, pass 2, pass 3), which together already verified the bitmask logic, glob-injection surfaces, the lru_cache isolation hazard, fnmatchcase path scoping, apply_policy_overlay exclusion of protected_name_globs, and the pre-existing TOCTOU window between metadata() and hard_protection().

What this commit changes

hygiene.py:410 adds an exemption so the ancestor walk in hard_protection no longer lets the scan target's own cloud-placeholder attribute cascade onto every descendant:

if cloud_placeholder and (current != target or path == target):

Plus a data-only correction (iCloudDriveiCloud Drive, matching Apple's documented path) and a SKILL.md clarification about size_qualifiers vs. logical_size totals.

No new security findings

Traced the exemption against every call site of hard_protection (lines 829, 1470, 1508, 1622, 1673, 1877, 1915): in every one, path/current is built from a candidate's relative path joined onto target (target.joinpath(*PurePosixPath(relative).parts)), and no candidate set ever contains the empty relative path — scan_tree's visit() only calls hard_protection on target's children, never on target itself (hygiene.py:829), and resolve_snapshot_target validates the target through separate name/mount/OS-managed-root checks, never through hard_protection (hygiene.py:1421-1449). So path == target in practice only happens for the walk's own boundary check, not for a real deletion candidate — the exemption cannot be reached by supplying a crafted plan path.

More importantly, the exemption is scoped to only the ancestor walk's contribution from the target directory itself:

  • A candidate F that is itself a genuine placeholder is still caught: the loop's first iteration is always current == path == F (current != target at that point, since F is a descendant), so F's own cloud_placeholder bit is evaluated and appended before the walk ever reaches target.
  • Every intermediate ancestor between F and target (exclusive of target) is still fully checked; only the single iteration where current == target is suppressed, and only for descendants (path != target).
  • The target's own entry (checked elsewhere via path == target, e.g. a future direct check) still reports cloud-placeholder honestly — the OR-clause path == target restores it — so the condition stays visible rather than silently disappearing, matching the stated goal.

This mirrors the pre-existing target-is-mount-point exemption at hygiene.py:429-437 both in shape and in the failure it prevents (a protection-bearing target directory causes scan_tree to truncate the walk at the root with no diagnostic, per the truncation check at hygiene.py:846-848).

Residual worth naming, not a vulnerability: the fix implicitly assumes a cloud attribute on the target directory itself doesn't imply anything about a directly-child file that carries no attribute of its own. The PR's own measurement (directories read 0x10, no cloud bits, across 99 sampled OneDrive subdirectories) supports that assumption for the measured case, and the PR body already discloses that non-OneDrive sync roots and any directory-level tiering behavior are unverified. Exploiting this gap would require the attacker to already control the filesystem attributes of the scan target itself — at that point they already have write access to the location being audited, so this isn't a privilege-escalation or authorization-bypass path, just a documented coverage residual consistent with what's already disclosed in the CHANGELOG.

The iCloud Drive name correction and SKILL.md wording change are data/documentation only — no new code path, no change to apply_policy_overlay's allowlist, no new user-controlled input reaching fnmatch or the filesystem.

Conclusion

No security vulnerabilities found in this incremental commit. The exemption is unreachable via a crafted plan/candidate path, doesn't weaken protection for any entry that is itself a placeholder, and is bounded the same way the existing mount-point exemption already is. Safe to merge on security grounds.

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

Claude has reviewed this PR 2 times. The lane skips further automatic reviews after 5; deleting this comment resets the count.

…ve-letter user path

The machine-specific-paths hygiene check reads the documented Windows
path spelling as a machine-specific absolute path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STASxNiqEm7doy2Z65un4H
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 18s —— View job


Code Review — PR #1818 (latest commit ea62070)

  • Gather context (prior 6 review passes already cover commits through f2f7eb7)
  • Review latest commit ea62070 diff
  • Check alignment with CLAUDE.md/AGENTS.md guidelines
  • Post findings

This pass covers only the newest commit, ea62070, on top of the six prior review passes on this PR (three code review + three security review, covering 5b22192 through f2f7eb7). Those already covered the full functional surface — hard_protection's bitmask, glob matching, the target-exemption fix, apply_policy_overlay isolation, and the forgery/backward-compat tests. Not repeated here.

What this commit does

Doc-only change, one file: CHANGELOG.md. It rewords the iCloud Drive residual note to describe the location without spelling out C:\Users\[username]\iCloud Drive as a literal path.

-  Only the OneDrive class was measured. `iCloud Drive` — with the space, per Apple's documented
-  Windows path `C:\Users\[username]\iCloud Drive` — and `Dropbox` were confirmed unprotected by name
+  Only the OneDrive class was measured. `iCloud Drive` — with the space, the folder name Apple
+  documents directly under the Windows user profile — and `Dropbox` were confirmed unprotected by name

Verification

  • git diff f2f7eb7..ea62070 --stat confirms the commit touches only CHANGELOG.md, 2 lines changed — no code, test, or policy changes.
  • The commit message's stated rationale checks out against the repo's own tooling: plugins/guardrails/lib/path-detection/hardcoded-path-patterns.sh detects C:\Users\<name>\ as a Windows-user hardcoded path (pattern comment at L114), which is exactly the shape the old CHANGELOG line contained ([username] placeholder notwithstanding — the check's own Users alternation isn't placeholder-aware, per the comment at L60). Removing the literal path and describing the location in prose instead is a correct, minimal fix for that class of hit.
  • The reworded sentence is factually equivalent to the original (still says "with the space", still attributes it to Apple's documentation, still not independently verified by this PR — consistent with the "macOS untested" / "never sampled" residual stated elsewhere in the same CHANGELOG entry) and reads cleanly in context (full surrounding paragraph).
  • No test, schema, or policy file references the removed literal path, so no regression risk elsewhere in the diff.

Verdict

Trivial, correctly-scoped style fix with no functional impact. Nothing to flag — safe to merge on this pass.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 31s —— View job


Security Review — PR #1818 (incremental pass since f2f7eb7)

  • Gather context from prior reviews
  • Fetch latest diff (f2f7eb7ea62070)
  • Analyze for security vulnerabilities
  • Post findings

This pass covers the newest commit, ea62070, on top of the four prior security review passes on this PR (all already covering the bitmask logic, glob-injection surfaces, lru_cache isolation, fnmatchcase scoping, apply_policy_overlay exclusion of protected_name_globs, the TOCTOU windows, and the target-ancestor-walk exemption).

What this commit changes

ea62070 touches exactly one file, CHANGELOG.md, rewording a single sentence:

-  Only the OneDrive class was measured. `iCloud Drive` — with the space, per Apple's documented
-  Windows path `C:\Users\[username]\iCloud Drive` — and `Dropbox` were confirmed unprotected by name
+  Only the OneDrive class was measured. `iCloud Drive` — with the space, the folder name Apple
+  documents directly under the Windows user profile — and `Dropbox` were confirmed unprotected by name

No code, policy, or workflow file is touched. The change drops a literal example path (C:\Users\[username]\iCloud Drive) in favor of a description, with no change to the actual claim being made.

No security findings

There is no code path here — this is a documentation-only wording edit to CHANGELOG.md. It does not touch hygiene.py, baseline-policy.json, SKILL.md, plugin.json, or any workflow file, so none of the security-relevant surfaces from prior passes (bitmask correctness, glob matching, overlay allowlisting, snapshot trust boundary, TOCTOU windows) are affected. Nothing new to report.

Conclusion

No security vulnerabilities found in this or any prior pass on this PR. Safe to merge on security grounds.

@kyle-sexton
kyle-sexton merged commit e937462 into main Jul 30, 2026
31 checks passed
@kyle-sexton
kyle-sexton deleted the fix/1804-cloud-placeholder-protection branch July 30, 2026 21:16
kyle-sexton added a commit that referenced this pull request Jul 31, 2026
…n cache tree (#1819)

## Summary

The engine gate defers on a word naming an existing file that is not the
bundled engine, so a consumer's own `tools/hygiene.py` is not mistaken
for this engine (#1640, #1611). Claude Code keeps a replaced version's
directory on disk after an update, so that same escape also covered
every **previous version of this engine** sitting beside the current one
— each a genuinely different file, each deletion-capable, and each
answering to nothing but its own containment once the always-on gate
defers.

The consequence is a kill-switch bypass, not an unbounded-delete bypass:
with `disk_hygiene_enabled: false` the plugin-level gate is the only
guard whenever the clean skill is not the active work, and it deferred.
The stale engine's own preview, approval-token, and platform blockers
still applied. Versions at or below 0.8.1 predate settings-based
kill-switch enforcement entirely.

## Fix

The escape is refused to any path resolving inside
`<plugins>/cache/<marketplace>/<name>`, at both sites that grant it —
the literal-parse branch and the `_provably_other_file` test in the
unparsable branch.

One deviation from the issue's suggestion: the prefix is derived from
the **guard module's own `__file__`** rather than from
`_plugin_data_root_from_root()`'s `--plugin-root` argv value. `__file__`
is the file actually executing, so it needs no argv channel and nothing
outside the process can redirect it — the same reasoning that already
keeps the kill-switch read off `CLAUDE_CONFIG_DIR`. A `--plugin-dir`
checkout carries no such prefix and the narrowing is inert there, which
is deliberate: a checkout has no cached siblings, and narrowing on it
would gate a contributor's every command naming their own working tree's
engine. That inertness is pinned by its own test rather than left
implicit.

`reference/safety-model.md` gains a paragraph stating the narrowing and
its residual, since that document is where the gate's accepted-residual
list lives.

## Verification

**The reported population, on the audit host** —
`~/.claude/plugins/cache/melodic-software/disk-hygiene/`:

- 17 version directories, `0.3.0` through `0.10.2`, matching the report
exactly.
- 16 carry an intact engine; `0.9.4`'s is absent, also as reported.
- `0.3.0` is still present, so the plugins reference's "about two weeks
before cleanup" does not bound this in practice.

**The escape, against the installed 0.10.2 guard** loaded exactly as the
hook loads it: all 15 non-current engines resolve, are not `samefile`
with the bundled one, and the gate **deferred on an `apply --execute`
invocation of every one of them**. Control: 0.10.2's own bundled engine
still gates. No engine was executed — the probe only resolves paths,
compares identity, and calls the classifier on command strings.

**The consequence the issue deliberately left undemonstrated**, isolated
rather than run against a live install with the switch off. Two
identical synthetic cache layouts
(`<base>/plugins/cache/market/disk-hygiene/{0.9.0,0.10.2}/…`), one built
with the guard from `origin/main` and one with the fixed guard, asked
the same four questions:

| Command shape | Before | After |
|---|---|---|
| Stale sibling engine, plain | defers | **GATES** |
| Stale sibling engine, beside an operator | defers | **GATES** |
| The install's own bundled engine | GATES | GATES |
| A consumer's own engine-named tool outside the cache | defers | defers
|

The last two rows are the point: the narrowing does not merely move
which copy is unguarded, and it does not re-break what #1640 and #1611
fixed.

**Gates:**

- `bash plugins/disk-hygiene/skills/clean/scripts/hygiene.test.sh` — 226
tests, OK (4 skipped); 3 new tests, which build a real `plugins/cache/…`
layout rather than stubbing the predicate, so they exercise the actual
derivation.
- `bash scripts/check-changelog-parity.sh --check-order` — pass.
- `check-skill.sh clean` — PASS, 0 errors.
- `markdownlint-cli2` on both changed markdown files — 0 errors; `ruff
check` on the guard — clean.

**Residual, unchanged:** a *copied* engine carried outside the cache
tree is still outside the prefix, as it is outside every identity check
the gate makes. That is the copy-evasion class the gate has always
accepted.

## Related

- Refs #1804 / PR #1818 — the other CRITICAL from the same audit,
against the engine rather than the gate. **PR #1818 should merge
first**: it claims `0.11.0` on this same manifest, so this PR takes
`0.12.0` to avoid a duplicate version. The changelog reads contiguously
once both land, and the only conflict is a keep-both-in-order in
`CHANGELOG.md`.
- Refs #1640, #1611 — the over-gating fixes whose escape this narrows;
both behaviours are re-pinned here.

Fixes #1805

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 31, 2026
…nial allow-list (#1820)

## Summary

Takes findings **1** and **5** of #1806. The issue bundles seven
findings; all seven are verified in [a comment on the
issue](#1806 (comment)),
and this PR deliberately does **not** close it — five findings remain
open, three of them needing a maintainer's call rather than an
implementation.

## Fix

**Finding 1 — hint matching was misanchored and case-sensitive in the
unsafe direction.** `has_protected_name()` casefolds and
`matching_hints()` did not, so on Windows and macOS — where both
spellings name the *same* file — protection was case-robust while
discovery was not.

All six `fnmatchcase` call sites now go through one `glob_matches()`
helper: hints, consumer protection globs, and the protection re-checks
in the preview, verify, and apply lanes. That is the part the issue
flagged as needing deliberate handling — the protection globs move
**with** the hints rather than by accident. Casefolding is the safe
direction for both roles: a protection glob that matches more can only
keep more, and a hint that matches more can only surface more for
triage, since hints are discovery signals and never cleanup verdicts.
The helper casefolds both operands and keeps `fnmatchcase` rather than
switching to `fnmatch`, whose folding follows the host platform — a
matcher whose verdict changes with where the scan runs is not one a
protection can rest on.

A new `atomic-write-staging-remnant` hint (`*.tmp.*`, ceiling `medium`)
covers the class the producer-specific hint's own `reason` already
claimed: `.tmp` as an **infix** before a pid and random suffix, the
standard write-temp-then-rename shape. The producer-specific hint still
fires alongside it, since it carries a narrower reason.

**Finding 5 — the Bash denial text under-reported the allow-list.** It
enumerated four engine subcommands and omitted the read-only kill-switch
probe that `_decide` allows *before* the classifier ever runs. Since the
documented bootstrap path is to submit a wrong shape so the denial
teaches the grammar, a consumer learning the allow-list from the denial
never learned the probe is permitted — and the probe is the step that
lets the model state the kill-switch value honestly instead of assuming
the default. The denial now names the probe and discloses the bundled
engine's own path, the only route left when a rendered body's
`${CLAUDE_PLUGIN_ROOT}` arrives unexpanded and the exact-path identity
check denies every guess.

The enumeration and the grammar are now one list, as suggested:
`classify_exact_engine_command` rejects any subcommand outside
`_ALLOWED_ENGINE_SUBCOMMANDS` before its own dispatch, and both bundled
script paths come from a single accessor each, so the message cannot
teach a grammar the classifier does not implement.

## Verification

Running `matching_hints()` against the shipped baseline, before and
after:

| Name | Before | After |
|---|---|---|
| `.rate-limits.json.tmp.1363789.17391` | none |
`atomic-write-staging-remnant` |
| `settings.json.tmp.4` | none | `atomic-write-staging-remnant` |
| `.claude.json.tmp.9552.9bfba4e83eaa` |
`claude-json-failed-atomic-write` | both hints |
| `thumbs.db` | none | `windows-explorer-metadata` |
| `TMP-build` | none | `common-temp-directory` |
| `Scratch.md` | none | `scratch-artifact` |

`Thumbs.db`, `tmp-build`, `scratch.md`, and `foo.tmp` are unchanged, so
this widens matching without disturbing what already matched.

**One correction to the issue's table:** the `.DS_Store` row does not
hold on Windows. `macos-finder-metadata` declares `"os": ["macos"]` and
`matching_hints()` filters by current OS before matching, so neither
spelling hints on a Windows host. That is correct OS filtering, not a
defect; the case finding stands on the other five rows.

**Gates:**

- `bash plugins/disk-hygiene/skills/clean/scripts/hygiene.test.sh` — 228
tests, OK (4 skipped); 5 new tests covering case-insensitive hints, the
staging-remnant class, case-insensitive consumer protection globs, the
denial text naming every accepted shape, and the classifier rejecting a
subcommand outside the shared list.
- `bash scripts/check-changelog-parity.sh --check-order` — pass.
- `check-skill.sh clean` — PASS, 0 errors; `markdownlint-cli2` — 0
errors; `ruff check` — clean.

## Related

- Refs #1806 — **stays open.** This PR takes findings 1 and 5 only.
Verified and still open there: finding 2 (byte qualification — its
`size_qualifiers` mechanism is landing in PR #1818, and the rest should
build on that rather than race it), finding 3 (a `summarize` surface — a
new subcommand and a new shape in the guard's grammar, a design call),
finding 4 (the `Stop` detector's marker — both remedies change when a
safety-observability detector goes quiet, a maintainer call), finding 6
(the probe's environment channel — the probe's allow-list requires
exactly two tokens, so the probe, the guard, and `SKILL.md` have to
change together), and finding 7 (run-state retention — a durability
policy call).
- Refs #1804 / PR #1818 and #1805 / PR #1819 — the two CRITICALs from
the same audit. All three are open against this manifest, so this PR
takes `0.13.0` (`0.11.0` and `0.12.0` are claimed by #1818 and #1819).
Merged in issue order the changelog reads contiguously.

No linked issue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@kyle-sexton
kyle-sexton restored the fix/1804-cloud-placeholder-protection branch August 1, 2026 01:39
kyle-sexton added a commit that referenced this pull request Aug 9, 2026
…1870)

## Summary

Completes finding **2** of #1806 (byte accounting cannot express
"unknown" / "not reclaimable local bytes"). Findings **1** and **5**
already shipped in 0.13.0 via PR #1820.

- Truncated directories now record `logical_size: null` with a
`not-walked` qualifier instead of looking empty (`0`).
- Every entry records `nlink` and cheap `allocated_size` (`st_blocks *
512` on POSIX; null on Windows).
- Files with `st_nlink > 1` carry `hardlinked`; sparse files carry
`sparse` when the platform exposes the signal.
- Snapshot / preview / apply / scan-complete report
`reclaimable_local_bytes*` as a figure distinct from the walked logical
roll-up; qualified entries are excluded.

Closes #1806

## Related

- Refs #1806 — findings **3** (`summarize` surface), **4**
(Stop-detector marker amortisation), **6** (probe path provenance vs
trusted settings), and **7** (run-state retention / snapshot path
containment) need design or coupled-grammar calls; left as follow-ups,
not in this PR.
- Refs #1804 / PR #1818 — landed the `size_qualifiers` /
`file_attributes` mechanism this builds on.
- Refs PR #1820 — already closed findings 1 and 5 on this issue.

## Test plan

- [x] `python -m unittest -v
plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py` — 245 tests,
OK (4 skipped)
- [x] New coverage: truncated-vs-empty contrast, hardlink exclusion from
reclaimable, `nlink`/`allocated_size` shape, protected sync-root
`not-walked`
- [ ] Do not merge from the worker lane

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kyle-sexton
kyle-sexton deleted the fix/1804-cloud-placeholder-protection branch August 14, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant