Auditing My Homelab Cron Scripts with an AI Auditor and a Skeptic
I had AI agents audit every unRAID user script in auditor and skeptic pairs, then got an outside review. It found database dumps disabled since April and a cleanup job deleting the backup image daily.
HomeLab, Monitoring and ops. Updated . 5 min read.
My Postgres dump script had been disabled since April. The appdata backup plugin skips the Postgres container, so three databases (5.7 GB, 1.7 GB, and 217 MB) had no backup at all. I found out because I had AI agents audit every cron script on the NUC, and made a second set of agents try to prove the first set wrong.
I'd just upgraded unRAID to 7.3.2, and I wanted a reason to trust the scripts that run on it every night. Reading every one of them carefully myself is the kind of job where I skim the boring ones, and the boring ones are where this kind of thing hides.
TL;DR: 14 agents in auditor and skeptic pairs, read-only on the box. One auditor per script group proposed findings and rewrites; one skeptic per group tried to refute each finding and hunted regressions in the rewrite. Then one outside review of the 12 rewrites caught two mirror-deletion paths the skeptics missed. Result: two rewrites, ten fixes, seven deletions, and three new Kuma push monitors.
Why a skeptic
A single auditor agent produces a confident list. Some of it is right, some is plausible, and some is a rewrite that fixes one bug and adds another. The skeptic's job was narrow: here are the scripts, here are the findings, refute what you can and break the rewrite if you can.
In practice most of the skeptics' value showed up as fixes to the fixes. A diagnosis is easy to check. A rewrite has to survive the details.
What it found
The final decision table, trimmed:
| Script | Verdict | Why |
|---|---|---|
| Postgres dumps | rewrite | disabled since April; plugin skips the container, so no backup at all |
| github_backup | rewrite | 120 s cap kills every pass (exit 137); 25 of 238 repos fetched, 78 stale over 30 days |
| glacier-backup | fix | the appdata plugin stops its container at 03:35: the cause of every failed pass since 08-15 |
| appdata-s3-backup | fix | no guard against the plugin still running at 05:00; Kuma up even when the snapshot failed |
| delete_dangling_images | fix | deletes the rclone image every morning; volume prune -a removes named volumes of stopped stacks |
| clean_registry | fix | GC runs even when docker stop registry failed; registry stays down if GC dies |
| restart-browser | fix | the hourly restart never fixed the leak, and kills every crawl in flight 24 times a day |
| fix_dns_after_array_start | fix | runs 11 s before AdGuard Home starts, so it edits a stopped container |
| ddns | fix | every failure silent, always exits 0, no monitor, writes the record every hour |
| start_bluetoothd | fix | a crashed bluetoothd stays dead until reboot, nothing alerts |
| six old scripts | delete | dead targets, plaintext passwords, a byte-identical duplicate |
A few of these deserve the longer story.
The GitHub mirror. The container's own loop runs a fetch pass, then sleeps 48 hours. The user script started it and killed it after 120 seconds, every day. So the mirror was a truncated snapshot of whatever it managed in two minutes, and Glacier was faithfully archiving that stale tree.
Glacier and the plugin. At 03:00 the appdata backup plugin stops containers, tars their appdata, and tries to start them again. The Glacier container was often still running then, so the plugin stopped it mid-upload and tarred /mnt/user/appdata/rclone, which held 5.5 GB of rclone logs. That tar also caused six failed backup nights. The fix wasn't in the script at all: add glacier-backup to the plugin's skip list and move the log and lock out of the tarred directory.
The image that deleted itself. The dangling-image cleanup removed the rclone image every morning. Both backup jobs then pulled it again at 05:00 and 06:00. It worked, just wastefully. The same script ran an online registry GC against a registry that was serving.
The S3 race. On parity-check days the plugin can still be running at 05:00, when the S3 sync starts. On 2026-09-03 it uploaded 74 GB for a 40 GB snapshot. It also reported up to Kuma when the snapshot itself had failed, and six failed snapshot directories (234 GB) were being mirrored to S3 Standard forever.
The deletions were the easy part: a Proton Drive copy of snapshots S3 already held, dead since 2025-10 on 422 errors after 2FA expired; a USB backup to a disk that wasn't attached, which still pinged the retired Healthchecks host; a one-time import whose rsync --delete against a removable source could have emptied 404 GB; a disabled script that would have cut house DNS if anyone re-enabled it; and an exact duplicate of restart-browser.
What the skeptics changed
This is where the second pass earned its tokens. A sample of what the skeptics folded into the rewrites:
- Postgres dumps: keep dumping the other databases when one fails. Lower the 1 KB size gate. Run at 03:45, not earlier, because the plugin stops AdGuard from 03:28 to 03:32 and the Kuma push would fail DNS.
- GitHub mirror: use
docker top <c> -o pid,argsto find the sleep marker, because plain-o argsfails on Docker 29. Fail when zero repos were fetched. Don't stop a run I started by hand. - Glacier: keep the Kuma retries as they are;
maxretries 0would false-alarm on every longer run. Cap the first orphan delete. Log Docker's stderr. - S3 sync: refuse to sync when the source is empty. A missing disk1 plus
--delete-afterwould wipe the offsite mirror. Capture the date at start, not mid-run.
The decision table's "safe rewrite" column tells the same story: the dumps, GitHub, and Glacier rewrites were marked not safe until those fixes landed, and those three were the highest-value rows.
The outside review
The skeptics worked from the auditors' findings, so before deploying I had a different model, Codex, review all 12 rewritten scripts, read-only and checked against the live box. It confirmed two mirror-deletion paths the skeptics had missed, and eight smaller gaps.
The big one was subtle. The S3 script's source guards ran before the wait for the backup plugin, and that wait can last hours. If the source disappeared in between, docker run would create the missing bind-mount source as an empty directory, and the sync could mirror that empty directory over the offsite copy. The guards now run right before docker run. Also added:
--max-delete 1500 # a runaway deletion stops herePlus: -failed directories don't count as snapshots, today's snapshot is checked before the sync, and the snapshot count no longer dies under pipefail when zero directories match. On the Glacier side, a directory whose copy failed now skips the orphan delete, an empty source or local listing counts as a failure, and the EXIT trap is installed before the lock.
One risk I accepted with eyes open: the DNS fix has no rollback after a failed docker network connect. That path only runs when DNS is already unreachable.
Decisions and deploy
A few questions needed a human. I kept ddns but daily: nothing dials the record, it's an emergency spare. I kept firefox_refresher, because a platform login lives in that browser. The 234 GB of failed snapshots and the extra rclone remotes are still open.
Everything went out in one script on 2026-09-03: tokens restored on the NUC from the old scripts and the job registry (never through the notes), old versions kept as script.bak-20260903, the retired folders removed, schedule.json rewritten, cron regenerated with update_cron. Three new Kuma push monitors: Postgres Dumps, GitHub Backup, and DDNS.
First runs by hand:
- The dump script wrote 1.4 GB, 89 MB, and 33 MB of dumps in 3 min 25 s.
- The GitHub mirror fetched 172 of 238 repositories in about 4 minutes. The other 66 aren't listed by the API for that token anymore, so the
MIN_REPOSfloor went from 200 to 150. - The image cleanup removed one unused image and kept rclone.
The part I'd repeat is the shape, not the model. An auditor alone gives you a list. A skeptic with the same inputs gives you rewrites that survive the second read. And an outside reviewer with no stake in either still finds the path where your backup deletes your backup.
Questions
- What is an auditor and skeptic pair?
- One agent reads a group of scripts and reports findings with a proposed rewrite. A second agent gets the same scripts and those findings and tries to refute each one, and checks the rewrite for regressions.
- What did the audit actually find?
- Postgres dumps disabled since April with nothing else backing those databases up, a GitHub mirror job killed after 120 seconds on every run, a cleanup script that deleted the rclone image every morning, an S3 sync that could race the nightly backup, and six dead scripts.
- Why run an outside review after the skeptics?
- The skeptics worked from the auditors' findings. A review by a different model, checked against the live box, still found two paths where an S3 or Glacier mirror could delete data, plus eight smaller gaps. All were fixed before deploy.
- Did the agents change anything on the server during the audit?
- No. The audit was read-only. Every write to the flash drive or to a monitor needed my yes, and the deploy went out as one script after I made the decisions.