Engagement Report: SELECT ALL Bypassed Its Own Risk Classifier

Hidden Bastard's deep scan correctly flags unfamiliar Library folders as high-risk, but its SELECT ALL button ignored that flag entirely and would have swept them into a one-click Trash operation anyway.

Another in the series of redacted-format engagement reports, this one on Hidden Bastard, a macOS junk-file eliminator that scans for caches, build artifacts, and stale developer cruft and moves what it finds to Trash. A tool whose entire premise is deciding what's safe to delete is also a tool where the review should specifically go looking for the one case where that decision is wrong. That's the target here: not the scanner's junk-detection accuracy, but the logic standing between "found it" and "it's in the Trash."

Scope and Objective

Target: FileScanner.swift's risk classification for the deep "biggest folders" scan, AppState.swift's selection logic that decides what gets acted on, and CleaningRulesEngine.swift's background automated-rule executor.

Objective: read the actual deletion path end to end rather than the README's description of it, and check whether every route into "files get removed" actually honors the same safety tiering the app advertises. No test suite exists in the repo, so verification meant extracting the real decision logic out of the source and running it, unmodified, against real fixture files in a disposable /tmp directory, never against anything live.

Findings

1. SELECT ALL bypassed the app's own risk gate (High)

Hidden Bastard's deep scan walks ~/Library for large folders no hardcoded path list would catch, then classifies each one by how safe it looks. classifyBigFolder() in FileScanner.swift is reasonable: known cache and build-artifact path signatures get tagged .low or .medium risk, and anything big and unrecognized defaults to "Large App Data" at .high risk. The app's own category description for that tier is explicit: "this also holds real data like profiles and logins. Review each item before deleting." The README repeats the promise in its own words: unfamiliar big folders are "flagged review-first" and "never auto-selected."

That promise held for exactly one of the two places selection happens. After a scan finishes, both scannerDidFinishScan() and the completion handler in startScan() set the initial selection with problemFiles.filter { $0.riskLevel == .low }, so high-risk items correctly start unchecked. But the SELECT ALL button in FileListView called AppState.selectAll(), and that function was:

func selectAll() {
    selectedFileIds = Set(problemFiles.map { $0.id })
}

No risk filter at all. One click selected every item on screen, high-risk Large App Data folders included, the exact category the app's own documentation says can hold a browser profile or login database. There was no confirmation dialog on MOVE TO TRASH either, and ContentView.swift's file list row has zero references to riskLevel: no color, no badge, nothing visually distinguishing "safe cache" from "might be your Chrome profile" beyond a line of description text styled identically to every other category. SELECT ALL, then MOVE TO TRASH, two clicks, would have caught anything the deep scan surfaced.

2. The Rules Engine's "Delete" action bypassed Trash entirely (Medium)

CleaningRulesEngine.swift runs user-defined automated rules on a schedule (hourly through monthly) via a background Timer, with no human in the loop at execution time. It fires silently and posts a notification afterward. RuleEditorView.swift lets a user build a custom rule and pick an action from RuleAction.allCases, which includes .delete. The executor's switch statement handled that case with FileManager.default.removeItem(atPath: file), a real, permanent delete, not trashItem(). The repo's own shipped default rule, "Clean Application Caches," was configured with action: .delete out of the box (disabled by default, but present as the template a user sees first).

This directly contradicts the README's blanket claim: "everything goes to Trash first, never a permanent delete." For the main scan-and-select flow that's true (FileScanner.deleteFile() only ever calls trashItem). For the unattended automation layer, it wasn't.

Verification

No test suite exists in this repo. Rather than guess at behavior from reading alone, I extracted the real decision logic verbatim (classifyBigFolder(), the post-scan auto-select filter, and selectAll()) into a standalone Swift script and ran it against real fixture folders built in a disposable /tmp directory, never against anything live, nothing outside that directory touched:

$ swift hb_verify.swift

=== Step 1: classifyBigFolder() against real fixture paths ===
SomeAppCache: size=6291456 bytes, category=Application Caches, risk=Low
Profile 1: size=629145600 bytes, category=Large App Data, risk=High

=== Step 2: real post-scan auto-select logic, verbatim from AppState.swift ===
SomeAppCache: auto-selected after scan = true
Profile 1: auto-selected after scan = false

=== Step 3: real SELECT ALL logic, verbatim from AppState.selectAll() ===
SomeAppCache: selected after SELECT ALL = true (risk=Low)
Profile 1: selected after SELECT ALL = true (risk=High)

=== Result ===
CONFIRMED: SELECT ALL includes 1 high-risk item(s) the app's own post-scan logic refuses to auto-select:
  - /tmp/hb_verify_fixtures/Library/Application Support/Chrome/Profile 1 (629145600 bytes, category: Large App Data)

The classifier itself is sound, a genuine cache folder gets tagged low-risk, a Chrome-profile-shaped folder gets correctly tagged high-risk, and the post-scan auto-select respects that tier. SELECT ALL did not.

Both bugs are now fixed. AppState.selectAll():

func selectAll() {
    selectedFileIds = Set(problemFiles.filter { $0.riskLevel != .high }.map { $0.id })
}

Re-running the same harness against the patched logic:

=== Step 3: real SELECT ALL logic, verbatim from PATCHED AppState.selectAll() ===
SomeAppCache: selected after SELECT ALL = true (risk=Low)
Profile 1: selected after SELECT ALL = false (risk=High)

=== Result ===
No discrepancy found.

For the Rules Engine, .delete and .moveToTrash now resolve to the same trashItem() call, and the shipped default rule's action was changed from .delete to .moveToTrash. The app no longer has a reachable permanent-delete path anywhere, scheduled or manual. I confirmed both changes compile and the app still builds clean: xcodebuild -project HiddenBastard.xcodeproj -scheme HiddenBastard -configuration Debug build ends in ** BUILD SUCCEEDED **.

Mitigations and Next Steps

  1. Fixed: selectAll() now excludes .high risk items, matching the exact filter already used right after a scan completes. One button can no longer select something the app's own classifier calls "review carefully."
  2. Fixed: the Rules Engine's .delete action now routes through trashItem() instead of removeItem(), so the README's "always goes to Trash" claim is true for the unattended scheduler, not just the interactive scan flow.
  3. Worth doing next, not done tonight: surface riskLevel visually in the file list itself (a colored badge, not just prose), and add a confirmation step before MOVE TO TRASH when the selection includes anything above .low risk. The classifier and the selection filters were both already correct in isolation. The gap was that nothing in the UI told a user which tier they were looking at, so there was no way to notice a bypass was even possible without reading the source.
  4. Any future RuleAction case needs the same question asked explicitly: does this action's executor actually honor the Trash-first promise, or does it just inherit whatever FileManager call seemed convenient at the time.

GhostInThePrompt.com // The risk filter applied to one selection button and not the other.