Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
30 KiB
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 underinternal/. - 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
settingstable (value TEXT NOT NULL). - No migration / no schema-version gate —
initwrites 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)— readsEMCLI_KEY.AdminKeyFromEnv() ([]byte, error)— readsEMCLI_ADMIN_KEY.NewDEK() ([]byte, error)— fresh random 32-byte key.
-
Removes:
KeyFromEnv,ErrNoKey,ErrBadKey(onlycrypto_test.goandinternal/cli/run.go:30reference 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:
// 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
TestKeyFromEnvincrypto_test.go
Replace TestKeyFromEnv (lines ~53–69) with:
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
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 intwithRoleAgent Role = iotaandRoleAdmin.store.Open(path string) (*Store, error)— signature change: drops thekeyparam; 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; runemcli init").
-
Note for later tasks: after
Open,s.keyis nil; a command MUST callUnlock(orInitKeys) before any account read/write, orcrypto.Open(s.key, …)inaccount.gowill fail. -
Step 1: Change
store.Opento open locked (no key param)
In internal/store/store.go, change the signature and the struct construction:
// 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:
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:
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
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
storecallers of the oldOpen(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
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(4openStore()call sites) - Modify:
internal/cli/interactive.go(runInitbootstrap) - Modify:
internal/cli/admin_test.go(adminEnvhelper) - 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
openStoreand addcommandRoleinrun.go
Replace the current openStore (lines ~28–39) with:
// 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
runInitbootstrap ininteractive.go
Replace the body of runInit (lines ~75–97) with:
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:
// 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
b64AgentKeyand fix the gating test inrun_test.go
Add next to b64Key (line ~53):
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:
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
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
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 2–3). -
Step 1: Write the invariant test
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
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.mdandskills/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:
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:
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.keybecomes DEK, account crypto untouched → Task 2 (no change toaccount.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 2TestAdminSlotNotOpenableByAgentKeyand Task 4. ✓ - Agent→admin superset fallback → Task 2
Unlock, Task 3TestAgentCommandWorksWithOnlyAdminKey. ✓ initrequires both keys; idempotent (no DEK regen) → Task 3runInit, Task 2TestInitKeysIdempotentKeepsDEK. ✓- No migration / no version gate → no such code added. ✓
- Error messages (admin privilege required / EMCLI_KEY not set / wrong key) → Task 3
openStore, Task 2unlockSlot. ✓ - 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. ✓