refactor(cli): wire commandRole into dispatch; doc + comment cleanup
Resolve final-review findings: commandRole is now the single source of truth (Run resolves role once and threads it to handlers, replacing hardcoded openStore roles). Tighten crypto/SKILL/SPEC/USER-MANUAL wording and document init's agent-key-on-first-init-only semantics. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -38,7 +38,8 @@ This manual is for **using and administering** `emcli`. It assumes you have the
|
|||||||
- **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
|
||||||
is authorised by the agent key — the agent or a human with either key can run it.)
|
is authorised by the agent key — `EMCLI_KEY` alone is sufficient; `EMCLI_ADMIN_KEY` also works
|
||||||
|
as a superset, so either key suffices for agent commands.)
|
||||||
|
|
||||||
**Accounts** are named (e.g. `gmail`, `work`). The agent refers to an account by name and never
|
**Accounts** are named (e.g. `gmail`, `work`). The agent refers to an account by name and never
|
||||||
sees its password.
|
sees its password.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// runAccount handles `account add|list`. Human-readable output (never JSON).
|
// runAccount handles `account add|list`. Human-readable output (never JSON).
|
||||||
func runAccount(args []string, out, errOut io.Writer) int {
|
func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
||||||
if len(args) == 0 || helpRequested(args[0]) {
|
if len(args) == 0 || helpRequested(args[0]) {
|
||||||
printCmdUsage(out, "account")
|
printCmdUsage(out, "account")
|
||||||
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, list")
|
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, list")
|
||||||
@@ -21,7 +21,7 @@ func runAccount(args []string, out, errOut io.Writer) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
sub, rest := args[0], args[1:]
|
sub, rest := args[0], args[1:]
|
||||||
st, err := openStore(store.RoleAdmin)
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -191,7 +191,7 @@ func auditList(st *store.Store, account string, limit int, out io.Writer) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runConfig handles `config set <key> <value>` and `config get <key>`.
|
// runConfig handles `config set <key> <value>` and `config get <key>`.
|
||||||
func runConfig(args []string, out, errOut io.Writer) int {
|
func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
|
||||||
if len(args) == 0 || helpRequested(args[0]) {
|
if len(args) == 0 || helpRequested(args[0]) {
|
||||||
printCmdUsage(out, "config")
|
printCmdUsage(out, "config")
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
@@ -204,7 +204,7 @@ func runConfig(args []string, out, errOut io.Writer) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
sub, key := args[0], args[1]
|
sub, key := args[0], args[1]
|
||||||
st, err := openStore(store.RoleAdmin)
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -246,7 +246,7 @@ func runConfig(args []string, out, errOut io.Writer) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runAudit handles `audit list [--account <name>] [--limit N]`.
|
// runAudit handles `audit list [--account <name>] [--limit N]`.
|
||||||
func runAudit(args []string, out, errOut io.Writer) int {
|
func runAudit(args []string, role store.Role, out, errOut io.Writer) int {
|
||||||
if len(args) > 0 && helpRequested(args[0]) {
|
if len(args) > 0 && helpRequested(args[0]) {
|
||||||
printCmdUsage(out, "audit")
|
printCmdUsage(out, "audit")
|
||||||
return 0
|
return 0
|
||||||
@@ -262,7 +262,7 @@ func runAudit(args []string, out, errOut io.Writer) int {
|
|||||||
if err := fs.Parse(args[1:]); err != nil {
|
if err := fs.Parse(args[1:]); err != nil {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore(store.RoleAdmin)
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -276,7 +276,7 @@ func runAudit(args []string, out, errOut io.Writer) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
|
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
|
||||||
func runWhitelist(args []string, out, errOut io.Writer) int {
|
func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
|
||||||
if len(args) == 0 || helpRequested(args[0]) {
|
if len(args) == 0 || helpRequested(args[0]) {
|
||||||
printCmdUsage(out, "whitelist")
|
printCmdUsage(out, "whitelist")
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
@@ -301,7 +301,7 @@ func runWhitelist(args []string, out, errOut io.Writer) int {
|
|||||||
fmt.Fprintln(errOut, "--account is required")
|
fmt.Fprintln(errOut, "--account is required")
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore(store.RoleAdmin)
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
+14
-13
@@ -99,7 +99,7 @@ func newDepsLive(st *store.Store, out io.Writer) Deps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runDoctor handles `doctor [--account <name>]` (human-readable diagnostics).
|
// runDoctor handles `doctor [--account <name>]` (human-readable diagnostics).
|
||||||
func runDoctor(args []string, out, errOut io.Writer) int {
|
func runDoctor(args []string, role store.Role, out, errOut io.Writer) int {
|
||||||
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||||
fs.SetOutput(errOut)
|
fs.SetOutput(errOut)
|
||||||
usageFlags(fs, "doctor", errOut)
|
usageFlags(fs, "doctor", errOut)
|
||||||
@@ -110,7 +110,7 @@ func runDoctor(args []string, out, errOut io.Writer) int {
|
|||||||
}
|
}
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore(store.RoleAgent)
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -135,21 +135,22 @@ 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)
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "list", "get", "search", "ack":
|
case "list", "get", "search", "ack":
|
||||||
return runAgent(cmd, rest, out, errOut)
|
return runAgent(cmd, rest, role, out, errOut)
|
||||||
case "send":
|
case "send":
|
||||||
return runSend(rest, out, errOut)
|
return runSend(rest, role, out, errOut)
|
||||||
case "account":
|
case "account":
|
||||||
return runAccount(rest, out, errOut)
|
return runAccount(rest, role, out, errOut)
|
||||||
case "whitelist":
|
case "whitelist":
|
||||||
return runWhitelist(rest, out, errOut)
|
return runWhitelist(rest, role, out, errOut)
|
||||||
case "config":
|
case "config":
|
||||||
return runConfig(rest, out, errOut)
|
return runConfig(rest, role, out, errOut)
|
||||||
case "audit":
|
case "audit":
|
||||||
return runAudit(rest, out, errOut)
|
return runAudit(rest, role, out, errOut)
|
||||||
case "doctor":
|
case "doctor":
|
||||||
return runDoctor(rest, out, errOut)
|
return runDoctor(rest, role, out, errOut)
|
||||||
case "init":
|
case "init":
|
||||||
return runInit(rest, out, errOut)
|
return runInit(rest, out, errOut)
|
||||||
default:
|
default:
|
||||||
@@ -159,7 +160,7 @@ func Run(args []string, out, errOut io.Writer) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes.
|
// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes.
|
||||||
func runAgent(cmd string, args []string, out, errOut io.Writer) int {
|
func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer) int {
|
||||||
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
|
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
|
||||||
fs.SetOutput(errOut)
|
fs.SetOutput(errOut)
|
||||||
usageFlags(fs, cmd, errOut)
|
usageFlags(fs, cmd, errOut)
|
||||||
@@ -190,7 +191,7 @@ func runAgent(cmd string, args []string, out, errOut io.Writer) int {
|
|||||||
_ = Failure(CodeUsage, "--account is required").Write(out)
|
_ = Failure(CodeUsage, "--account is required").Write(out)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore(store.RoleAgent)
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = Failure(CodeConfig, err.Error()).Write(out)
|
_ = Failure(CodeConfig, err.Error()).Write(out)
|
||||||
return 1
|
return 1
|
||||||
@@ -255,7 +256,7 @@ func (s *stringSlice) Set(v string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runSend handles the `send` agent command (JSON envelope output).
|
// runSend handles the `send` agent command (JSON envelope output).
|
||||||
func runSend(args []string, out, errOut io.Writer) int {
|
func runSend(args []string, role store.Role, out, errOut io.Writer) int {
|
||||||
fs := flag.NewFlagSet("send", flag.ContinueOnError)
|
fs := flag.NewFlagSet("send", flag.ContinueOnError)
|
||||||
fs.SetOutput(errOut)
|
fs.SetOutput(errOut)
|
||||||
usageFlags(fs, "send", errOut)
|
usageFlags(fs, "send", errOut)
|
||||||
@@ -280,7 +281,7 @@ func runSend(args []string, out, errOut io.Writer) int {
|
|||||||
_ = Failure(CodeUsage, "--account is required").Write(out)
|
_ = Failure(CodeUsage, "--account is required").Write(out)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore(store.RoleAgent)
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = Failure(CodeConfig, err.Error()).Write(out)
|
_ = Failure(CodeConfig, err.Error()).Write(out)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package crypto provides AES-256-GCM field encryption keyed from EMCLI_KEY.
|
// Package crypto provides AES-256-GCM field encryption; keys are loaded from EMCLI_KEY (agent) or EMCLI_ADMIN_KEY (admin).
|
||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ func (s *Store) dbPath() string {
|
|||||||
// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init).
|
// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init).
|
||||||
func (s *Store) InitKeys(adminKey, agentKey []byte) error {
|
func (s *Store) InitKeys(adminKey, agentKey []byte) error {
|
||||||
if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil {
|
if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil {
|
||||||
|
// Already initialised: the DEK and both wrap slots already exist, so the
|
||||||
|
// agent key is not consumed here. Only the admin key is used to unlock the
|
||||||
|
// existing dek_wrap_admin slot; the DEK itself is preserved unchanged.
|
||||||
return s.Unlock(RoleAdmin, adminKey, nil)
|
return s.Unlock(RoleAdmin, adminKey, nil)
|
||||||
}
|
}
|
||||||
dek, err := crypto.NewDEK()
|
dek, err := crypto.NewDEK()
|
||||||
|
|||||||
@@ -21,9 +21,8 @@ sets its exit code to match.
|
|||||||
provided only `EMCLI_KEY` (the agent key), which authorises these commands and nothing else.
|
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
|
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`,
|
require `EMCLI_ADMIN_KEY`) — do not run or suggest running `account`, `whitelist`, `config`,
|
||||||
`audit`, or `init` unless the user explicitly asks you to help administer and confirms they have
|
`audit`, or `init`. You have only `EMCLI_KEY` (agent key); `emcli` will refuse admin commands
|
||||||
provided `EMCLI_ADMIN_KEY` in your environment. Attempting admin commands with only `EMCLI_KEY`
|
with a privilege error.
|
||||||
will be refused by `emcli` 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").
|
||||||
|
|||||||
@@ -73,8 +73,9 @@ the DEK-wrapping scheme:
|
|||||||
commands (`list`, `get`, `search`, `ack`, `send`, `doctor`) only. `EMCLI_ADMIN_KEY` is a superset:
|
commands (`list`, `get`, `search`, `ack`, `send`, `doctor`) only. `EMCLI_ADMIN_KEY` is a superset:
|
||||||
a process with only the admin key can also run agent commands.
|
a process with only the admin key can also run agent commands.
|
||||||
- Agent commands use `EMCLI_KEY`; if only `EMCLI_ADMIN_KEY` is set, they fall back to it.
|
- Agent commands use `EMCLI_KEY`; if only `EMCLI_ADMIN_KEY` is set, they fall back to it.
|
||||||
If neither key satisfies the required slot, `emcli` exits with:
|
If a process holding only `EMCLI_KEY` attempts an admin command, `emcli` exits with:
|
||||||
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
|
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
|
||||||
|
(An agent command with no key set at all yields a different `config` error: `EMCLI_KEY is not set`.)
|
||||||
- `EMCLI_KEY` is supplied by the orchestrator that launches `emcli`, never as an argument the agent
|
- `EMCLI_KEY` is supplied by the orchestrator that launches `emcli`, never as an argument the agent
|
||||||
constructs. The agent has no command that reveals secret values.
|
constructs. The agent has no command that reveals secret values.
|
||||||
- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has no other
|
- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has no other
|
||||||
|
|||||||
Reference in New Issue
Block a user