diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go index 42dab04f..3d384620 100644 --- a/cmd/mesh-controller/agent_account_test.go +++ b/cmd/mesh-controller/agent_account_test.go @@ -170,7 +170,7 @@ func TestNoVerbSetsANodesAccounts(t *testing.T) { "node frobnicate", } { argv, err := argvFor("command", map[string]any{"command": line}) - if err == nil || !strings.Contains(err.Error(), "controller's terminal only") || + if err == nil || !strings.Contains(err.Error(), "controller's terminal") || !strings.Contains(err.Error(), "ADR 0266") { t.Errorf("%q ran as %v (%v); want a refusal naming the terminal", line, argv, err) } diff --git a/cmd/mesh-controller/calls_verb_test.go b/cmd/mesh-controller/calls_verb_test.go index 1aaea74f..606a53f8 100644 --- a/cmd/mesh-controller/calls_verb_test.go +++ b/cmd/mesh-controller/calls_verb_test.go @@ -37,8 +37,6 @@ func TestAPushAnswersBeforeItSends(t *testing.T) { }{ {"push", map[string]any{"node": "anchor", "why": "w"}, true}, {"push", map[string]any{"why": "w"}, true}, - {"command", map[string]any{"command": "push anchor --why w"}, true}, - {"command", map[string]any{"command": "push --behind --why=w"}, true}, {"command", map[string]any{"command": "builds"}, false}, {"status", map[string]any{}, false}, {"assign", map[string]any{"node": "anchor", "module": "m"}, false}, diff --git a/cmd/mesh-controller/handacts_test.go b/cmd/mesh-controller/handacts_test.go index b8d445b7..7d2713d5 100644 --- a/cmd/mesh-controller/handacts_test.go +++ b/cmd/mesh-controller/handacts_test.go @@ -22,10 +22,6 @@ func TestARepairByHandWithoutAReasonIsRefused(t *testing.T) { {"plans", map[string]any{"close": "plan-1"}}, {"plans", map[string]any{"stop": "plan-1"}}, {"hand-act", map[string]any{"what": "restarted the proxy", "cause": "proxy-stuck"}}, - {"command", map[string]any{"command": "push anchor"}}, - {"command", map[string]any{"command": "plans close plan-1"}}, - {"command", map[string]any{"command": "broker consumer-reset EVENTS controller"}}, - {"command", map[string]any{"command": "hand-act record restarted --cause x"}}, } { argv, err := argvFor(c.verb, c.args) if c.verb == "plans" && err == nil { @@ -65,7 +61,6 @@ func TestARepairByHandCarriesItsReason(t *testing.T) { {"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"}, {"hand-act", map[string]any{"what": "restarted", "why": "hung", "cause": "proxy", "condition": "machine.a.silent"}, "hand-act record restarted --why hung --cause proxy --condition machine.a.silent"}, - {"command", map[string]any{"command": "push anchor --why stuck"}, "push anchor --why stuck"}, {"command", map[string]any{"command": "plans plan-1"}, "plans plan-1"}, } { argv, err := argvFor(c.verb, c.args) diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 897cdb45..2f2e47f6 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -252,9 +252,14 @@ func (a *verbArguments) commandLine() ([]string, error) { // settings verb is where what a verb may not set is refused, and one route is one set of words. // The command refuses places and accesses through any verb as well; this says so before it runs. if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) { - return nil, errors.New("settings are set and cleared through the settings verb, not the generic " + - "command; and places and accesses only at the controller's terminal (novox/hq issue 339). " + - "Nothing was done") + return nil, &heldAtTheTerminal{msg: "settings are set and cleared through the settings verb, not the " + + "generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " + + "Nothing was done"} + } + // The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own + // line, or is the operator's at the controller's terminal. + if err := commandReads(argv); err != nil { + return nil, err } // The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through // it says why, as it would through its own verb. @@ -1074,7 +1079,8 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) { } continue } - if _, err := argvFor(verb, sampleArguments(v)); err != nil { + var policy *heldAtTheTerminal + if _, err := argvFor(verb, sampleArguments(v)); err != nil && !errors.As(err, &policy) { // **A row ahead of this binary is not a reason to go silent.** // // The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb @@ -1339,13 +1345,111 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info { // nodeReads are the `node` subcommands a verb may run: the ones that only read. var nodeReads = map[string]bool{"list": true, "show": true} +// flagsOnly says a command line's rest names no subcommand: empty, or beginning with a flag. For a command +// with no subcommands every word is a flag, its value or a name it reads. +func flagsOnly(rest []string) bool { return len(rest) == 0 || strings.HasPrefix(rest[0], "-") } + +// subIn says the rest begins with one of these subcommands. +func subIn(rest []string, subs ...string) bool { + return len(rest) > 0 && slices.Contains(subs, rest[0]) +} + +// commandReadForms are the command lines the generic `command` verb may run (novox/hq ADR 0266): **an allow +// list of the ones that only read**, judged command by command. Anything else — every command that writes a +// record, sends, builds, issues an account or a token, sets a key, accepts, rotates, recovers or exports a +// secret — is refused, and a command added later is refused until it is judged a read. Writing has its named +// verbs, which compose their own lines and are judged by terminalOnly; the rest is the operator's at the +// controller's terminal. +var commandReadForms = map[string]func(rest []string) bool{ + "status": flagsOnly, "version": flagsOnly, "help": flagsOnly, "seats": flagsOnly, "healers": flagsOnly, + "hand-acts": flagsOnly, "durations": flagsOnly, "collection": flagsOnly, "images": flagsOnly, + "artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, "queue": flagsOnly, + // `plan ` previews a node's declaration; it sends nothing. + "plan": func([]string) bool { return true }, + // `plans` lists and `plans ` shows one; `plans stop|close|go` acts. + "plans": func(r []string) bool { return !subIn(r, "stop", "close", "go") }, + // `doctor` answers the last run, `probes` and `signals` describe; `doctor run` runs. + "doctor": func(r []string) bool { return flagsOnly(r) || subIn(r, "probes", "signals") }, + "conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") }, + "node": func(r []string) bool { return subIn(r, "list", "show") }, + "module": func(r []string) bool { return subIn(r, "list") }, + "settings": func(r []string) bool { return subIn(r, "show", "preferences") }, + "retire": func(r []string) bool { return subIn(r, "list") }, + "cleanup": func(r []string) bool { return subIn(r, "list") }, + "delivery": func(r []string) bool { return subIn(r, "plan", "walks") }, + // `bus` alone says the bus's step; `bus upgrade` takes one. + "bus": func(r []string) bool { return len(r) == 0 }, + // `mirrors` lists; --record and --confirm keep a mirror. + "mirrors": func(r []string) bool { + return flagsOnly(r) && !slices.ContainsFunc(r, func(w string) bool { + return w == "--record" || w == "-record" || strings.HasPrefix(w, "--record=") || strings.HasPrefix(w, "-record=") || + w == "--confirm" || w == "-confirm" || strings.HasPrefix(w, "--confirm=") + }) + }, +} + +// heldAtTheTerminal is a refusal of policy (novox/hq ADR 0266): the verb is known and served, and this line is +// the operator's at the controller's terminal. Never read as a verb this binary is behind on. +type heldAtTheTerminal struct{ msg string } + +func (e *heldAtTheTerminal) Error() string { return e.msg } + +func terminalRefusal(format string, args ...any) error { + return &heldAtTheTerminal{fmt.Sprintf(format, args...)} +} + +// commandReads refuses a line the generic verb may not run, saying what it may. +func commandReads(argv []string) error { + if read, ok := commandReadForms[argv[0]]; ok && read(argv[1:]) { + return nil + } + return terminalRefusal("%q is not a reading command, and the generic command verb only reads (novox/hq ADR 0266): "+ + "whoever may call a verb includes agents, and a line that writes, issues, sets a key or reveals a secret "+ + "would be theirs to run. Use the named verb for it, or run it at the controller's terminal. The verb may "+ + "run: %s. Nothing was done", strings.Join(argv, " "), commandReadNames()) +} + +func commandReadNames() string { + names := make([]string, 0, len(commandReadForms)) + for n := range commandReadForms { + names = append(names, n) + } + sort.Strings(names) + return strings.Join(names, ", ") + " (each in its reading forms)" +} + +// terminalOnlyCommands are the commands no verb runs, whatever composed them (novox/hq ADR 0266): they set +// the operator's key, issue a credential or a token that is answered to the caller, or accept, recover or +// export a secret. Their answers or effects hand whoever calls them what the runtime's account holds. +var terminalOnlyCommands = map[string]string{ + "operator": "the operator's key and credential", + "identity": "the mesh's identity keys", + "token": "a token a machine joins with, answered to the caller", + "broker": "the bus's accounts", + "api": "the controller's API keys", + "licence": "the licences' secrets", +} + // terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal // alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and // `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself // the operator account, or cleared the agent account, would have the next send grant it root through the // sudo module's rule. An allow list, so a subcommand added later is refused until it is judged a read. func terminalOnly(argv []string) error { - if len(argv) == 0 || argv[0] != "node" { + if len(argv) == 0 { + return nil + } + if what, kept := terminalOnlyCommands[argv[0]]; kept { + return terminalRefusal("%s is run at the controller's terminal only, never through a verb: it holds %s, and "+ + "whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was done", argv[0], what) + } + // Of a secret's commands only rotation, which seals the new value to the machine that uses it. + if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") { + return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+ + "recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+ + "0266). Nothing was done", strings.Join(argv[1:], " ")) + } + if argv[0] != "node" { return nil } if len(argv) > 1 && nodeReads[argv[1]] { @@ -1355,7 +1459,7 @@ func terminalOnly(argv []string) error { if len(argv) > 1 { sub += " " + argv[1] } - return fmt.Errorf("%s is run at the controller's terminal only, never through a verb: a node's accounts "+ + return terminalRefusal("%s is run at the controller's terminal only, never through a verb: a node's accounts "+ "decide who may become root on it (novox/hq ADR 0266). A verb may run node list and node show. "+ "Nothing was done", sub) } diff --git a/cmd/mesh-controller/seatverbs_schema_test.go b/cmd/mesh-controller/seatverbs_schema_test.go index 08ca631c..176b3455 100644 --- a/cmd/mesh-controller/seatverbs_schema_test.go +++ b/cmd/mesh-controller/seatverbs_schema_test.go @@ -271,7 +271,6 @@ var accountedFlags = map[string]map[string]string{ "builds": {"n": "=limit"}, "plans": {"n": "=limit", "what-if": "=repository"}, // The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169). - "token issue": {"overlay-key": "=overlay_key"}, "durations": { "json": "set by the verb: the answer is data", "all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it", diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index e0a71f2e..9a6643ab 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "github.com/novox/mesh-controller/internal/link" "strings" @@ -108,11 +109,14 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) { } } -// `token` is `token issue` at a shell, with the machine's tunnel key (novox/hq ADR 0169). -func TestTokenIssuesForAMachineAndItsTunnelKey(t *testing.T) { - argv, err := argvFor("token", map[string]any{"new": "laptop", "overlay_key": "k", "for": "2h"}) - if err != nil || strings.Join(argv, " ") != "token issue --new laptop --overlay-key k --for 2h" { - t.Fatalf("token: %v %v", argv, err) +// `token` answers a joining token to its caller, and whoever may call a verb includes agents: it is the +// controller's terminal's alone (novox/hq ADR 0266), refused through the verb whatever it is given. +func TestTokenIsRefusedThroughAVerb(t *testing.T) { + for _, args := range []map[string]any{{"new": "laptop", "overlay_key": "k", "for": "2h"}, {"node": "ace"}} { + argv, err := argvFor("token", args) + if err == nil || !strings.Contains(err.Error(), "controller's terminal only") { + t.Fatalf("token %v: %v %v", args, argv, err) + } } } @@ -249,8 +253,8 @@ func TestCommandRunsTheLineAsGiven(t *testing.T) { if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` { t.Fatalf("a quoted word stays one word: %q %v", argv, err) } - argv, err = argvFor("command", map[string]any{"command": `module show "the box" --json`}) - if err != nil || len(argv) != 4 || argv[2] != "the box" { + argv, err = argvFor("command", map[string]any{"command": `plan "the box" --json`}) + if err != nil || len(argv) != 3 || argv[1] != "the box" { t.Fatalf("double quotes group: %q %v", argv, err) } if _, err := argvFor("command", map[string]any{"command": " "}); err == nil { @@ -355,3 +359,58 @@ func TestTheControllerAnnouncesTheVerbsItServes(t *testing.T) { } } } + +// The generic verb only reads (novox/hq ADR 0266): an allow list of reading forms, and every line that +// writes, issues, sets a key or reveals a secret refused — the chain a review found ran through it: set the +// operator's key to one the caller holds, rotate secrets sealed to it, open them. +func TestTheCommandVerbOnlyReads(t *testing.T) { + for _, line := range []string{ + "operator key set --replace k", "operator issue", "secret accept a b", "secret rotate a b c", + "secret recover a", "secret export a", "token issue --new x", "identity show", "broker users", + "api key", "licence show", "push anchor --why w", "assign novox m", "settings set m {}", + "settings clear m", "module add f", "module check /etc", "module forget m", "module issue m --node a", + "seat rename a b", "plans close p --why w", "plans go p", "doctor run", "conditions silence c --why w", + "retire approve x", "cleanup delete x", "delivery check", "delivery go x", "bus upgrade", + "mirrors --record x", "mirrors --confirm", "hand-act record x --why y --cause z", "serve", "migrate", + "prepare", "declare x", "overlay x", "facts", "merge-gate", "check-here", "build x", "rebuild x", + "cancel x", "kill x", "clear x", "replay x", "pause", "resume", "pin a b", "unpin a", "take x", + "converge", "adopt x", "rollout x", "upgrade x", "collect", "board", "builder", "ask x", "frobnicate", + } { + argv, err := argvFor("command", map[string]any{"command": line}) + var policy *heldAtTheTerminal + if err == nil || !errors.As(err, &policy) { + t.Errorf("%q ran as %v (%v); the generic verb only reads", line, argv, err) + } + } + for _, line := range []string{ + "status --json", "version", "seats --json", "healers", "hand-acts --days 3", "durations", "collection", + "images", "artifacts --collected", "data --machine a", "builds --log b", "queue", "plan ace --diff", + "plans", "plans plan-1", "doctor", "doctor probes", "doctor signals", "conditions", "conditions list", + "conditions show c", "conditions history", "node list", "node show ace", "module list", + "settings show m", "settings preferences", "retire list", "cleanup list", "delivery plan --repository r", + "delivery walks", "bus", "mirrors --json", + } { + if _, err := argvFor("command", map[string]any{"command": line}); err != nil { + t.Errorf("%q, a read, was refused: %v", line, err) + } + } +} + +// What hands a caller a key, a credential or a secret is refused whichever verb composed it. +func TestNoVerbSetsTheOperatorsKeyOrRevealsASecret(t *testing.T) { + for _, argv := range [][]string{ + {"operator", "key", "set"}, {"operator", "issue"}, {"identity"}, {"token", "issue"}, {"broker", "users"}, + {"api"}, {"licence"}, {"secret", "export", "x"}, {"secret", "recover", "x"}, {"secret", "accept", "x"}, {"secret"}, + } { + if err := terminalOnly(argv); err == nil { + t.Errorf("%v passed", argv) + } + } + if err := terminalOnly([]string{"secret", "rotate", "n", "m", "s"}); err != nil { + t.Errorf("rotating seals to the machine that uses the secret, and stays a verb's: %v", err) + } + // A policy refusal is not a verb this binary is behind on: every verb is still served. + if _, behind, err := seatToolHandlers(); err != nil || len(behind) != 0 { + t.Fatalf("behind %v: %v", behind, err) + } +} diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 2df181ee..6f2525fa 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -237,9 +237,9 @@ var ControllerVerbs = []Verb{ "node": "the machine that runs the module", "module": "the module's name", }, []string{"node", "module"})}, - {Name: "token", Description: "Issue a one-time token for a machine to join with. Give the public half of the " + - "tunnel key the machine made (`nox-mesh-host key`): the machine is given its address and made a peer of " + - "the hub, and joins through the tunnel. The token is shown once, in the answer.", + {Name: "token", Description: "Refused through a verb since novox/hq ADR 0266: the one-time token a machine " + + "joins with is answered to its caller, and whoever may call a verb includes agents. Issue it at the " + + "controller's terminal: `mesh-controller token issue --new --overlay-key `.", Input: schema(map[string]string{ "node": "a machine the mesh already has a record for", "new": "or the name of a machine to create the record for", @@ -267,11 +267,14 @@ var ControllerVerbs = []Verb{ "list": "\"preferences\": every module's preferences — key, default and why — and the value on each " + "machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)", }, nil, "clear", "replace", "history")}, - {Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " + - "shell — `node show ace`, `module list` — and answer what it printed. The generic verb beside the named " + - "ones (novox/hq ADR 0154): what the binary can do, without a verb per command. Held back: every `node` " + - "command but `node list` and `node show` — a node's accounts decide who may become root on it, and are " + - "set at the controller's terminal only (ADR 0266).", + {Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " + + "its shell — `node show ace`, `module list`, `plans`, `conditions show ` — and answer what it " + + "printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " + + "status, version, help, seats, healers, hand-acts, durations, collection, images, artifacts, data, builds, " + + "queue, plan, plans (not stop/close/go), doctor (not run), conditions list/show/history, node list/show, " + + "module list, settings show/preferences, retire list, cleanup list, delivery plan/walks, bus, mirrors (not " + + "--record/--confirm). What writes has its named verb; what sets a key, issues a credential or a token, or " + + "accepts, recovers or exports a secret is the controller's terminal's alone.", Input: schema(map[string]string{ "command": "the command line, as the controller's binary takes it; quotes group a word with spaces", }, []string{"command"})},