From cbd777bf14fdf31c2a1037d67f3dd03debf556b4 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 9 Jan 2026 17:13:34 +0800 Subject: [PATCH 01/10] Update documents and instructions --- SECURITY_AUDIT.md | 159 ++++++++++++++++++++-------------------------- 1 file changed, 70 insertions(+), 89 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 2fc4a61..04b87a0 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -2,36 +2,17 @@
-**Security Audit & Compliance Report** - -Version 1.19.0 | January 5, 2026 - ---- - -**Audit Status:** PASSED | **Risk Level:** LOW +**Status:** PASSED | **Risk Level:** LOW | **Version:** 1.19.0 (2026-01-09)
--- -## Table of Contents - -1. [Audit Overview](#audit-overview) -2. [Security Philosophy](#security-philosophy) -3. [Threat Model](#threat-model) -4. [Defense Architecture](#defense-architecture) -5. [Safety Mechanisms](#safety-mechanisms) -6. [User Controls](#user-controls) -7. [Testing & Compliance](#testing--compliance) -8. [Dependencies](#dependencies) - ---- - ## Audit Overview | Attribute | Details | |-----------|---------| -| Audit Date | December 31, 2025 | +| Audit Date | January 9, 2026 | | Audit Conclusion | **PASSED** | | Mole Version | V1.19.0 | | Audited Branch | `main` (HEAD) | @@ -42,12 +23,12 @@ Version 1.19.0 | January 5, 2026 **Key Findings:** -- Multi-layered validation prevents critical system modifications -- Conservative cleaning logic with 60-day dormancy rules -- Comprehensive protection for VPN, AI tools, and system components -- Atomic operations with crash recovery mechanisms -- Full user control with dry-run and whitelist capabilities -- Installer cleanup safely scans common locations with user confirmation +- Multi-layer validation effectively blocks risky system modifications. +- Conservative cleaning logic ensures safety (e.g., 60-day dormancy rule). +- Comprehensive protection for VPNs, AI tools, and core system components. +- Atomic operations prevent state corruption during crashes. +- Dry-run and whitelist features give users full control. +- Installer cleanup scans safely and requires user confirmation. --- @@ -55,14 +36,14 @@ Version 1.19.0 | January 5, 2026 **Core Principle: "Do No Harm"** -Mole operates under a **Zero Trust** architecture for all filesystem operations. Every modification request is treated as potentially dangerous until passing strict validation. +We built Mole on a **Zero Trust** architecture for filesystem operations. Every modification request is treated as dangerous until it passes strict validation. **Guiding Priorities:** -1. **System Stability First** - Prefer leaving 1GB of junk over deleting 1KB of critical data -2. **Conservative by Default** - Require explicit user confirmation for high-risk operations -3. **Fail Safe** - When in doubt, abort rather than proceed -4. **Transparency** - All operations are logged and can be previewed via dry-run mode +1. **System Stability First** - We'd rather leave 1GB of junk than delete 1KB of your data. +2. **Conservative by Default** - High-risk operations always require explicit confirmation. +3. **Fail Safe** - When in doubt, we abort immediately. +4. **Transparency** - Every operation is logged and allows a preview via dry-run mode. --- @@ -89,7 +70,7 @@ Mole operates under a **Zero Trust** architecture for all filesystem operations. ### Multi-Layered Validation System -All automated operations pass through hardened middleware (`lib/core/file_ops.sh`) with 4 validation layers: +All automated operations pass through hardened middleware (`lib/core/file_ops.sh`) with 4 layers of validation: #### Layer 1: Input Sanitization @@ -114,7 +95,7 @@ Even with `sudo`, these paths are **unconditionally blocked**: /Library/Extensions # Kernel extensions ``` -**Exception:** `/System/Library/Caches/com.apple.coresymbolicationd/data` (safe, rebuildable cache) +**Exception:** `/System/Library/Caches/com.apple.coresymbolicationd/data` (safe, rebuildable cache). **Code:** `lib/core/file_ops.sh:60-78` @@ -122,9 +103,9 @@ Even with `sudo`, these paths are **unconditionally blocked**: For privileged operations, pre-flight checks prevent symlink-based attacks: -- Detects symlinks pointing from cache folders to system files -- Refuses recursive deletion of symbolic links in sudo mode -- Validates real path vs symlink target +- Detects symlinks from cache folders pointing to system files. +- Refuses recursive deletion of symbolic links in sudo mode. +- Validates real path vs. symlink target. **Code:** `lib/core/file_ops.sh:safe_sudo_recursive_delete()` @@ -132,18 +113,18 @@ For privileged operations, pre-flight checks prevent symlink-based attacks: When running with `sudo`: -- Auto-corrects ownership back to user (`chown -R`) -- Operations restricted to user's home directory -- Multiple validation checkpoints +- Auto-corrects ownership back to user (`chown -R`). +- Restricts operations to the user's home directory. +- Enforces multiple validation checkpoints. ### Interactive Analyzer (Go) -The analyzer (`mo analyze`) uses a different security model: +The analyzer (`mo analyze`) uses a distinct security model: -- Runs with standard user permissions only -- Respects macOS System Integrity Protection (SIP) -- All deletions require explicit user confirmation -- OS-level enforcement (cannot delete `/System` due to Read-Only Volume) +- Runs with standard user permissions only. +- Respects macOS System Integrity Protection (SIP). +- Requires explicit user confirmation for all deletions. +- OS-level enforcement (cannot delete `/System` due to Read-Only Volume). **Code:** `cmd/analyze/*.go` @@ -159,7 +140,7 @@ The analyzer (`mo analyze`) uses a different security model: |------|--------------|-----------| | 1. App Check | All installation locations | Must be missing from `/Applications`, `~/Applications`, `/System/Applications` | | 2. Dormancy | Modification timestamps | Untouched for ≥60 days | -| 3. Vendor Whitelist | Cross-reference database | Adobe, Microsoft, Google resources protected | +| 3. Vendor Whitelist | Cross-reference database | Adobe, Microsoft, and Google resources are protected | **Code:** `lib/clean/apps.sh:orphan_detection()` @@ -169,8 +150,8 @@ For user-selected app removal: - **Sanitized Name Matching:** "Visual Studio Code" → `VisualStudioCode`, `.vscode` - **Safety Limit:** 3-char minimum (prevents "Go" matching "Google") -- **Disabled:** Fuzzy matching, wildcard expansion for short names -- **User Confirmation:** Required before deletion +- **Disabled:** Fuzzy matching and wildcard expansion for short names. +- **User Confirmation:** Required before deletion. **Code:** `lib/clean/apps.sh:uninstall_app()` @@ -183,19 +164,19 @@ For user-selected app removal: | System Components | Control Center, System Settings, TCC | Centralized detection via `is_critical_system_component()` | | Time Machine | Local snapshots, backups | Checks `backupd` process, aborts if active | | VPN & Proxy | Shadowsocks, V2Ray, Tailscale, Clash | Protects network configs | -| AI & LLM Tools | Cursor, Claude, ChatGPT, Ollama, LM Studio | Protects models, tokens, sessions | +| AI & LLM Tools | Cursor, Claude, ChatGPT, Ollama, LM Studio | Protects models, tokens, and sessions | | Startup Items | `com.apple.*` LaunchAgents/Daemons | System items unconditionally skipped | **Orphaned Helper Cleanup (`opt_startup_items_cleanup`):** Removes LaunchAgents/Daemons whose associated app has been uninstalled: -- Checks `AssociatedBundleIdentifiers` to detect orphans -- Skips all `com.apple.*` system items -- Skips paths under `/System/*`, `/usr/bin/*`, `/usr/lib/*`, `/usr/sbin/*`, `/Library/Apple/*` -- Uses `safe_remove` / `safe_sudo_remove` with path validation -- Unloads service via `launchctl` before deletion -- `mdfind` operations have 10-second timeout protection +- Checks `AssociatedBundleIdentifiers` to detect orphans. +- Skips all `com.apple.*` system items. +- Skips paths under `/System/*`, `/usr/bin/*`, `/usr/lib/*`, `/usr/sbin/*`, `/Library/Apple/*`. +- Uses `safe_remove` / `safe_sudo_remove` with path validation. +- Unloads service via `launchctl` before deletion. +- **Timeout Protection:** 10-second limit on `mdfind` operations. **Code:** `lib/optimize/tasks.sh:opt_startup_items_cleanup()` @@ -206,9 +187,9 @@ Removes LaunchAgents/Daemons whose associated app has been uninstalled: | Network Interface Reset | Atomic execution blocks | Wi-Fi/AirDrop restored to pre-operation state | | Swap Clearing | Daemon restart | `dynamic_pager` handles recovery safely | | Volume Scanning | Timeout + filesystem check | Auto-skip unresponsive NFS/SMB/AFP mounts | -| Homebrew Cache | Pre-flight size check | Skip if <50MB (avoids 30-120s delay) | +| Homebrew Cache | Pre-flight size check | Skip if <50MB (avoids long delays) | | Network Volume Check | `diskutil info` with timeout | Prevents hangs on slow/dead mounts | -| SQLite Vacuum | App-running check + 20s timeout | Skips if Mail/Safari/Messages running | +| SQLite Vacuum | App-running check + 20s timeout | Skips if Mail/Safari/Messages active | | dyld Cache Update | 24-hour freshness check + 180s timeout | Skips if recently updated | | App Bundle Search | 10s timeout on mdfind | Fallback to standard paths | @@ -230,10 +211,10 @@ run_with_timeout 5 diskutil info "$mount_point" || skip_volume **Behavior:** -- Simulates entire operation without filesystem modifications -- Lists every file/directory that **would** be deleted -- Calculates total space that **would** be freed -- Zero risk - no actual deletion commands executed +- Simulates the entire operation without modifying a single file. +- Lists every file/directory that **would** be deleted. +- Calculates total space that **would** be freed. +- **Zero risk** - no actual deletion commands are executed. ### Custom Whitelists @@ -247,19 +228,19 @@ run_with_timeout 5 diskutil info "$mount_point" || skip_volume ~/Library/Application Support/CriticalApp ``` -- Paths are **unconditionally protected** -- Applies to all operations (clean, optimize, uninstall) -- Supports absolute paths and `~` expansion +- Paths are **unconditionally protected**. +- Applies to all operations (clean, optimize, uninstall). +- Supports absolute paths and `~` expansion. **Code:** `lib/core/file_ops.sh:is_whitelisted()` ### Interactive Confirmations -Required for: +We mandate confirmation for: -- Uninstalling system-scope applications -- Removing large data directories (>1GB) -- Deleting items from shared vendor folders +- Uninstalling system-scope applications. +- Removing large data directories (>1GB). +- Deleting items from shared vendor folders. --- @@ -291,33 +272,33 @@ bats tests/security.bats # Run specific suite | Standard | Implementation | |----------|----------------| | OWASP Secure Coding | Input validation, least privilege, defense-in-depth | -| CWE-22 (Path Traversal) | Enhanced detection: rejects `/../` components while allowing `..` in directory names (Firefox compatibility) | +| CWE-22 (Path Traversal) | Enhanced detection: rejects `/../` components, safely handles `..` in directory names | | CWE-78 (Command Injection) | Control character filtering | | CWE-59 (Link Following) | Symlink detection before privileged operations | | Apple File System Guidelines | Respects SIP, Read-Only Volumes, TCC | ### Security Development Lifecycle -- **Static Analysis:** shellcheck for all shell scripts -- **Code Review:** All changes reviewed by maintainers -- **Dependency Scanning:** Minimal external dependencies, all vetted +- **Static Analysis:** `shellcheck` runs on all shell scripts. +- **Code Review:** All changes are manually reviewed by maintainers. +- **Dependency Scanning:** Minimal external dependencies, all carefully vetted. ### Known Limitations | Limitation | Impact | Mitigation | |------------|--------|------------| -| Requires `sudo` for system caches | Initial friction | Clear documentation | -| 60-day rule may delay cleanup | Some orphans remain longer | Manual `mo uninstall` available | -| No undo functionality | Deleted files unrecoverable | Dry-run mode, warnings | -| English-only name matching | May miss non-English apps | Bundle ID fallback | +| Requires `sudo` for system caches | Initial friction | Clear documentation explaining why | +| 60-day rule may delay cleanup | Some orphans remain longer | Manual `mo uninstall` is always available | +| No undo functionality | Deleted files are unrecoverable | Dry-run mode and warnings are clear | +| English-only name matching | May miss non-English apps | Fallback to Bundle ID matching | **Intentionally Out of Scope (Safety):** -- Automatic deletion of user documents/media -- Encryption key stores or password managers -- System configuration files (`/etc/*`) -- Browser history or cookies -- Git repository cleanup +- Automatic deletion of user documents/media. +- Encryption key stores or password managers. +- System configuration files (`/etc/*`). +- Browser history or cookies. +- Git repository cleanup. --- @@ -325,7 +306,7 @@ bats tests/security.bats # Run specific suite ### System Binaries -Mole relies on standard macOS system binaries (all SIP-protected): +Mole relies on standard, SIP-protected macOS system binaries: | Binary | Purpose | Fallback | |--------|---------|----------| @@ -347,14 +328,14 @@ The compiled Go binary (`analyze-go`) includes: **Supply Chain Security:** -- All dependencies pinned to specific versions -- Regular security audits -- No transitive dependencies with known CVEs -- **Automated Releases**: Binaries compiled via GitHub Actions and signed -- **Source Only**: Repository contains no pre-compiled binaries +- All dependencies are pinned to specific versions. +- Regular security audits. +- No transitive dependencies with known CVEs. +- **Automated Releases**: Binaries are compiled and signed via GitHub Actions. +- **Source Only**: The repository contains no pre-compiled binaries. --- -**Certification:** This security audit certifies that Mole implements industry-standard defensive programming practices and adheres to macOS security guidelines. The architecture prioritizes system stability and data integrity over aggressive optimization. +**Our Commitment:** This document certifies that Mole implements industry-standard defensive programming practices and strictly adheres to macOS security guidelines. We prioritize system stability and data integrity above all else. -*For security concerns or vulnerability reports, please contact the maintainers via GitHub Issues.* +*For security concerns or vulnerability reports, please open an issue or contact the maintainers directly.* From 35078702750577fd354035d36c22af2cb0808bee Mon Sep 17 00:00:00 2001 From: Biplav Barua <90949688+biplavbarua@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:35:38 +0530 Subject: [PATCH 02/10] feat: add Angular, SvelteKit, Astro, and coverage to purge targets (#286) --- lib/clean/project.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 85a5c37..7a70ba9 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -36,6 +36,10 @@ readonly PURGE_TARGETS=( ".dart_tool" # Flutter/Dart build cache ".zig-cache" # Zig "zig-out" # Zig + ".angular" # Angular + ".svelte-kit" # SvelteKit + ".astro" # Astro + "coverage" # Code coverage reports ) # Minimum age in days before considering for cleanup. readonly MIN_AGE_DAYS=7 From ebb4f7a1e91bd27481d86ea86902aa2432b966a7 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 10 Jan 2026 07:24:58 +0800 Subject: [PATCH 03/10] feat(analyze): safer deletion with Trash and two-key confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change delete confirmation from double-delete to Delete→Enter - Move files to macOS Trash instead of permanent deletion - Allow file recovery from Trash if accidentally deleted - Update UI prompts to show 'Press Enter to confirm' - Skip Finder-dependent tests in CI environments - Update SECURITY_AUDIT.md with new safety mechanisms Closes #288 --- SECURITY_AUDIT.md | 3 +- cmd/analyze/analyze_test.go | 16 +++--- cmd/analyze/delete.go | 105 ++++++++++++++++++++++-------------- cmd/analyze/delete_test.go | 54 +++++++++++++++++-- cmd/analyze/main.go | 2 +- cmd/analyze/view.go | 4 +- 6 files changed, 129 insertions(+), 55 deletions(-) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 04b87a0..60e0c2c 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -123,7 +123,8 @@ The analyzer (`mo analyze`) uses a distinct security model: - Runs with standard user permissions only. - Respects macOS System Integrity Protection (SIP). -- Requires explicit user confirmation for all deletions. +- **Two-Key Confirmation:** Deletion requires ⌫ (Delete) to enter confirmation mode, then Enter to confirm. Prevents accidental double-press of the same key. +- **Trash Instead of Delete:** Files are moved to macOS Trash using Finder's native API, allowing easy recovery if needed. - OS-level enforcement (cannot delete `/System` due to Read-Only Volume). **Code:** `cmd/analyze/*.go` diff --git a/cmd/analyze/analyze_test.go b/cmd/analyze/analyze_test.go index 213f5c2..6ae8d2e 100644 --- a/cmd/analyze/analyze_test.go +++ b/cmd/analyze/analyze_test.go @@ -90,6 +90,11 @@ func TestScanPathConcurrentBasic(t *testing.T) { } func TestDeletePathWithProgress(t *testing.T) { + // Skip in CI environments where Finder may not be available. + if os.Getenv("CI") != "" { + t.Skip("Skipping Finder-dependent test in CI") + } + parent := t.TempDir() target := filepath.Join(parent, "target") if err := os.MkdirAll(target, 0o755); err != nil { @@ -107,18 +112,15 @@ func TestDeletePathWithProgress(t *testing.T) { } var counter int64 - count, err := deletePathWithProgress(target, &counter) + count, err := trashPathWithProgress(target, &counter) if err != nil { - t.Fatalf("deletePathWithProgress returned error: %v", err) + t.Fatalf("trashPathWithProgress returned error: %v", err) } if count != int64(len(files)) { - t.Fatalf("expected %d files removed, got %d", len(files), count) - } - if got := atomic.LoadInt64(&counter); got != count { - t.Fatalf("counter mismatch: want %d, got %d", count, got) + t.Fatalf("expected %d files trashed, got %d", len(files), count) } if _, err := os.Stat(target); !os.IsNotExist(err) { - t.Fatalf("expected target to be removed, stat err=%v", err) + t.Fatalf("expected target to be moved to Trash, stat err=%v", err) } } diff --git a/cmd/analyze/delete.go b/cmd/analyze/delete.go index 3204241..04a7fa2 100644 --- a/cmd/analyze/delete.go +++ b/cmd/analyze/delete.go @@ -1,19 +1,24 @@ package main import ( - "io/fs" + "context" + "fmt" "os" + "os/exec" "path/filepath" "sort" "strings" "sync/atomic" + "time" tea "github.com/charmbracelet/bubbletea" ) +const trashTimeout = 30 * time.Second + func deletePathCmd(path string, counter *int64) tea.Cmd { return func() tea.Msg { - count, err := deletePathWithProgress(path, counter) + count, err := trashPathWithProgress(path, counter) return deleteProgressMsg{ done: true, err: err, @@ -23,20 +28,20 @@ func deletePathCmd(path string, counter *int64) tea.Cmd { } } -// deleteMultiplePathsCmd deletes paths and aggregates results. +// deleteMultiplePathsCmd moves paths to Trash and aggregates results. func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd { return func() tea.Msg { var totalCount int64 var errors []string - // Delete deeper paths first to avoid parent/child conflicts. + // Process deeper paths first to avoid parent/child conflicts. pathsToDelete := append([]string(nil), paths...) sort.Slice(pathsToDelete, func(i, j int) bool { return strings.Count(pathsToDelete[i], string(filepath.Separator)) > strings.Count(pathsToDelete[j], string(filepath.Separator)) }) for _, path := range pathsToDelete { - count, err := deletePathWithProgress(path, counter) + count, err := trashPathWithProgress(path, counter) totalCount += count if err != nil { if os.IsNotExist(err) { @@ -72,48 +77,70 @@ func (e *multiDeleteError) Error() string { return strings.Join(e.errors[:min(3, len(e.errors))], "; ") } -func deletePathWithProgress(root string, counter *int64) (int64, error) { +// trashPathWithProgress moves a path to Trash using Finder. +// This allows users to recover accidentally deleted files. +func trashPathWithProgress(root string, counter *int64) (int64, error) { + // Verify path exists. + info, err := os.Stat(root) + if err != nil { + return 0, err + } + + // Count items for progress reporting. var count int64 - var firstErr error - - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - // Skip permission errors but continue. - if os.IsPermission(err) { - if firstErr == nil { - firstErr = err - } - return filepath.SkipDir + if info.IsDir() { + _ = filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error { + if err != nil { + return nil } - if firstErr == nil { - firstErr = err - } - return nil - } - - if !d.IsDir() { - if removeErr := os.Remove(path); removeErr == nil { + if !d.IsDir() { count++ if counter != nil { atomic.StoreInt64(counter, count) } - } else if firstErr == nil { - firstErr = removeErr } - } - - return nil - }) - - if err != nil && firstErr == nil { - firstErr = err - } - - if removeErr := os.RemoveAll(root); removeErr != nil { - if firstErr == nil { - firstErr = removeErr + return nil + }) + } else { + count = 1 + if counter != nil { + atomic.StoreInt64(counter, 1) } } - return count, firstErr + // Move to Trash using Finder AppleScript. + if err := moveToTrash(root); err != nil { + return 0, err + } + + return count, nil +} + +// moveToTrash uses macOS Finder to move a file/directory to Trash. +// This is the safest method as it uses the system's native trash mechanism. +func moveToTrash(path string) error { + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + // Escape path for AppleScript (handle quotes and backslashes). + escapedPath := strings.ReplaceAll(absPath, "\\", "\\\\") + escapedPath = strings.ReplaceAll(escapedPath, "\"", "\\\"") + + script := fmt.Sprintf(`tell application "Finder" to delete POSIX file "%s"`, escapedPath) + + ctx, cancel := context.WithTimeout(context.Background(), trashTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "osascript", "-e", script) + output, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("timeout moving to Trash") + } + return fmt.Errorf("failed to move to Trash: %s", strings.TrimSpace(string(output))) + } + + return nil } diff --git a/cmd/analyze/delete_test.go b/cmd/analyze/delete_test.go index 6039367..9e48b22 100644 --- a/cmd/analyze/delete_test.go +++ b/cmd/analyze/delete_test.go @@ -6,7 +6,47 @@ import ( "testing" ) +func TestTrashPathWithProgress(t *testing.T) { + // Skip in CI environments where Finder may not be available. + if os.Getenv("CI") != "" { + t.Skip("Skipping Finder-dependent test in CI") + } + + parent := t.TempDir() + target := filepath.Join(parent, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + + files := []string{ + filepath.Join(target, "one.txt"), + filepath.Join(target, "two.txt"), + } + for _, f := range files { + if err := os.WriteFile(f, []byte("content"), 0o644); err != nil { + t.Fatalf("write %s: %v", f, err) + } + } + + var counter int64 + count, err := trashPathWithProgress(target, &counter) + if err != nil { + t.Fatalf("trashPathWithProgress returned error: %v", err) + } + if count != int64(len(files)) { + t.Fatalf("expected %d files trashed, got %d", len(files), count) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("expected target to be moved to Trash, stat err=%v", err) + } +} + func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) { + // Skip in CI environments where Finder may not be available. + if os.Getenv("CI") != "" { + t.Skip("Skipping Finder-dependent test in CI") + } + base := t.TempDir() parent := filepath.Join(base, "parent") child := filepath.Join(parent, "child") @@ -32,12 +72,16 @@ func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) { t.Fatalf("unexpected error: %v", progress.err) } if progress.count != 2 { - t.Fatalf("expected 2 files deleted, got %d", progress.count) + t.Fatalf("expected 2 files trashed, got %d", progress.count) } if _, err := os.Stat(parent); !os.IsNotExist(err) { - t.Fatalf("expected parent to be removed, err=%v", err) - } - if _, err := os.Stat(child); !os.IsNotExist(err) { - t.Fatalf("expected child to be removed, err=%v", err) + t.Fatalf("expected parent to be moved to Trash, err=%v", err) + } +} + +func TestMoveToTrashNonExistent(t *testing.T) { + err := moveToTrash("/nonexistent/path/that/does/not/exist") + if err == nil { + t.Fatal("expected error for non-existent path") } } diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index bba4647..712d70b 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -530,7 +530,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Delete confirm flow. if m.deleteConfirm { switch msg.String() { - case "delete", "backspace": + case "enter": m.deleteConfirm = false m.deleting = true var deleteCount int64 diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go index 67ae52b..f2845a9 100644 --- a/cmd/analyze/view.go +++ b/cmd/analyze/view.go @@ -390,12 +390,12 @@ func (m model) View() string { } if deleteCount > 1 { - fmt.Fprintf(&b, "%sDelete:%s %d items (%s) %sPress ⌫ again | ESC cancel%s\n", + fmt.Fprintf(&b, "%sDelete:%s %d items (%s) %sPress Enter to confirm | ESC cancel%s\n", colorRed, colorReset, deleteCount, humanizeBytes(totalDeleteSize), colorGray, colorReset) } else { - fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress ⌫ again | ESC cancel%s\n", + fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress Enter to confirm | ESC cancel%s\n", colorRed, colorReset, m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size), colorGray, colorReset) From b952752a7d08761a54a8c07bd44c5311a3b3cd2f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:25:33 +0000 Subject: [PATCH 04/10] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 185 +++++++++++++++++++++++++---------------------- 1 file changed, 98 insertions(+), 87 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 3ab37f2..a12b041 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -113,93 +113,16 @@ - + - - - purofle + + + Schlauer-Hax - - - - - - - - huyixi - - - - - - - - - - - bunizao - - - - - - - - - - - zeldrisho - - - - - - - - - - - yuzeguitarist - - - - - - - - - - - thijsvanhal - - - - - - - - - - - Sizk - - - - - - - - - - - ndbroadbent - - - @@ -210,15 +133,92 @@ MohammedEsafi - + - + - - - Schlauer-Hax + + + ndbroadbent + + + + + + + + + + + Sizk + + + + + + + + + + + thijsvanhal + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + zeldrisho + + + + + + + + + + + bunizao + + + + + + + + + + + huyixi + + + + + + + + + + + purofle @@ -310,6 +310,17 @@ + + + + + + + + biplavbarua + + + From 0a654f365b2f05e0d7fab113c8b2a2c2d29dad54 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 10 Jan 2026 08:22:17 +0800 Subject: [PATCH 05/10] fix(clean): enhance orphan detection accuracy and safety - Expand app scan to include Homebrew Cask and Setapp locations - Add lsappinfo fallback for more reliable running app detection - Add sensitive data protection patterns (1Password, Keychain, etc.) - Add mdfind fallback with file-based caching (Bash 3.2 compatible) --- lib/clean/apps.sh | 84 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 7e844c0..b3f6201 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -87,6 +87,11 @@ scan_installed_apps() { "/Applications" "/System/Applications" "$HOME/Applications" + # Homebrew Cask locations + "/opt/homebrew/Caskroom" + "/usr/local/Caskroom" + # Setapp applications + "$HOME/Library/Application Support/Setapp/Applications" ) # Temp dir avoids write contention across parallel scans. local scan_tmp_dir=$(create_temp_dir) @@ -115,8 +120,12 @@ scan_installed_apps() { done # Collect running apps and LaunchAgents to avoid false orphan cleanup. ( - local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null || echo "") + local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2>/dev/null || echo "") echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' > "$scan_tmp_dir/running.txt" + # Fallback: lsappinfo is more reliable than osascript + if command -v lsappinfo > /dev/null 2>&1; then + run_with_timeout 3 lsappinfo list 2>/dev/null | grep -o '"CFBundleIdentifier"="[^"]*"' | cut -d'"' -f4 >> "$scan_tmp_dir/running.txt" 2>/dev/null || true + fi ) & pids+=($!) ( @@ -138,25 +147,59 @@ scan_installed_apps() { local app_count=$(wc -l < "$installed_bundles" 2> /dev/null | tr -d ' ') debug_log "Scanned $app_count unique applications" } +# Sensitive data patterns that should never be treated as orphaned +# These patterns protect security-critical application data +readonly ORPHAN_NEVER_DELETE_PATTERNS=( + "*1password*" "*1Password*" + "*keychain*" "*Keychain*" + "*bitwarden*" "*Bitwarden*" + "*lastpass*" "*LastPass*" + "*keepass*" "*KeePass*" + "*dashlane*" "*Dashlane*" + "*enpass*" "*Enpass*" + "*credential*" "*Credential*" + "*token*" "*Token*" + "*wallet*" "*Wallet*" + "*ssh*" "*gpg*" "*gnupg*" +) + +# Cache file for mdfind results (Bash 3.2 compatible, no associative arrays) +ORPHAN_MDFIND_CACHE_FILE="" + # Usage: is_bundle_orphaned "bundle_id" "directory_path" "installed_bundles_file" is_bundle_orphaned() { local bundle_id="$1" local directory_path="$2" local installed_bundles="$3" + + # 1. Fast path: check protection list (in-memory, instant) if should_protect_data "$bundle_id"; then return 1 fi - if grep -Fxq "$bundle_id" "$installed_bundles" 2> /dev/null; then - return 1 - fi - if should_protect_data "$bundle_id"; then + + # 2. Fast path: check sensitive data patterns (in-memory, instant) + local bundle_lower + bundle_lower=$(echo "$bundle_id" | LC_ALL=C tr '[:upper:]' '[:lower:]') + for pattern in "${ORPHAN_NEVER_DELETE_PATTERNS[@]}"; do + # shellcheck disable=SC2053 + if [[ "$bundle_lower" == $pattern ]]; then + return 1 + fi + done + + # 3. Fast path: check installed bundles file (file read, fast) + if grep -Fxq "$bundle_id" "$installed_bundles" 2>/dev/null; then return 1 fi + + # 4. Fast path: hardcoded system components case "$bundle_id" in loginwindow | dock | systempreferences | systemsettings | settings | controlcenter | finder | safari) return 1 ;; esac + + # 5. Fast path: 60-day modification check (stat call, fast) if [[ -e "$directory_path" ]]; then local last_modified_epoch=$(get_file_mtime "$directory_path") local current_epoch @@ -166,6 +209,37 @@ is_bundle_orphaned() { return 1 fi fi + + # 6. Slow path: mdfind fallback with file-based caching (Bash 3.2 compatible) + # This catches apps installed in non-standard locations + if [[ -n "$bundle_id" ]] && [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] && [[ ${#bundle_id} -ge 5 ]]; then + # Initialize cache file if needed + if [[ -z "$ORPHAN_MDFIND_CACHE_FILE" ]]; then + ORPHAN_MDFIND_CACHE_FILE=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX") + register_temp_file "$ORPHAN_MDFIND_CACHE_FILE" + fi + + # Check cache first (grep is fast for small files) + if grep -Fxq "FOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2>/dev/null; then + return 1 + fi + if grep -Fxq "NOTFOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2>/dev/null; then + # Already checked, not found - continue to return 0 + : + else + # Query mdfind with strict timeout (2 seconds max) + local app_exists + app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2>/dev/null | head -1 || echo "") + if [[ -n "$app_exists" ]]; then + echo "FOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" + return 1 + else + echo "NOTFOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" + fi + fi + fi + + # All checks passed - this is an orphan return 0 } # Orphaned app data sweep. From 9c39eef7feea74c204d03e5c18040c3d1b38f34b Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 10 Jan 2026 00:23:16 +0000 Subject: [PATCH 06/10] chore: auto format code --- lib/clean/apps.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index b3f6201..56b7fea 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -120,11 +120,11 @@ scan_installed_apps() { done # Collect running apps and LaunchAgents to avoid false orphan cleanup. ( - local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2>/dev/null || echo "") + local running_apps=$(run_with_timeout 5 osascript -e 'tell application "System Events" to get bundle identifier of every application process' 2> /dev/null || echo "") echo "$running_apps" | tr ',' '\n' | sed -e 's/^ *//;s/ *$//' -e '/^$/d' > "$scan_tmp_dir/running.txt" # Fallback: lsappinfo is more reliable than osascript if command -v lsappinfo > /dev/null 2>&1; then - run_with_timeout 3 lsappinfo list 2>/dev/null | grep -o '"CFBundleIdentifier"="[^"]*"' | cut -d'"' -f4 >> "$scan_tmp_dir/running.txt" 2>/dev/null || true + run_with_timeout 3 lsappinfo list 2> /dev/null | grep -o '"CFBundleIdentifier"="[^"]*"' | cut -d'"' -f4 >> "$scan_tmp_dir/running.txt" 2> /dev/null || true fi ) & pids+=($!) @@ -188,7 +188,7 @@ is_bundle_orphaned() { done # 3. Fast path: check installed bundles file (file read, fast) - if grep -Fxq "$bundle_id" "$installed_bundles" 2>/dev/null; then + if grep -Fxq "$bundle_id" "$installed_bundles" 2> /dev/null; then return 1 fi @@ -220,16 +220,16 @@ is_bundle_orphaned() { fi # Check cache first (grep is fast for small files) - if grep -Fxq "FOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2>/dev/null; then + if grep -Fxq "FOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then return 1 fi - if grep -Fxq "NOTFOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2>/dev/null; then + if grep -Fxq "NOTFOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then # Already checked, not found - continue to return 0 : else # Query mdfind with strict timeout (2 seconds max) local app_exists - app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2>/dev/null | head -1 || echo "") + app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") if [[ -n "$app_exists" ]]; then echo "FOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" return 1 From 273a6b5cf0ed120861e4be3cb8a0efd0614719af Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 10 Jan 2026 08:51:14 +0800 Subject: [PATCH 07/10] fix(clean): enhance orphan detection accuracy and safety - Expand app scan to include Homebrew Cask and Setapp locations - Add lsappinfo fallback for more reliable running app detection - Add sensitive data protection patterns (1Password, Keychain, etc.) - Add mdfind fallback with file-based caching (Bash 3.2 compatible) --- lib/clean/apps.sh | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index b3f6201..01426df 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -157,10 +157,8 @@ readonly ORPHAN_NEVER_DELETE_PATTERNS=( "*keepass*" "*KeePass*" "*dashlane*" "*Dashlane*" "*enpass*" "*Enpass*" - "*credential*" "*Credential*" - "*token*" "*Token*" - "*wallet*" "*Wallet*" "*ssh*" "*gpg*" "*gnupg*" + "com.apple.keychain*" ) # Cache file for mdfind results (Bash 3.2 compatible, no associative arrays) From 7d43e669a8f85f7c0e247fdee703680c93a807ce Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 10 Jan 2026 08:51:14 +0800 Subject: [PATCH 08/10] fix(analyze): improve deletion safety and UI clarity - Update UI status to 'Moving to Trash...' for clarity - Use os.Lstat instead of os.Stat to correctly handle broken symlinks during deletion checks --- cmd/analyze/delete.go | 4 ++-- cmd/analyze/main.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/analyze/delete.go b/cmd/analyze/delete.go index 04a7fa2..11feaee 100644 --- a/cmd/analyze/delete.go +++ b/cmd/analyze/delete.go @@ -80,8 +80,8 @@ func (e *multiDeleteError) Error() string { // trashPathWithProgress moves a path to Trash using Finder. // This allows users to recover accidentally deleted files. func trashPathWithProgress(root string, counter *int64) (int64, error) { - // Verify path exists. - info, err := os.Stat(root) + // Verify path exists (use Lstat to handle broken symlinks). + info, err := os.Lstat(root) if err != nil { return 0, err } diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index 712d70b..f34464a 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -515,7 +515,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.deleting && m.deleteCount != nil { count := atomic.LoadInt64(m.deleteCount) if count > 0 { - m.status = fmt.Sprintf("Deleting... %s items removed", formatNumber(count)) + m.status = fmt.Sprintf("Moving to Trash... %s items", formatNumber(count)) } } return m, tickCmd() From 8a3443be4d8caf91ee3f713f30e27585641ec649 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 10 Jan 2026 08:58:16 +0800 Subject: [PATCH 09/10] update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a174545..90130b8 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ mo purge --paths # Configure project scan directories - **Terminal**: iTerm2 has known compatibility issues; we recommend Alacritty, kitty, WezTerm, Ghostty, or Warp. - **Safety**: Built with strict protections. See [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`. +- **Be Careful**: Although safe by design, file deletion is permanent. Please review operations carefully. - **Debug Mode**: Use `--debug` for detailed logs (e.g., `mo clean --debug`). Combine with `--dry-run` for comprehensive preview including risk levels and file details. - **Navigation**: Supports arrow keys and Vim bindings (`h/j/k/l`). - **Status Shortcuts**: In `mo status`, press `k` to toggle cat visibility and save preference, `q` to quit. From ef713afda7fac80b6c2265a1f1648a5c2178e5b8 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 10 Jan 2026 09:41:17 +0800 Subject: [PATCH 10/10] update test --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b55bb71..eb5a23c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -79,7 +79,7 @@ jobs: echo "Checking for hardcoded secrets..." matches=$(grep -r "password\|secret\|api_key" --include="*.sh" . \ | grep -v "# \|test" \ - | grep -v -E "lib/core/sudo\.sh|lib/core/app_protection\.sh|lib/clean/user\.sh|lib/clean/brew\.sh|bin/optimize\.sh" || true) + | grep -v -E "lib/core/sudo\.sh|lib/core/app_protection\.sh|lib/clean/user\.sh|lib/clean/brew\.sh|bin/optimize\.sh|lib/clean/apps\.sh" || true) if [[ -n "$matches" ]]; then echo "$matches" echo "✗ Potential secrets found"