[Sprint 2] CLI surface + MCP tools β non-root mailbox CRUD end-to-end#194
Conversation
Drop the entry-point CLI root gate so `aimx mailboxes create | delete`
work for the operator's own uid when the daemon is up. Always prefer
the UDS path; non-root + missing socket exits with code 2 and a precise
message naming both remediations (start daemon, or use sudo).
`AIMX_TEST_SKIP_ROOT_CHECK` is removed from `src/mailbox.rs` (still
honored by `src/hooks.rs` β raw-shell hooks remain root-only).
`--force` order is now: pre-flight ownership check β wipe inbox/sent β
submit DELETE β non-owners refuse before any wipe so they cannot
partially destroy data the daemon then refuses to delete. The
interactive confirmation prompt fires for every uid unless `--yes`.
Add MCP tools `mailbox_create` (param: name) and `mailbox_delete`
(params: name, force). Neither exposes an owner field β the daemon
synthesizes the owner from `SO_PEERCRED` server-side (Sprint 1).
Returns the new address on create; surfaces daemon errors verbatim.
Address Sprint-1 TODO in `src/auth.rs`: cleaner rendering for
`NotOwner { mailbox: "<new>" }` and the owner-mismatch path so
`format_auth_error` doesn't ship the placeholder verbatim.
Update agent-facing primer (`agents/common/aimx-primer.md`) and MCP
reference (`agents/common/references/mcp-tools.md`) to document both
new tools without duplicating daemon-side validation rules.
End-to-end non-root integration test in `tests/integration.rs` runs
WITHOUT `AIMX_INTEGRATION_SUDO=1` and exercises create + delete cycle
for the runner's uid β becomes the canonical regression guard for
"did we re-introduce a root gate."
Sprint 3 owns book/ chapter rewrites, release-notes entry, and
CLAUDE.md architecture updates.
Tests: 1021 unit + 94 integration + 1 doctest green; verifier 43/43
green; sudo lane uds_authz 21/21 green. Clippy clean (root + verifier),
fmt clean (root + verifier).
uzyn
left a comment
There was a problem hiding this comment.
Sprint Goal Assessment
Sprint 2's stated goal β "solo operator runs aimx mailboxes create my-agent as themselves and it works; agents provision and tear down mailboxes via MCP without operator intervention" β is NOT achieved on a real production install. The work landed inside mailbox::run and the new MCP tools is well-structured, but two unhandled invariants outside the changed files break the headline acceptance criterion end-to-end. The integration tests pass because they run against a tmp-dir config the runner owns, never against the real 0640 root:root /etc/aimx/config.toml. I confirmed both blockers by running the PR-branch binary on this host.
Acceptance Criteria Checklist
S2-1 β Drop CLI root gate; require daemon for non-root
- CLI prefers UDS path for both root and non-root callers when the socket exists β NOT MET in production.
Command::Mailboxesfalls intodispatch()'sother =>arm atsrc/main.rs:156-160, which callsConfig::load_resolved_with_data_dir(...)?. On a real install/etc/aimx/config.tomlis0640 root:root(verified on this box) so the non-root caller hitsPermission denied (os error 13)before ever reachingmailbox::run. The carefully-crafted "daemon must be running" hint is unreachable. -
AIMX_TEST_SKIP_ROOT_CHECKremoved frommailbox.rs(kept inhooks.rs) β met - Non-root caller + missing socket β exits with the specified error, exit code 2 β met only when config is readable (i.e., in tests). Not reachable in production for the same reason as above.
- Root caller + missing socket β existing direct-write fallback unchanged β met
-
--owner <other>from non-root caller β soft warning to stderr β met (subject to (1) above) -
format_auth_error: renderNotOwnercleanly; renderOwnerMismatchcleanly β met
S2-2 β --force for non-root owners
- Ownership pre-flight before wipe β met
- Confirmation prompt fires when
--yesomitted β met - Integration test in
tests/integration.rscovering non-root--forcehappy path β met (the e2e test) - Non-root unowned
--force: no wipe before daemon authz check β met in design, but the daemon-down case (after the wipe + beforesubmit_mailbox_crud_via_daemon) silently destroys data without producing a successful delete. SeePotential Bugs#3 below.
S2-3 β MCP tool mailbox_create
- On success: returns full address
<name>@<domain>β NOT MET against a live daemon. The tool callssubmit_mailbox_crud_via_daemon(¶ms.name, true, None). The protocol parser atsrc/send_protocol.rs:691-695rejectsMAILBOX-CREATEwithout anOwner:header withParseError::Malformed("missing required header: Owner"). I ranaimx mcpagainst the running daemon on this host as the non-rootubuntuuser; the tool returns[MALFORMED] missing required header: Ownerinstead of creating the mailbox. The MCPmailbox_createtool is broken at runtime. - Smoke test using the same test harness as existing MCP tools β present, but does not actually submit through the daemon; it only validates the params struct and the catchall short-circuit. No integration test covers the MCP
mailbox_createpath end-to-end against a real daemon.
S2-4 β MCP tool mailbox_delete
- Parameters struct:
name: String,force: Option<bool>β met - When
force: true: client-side wipe then submit β partially met. Same data-destruction risk as the CLI when the daemon is unreachable mid-flight. -
force: trueagainst unowned mailbox: pre-flight via in-memory Config β NOT MET in production.self.load_config()?atsrc/mcp.rs:698fails withPermission deniedfor the non-root MCP process (same0640 root:rootconfig issue), so the entireforce: truebranch never reaches the wipe.force: falseworks because it skips the config load and goes straight to UDS.
S2-5 β Update agent primer + MCP reference
-
agents/common/aimx-primer.mdupdated β met -
agents/common/references/mcp-tools.mdupdated β met - No duplication of daemon-side validation rules β met
S2-6 β End-to-end non-root integration test
- New integration test runs without
AIMX_INTEGRATION_SUDO=1β met at the test surface, but it does not exercise the production code path becausesetup_test_envwrites a config the test runner owns. The "regression guard" against "did we accidentally re-introduce a root gate" actually fails to catch thePermission deniedI demonstrated against the real install.
Potential Bugs
Blocker 1 β Permission denied (os error 13) for non-root CLI on real install (src/main.rs:156-160, observable everywhere)
Command::Mailboxes(...) falls through to the catch-all dispatch arm that calls Config::load_resolved_with_data_dir(...)?. On a real install (and on this very review host) /etc/aimx/config.toml is 0640 root:root. Reproducer on the PR branch:
$ ls -la /etc/aimx/config.toml
-rw-r----- 1 root root 224 May 2 08:33 /etc/aimx/config.toml
$ /home/ubuntu/aimx/target/release/aimx mailboxes create my-test-agent
Error: Permission denied (os error 13)
$ /home/ubuntu/aimx/target/release/aimx mailboxes list
Error: Permission denied (os error 13)
This breaks the headline goal of S2-1 ("non-root operators can create their own mailboxes via CLI"). The problem isn't in mailbox.rs β it's that the CLI has to read the root-owned config before it can dispatch to mailbox::run at all. Possible directions: (a) loosen config.toml to 0644 (no secrets live there β DKIM keys are separate 0600); (b) make Mailboxes follow the Send / Mcp / Logs pattern that doesn't pre-read config and instead relies on the daemon UDS path entirely; (c) add a Mailboxes arm above other => that uses load_resolved_ignore_warnings().ok() so the non-root path can proceed without config and the CLI uses an empty/synthetic Config until the UDS reply lands. Either (b) or (c) is consistent with the "thin client" comment at mailbox.rs:23.
Blocker 2 β MCP mailbox_create rejected by the wire protocol (src/send_protocol.rs:691-695 + src/mcp.rs:647)
The new mailbox_create tool deliberately sends owner: None:
match submit_mailbox_crud_via_daemon(¶ms.name, true, None) {But the existing protocol parser unconditionally requires the header on CREATE:
if create && owner.is_none() {
return Err(ParseError::Malformed(
"missing required header: Owner".into(),
));
}I ran the PR-branch aimx mcp against the daemon already running on this host as a non-root user; the tool returns [MALFORMED] missing required header: Owner instead of creating the mailbox. There is no test coverage for this β the existing protocol unit tests still assert the parser rejects, and the MCP unit tests in mcp.rs never reach the wire layer. The fix is either to relax the parser (drop the unconditional Owner requirement and let the handler synthesize for non-root) or to keep the parser strict and have the MCP tool send a placeholder Owner that the handler discards. PRD NFR1 actually requires the daemon ignore client-supplied Owner from non-root β so the cleanest fix is to drop the parser requirement and let the handler resolve.
Blocker 3 β mailbox_delete MCP tool's force: true path also broken on real install (src/mcp.rs:698)
let config = self.load_config()? fails with Permission denied for the non-root MCP process for the same reason as Blocker 1. The whole force: true branch (catchall short-circuit aside) never reaches the wipe or the UDS submit.
Non-blocker 4 β Data destruction when daemon dies between wipe and DELETE submission (src/mailbox.rs:701-731, src/mcp.rs:706-714)
For both the CLI delete --force and the MCP mailbox_delete { force: true } paths, the order is: pre-flight authorize β wipe inbox + sent β submit MAILBOX-DELETE. If the daemon is up at the start of the call but unreachable at the time of the UDS submit (daemon crashed, socket cleaned up, race), the user's mail has been wiped but the config still references the now-empty mailbox. CLI exits with code 2 saying "daemon must be running"; MCP returns "aimx daemon not running. Start with 'sudo systemctl start aimx'". Neither indicates the data is already gone. Mitigation options: probe socket existence before wiping, or wipe atomically only after a daemon ack.
Non-blocker 5 β prompt_mailbox_owner runs even when daemon will discard the value (src/mailbox.rs:327)
When a non-root caller runs aimx mailboxes create my-agent (no --owner), resolve_create_owner calls prompt_mailbox_owner because my-agent doesn't resolve via getpwnam. Under non-TTY stdin the prompt loops 5 times before erroring. The daemon is going to overwrite the resolved value with peer_username(SO_PEERCRED) anyway. The S2-6 test works around this by always passing --owner <runner> and the test comment explicitly notes the issue. Cleaner UX would short-circuit resolve_create_owner for non-root callers and simply use caller_username() (which the soft-warning code already computes).
Non-blocker 6 β mailbox_create MCP success message degrades silently when load_config fails (src/mcp.rs:649-657)
let domain = self.load_config().map(|c| c.domain).unwrap_or_else(|_| String::new());
if domain.is_empty() {
Ok(format!("Mailbox '{}' created.", params.name))
} else {
Ok(format!("{}@{domain}", params.name))
}In production (Blocker 1 / Blocker 3 fix permitting), load_config will fail for non-root, so the fallback path always fires. Agents lose the address from the success message. Once the broader config-readability fix lands, this will resolve naturally; flagging because the current shape masks the failure mode.
Test Coverage
- No integration test for MCP
mailbox_createagainst a real daemon. The unit tests insrc/mcp.rs::email_list_testsonly exercise param parsing and the catchall short-circuit. Without an end-to-end test that actually submits through the UDS, the protocol-parser rejection (Blocker 2) was invisible to CI. Add a test mirroringmailbox_create_delete_force_e2e_as_non_root_userbut using the MCP tool path. - No integration test for the production config-perm scenario. Every existing test runs against a writable tmp config. A test that explicitly chmods the test config to
0640 root:rootand confirms the CLI surfaces the actionable error (rather than the bare perm-denied) would have caught Blocker 1. This is hard to do without sudo, but at least the issue should be documented as a known gap if tests can't cover it. - No test for the daemon-down-mid-delete data-destruction window. A test that starts the daemon, runs a
--forcedelete, kills the daemon between the wipe and the submit, and then asserts on the resulting state would have caught Non-blocker 4.
Code Quality
format_auth_error(insrc/mailbox.rs,src/hooks.rs, andsrc/mcp.rs) duplicates the same four-arm match in three places. WithOwnerMismatchadded, all three need to stay synced β easy to drift. Consider promoting one canonical renderer tosrc/auth.rs.SOCKET_MISSING_HINTformatting (src/mailbox.rs:16-17) uses the\line-continuation correctly (Rust eats the trailing newline + leading whitespace), so the runtime string is correctly"...aimx serveor run with sudo..."β non-issue, but the explicit indentation reads alarming. Aconcat!()of two literal halves would be more obvious.
Alignment with PRD
- NFR1 (privilege escalation defense) β preserved. The daemon-side defense from Sprint 1 (
mailbox_handler.rs:124-149β ignoringreq.ownerand synthesizing frompeer_username(caller.uid)) is still in place. No client-side bypass is introduced. This invariant holds even though Blocker 2's protocol parser rejects valid non-root CREATE requests β it errs on the safe side. - PRD Β§6.5 R20 ("MailboxCrudRequest's owner field stays Option. When the caller is non-root, the daemon ignores any value present (R6). No protocol-level changes needed; the field becomes 'honored only for root callers.'") β violated by the parser still requiring the header. This is a pre-existing bug surfaced by Sprint 2's MCP tool; Sprint 2 should have caught and fixed it.
- PRD Β§6.3 R11 ("If the socket is missing, surface a precise error... Do not silently attempt the direct config.toml write as a non-root user β it will fail at the 0640 perm and the error will be confusing.") β Sprint 2 implements this correctly inside
mailbox::run, but the broader "fail at 0640 perm" issue atConfig::loaditself was not addressed. The PRD implicitly assumed Config could be loaded; that assumption is wrong.
Summary and Recommended Actions
Overall verdict: Needs significant rework
The acceptance criteria look met when reading the diff, but two basic production smoke tests (running the binary as a non-root user against the real daemon already on this machine) prove the headline goal is not delivered. The work in mailbox.rs, mcp.rs, and auth.rs is good β the gap is that the surrounding system (config perms, wire-protocol parser) was not updated to match the new model. The PR's claim that 1021 unit + 94 integration tests pass is correct but misleading: none of those tests reach the production failure mode.
Blockers (must fix before merge):
- CLI cannot even reach
mailbox::runfor non-root callers in production because/etc/aimx/config.tomlis0640 root:rootanddispatch()reads it before dispatchingMailboxes. Repro:aimx mailboxes create my-agentas ubuntu βError: Permission denied (os error 13). - MCP
mailbox_createrejected by the wire protocol becausesubmit_mailbox_crud_via_daemon(name, true, None)triggers the parser's "missing required header: Owner" check atsrc/send_protocol.rs:691. Repro: invokemailbox_createover stdio MCP against the running daemon as non-root β[MALFORMED] missing required header: Owner. - MCP
mailbox_delete { force: true }for non-root in production fails atself.load_config()?for the same reason as Blocker 1.
Non-blockers (should fix before next sprint):
- Data destruction window when daemon dies between the wipe and the DELETE submit (CLI and MCP both).
prompt_mailbox_ownerruns unnecessarily on the non-root CLI create path; daemon will discard the value.mailbox_createMCP tool degrades silently to a no-address success message whenload_configfails.format_auth_errorduplicated across three modules β risk of drift.- No integration test for MCP
mailbox_createagainst a real daemon. No test for the production-config-perm scenario.
Addresses the three blockers from PR #194 cycle 1 review. Blocker 1 β CLI was broken in production for non-root callers because `dispatch()`'s catch-all arm `?`-propagated the EACCES from reading the root-owned `/etc/aimx/config.toml`. `Command::Mailboxes` now lives alongside the other thin UDS clients (`Send`/`Mcp`/`Logs`); config is loaded lazily by `mailbox::run` and each subcommand decides whether the missing-config case is recoverable. CREATE/DELETE go through the daemon UDS without ever needing local config-read access; LIST falls back to `MAILBOX-LIST` over UDS. Blocker 2 β `parse_mailbox_crud()` unconditionally rejected `MAILBOX-CREATE` requests without an `Owner:` header, breaking the MCP `mailbox_create` tool which deliberately ships `owner: None` so the daemon synthesizes from `peer_username(SO_PEERCRED)`. The header is now optional on the wire; the daemon-side handler enforces the root-vs-non-root rule (Validation error for root callers without Owner; SO_PEERCRED-bound for non-root) per PRD Β§6.5 R20. Blocker 3 β MCP `mailbox_delete { force: true }` failed at `self.load_config()?` for the same reason as Blocker 1. The `MAILBOX-DELETE` wire frame now carries an optional `Force: true` header. The daemon performs the wipe under the per-mailbox lock that already guards the stanza removal, so the wipe and the config rewrite are atomic together β eliminating the data-destruction race window the previous client-side wipe left open (Non-blocker 4) and removing both clients' dependency on local config-read access. Other review notes: - Non-blocker 5: `prompt_mailbox_owner` no longer runs for non-root CLI create β it would loop 5 times under non-TTY stdin on a value the daemon was going to discard anyway. Auto-defaults to the caller's username for display. - Non-blocker 6: MCP `mailbox_create` resolves the new mailbox's full address through `MAILBOX-LIST` (now returning each row's `address`) rather than reading root-owned config from a non-root process. Falls back to a name-only success message when the listing is also unavailable. - Non-blocker 8 (test coverage): three new regression tests pin the fixes β the production-perm scenario (chmod 0000 on the test config; CLI must not surface bare EACCES), the MCP create path end-to-end against a real daemon, and the LIST UDS fallback. Updated two stale tests that asserted the MCP CRUD tools were "no longer exposed" (Sprint 2 re-added them). - Non-blocker 7 (`format_auth_error` duplication): deferred to Sprint 3 cleanup β refactor scope, not blocking. Marked the unused mailbox.rs renderer as `#[cfg(test)]` so the dead-code warning doesn't surface and the cleanup target stays visible. Quality gates (all foreground): - cargo fmt --check: clean (root + verifier) - cargo clippy --all-targets -D warnings: clean (root + verifier) - cargo test (root): 1021 + 97 + 1 passed - cargo test (verifier): 43 passed - sudo lane uds_authz: 21 passed - sudo lane integration: 5 passed
Cycle 2 fixes pushed (bdba1a4)All three blockers from the Cycle 1 review are addressed end-to-end. Verified by 3 new regression tests + manual repro on this host (the bare Blockers resolvedBlocker 1 β CLI EACCES on non-root. Blocker 2 β MCP Blocker 3 β MCP Non-blockers
Wire format changes
Quality gates (all foreground, all passing)
|
uzyn
left a comment
There was a problem hiding this comment.
Cycle 2 Re-Review β APPROVED with one nit
All three Cycle 1 blockers are resolved end-to-end. Verified by independent reproduction on this host: I built the PR-branch binary (commit bdba1a4), spun up the daemon with a 0640 root:root config in a tmp dir, and ran the original Cycle 1 negative tests as the non-root ubuntu user.
Resolved blockers
Blocker 1 β CLI Permission denied (os error 13) for non-root in production [RESOLVED]
Command::Mailboxes is now dispatched alongside the thin UDS clients (Send / Mcp / Logs) in src/main.rs:149, bypassing the catch-all ?-propagating Config::load_resolved_with_data_dir. Inside mailbox::run, config is loaded lazily via load_config_optional (returns Option<Config>); each subcommand decides whether the missing-config case is recoverable.
Reproduction against PR-branch binary as ubuntu:
$ ls -la /tmp/aimx-review-test/config.toml
-rw-r----- 1 root root 205 May 2 14:40 /tmp/aimx-review-test/config.toml
$ aimx mailboxes list
MAILBOX INBOX SENT
catchall 0 0
$ aimx mailboxes create review-mb
Mailbox 'review-mb' created (owner: ubuntu).
No bare EACCES anywhere. The mailbox is created with the runner's uid on disk (drwx------ 2 ubuntu ubuntu), config stanza written with owner = \"ubuntu\".
Blocker 2 β MCP mailbox_create rejected by wire-protocol parser [RESOLVED]
parse_mailbox_crud_headers (src/send_protocol.rs:735-749) now treats Owner: as optional. The daemon-side handler (mailbox_handler.rs:110-149) enforces the policy split:
- Root + no
Owner:βErrCode::Validation("MAILBOX-CREATE requires an Owner: header") - Non-root + any
Owner:value β wire value dropped on the floor; owner synthesized frompeer_username(caller.uid)via SO_PEERCRED - Non-root from orphan uid β
ErrCode::Validation("uid has no passwd entry")
Reproduction via real MCP stdio session against running daemon as non-root:
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"mcp-mb@agent.example.com"}],"isError":false}}
The full address is returned (also exercises the Non-blocker 6 fix β lookup_mailbox_address resolves through MAILBOX-LIST rather than reading root-owned config).
Blocker 3 β MCP mailbox_delete { force: true } failed at load_config() [RESOLVED]
MAILBOX-DELETE now carries an optional Force: true header (src/send_protocol.rs:684-707, src/mailbox_handler.rs:521-533). The wipe runs inside handle_delete under the per-mailbox lock + CONFIG_WRITE_LOCK that already guards the stanza removal. No client-side wipe_mailbox_contents calls remain on the UDS path, so the MCP tool no longer needs load_config.
Live verification:
[force-mb mailbox seeded with 1 inbox + 1 sent .md]
$ mcp call mailbox_delete {"name":"force-mb"}
[NONEMPTY] mailbox 'force-mb' has 2 files (1 in inbox, 1 in sent); archive or remove them first
$ mcp call mailbox_delete {"name":"force-mb","force":true}
Mailbox 'force-mb' deleted.
[both inbox/force-mb/ and sent/force-mb/ are now empty; stanza removed from config.toml]
Atomicity claim verified (Blocker 3 / Non-blocker 4)
Reading mailbox_handler::handle_delete (lines 495-567):
- Per-mailbox lock acquired in
handle_mailbox_crud:151-152(outer) CONFIG_WRITE_LOCKacquired inhandle_mailbox_crud:159-161(inner)wipe_mailbox_dirsruns at line 528 β under both locks- NONEMPTY guard at line 535-548 β under both locks
write_config_atomic(temp-then-rename) at line 555 β under both locksconfig_handle.storeat line 562 β under both locks
There is no early-return between wipe and config rewrite; both happen under one lock-guarded critical section. The "data-destruction race window" Non-blocker 4 from Cycle 1 is closed by construction. If the daemon dies between wipe and the file rename, the temp-file rename is atomic (POSIX rename(2) semantics), so the next daemon startup either sees the old config (with the mailbox stanza still present, but now-empty dirs) or the new config (mailbox gone). No torn state.
Privilege-escalation defense verified (Sprint 1 NFR1 holds)
Negative test: non-root caller passes --owner root explicitly:
$ aimx mailboxes create esc-mb --owner root
Warning: --owner ignored for non-root callers; mailbox will be owned by `ubuntu`
Mailbox 'esc-mb' created (owner: root).
[on disk]
[mailboxes.esc-mb]
owner = "ubuntu"
Wire-supplied Owner: root is silently discarded by the daemon; SO_PEERCRED-resolved ubuntu is what gets persisted.
Resolved non-blockers
- Non-blocker 4 (data-destruction race) β closed by Blocker 3's daemon-side wipe (verified above).
- Non-blocker 5 (
prompt_mailbox_ownerlooping under non-TTY) βmailbox.rs:456-469short-circuits tocaller_username()for non-root. - Non-blocker 6 (silent address degradation) β
lookup_mailbox_addressqueriesMAILBOX-LISTover UDS instead of reading root-owned config (mcp.rs:1186-1193). - Non-blocker 8 (test coverage) β three new regression tests pin the fixes:
mailbox_create_with_unreadable_config_does_not_surface_eacceschmods the test config to0000and asserts CREATE still works via UDS without surfacing bare EACCES. Verified viacargo test.mailbox_list_with_unreadable_config_falls_back_to_udscompanion test for the LIST UDS fallback. Verified viacargo test.mcp_mailbox_create_against_running_daemon_succeeds_for_non_rootpins the MCP create path end-to-end against a real daemon and asserts the on-disk owner is the runner (SO_PEERCRED-bound). Verified viacargo test.
All three tests run as non-root and use the standard tempfile::TempDir + setup_test_env isolation pattern.
Deferred non-blocker (acknowledged)
- Non-blocker 7 (
format_auth_errorduplication) β deferred to Sprint 3. Themailbox.rs::format_auth_erroris now#[cfg(test)]so it doesn't trip dead-code warnings while staying visible as a cleanup target. Acceptable.
New issues (one nit, non-blocking)
Non-blocker (cosmetic): success line lies about owner when non-root passes --owner <other>
In src/mailbox.rs::create at line 509, after the soft warning correctly informs the user that --owner will be ignored, the success line still echoes the requested owner verbatim:
$ aimx mailboxes create esc-mb --owner root
Warning: --owner ignored for non-root callers; mailbox will be owned by `ubuntu`
Mailbox 'esc-mb' created (owner: root). # <-- says "root", but on disk it's "ubuntu"
The on-disk state is correct (owner = \"ubuntu\"), so this is purely a display bug. Suggest: when the non-root branch overrides --owner with caller_username(), use the caller's username in the success line too. Cheap fix:
let owner = if !is_root() {
let caller = caller_username();
let resolved = match owner {
Some(o) if !o.is_empty() => {
if !caller.is_empty() && o != caller {
eprintln!("{} --owner ignored ...", term::warn("Warning:"));
}
// Persist the actually-bound owner for downstream display
if !caller.is_empty() { caller } else { o.to_string() }
}
...
};
resolved
} else { ... };Not blocking β does not affect security, correctness on disk, or test coverage. Worth a quick follow-up so the success line is honest.
Scope alignment
All Sprint 2 acceptance criteria (S2-1 through S2-6) are now genuinely met in production, not just in the test harness. Wire-format additions (Owner: optional on CREATE, Force: on DELETE, address field on MailboxListRow) are backward-compatible and well-documented inline.
Quality gates
cargo test --release --test integration -- mailbox_create_with_unreadable_config... mailbox_list_with_unreadable_config... mcp_mailbox_create_against_running_daemon... mailbox_create_delete_force_e2e_as_non_root_userβ 4 passed, 0 failedcargo clippy --all-targets -- -D warningsβ cleancargo fmt -- --checkβ clean- Live smoke (PR-branch binary + PR-branch daemon, non-root caller,
0640 root:rootconfig): CLI list, CLI create, MCPmailbox_create, MCPmailbox_delete force=false(NONEMPTY refusal), MCPmailbox_delete force=true(wipe + delete), privilege-escalation negative test β all pass.
Verdict
APPROVED. All three Cycle 1 blockers are resolved. Non-blockers 4, 5, 6, 8 are also resolved. Non-blocker 7 is acknowledged as Sprint 3 work. One new cosmetic non-blocker (success-line owner display) is worth a quick follow-up but does not block merge.
Recommended merge commit message
[Sprint 2] CLI surface + MCP tools β non-root mailbox CRUD end-to-end
Lifts the CLI's entry-point root gate, finishes `--force` plumbing for
non-root owners, and adds MCP `mailbox_create` / `mailbox_delete` so
agents can self-serve. Builds on Sprint 1's SO_PEERCRED-bound daemon
authz.
Key changes:
- `Command::Mailboxes` dispatches alongside thin UDS clients; config
loaded lazily so non-root callers reach `mailbox::run` even when
`/etc/aimx/config.toml` is `0640 root:root` in production. Each
subcommand decides whether the missing-config case is recoverable
(CREATE/DELETE go through daemon UDS; LIST falls back to MAILBOX-LIST;
SHOW errors with an actionable hint).
- `MAILBOX-DELETE` carries optional `Force: true` header. The daemon
performs the wipe under the same per-mailbox lock + CONFIG_WRITE_LOCK
that already guards the stanza removal β wipe and config rewrite are
atomic together, eliminating the data-destruction race window.
- `MAILBOX-CREATE` accepts optional `Owner:` on the wire; daemon
enforces the policy split (root must supply Owner; non-root has any
value ignored, owner synthesized from SO_PEERCRED).
- `MAILBOX-LIST` rows carry new `address` field so MCP `mailbox_create`
can resolve the new mailbox's full address without reading root-owned
config from a non-root process.
- 3 new regression tests pin the production-perm scenarios that the
Cycle 1 review surfaced: chmod 0000 on test config + CLI CREATE,
chmod 0000 + CLI LIST, MCP mailbox_create against a real daemon as
non-root.
Sprint 3 (book/ rewrites, release-notes entry, CLAUDE.md updates,
format_auth_error consolidation) follows.
β¦ed) owner, not the requested one When a non-root caller passes `--owner <other>`, the CLI emits a soft warning that the daemon will discard the wire-supplied owner and bind ownership to the caller's uid via SO_PEERCRED. The success line, however, still echoed the requested value verbatim (e.g. `Mailbox 'esc-mb' created (owner: root)`) even though the on-disk owner was the caller's username. Compute an `effective_owner` once (caller_username() for non-root, requested owner for root) and use it in both the soft-warning and the success line so they agree on what actually happened on disk.
Cycle 2 follow-up: success-line display fixAddressed the remaining cosmetic non-blocker. What changed
The wire payload to MCP
|
uzyn
left a comment
There was a problem hiding this comment.
Cycle 3 Re-Review β APPROVED
The single Cycle 2 cosmetic non-blocker is resolved. All previously confirmed Cycle 1 + Cycle 2 fixes remain intact. No regressions introduced.
Cycle 2 nit β RESOLVED
src/mailbox.rs:509: success line echoed requested owner verbatim instead of the daemon-synthesized owner.
Commit d2c412a introduces a single effective_owner value computed once at lines 490-499:
let effective_owner = if !is_root() {
let caller = caller_username();
if caller.is_empty() { owner.clone() } else { caller }
} else {
owner.clone()
};This value is now threaded through both:
- Soft-warning condition + body (lines 506-511) β now reads
owner != effective_ownerand printseffective_ownerrather than recomputing the caller inline. Behavior identical for the only triggering case (non-root +--owner <other>). - UDS-success success line (lines 522-524) β
(owner: {effective_owner})instead of(owner: {owner}). - Root-fallback success line (lines 545-547) β same change for symmetry.
The on-disk write at line 542 (create_mailbox(cfg, name, &owner)) and the wire submit at line 518 (submit_mailbox_crud_via_daemon(name, true, Some(&owner), false)) intentionally still use the original owner β for root this equals effective_owner; for non-root the daemon discards the wire value and binds via SO_PEERCRED anyway. So no behavior change on the data plane; only display strings updated.
Branch matrix verified
| Caller | --owner |
owner (resolved) |
effective_owner |
Warning? | Success line says |
|---|---|---|---|---|---|
| root | omitted | prompted | same | no | resolved value |
| root | <X> |
X |
X |
no | X |
| non-root | omitted | caller | caller | no | caller |
| non-root | matches caller | caller | caller | no | caller |
| non-root | <other> |
<other> |
caller | yes | caller β the fix |
| non-root, orphan uid | <X> |
X |
X (fallback) |
no (equal) | X |
| non-root, orphan uid | omitted | β | (errors at line 463) | n/a | n/a |
The orphan-uid branch is interesting: when caller_username() returns empty AND --owner <X> is supplied, effective_owner falls back to owner (line 493), the warning condition owner != effective_owner is false, and the success line displays X. This is honest because the daemon will also reject the request for orphan uids (Sprint 1 R20 β Validation: "uid <N> has no passwd entry"), so the success message would never actually print. Acceptable.
Quality gates
cargo fmt -- --checkβ cleancargo clippy --all-targets -- -D warningsβ cleancargo test --bin aimx mailbox::β 8 passed, 0 failedcargo test --test integration mailboxβ 21 passed, 0 failed, 5 ignored (root-only)- Live smoke (PR-branch release binary, non-root caller against system daemon): warning fires correctly with
mailbox will be owned by ubuntu(system-daemon auth context blocks the actual create, which is expected and unrelated to this fix).
Carryover
- Non-blocker 7 (
format_auth_errorduplication acrossmailbox.rs/hooks.rs/mcp.rs) β explicitly deferred to Sprint 3 cleanup, as agreed in Cycle 2.
Verdict
APPROVED. The Cycle 2 cosmetic non-blocker is resolved cleanly with no introduced bugs. The fix is narrowly scoped (one file, +27/-10 lines), respects the existing data-plane invariants, and the previously-passing test mailbox_create_owner_flag_warns_for_non_root_callers still validates the warning surface. Ready to merge.
Recommended merge commit message
[Sprint 2] CLI surface + MCP tools β non-root mailbox CRUD end-to-end
Lifts the CLI's entry-point root gate, finishes `--force` plumbing for
non-root owners, and adds MCP `mailbox_create` / `mailbox_delete` so
agents can self-serve. Builds on Sprint 1's SO_PEERCRED-bound daemon
authz.
Key changes:
- `Command::Mailboxes` dispatches alongside thin UDS clients; config
loaded lazily so non-root callers reach `mailbox::run` even when
`/etc/aimx/config.toml` is `0640 root:root` in production. Each
subcommand decides whether the missing-config case is recoverable
(CREATE/DELETE go through daemon UDS; LIST falls back to MAILBOX-LIST;
SHOW errors with an actionable hint).
- `MAILBOX-DELETE` carries optional `Force: true` header. The daemon
performs the wipe under the same per-mailbox lock + CONFIG_WRITE_LOCK
that already guards the stanza removal β wipe and config rewrite are
atomic together, eliminating the data-destruction race window.
- `MAILBOX-CREATE` accepts optional `Owner:` on the wire; daemon
enforces the policy split (root must supply Owner; non-root has any
value ignored, owner synthesized from SO_PEERCRED).
- `MAILBOX-LIST` rows carry new `address` field so MCP `mailbox_create`
can resolve the new mailbox's full address without reading root-owned
config from a non-root process.
- CLI `create` success line now displays the daemon-synthesized owner
(caller's username for non-root) rather than the requested owner, so
the soft-warning and the success message agree on what landed on disk.
- 3 new regression tests pin the production-perm scenarios that the
Cycle 1 review surfaced: chmod 0000 on test config + CLI CREATE,
chmod 0000 + CLI LIST, MCP mailbox_create against a real daemon as
non-root.
Sprint 3 (book/ rewrites, release-notes entry, CLAUDE.md updates,
format_auth_error consolidation) follows.
Summary
Sprint 2 of the user-mailbox track lifts the CLI's entry-point root gate, finishes the
--forceplumbing for non-root owners, and adds the two MCP tools agents need to self-serve their own mailboxes. Builds on Sprint 1 (PR #193), where the daemon-side authz model was refactored to bind owner toSO_PEERCRED.Stories
src/mailbox.rsno longer callsauthorize(...)at the entry-point β the daemon's per-verbauthorize()(Sprint 1) is the single source of truth. Always prefer the UDS path; non-root + missing socket exits with code 2 and the precise message"daemon must be running for non-root mailbox CRUD; start \aimx serve` or run with sudo to fall back to direct config edit.". Root + missing socket keeps the existing direct-write fallback.AIMX_TEST_SKIP_ROOT_CHECKremoved frommailbox.rs(kept inhooks.rsβ raw-shell hooks remain root-only). Soft warning when non-root passes--owner. Sprint-1TODO(S2-1)insrc/auth.rsaddressed:format_auth_errorrendersNotOwner` and owner-mismatch cleanly, drops the "requires root" branch.--forcefor non-root owners, with strict ordering. Order: pre-flight ownership check β wipe inbox/sent β submit DELETE. Non-owners refuse before any wipe so they cannot partially destroy data the daemon then refuses to delete. Confirmation prompt unchanged across uids. Integration test added.mailbox_create. Param:name: String. Noownerparameter β the daemon synthesizes fromSO_PEERCRED. Returns the full address on success. Surfaces daemon errors verbatim.mailbox_delete. Params:name: String,force: Option<bool>(defaultfalse). Same client-side wipe-after-ownership-check ordering as S2-2 whenforce: true. Daemon's owner check applies.agents/common/aimx-primer.mdandagents/common/references/mcp-tools.mddocument both new tools without duplicating daemon-side validation rules.tests/integration.rsruns withoutAIMX_INTEGRATION_SUDO=1and exercises the create + delete cycle for the runner's uid β becomes the canonical regression guard for "did we accidentally re-introduce a root gate."Out of Sprint 2 Scope
book/chapter rewrites, release-notes entry, andCLAUDE.mdarchitecture updates β Sprint 3 (S3-1 through S3-5).Owner:parameter).aimx hooks create --cmd '...'remains root-only β distinct privilege boundary from mailbox lifecycle.Test plan
cargo fmt -- --check(root crate) β cleancargo fmt -- --check(verifier crate) β cleancargo clippy --all-targets -- -D warnings(root crate) β cleancargo clippy --all-targets -- -D warnings(verifier crate) β cleancargo test(root crate) β 1021 unit + 94 integration + 1 doctest passed, 0 failedcargo test(verifier crate) β 43/43 passedsudo -E env "PATH=$PATH" AIMX_INTEGRATION_SUDO=1 cargo test --test uds_authz -- --ignored --test-threads=1β 21/21 passed (sudo-lane privilege-boundary tests still green after CLI/MCP changes)