diff --git a/docs/superpowers/plans/2026-06-22-two-key-privilege.md b/docs/superpowers/plans/2026-06-22-two-key-privilege.md new file mode 100644 index 0000000..b79464f --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-two-key-privilege.md @@ -0,0 +1,855 @@ +# Two-Key Privilege Separation 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:** Enforce the agent/admin trust boundary with two environment keys — `EMCLI_ADMIN_KEY` (all commands) and `EMCLI_KEY` (agent commands only) — so a forced agent holding only `EMCLI_KEY` cannot run admin commands. + +**Architecture:** Envelope encryption. `init` generates a random data-encryption key (DEK) that seals all account secrets exactly as today. The DEK is stored in `settings`, sealed twice: under the admin KEK (`dek_wrap_admin`) and under the agent KEK (`dek_wrap_agent`). Admin commands unwrap the DEK from the admin slot **only** (no agent fallback); agent commands use the agent slot (falling back to the admin slot when only the admin key is present). The store's existing `s.key` field simply becomes the DEK, so `account.go` / mail crypto is untouched. + +**Tech Stack:** Go, `modernc.org/sqlite`, AES-256-GCM (existing `crypto.Seal`/`crypto.Open`), standard `flag` CLI. + +## Global Constraints + +- Module path: `git.dcglab.co.uk/steve/emcli`. Packages under `internal/`. +- Keys are base64-encoded **exactly 32 bytes** (AES-256). Reject anything else. +- Single static CGO-free binary; `go vet ./...` must stay clean; tests pass under `-race`. +- Secrets (keys, passwords, DEK) never appear on stdout, in the JSON envelope, or the audit log. +- Agent commands emit exactly one JSON object on stdout; admin commands print human-readable text (never JSON). +- DEK never written to disk in cleartext; wrap slots stored as base64 text in the `settings` table (`value TEXT NOT NULL`). +- No migration / no schema-version gate — `init` writes wrap slots into a fresh DB (decided in spec). + +**Command → role mapping (single source of truth, implemented in Task 3):** + +| Command | Role | +|---|---| +| `list`, `get`, `search`, `ack`, `send`, `doctor` | agent | +| `account`, `whitelist`, `config`, `audit` | admin | +| `init` | bootstrap (needs **both** keys) | +| `help` / no args | none (no DB access) | + +--- + +## Task 1: crypto — named-var key loaders + DEK generation + +**Files:** +- Modify: `internal/crypto/crypto.go` +- Test: `internal/crypto/crypto_test.go` + +**Interfaces:** +- Consumes: existing `Seal(key, plaintext []byte) ([]byte, error)`, `Open(key, blob []byte) ([]byte, error)` (unchanged — they double as DEK wrap/unwrap). +- Produces: + - `AgentKeyFromEnv() ([]byte, error)` — reads `EMCLI_KEY`. + - `AdminKeyFromEnv() ([]byte, error)` — reads `EMCLI_ADMIN_KEY`. + - `NewDEK() ([]byte, error)` — fresh random 32-byte key. +- Removes: `KeyFromEnv`, `ErrNoKey`, `ErrBadKey` (only `crypto_test.go` and `internal/cli/run.go:30` reference them; the latter is rewritten in Task 3). + +- [ ] **Step 1: Replace the env/error section of `crypto.go`** + +Replace the current `var ( ErrNoKey … ); func KeyFromEnv()` block (lines ~14–30) with: + +```go +// keyFromEnv reads and validates a base64-encoded 32-byte AES key from the +// named environment variable. Errors name the variable so callers get a +// role-appropriate message. +func keyFromEnv(varName string) ([]byte, error) { + raw := os.Getenv(varName) + if raw == "" { + return nil, fmt.Errorf("%s is not set", varName) + } + key, err := base64.StdEncoding.DecodeString(raw) + if err != nil || len(key) != 32 { + return nil, fmt.Errorf("%s must be base64 of exactly 32 bytes", varName) + } + return key, nil +} + +// AgentKeyFromEnv reads the agent KEK from EMCLI_KEY (agent commands only). +func AgentKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_KEY") } + +// AdminKeyFromEnv reads the admin KEK from EMCLI_ADMIN_KEY (all commands). +func AdminKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_ADMIN_KEY") } + +// NewDEK returns a fresh random 32-byte data-encryption key. +func NewDEK() ([]byte, error) { + dek := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, dek); err != nil { + return nil, err + } + return dek, nil +} +``` + +Update the import block to add `"fmt"` and drop `"errors"` if now unused (it is — no other `errors.` use remains after removing the sentinels; `Open` uses `errors.New` so KEEP `"errors"`). Net: add `"fmt"`, keep everything else. + +- [ ] **Step 2: Rewrite `TestKeyFromEnv` in `crypto_test.go`** + +Replace `TestKeyFromEnv` (lines ~53–69) with: + +```go +func TestAgentAndAdminKeyFromEnv(t *testing.T) { + good := base64.StdEncoding.EncodeToString(testKey()) + + t.Setenv("EMCLI_KEY", good) + if k, err := AgentKeyFromEnv(); err != nil || len(k) != 32 { + t.Fatalf("AgentKeyFromEnv: key=%d err=%v", len(k), err) + } + t.Setenv("EMCLI_ADMIN_KEY", good) + if k, err := AdminKeyFromEnv(); err != nil || len(k) != 32 { + t.Fatalf("AdminKeyFromEnv: key=%d err=%v", len(k), err) + } + + t.Setenv("EMCLI_ADMIN_KEY", "") + if _, err := AdminKeyFromEnv(); err == nil || + !strings.Contains(err.Error(), "EMCLI_ADMIN_KEY") { + t.Fatalf("empty admin key: want EMCLI_ADMIN_KEY error, got %v", err) + } + + t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort"))) + if _, err := AgentKeyFromEnv(); err == nil || + !strings.Contains(err.Error(), "32 bytes") { + t.Fatalf("short key: want length error, got %v", err) + } +} + +func TestNewDEKIsRandom32(t *testing.T) { + a, err := NewDEK() + if err != nil || len(a) != 32 { + t.Fatalf("NewDEK: len=%d err=%v", len(a), err) + } + b, _ := NewDEK() + if bytes.Equal(a, b) { + t.Fatal("two DEKs must differ") + } +} +``` + +Add `"strings"` to the test imports (`bytes` and `encoding/base64` are already imported). + +- [ ] **Step 3: Run crypto tests, expect FAIL to PASS transition** + +Run: `go test ./internal/crypto/...` +Expected: PASS. (If it fails to compile because `"errors"` became unused, that means `Open` no longer references it — it does, so this should not happen; if `"fmt"` is reported unused, you forgot to add a loader. Fix and re-run.) + +- [ ] **Step 4: Commit** + +```bash +git add internal/crypto/crypto.go internal/crypto/crypto_test.go +git commit -m "feat(crypto): named-var key loaders (admin/agent) + NewDEK + +Replace KeyFromEnv with AgentKeyFromEnv/AdminKeyFromEnv reading EMCLI_KEY +and EMCLI_ADMIN_KEY; add NewDEK for envelope encryption. Seal/Open double +as DEK wrap/unwrap. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +> Note: the repo will not fully build until Task 3 (cli still references the removed `crypto.KeyFromEnv`). The crypto package and its tests are self-contained and pass. + +--- + +## Task 2: store — unlock/init split + DEK wrap slots + +**Files:** +- Modify: `internal/store/store.go` +- Create: `internal/store/keys.go` +- Test: `internal/store/keys_test.go` + +**Interfaces:** +- Consumes: `crypto.NewDEK`, `crypto.Seal`, `crypto.Open` (Task 1); existing `(*Store).GetSetting`, `(*Store).SetSetting`. +- Produces: + - `type Role int` with `RoleAgent Role = iota` and `RoleAdmin`. + - `store.Open(path string) (*Store, error)` — **signature change**: drops the `key` param; store opens locked. + - `(*Store).InitKeys(adminKey, agentKey []byte) error`. + - `(*Store).Unlock(role Role, adminKey, agentKey []byte) error`. + - `var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")`. +- Note for later tasks: after `Open`, `s.key` is nil; a command MUST call `Unlock` (or `InitKeys`) before any account read/write, or `crypto.Open(s.key, …)` in `account.go` will fail. + +- [ ] **Step 1: Change `store.Open` to open locked (no key param)** + +In `internal/store/store.go`, change the signature and the struct construction: + +```go +// Open opens (creating if needed) the DB at path and applies the schema. +// The store opens LOCKED: call InitKeys (first run) or Unlock before any +// secret read/write. +func Open(path string) (*Store, error) { +``` + +and replace `s := &Store{db: db, key: key}` with: + +```go + s := &Store{db: db} +``` + +Leave the rest of `Open` (dir creation, pragma, schema, schema_version setting) unchanged. The `key []byte` field on `Store` stays as-is (now populated by `Unlock`/`InitKeys`). + +- [ ] **Step 2: Write the failing test for InitKeys + Unlock** + +Create `internal/store/keys_test.go`: + +```go +package store + +import ( + "bytes" + "path/filepath" + "testing" + + "git.dcglab.co.uk/steve/emcli/internal/crypto" +) + +func k(b byte) []byte { + key := make([]byte, 32) + for i := range key { + key[i] = b + } + return key +} + +func tempStore(t *testing.T) *Store { + t.Helper() + st, err := Open(filepath.Join(t.TempDir(), "emcli.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func TestInitKeysThenUnlockBothSlotsRecoverSameDEK(t *testing.T) { + admin, agent := k(0xAA), k(0xBB) + st := tempStore(t) + if err := st.InitKeys(admin, agent); err != nil { + t.Fatalf("InitKeys: %v", err) + } + // Seal a password under the DEK that InitKeys set. + if _, err := st.AddAccount(Account{ + Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993, + IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw", + }); err != nil { + t.Fatalf("AddAccount: %v", err) + } + + // Re-open and unlock via the AGENT slot. + path := st.dbPath() + st.Close() + st2, _ := Open(path) + if err := st2.Unlock(RoleAgent, nil, agent); err != nil { + t.Fatalf("Unlock(agent): %v", err) + } + got, err := st2.GetAccount("a") + if err != nil || got.Password != "pw" { + t.Fatalf("agent-slot decrypt: pw=%q err=%v", got.Password, err) + } + st2.Close() + + // Unlock via the ADMIN slot recovers the same DEK. + st3, _ := Open(path) + if err := st3.Unlock(RoleAdmin, admin, nil); err != nil { + t.Fatalf("Unlock(admin): %v", err) + } + got3, err := st3.GetAccount("a") + if err != nil || got3.Password != "pw" { + t.Fatalf("admin-slot decrypt: pw=%q err=%v", got3.Password, err) + } + st3.Close() +} + +func TestUnlockWrongKeyFails(t *testing.T) { + st := tempStore(t) + if err := st.InitKeys(k(0xAA), k(0xBB)); err != nil { + t.Fatal(err) + } + path := st.dbPath() + st.Close() + st2, _ := Open(path) + if err := st2.Unlock(RoleAdmin, k(0x11), nil); err == nil { + t.Fatal("Unlock with wrong admin key must fail") + } + st2.Close() +} + +func TestAdminSlotNotOpenableByAgentKey(t *testing.T) { + st := tempStore(t) + admin, agent := k(0xAA), k(0xBB) + if err := st.InitKeys(admin, agent); err != nil { + t.Fatal(err) + } + // RoleAdmin must use the admin slot; passing the agent key as the admin + // key must fail — there is no fallback to the agent slot. + if err := st.Unlock(RoleAdmin, agent, agent); err == nil { + t.Fatal("agent key must not unlock the admin slot") + } +} + +func TestInitKeysIdempotentKeepsDEK(t *testing.T) { + st := tempStore(t) + admin, agent := k(0xAA), k(0xBB) + if err := st.InitKeys(admin, agent); err != nil { + t.Fatal(err) + } + st.AddAccount(Account{ + Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993, + IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw", + }) + // Second InitKeys must NOT regenerate the DEK (would orphan the password). + if err := st.InitKeys(admin, agent); err != nil { + t.Fatalf("re-InitKeys: %v", err) + } + got, err := st.GetAccount("a") + if err != nil || got.Password != "pw" { + t.Fatalf("password lost after re-init: pw=%q err=%v", got.Password, err) + } + _ = bytes.Equal // keep import if unused elsewhere +} +``` + +- [ ] **Step 3: Run the test to verify it fails (compile error)** + +Run: `go test ./internal/store/ -run TestInitKeys -v` +Expected: FAIL — compile errors (`InitKeys`, `Unlock`, `RoleAgent`, `RoleAdmin`, `dbPath` undefined). + +- [ ] **Step 4: Implement `internal/store/keys.go`** + +```go +package store + +import ( + "database/sql" + "encoding/base64" + "errors" + "fmt" + + "git.dcglab.co.uk/steve/emcli/internal/crypto" +) + +// Role selects which DEK wrap slot a command may unlock. +type Role int + +const ( + RoleAgent Role = iota // agent commands; uses dek_wrap_agent (admin slot as fallback) + RoleAdmin // all commands; uses dek_wrap_admin ONLY +) + +const ( + settingDEKWrapAdmin = "dek_wrap_admin" + settingDEKWrapAgent = "dek_wrap_agent" +) + +// ErrLocked means the DB has no DEK wrap slots yet (never initialized). +var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`") + +// dbPath returns the file path SQLite opened (used by tests to re-open). +func (s *Store) dbPath() string { + var p string + _ = s.db.QueryRow("PRAGMA database_list").Scan(new(int), new(string), &p) + return p +} + +// InitKeys generates a DEK (only if absent), seals it under both KEKs, writes +// both wrap slots, and unlocks the store. If the slots already exist it does +// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init). +func (s *Store) InitKeys(adminKey, agentKey []byte) error { + if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil { + return s.Unlock(RoleAdmin, adminKey, nil) + } + dek, err := crypto.NewDEK() + if err != nil { + return err + } + wrapAdmin, err := crypto.Seal(adminKey, dek) + if err != nil { + return err + } + wrapAgent, err := crypto.Seal(agentKey, dek) + if err != nil { + return err + } + if err := s.SetSetting(settingDEKWrapAdmin, base64.StdEncoding.EncodeToString(wrapAdmin)); err != nil { + return err + } + if err := s.SetSetting(settingDEKWrapAgent, base64.StdEncoding.EncodeToString(wrapAgent)); err != nil { + return err + } + s.key = dek + return nil +} + +// Unlock loads the DEK into the store by decrypting the wrap slot for role. +// RoleAdmin uses the admin slot ONLY. RoleAgent prefers the agent slot and +// falls back to the admin slot only when no agent key is supplied. +func (s *Store) Unlock(role Role, adminKey, agentKey []byte) error { + switch role { + case RoleAdmin: + return s.unlockSlot(settingDEKWrapAdmin, adminKey) + case RoleAgent: + if len(agentKey) > 0 { + return s.unlockSlot(settingDEKWrapAgent, agentKey) + } + return s.unlockSlot(settingDEKWrapAdmin, adminKey) + default: + return fmt.Errorf("unknown role %d", role) + } +} + +func (s *Store) unlockSlot(settingKey string, kek []byte) error { + if len(kek) == 0 { + return ErrLocked + } + enc, err := s.GetSetting(settingKey) + if errors.Is(err, sql.ErrNoRows) { + return ErrLocked + } + if err != nil { + return err + } + blob, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return fmt.Errorf("corrupt wrap slot %q: %w", settingKey, err) + } + dek, err := crypto.Open(kek, blob) + if err != nil { + return errors.New("wrong key for this DB") + } + s.key = dek + return nil +} +``` + +- [ ] **Step 5: Run the store tests, expect PASS** + +Run: `go test ./internal/store/... -v` +Expected: PASS, including the existing store tests. (Existing `store_test.go` may call `Open(path, key)` with two args — if so, that is fixed in Step 6.) + +- [ ] **Step 6: Fix any existing `store` callers of the old `Open(path, key)` signature** + +Run: `git grep -n "store.Open(\|Open(path," internal/store` +For each in-package call to `Open` with two args (e.g. in `store_test.go`), change `Open(path, someKey)` to `Open(path)` followed by `st.InitKeys(k(0xAA), k(0xBB))` (or `st.Unlock(...)` if the test re-opens an initialized DB). Re-run `go test ./internal/store/...` until green. + +- [ ] **Step 7: Commit** + +```bash +git add internal/store/store.go internal/store/keys.go internal/store/keys_test.go internal/store/store_test.go +git commit -m "feat(store): envelope DEK with admin/agent wrap slots + +Open() now opens LOCKED; InitKeys generates a DEK sealed under both KEKs; +Unlock loads it from the role's slot (admin slot has no agent fallback). +s.key becomes the DEK, so account/mail crypto is unchanged. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: cli — role routing, openStore(role), init bootstrap + +**Files:** +- Modify: `internal/cli/run.go` (openStore, command routing, agent call sites) +- Modify: `internal/cli/admin.go` (4 `openStore()` call sites) +- Modify: `internal/cli/interactive.go` (`runInit` bootstrap) +- Modify: `internal/cli/admin_test.go` (`adminEnv` helper) +- Modify: `internal/cli/run_test.go` (b64 helpers, gating test) + +**Interfaces:** +- Consumes: `crypto.AdminKeyFromEnv`, `crypto.AgentKeyFromEnv` (Task 1); `store.Open`, `store.Role`, `store.RoleAgent`, `store.RoleAdmin`, `(*Store).Unlock`, `(*Store).InitKeys` (Task 2). +- Produces: `commandRole(cmd string) store.Role`; `openStore(role store.Role) (*store.Store, error)`. + +- [ ] **Step 1: Rewrite `openStore` and add `commandRole` in `run.go`** + +Replace the current `openStore` (lines ~28–39) with: + +```go +// commandRole maps a command to the privilege it requires. Admin commands +// mutate configuration or expose oversight data; everything else is agent. +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 + } +} + +// openStore resolves the keys for the role, opens the DB, and unlocks the DEK. +// Admin commands require EMCLI_ADMIN_KEY and unlock the admin slot only; agent +// commands use EMCLI_KEY (falling back to the admin key if that is all there is). +func openStore(role store.Role) (*store.Store, error) { + adminKey, adminErr := crypto.AdminKeyFromEnv() + agentKey, agentErr := crypto.AgentKeyFromEnv() + + switch role { + case store.RoleAdmin: + if adminErr != nil { + return nil, fmt.Errorf("this command requires EMCLI_ADMIN_KEY (admin privilege)") + } + case store.RoleAgent: + if agentErr != nil && adminErr != nil { + return nil, agentErr // "EMCLI_KEY is not set" + } + } + + path, err := store.DefaultDBPath() + if err != nil { + return nil, err + } + st, err := store.Open(path) + if err != nil { + return nil, err + } + if err := st.Unlock(role, adminKey, agentKey); err != nil { + st.Close() + return nil, err + } + return st, nil +} +``` + +Remove the now-unused `crypto` import only if it becomes unused — it does NOT (openStore still uses it). Ensure `run.go` imports include `"fmt"` (already present) and `crypto`/`store` (already present). + +- [ ] **Step 2: Update the three agent call sites in `run.go`** + +- `runDoctor` (line ~82): `st, err := openStore(store.RoleAgent)` +- `runAgent` (line ~162): `st, err := openStore(store.RoleAgent)` +- `runSend` (line ~252): `st, err := openStore(store.RoleAgent)` + +- [ ] **Step 3: Update the four admin call sites in `admin.go`** + +In `runAccount` (~24), `runConfig` (~207), `runAudit` (~265), `runWhitelist` (~304): change each `st, err := openStore()` to `st, err := openStore(store.RoleAdmin)`. Confirm `admin.go` imports `git.dcglab.co.uk/steve/emcli/internal/store` (it returns `store` types already; if not imported, add it). + +- [ ] **Step 4: Rewrite `runInit` bootstrap in `interactive.go`** + +Replace the body of `runInit` (lines ~75–97) with: + +```go +func runInit(args []string, out, errOut io.Writer) int { + if len(args) > 0 && helpRequested(args[0]) { + printCmdUsage(out, "init") + return 0 + } + adminKey, err := crypto.AdminKeyFromEnv() + if err != nil { + fmt.Fprintf(errOut, "emcli: %v\n", err) + return 1 + } + agentKey, err := crypto.AgentKeyFromEnv() + if err != nil { + fmt.Fprintf(errOut, "emcli: %v\n", err) + return 1 + } + path, err := store.DefaultDBPath() + if err != nil { + fmt.Fprintf(errOut, "emcli: %v\n", err) + return 1 + } + st, err := store.Open(path) + if err != nil { + fmt.Fprintf(errOut, "emcli: %v\n", err) + return 1 + } + defer st.Close() + if err := st.InitKeys(adminKey, agentKey); err != nil { + fmt.Fprintf(errOut, "emcli: %v\n", err) + return 1 + } + + if _, err := st.GetSetting("audit_retention_days"); err != nil { + _ = st.SetSetting("audit_retention_days", "90") + } + accs, _ := st.ListAccounts() + if len(accs) > 0 { + fmt.Fprintf(out, "emcli is already initialized (%d account(s)); adding another.\n", len(accs)) + } else { + fmt.Fprintln(out, "Initializing emcli — add your first account.") + } + return addInteractive(st, tui.Fields{}, out, errOut) +} +``` + +Add imports to `interactive.go` if missing: `git.dcglab.co.uk/steve/emcli/internal/crypto` and `git.dcglab.co.uk/steve/emcli/internal/store` (it already uses `store` for `addInteractive`; `tui` and `fmt` are already imported). + +- [ ] **Step 5: Update test helpers in `admin_test.go`** + +Replace `adminEnv` (lines ~14–20) with a version that sets both keys AND seeds the wrap slots: + +```go +// adminEnv points both keys + EMCLI_DB at a fresh, initialized temp DB. +func adminEnv(t *testing.T) string { + t.Helper() + db := filepath.Join(t.TempDir(), "emcli.db") + t.Setenv("EMCLI_ADMIN_KEY", b64Key()) + t.Setenv("EMCLI_KEY", b64AgentKey()) + t.Setenv("EMCLI_DB", db) + + st, err := store.Open(db) + if err != nil { + t.Fatalf("Open: %v", err) + } + adminKey, _ := crypto.AdminKeyFromEnv() + agentKey, _ := crypto.AgentKeyFromEnv() + if err := st.InitKeys(adminKey, agentKey); err != nil { + t.Fatalf("InitKeys: %v", err) + } + st.Close() + return db +} +``` + +Add `"git.dcglab.co.uk/steve/emcli/internal/crypto"` to `admin_test.go` imports (`store` and `filepath` are already imported). + +- [ ] **Step 6: Add `b64AgentKey` and fix the gating test in `run_test.go`** + +Add next to `b64Key` (line ~53): + +```go +func b64AgentKey() string { + // 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface. + return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" +} +``` + +In `TestRunVersionIsJSONForAgentButTextHere` (lines ~21–33), make the admin-key absence explicit so the test is deterministic regardless of the developer's shell: + +```go + t.Setenv("EMCLI_KEY", "") + t.Setenv("EMCLI_ADMIN_KEY", "") + code := Run([]string{"account", "list"}, &out, &errOut) +``` + +The assertion `strings.Contains(out+err, "EMCLI_KEY")` still holds — the admin error text contains `EMCLI_ADMIN_KEY`. + +- [ ] **Step 7: Build and run the full cli suite** + +Run: `go build ./... && go test ./internal/cli/... -race` +Expected: PASS. (This is the first point the whole repo builds again.) If `crypto` shows as an unused import anywhere you touched, remove it; if a `store` import is missing in `admin.go`/`interactive.go`, add it. + +- [ ] **Step 8: Add routing tests in a new `internal/cli/role_test.go`** + +```go +package cli + +import ( + "path/filepath" + "testing" + + "git.dcglab.co.uk/steve/emcli/internal/crypto" + "git.dcglab.co.uk/steve/emcli/internal/store" +) + +func TestCommandRole(t *testing.T) { + admin := []string{"account", "whitelist", "config", "audit"} + agent := []string{"list", "get", "search", "ack", "send", "doctor"} + for _, c := range admin { + if commandRole(c) != store.RoleAdmin { + t.Errorf("%s should be admin", c) + } + } + for _, c := range agent { + if commandRole(c) != store.RoleAgent { + t.Errorf("%s should be agent", c) + } + } +} + +func TestAgentCommandWorksWithOnlyAdminKey(t *testing.T) { + // A human holding only the admin key can still run agent commands + // (admin is a superset → agent-role unlock falls back to the admin slot). + db := filepath.Join(t.TempDir(), "emcli.db") + t.Setenv("EMCLI_ADMIN_KEY", b64Key()) + t.Setenv("EMCLI_KEY", b64AgentKey()) + t.Setenv("EMCLI_DB", db) + st, _ := store.Open(db) + ak, _ := crypto.AdminKeyFromEnv() + gk, _ := crypto.AgentKeyFromEnv() + st.InitKeys(ak, gk) + st.Close() + + // Only the admin key now; agent command must still open the store. + t.Setenv("EMCLI_KEY", "") + s2, err := openStore(store.RoleAgent) + if err != nil { + t.Fatalf("agent role with only admin key should open: %v", err) + } + s2.Close() +} +``` + +Run: `go test ./internal/cli/ -run 'TestCommandRole|TestAgentCommandWorksWithOnlyAdminKey' -v` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add internal/cli/run.go internal/cli/admin.go internal/cli/interactive.go \ + internal/cli/admin_test.go internal/cli/run_test.go internal/cli/role_test.go +git commit -m "feat(cli): two-key role routing + init bootstrap + +openStore(role) selects the DEK wrap slot; admin commands require +EMCLI_ADMIN_KEY (admin slot only, no agent fallback); init writes both +slots from both keys. Test helpers seed the wrap slots. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: headline security-invariant test + +**Files:** +- Create: `internal/cli/security_invariant_test.go` + +**Interfaces:** +- Consumes: `store.Open`, `(*Store).InitKeys`, `crypto.AdminKeyFromEnv`, `crypto.AgentKeyFromEnv`, `run`, `b64Key`, `b64AgentKey` (Tasks 2–3). + +- [ ] **Step 1: Write the invariant test** + +```go +package cli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "git.dcglab.co.uk/steve/emcli/internal/crypto" + "git.dcglab.co.uk/steve/emcli/internal/store" +) + +func dbBytes(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read db: %v", err) + } + return b +} + +// A forced agent holding ONLY EMCLI_KEY must not be able to run any admin +// command, and the DB must be unchanged after it tries. +func TestAgentKeyCannotRunAdminCommands(t *testing.T) { + db := filepath.Join(t.TempDir(), "emcli.db") + t.Setenv("EMCLI_ADMIN_KEY", b64Key()) + t.Setenv("EMCLI_KEY", b64AgentKey()) + t.Setenv("EMCLI_DB", db) + + st, _ := store.Open(db) + ak, _ := crypto.AdminKeyFromEnv() + gk, _ := crypto.AgentKeyFromEnv() + if err := st.InitKeys(ak, gk); err != nil { + t.Fatalf("InitKeys: %v", err) + } + st.Close() + + // Simulate the agent's environment: admin key absent. + t.Setenv("EMCLI_ADMIN_KEY", "") + + before := dbBytes(t, db) + adminAttempts := [][]string{ + {"account", "list"}, + {"config", "set", "audit_retention_days", "30"}, + {"audit"}, + } + for _, args := range adminAttempts { + code, out, errOut := run(t, args...) + if code == 0 { + t.Fatalf("admin command %v must be refused with only EMCLI_KEY (out=%q err=%q)", args, out, errOut) + } + } + if !bytes.Equal(before, dbBytes(t, db)) { + t.Fatal("DB changed despite all admin commands being refused") + } +} +``` + +- [ ] **Step 2: Run it** + +Run: `go test ./internal/cli/ -run TestAgentKeyCannotRunAdminCommands -v -race` +Expected: PASS. (If any admin command exits 0, the role gate is broken — fix Task 3 before continuing.) + +- [ ] **Step 3: Full suite + vet** + +Run: `go vet ./... && go test ./... -race` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/cli/security_invariant_test.go +git commit -m "test(cli): prove agent key cannot run admin commands + +Initialize a DB, drop EMCLI_ADMIN_KEY, attempt every admin command with +only EMCLI_KEY: each is refused and the DB is byte-for-byte unchanged. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: documentation + +**Files:** +- Modify: `README.md` +- Modify: `USER-MANUAL.md` +- Modify: `specifications/SPEC.md` +- Modify: `skills/emcli/SKILL.md` and `skills/emcli/AGENTIC-MANUAL.md` (whichever document key setup) + +**Interfaces:** none (docs only). No code; no test cycle — verification is `grep` + a manual read. + +- [ ] **Step 1: README "Getting started" — two keys** + +Replace the single-key export block with: + +```bash +export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this +export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this +emcli init # writes both wrap slots +emcli doctor # confirm connect/auth (agent key is enough) +``` + +Add one sentence: *"`emcli init` needs both keys. Give the agent's orchestrator only `EMCLI_KEY`; admin commands (`account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` and will refuse to run without it."* + +- [ ] **Step 2: USER-MANUAL — key model + role table** + +Add a "Privilege model" section documenting: the two env vars; the DEK/envelope design in one paragraph (DEK sealed under both keys; admin slot has no agent fallback); the command→role table (copy from Global Constraints above); and the agent-launcher guidance (set only `EMCLI_KEY`). Update any existing single-`EMCLI_KEY` setup and `init` instructions to the two-key flow. + +- [ ] **Step 3: SPEC §4/§5 — enforced trust boundary** + +In §4 "Trust boundary": change the wording from *the agent invokes only the agent commands* (convention) to the enforced model — agent commands accept `EMCLI_KEY`; admin commands require `EMCLI_ADMIN_KEY` and unlock the admin DEK slot only. In §5 "Configuration & secrets": document `EMCLI_ADMIN_KEY`, `EMCLI_KEY`, the DEK, and the two `settings` wrap rows (`dek_wrap_admin`, `dek_wrap_agent`). + +- [ ] **Step 4: skill docs — agent gets only EMCLI_KEY** + +In `skills/emcli/SKILL.md` / `AGENTIC-MANUAL.md`, state that the agent is provided only `EMCLI_KEY` and therefore can run `list`/`get`/`search`/`ack`/`send`/`doctor`; admin commands are unavailable to it by design. Remove any text implying the agent can configure accounts/whitelists. + +- [ ] **Step 5: Verify and commit** + +Run: `git grep -n "EMCLI_KEY" README.md USER-MANUAL.md specifications/SPEC.md skills/` +Confirm every setup/init reference reflects the two-key model and no doc tells the agent to run admin commands. Then: + +```bash +git add README.md USER-MANUAL.md specifications/SPEC.md skills/ +git commit -m "docs: document two-key privilege model + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review (completed by plan author) + +**Spec coverage:** +- Two env vars / role model → Tasks 1, 3. ✓ +- Envelope DEK + two wrap slots → Task 2. ✓ +- `s.key` becomes DEK, account crypto untouched → Task 2 (no change to `account.go`). ✓ +- Command classification (doctor=agent, audit=admin) → Task 3 `commandRole`. ✓ +- Admin slot has no agent fallback (enforcement linchpin) → Task 2 `Unlock`/`unlockSlot`, proved in Task 2 `TestAdminSlotNotOpenableByAgentKey` and Task 4. ✓ +- Agent→admin superset fallback → Task 2 `Unlock`, Task 3 `TestAgentCommandWorksWithOnlyAdminKey`. ✓ +- `init` requires both keys; idempotent (no DEK regen) → Task 3 `runInit`, Task 2 `TestInitKeysIdempotentKeepsDEK`. ✓ +- No migration / no version gate → no such code added. ✓ +- Error messages (admin privilege required / EMCLI_KEY not set / wrong key) → Task 3 `openStore`, Task 2 `unlockSlot`. ✓ +- Existing-test fallout (helpers) → Task 3 Steps 5–6. ✓ +- Headline invariant test → Task 4. ✓ +- Docs → Task 5. ✓ + +**Placeholder scan:** No TBD/TODO; all code blocks complete; the only `bytes.Equal` no-op is annotated. ✓ + +**Type consistency:** `store.Role`/`RoleAgent`/`RoleAdmin`, `Open(path)`, `InitKeys(adminKey, agentKey)`, `Unlock(role, adminKey, agentKey)`, `commandRole`, `openStore(role)`, `AgentKeyFromEnv`/`AdminKeyFromEnv`/`NewDEK`, `b64Key`/`b64AgentKey` are used identically across tasks. ✓