Skip to content

feat(uninstall): remove what this tool put on the host, not one file#14

Merged
xxvcc merged 3 commits into
mainfrom
feat/real-uninstall
Jul 18, 2026
Merged

feat(uninstall): remove what this tool put on the host, not one file#14
xxvcc merged 3 commits into
mainfrom
feat/real-uninstall

Conversation

@xxvcc

@xxvcc xxvcc commented Jul 17, 2026

Copy link
Copy Markdown
Owner

This started from one observation — 「卸载稳定命令」这个命名很奇怪 — and the name turned out to be the honest part. uninstall deleted one file and left everything else exactly where it was. The label needed that opaque object ("the stable command") precisely because "uninstall" alone would have been a lie.

The menu entry is now just 「卸载」, because it now means it.

What it does

Inventory → one confirmation → teardown in an order where every step leaves the host no worse than it found it.

[信息] 卸载将移除:
┌────────────┬────────────────────────┬─────────────────────┐
│ 账号       │ 状态                   │ 依据                │
├────────────┼────────────────────────┼─────────────────────┤
│ ltademo-a1 │ 在册(连同家目录删除) │ registry sudo-grant │
└────────────┴────────────────────────┴─────────────────────┘
  状态目录: /var/lib/linux-temp-admin
  已安装的命令: /usr/local/sbin/linux-temp-admin
[信息] 审计日志保留在 /var/log/linux-temp-admin/audit.log —— 它记录了谁开过、谁删过 root 级账号,卸载不会替你抹掉它。
确认卸载请输入 YES:

Accounts go through the ordinary revoke — same path, same protections, same audit trail. Nothing reimplements deletion.

uninstall --force was a trap

The auto-delete unit hardcodes ExecStart=/usr/local/sbin/linux-temp-admin revoke --user X --yes. --force skipped the "registered users still exist" refusal, so it deleted the binary out from under every timer on the box. Those timers then fired against a missing path forever: the accounts never expired, and a NOPASSWD-sudo account was left with nothing coming for it.

--force no longer bypasses accounts; it keeps only its original meaning (remove a target that isn't a safe root-owned regular file).

The invariant

The binary is never removed while a managed account survives. Leaving a sudo-capable account while deleting the only thing that manages it is worse than not uninstalling. If any account can't be removed, the binary and the state directory stay (that row is the only record of what it was), the account is named, rc=1.

Survival is decided by asking the system (user.Exists), not by revoke's exit code — which returns 0 for a deletion, for cleaning up an already-gone account, and for a confirmation the operator declined.

The inventory unions witnesses; the registry is the weakest

Every way a file goes wrong makes accounts vanish from it rather than announce themselves, and an inventory that under-reports is exactly how a teardown strands what it never saw. So an account named by its sudo grant, sshd exception, auto-delete unit, or v1's registry is torn down too. A witness that cannot be read refuses the whole uninstall.

The GECOS marker is deliberately not a witness — a security property, not an omission. It's the one signal an account can write to itself, so usermod -c 'linux-temp-admin temporary admin' realadmin would otherwise enlist a real administrator's account and their home directory into a teardown.

v1 is read before it is deleted

/var/lib/linux-temp-admin/users.tsv isn't litter — it's v1's account registry. v1's units carry no -v2- infix (so the v2 glob walks past them) and v1's install path was byte-identical to v2's, so a v1 timer on an upgraded host invokes this binary. Deleting that registry while globbing only the v2 prefix would have reproduced this release's own footgun for v1 accounts and shredded the only record naming them. schedule gained the Orphans/UnitUsers sweep sudoers and sshdconf always had.

Also here

  • The audit log survives by default. It records who opened and closed root-capable accounts; erasing it on the way out is what covering your tracks looks like. --purge-audit removes it, and the teardown's own record is written before the purge, so "purge" can't mean "leave exactly one line".
  • Refuses when run from the account it would delete — an interlock for the honest operator, not a security boundary (sudo su - drops SUDO_USER, the only identity signal this tool has). The code says so rather than implying a guarantee it can't keep.
  • sudoers.Remove reports failure and every caller listens. Note Go won't point at those call sites — a function returning an error can be called as a statement, so the signature change compiled clean with every caller still ignoring it. Found by grep, not the compiler.
  • A recorded UID decides in both directions (was to be v2.5.1; ships here).
  • StateDir/AuditLogDir are App fields, not constants read in place — these are removed recursively and CI runs the integration suite as root on every push. A test reading the constants would delete the real /var/lib/linux-temp-admin, on the runner and on the developer's box. A test pins that they stay injected.

Verification

Every guard mutation-tested — removed, test confirmed to fail, restored:

mutant caught by
survivor no longer blocks the binary TestUninstallKeepsTheBinaryWhenAnAccountSurvives
self-uninstall interlock removed TestUninstallRefusesFromTheAccountItWouldDelete
blind inventory proceeds TestUninstallRefusesWhenTheInventoryIsBlind
--yes deletes accounts without --remove-users TestUninstallWithAccountsRefusesNonInteractivelyWithoutTheFlag
audit log purged by default TestUninstallKeepsTheAuditLogUnlessAskedTwice
sudo-grant / v1-registry / unit witness dropped TestInventoryUnionsEveryWitness

An existing test caught a real regression on the way: the first draft warned where the old code refused on an unreadable registry — the exact blind-inventory hazard.

Verified on a real pty. Full unit + go vet (both tags) + -race + root integration + non-root-as-nobody all pass.

Minor, not patch: uninstall does substantially more, --force and the menu's fifth entry change meaning, --remove-users/--purge-audit are new. invite, revoke, status, cleanup-expired are untouched.

🤖 Generated with Claude Code

xxvcc and others added 3 commits July 17, 2026 07:19
Groundwork only; uninstall itself is unchanged. Both gaps were found by
mapping what a complete teardown must touch, and both are defects on their
own terms.

schedule can now FIND its units. sudoers and sshdconf each have an Orphans()
sweep; schedule had none, because Cancel is name-keyed — it derives every path
from a username you must already know, and the registry that knows usernames
is the thing that goes missing. So a unit whose row was lost was unreachable:
nothing could name it, so nothing could cancel it. That has teeth because a
unit IS an ExecStart on the installed binary: one nobody can name still fires,
and once the binary is gone it fires forever and fails forever, leaving the
account it should have deleted alive with its grants.

The sweep globs v1's prefix too, and that is the point rather than a courtesy.
v1's units carry no "-v2-" infix, so the v2 glob walks straight past them, and
v1's install path was byte-identical to v2's — a v1 timer on an upgraded host
invokes THIS binary, with an argv this code parses perfectly. Its accounts
strand exactly like v2's. config gains StateDir and the v1 names so they can
be found rather than guessed at.

sudoers.Remove can now report failure, and every caller listens. It returned
nothing, so the removal of a file that hands out passwordless root the instant
its username exists could fail without a word. revoke strips that grant FIRST,
deliberately, so an account surviving a refused revoke cannot survive holding
root — and the error saying it had survived was discarded. cleanup-expired was
worse: it printed "removed an orphaned sudo grant" whichever way the removal
went, which is the worst available lie about that particular file.

Note Go will not point at these call sites: a function returning an error can
be called as a statement, so changing the signature compiled clean and every
existing caller kept ignoring it. They were found by grep, not the compiler.

The test for the surviving grant makes os.Remove fail AS ROOT — a read-only
directory does not, root bypasses it, and the test would have skipped in CI
forever while asserting nothing. It also asserts the distinctive phrase rather
than "sudo": revoke's absent-account branch says "cleaning up
registry/sudoers/sshd" on that very path, so the loose substring passed with
the reporting deleted. The mutation caught both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
uninstall deleted /usr/local/sbin/linux-temp-admin and left everything else
exactly where it was: the accounts, their sudo grants, their sshd exceptions,
their auto-delete tasks, the registry, the language preference, and v1's
leftovers. The menu entry had to be called "卸载稳定命令" — uninstall the
stable command — because the opaque object was the only honest part of the
label. It now says 卸载, because it now means it.

It takes an inventory, shows it, asks once, then tears down in an order where
every step leaves the host no worse than it found it.

--force was a trap. The auto-delete unit hardcodes an ExecStart on the
install path, and --force skipped the "registered users still exist" refusal
— so it deleted the binary out from under every timer on the box, which then
fired against a missing path forever. The accounts never expired and a
NOPASSWD-sudo account was left with nothing coming for it. --force keeps only
its original meaning now.

The invariant is that the binary is never removed while a managed account
survives: leaving a sudo-capable account while deleting the only thing that
manages it is worse than not uninstalling. Survival is decided by asking the
system, not by revoke's exit code — revoke answers 0 for a deletion, for
cleaning up an account that was already gone, AND for a declined
confirmation, so its rc cannot carry that weight.

The inventory unions witnesses because the registry is the weakest one: every
way it goes wrong drops accounts silently rather than announcing them, and an
inventory that under-reports is how a teardown strands what it never saw. A
witness that cannot be READ refuses the whole thing. The GECOS marker is
deliberately not a witness — it is the one signal an account can write to
itself, so trusting it would let `usermod -c` enlist a real administrator's
account and home directory into a teardown.

v1's users.tsv is read before it is deleted. It is not litter; it is v1's
account registry, and v1's install path was byte-identical to v2's, so a v1
timer invokes this binary. Deleting that registry while globbing only the
"-v2-" prefix would have reproduced this very release's footgun for v1
accounts and shredded the only record naming them.

The audit log survives by default. It records who opened and closed
root-capable accounts; erasing it on the way out is what covering tracks
looks like. The teardown's own record is written before --purge-audit runs,
or the purge would recreate the file and mean "leave exactly one line".

Refusing to run from the account it would delete is an interlock for the
honest operator, not a security boundary: `sudo su -` drops SUDO_USER, the
only identity signal this tool has. The code says that rather than implying a
guarantee it cannot keep.

StateDir and AuditLogDir are App fields, not constants read in place, for the
same reason InstallPath already was: these are removed RECURSIVELY, and CI
runs the integration suite as root on every push. A test reading the
constants would delete the real /var/lib/linux-temp-admin — on the runner and
on the developer's box. A test pins that they stay injected.

Every guard here was mutation-tested: each was removed, the test confirmed to
fail, the guard restored. An existing test (TestUninstallRefusesOnRegistryReadError)
caught a real regression on the way — the first draft warned where the old
code refused, which is exactly the blind-inventory hazard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A six-lens review (43 agents, three refuters per finding) surfaced six real
defects. The highest was a strand the redesign was specifically meant to
prevent.

Witness-only accounts are now actually removed. The teardown reused revoke
with --yes but not --force, and revoke REFUSES an unregistered account before
its cleanup branch runs. Every witness the union inventory was built for —
the sudoers glob, the sshd glob, the unit globs, v1's registry — names
accounts that are by definition NOT in the v2 registry, so revoke turned away
exactly the accounts the inventory worked hardest to find. Because such an
account (when already gone) is not a survivor by user.Exists, the teardown
removed the binary and printed "their grants are gone" while a NOPASSWD:ALL
drop-in that re-arms full root on username reuse sat untouched on disk. It now
passes --force --confirm-force <name>; revoke's protected-target and UID-proof
gates ignore --force, so a real non-managed account is still refused and the
survivor check still catches it.

v1's units are removed, not just found. Cancel derived the unit path from the
v2 prefix only, so a v1 timer the inventory listed was disabled and deleted
under a name that does not exist — leaving the real v1 .timer/.service enabled
against the now-removed binary, reproducing this release's own footgun for v1.
Cancel now disables and removes every unit prefix it recognises.

The v1 registry witness got the error channel every other witness has. It
collapsed "present but unreadable" into "absent" and never checked sc.Err(),
so the one file the code calls the only record of a sudo-less v1 account could
vanish from the inventory without tripping the fatal-inventory gate. Now a
present-but-unreadable v1 registry refuses the uninstall.

The GECOS test could not fail. It asserted the marker is not a witness while
creating no marked account, so it would have passed if the marker BECAME a
witness — the vacuous-assertion shape that has bitten this work repeatedly. It
now creates a real account carrying the managed GECOS and nothing else, and a
mutation that trusts the marker fails it.

Two documentation fixes the review caught: binaryBlocker said "needs --force"
even when --force was passed (it now reports no blocker under force), and the
CHANGELOG claimed revoke/cleanup-expired were "untouched" in the same entry
that describes changing them (reworded to: their CONTRACT is unchanged, with
the one internal change named).

Every new guard mutation-tested. Full unit + vet (both tags) + race + root
integration + non-root-as-nobody pass, and the witness-only cleanup was
confirmed on a real pty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@xxvcc

xxvcc commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review (43 agents, 3 refuters/finding) found six real defects — fixed in e2c9ee9

The highest was a strand the redesign exists to prevent, and the review caught it because I'd built the union inventory and then reused revoke in a way that turned exactly those accounts away.

HIGH — witness-only accounts were refused, not removed

The teardown called revoke --user X --yes without --force, and revoke refuses an unregistered account before its cleanup branch. Every witness the union inventory exists for (sudoers/sshd/unit globs, v1 registry) names accounts not in the v2 registry — so revoke turned away precisely the accounts the inventory worked hardest to find. When such an account was already gone, user.Exists reported no survivor, so the teardown removed the binary and printed "their grants are gone" while a NOPASSWD:ALL drop-in sat untouched on disk. Now passes --force --confirm-force <name>; revoke's protected-target and UID-proof gates ignore --force, so a real non-managed account is still refused and the survivor check still catches it. Verified on a real pty.

MEDIUM (completeness critic) — v1 units found but never removed

Cancel derived the unit path from the v2 prefix only, so a v1 timer the inventory listed got "removed" under a name that doesn't exist — leaving the real v1 .timer/.service enabled against the now-deleted binary, reproducing this release's own footgun for v1. Cancel now disables and removes every unit prefix it recognises.

HIGH — v1 registry witness had no error channel

It collapsed present-but-unreadable into absent and never checked sc.Err(), so the one file the code calls the only record of a sudo-less v1 account could vanish from the inventory without tripping the fatal-inventory gate. Now refuses the uninstall, like an unreadable v2 registry.

MEDIUM — the GECOS test could not fail

It asserted the marker is not a witness while creating no marked account, so it would have passed if the marker became one. This is the vacuous-assertion shape that's bitten this work repeatedly. It now creates a real account carrying the managed GECOS and nothing else, and a mutation that trusts the marker fails it.

LOW ×2 (docs)

binaryBlocker said "needs --force" even when --force was passed; the CHANGELOG claimed revoke/cleanup-expired were "untouched" in the same entry describing a change to them. Both fixed.

Every new guard mutation-tested. Full unit + vet (both tags) + -race + root integration + non-root-as-nobody pass.

Refuted (not changed): 7 findings, incl. a claim that the v2.5.1 CHANGELOG history was "erased" (v2.5.1 was never tagged/released — it folds into this release) and a re-report of the witness-only issue against already-fixed code.

@xxvcc
xxvcc merged commit b47a7f1 into main Jul 18, 2026
14 checks passed
@xxvcc
xxvcc deleted the feat/real-uninstall branch July 18, 2026 02:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant