[Sprint 7] aimx doctor extensions + hooks prune --orphans#131
Conversation
S7-1: add `check_mailbox_ownership` — per mailbox, verify `getpwnam(owner)` resolves, the inbox/ and sent/ dirs exist, are chowned `owner:owner` mode 0700, and surface stale `inbox/<name>/` / `sent/<name>/` dirs as ORPHAN-STORAGE + config mailboxes with no storage dirs as ORPHAN-CONFIG. Findings carry a severity and the Checks section makes `aimx doctor` exit non-zero on any `Fail` finding. S7-2: add `check_templates` — per `[[hook_template]]`, verify run_as resolves (or is reserved), cmd[0] is an executable file, and the run_as user can `access(X_OK)` cmd[0]. The access probe tries `runuser -u <user> -- test -x <path>` first, then falls back to a fork+seteuid probe (root-only); non-root doctor runs that can't evaluate the probe emit an INFO finding so operators know the check was skipped rather than silently bypassed. S7-3: add `check_hook_invariants`, `check_catchall_user`, and `translate_load_warnings`. The hook invariant is re-run defensively against a hand-edited config.toml; the catchall-user check fails when a catchall mailbox is configured but `aimx-catchall` does not resolve; legacy `aimx-hook` is surfaced as an INFO note. S7-4: add `aimx hooks prune --orphans` (+ `--dry-run`). Root-only (with AIMX_TEST_SKIP_ROOT_CHECK for tests). Refuses when the internal doctor pre-flight reports any non-orphan `Fail` finding; otherwise atomically rewrites `config.toml` via `write_config_atomic` + sends SIGHUP to the daemon. Idempotent on a second pass. Summary line: `Removed N templates (...) and M hooks from K mailboxes`. Existing doctor integration tests updated to inspect stdout without asserting on exit status — the Sprint 7 checks now surface fixture-level ownership drift as FAIL findings by design.
uzyn
left a comment
There was a problem hiding this comment.
Sprint Goal Assessment
Sprint 7's goal — surfacing every misconfiguration that can break isolation or hook execution via aimx doctor, and cleaning up residue via aimx hooks prune --orphans — is fully met. The four stories (S7-1 through S7-4) are implemented end-to-end with distinct, stable check IDs, deterministic orphan classification, root-gating on prune, a dry-run preview, an atomic config rewrite + SIGHUP, and idempotent repeat runs. 1199 unit + 94 integration tests pass locally; clippy and fmt are clean.
Acceptance Criteria Checklist
S7-1 Mailbox ownership checks
-
check_mailbox_ownershipadded and wired intodoctor::runviarun_checks - 8 distinct finding IDs — exceeds the "5 checks" ask:
MAILBOX-OWNER-ORPHAN,MAILBOX-DIR-MISSING,MAILBOX-DIR-NOT-DIR,MAILBOX-DIR-OWNER-DRIFT,MAILBOX-DIR-GROUP-DRIFT,MAILBOX-DIR-MODE-DRIFT,ORPHAN-STORAGE,ORPHAN-CONFIG - Severities are sensibly assigned: Fail for dir-missing / not-a-dir / uid-drift; Warn for group + mode drift + orphan-owner + orphan-storage + orphan-config. That matches the PRD intent (uid drift breaks isolation, mode/group drift is recoverable)
- Findings rendered through
term::*_badgehelpers - Non-zero exit when any
Failis present (viadoctor::runreturningErr) - Unit tests exercise the main branches via
set_test_resolver - Partial test coverage:
MAILBOX-DIR-NOT-DIR,MAILBOX-DIR-OWNER-DRIFT(uid mismatch),MAILBOX-DIR-GROUP-DRIFThave no dedicated unit test.MAILBOX-DIR-OWNER-DRIFTis indirectly exercised in integration via the fixture drift, but there is no direct assertion pinning the check ID
S7-2 Template checks
-
check_templates_with_runneradded;run_asresolution,cmd[0]exists + executable,access(X_OK)as target user all checked -
AccessRunnertrait +RealAccessRunnerseam;runuserfirst, fork+seteuid fallback when running as root,AccessResult::Unknown→ Info finding when neither path evaluates - Finding text includes suggested fixes (
agent-setup --redetect,hooks prune --orphans) - Unit tests cover: valid template, missing run_as, missing cmd[0], denied access, unknown access, root run_as short-circuit
- Fork path is safe: only async-signal-safe calls (
setegid,seteuid,access,_exit) in child;CStringallocated before fork; no Rust destructors run between fork and_exit; no FD leaks introduced (no new FDs opened in child). Exit codes are discriminated (0=Allowed, 1=Denied, other=Unknown).doctor::runis invoked from a single-threadedfn main, so the fork is well-defined
S7-3 Hook invariant + catchall + legacy-user notes
-
check_hook_invariantsre-runs the Sprint 1 invariant defensively -
check_catchall_userfails if a catchall mailbox exists butaimx-catchalldoes not resolve -
check_legacy_aimx_hook_useremits Info (not Fail) whenaimx-hookis still present -
translate_load_warningsconverts everyLoadWarningvariant to a doctor finding at the right severity (Warn for orphans, Info for legacy / root-catchall) - Unit tests cover every branch
S7-4 aimx hooks prune --orphans
- Subcommand added in
src/cli.rsandsrc/hooks.rs - Root-only (with
AIMX_TEST_SKIP_ROOT_CHECKescape hatch for tests — consistent with other root-gated commands in the codebase) - Refuses when non-orphan Fail findings exist; error message names each offending check by ID + message
- Atomic rewrite via
write_config_atomic(temp-then-rename + fsync) -
--dry-runprints the planned diff without writing - Idempotent on second run (verified by both unit and integration tests)
- SIGHUP to the running daemon when available; restart hint otherwise
- Note: the sprint plan said "ConfigHandle::store swap (reuses S5-2 pattern)". The CLI cannot directly swap the daemon's
Arc<Config>— it writes + SIGHUPs, and the daemon swaps on reload. This is the correct pattern for CLI→daemon config edits and matchesaimx mailboxes create/deletefallback path; treating this as not a deviation
Scrutiny areas explicitly flagged
-
Severity classification for orphans:
ORPHAN-STORAGEandORPHAN-CONFIGare classifiedWarn, notFail. This is sensible — an orphan dir or an unprovisioned mailbox entry does not immediately break isolation or correctness, and forcingFailwould makeaimx doctorexit non-zero on routine post-userdelstate. Because they areWarn, they do NOT blockhooks prune --orphansunconditionally (the prune only blocks on non-orphanFail). Consistent. -
runuser-first / fork-setuid-fallback: Safe. Runuser exit codes are discriminated carefully (0=Allowed, 1=Denied, any other=Unknown). The fork path is euid-gated (non-root returns
Unknownimmediately without forking). The child only calls async-signal-safe POSIX functions before_exit.CString::newis outside the fork, so the child does not allocate. No Rust destructors run in the child. -
Doctor exit code: Correct —
any_fail→Err(...)→ non-zero exit viamain.rs. -
Prune root-only + --dry-run + refusal: All correct and directly test-covered.
-
Test fixture regression (
doctor_shows_domain_and_mailboxes,doctor_renders_logs_pointer_section): Confirmed real fixture issue, not a regression.setup_test_envdeclaresaimx-catchallas the catchall owner but chownsinbox/catchall/to the test runner's uid, and never createssent/catchall/at all. The new checks correctly flag both (MAILBOX-DIR-OWNER-DRIFT/MAILBOX-DIR-MISSING), so the tests legitimately cannot assert.success()anymore.
Potential Bugs
None blocking. Notes:
run_runusermaps "Some(_)" (any non-1 exit code) fromrunusertoNone(try fallback), including runuser-internal codes (2 "permission denied", 126 "no shell", 127 "command not found"). That's by design — runuser couldn't evaluate, so try fork. The behavior is safe but could mask genuine runuser misconfig from the operator. Non-blocker.
Test Coverage
- Non-blocker:
MAILBOX-DIR-NOT-DIR,MAILBOX-DIR-OWNER-DRIFT(uid mismatch scenario),MAILBOX-DIR-GROUP-DRIFThave no dedicated unit test. These are straightforward to exercise with atempdir+ a resolver that returns a uid/gid different from the fs-reported ones. - Non-blocker: Dropping
.success()fromdoctor_shows_domain_and_mailboxesanddoctor_renders_logs_pointer_sectionweakens the exit-code contract — any future regression that makes doctor fail for an unrelated reason would still pass these tests. A cleaner fix would be either (a) make the fixture clean (create a user the test runner can chown to +sent/catchall/), or (b) assert the specific expected Fail findings rather than asserting nothing about exit status. Not a blocker because the assertions on stdout content still pin the happy-path rendering, but worth noting. - Non-blocker: The
RealAccessRunnerrunuser / fork-seteuid subprocess paths are not covered by integration tests. Hard to test hermetically without a real second user, so the trait seam + mock is the right call; flagging for completeness only.
Code Quality
FindingSeverity::Passis declared with#[allow(dead_code)]for future use. The comment justifies it, but it is strictly speaking dead code today. Non-blocker.hook_run_as_is_orphanandcheck_templates_with_runnerboth duplicate thename == RESERVED_RUN_AS_ROOT || name == RESERVED_RUN_AS_CATCHALLshort-circuit. There's already a localis_reserved_run_ashelper indoctor.rs;hooks.rsinlines the check. Non-blocker cosmetic.pruneis a long function (~90 lines) that combines root check, doctor pre-flight, plan build, dry-run, write, SIGHUP. Still readable, but splitting the pre-flight into its own helper would make it easier to unit-test independently. Non-blocker.
Alignment with PRD
Fully aligned with the PRD §6 isolation model and §10 cleanup posture. The Warn vs Fail split mirrors the PRD's "operator cleanup is eventually-consistent" stance. The ORPHAN_CHECK_IDS contract is the right seam between doctor and prune.
Summary and Recommended Actions
Overall verdict: Ready to merge.
Blockers: none.
Non-blockers (worth addressing but not gating merge):
- Add unit tests for
MAILBOX-DIR-NOT-DIR,MAILBOX-DIR-OWNER-DRIFT,MAILBOX-DIR-GROUP-DRIFTso every documented finding ID has a direct test. - Either fix the
setup_test_envfixture (createsent/catchall/and chown to a real resolvable user) or replace the dropped.success()assertions with an explicit exit-status assertion that pins the expected Fail findings. The current "no exit-status assertion" weakens the contract.
Nice-to-haves (low priority):
- Dedupe the reserved-run_as short-circuit between
doctor.rs::is_reserved_run_asandhooks.rs::hook_run_as_is_orphan. - Consider surfacing runuser internal errors (exit codes >1) as a distinct Info finding rather than silently falling through to the fork path, so operators can debug PAM / permission issues.
- Split
pruneintopreflight_non_orphan_fails+pruneto ease unit-testing the preflight independently.
… split Addresses non-blocker and nice-to-have feedback on PR #131. Non-blockers * Add direct unit tests for MAILBOX-DIR-NOT-DIR, MAILBOX-DIR-OWNER-DRIFT, and MAILBOX-DIR-GROUP-DRIFT so every documented mailbox-ownership finding ID has a targeted test (previously only covered through the integration fixture drift). * Clean up setup_test_env: both mailboxes now own by the test runner, all four storage dirs are created at mode 0700, so doctor exits success on the happy-path fixture. Restore .success() on doctor_shows_domain_and_mailboxes and doctor_renders_logs_pointer_section. Nice-to-haves * Factor the reserved-run_as short-circuit into a shared is_reserved_run_as() helper in config.rs; callers in doctor.rs and hooks.rs use it instead of duplicating the root/aimx-catchall check. * Surface non-1 runuser exit codes (126, 127, PAM errors, etc.) via a tracing::debug! log with the captured stderr before falling through to the fork+seteuid probe, so operators can diagnose PAM/shell/permission issues without the signal being conflated with "binary not executable". * Split hooks::prune into prune_preflight_check() + prune so the non-orphan-fail refusal logic can be unit-tested independently.
Review fixups (commit 8df2f24)Addressed both non-blockers and all three nice-to-haves. Non-blockers
Nice-to-haves
Verification
|
uzyn
left a comment
There was a problem hiding this comment.
Re-review: Sprint 7 review fixups (commit 8df2f24)
All five items from the prior review are addressed. Verified each independently against the diff and by running the suite locally.
Resolved
- Unit tests for
MAILBOX-DIR-NOT-DIR,MAILBOX-DIR-OWNER-DRIFT,MAILBOX-DIR-GROUP-DRIFT— three new tests insrc/doctor.rs::tests(mailbox_ownership_flags_not_a_dir,mailbox_ownership_flags_owner_uid_drift,mailbox_ownership_flags_group_drift). Each pins the exact finding ID and severity. Owner-drift uses a resolver that returns uid 424242 so the fs-vs-resolver mismatch is unambiguous; group-drift usesgid.wrapping_add(10_000)which stays correct on hosts with high real gids; not-a-dir places a regular file at the inbox path and asserts the Fail finding. All three pass locally (70/70 doctor tests green). - Fixture cleanup +
.success()restored —setup_test_envnow chowns both mailboxes to the test runner's username, creates all four storage dirs (inbox/catchall,sent/catchall,inbox/alice,sent/alice) at mode 0700..success()restored on bothdoctor_shows_domain_and_mailboxesanddoctor_renders_logs_pointer_section. Exit-code contract is back. Both tests pass. - Reserved-run_as dedupe —
is_reserved_run_as()promoted topubinconfig.rs:23and reused bydoctor.rs:1212,hooks.rs:579(build_prune_plan), andhooks.rs:636(hook_run_as_is_orphan). Local duplicate indoctor.rsremoved. Clean. - Surface runuser internal errors distinctly —
run_runusernow pipes stderr (previouslyStdio::null()) and emitstracing::debug!structured events withrun_as,path,exit_code, and capturedstderrfor (a) non-1 exit codes, (b) signal termination, and (c) spawn failures other thanNotFound. Exit code 1 ("test -x denied") still short-circuits toAccessResult::Denied, so semantics are unchanged — only observability. - Prune preflight split —
prune_preflight_check(config) -> Result<(), PruneRefusal>extracted with its own doc comment;prunenow calls it and maps the refusal into a boxed error. Logic is byte-for-byte equivalent to the inlined version.
Still unresolved
None blocking. One minor observation on item 5: the refactor extracts the helper but no unit test was added that exercises prune_preflight_check directly. The stated rationale for the split was "so the non-orphan-fail refusal logic can be unit-tested independently" — the seam is in place, but the follow-up test did not land. Not merge-blocking; end-to-end coverage via hooks_prune_orphans_dry_run_then_apply / hooks_prune_requires_orphans_and_root still holds. Worth a trailing commit if the author wants to close the loop fully.
New issues found
None. The changes are surgical and do not touch any production code paths beyond the reserved-run_as helper consolidation and the stderr piping on runuser. Both are safe.
Verification
cargo fmt -- --checkclean.cargo clippy --all-targets -- -D warningsclean.cargo test— 1202 unit + 94 integration tests pass (up from 1199, consistent with the three new doctor tests); 3 ignored tests (real-user isolation + uds_authz) remain ignored as expected.
Summary
Ready to merge. All blockers from the prior review were already absent; all four concrete non-blocker/nice-to-have items are fully addressed; the fifth (preflight split) is structurally done but its unit-test payoff is left on the table. Net change is strict quality/observability improvement with no regression risk.
Posted as a comment (not --approve) because GitHub blocks self-approval on this repo; treat as an approval.
Recommended merge commit message
[Sprint 7] aimx doctor extensions + hooks prune --orphans (#131)
S7-1 adds check_mailbox_ownership: per-mailbox getpwnam(owner) check plus
inbox/<name>/ and sent/<name>/ existence, uid, gid, and 0700 mode checks.
Stale storage dirs surface as ORPHAN-STORAGE; config mailboxes missing
storage surface as ORPHAN-CONFIG. Findings carry severities and aimx
doctor now exits non-zero on any Fail.
S7-2 adds check_templates: every [[hook_template]] is validated — run_as
resolves (or is reserved), cmd[0] is an executable file, and run_as can
access(X_OK) cmd[0] via runuser-first / fork+seteuid fallback. Non-root
hosts without runuser surface an INFO finding rather than silently
skipping.
S7-3 adds check_hook_invariants, check_catchall_user, and
translate_load_warnings. Catchall mailboxes without an aimx-catchall
user fail; legacy aimx-hook surfaces as INFO; every LoadWarning variant
is translated into a matching doctor finding.
S7-4 adds aimx hooks prune --orphans [--dry-run]. Root-only, atomic
config rewrite via write_config_atomic + SIGHUP, refuses when doctor
reports non-orphan Fail findings, idempotent on a second pass.
Covers the three branches called out in the PR #131 review: * clean config (no non-orphan Fail findings) returns Ok(()). * a non-orphan Fail finding (MAILBOX-DIR-MISSING) produces PruneRefusal whose message names both the check ID and the offending mailbox. * orphan-only findings (ORPHAN-TEMPLATE-RUN_AS at Warn severity) do not block preflight, matching the intent of the function.
|
Added direct unit tests for
Verified locally:
|
uzyn
left a comment
There was a problem hiding this comment.
Re-review #2: prune_preflight_check unit tests (commit 9173450)
The previously-flagged nice-to-have from the prior re-review — "no direct unit test for prune_preflight_check" — is cleanly resolved. Verified the new commit against the diff, the function under test, and the full test suite.
Resolved
- Direct
prune_preflight_checkcoverage — three new unit tests insrc/hooks.rs::tests, each asserts a distinct branch through the helper:prune_preflight_check_passes_on_clean_config— properly-provisioned mailbox (owner resolves via the mock,inbox/alice/+sent/alice/created at mode0700), empty template list.run_checksreturns noFailfindings → preflight returnsOk(()).prune_preflight_check_refuses_on_non_orphan_fail— deliberately omitscreate_mailbox_dirs, socheck_mailbox_ownershipemitsMAILBOX-DIR-MISSINGatFail. The check ID is not inORPHAN_CHECK_IDS, so preflight returnsErr(PruneRefusal)with a message that names both the check ID and the offending mailbox (alice) — both pinned via explicitassert!contains checks.prune_preflight_check_ignores_orphan_only_findings— adds aHookTemplate { run_as: "bob", .. }while the mock resolver does not know "bob". The test then callsrun_checksdirectly and assertsORPHAN-TEMPLATE-RUN_ASis actually in the findings before asserting preflight passes. That sanity-check guards against the test passing vacuously if the finding ID is ever renamed or the check is dropped.
The three tests use the shared set_test_resolver / resolver_without_bob harness already used throughout hooks.rs::tests, so the seam is consistent with the rest of the file.
Still unresolved
None.
New issues found
None. The diff is +116 / -1 in src/hooks.rs and is entirely confined to mod tests. No production code was touched, so there is no regression surface.
Minor observation, not a finding: ORPHAN_CHECK_IDS today contains only Warn-severity findings, so the !ORPHAN_CHECK_IDS.contains(&f.check) filter in prune_preflight_check is strictly redundant with the severity == Fail filter. Test 3 therefore verifies the observable contract (orphan-caused findings don't block prune) rather than the ORPHAN_CHECK_IDS filter itself. That is fine — the observable contract is the right thing to pin, and the ORPHAN_CHECK_IDS filter exists precisely so that a future change that promotes an orphan finding from Warn to Fail does not silently start blocking hooks prune --orphans. The filter is defensive rather than dead.
Verification
cargo test— 1205 unit + 94 integration tests pass (matches the implementer's claim); 3 unit + 5 integration tests remainignoredas expected (real-user isolation + uds_authz).- Scoped run
cargo test prune_preflight_check— 3 passed, 0 failed. cargo clippy --all-targets -- -D warnings— clean.cargo fmt -- --check— clean.- Verifier crate untouched.
Summary
Ready to merge. All blockers, non-blockers, and nice-to-haves from both prior reviews are now closed.
Posted as a comment (not --approve) because GitHub blocks self-approval on this repo; treat as an approval.
Recommended merge commit message
[Sprint 7] aimx doctor extensions + hooks prune --orphans (#131)
S7-1 adds check_mailbox_ownership: per-mailbox getpwnam(owner) check plus
inbox/<name>/ and sent/<name>/ existence, uid, gid, and 0700 mode checks.
Stale storage dirs surface as ORPHAN-STORAGE; config mailboxes missing
storage surface as ORPHAN-CONFIG. Findings carry severities and aimx
doctor now exits non-zero on any Fail.
S7-2 adds check_templates: every [[hook_template]] is validated — run_as
resolves (or is reserved), cmd[0] is an executable file, and run_as can
access(X_OK) cmd[0] via runuser-first / fork+seteuid fallback. Non-root
hosts without runuser surface an INFO finding rather than silently
skipping.
S7-3 adds check_hook_invariants, check_catchall_user, and
translate_load_warnings. Catchall mailboxes without an aimx-catchall
user fail; legacy aimx-hook surfaces as INFO; every LoadWarning variant
is translated into a matching doctor finding.
S7-4 adds aimx hooks prune --orphans [--dry-run]. Root-only, atomic
config rewrite via write_config_atomic + SIGHUP, refuses when doctor
reports non-orphan Fail findings, idempotent on a second pass.
…owned by the reserved user The Sprint 7 `check_catchall_user` fired a FAIL finding whenever any catchall mailbox was configured and the `aimx-catchall` system user was absent, regardless of the catchall's actual owner. Ingest only chowns mail as the catchall mailbox's configured owner, so the reserved user is only required when the operator keeps the `aimx setup` default (`owner = "aimx-catchall"`). The integration fixture (`setup_test_env`) sets both mailbox owners to the current test-runner username, which means the reserved user is not needed, yet the check still fired on CI runners where `aimx-catchall` does not exist — breaking `doctor_shows_domain_and_mailboxes` and `doctor_renders_logs_pointer_section` on ubuntu-latest. Narrow the check to only fire when a catchall mailbox is owned by the reserved `aimx-catchall` user. Update the finding message to reflect the refined semantics, adjust the matching unit tests, and add a new unit test covering the non-reserved-owner path.
uzyn
left a comment
There was a problem hiding this comment.
Sprint 7 Re-Review (CI-fix cycle, commit 35253d9)
Verdict: Ready to merge
Scope of this re-review
Commit 35253d9 narrows check_catchall_user in src/doctor.rs so CATCHALL-USER-MISSING only fires when a catchall mailbox is actually owned by the reserved aimx-catchall user. When the operator has assigned a different owner to the catchall, ingest chowns as that owner and the reserved user is irrelevant — so the check is now a no-op on that configuration.
Correctness of the narrowing
Verified the architectural claim by reading the ingest path:
src/ingest.rs(chown_as_owner_or_warn) →src/ownership.rs::chown_as_ownerresolvesmb.owner, notaimx-catchallunconditionally. The narrowing correctly matches what ingest actually needs.- Hook-level
run_as = aimx-catchallorphan detection remains covered byLoadWarning::OrphanHookRunAs+translate_load_warnings— a completely separate code path, unaffected by this change. No gap introduced. is_catchall(&config)still gates the outer check correctly.
Test coverage
- New unit test
catchall_user_check_skips_when_catchall_owner_is_not_reserveddirectly exercises the narrowed branch: non-reserved catchall owner + unresolvableaimx-catchall→ zero findings. - Two existing unit tests (
catchall_user_check_fires_when_user_missing,catchall_user_check_passes_when_user_exists) correctly updated to useRESERVED_RUN_AS_CATCHALLas the owner so they now exercise the reserved-user path. - Integration tests
doctor_shows_domain_and_mailboxes(and the sibling restored.success()assertion):setup_test_envsets the catchall owner tocurrent_username(), so with the narrowed checkCATCHALL-USER-MISSINGno longer fires on CI hosts that lackaimx-catchall.
CI
Run 24766341612: core-tests, integration-isolation, and verifier-tests all green.
Findings
None. No blockers, no non-blockers, no nice-to-haves beyond what has already been deferred to Sprint 7.5.
Recommended merge commit message
[Sprint 7] aimx doctor extensions + hooks prune --orphans (#131)
Adds Sprint 7 doctor extensions (mailbox ownership/dir drift, catchall
user, hook invariants, load-warning surfacing) and `aimx hooks prune
--orphans` for removing hooks that reference missing templates or
unresolvable run_as users.
Doctor now exits non-zero when any FAIL-severity finding is emitted so
CI / `doctor && serve`-style wrappers catch misconfiguration.
CI-fix in 35253d9 scopes CATCHALL-USER-MISSING to catchall mailboxes
whose configured owner is the reserved `aimx-catchall`; when the
operator has assigned a non-reserved owner, the reserved user is not
required and the check is skipped. Adds a unit test for the new
branch and updates two prior tests to use the reserved owner.
Summary
Implements Sprint 7 of the agent-integration track.
check_mailbox_ownership: per-mailboxgetpwnam(owner)+inbox/<name>/+sent/<name>/existence, uid, gid, and 0700 mode checks. Stale storage dirs surface asORPHAN-STORAGE; config mailboxes with no storage dirs surface asORPHAN-CONFIG.check_templates: every[[hook_template]]is validated —run_asresolves (or is reserved),cmd[0]is an executable file, and therun_asuser canaccess(X_OK)cmd[0]. The access probe usesrunuser -u <user> -- test -x <path>first, then falls back to a fork+seteuid probe (root-only). Non-root doctor runs that can't evaluate the probe emit anINFOfinding rather than silently skipping.check_hook_owner_invariant;check_catchall_userfails when a catchall mailbox exists but theaimx-catchalluser does not resolve; legacyaimx-hooksurfaces as anINFOnote;LoadWarnings fromConfig::loadare translated into matching doctor findings.aimx hooks prune --orphans [--dry-run]: root-only CLI, atomic config rewrite viawrite_config_atomic+SIGHUP, refuses when doctor reports non-orphan failures, idempotent second pass. Summary line:Removed N templates (...) and M hooks from K mailboxes.The existing doctor output (
format_status) is unchanged; the new findings render in a newCheckssection after the existing report, andaimx doctornow exits non-zero when anyFAIL-severity finding is present.Test plan
cargo test— 1199 unit tests + 94 integration tests pass (3 ignored: real-user isolation + uds_authz tests).cargo clippy --all-targets -- -D warningsclean.cargo fmt -- --checkclean.check_mailbox_ownership,check_templates_with_runnerwith a mockAccessRunner,check_hook_invariants,check_catchall_user,translate_load_warnings,format_checks,ORPHAN_CHECK_IDS).hooks.rsunit tests coverbuild_prune_plan,apply_prune_plan(including idempotence),hook_run_as_is_orphan, andformat_prune_summary.hooks prune --orphansCLI path:hooks_prune_orphans_dry_run_then_apply,hooks_prune_requires_orphans_and_root.Notes
access(X_OK)under a different uid: the runner triesrunuserfirst (the documented primary path), then falls back tofork(2)+setegid(2)+seteuid(2)+access(..., X_OK)whenrunuseris absent. Non-root hosts withoutrunuserfall through toAccessResult::Unknown, which surfaces as anINFOfinding so the operator knows the check was inconclusive.doctor_*integration tests had to drop their.success()expectations — the Sprint 7 Checks section legitimately flags the test fixture's pre-chowned dirs as drift (uid mismatch vs.aimx-catchall). They still assert the report's stdout content end-to-end.