fix(install): the three #288 review follow-ups — #289, #290, #291 - #295
fix(install): the three #288 review follow-ups — #289, #290, #291#295CrazyWillBear wants to merge 5 commits into
Conversation
…reate `_account_wanted` was reached on every interactive `chief uninstall`, including single-user installs where no dedicated account exists. Answering yes ran `sudo userdel --remove chief` against whatever pre-existing account happened to carry that name, taking its home with it. And `execute(step)`'s return code was discarded, so "system account chief removed." printed even when both steps failed. Gate the whole branch on `posture.chief_account(repo_dir / ACCOUNT_REPORT)`, which resolves the installer's own report to a real passwd entry — so the account name comes from the report rather than a CLI default that no caller ever passes (the now-dead `user` parameter is gone). Report failure honestly. Closes #291
`grant_reason` rejected only the literal home path, `/` and relative paths, so `~/..`, `/home` and `/Users` all passed — defeating the "home directory root is never offered" claim in docs/SECURITY.md, docs/OPERATIONS.md and PRD #286, and putting a recursive `chgrp -R chief /Users` two keystrokes away. It also took paths that do not exist, whose `chgrp` then aborts `_run` *after* the account and tree steps have landed. Judge the resolved path: refuse `/`, anything the home is inside, and any non-directory. Moved to dedicated_ask.py, its only caller, which keeps account.py off the 200-line cap it was one edit away from. The account name is now gated on `^[a-z_][a-z0-9_-]{0,31}$` before it reaches argv and `Path("/Users") / user` — `default_home("darwin", "/etc")` returns `/etc`, i.e. `useradd --home-dir /etc`. tests/test_install.py also carries the uninstall tests for #291, committed in 1c75a13 — the two sets interleave in one file. Closes #289
Dedicated mode switched the twin dedup off wholesale. That is right for chief's own store — two identical texts seconds apart there are genuinely two messages, not one row recorded twice — but the two stores are polled independently, so a group chat both accounts are in holds every message twice under different rowids: two stranger rows, two bus events, two monitor evaluations, two classifier calls, two wakes. Record every message in the dedup window regardless, so whichever store reads it first primes the key, and suppress only where a repeat is an artefact. The key is `(thread_key, sender, text)` over 5s, so two genuinely identical group messages seconds apart do collapse on the owner's-store path — that path is monitor-only, which is the trade this takes. Closes #290
…dedup race
The high finding was a regression this branch introduced: `--purge-data`
rmtree's `data/`, and the new account gate then read `data/account-setup` out
of the directory it had just deleted. `chief uninstall --purge-data
--remove-account --yes` kept the account, its home and chief's whole message
store, and reported that no account was ever created — where main removed it.
The report is now read before the purge.
- `grant_reason` refuses anything not strictly inside the owner's home. That
is the question the wizard actually asks ("which of *your* directories"),
and it subsumes every earlier clause: `/`, the home root, `~/..`, and the
system roots. `chmod -R g+rwX /etc` hands chief group-write on `sudoers`,
which is root by another name and is ruled out by the no-escalation promise
in docs/SECURITY.md.
- The dedup only held when chief's store was polled first. The two stores keep
independent cursors, so the owner's copy can land a tick ahead, and the
window cannot break the tie — it is measured on the row's own date, which is
identical in both. `RecentDedup.see` now records which store logged a
sighting and returns it, so a cross-store repeat is suppressed either way
round and only a `mine`→`mine` repeat stays exempt.
- `install.sh` no longer `rm -f`s the account report before the offer. A
`--single-user` re-run, a non-interactive re-run, or a declined offer wiped
it on a box that does have an account, turning `--remove-account` into a
silent no-op forever after. It is written aside and moved into place only on
a create/adopt answer.
- Uninstall stops at the first failed step and returns 1: `groupdel --force`
after a failed `userdel` deletes the group out from under an account that is
still there, and exit 0 read as success to anything scripted.
- The uninstall test fixtures wrote the developer's own username into a record
that drives `sudo userdel --remove`. Inert only because every call site
passes a recording executor — the default is a live `subprocess.run`. Now
`nobody`.
- OPERATIONS.md no longer claims the gate means "created by this install":
`chief_account` accepts `mode=existing` too, so an adopted account is
removable the same way. Documented rather than gated, per owner's call.
- Two comments corrected: the account-name check guards an *absolute* name
swallowing the `Path("/Users") / user` join, not `..`; and the dedup comment
now states the collapse cost #290 asked it to state.
Four new regression tests, each failing before its fix.
Review findings addressed —
|
| Sev | Finding | Resolution |
|---|---|---|
| high | --purge-data rmtree's data/, then the new gate reads data/account-setup out of it — so --purge-data --remove-account --yes kept the account and reported none ever existed. main removed it. |
Report read before the purge. Regression test. |
| medium | /etc and other system roots still grantable; chmod -R g+rwX /etc is root via sudoers, contradicting SECURITY.md's no-escalation bullet |
grant_reason now refuses anything not strictly inside the owner's home — which is the question the wizard actually asks, and subsumes every earlier clause |
| medium | Dedup only held when chief's store polled first; independent cursors mean the owner's copy can land a tick ahead, and the window can't break the tie (measured on the row's own date, identical in both stores) | RecentDedup.see records which store logged a sighting and returns it; cross-store repeats suppressed either order, only mine→mine stays exempt. Regression test for the owner-first order. |
| medium | install.sh:96 unconditional rm -f of the report — any --single-user, non-interactive, or declined re-run stranded a real account, making --remove-account a permanent no-op |
Written aside via mktemp, moved into place only on a create/adopt answer |
| medium | Test fixtures wrote the developer's real username into a record driving sudo userdel --remove; inert only because every call site passes a recording executor, while the default is a live subprocess.run |
nobody |
| medium | Gate accepts mode=existing, so an adopted account is still removable — contradicting the new "created by this install" doc claim |
Doc fixed, behavior kept (owner's call): removal stays explicit opt-in and interactively confirmed, and OPERATIONS.md now says created or adopted, spelling out that --remove-account on an existing install deletes a user predating chief |
| low | groupdel --force ran after a failed userdel, and exit 0 read as success |
Stops at the first failure, returns 1. Regression test. |
| low | _name docstring named the wrong escape (.., and useradd on a macOS sysadminctl path) |
Corrected — it guards an absolute name swallowing the Path("/Users") / user join |
| low | Dedup comment stated the opposite side of the trade from what #290 asked for | Now states the collapse cost, including the one-to-one cross-store case |
Worth recording: the reviewer noted #290's implementation is stronger than the issue text specified — the issue's literal "run the dedup for non-mine rows only" would never have primed the window from chief's store and would have fixed nothing.
Three verifier agents went over 71d15da. The cross-store dedup it added was only half right: recording `mine` alongside each sighting meant the owner-store copy overwrote the flag, so the next row from chief's own store lost its exemption and a genuine second message was dropped where main delivered it. dedicated, group both accounts are in, "ok" twice 2s apart: chief r1 deliver owner r1 DROP (correct — but rewrites mine -> False) chief r2 DROP <- real message, lost The key was the problem, not the bookkeeping: (thread_key, sender, text) cannot tell one message read from two stores apart from two people typing the same thing. `guid` can, and the store module already knew it — the comment above DEDUP_WINDOW_NS records that self-DM twins carry *different* guids, which is exactly what makes the two cases separable: - twins (same text, different guid) -> the text heuristic, unchanged, and now restored to main's semantics verbatim for the non-dedicated path; - both stores (same message, same guid) -> exact, no heuristic, no false collapse, and read skew cannot defeat it because the window is measured on the row's own date, which is identical in both copies. Single-store installs are unaffected: a guid is only ever read once there, so the new check never matches. If guids should turn out not to match across two Apple IDs the failure is a double delivery, never a lost message. Also from the verifiers: - `mktemp` with no template (install.sh) would likely fail on BSD/macOS — the only platform the dedicated path runs on — and no CI job reaches the line, since it needs an interactive tty. Now takes a template, and the report is chmod'd 0644: mktemp makes it 0600 and the tree chown has already run, so chief could not read its own install record. - `chief account` defaulted `--report` to None and the launcher forwards none, so the documented alternative to re-running install.sh (docs/OPERATIONS.md) recorded nothing — and after 1c75a13 that is an account chief can never remove. Defaulted to data/account-setup. - `uninstall --purge-data` with the account kept (the default) deletes the only record of it, so nothing can remove it afterwards. Says so, with the manual command, while it is still useful. - `--keep-account` had no test on either side of this change. - docs/SECURITY.md described the old grant rule; the stale `useradd` claim survived in a test docstring after being fixed in the code. 796 passed, ruff + mypy + shellcheck clean.
Verification round —
|
| Sev | Defect | Fix |
|---|---|---|
| medium | mktemp with no template would likely fail on BSD/macOS — the only platform the dedicated path runs on. Needs an interactive tty, so no CI job reaches the line and shellcheck can't see it. |
Takes a template |
| medium | chief account defaulted --report to None and the launcher forwards none, so the documented alternative to re-running install.sh recorded nothing — and after 1c75a13 that's an account chief can never remove |
Defaults to data/account-setup |
| medium | uninstall --purge-data with the account kept (the default) destroys the only record of it, stranding it permanently — the same failure class this PR fixed for install.sh |
Says so, with the manual command |
| low | Report landed 0600 owner-only after the mv; the tree chown had already run, so chief couldn't read its own install record |
chmod 0644 |
| low | --keep-account had no test on either side of this change |
Added |
| low | SECURITY.md still described the old grant rule; the stale useradd claim survived in a test docstring after being fixed in the code |
Both corrected |
Confirmed genuinely fixed
M1 — verified against a built symlink tree: symlinked parents are caught (.resolve() walks the chain), both sides resolved so a symlinked home still admits its own subdirectories, and macOS firmlinks don't break legitimate grants. M4, M5 (clause-by-clause against the code), L1 (return traced through to the process exit code), L3. The high was verified empirically by running the new test's body against the pre-fix module loaded from 9305028.
Non-dedicated mode was verified byte-for-byte equivalent to 77338af — the single-user install has no regression from any of this.
Known and accepted
$HOME=/ would let /etc through (is_relative_to("/") is true for everything); bind mounts defeat resolve() but need root already. Neither is a regression, both need a machine already lost.
Closes #289, closes #290, closes #291 — the three
review-fixfollow-ups from the/my-reviewof PR #288, one commit each.Context: PRD #286's migration is abandoned (Apple refused a second Apple ID), so the dedicated path ships unvalidated. That reframes these three — #291 is the one with live-user reach, firing on every interactive
chief uninstallincluding single-user installs.#291 — uninstall offered to delete an account it never created
_account_wantedwas reached on every interactive uninstall. Answering yes ransudo userdel --remove chiefagainst whatever pre-existing account happened to carry that name, taking its home with it. Separately,execute(step)'s return code was discarded, so "system account chief removed." printed even when both steps failed.Gated the whole branch on
posture.chief_account(repo_dir / ACCOUNT_REPORT)— the question is only asked when this install's own report names an account that resolves to a real passwd entry.--remove-accounton a single-user box is now a no-op. The account name comes from the report rather than a CLI default no caller ever passed, so the now-deaduserparameter is gone.#289 —
grant_reasonwas weaker than the guarantee three docs make for itIt rejected only the literal home path,
/and relative paths, so~/..,/homeand/Usersall passed —chgrp -R chief /Userstwo keystrokes away. Now judged on the resolved path: refuse/, anything the home is inside, and any non-directory (which also catches the typo that previously aborted_runafter the account and tree steps had landed).Moved to
dedicated_ask.py, its only caller —account.pygoes 195 → 185, off the 200-line cap it was one edit from. Account name gated on^[a-z_][a-z0-9_-]{0,31}$before it reaches argv andPath("/Users") / user.#290 — dual-store double-delivery of group chats
The two stores are polled independently and the twin dedup was off wholesale in dedicated mode, so a group both accounts are in held every message twice: two stranger rows, two monitor evaluations, two classifier calls, two wakes.
Now records every message in the window regardless — so whichever store reads it first primes the key — and suppresses only where a repeat is an artefact. Non-dedicated behavior is bit-identical.
Known trade: the key is
(thread_key, sender, text)over 5s, so two genuinely identical group messages seconds apart collapse on the owner's-store path. That path is monitor-only; noted in a comment.Not done
/etcis still grantable. #289 listed it, but the documented guarantee is about the home root, and/etcis an existing directory the owner typed deliberately. A system-path denylist is a different promise than the docs make — say so if you want it.Validation
uv run pytest790 passed,ruff check .andmypy .clean.Six new tests, all failing before the fix. #290 and #291 are exercised against real fakes; #289's
grant_reasonis a pure function and fully unit-tested, but thechgrpit gates has still never run on real hardware — same caveat as the rest of the dedicated path.Docs updated in the same change:
docs/OPERATIONS.md(uninstall's account question) anddocs/SECURITY.md(the grant claim now states what is actually enforced).