docs(spec): agent-readable account list (reduced JSON view)
Let an agent holding only EMCLI_KEY discover accounts via `account list`, exposing name/from/can_send (not host/username); admin keeps the full text table. account add/edit/remove stay admin-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
# Agent-readable `account list` — design
|
||||
|
||||
**Date:** 2026-06-23
|
||||
**Status:** Approved (brainstorm), ready for implementation plan
|
||||
**Author:** Steve + Claude
|
||||
|
||||
## Problem
|
||||
|
||||
An agent process is launched with only `EMCLI_KEY` (the two-key model — see
|
||||
`2026-06-22-two-key-privilege-design.md`). Every agent command takes
|
||||
`--account NAME`, but the agent has no way to *discover* which accounts exist:
|
||||
`account list` is classified admin-only and is refused under the agent key. So
|
||||
account names must be supplied to the agent out of band, which is brittle and
|
||||
defeats the point of a self-directed agent.
|
||||
|
||||
The two-key spec deliberately gated the whole `account` command to admin because
|
||||
`account add/edit/remove` mutate configuration. But `account list` is read-only
|
||||
and exposes no secrets — `store.ListAccounts` never decrypts the password
|
||||
(`enc_password` is scanned and discarded). Gating *discovery* behind admin is
|
||||
stricter than the threat model requires.
|
||||
|
||||
## Goal
|
||||
|
||||
Let an agent holding only `EMCLI_KEY` run `account list` to discover the
|
||||
accounts it may operate on, while:
|
||||
|
||||
- keeping `account add/edit/remove` admin-only (mutation stays gated);
|
||||
- exposing to the agent only what it needs — **account name, the send-as From
|
||||
address, and whether the account can send** — and *not* the IMAP host/port or
|
||||
login username;
|
||||
- preserving the admin's existing full-detail view unchanged.
|
||||
|
||||
## Constraints / decisions
|
||||
|
||||
Settled during brainstorming:
|
||||
|
||||
1. **Scope is exactly `account list`.** `whitelist list`, `config get`, and
|
||||
`audit list` stay admin-only. `audit` in particular is oversight data and
|
||||
must remain invisible to the agent.
|
||||
2. **Privilege-dependent rendering.** The admin keeps the current full table
|
||||
(`NAME MODE IMAP USER`, human-readable). The agent gets a *reduced* view
|
||||
containing only name, From, and send-capability.
|
||||
3. **Agent output is JSON.** The agent is a machine consumer, so its
|
||||
`account list` emits the standard agent envelope (like `list`/`get`/`search`),
|
||||
not a text table. The admin path stays human-readable text.
|
||||
4. **No secret exposure, no schema change.** `ListAccounts` already avoids
|
||||
decrypting passwords; nothing about the data model changes.
|
||||
|
||||
## Approach
|
||||
|
||||
Reclassify `account list` to the agent role, and branch rendering on the
|
||||
caller's actual privilege (presence of the admin key).
|
||||
|
||||
### Routing (`internal/cli/run.go`)
|
||||
|
||||
`commandRole` becomes subcommand-aware for `account`:
|
||||
|
||||
- `account list` → `RoleAgent`
|
||||
- `account add | edit | remove` (and bare `account`) → `RoleAdmin`
|
||||
- all other commands unchanged.
|
||||
|
||||
`commandRole` currently takes `cmd string`; it changes to take the full
|
||||
`args []string` so it can peek at the `account` subcommand. `Run` passes
|
||||
`args` through. This keeps `commandRole` the single source of truth for the
|
||||
classification table.
|
||||
|
||||
Authorization mechanics are unchanged: `openStore(RoleAgent)` requires
|
||||
`EMCLI_KEY` (falling back to the admin key for a human who holds only that and
|
||||
runs `account list`). `account add/edit/remove` still hard-require
|
||||
`EMCLI_ADMIN_KEY` with no fallback.
|
||||
|
||||
### Rendering (`internal/cli/admin.go`, the `list` branch)
|
||||
|
||||
Determine privilege from the environment: `_, err := crypto.AdminKeyFromEnv()`;
|
||||
`isAdmin := err == nil`. (Holding the admin key *is* being the admin in this
|
||||
trust model. A human with only the admin key still gets the admin view; an agent
|
||||
with only `EMCLI_KEY` gets the reduced view.)
|
||||
|
||||
- **Admin** → existing full table, unchanged:
|
||||
```
|
||||
NAME MODE IMAP USER
|
||||
work RW imap.example.com:993 me@example.com
|
||||
```
|
||||
- **Agent** → JSON envelope to stdout:
|
||||
```json
|
||||
{"error":false,"error_detail":{},"data":{"accounts":[
|
||||
{"name":"work","from":"me@example.com","can_send":true},
|
||||
{"name":"alerts","from":"alerts@example.com","can_send":false}
|
||||
]}}
|
||||
```
|
||||
where `from = Account.SendFrom()` (the configured From address, falling back
|
||||
to the username) and `can_send = (Mode == "RW")` (RW accounts have SMTP
|
||||
configured; RO cannot send). The IMAP host/port and the raw login username are
|
||||
**not** emitted (when From falls back to the username it may coincide with it,
|
||||
which is acceptable — the user asked for From specifically).
|
||||
|
||||
The reduced view reuses the existing `Success(...)` envelope and `Envelope.Write`
|
||||
helper; no new output machinery.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Agent key on `account add/edit/remove` → unchanged:
|
||||
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`, non-zero.
|
||||
- Agent `account list` with neither key set → the agent-command config error
|
||||
(`EMCLI_KEY is not set`), surfaced the same way other agent commands surface a
|
||||
missing key. Because the agent path now emits JSON, a missing-key failure on
|
||||
`account list` is a JSON `Failure(CodeConfig, …)` envelope, consistent with the
|
||||
other agent commands.
|
||||
- DB/list errors on the agent path → `Failure(CodeDB, …)` envelope; on the admin
|
||||
path → existing `list: <err>` text to stderr.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Routing:** extend the `commandRole` table test — `account list` → agent;
|
||||
`account add` / `account edit` / `account remove` → admin.
|
||||
- **Agent view:** with only `EMCLI_KEY`, `account list` exits 0, emits a valid
|
||||
envelope, and the `data.accounts` entries carry `name`/`from`/`can_send` — and
|
||||
the output does **not** contain the IMAP host or the login username.
|
||||
- **Admin view:** with `EMCLI_ADMIN_KEY`, `account list` still prints the full
|
||||
`NAME MODE IMAP USER` table (regression guard).
|
||||
- **`can_send`:** an RW account yields `can_send:true`, an RO account
|
||||
`can_send:false`; `from` reflects `SendFrom()` (explicit From, else username).
|
||||
- **Security invariant (`security_invariant_test.go`):** remove
|
||||
`{"account","list"}` from the refused-commands set (it is now allowed) and
|
||||
replace it with a *mutating* `account add …` attempt, so the "forced agent
|
||||
cannot run admin commands and the DB is byte-for-byte unchanged" invariant
|
||||
still covers the `account` family.
|
||||
|
||||
## Documentation updates
|
||||
|
||||
- **USER-MANUAL:** role/command table — `account list` is agent-readable
|
||||
(reduced JSON view); `account add/edit/remove` remain admin.
|
||||
- **`skills/emcli` (SKILL.md / AGENTIC-MANUAL.md):** document that the agent
|
||||
discovers accounts via `account list`, including the JSON shape
|
||||
(`name`, `from`, `can_send`).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Agent access to `whitelist list`, `config get`, or `audit list`.
|
||||
- Any change to the admin `account list` columns or to the data model.
|
||||
- JSON output for the admin `account list` path (stays human-readable text).
|
||||
Reference in New Issue
Block a user