Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c651b00d08 | |||
| 8ed10dd503 | |||
| 2140d9e173 | |||
| 64ff32ab29 | |||
| 7039371f70 | |||
| e1b4ec38e5 |
+26
-10
@@ -33,8 +33,9 @@ This manual is for **using and administering** `emcli`. It assumes you have the
|
|||||||
## 1. Key concepts
|
## 1. Key concepts
|
||||||
|
|
||||||
**Two kinds of commands.**
|
**Two kinds of commands.**
|
||||||
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY`
|
- **Admin commands** (`init`, `account add/edit/remove`, `whitelist`, `config`, `audit`) require
|
||||||
and are for *you*, the human. They print human-readable text or open an interactive form.
|
`EMCLI_ADMIN_KEY` and are for *you*, the human. They print human-readable text or open an
|
||||||
|
interactive form. (`account list` is the one exception — it is also an agent command; see below.)
|
||||||
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`, `doctor`) require `EMCLI_KEY` (or
|
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`, `doctor`) require `EMCLI_KEY` (or
|
||||||
`EMCLI_ADMIN_KEY` as a superset) and are for the *agent*. They print one line of JSON and
|
`EMCLI_ADMIN_KEY` as a superset) and are for the *agent*. They print one line of JSON and
|
||||||
nothing else, so a program can consume them reliably. (`doctor` prints human-readable text but
|
nothing else, so a program can consume them reliably. (`doctor` prints human-readable text but
|
||||||
@@ -124,8 +125,12 @@ DEK for an admin command, even if it somehow knows the agent key.
|
|||||||
|
|
||||||
| Command | Role required |
|
| Command | Role required |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `list`, `get`, `search`, `ack`, `send`, `doctor` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
|
| `list`, `get`, `search`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
|
||||||
| `account`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
|
| `account add/edit/remove`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
|
||||||
|
|
||||||
|
`account list` is dual-role: with the admin key it prints the full `NAME MODE IMAP USER` table;
|
||||||
|
with only `EMCLI_KEY` (an agent) it prints a JSON envelope exposing just `name`, `from`, and
|
||||||
|
`can_send` — no host or login username.
|
||||||
| `init` | Both keys required (writes both wrap slots) |
|
| `init` | Both keys required (writes both wrap slots) |
|
||||||
|
|
||||||
### Agent launcher guidance
|
### Agent launcher guidance
|
||||||
@@ -261,9 +266,15 @@ emcli account edit --name work --mode RW --smtp-host smtp.example.com --smtp-por
|
|||||||
emcli account edit --name gmail --password 'new-app-password' # rotate the app password
|
emcli account edit --name gmail --password 'new-app-password' # rotate the app password
|
||||||
```
|
```
|
||||||
|
|
||||||
> Note: the flag form of `account edit` covers connection/auth fields and `--subject-regex`. To
|
```bash
|
||||||
> toggle whitelists or `process-backlog`, use the interactive form (`emcli account edit --name X`
|
emcli account edit --name work --from 'Work Team <you@yourdomain.com>' # set the send-as address
|
||||||
> with no other flags), or the `whitelist` commands in section 6.
|
emcli account edit --name work --from '' # clear it (revert to username)
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: the flag form of `account edit` covers connection/auth fields, `--from`, and
|
||||||
|
> `--subject-regex`. Passing `--from ''` clears the send-as address so mail falls back to the login
|
||||||
|
> username. To toggle whitelists or `process-backlog`, use the interactive form (`emcli account edit
|
||||||
|
> --name X` with no other flags), or the `whitelist` commands in section 6.
|
||||||
|
|
||||||
**Remove an account** (requires `--yes`):
|
**Remove an account** (requires `--yes`):
|
||||||
|
|
||||||
@@ -421,6 +432,11 @@ emcli send --account gmail --to a@x.com --subject "Re: Hi" --body "thanks" \
|
|||||||
client. The source is subject to the inbound whitelist — you can't reply to mail you aren't allowed
|
client. The source is subject to the inbound whitelist — you can't reply to mail you aren't allowed
|
||||||
to see.
|
to see.
|
||||||
|
|
||||||
|
The message's `From:` is the account's send-as address (`--from`, set on `account add`/`edit`); if
|
||||||
|
none is configured it falls back to the login username. A display-name address like
|
||||||
|
`Steve Cliff <me@example.com>` shows the name in the recipient's client while the bare address is
|
||||||
|
used for the SMTP envelope.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. The JSON envelope
|
## 8. The JSON envelope
|
||||||
@@ -541,8 +557,8 @@ base64-encoded 32-byte key (section 2). Agent commands (`list`, `get`, `search`,
|
|||||||
`doctor`) need this key.
|
`doctor`) need this key.
|
||||||
|
|
||||||
**"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2).
|
**"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2).
|
||||||
Admin commands (`account`, `whitelist`, `config`, `audit`, `init`) need this key; `EMCLI_KEY`
|
Admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) need this key;
|
||||||
alone is not enough for them.
|
`EMCLI_KEY` alone is not enough for them. (`account list` is the exception — an agent can run it.)
|
||||||
|
|
||||||
**A command fails to decrypt / wrong key.** The key doesn't match the one used when the database
|
**A command fails to decrypt / wrong key.** The key doesn't match the one used when the database
|
||||||
was initialised. Restore the original key, or re-run `emcli init` (idempotent — it won't regenerate
|
was initialised. Restore the original key, or re-run `emcli init` (idempotent — it won't regenerate
|
||||||
@@ -583,7 +599,7 @@ emcli <command> --help # usage and flags for one command
|
|||||||
# Admin (requires EMCLI_ADMIN_KEY)
|
# Admin (requires EMCLI_ADMIN_KEY)
|
||||||
emcli init # create DB + add first account (form)
|
emcli init # create DB + add first account (form)
|
||||||
emcli account add [flags | none for form] # add an account
|
emcli account add [flags | none for form] # add an account
|
||||||
emcli account list # list accounts (no secrets)
|
emcli account list # full table (admin) / name+from+can_send JSON (agent)
|
||||||
emcli account edit --name N [flags | none for form] # change an account
|
emcli account edit --name N [flags | none for form] # change an account
|
||||||
emcli account remove --name N --yes # delete an account
|
emcli account remove --name N --yes # delete an account
|
||||||
emcli whitelist in|out add|remove|list --account N [--address A]
|
emcli whitelist in|out add|remove|list --account N [--address A]
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
# Agent-readable `account list` Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let an agent holding only `EMCLI_KEY` run `emcli account list` and get a reduced JSON view (name, from, can_send), while admin keeps the full text table and `account add/edit/remove` stay admin-only.
|
||||||
|
|
||||||
|
**Architecture:** Make `commandRole` subcommand-aware so `account list` routes to the agent role; branch the `list` renderer on whether the admin key is present (admin → existing text table; agent → standard `Success` JSON envelope). No schema change; `ListAccounts` already avoids decrypting secrets.
|
||||||
|
|
||||||
|
**Tech Stack:** Go, standard library (`flag`, `encoding/json`), existing `internal/cli` envelope helpers and `internal/crypto` key loaders.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Agent output is the existing JSON envelope shape: `{"error":bool,"error_detail":{...},"data":{...}}` via `Success(...)` / `Failure(...)` in `internal/cli/envelope.go`.
|
||||||
|
- Admin `account list` output stays byte-for-byte the current human-readable table (`NAME MODE IMAP USER`).
|
||||||
|
- The agent (reduced) view exposes only `name`, `from`, `can_send` — never the IMAP host/port or login username.
|
||||||
|
- `from = Account.SendFrom()` (explicit `FromAddress`, else `Username`). `can_send = (Mode == "RW")`.
|
||||||
|
- `account add/edit/remove` remain admin-only (hard-require `EMCLI_ADMIN_KEY`, no fallback).
|
||||||
|
- Privilege detection: a caller is "admin" iff `crypto.AdminKeyFromEnv()` returns no error.
|
||||||
|
- Spec: `docs/superpowers/specs/2026-06-23-agent-account-list-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Route `account list` to the agent role and render by privilege
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/cli/run.go` (`commandRole`, its call site in `Run`)
|
||||||
|
- Modify: `internal/cli/admin.go` (the `list` branch of `runAccount`; add `crypto` import)
|
||||||
|
- Modify: `internal/cli/role_test.go` (`TestCommandRole`)
|
||||||
|
- Modify: `internal/cli/security_invariant_test.go` (refused-commands set)
|
||||||
|
- Create/Test: `internal/cli/account_list_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `store.Account.SendFrom() string`, `store.Account.Mode string`, `store.Store.ListAccounts() ([]store.Account, error)`, `crypto.AdminKeyFromEnv() ([]byte, error)`, `Success(map[string]any) Envelope`, `Failure(code, msg string) Envelope`, `Envelope.Write(io.Writer) error`, test helpers `adminEnv(t)`, `run(t, args...)`.
|
||||||
|
- Produces: `commandRole(args []string) store.Role` (signature changes from `commandRole(cmd string)`). Agent `account list` emits `{"data":{"accounts":[{"name":string,"from":string,"can_send":bool}]}}`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite `TestCommandRole` for the new signature and subcommand routing**
|
||||||
|
|
||||||
|
Replace the body of `TestCommandRole` in `internal/cli/role_test.go` with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestCommandRole(t *testing.T) {
|
||||||
|
adminCmds := [][]string{
|
||||||
|
{"whitelist"}, {"config"}, {"audit"},
|
||||||
|
{"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"},
|
||||||
|
}
|
||||||
|
agentCmds := [][]string{
|
||||||
|
{"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"},
|
||||||
|
{"account", "list"},
|
||||||
|
}
|
||||||
|
for _, c := range adminCmds {
|
||||||
|
if commandRole(c) != store.RoleAdmin {
|
||||||
|
t.Errorf("%v should be admin", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range agentCmds {
|
||||||
|
if commandRole(c) != store.RoleAgent {
|
||||||
|
t.Errorf("%v should be agent", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `init` is intentionally absent from this table. `commandRole({"init"})` falls through to the agent arm, but `Run` dispatches `init` via its own bootstrap path (which requires both keys), so its `commandRole` result is never used — asserting a role for it here would be both wrong and meaningless.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the routing test to verify it fails to compile**
|
||||||
|
|
||||||
|
Run: `go test ./internal/cli/ -run TestCommandRole`
|
||||||
|
Expected: FAIL — compile error, `commandRole` takes `string`, called with `[]string`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Make `commandRole` subcommand-aware, update its call site, and fix the security invariant**
|
||||||
|
|
||||||
|
In `internal/cli/run.go`, replace:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func commandRole(cmd string) store.Role {
|
||||||
|
switch cmd {
|
||||||
|
case "account", "whitelist", "config", "audit":
|
||||||
|
return store.RoleAdmin
|
||||||
|
default: // list, get, search, ack, send, doctor
|
||||||
|
return store.RoleAgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func commandRole(args []string) store.Role {
|
||||||
|
switch args[0] {
|
||||||
|
case "account":
|
||||||
|
// account list is a read-only discovery view available to agents;
|
||||||
|
// add/edit/remove mutate config and require admin.
|
||||||
|
if len(args) >= 2 && args[1] == "list" {
|
||||||
|
return store.RoleAgent
|
||||||
|
}
|
||||||
|
return store.RoleAdmin
|
||||||
|
case "whitelist", "config", "audit":
|
||||||
|
return store.RoleAdmin
|
||||||
|
default: // list, get, search, ack, send, doctor
|
||||||
|
return store.RoleAgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `Run`, change the call site from `role := commandRole(cmd)` to `role := commandRole(args)`.
|
||||||
|
|
||||||
|
In `internal/cli/security_invariant_test.go`, in `TestAgentKeyCannotRunAdminCommands`, replace the `adminAttempts` entry `{"account", "list"}` so the set covers a *mutating* account command instead (account list is now allowed for agents):
|
||||||
|
|
||||||
|
```go
|
||||||
|
adminAttempts := [][]string{
|
||||||
|
{"account", "add", "--name", "x", "--imap-host", "h", "--username", "u@x.com"},
|
||||||
|
{"config", "set", "audit_retention_days", "30"},
|
||||||
|
{"audit"},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the cli package tests to verify routing + invariant pass**
|
||||||
|
|
||||||
|
Run: `go test ./internal/cli/ -run 'TestCommandRole|TestAgentKeyCannotRunAdminCommands'`
|
||||||
|
Expected: PASS (both). The agent can no longer be proven to refuse `account list` — that is intended; the invariant now proves `account add` is refused and the DB is unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the rendering tests (agent JSON view + admin text view)**
|
||||||
|
|
||||||
|
Create `internal/cli/account_list_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// With only EMCLI_KEY set, `account list` emits the reduced JSON envelope:
|
||||||
|
// name/from/can_send, and never the IMAP host or login username.
|
||||||
|
func TestAccountListAgentJSONView(t *testing.T) {
|
||||||
|
adminEnv(t) // both keys + initialized temp DB
|
||||||
|
run(t, "account", "add", "--name", "work", "--mode", "RW",
|
||||||
|
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
|
||||||
|
"--username", "login@example.com", "--from", "me@example.com")
|
||||||
|
run(t, "account", "add", "--name", "alerts", "--mode", "RO",
|
||||||
|
"--imap-host", "imap.example.com", "--username", "alerts@example.com")
|
||||||
|
|
||||||
|
// Drop the admin key → caller is an agent.
|
||||||
|
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||||
|
code, out, errOut := run(t, "account", "list")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("agent account list should succeed: code=%d err=%q", code, errOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
var env struct {
|
||||||
|
Error bool `json:"error"`
|
||||||
|
Data struct {
|
||||||
|
Accounts []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
From string `json:"from"`
|
||||||
|
CanSend bool `json:"can_send"`
|
||||||
|
} `json:"accounts"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(out), &env); err != nil {
|
||||||
|
t.Fatalf("output is not the agent envelope: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if env.Error || len(env.Data.Accounts) != 2 {
|
||||||
|
t.Fatalf("want 2 accounts and no error, got %+v", env)
|
||||||
|
}
|
||||||
|
// The reduced view must not leak the IMAP host or the login username.
|
||||||
|
if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") {
|
||||||
|
t.Fatalf("agent view leaked host/username:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := map[string]struct {
|
||||||
|
from string
|
||||||
|
canSend bool
|
||||||
|
}{}
|
||||||
|
for _, a := range env.Data.Accounts {
|
||||||
|
got[a.Name] = struct {
|
||||||
|
from string
|
||||||
|
canSend bool
|
||||||
|
}{a.From, a.CanSend}
|
||||||
|
}
|
||||||
|
if g := got["work"]; g.from != "me@example.com" || !g.canSend {
|
||||||
|
t.Errorf("work: want from=me@example.com can_send=true, got %+v", g)
|
||||||
|
}
|
||||||
|
// alerts has no --from → SendFrom() falls back to the username.
|
||||||
|
if g := got["alerts"]; g.from != "alerts@example.com" || g.canSend {
|
||||||
|
t.Errorf("alerts: want from=alerts@example.com can_send=false, got %+v", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With the admin key present, `account list` stays the full human-readable table.
|
||||||
|
func TestAccountListAdminTextView(t *testing.T) {
|
||||||
|
adminEnv(t)
|
||||||
|
run(t, "account", "add", "--name", "work", "--mode", "RW",
|
||||||
|
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
|
||||||
|
"--username", "login@example.com", "--from", "me@example.com")
|
||||||
|
|
||||||
|
code, out, _ := run(t, "account", "list")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("admin account list failed: code=%d", code)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"NAME", "MODE", "IMAP", "USER", "imap.example.com:993", "login@example.com"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("admin view missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, `"accounts"`) {
|
||||||
|
t.Fatalf("admin view should be text, not JSON:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the rendering tests to verify the agent view fails**
|
||||||
|
|
||||||
|
Run: `go test ./internal/cli/ -run 'TestAccountListAgentJSONView|TestAccountListAdminTextView'`
|
||||||
|
Expected: `TestAccountListAdminTextView` PASS (already text); `TestAccountListAgentJSONView` FAIL — output is still the text table, so `json.Unmarshal` errors.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Split the `list` branch by privilege in `runAccount`**
|
||||||
|
|
||||||
|
In `internal/cli/admin.go`, add the crypto import. Change the import block:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||||
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||||
|
"git.dcglab.co.uk/steve/emcli/internal/tui"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the `case "list":` block (currently):
|
||||||
|
|
||||||
|
```go
|
||||||
|
case "list":
|
||||||
|
accs, err := st.ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errOut, "list: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
|
||||||
|
for _, a := range accs {
|
||||||
|
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
|
||||||
|
a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
case "list":
|
||||||
|
// Holding the admin key means the caller is the human admin (full
|
||||||
|
// detail). An agent holds only EMCLI_KEY and gets a reduced JSON view.
|
||||||
|
_, adminErr := crypto.AdminKeyFromEnv()
|
||||||
|
isAdmin := adminErr == nil
|
||||||
|
accs, err := st.ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
if isAdmin {
|
||||||
|
fmt.Fprintf(errOut, "list: %v\n", err)
|
||||||
|
} else {
|
||||||
|
_ = Failure(CodeDB, err.Error()).Write(out)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if !isAdmin {
|
||||||
|
items := make([]map[string]any, 0, len(accs))
|
||||||
|
for _, a := range accs {
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"name": a.Name,
|
||||||
|
"from": a.SendFrom(),
|
||||||
|
"can_send": a.Mode == "RW",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = Success(map[string]any{"accounts": items}).Write(out)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
|
||||||
|
for _, a := range accs {
|
||||||
|
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
|
||||||
|
a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run the full cli package test suite**
|
||||||
|
|
||||||
|
Run: `go test ./internal/cli/`
|
||||||
|
Expected: PASS (all tests, including the two new rendering tests, the routing test, and the security invariant).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Run the whole module to confirm nothing else regressed**
|
||||||
|
|
||||||
|
Run: `go build ./... && go test ./...`
|
||||||
|
Expected: build clean; all packages PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/cli/run.go internal/cli/admin.go internal/cli/role_test.go \
|
||||||
|
internal/cli/security_invariant_test.go internal/cli/account_list_test.go
|
||||||
|
git commit -m "feat(cli): agent-readable account list (reduced JSON view)
|
||||||
|
|
||||||
|
account list now routes to the agent role; an agent (EMCLI_KEY only) gets a
|
||||||
|
JSON envelope of name/from/can_send, while the 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>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Update user and agent documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `USER-MANUAL.md` (command-kinds note, role table, cheatsheet)
|
||||||
|
- Modify: `skills/emcli/SKILL.md` (allowed-commands note, command table, do/don't)
|
||||||
|
- Modify: `skills/emcli/AGENTIC-MANUAL.md` (§4 account discovery)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: behavior shipped in Task 1 (agent `account list` → `{"data":{"accounts":[{name,from,can_send}]}}`).
|
||||||
|
- Produces: docs only; no code interface.
|
||||||
|
|
||||||
|
- [ ] **Step 1: USER-MANUAL — note that `account list` is the one agent-readable admin view**
|
||||||
|
|
||||||
|
In `USER-MANUAL.md`, in the "Two kinds of commands" block, change the Admin bullet (line ~36) from:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY`
|
||||||
|
and are for *you*, the human. They print human-readable text or open an interactive form.
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **Admin commands** (`init`, `account add/edit/remove`, `whitelist`, `config`, `audit`) require
|
||||||
|
`EMCLI_ADMIN_KEY` and are for *you*, the human. They print human-readable text or open an
|
||||||
|
interactive form. (`account list` is the one exception — it is also an agent command; see below.)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: USER-MANUAL — update the role table**
|
||||||
|
|
||||||
|
Replace the role table rows (lines ~127-128):
|
||||||
|
|
||||||
|
```
|
||||||
|
| `list`, `get`, `search`, `ack`, `send`, `doctor` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
|
||||||
|
| `account`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```
|
||||||
|
| `list`, `get`, `search`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
|
||||||
|
| `account add/edit/remove`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
|
||||||
|
|
||||||
|
`account list` is dual-role: with the admin key it prints the full `NAME MODE IMAP USER` table;
|
||||||
|
with only `EMCLI_KEY` (an agent) it prints a JSON envelope exposing just `name`, `from`, and
|
||||||
|
`can_send` — no host or login username.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: USER-MANUAL — annotate the cheatsheet**
|
||||||
|
|
||||||
|
In the cheatsheet (line ~597), change:
|
||||||
|
|
||||||
|
```
|
||||||
|
emcli account list # list accounts (no secrets)
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```
|
||||||
|
emcli account list # full table (admin) / name+from+can_send JSON (agent)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: SKILL.md — carve `account list` out of the forbidden-commands rule**
|
||||||
|
|
||||||
|
In `skills/emcli/SKILL.md`, change the first bullet (lines ~20-24):
|
||||||
|
|
||||||
|
```
|
||||||
|
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`. You are
|
||||||
|
provided only `EMCLI_KEY` (the agent key), which authorises these commands and nothing else.
|
||||||
|
Account setup, passwords, whitelists, and config are the **user's** job (admin commands that
|
||||||
|
require `EMCLI_ADMIN_KEY`) — do not run or suggest running `account`, `whitelist`, `config`,
|
||||||
|
`audit`, or `init`. You have only `EMCLI_KEY` (agent key); `emcli` will refuse admin commands
|
||||||
|
with a privilege error.
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`, and
|
||||||
|
`account list` (to discover accounts). You are provided only `EMCLI_KEY` (the agent key), which
|
||||||
|
authorises these and nothing else. Account *setup* (`account add/edit/remove`), passwords,
|
||||||
|
whitelists, and config are the **user's** job (admin commands that require `EMCLI_ADMIN_KEY`) —
|
||||||
|
do not run or suggest running `account add/edit/remove`, `whitelist`, `config`, `audit`, or
|
||||||
|
`init`. `emcli` will refuse those with a privilege error.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: SKILL.md — add `account list` to the command table**
|
||||||
|
|
||||||
|
In the command table (after the `send` row, line ~122), add:
|
||||||
|
|
||||||
|
```
|
||||||
|
| `emcli account list` | Discover accounts: JSON `name` / `from` / `can_send` per account |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: SKILL.md — fix the "don't" bullet**
|
||||||
|
|
||||||
|
Change (lines ~147-148):
|
||||||
|
|
||||||
|
```
|
||||||
|
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`audit`/`init`) — you have only
|
||||||
|
`EMCLI_KEY` (agent key); `emcli` will refuse admin commands with a privilege error.
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```
|
||||||
|
- ❌ Don't run admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) —
|
||||||
|
you have only `EMCLI_KEY` (agent key); `emcli` will refuse them with a privilege error.
|
||||||
|
(`account list` is allowed — use it to discover accounts.)
|
||||||
|
```
|
||||||
|
|
||||||
|
Also change the ✅ bullet (line ~145) from `Ask the user for the account name; keep bodies plain text.` to:
|
||||||
|
|
||||||
|
```
|
||||||
|
- ✅ Discover accounts with `emcli account list`, or ask the user; keep bodies plain text.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: AGENTIC-MANUAL — document discovery via `account list`**
|
||||||
|
|
||||||
|
In `skills/emcli/AGENTIC-MANUAL.md`, replace the body of `## 4. Find the account(s)` (lines ~88-97):
|
||||||
|
|
||||||
|
```
|
||||||
|
You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use.
|
||||||
|
`emcli doctor` is an agent command (authorised by `EMCLI_KEY`), so you can run it to check that
|
||||||
|
configured accounts connect and authenticate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emcli doctor # all accounts
|
||||||
|
emcli doctor --account gmail
|
||||||
|
```
|
||||||
|
|
||||||
|
Just take the account name from the user and start with the workflow in `SKILL.md`.
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```
|
||||||
|
You refer to an account by name (e.g. `gmail`, `work`). Discover the configured accounts yourself
|
||||||
|
with `emcli account list` (an agent command authorised by `EMCLI_KEY`); it prints a JSON envelope
|
||||||
|
with one entry per account:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emcli account list
|
||||||
|
# {"error":false,"error_detail":{},"data":{"accounts":[
|
||||||
|
# {"name":"gmail","from":"me@gmail.com","can_send":true},
|
||||||
|
# {"name":"alerts","from":"alerts@x.com","can_send":false}]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` is what you pass to `--account`; `from` is the send-as identity; `can_send` is false for
|
||||||
|
read-only accounts (they reject `send`). If unsure which to use, ask the user. `emcli doctor`
|
||||||
|
(also an agent command) checks that accounts connect and authenticate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emcli doctor # all accounts
|
||||||
|
emcli doctor --account gmail
|
||||||
|
```
|
||||||
|
|
||||||
|
Then start with the workflow in `SKILL.md`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Sanity-check the docs render and reference reality**
|
||||||
|
|
||||||
|
Run: `grep -n "account list" USER-MANUAL.md skills/emcli/SKILL.md skills/emcli/AGENTIC-MANUAL.md`
|
||||||
|
Expected: each file shows the updated `account list` references; no remaining text claims the agent cannot run `account list`.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add USER-MANUAL.md skills/emcli/SKILL.md skills/emcli/AGENTIC-MANUAL.md
|
||||||
|
git commit -m "docs: agent can discover accounts via account list
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- Run all Go commands from the repo root (`/home/steve/src/emcli`).
|
||||||
|
- The two intentional red states are Step 2 (compile error) and Step 6 (agent JSON test) in Task 1. Every other test run must be green.
|
||||||
|
- Do not change the admin text table or add columns to it — admin output must stay identical.
|
||||||
|
- `adminEnv(t)` and `run(t, ...)` live in `internal/cli/admin_test.go`; `b64Key`/`b64AgentKey` in `internal/cli/run_test.go`. No new helpers are needed.
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// With only EMCLI_KEY set, `account list` emits the reduced JSON envelope:
|
||||||
|
// name/from/can_send, and never the IMAP host or login username.
|
||||||
|
func TestAccountListAgentJSONView(t *testing.T) {
|
||||||
|
adminEnv(t) // both keys + initialized temp DB
|
||||||
|
run(t, "account", "add", "--name", "work", "--mode", "RW",
|
||||||
|
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
|
||||||
|
"--username", "login@example.com", "--from", "me@example.com")
|
||||||
|
run(t, "account", "add", "--name", "alerts", "--mode", "RO",
|
||||||
|
"--imap-host", "imap.example.com", "--username", "alerts@example.com")
|
||||||
|
|
||||||
|
// Drop the admin key → caller is an agent.
|
||||||
|
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||||
|
code, out, errOut := run(t, "account", "list")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("agent account list should succeed: code=%d err=%q", code, errOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
var env struct {
|
||||||
|
Error bool `json:"error"`
|
||||||
|
Data struct {
|
||||||
|
Accounts []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
From string `json:"from"`
|
||||||
|
CanSend bool `json:"can_send"`
|
||||||
|
} `json:"accounts"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(out), &env); err != nil {
|
||||||
|
t.Fatalf("output is not the agent envelope: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if env.Error || len(env.Data.Accounts) != 2 {
|
||||||
|
t.Fatalf("want 2 accounts and no error, got %+v", env)
|
||||||
|
}
|
||||||
|
// The reduced view must not leak the IMAP host or the login username.
|
||||||
|
if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") {
|
||||||
|
t.Fatalf("agent view leaked host/username:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := map[string]struct {
|
||||||
|
from string
|
||||||
|
canSend bool
|
||||||
|
}{}
|
||||||
|
for _, a := range env.Data.Accounts {
|
||||||
|
got[a.Name] = struct {
|
||||||
|
from string
|
||||||
|
canSend bool
|
||||||
|
}{a.From, a.CanSend}
|
||||||
|
}
|
||||||
|
if g := got["work"]; g.from != "me@example.com" || !g.canSend {
|
||||||
|
t.Errorf("work: want from=me@example.com can_send=true, got %+v", g)
|
||||||
|
}
|
||||||
|
// alerts has no --from → SendFrom() falls back to the username.
|
||||||
|
if g := got["alerts"]; g.from != "alerts@example.com" || g.canSend {
|
||||||
|
t.Errorf("alerts: want from=alerts@example.com can_send=false, got %+v", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With the admin key present, `account list` stays the full human-readable table.
|
||||||
|
func TestAccountListAdminTextView(t *testing.T) {
|
||||||
|
adminEnv(t)
|
||||||
|
run(t, "account", "add", "--name", "work", "--mode", "RW",
|
||||||
|
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
|
||||||
|
"--username", "login@example.com", "--from", "me@example.com")
|
||||||
|
|
||||||
|
code, out, _ := run(t, "account", "list")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("admin account list failed: code=%d", code)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"NAME", "MODE", "IMAP", "USER", "imap.example.com:993", "login@example.com"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("admin view missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, `"accounts"`) {
|
||||||
|
t.Fatalf("admin view should be text, not JSON:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||||
"git.dcglab.co.uk/steve/emcli/internal/tui"
|
"git.dcglab.co.uk/steve/emcli/internal/tui"
|
||||||
)
|
)
|
||||||
@@ -23,7 +24,14 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
|||||||
sub, rest := args[0], args[1:]
|
sub, rest := args[0], args[1:]
|
||||||
st, err := openStore(role)
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// account list is an agent command (a JSON consumer), so its
|
||||||
|
// open/key failures are emitted as an envelope, like the other agent
|
||||||
|
// commands; the admin subcommands stay human-readable.
|
||||||
|
if sub == "list" {
|
||||||
|
_ = Failure(CodeConfig, err.Error()).Write(out)
|
||||||
|
} else {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
|
}
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
@@ -171,11 +179,31 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
|||||||
fmt.Fprintf(out, "account %q removed\n", *name)
|
fmt.Fprintf(out, "account %q removed\n", *name)
|
||||||
return 0
|
return 0
|
||||||
case "list":
|
case "list":
|
||||||
|
// Holding the admin key means the caller is the human admin (full
|
||||||
|
// detail). An agent holds only EMCLI_KEY and gets a reduced JSON view.
|
||||||
|
_, adminErr := crypto.AdminKeyFromEnv()
|
||||||
|
isAdmin := adminErr == nil
|
||||||
accs, err := st.ListAccounts()
|
accs, err := st.ListAccounts()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isAdmin {
|
||||||
fmt.Fprintf(errOut, "list: %v\n", err)
|
fmt.Fprintf(errOut, "list: %v\n", err)
|
||||||
|
} else {
|
||||||
|
_ = Failure(CodeDB, err.Error()).Write(out)
|
||||||
|
}
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
if !isAdmin {
|
||||||
|
items := make([]map[string]any, 0, len(accs))
|
||||||
|
for _, a := range accs {
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"name": a.Name,
|
||||||
|
"from": a.SendFrom(),
|
||||||
|
"can_send": a.Mode == "RW",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = Success(map[string]any{"accounts": items}).Write(out)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
|
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
|
||||||
for _, a := range accs {
|
for _, a := range accs {
|
||||||
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
|
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
|
||||||
|
|||||||
@@ -9,16 +9,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCommandRole(t *testing.T) {
|
func TestCommandRole(t *testing.T) {
|
||||||
admin := []string{"account", "whitelist", "config", "audit"}
|
adminCmds := [][]string{
|
||||||
agent := []string{"list", "get", "search", "ack", "send", "doctor"}
|
{"whitelist"}, {"config"}, {"audit"},
|
||||||
for _, c := range admin {
|
{"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"},
|
||||||
|
}
|
||||||
|
agentCmds := [][]string{
|
||||||
|
{"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"},
|
||||||
|
{"account", "list"},
|
||||||
|
}
|
||||||
|
for _, c := range adminCmds {
|
||||||
if commandRole(c) != store.RoleAdmin {
|
if commandRole(c) != store.RoleAdmin {
|
||||||
t.Errorf("%s should be admin", c)
|
t.Errorf("%v should be admin", c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, c := range agent {
|
for _, c := range agentCmds {
|
||||||
if commandRole(c) != store.RoleAgent {
|
if commandRole(c) != store.RoleAgent {
|
||||||
t.Errorf("%s should be agent", c)
|
t.Errorf("%v should be agent", c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-4
@@ -27,9 +27,16 @@ func realMailer(acc store.Account) (Mailer, error) {
|
|||||||
|
|
||||||
// commandRole maps a command to the privilege it requires. Admin commands
|
// commandRole maps a command to the privilege it requires. Admin commands
|
||||||
// mutate configuration or expose oversight data; everything else is agent.
|
// mutate configuration or expose oversight data; everything else is agent.
|
||||||
func commandRole(cmd string) store.Role {
|
func commandRole(args []string) store.Role {
|
||||||
switch cmd {
|
switch args[0] {
|
||||||
case "account", "whitelist", "config", "audit":
|
case "account":
|
||||||
|
// account list is a read-only discovery view available to agents;
|
||||||
|
// add/edit/remove mutate config and require admin.
|
||||||
|
if len(args) >= 2 && args[1] == "list" {
|
||||||
|
return store.RoleAgent
|
||||||
|
}
|
||||||
|
return store.RoleAdmin
|
||||||
|
case "whitelist", "config", "audit":
|
||||||
return store.RoleAdmin
|
return store.RoleAdmin
|
||||||
default: // list, get, search, ack, send, doctor
|
default: // list, get, search, ack, send, doctor
|
||||||
return store.RoleAgent
|
return store.RoleAgent
|
||||||
@@ -135,7 +142,7 @@ func Run(args []string, out, errOut io.Writer) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
cmd, rest := args[0], args[1:]
|
cmd, rest := args[0], args[1:]
|
||||||
role := commandRole(cmd)
|
role := commandRole(args)
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "list", "get", "search", "ack":
|
case "list", "get", "search", "ack":
|
||||||
return runAgent(cmd, rest, role, out, errOut)
|
return runAgent(cmd, rest, role, out, errOut)
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ func TestRunUnknownCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) {
|
func TestAccountListMissingKeyFailsClosedAsJSON(t *testing.T) {
|
||||||
// `account list` with no DB key should fail closed with a usage/config error,
|
// `account list` is an agent command: with no DB key it fails closed before
|
||||||
// proving the key check happens before any DB work.
|
// any DB work, emitting a JSON config-error envelope that names EMCLI_KEY.
|
||||||
var out, errOut bytes.Buffer
|
var out, errOut bytes.Buffer
|
||||||
t.Setenv("EMCLI_KEY", "")
|
t.Setenv("EMCLI_KEY", "")
|
||||||
t.Setenv("EMCLI_ADMIN_KEY", "")
|
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||||
@@ -28,8 +28,15 @@ func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) {
|
|||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("missing EMCLI_KEY must fail")
|
t.Fatal("missing EMCLI_KEY must fail")
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String()+errOut.String(), "EMCLI_ADMIN_KEY") {
|
var env map[string]any
|
||||||
t.Fatalf("should mention EMCLI_ADMIN_KEY, got out=%q err=%q", out.String(), errOut.String())
|
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||||
|
t.Fatalf("agent account list error must be JSON, got out=%q err=%q", out.String(), errOut.String())
|
||||||
|
}
|
||||||
|
if env["error"] != true {
|
||||||
|
t.Fatalf("want error envelope: %v", env)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "EMCLI_KEY") {
|
||||||
|
t.Fatalf("should name the missing EMCLI_KEY, got %q", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func TestAgentKeyCannotRunAdminCommands(t *testing.T) {
|
|||||||
|
|
||||||
before := dbBytes(t, db)
|
before := dbBytes(t, db)
|
||||||
adminAttempts := [][]string{
|
adminAttempts := [][]string{
|
||||||
{"account", "list"},
|
{"account", "add", "--name", "x", "--imap-host", "h", "--username", "u@x.com"},
|
||||||
{"config", "set", "audit_retention_days", "30"},
|
{"config", "set", "audit_retention_days", "30"},
|
||||||
{"audit"},
|
{"audit"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ checksum, makes it executable in `~/.local/bin` (ensure that's on your PATH), an
|
|||||||
|
|
||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `EMCLI_VERSION` | `v0.5.0` | Release tag to fetch |
|
| `EMCLI_VERSION` | `v0.5.1` | Release tag to fetch |
|
||||||
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
|
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
|
||||||
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
|
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
|
||||||
|
|
||||||
@@ -85,16 +85,27 @@ with both keys exported. Account creation and other admin is the human's job —
|
|||||||
|
|
||||||
## 4. Find the account(s)
|
## 4. Find the account(s)
|
||||||
|
|
||||||
You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use.
|
You refer to an account by name (e.g. `gmail`, `work`). Discover the configured accounts yourself
|
||||||
`emcli doctor` is an agent command (authorised by `EMCLI_KEY`), so you can run it to check that
|
with `emcli account list` (an agent command authorised by `EMCLI_KEY`); it prints a JSON envelope
|
||||||
configured accounts connect and authenticate:
|
with one entry per account:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emcli account list
|
||||||
|
# {"error":false,"error_detail":{},"data":{"accounts":[
|
||||||
|
# {"name":"gmail","from":"me@gmail.com","can_send":true},
|
||||||
|
# {"name":"alerts","from":"alerts@x.com","can_send":false}]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` is what you pass to `--account`; `from` is the send-as identity; `can_send` is false for
|
||||||
|
read-only accounts (they reject `send`). If unsure which to use, ask the user. `emcli doctor`
|
||||||
|
(also an agent command) checks that accounts connect and authenticate:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
emcli doctor # all accounts
|
emcli doctor # all accounts
|
||||||
emcli doctor --account gmail
|
emcli doctor --account gmail
|
||||||
```
|
```
|
||||||
|
|
||||||
Just take the account name from the user and start with the workflow in `SKILL.md`.
|
Then start with the workflow in `SKILL.md`.
|
||||||
|
|
||||||
## You're set up
|
## You're set up
|
||||||
|
|
||||||
|
|||||||
+11
-9
@@ -17,12 +17,12 @@ sets its exit code to match.
|
|||||||
|
|
||||||
## Security model — read this first
|
## Security model — read this first
|
||||||
|
|
||||||
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`. You are
|
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`, and
|
||||||
provided only `EMCLI_KEY` (the agent key), which authorises these commands and nothing else.
|
`account list` (to discover accounts). You are provided only `EMCLI_KEY` (the agent key), which
|
||||||
Account setup, passwords, whitelists, and config are the **user's** job (admin commands that
|
authorises these and nothing else. Account *setup* (`account add/edit/remove`), passwords,
|
||||||
require `EMCLI_ADMIN_KEY`) — do not run or suggest running `account`, `whitelist`, `config`,
|
whitelists, and config are the **user's** job (admin commands that require `EMCLI_ADMIN_KEY`) —
|
||||||
`audit`, or `init`. You have only `EMCLI_KEY` (agent key); `emcli` will refuse admin commands
|
do not run or suggest running `account add/edit/remove`, `whitelist`, `config`, `audit`, or
|
||||||
with a privilege error.
|
`init`. `emcli` will refuse those with a privilege error.
|
||||||
- **Never touch the secret key.** `EMCLI_KEY` is supplied in the environment by whoever launched
|
- **Never touch the secret key.** `EMCLI_KEY` is supplied in the environment by whoever launched
|
||||||
you. Do not read it, print it, log it, pass it as an argument, or try to generate one. If it is
|
you. Do not read it, print it, log it, pass it as an argument, or try to generate one. If it is
|
||||||
missing, stop and tell the user (see "Files & first run").
|
missing, stop and tell the user (see "Files & first run").
|
||||||
@@ -120,6 +120,7 @@ read-only — tell the user; do not attempt another account without their say-so
|
|||||||
| `emcli search --account A [--folder F] [--from X] [--subject-contains X] [--text X] [--since-date D] [--before-date D]` | Server-side search |
|
| `emcli search --account A [--folder F] [--from X] [--subject-contains X] [--text X] [--since-date D] [--before-date D]` | Server-side search |
|
||||||
| `emcli ack --account A [--folder F] --uid-list U1,U2` | Mark message(s) processed |
|
| `emcli ack --account A [--folder F] --uid-list U1,U2` | Mark message(s) processed |
|
||||||
| `emcli send --account A --to X [--cc X] [--bcc X] --subject S --body B [--attach P]… [--reply-to U]` | Send / reply |
|
| `emcli send --account A --to X [--cc X] [--bcc X] --subject S --body B [--attach P]… [--reply-to U]` | Send / reply |
|
||||||
|
| `emcli account list` | Discover accounts: JSON `name` / `from` / `can_send` per account |
|
||||||
|
|
||||||
Defaults: `--folder INBOX`, `--limit 50` (max 500). Dates are RFC 3339 (e.g.
|
Defaults: `--folder INBOX`, `--limit 50` (max 500). Dates are RFC 3339 (e.g.
|
||||||
`2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output.
|
`2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output.
|
||||||
@@ -142,8 +143,9 @@ The user configures these; you cannot change them and shouldn't try.
|
|||||||
|
|
||||||
- ✅ Check `error` on every call; report `policy`/`not_found`/`auth` outcomes plainly to the user.
|
- ✅ Check `error` on every call; report `policy`/`not_found`/`auth` outcomes plainly to the user.
|
||||||
- ✅ `get` to read, then `ack` only after you've truly processed a message.
|
- ✅ `get` to read, then `ack` only after you've truly processed a message.
|
||||||
- ✅ Ask the user for the account name; keep bodies plain text.
|
- ✅ Discover accounts with `emcli account list`, or ask the user; keep bodies plain text.
|
||||||
- ❌ Don't read, print, or invent `EMCLI_KEY` or any password.
|
- ❌ Don't read, print, or invent `EMCLI_KEY` or any password.
|
||||||
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`audit`/`init`) — you have only
|
- ❌ Don't run admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) —
|
||||||
`EMCLI_KEY` (agent key); `emcli` will refuse admin commands with a privilege error.
|
you have only `EMCLI_KEY` (agent key); `emcli` will refuse them with a privilege error.
|
||||||
|
(`account list` is allowed — use it to discover accounts.)
|
||||||
- ❌ Don't treat a blocked send or filtered message as a bug to route around — it's the user's policy.
|
- ❌ Don't treat a blocked send or filtered message as a bug to route around — it's the user's policy.
|
||||||
|
|||||||
@@ -7,17 +7,17 @@
|
|||||||
# bash install.sh
|
# bash install.sh
|
||||||
#
|
#
|
||||||
# Environment overrides:
|
# Environment overrides:
|
||||||
# EMCLI_VERSION release tag to fetch (default: v0.5.0)
|
# EMCLI_VERSION release tag to fetch (default: v0.5.1)
|
||||||
# EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli)
|
# EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli)
|
||||||
# EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
|
# EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
|
||||||
#
|
#
|
||||||
# Release assets follow this naming scheme:
|
# Release assets follow this naming scheme:
|
||||||
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.5.0_linux_amd64
|
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.5.1_linux_amd64
|
||||||
# checksums.txt (sha256, one "<sum> <asset>" line per asset)
|
# checksums.txt (sha256, one "<sum> <asset>" line per asset)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${EMCLI_VERSION:-v0.5.0}"
|
VERSION="${EMCLI_VERSION:-v0.5.1}"
|
||||||
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
|
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
|
||||||
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"
|
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user