Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Security model

AIMX is a mail server for a single operator running AI agents on a host they control. This page describes the threat model, the trust boundaries that hold the design together, and what AIMX does not defend against.

AIMX has two boundaries: an external boundary at the root-only DKIM private key, and an internal boundary at server-side per-verb authorization keyed on the caller’s SO_PEERCRED uid. The DKIM key is what lets a sender credibly impersonate your domain on the public internet — local file ACLs and socket modes only decide who on this host gets to ask the daemon to sign. Letting unprivileged subjects reach the signing oracle but never the key itself is what makes the rest of the design ergonomic.

At a glance

  • Single-operator, single-host. Every local user and agent on the box is inside the trust boundary.
  • aimx serve runs as root and owns the DKIM key. Every other process (aimx send, aimx mcp, hook subprocesses) is unprivileged and cannot forge outbound signatures.
  • /run/aimx/aimx.sock is 0666, but every verb runs auth::authorize server-side using the caller’s SO_PEERCRED uid. It is a signing oracle for the configured mailboxes plus an owner-bound CRUD surface; it cannot run arbitrary commands.
  • Hook subprocesses always run as the mailbox owner. The daemon setuids to mailbox.owner_uid before exec and cmd is execvp’d directly — no shell wrapper unless the operator spelled ["/bin/sh", "-c", "..."].
  • DKIM, SPF, and DMARC results are recorded on every inbound email; only DKIM gates hook execution. Mail is always stored regardless of the result.
  • No IMAP, POP3, webmail, SMTP AUTH, retry queue, DSN bounces, spam filtering, or rate limiting. Out of scope by design.

Threat model

Who you are

The operator: a single administrator of a VPS or home server with port 25 open to the internet. You own the host, the domain, and every local user on it. You install AIMX to give AI agents their own addresses on your domain.

Who the adversaries are

AIMX is built to survive:

  • The public internet on port 25. Unauthenticated senders, spammers, phishers, scanners. AIMX records the authentication results and lets the operator (or an agent via a hook) decide what to do with the mail.
  • A confused or compromised local agent. A prompt-injected agent might try to exfiltrate mail, send spam under your domain, or install a shell-running hook. The design prevents the third outright and bounds the first two.
  • A curious local user. Reads every mailbox they own and submits mail through the socket, but cannot sign as a wildcard, forge DKIM, mutate or hook another uid’s mailbox, or run hooks as anyone but the owner.

Who the adversaries are not

AIMX does not attempt to defend against:

  • Hostile code running as root. Root can read the DKIM key, edit config.toml, and replace the daemon binary. Nothing below that privilege level can stop it.
  • Multiple mutually-distrustful humans on one host. Mailbox isolation (0700, per-owner) prevents reads across uids, but any local user can submit to the UDS and act on mailboxes they own. If two users on one box cannot trust each other to operate the daemon, AIMX is the wrong tool — run Postfix or Stalwart.

Trust boundaries

The clearest view of AIMX is a table of who-can-reach-what:

SubjectRuns asHolds the DKIM keyCan submit to UDSCan edit config.toml
aimx serverootyesn/a (handles the socket)yes (via its own UDS)
aimx sendinvoking usernoyesno
aimx mcpthe agent’s usernoyes (subset of verbs)no
Hook subprocessmailbox owner (setuid to mailbox.owner_uid)nono (env-only)no
Any local usertheir login UIDnoyes (socket is 0666)no

The only subject that touches the DKIM key is the daemon. Every other subject that wants to send mail under your domain has to ask the daemon nicely, over the socket — and the daemon, not the caller, decides whether to sign.

Per-action authorization

Every authorization decision in AIMX — CLI, UDS, or MCP — flows through the predicate in src/auth.rs. Root passes unconditionally; non-root callers are bound by the per-action rules below. The same predicate gates the host CLI verb, the UDS verb, and the MCP tool, so the model is symmetric end-to-end.

ActionRootOwner-gated (non-root)Notes
MailboxReadalwayscaller uid must equal mailbox owner_uidaimx mailboxes show, email_* MCP tools, email_list.
MailboxSendAsalwayscaller uid must equal mailbox owner_uidaimx send and email_send / email_reply.
MarkReadWritealwayscaller uid must equal mailbox owner_uidemail_mark_read / email_mark_unread.
MailboxCreatealways (may pass --owner to create cross-uid)caller may only create a mailbox owned by their own uid; the daemon synthesizes the owner from SO_PEERCRED and ignores any client-supplied Owner: header from non-rootaimx mailboxes create, MAILBOX-CREATE UDS, mailbox_create MCP.
MailboxDeletealwayscaller uid must equal mailbox owner_uidaimx mailboxes delete (incl. --force), MAILBOX-DELETE UDS, mailbox_delete MCP.
HookCrudalwayscaller uid must equal mailbox owner_uidaimx hooks create / list / delete, HOOK-* UDS verbs, hook_* MCP tools. Hooks always run as the mailbox owner.
SystemCommandalwaysrejectedsetup, serve, uninstall, dkim-keygen, portcheck.

Mailbox create and delete are owner-gated, not root-gated: every local uid on the box is already inside the trust boundary, so sudo for spinning up a mailbox owned by yourself bought zero security and broke the daily workflow. The privilege-escalation defense is structural — for every non-root UDS request, the daemon resolves the owner identity from the kernel-validated SO_PEERCRED peer uid and ignores any client-supplied Owner: field.

Two cases stay operator-only:

  • Cross-uid creates. Only root may pass --owner <other-user>. mailbox_create has no owner parameter, and the UDS handler discards Owner: headers from non-root callers.
  • The catchall. Owned by the reserved aimx-catchall user (no shell, no resolvable login uid). Provisioned only by aimx setup and not exposed through any agent surface.

The DKIM boundary

The DKIM private key is the one thing on disk that unprivileged subjects cannot forge, bypass, or reproduce.

PathModeOwner
/etc/aimx/config.toml0640root:root
/etc/aimx/dkim/private.key0600root:root
/etc/aimx/dkim/public.key0644root:root

aimx serve loads the private key once at startup into an Arc<DkimKey> and signs every outbound message in-process. The key is never passed to subprocesses and never written to a descriptor other than its original file. When the key is rotated (new selector), you SIGTERM the daemon, update dkim_selector in config.toml, and start the daemon again.

If the DKIM key leaks, anyone can sign mail under your domain until you rotate. Treat it like an SSH host key. See How do I rotate the DKIM key without a delivery gap? for the recipe.

File and socket layout

PathModeOwnerPurpose
/etc/aimx/0755root:rootConfig + DKIM directory
/etc/aimx/config.toml0640root:rootMailboxes, trust policy, hooks
/etc/aimx/dkim/private.key0600root:rootDKIM signing key
/etc/aimx/dkim/public.key0644root:rootPublished in the _domainkey TXT record
/var/lib/aimx/0755root:rootStorage root (traversable; per-mailbox dirs are 0700)
/var/lib/aimx/inbox/<mailbox>/0700<owner>:<owner>Inbound mail (owner = the configured Linux user)
/var/lib/aimx/sent/<mailbox>/0700<owner>:<owner>Outbound copies (same owner)
/run/aimx/0755root:rootRuntime directory (provided by systemd/OpenRC)
/run/aimx/aimx.sock0666root:rootUDS signing oracle + owner-gated CRUD (world-writable, server-side auth::authorize per verb)

Two choices are load-bearing: per-owner mailbox isolation, and the world-writable socket. Both are deliberate.

/etc/aimx/ and /var/lib/aimx/

/etc/aimx/ holds secrets and policy: the DKIM private key, the mailbox list, the hook config. The whole tree is root-owned, the key is 0600, the config is 0640, and the daemon is the only process that reads it.

/var/lib/aimx/ holds the mailboxes: Markdown files with TOML frontmatter, plus attachments as siblings in bundle directories. Each mailbox is <owner>:<owner> 0700. The daemon enforces this on every write. Storage is deliberately flat text so agents can ls, grep, RAG-index, or read from a shell hook without an IMAP layer. Per-mailbox ownership scopes each agent to its own inbox while preserving flat-corpus ergonomics inside.

Secrets never flow outward, mail never flows into the secrets tree.

Inbound SMTP

aimx serve listens on port 25 and accepts plain SMTP. STARTTLS is advertised and supported but not required — remote MTAs that speak plain SMTP are accepted, because that is still the norm for inter-MTA traffic. There is no AUTH extension, no SMTP-AUTH, and no rate limit. Any sender on the public internet can connect, complete an SMTP dialogue, and hand AIMX a message.

Port 25 is open, but AIMX is not an open relay

Every MTA on the public internet listens on port 25 — RFC 5321 says that is where mail arrives. Exposing it is not a security posture, it is a prerequisite for receiving mail. What matters is what the listener does with what it receives.

An open relay accepts mail over SMTP and forwards it back out to a third-party destination, typically for spam. AIMX is not an open relay, by construction:

  • Inbound and outbound paths never cross. Inbound writes the message to inbox/<mailbox>/ and returns. The outbound path is triggered only by a SEND request on the UDS, submitted by a process already on the host with a validated From: that resolves to a configured local mailbox.
  • Inbound recipients must be yours. Every RCPT TO is compared case-insensitively against config.domain. Unrelated domains, subdomains of config.domain, and malformed addresses are refused with 550 5.7.1 relay not permitted before any storage. The catchall only ever covers unknown locals on your domain.
  • Outbound senders must be yours. The daemon parses From: from the submitted body and refuses to sign anything whose domain is not exactly config.domain or whose local part does not resolve to a concrete (non-wildcard) configured mailbox. Even a local user with UDS access cannot cause AIMX to sign mail as someone-else@someone-elses-domain.

What the daemon does with an inbound message

When a message arrives, the daemon:

  1. Parses the raw .eml via mail-parser and extracts headers, body, and attachments.
  2. Runs DKIM / SPF / DMARC checks and records the result strings in the email’s frontmatter (dkim = "pass" | "fail" | "none", etc.). The three fields are always written, never omitted.
  3. Writes the Markdown file to inbox/<mailbox>/ atomically (temp file + rename).
  4. Evaluates the effective trust for the mailbox (see Trust evaluation) and gates on_receive hooks accordingly.

Inbound non-goals

AIMX has no spam filter, rate limiter, greylist, bounce generator, or retry queue:

  • No spam filter. The agent is the spam filter. An LLM reading a mailbox can classify a message far more accurately than a rule-based scorer. Storing the mail and flagging trusted = "false" is the right split.
  • No rate limiter. The design assumes a single-operator host at human-agent volumes, not a multi-tenant relay. Inbound DoS is better handled at the network edge.
  • No greylisting. Agents are supposed to react in real time. Deferring a first-time sender ten minutes defeats the purpose of an agent mailbox.
  • No bounce / DSN generation. Inbound failures (unknown recipient domain, malformed address) are reported synchronously as a 5xx in the SMTP dialogue. Async DSNs are how backscatter happens.
  • No outbound retry queue. Every send is initiated by a live caller, who can retry with better context than a blind queue. 4xx is returned to the caller; 5xx is persisted with the reason.

Bounds that do exist are sized for accidental misuse, not a determined DoS attacker:

  • DEFAULT_MAX_BODY_SIZE = 25 MB.
  • MAX_HEADER_LINE = 8 KiB.
  • UDS_REQUEST_TIMEOUT = 30 s per connection.

If your threat model includes hostile volumes of inbound mail, front AIMX with a firewall or a small greylisting MTA.

Outbound SMTP

Outbound flows through the UDS. Clients submit an unsigned message; the daemon validates From:, signs, and delivers directly to the recipient’s MX.

From: validation is strict:

  • Domain must be exactly config.domain (case-insensitive).
  • Local part must resolve to a concrete, non-wildcard mailbox in config.toml.
  • The catchall is inbound-only and is explicitly refused as a sender.

A local user with socket access can sign as any mailbox they own but cannot invent new senders or hide behind the wildcard. A compromised agent can send under its own mailbox (that is the point) but cannot impersonate another configured mailbox unless they share one.

Delivery is direct: AIMX resolves the recipient’s MX via hickory-resolver (falling back to A per RFC 5321) and connects to port 25. Opportunistic STARTTLS is attempted. There is no relay, no submission server, no queue. 4xx is returned to the caller and not persisted; the caller retries. 5xx is persisted to sent/<mailbox>/ with delivery_status = "failed" and the reason in delivery_details. No DSN is generated. The trade is reliability for visibility — the calling agent always knows the outcome.

aimx send refuses root: it is a thin UDS client that doesn’t need privilege, so it rejects it as belt-and-suspenders against an accidental sudo aimx send.

Unix domain socket

/run/aimx/aimx.sock is bound at mode 0666. Any local user can connect().

This is deliberate. The DKIM key never leaves the daemon, and every per-mailbox action is owner-gated server-side — the daemon resolves the caller’s uid via SO_PEERCRED and runs auth::authorize before any state work. Given those two boundaries, tightening the socket mode buys very little and costs real ergonomics: agents launched by humans run under unprivileged uids, and locking the socket to root would force every aimx send through sudo and every MCP client to spawn a privileged helper.

Alternatives considered and rejected:

  • Socket mode 0660 with a shared group. Fragile across reinstalls and user-management flows. Forgetting to add a new user to the group silently breaks their agent.
  • A userland auth handshake. Adds failure modes; the kernel already supplies peer credentials via SO_PEERCRED. A userland handshake on top would be redundant.
  • sudo for aimx send. Gates the common case (an agent sends mail) on root, defeating the unprivileged agent.

A malicious local user can send mail as a mailbox they own — which they could also do via the agent’s shell session, so gating the socket wouldn’t have stopped them anyway. What they cannot do: forge mail under a domain you don’t own, mutate or hook a mailbox owned by another uid, run hooks as anyone but the owner, run arbitrary commands as root, or read the DKIM key. That is the boundary the design defends.

The accepted verbs:

  • SEND — submit an unsigned RFC 5322 message for DKIM signing and MX delivery.
  • MARK-READ / MARK-UNREAD — rewrite the read field under a per-mailbox lock.
  • MAILBOX-CREATE / MAILBOX-DELETE — add or remove a configured mailbox; hot-swap Arc<Config>.
  • HOOK-CREATE — create a hook with a raw argv on a mailbox the caller owns. Stamped origin = "mcp".
  • HOOK-DELETE — remove an existing hook, subject to origin protection.
  • HOOK-LIST / MAILBOX-LIST — read-only enumeration filtered to owned mailboxes / hooks; root sees all.
  • VERSION — daemon build metadata.

The daemon parses a tagged Request enum with #[serde(deny_unknown_fields)]. There is no verb that writes raw shell strings to config.toml, runs subprocesses under arbitrary UIDs, reads the DKIM key, or reloads config from a caller-chosen path. Combined with the 30 s per-connection timeout and 25 MB body cap, the socket is small and auditable.

Hooks and MCP

Hooks are the one piece of AIMX that runs external commands. Every hook is a raw argv attached to a mailbox; there is no template layer and no per-hook run_as. The trust boundary is the mailbox owner. See Hooks & Trust for the full model.

Hooks always exec as the mailbox owner (the daemon setuids before exec); cmd[0] must be an absolute path; argv is execvp’d directly. The trust gate fires on_receive hooks only when email.trusted == "true" or the hook sets fire_on_untrusted = true. after_send hooks have no gate.

Every hook carries an origin tag: operator (hand-edited or via CLI direct-write fallback) or mcp (via the UDS). MCP-origin hooks can be deleted via MCP or CLI; operator-origin hooks can only be deleted via CLI. An agent cannot dismantle a policy hook the operator installed.

aimx mcp is a stdio MCP server launched by the client, running as the agent’s own user. Every tool routes through the daemon UDS, which authorizes via SO_PEERCRED against the target mailbox’s owner_uid. The MCP process never reads /etc/aimx/, never touches the DKIM key, and cannot mutate a mailbox it does not own. It cannot change which uid a hook runs as (no run_as knob exists) or forge an origin tag (the daemon stamps mcp itself). See MCP Server: Per-user authorization.

Explicitly out of scope

These are not on a roadmap. They are non-goals.

  1. Multi-user mailbox ACLs beyond owner/root. Each mailbox is owned by one Linux user at mode 0700; there is no shared-group readership and no fine-grained per-other-user ACL layer. Use Postfix or Stalwart if you need that.
  2. SMTP AUTH / submission port 587. AIMX is not a submission MTA. Its outbound path is UDS → DKIM-sign → direct MX.
  3. IMAP / POP3 / webmail. Agents read .md files via MCP or the filesystem. There is no mailbox server protocol.
  4. Reverse DNS (PTR). Configured at your VPS provider, not by aimx setup. Optional but improves deliverability.
  5. Socket-mode-based UDS gating. The socket is 0666 on purpose; per-verb authorization runs server-side on every request via SO_PEERCRED + auth::authorize. We don’t tighten the socket mode itself.
  6. Spam filtering, greylisting, inbound rate limits. Front AIMX with a firewall or small MTA if you need these.
  7. Retry queues, DSN generation. Failures are agent-visible in real time, not queued behind the scenes.
  8. Detailed audit logging. Every hook fire emits one structured line via tracing. That is the log. There is no separate audit file.

Hardening

Knobs you can tighten beyond the defaults:

  • Firewall port 25 inbound from known-bad netblocks. AIMX does not do this itself.
  • Run on a dedicated host if local users cannot be trusted to sign mail as any configured mailbox.
  • Rotate the DKIM selector periodically. See How do I rotate the DKIM key without a delivery gap?.
  • Keep the per-mailbox hook list minimal. Review with aimx hooks list --mailbox <name> before adding more.
  • Review hook-fire logs after a new hook lands: journalctl -u aimx | grep hook_name=<name>.
  • Switch trust = "verified" and populate trusted_senders once you know which senders should trigger agents. Default "none" is safe but silent.
  • Pick the mailbox owner deliberately. Hooks always run as mailbox.owner_uid. Pick the owner that matches what the hook needs to touch, not the user that’s most convenient.

Ready to try AIMX?

One command, one box, one inbox.

Get started