From c560256ab49d761bd71bc1b033fa7a888a1632b5 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:21:33 -0400 Subject: [PATCH 1/2] feat(machine-health): add drive-root-litter check surfacing stray volume-root entries A disk audit found an empty C:\tmp path-translation artifact and a 0-byte C:\log.txt at the volume root; no existing check would ever have reported either. The new catalog entry #19 lists each fixed-volume root non-recursively and diffs it against an expected-entry baseline that is data (references/windows/drive-root-baseline.jsonc), not script logic: the system drive gets the full baseline diff, non-system volumes report only known litter-name shapes, and admitting a legitimate entry is a data edit. Severity caps at WARN (>=10 residue entries), INFO below, no CRIT, and the check is excluded from the trend engine's generic upward upgrade; output is deterministic so an unchanged dropping feeds identical_streak demotion instead of reading as news. Read-only, unelevated, Windows-only; removal routes to disk-hygiene:clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV --- .../machine-health/.claude-plugin/plugin.json | 2 +- plugins/machine-health/CHANGELOG.md | 16 + .../skills/audit/catalog/checks.jsonc | 13 + .../audit/references/windows/check-catalog.md | 71 +++++ .../windows/drive-root-baseline.jsonc | 73 +++++ .../windows/checks/Test-DriveRootLitter.ps1 | 282 ++++++++++++++++++ .../windows/lib/Invoke-TrendAnalysis.ps1 | 4 + .../checks/Test-DriveRootLitter.Tests.ps1 | 249 ++++++++++++++++ .../lib/Invoke-TrendAnalysis.Tests.ps1 | 19 ++ 9 files changed, 728 insertions(+), 1 deletion(-) create mode 100644 plugins/machine-health/skills/audit/references/windows/drive-root-baseline.jsonc create mode 100644 plugins/machine-health/skills/audit/scripts/windows/checks/Test-DriveRootLitter.ps1 create mode 100644 plugins/machine-health/skills/audit/tests/windows/checks/Test-DriveRootLitter.Tests.ps1 diff --git a/plugins/machine-health/.claude-plugin/plugin.json b/plugins/machine-health/.claude-plugin/plugin.json index ae5eded00..c2cd2aeab 100644 --- a/plugins/machine-health/.claude-plugin/plugin.json +++ b/plugins/machine-health/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "machine-health", - "version": "0.11.18", + "version": "0.12.0", "description": "Workstation health audit: OS-specific checks (disk, OS updates, security posture, CISA KEV correlation) run from a versioned catalog with trend-aware severity, approval-gated remediations, and dated markdown reports. Windows fully implemented; macOS/Linux scaffolded (report UNKNOWN and stop). Machine state persists in the plugin data directory; the report directory and check catalog are configurable.", "author": { "name": "Melodic Software", diff --git a/plugins/machine-health/CHANGELOG.md b/plugins/machine-health/CHANGELOG.md index be390e38a..e02fe121a 100644 --- a/plugins/machine-health/CHANGELOG.md +++ b/plugins/machine-health/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the `machine-health` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.12.0] + +### Added + +- **New check: `drive-root-litter` (catalog #19).** Reports unexpected files and directories at + fixed-volume roots — the class a disk audit found as an empty `C:\tmp` path-translation artifact + and a 0-byte `C:\log.txt` dropped by an elevated process with CWD `C:\` — so root droppings + surface on a routine health run instead of only during a manual audit. The expected-entry set is + data (`references/windows/drive-root-baseline.jsonc`), not script logic: the system drive gets a + full baseline diff, non-system volumes report only known litter-name shapes (user content there is + presumed intentional), and admitting a new legitimate entry is a data edit. Severity caps at WARN + (≥10 residue entries) with INFO below — tidiness, never CRIT — and the check is excluded from the + trend engine's generic upward upgrade. Output is deterministic (sorted residue, day-granularity + `created` dates) so an unchanged dropping feeds `identical_streak` demotion instead of reading as + news every run. Read-only, no elevation, Windows only; removal routes to `disk-hygiene:clean`. + ## [0.11.18] ### Fixed diff --git a/plugins/machine-health/skills/audit/catalog/checks.jsonc b/plugins/machine-health/skills/audit/catalog/checks.jsonc index 80fc4b94f..358e71be7 100644 --- a/plugins/machine-health/skills/audit/catalog/checks.jsonc +++ b/plugins/machine-health/skills/audit/catalog/checks.jsonc @@ -252,6 +252,19 @@ "added_on": "2026-08-21", "crash_count": 0, "identical_streak": 0 + }, + { + "id": "drive-root-litter", + "category": "storage", + "os": ["windows"], + "script": "scripts/windows/checks/Test-DriveRootLitter.ps1", + "severity_rules": "references/windows/check-catalog.md#19-drive-root-litter", + "needs_admin": false, + "enabled": true, + "deprecated": false, + "added_on": "2026-08-30", + "crash_count": 0, + "identical_streak": 0 } ] } diff --git a/plugins/machine-health/skills/audit/references/windows/check-catalog.md b/plugins/machine-health/skills/audit/references/windows/check-catalog.md index 3e2582bf2..aaf97a838 100644 --- a/plugins/machine-health/skills/audit/references/windows/check-catalog.md +++ b/plugins/machine-health/skills/audit/references/windows/check-catalog.md @@ -12,6 +12,7 @@ - [8. Driver inventory](#8-driver-inventory) - [17. Claude Code temp root](#17-claude-code-temp-root) - [18. Environment and PATH health](#18-environment-and-path-health) +- [19. Drive-root litter](#19-drive-root-litter) Per-check rubrics for Windows. Section numbers follow the order of `catalog/checks.jsonc` and are load-bearing — each is the anchor a catalog entry's `severity_rules` points at, so renumbering breaks @@ -363,3 +364,73 @@ All checks emit the schema in `references/shared/output-schema.md`, use `scripts A User-scope `=1` silently freezes the installed binary — the defect that motivated this check. Duplicate and missing PATH entries are INFO because presence is a shape, not a verdict that the entry should be removed. + +--- + +## 19. Drive-root litter + +- **Script:** `scripts/windows/checks/Test-DriveRootLitter.ps1` +- **Category:** `storage` +- **Needs admin:** no. Listing a volume root, reading root-entry owners via `Get-Acl`, and + probing a stray directory for emptiness all work un-elevated; when an owner or emptiness + probe is denied anyway the field ships as `null` and severity is unaffected. +- **Remediation:** none. The check reports; it never deletes, moves, or modifies. Removal + routes to `disk-hygiene:clean` (`detail.remediation_route`), which owns deletion behind + its own snapshot and approval model. +- **Commands:** + + ```powershell + Get-ChildItem -LiteralPath "$env:SystemDrive\" -Force | Select-Object Name, Mode, Length + Get-Volume | Where-Object { $_.DriveType -eq 'Fixed' -and $_.DriveLetter } + ``` + +- **What it detects:** entries at a fixed-volume root that nothing on the machine accounts + for — the class a disk audit once found as an empty `C:\tmp` path-translation artifact + and a 0-byte `C:\log.txt` dropped by an elevated process whose CWD was `C:\`. Both files + and directories are in scope; the listing is **non-recursive** (the root's own entries, + nothing below them). + +- **Baseline is data, not logic:** the expected-entry set lives in + `references/windows/drive-root-baseline.jsonc`. Admitting a newly legitimate entry is an + edit to that file, never a script change. Names are `-like` patterns (case-insensitive, + `*`/`?` wildcards) matched **type-aware** — a directory only matches the `directories` + list, a file only the `files` list, so a stray file named `Recovery` cannot hide behind + the expected directory of the same name. + +- **Per-volume posture:** + - **System drive** (`%SystemDrive%`): full baseline diff. Every root entry not matching + `all_volumes` + `system_drive` is residue. + - **Non-system fixed volumes** (data drives, Dev Drives): a user-managed root legitimately + holds arbitrary content, so a baseline diff there would be all noise. Only names matching + the `data_volume_litter` shapes (`tmp`, `temp`, `tmp.*`, `log.txt`, `*.tmp`) are + reported; everything else is presumed intentional. A machine that deliberately keeps a + `D:\tmp` admits it with a baseline data edit. + - Removable and network drives are never scanned. + +- **Severity rubric:** + - `WARN` — ≥10 residue entries: something is actively dumping at a root, action this week. + - `INFO` — 1–9 residue entries. + - `OK` — no residue. + - `UNKNOWN` — the baseline file is missing or unparsable (no way to tell residue from a + legitimate entry), **or** any root could not be listed at all (an unlistable root can + hide any amount of litter, so partial results cannot support a threshold verdict — + partial residue still ships in `detail`). `ran_successfully = false` keeps such a run + out of `checks_ran` so an undercounted `residue_count` never becomes a trend baseline. + - No `CRIT`. Root litter is tidiness with no data-loss or security consequence, and + `references/shared/severity-rubric.md` reserves `CRIT` for imminent-failure and security + conditions while directing ambiguity to the lower level. `drive-root-litter` is mapped + to `residue_count` for history but deliberately **excluded** from the trend engine's + generic upward upgrade for the same reason. + +- **Trend behavior:** output is deterministic — residue sorted by volume then name, and each + entry carries a `created` **date** (day granularity, stable across runs) rather than an + instant — so a dropping that sits unchanged produces identical findings run over run and + feeds the catalog's `identical_streak` demotion accounting instead of reading as news + every week. `residue_count` is the history metric. + +- **Notes:** owner (`Get-Acl`) and directory emptiness (first `EnumerateFileSystemEntries` + hit only — the check never recurses into a stray directory) are best-effort diagnostic + context: the original `C:\log.txt` was attributed by its `BUILTIN\Administrators` owner. + `Get-Volume` failing (Storage module unavailable) degrades to scanning the system drive + alone rather than `UNKNOWN`. The check is Windows-only; a POSIX port would need its own + baseline semantics (`/` has a very different expected set) and is not scaffolded. diff --git a/plugins/machine-health/skills/audit/references/windows/drive-root-baseline.jsonc b/plugins/machine-health/skills/audit/references/windows/drive-root-baseline.jsonc new file mode 100644 index 000000000..f4fbbb958 --- /dev/null +++ b/plugins/machine-health/skills/audit/references/windows/drive-root-baseline.jsonc @@ -0,0 +1,73 @@ +// Expected-entry baseline for the drive-root-litter check +// (scripts/windows/checks/Test-DriveRootLitter.ps1). +// +// The check lists each fixed-volume root non-recursively and reports the +// residue: entries this file does not expect. The baseline is data on purpose +// so admitting a new legitimate entry is an edit here, never a script change. +// Names are matched case-insensitively with PowerShell -like semantics, so `*` +// and `?` are wildcards. Matching is type-aware: a directory only matches the +// "directories" list and a file only the "files" list -- a stray FILE named +// "Recovery" must not hide behind the directory of the same name. +// +// Rubric and rationale: references/windows/check-catalog.md#19-drive-root-litter +{ + // Expected at the root of ANY fixed volume. NTFS/ReFS housekeeping the OS + // creates on every volume it touches. + "all_volumes": { + "directories": [ + "$Recycle.Bin", + "System Volume Information", + "Recovery", + "Config.Msi", // Windows Installer rollback staging; appears wherever an MSI ran + "OneDriveTemp" // OneDrive sync staging on the volume hosting a synced folder + ], + "files": [] + }, + // Expected only at the SYSTEM drive root (%SystemDrive%, normally C:\). + "system_drive": { + "directories": [ + "Windows", + "Program Files", + "Program Files (x86)", + "ProgramData", + "Users", + "PerfLogs", + "Documents and Settings", // hidden compatibility junction to Users + "$SysReset", // Reset-this-PC logs + "$WinREAgent", // Windows RE servicing staging + "$GetCurrent", // media-based feature-update staging + "$Windows.~BT", // in-place upgrade staging + "$Windows.~WS", // media-creation staging + "inetpub" // created on ALL machines by the April 2025 security update (CVE-2025-21204 hardening), IIS installed or not + ], + "files": [ + "pagefile.sys", + "swapfile.sys", + "hiberfil.sys", + "DumpStack.log", + "DumpStack.log.tmp", + "bootmgr", + "BOOTNXT", + "BOOTSECT.BAK", + "bootTel.dat", + "autoexec.bat", // NTVDM-era stubs some upgrade lineages still carry + "config.sys" + ] + }, + // A NON-system volume root (data drive, Dev Drive) legitimately holds + // arbitrary user content, so a baseline diff there would be all noise. + // On those volumes only names matching these known machine-generated + // dropping shapes are reported; everything else is presumed intentional. + "data_volume_litter": { + "directories": [ + "tmp", // POSIX-tool path-translation artifact (an MSYS/Cygwin /tmp landing at the root) + "temp", + "tmp.*" // mktemp-style names written to the wrong CWD + ], + "files": [ + "log.txt", // default log name of a process whose CWD was the volume root + "tmp.*", + "*.tmp" + ] + } +} diff --git a/plugins/machine-health/skills/audit/scripts/windows/checks/Test-DriveRootLitter.ps1 b/plugins/machine-health/skills/audit/scripts/windows/checks/Test-DriveRootLitter.ps1 new file mode 100644 index 000000000..1d528be0b --- /dev/null +++ b/plugins/machine-health/skills/audit/scripts/windows/checks/Test-DriveRootLitter.ps1 @@ -0,0 +1,282 @@ +#Requires -Version 7.4 +<# +.SYNOPSIS +Check: unexpected entries at fixed-volume roots. Emits a CheckResult JSON on stdout. + +See references/windows/check-catalog.md#19-drive-root-litter for rubric. + +.DESCRIPTION +Lists each fixed-volume root non-recursively and diffs it against the +expected-entry baseline in references/windows/drive-root-baseline.jsonc. +The system drive gets the full baseline diff; a non-system volume root +legitimately holds arbitrary user content, so only known litter-name shapes +are reported there. Read-only: the check never deletes, moves, or modifies +anything it finds. +#> +[CmdletBinding()] +param( + [switch]$Human, + # The overrides below exist for tests and manual scratch-tree runs; the + # orchestrator dispatches argument-less (Get-CheckArgument default) and the + # defaults resolve the real volumes and the shipped baseline. + [string]$SystemRootPath, + [string[]]$DataRootPath, + [string]$BaselinePath +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Continue' +. (Join-Path $PSScriptRoot '..\lib\Write-HealthResult.ps1') +. (Join-Path $PSScriptRoot '..\lib\ConvertFrom-Jsonc.ps1') + +function Get-BaselineList { + <# + .SYNOPSIS + Returns one pattern list from the parsed baseline, throwing a message that + names the missing piece rather than letting StrictMode produce a generic + property error. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $true)] $Baseline, + [Parameter(Mandatory = $true)] [string] $Section, + [Parameter(Mandatory = $true)] [ValidateSet('directories', 'files')] [string] $Kind + ) + $sectionProp = $Baseline.PSObject.Properties[$Section] + if ($null -eq $sectionProp) { throw "Baseline has no '$Section' section." } + $kindProp = $sectionProp.Value.PSObject.Properties[$Kind] + if ($null -eq $kindProp) { throw "Baseline section '$Section' has no '$Kind' list." } + return [string[]]@($kindProp.Value) +} + +function Test-NameMatchesAny { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] [string] $Name, + [AllowEmptyCollection()] [string[]] $Patterns + ) + foreach ($p in $Patterns) { + if ($Name -like $p) { return $true } + } + return $false +} + +function Get-RootResidue { + <# + .SYNOPSIS + Lists one volume root (non-recursive) and returns the entries the baseline + does not account for. + + .DESCRIPTION + Posture depends on the volume: + - system: report every entry not matching all_volumes + system_drive. + - non-system: report only entries matching data_volume_litter shapes; + everything else is presumed intentional user content. + Owner and directory-emptiness are best-effort context: either can be + unreadable without elevation, and a $null there never changes severity. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] [string] $Root, + [Parameter(Mandatory = $true)] [bool] $IsSystem, + [Parameter(Mandatory = $true)] $Baseline + ) + + $sharedDirs = Get-BaselineList -Baseline $Baseline -Section 'all_volumes' -Kind 'directories' + $sharedFiles = Get-BaselineList -Baseline $Baseline -Section 'all_volumes' -Kind 'files' + $systemDirs = Get-BaselineList -Baseline $Baseline -Section 'system_drive' -Kind 'directories' + $systemFiles = Get-BaselineList -Baseline $Baseline -Section 'system_drive' -Kind 'files' + $litterDirs = Get-BaselineList -Baseline $Baseline -Section 'data_volume_litter' -Kind 'directories' + $litterFiles = Get-BaselineList -Baseline $Baseline -Section 'data_volume_litter' -Kind 'files' + + # -ErrorAction Stop: a root that cannot be listed at all must surface as a + # failed scan, not as a clean one -- an empty listing reads as zero residue. + $entries = @(Get-ChildItem -LiteralPath $Root -Force -ErrorAction Stop) + + $residue = [System.Collections.Generic.List[pscustomobject]]::new() + foreach ($e in $entries) { + $isDir = [bool]$e.PSIsContainer + if ($IsSystem) { + $expected = if ($isDir) { @($sharedDirs) + @($systemDirs) } else { @($sharedFiles) + @($systemFiles) } + if (Test-NameMatchesAny -Name $e.Name -Patterns $expected) { continue } + } else { + # Volume housekeeping ($Recycle.Bin etc.) appears on data volumes + # too; it is expected there, not litter. + $expected = if ($isDir) { $sharedDirs } else { $sharedFiles } + if (Test-NameMatchesAny -Name $e.Name -Patterns $expected) { continue } + $litter = if ($isDir) { $litterDirs } else { $litterFiles } + if (-not (Test-NameMatchesAny -Name $e.Name -Patterns $litter)) { continue } + } + + $owner = try { (Get-Acl -LiteralPath $e.FullName -ErrorAction Stop).Owner } catch { $null } + $isEmpty = $null + if ($isDir) { + $isEmpty = try { + # First entry only -- a stray directory can be arbitrarily large + # and this check never recurses. + $first = [System.IO.Directory]::EnumerateFileSystemEntries($e.FullName) | + Select-Object -First 1 + $null -eq $first + } catch { $null } + } + + $residue.Add([pscustomobject]@{ + volume = $Root + name = $e.Name + type = $isDir ? 'directory' : 'file' + size_bytes = $isDir ? $null : [long]$e.Length + is_empty = $isEmpty + owner = $owner + # Date, not instant: stable across runs (identical output feeds + # the catalog's identical_streak accounting) and day granularity + # is all "how long has this dropping been here" needs. + created = $e.CreationTimeUtc.ToString('yyyy-MM-dd') + }) + } + + return [pscustomobject]@{ + Root = $Root + Posture = $IsSystem ? 'baseline' : 'litter-names' + EntryCount = $entries.Count + Residue = $residue + } +} + +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$id = 'drive-root-litter' +$category = 'storage' +$commands = @( + "Get-ChildItem -LiteralPath `"`$env:SystemDrive\`" -Force | Select-Object Name, Mode, Length" + "Get-Volume | Where-Object { `$_.DriveType -eq 'Fixed' -and `$_.DriveLetter }" +) + +try { + $skillRoot = Split-Path -Path $PSScriptRoot -Parent | Split-Path -Parent | Split-Path -Parent + if (-not $BaselinePath) { + $BaselinePath = Join-Path $skillRoot 'references\windows\drive-root-baseline.jsonc' + } + # No baseline means no way to tell residue from a legitimate entry, so the + # check cannot answer -- UNKNOWN, never a guess. + $baseline = Get-Content -LiteralPath $BaselinePath -Raw -ErrorAction Stop | ConvertFrom-Jsonc + + $systemRoot = if ($SystemRootPath) { $SystemRootPath } else { "$env:SystemDrive\" } + $dataRoots = if ($PSBoundParameters.ContainsKey('DataRootPath')) { + @($DataRootPath) + } else { + # Fixed, lettered volumes only: removable media and network drives hold + # user-managed content with no OS-imposed root layout to diff against. + $systemLetter = $systemRoot.TrimEnd('\').TrimEnd(':') + @(Get-Volume -ErrorAction SilentlyContinue | + Where-Object { $_.DriveType -eq 'Fixed' -and $_.DriveLetter } | + Where-Object { "$($_.DriveLetter)" -ne $systemLetter } | + ForEach-Object { "$($_.DriveLetter):\" } | + Sort-Object) + } + + $volumes = [System.Collections.Generic.List[pscustomobject]]::new() + $residue = [System.Collections.Generic.List[pscustomobject]]::new() + $failedRoots = [System.Collections.Generic.List[string]]::new() + + # Trailing separator so detail.residue "volume + name" concatenations read + # as real paths ("C:\tmp"), for drive roots and override paths alike. + $normalize = { param($p) ($p.EndsWith('\') -or $p.EndsWith('/')) ? $p : "$p\" } + $systemRoot = & $normalize $systemRoot + $dataRoots = @($dataRoots | ForEach-Object { & $normalize $_ }) + + $targets = @([pscustomobject]@{ Root = $systemRoot; IsSystem = $true }) + $targets += @($dataRoots | ForEach-Object { [pscustomobject]@{ Root = $_; IsSystem = $false } }) + + foreach ($t in $targets) { + try { + $scan = Get-RootResidue -Root $t.Root -IsSystem $t.IsSystem -Baseline $baseline + $volumes.Add([pscustomobject]@{ + root = $scan.Root + posture = $scan.Posture + entry_count = $scan.EntryCount + residue_count = $scan.Residue.Count + }) + foreach ($r in $scan.Residue) { $residue.Add($r) } + } catch { + $failedRoots.Add("$($t.Root): $($_.Exception.Message)") + } + } + + # Deterministic order: identical machine state must produce identical + # output run over run so the catalog's identical_streak accounting works. + $residueSorted = @($residue | Sort-Object volume, name) + + $detail = @{ + baseline_path = $BaselinePath + system_root = $systemRoot + volumes = @($volumes) + residue = $residueSorted + residue_count = $residueSorted.Count + unreadable_root_count = $failedRoots.Count + remediation_route = 'disk-hygiene:clean' + } + + if ($failedRoots.Count -gt 0) { + # An unlistable root can hide any amount of litter, so a threshold + # verdict on the volumes that did scan would overstate what is known. + # Partial residue still ships so the human sees what was found. + $result = New-HealthResult -Id $id -Category $category -Os 'windows' ` + -Severity 'UNKNOWN' ` + -Summary ("Drive-root scan incomplete: $($failedRoots.Count) root(s) could not be " + + "listed; $($residueSorted.Count) unexpected entries found on the rest.") ` + -Commands $commands -Detail $detail -NeedsAdmin $false ` + -RanSuccessfully $false ` + -ErrorMessage ($failedRoots -join '; ') ` + -DurationMs ([int]$sw.ElapsedMilliseconds) + } else { + # Severity ladder (most severe first). This check never emits CRIT: + # root litter is tidiness with no data-loss or security consequence, + # and severity-rubric.md reserves CRIT for imminent-failure and + # security conditions while directing ambiguity to the lower level. + # WARN -- >=10 residue entries: something is actively dumping at a root. + # INFO -- 1-9 residue entries. + # OK -- none. + $severity = 'OK' + if ($residueSorted.Count -ge 10) { + $severity = 'WARN' + } elseif ($residueSorted.Count -ge 1) { + $severity = 'INFO' + } + + $summary = if ($residueSorted.Count -eq 0) { + "Volume roots clean: $($volumes.Count) root(s) scanned, no unexpected entries." + } else { + # Full paths when they fit, bare names when they don't: the schema + # caps summary at 240 chars and an override root can be arbitrarily + # long. Deterministic either way; detail.residue always has the paths. + $lead = "$($residueSorted.Count) unexpected " + + "entr$($residueSorted.Count -eq 1 ? 'y' : 'ies') at volume roots" + $tail = '. Route removal to disk-hygiene:clean.' + $candidate = '' + foreach ($render in @({ "$($args[0].volume)$($args[0].name)" }, { $args[0].name })) { + $examples = @($residueSorted | Select-Object -First 3 | + ForEach-Object { & $render $_ }) -join ', ' + $candidate = "$lead ($examples)$tail" + if ($candidate.Length -le 240) { break } + } + if ($candidate.Length -gt 240) { $candidate = "$lead$tail" } + $candidate + } + + $result = New-HealthResult -Id $id -Category $category -Os 'windows' ` + -Severity $severity -Summary $summary -Commands $commands -Detail $detail ` + -NeedsAdmin $false -RanSuccessfully $true ` + -DurationMs ([int]$sw.ElapsedMilliseconds) + } +} catch { + $result = New-HealthResult -Id $id -Category $category -Os 'windows' ` + -Severity 'UNKNOWN' -Summary 'Drive-root litter check failed.' -Commands $commands ` + -RanSuccessfully $false -ErrorMessage $_.Exception.Message ` + -DurationMs ([int]$sw.ElapsedMilliseconds) +} + +$sw.Stop() +$result.duration_ms = [int]$sw.ElapsedMilliseconds +$result | Write-HealthResult -Human:$Human diff --git a/plugins/machine-health/skills/audit/scripts/windows/lib/Invoke-TrendAnalysis.ps1 b/plugins/machine-health/skills/audit/scripts/windows/lib/Invoke-TrendAnalysis.ps1 index 74238ba8b..4a7a720e1 100644 --- a/plugins/machine-health/skills/audit/scripts/windows/lib/Invoke-TrendAnalysis.ps1 +++ b/plugins/machine-health/skills/audit/scripts/windows/lib/Invoke-TrendAnalysis.ps1 @@ -156,6 +156,7 @@ function Get-TrendRelevantKey { 'reliability' { return 'stability_min_7d' } 'claude-temp-root' { return 'total_gb' } 'environment-health' { return 'user_path_length' } + 'drive-root-litter' { return 'residue_count' } default { return $null } } } @@ -190,6 +191,9 @@ function Test-WorseningTrend { # not in $upwardWorsens: the check has several independent WARN causes # (credential names, DISABLE_AUTOUPDATER, REG_SZ Path). A generic # upgrade would turn those into CRIT whenever Path grew by >=5 chars. + # drive-root-litter is likewise mapped (residue_count) but excluded: + # root litter is tidiness, and its rubric caps at WARN -- a generic + # upgrade would mint a CRIT from five new stray files. $downwardWorsens = @('battery', 'reliability') $delta = $cur - $prev diff --git a/plugins/machine-health/skills/audit/tests/windows/checks/Test-DriveRootLitter.Tests.ps1 b/plugins/machine-health/skills/audit/tests/windows/checks/Test-DriveRootLitter.Tests.ps1 new file mode 100644 index 000000000..a82589aae --- /dev/null +++ b/plugins/machine-health/skills/audit/tests/windows/checks/Test-DriveRootLitter.Tests.ps1 @@ -0,0 +1,249 @@ +#Requires -Version 7.4 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.7.0' } +<# +.SYNOPSIS +Tests for scripts/windows/checks/Test-DriveRootLitter.ps1. + +.DESCRIPTION +Every test points -SystemRootPath (and usually an empty -DataRootPath) at a +fixture directory, so the real machine's volume roots never leak into a +result. The shipped baseline in references/windows/drive-root-baseline.jsonc +is used as-is: the tests double as a guard that the shipped data still admits +the stock Windows layout and still catches the litter shapes. +#> + +BeforeAll { + $script:TestsRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $script:SkillRoot = Split-Path -Parent $script:TestsRoot + $script:ScriptPath = Join-Path $script:SkillRoot 'scripts\windows\checks\Test-DriveRootLitter.ps1' + $script:BaselinePath = Join-Path $script:SkillRoot 'references\windows\drive-root-baseline.jsonc' + $script:LibRoot = Join-Path $script:SkillRoot 'scripts\windows\lib' + . (Join-Path $script:LibRoot 'Assert-CheckResult.ps1') + Import-Module (Join-Path $script:TestsRoot 'helpers\Mock-Helpers.psm1') -Force + . (Join-Path $script:TestsRoot 'helpers\Invoke-CheckScript.ps1') + + function Invoke-DriveRootLitterAsObject { + param( + [Parameter(Mandatory)] [string] $SystemRootPath, + [string[]] $DataRootPath = @(), + [string] $BaselinePath + ) + $extra = @{} + if ($BaselinePath) { $extra['BaselinePath'] = $BaselinePath } + return ConvertFrom-CheckOutput (& $script:ScriptPath ` + -SystemRootPath $SystemRootPath -DataRootPath $DataRootPath @extra) + } + + function New-BaselineOnlyRoot { + <# + .SYNOPSIS + Builds a fixture root holding only entries the shipped baseline expects + at a system-drive root -- a representative subset of directories and + files, including the housekeeping names shared by every volume. + #> + param([Parameter(Mandatory)] [string] $Path) + New-Item -ItemType Directory -Path $Path -Force | Out-Null + foreach ($d in @('Windows', 'Program Files', 'Program Files (x86)', 'ProgramData', + 'Users', 'PerfLogs', 'Recovery', '$Recycle.Bin', 'System Volume Information', + 'Config.Msi', 'OneDriveTemp', 'inetpub')) { + New-Item -ItemType Directory -Path (Join-Path $Path $d) -Force | Out-Null + } + foreach ($f in @('pagefile.sys', 'swapfile.sys', 'DumpStack.log.tmp', 'bootmgr')) { + Set-Content -LiteralPath (Join-Path $Path $f) -Value 'x' -NoNewline + } + return $Path + } +} + +Describe 'Test-DriveRootLitter' -Tag 'check' { + BeforeEach { + $script:tmpDir = New-MachineHealthTempDir -Prefix 'machine-health-drive-root' + $script:sysRoot = New-BaselineOnlyRoot -Path (Join-Path $script:tmpDir 'sysroot') + } + + AfterEach { + Remove-MachineHealthTempDir -Path $script:tmpDir + } + + Context 'baseline-only root' { + It 'reports OK with zero residue for a stock system-drive layout' { + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot + { Assert-CheckResult $result } | Should -Not -Throw + $result.id | Should -Be 'drive-root-litter' + $result.category | Should -Be 'storage' + $result.severity | Should -Be 'OK' + $result.ran_successfully | Should -BeTrue + $result.needs_admin | Should -BeFalse + $result.detail.residue_count | Should -Be 0 + $result.summary | Should -Match 'clean' + } + + It 'matches baseline names case-insensitively' { + Rename-Item -LiteralPath (Join-Path $script:sysRoot 'Windows') -NewName 'WINDOWS' + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot + $result.detail.residue_count | Should -Be 0 + } + } + + Context 'unexpected entries on the system drive' { + It 'reports a stray directory as INFO residue' { + New-Item -ItemType Directory -Path (Join-Path $script:sysRoot 'tmp') -Force | Out-Null + + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot + { Assert-CheckResult $result } | Should -Not -Throw + $result.severity | Should -Be 'INFO' + $result.detail.residue_count | Should -Be 1 + $result.detail.residue[0].name | Should -Be 'tmp' + $result.detail.residue[0].type | Should -Be 'directory' + $result.detail.residue[0].is_empty | Should -BeTrue + $result.summary | Should -Match 'tmp' + } + + It 'reports a stray file as INFO residue with its size' { + Set-Content -LiteralPath (Join-Path $script:sysRoot 'log.txt') -Value '' -NoNewline + + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot + { Assert-CheckResult $result } | Should -Not -Throw + $result.severity | Should -Be 'INFO' + $result.detail.residue_count | Should -Be 1 + $result.detail.residue[0].name | Should -Be 'log.txt' + $result.detail.residue[0].type | Should -Be 'file' + $result.detail.residue[0].size_bytes | Should -Be 0 + } + + It 'matches type-aware: a stray FILE named like an expected DIRECTORY is residue' { + Remove-Item -LiteralPath (Join-Path $script:sysRoot 'Recovery') -Force + Set-Content -LiteralPath (Join-Path $script:sysRoot 'Recovery') -Value 'not a dir' + + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot + $result.detail.residue_count | Should -Be 1 + $result.detail.residue[0].type | Should -Be 'file' + } + + It 'never mutates what it finds' { + $stray = Join-Path $script:sysRoot 'tmp' + New-Item -ItemType Directory -Path $stray -Force | Out-Null + $strayFile = Join-Path $script:sysRoot 'log.txt' + Set-Content -LiteralPath $strayFile -Value '' -NoNewline + $before = @(Get-ChildItem -LiteralPath $script:sysRoot -Force | Sort-Object Name) + + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot + $result.detail.residue_count | Should -Be 2 + $after = @(Get-ChildItem -LiteralPath $script:sysRoot -Force | Sort-Object Name) + @($after).Count | Should -Be @($before).Count + Test-Path -LiteralPath $stray | Should -BeTrue + Test-Path -LiteralPath $strayFile | Should -BeTrue + $result.detail.remediation_route | Should -Be 'disk-hygiene:clean' + } + + It 'emits deterministic residue ordering across runs' { + foreach ($n in @('zzz-stray', 'aaa-stray', 'mmm-stray')) { + New-Item -ItemType Directory -Path (Join-Path $script:sysRoot $n) -Force | Out-Null + } + $first = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot + $second = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot + @($first.detail.residue.name) | Should -Be @('aaa-stray', 'mmm-stray', 'zzz-stray') + ($first.detail.residue | ConvertTo-Json -Depth 5) | + Should -Be ($second.detail.residue | ConvertTo-Json -Depth 5) ` + -Because 'identical machine state must produce identical findings (identical_streak)' + } + } + + Context 'severity ladder' { + It 'stays OK at zero residue' { + (Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot).severity | + Should -Be 'OK' + } + + It 'caps a handful of residue entries at INFO, never higher' { + 1..9 | ForEach-Object { + Set-Content -LiteralPath (Join-Path $script:sysRoot "stray$_.bin") -Value 'x' + } + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot + $result.detail.residue_count | Should -Be 9 + $result.severity | Should -Be 'INFO' + } + + It 'reports WARN at >=10 residue entries and never CRIT' { + 1..12 | ForEach-Object { + Set-Content -LiteralPath (Join-Path $script:sysRoot "stray$_.bin") -Value 'x' + } + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot + $result.detail.residue_count | Should -Be 12 + $result.severity | Should -Be 'WARN' + } + } + + Context 'non-system volume posture' { + It 'presumes user content legitimate and reports only litter-name shapes' { + $dataRoot = Join-Path $script:tmpDir 'dataroot' + foreach ($d in @('repos', 'worktrees', 'media', 'tmp')) { + New-Item -ItemType Directory -Path (Join-Path $dataRoot $d) -Force | Out-Null + } + Set-Content -LiteralPath (Join-Path $dataRoot 'log.txt') -Value '' -NoNewline + Set-Content -LiteralPath (Join-Path $dataRoot 'notes.md') -Value 'mine' + + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot ` + -DataRootPath @($dataRoot) + { Assert-CheckResult $result } | Should -Not -Throw + $result.detail.residue_count | Should -Be 2 + @($result.detail.residue.name) | Should -Be @('log.txt', 'tmp') + @($result.detail.volumes).Count | Should -Be 2 + @($result.detail.volumes | Where-Object posture -eq 'litter-names').residue_count | + Should -Be 2 + } + + It 'treats volume housekeeping on a data root as expected, not litter' { + $dataRoot = Join-Path $script:tmpDir 'dataroot' + foreach ($d in @('$Recycle.Bin', 'System Volume Information')) { + New-Item -ItemType Directory -Path (Join-Path $dataRoot $d) -Force | Out-Null + } + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot ` + -DataRootPath @($dataRoot) + $result.detail.residue_count | Should -Be 0 + } + } + + Context 'degraded inputs' { + It 'reports UNKNOWN when the baseline file is missing' { + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot ` + -BaselinePath (Join-Path $script:tmpDir 'absent-baseline.jsonc') + { Assert-CheckResult $result } | Should -Not -Throw + $result.severity | Should -Be 'UNKNOWN' + $result.ran_successfully | Should -BeFalse + } + + It 'reports UNKNOWN when the baseline file is malformed' { + $bad = Join-Path $script:tmpDir 'bad-baseline.jsonc' + Set-Content -LiteralPath $bad -Value '{ "all_volumes": { } }' + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot ` + -BaselinePath $bad + $result.severity | Should -Be 'UNKNOWN' + $result.ran_successfully | Should -BeFalse + $result.error | Should -Match 'directories' + } + + It 'reports UNKNOWN with partial residue when a root cannot be listed' { + New-Item -ItemType Directory -Path (Join-Path $script:sysRoot 'tmp') -Force | Out-Null + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot ` + -DataRootPath @(Join-Path $script:tmpDir 'no-such-root') + { Assert-CheckResult $result } | Should -Not -Throw + $result.severity | Should -Be 'UNKNOWN' + $result.ran_successfully | Should -BeFalse + $result.detail.unreadable_root_count | Should -Be 1 + $result.detail.residue_count | Should -Be 1 ` + -Because 'the residue found on listable roots still ships as a floor' + } + } + + Context 'schema conformance' { + It 'emits a schema-valid result with residue path concatenation readable' { + New-Item -ItemType Directory -Path (Join-Path $script:sysRoot 'tmp') -Force | Out-Null + $result = Invoke-DriveRootLitterAsObject -SystemRootPath $script:sysRoot + { Assert-CheckResult $result } | Should -Not -Throw + $result.duration_ms | Should -BeLessOrEqual 90000 + $entry = $result.detail.residue[0] + "$($entry.volume)$($entry.name)" | Should -Be (Join-Path $script:sysRoot 'tmp') + } + } +} diff --git a/plugins/machine-health/skills/audit/tests/windows/lib/Invoke-TrendAnalysis.Tests.ps1 b/plugins/machine-health/skills/audit/tests/windows/lib/Invoke-TrendAnalysis.Tests.ps1 index 6b26540a0..d8cef52f2 100644 --- a/plugins/machine-health/skills/audit/tests/windows/lib/Invoke-TrendAnalysis.Tests.ps1 +++ b/plugins/machine-health/skills/audit/tests/windows/lib/Invoke-TrendAnalysis.Tests.ps1 @@ -177,6 +177,25 @@ Describe 'Invoke-TrendAnalysis' -Tag 'lib' { $result[0].trend.adjusted_from | Should -BeNullOrEmpty } + It 'maps drive-root-litter to residue_count without a generic upward upgrade' { + Get-TrendRelevantKey -CheckId 'drive-root-litter' | Should -Be 'residue_count' + Test-WorseningTrend -CheckId 'drive-root-litter' -CurrentValue 15 -PriorValue 10 | + Should -BeFalse -Because 'root litter is tidiness; its rubric caps at WARN' + + $history = @( + New-HistoryEntry ` + -SeverityByCategory @{ storage = [pscustomobject]@{ WARN = 1 } } ` + -TopMetrics @{ 'drive-root-litter.residue_count' = 10 } ` + -ChecksRan @('drive-root-litter') + ) + $checks = @(New-CheckStub -Id 'drive-root-litter' -Category 'storage' ` + -Severity 'WARN' -Detail @{ residue_count = 15 }) + $result = Invoke-TrendAnalysis -CheckResults $checks -HistoryTail $history + $result[0].trend.delta | Should -Match 'residue_count' + $result[0].severity | Should -Be 'WARN' + $result[0].trend.adjusted_from | Should -BeNullOrEmpty + } + It 'treats battery downward drop as worsening (fullCapacityPct going down)' { $history = @( New-HistoryEntry ` From 033af5e832497ba9cca89025e5f983bacf161855 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:05:11 -0400 Subject: [PATCH 2/2] docs(machine-health): correct advertised Windows check count to 19 The drive-root-litter addition brings the shipped catalog to 19 enabled Windows checks; README.md still advertised 18. Plugin-wide grep found no other stale count (CHANGELOG and check-catalog '18' hits are section references, sdk-eol-table's is Node 18). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Egdf11hXdBch1HTFmjB8FV --- plugins/machine-health/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/machine-health/README.md b/plugins/machine-health/README.md index d97744353..924c6f46e 100644 --- a/plugins/machine-health/README.md +++ b/plugins/machine-health/README.md @@ -6,7 +6,7 @@ from a versioned catalog, with trend-aware severity, narrow approval-gated remed dated markdown report per run. Fail-safe posture throughout: surface issues over silently fixing them, and every finding carries reproduction commands. -Windows is fully implemented (18 checks, PowerShell 7.x). macOS and Linux are scaffolded as +Windows is fully implemented (19 checks, PowerShell 7.x). macOS and Linux are scaffolded as honest `NOT_IMPLEMENTED` stubs. On those hosts the skill reports UNKNOWN and stops rather than pretending coverage.