跳到正文
DEV Community· Edy Cu·· 3 小時前AI 評分54

hantu 在作者的 Mac 上發現 5 個已卸載應用程式留下的 root 權限輔助程式

Ghosts in my Mac: the apps are gone, but five root helpers stayed

AI 導讀

Edy Cu 開發了 macOS 命令列掃描工具 hantu,在自己的 Mac 掃描 58 個 launchd plist,找到 5 個仍設為以 root 身份執行、但所屬應用程式已移除的輔助程式。

正文

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine

What I Built

There are five programs on my Mac that are set up to run as root, for apps I no longer have. The oldest one's plist was last modified more than four years ago. I found them by writing a ghost hunter.

Here's how that happens. Some apps ask for your admin password once and install a small privileged helper, plus a launchd plist that tells macOS how to start it. Drag the app to the Trash, and the helper and its plist can stay. Nothing on screen tells you they're there.

hantu (Indonesian for ghost) is a read-only command-line scanner for macOS. It reads every launchd plist in the three folders where apps put them, works out which program each one would start, and asks two questions: is that program still on disk, and does any installed app still claim it?

The theme is Ghost in the Machine, and I wanted ghosts I could count. This is one read-only run on my own Mac, on October 31:

What Count
Launch plists scanned 58
Ghosts 5
Ghosts that are launch daemons with no UserName, so launchd starts them as root 5 of 5
Oldest ghost 1549 days since its plist was last modified
Plists pointing at a missing program 0
Couldn't tell either way 9

I expected the opposite: a pile of dead links, plists pointing at programs that no longer exist. Harmless clutter. There were none.

Every ghost still had its program on disk, in /Library/PrivilegedHelperTools, with a plist in /Library/LaunchDaemons that tells launchd how to start it as root. Only the apps were gone. Three of the five come from vendors you'll know: a proxy helper from Charles (com.xk72.charles.ProxyHelper, 1188 days) and two Adobe helpers, one of them com.adobe.ARMDC.Communicator (695 days).

hantu is for anyone who has used a Mac for a few years and tried apps they later removed. That's most of us.

Demo

hantu lives in the terminal, so the demo is the terminal:

pipx install git+https://github.com/edycutjong/hantu
hantu scan --ghosts

What it printed on my Mac. I only publish labels from a short list of well-known vendors, and only one per vendor, so three are withheld:

HALF-GHOST  1549d  [label withheld]
HALF-GHOST  1188d  com.xk72.charles.ProxyHelper  ->  /Library/PrivilegedHelperTools/com.xk72.charles.ProxyHelper
                  privileged helper still on disk, but no installed app declares it (SMPrivilegedExecutables)
HALF-GHOST  1096d  [label withheld]
HALF-GHOST   695d  com.adobe.ARMDC.Communicator  ->  /Library/PrivilegedHelperTools/com.adobe.ARMDC.Communicator
                  privileged helper still on disk, but no installed app declares it (SMPrivilegedExecutables)
HALF-GHOST   695d  [label withheld]

58 plists: 0 orphaned, 5 half-ghost, 44 alive, 9 unknown. Nothing was changed.

Then hantu explain sends each ghost's plist to Gemma 4 31B and gets back one sentence and a risk score from 1 to 5:

  • Charles, risk 3/5: "This belonged to Charles Proxy and is trying to run a privileged network proxy helper."
  • Adobe, risk 3/5: "This belonged to Adobe software and is trying to run a privileged communication service."

The redacted run is in evidence/gemma-run.md.

Code

github.com/edycutjong/hantu: MIT, standard-library Python (tested on 3.11), 79 tests.

The verdict is plain code. Here's the check for a privileged helper nobody claims, from hantu/scan.py:

elif target.startswith(HELPER_DIR) and os.path.basename(target) not in index.helpers:
    ag.associated_installed = False
    ag.verdict = HALF_GHOST
    ag.reason = "privileged helper still on disk, but no installed app declares it (SMPrivilegedExecutables)"
    return ag

index.helpers is built from every installed app's Info.plist: the helpers it declares in SMPrivilegedExecutables and the ones it ships in Contents/Library/LaunchServices. So if a helper sits in /Library/PrivilegedHelperTools, its plist names no owning app, and no installed app declares or ships it, hantu calls it a ghost.

The 79 pytest tests run against fixture plists, and the suite fails any test that tries to reach the network.

How I Built It

Code decides, the model explains. I didn't want a language model deciding whether a root daemon is abandoned. A file check and an Info.plist lookup can answer that, and they can be tested. What a model is good at is reading a plist full of MachServices, Sockets and RunAtLoad keys and saying in one sentence what the thing was for. So there are four verdicts, all set in code:

  • ORPHANED: the program launchd would start is missing.
  • HALF-GHOST: the program is there, but no installed app claims it.
  • ALIVE: the program is there and nothing shows its owner is gone.
  • UNKNOWN: a malformed plist, a relative path, a disk that may just be unmounted. Never counted as a ghost.

Gemma 4 31B, open weights. hantu explain calls google/gemma-4-31b-it through OpenRouter with a small standard-library HTTP client. The prompt says the classification is already made and not to question it. The reply has to be strict JSON: an id, one sentence, a risk from 1 to 5, a short reason. hantu checks every item (id in range, risk an integer from 1 to 5, text not empty, length capped) and drops what fails. Nothing in the reply can change a verdict.

What came back was honest and a little flat. Gemma gave one ghost 4 out of 5: the oldest, and the only one set to run at load. Its reason: "Privileged daemon that runs at load and creates a system socket." The other four got 3 out of 5 with the same reason, word for word: "Runs as a privileged daemon with root-level access." That isn't much of a ranking. But the one outlier it found is a real difference.

The free Gemma endpoints answered HTTP 429 ("temporarily rate-limited upstream") every time I tried them that night. hantu backs off, retries, and falls back from the 31B model to the 26B one, and both stayed busy. So I ran hantu explain --model google/gemma-4-31b-it, the same weights through OpenRouter's paid listing. It took about 13 seconds and cost $0.00046.

An early version of the half-ghost rule, before the first commit, flagged macFUSE. It isn't a ghost: macFUSE keeps its app inside a filesystem bundle under /Library/Filesystems. hantu looks there now. That's why the README calls HALF-GHOST a rule, not a proof, and why cleanup never touches half-ghosts.

Safety and privacy. A plain hantu scan never writes and never sends anything anywhere. Nothing changes unless you pass both --clean and --yes. Even then, hantu only moves ORPHANED plists out of your own ~/Library/LaunchAgents into a quarantine folder with a manifest, and runs launchctl bootout if the agent was loaded. Undo is mv. That path is tested on fixture plists in temp folders; I haven't run it on a real machine. My five ghosts are root daemons under /Library, so hantu won't touch them at all. Removing them is a decision for a human with sudo.

explain does send data off your Mac: each ghost's plist (capped at 1200 characters), file name, age, verdict and reason go to OpenRouter and the Gemma host. Before sending, your home folder becomes ~, environment variable values are masked, and identity keys such as UserName are dropped.

What it doesn't do. It doesn't scan Login Items, which needs privileges or a permission prompt. It can't tell whether a ghost daemon is loaded right now; that needs root too. And an app installed somewhere hantu doesn't look will make its helper look like a ghost, so read the line before you delete anything.

Why Does Open Innovation Matter?

A tool that inspects root daemons should be readable. hantu is under a thousand lines of standard-library Python, MIT-licensed, with no dependencies. You can read every line that touches your system before you run it. I didn't want to clean up after closed apps with root helpers by installing another closed app with a root helper.

Open weights turned the rate limit into a detour. When the free endpoint was busy, I changed one flag and the same Gemma answered from another listing. When I checked that night, OpenRouter listed 13 providers serving those weights. That only works because the weights are public: any host can serve them, so one busy endpoint isn't the end of the road.

Open weights are also what make the next step possible: running Gemma on the Mac itself, so the plists never leave it. hantu doesn't do that yet; today explain uses a hosted endpoint, and the README says so. But the model only ever receives text and returns JSON, and the code makes every decision, so any open model can be swapped in and the verdicts stay exactly the same. The whole chain from plist to verdict is code you can read and tests you can run.

My Agent Session

I built hantu with Claude Code as my coding agent, under a few hard rules: only ever read the real machine, never delete or unload anything on it, test the cleanup on fixtures only, and put no number in the README that a committed run didn't produce. This post was drafted the same way, and every number in it was checked against the repo before posting.

There's no DevRelay session to embed. The commit history shows the order of the work: the read-only scanner, then the Gemma step and the quarantine-only cleanup, then the CLI and report, then the README checked against a real run, and last the real Gemma output. It shows the order, not the timing: the first four commits were made together.

The Spirit of It

Halloween and Día de los Muertos are both about what stays after something is gone. On my Mac, what stayed was five root helpers, each with a plist that still tells launchd how to start it, for apps that are no longer installed.

The moment it landed was one summary line: 0 orphaned, 5 half-ghost. I had expected broken links. I got real programs, set up to run as root, with nobody left to own them.

It's Halloween. Run hantu and see who still lives in your house. A plain scan only reads.

Prize Categories

Best Use of Gemma. Gemma 4 31B is the model behind hantu explain. It reads each ghost's plist and returns validated JSON, one plain-English sentence and a coarse risk score, and hantu sorts the list by that score. On my Mac it picked out the one ghost set to run at load from four ties. The code decides what is a ghost; Gemma tells you what it was.

來源:DEV Community · dev.to