docs(plan): two-key privilege separation implementation plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:43:16 +01:00
parent 2bc2c1b50e
commit 77ba5a146f
@@ -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 ~1430) 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 ~5369) 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) <noreply@anthropic.com>"
```
> 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) <noreply@anthropic.com>"
```
---
## 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 ~2839) 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 ~7597) 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 ~1420) 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 ~2133), 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) <noreply@anthropic.com>"
```
---
## 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 23).
- [ ] **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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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 56. ✓
- 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. ✓