Let the generic command verb only read, and keep keys and tokens at the terminal

Review found a chain through the command verb: set the operator's key to one
the caller holds, rotate secrets so they are sealed to it too, read the sealed
copies, open them. Whoever may call a verb includes agents (hq ADR 0266), so
command now runs an allow list of reading forms, and operator, identity,
token, broker, api, licence and every secret command but rotate are refused
through any verb.
This commit is contained in:
jochen
2026-10-08 21:46:10 +02:00
parent 8f76123cbe
commit 87075fee68
7 changed files with 187 additions and 29 deletions
+1 -1
View File
@@ -170,7 +170,7 @@ func TestNoVerbSetsANodesAccounts(t *testing.T) {
"node frobnicate", "node frobnicate",
} { } {
argv, err := argvFor("command", map[string]any{"command": line}) 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") { !strings.Contains(err.Error(), "ADR 0266") {
t.Errorf("%q ran as %v (%v); want a refusal naming the terminal", line, argv, err) t.Errorf("%q ran as %v (%v); want a refusal naming the terminal", line, argv, err)
} }
-2
View File
@@ -37,8 +37,6 @@ func TestAPushAnswersBeforeItSends(t *testing.T) {
}{ }{
{"push", map[string]any{"node": "anchor", "why": "w"}, true}, {"push", map[string]any{"node": "anchor", "why": "w"}, true},
{"push", map[string]any{"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}, {"command", map[string]any{"command": "builds"}, false},
{"status", map[string]any{}, false}, {"status", map[string]any{}, false},
{"assign", map[string]any{"node": "anchor", "module": "m"}, false}, {"assign", map[string]any{"node": "anchor", "module": "m"}, false},
-5
View File
@@ -22,10 +22,6 @@ func TestARepairByHandWithoutAReasonIsRefused(t *testing.T) {
{"plans", map[string]any{"close": "plan-1"}}, {"plans", map[string]any{"close": "plan-1"}},
{"plans", map[string]any{"stop": "plan-1"}}, {"plans", map[string]any{"stop": "plan-1"}},
{"hand-act", map[string]any{"what": "restarted the proxy", "cause": "proxy-stuck"}}, {"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) argv, err := argvFor(c.verb, c.args)
if c.verb == "plans" && err == nil { 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"}, {"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", 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"}, "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"}, {"command", map[string]any{"command": "plans plan-1"}, "plans plan-1"},
} { } {
argv, err := argvFor(c.verb, c.args) argv, err := argvFor(c.verb, c.args)
+107 -3
View File
@@ -248,6 +248,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
if len(argv) == 0 { if len(argv) == 0 {
return nil, errors.New("command names no command") return nil, errors.New("command names no command")
} }
// 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 // 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. // it says why, as it would through its own verb.
if repair := repairingCommand(argv); repair != "" && !slices.ContainsFunc(argv, isWhyFlag) { if repair := repairingCommand(argv); repair != "" && !slices.ContainsFunc(argv, isWhyFlag) {
@@ -1066,7 +1071,8 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
} }
continue 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.** // **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 // The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb
@@ -1331,13 +1337,111 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
// nodeReads are the `node` subcommands a verb may run: the ones that only read. // nodeReads are the `node` subcommands a verb may run: the ones that only read.
var nodeReads = map[string]bool{"list": true, "show": true} 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 <node>` previews a node's declaration; it sends nothing.
"plan": func([]string) bool { return true },
// `plans` lists and `plans <id>` 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 // 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 // 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 // `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 // 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. // 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 { 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 return nil
} }
if len(argv) > 1 && nodeReads[argv[1]] { if len(argv) > 1 && nodeReads[argv[1]] {
@@ -1347,7 +1451,7 @@ func terminalOnly(argv []string) error {
if len(argv) > 1 { if len(argv) > 1 {
sub += " " + 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. "+ "decide who may become root on it (novox/hq ADR 0266). A verb may run node list and node show. "+
"Nothing was done", sub) "Nothing was done", sub)
} }
@@ -271,7 +271,6 @@ var accountedFlags = map[string]map[string]string{
"builds": {"n": "=limit"}, "builds": {"n": "=limit"},
"plans": {"n": "=limit", "what-if": "=repository"}, "plans": {"n": "=limit", "what-if": "=repository"},
// The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169). // The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169).
"token issue": {"overlay-key": "=overlay_key"},
"durations": { "durations": {
"json": "set by the verb: the answer is data", "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", "all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it",
+68 -9
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/link"
"strings" "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). // `token` answers a joining token to its caller, and whoever may call a verb includes agents: it is the
func TestTokenIssuesForAMachineAndItsTunnelKey(t *testing.T) { // controller's terminal's alone (novox/hq ADR 0266), refused through the verb whatever it is given.
argv, err := argvFor("token", map[string]any{"new": "laptop", "overlay_key": "k", "for": "2h"}) func TestTokenIsRefusedThroughAVerb(t *testing.T) {
if err != nil || strings.Join(argv, " ") != "token issue --new laptop --overlay-key k --for 2h" { for _, args := range []map[string]any{{"new": "laptop", "overlay_key": "k", "for": "2h"}, {"node": "ace"}} {
t.Fatalf("token: %v %v", argv, err) 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)
}
} }
} }
@@ -245,12 +249,12 @@ func TestCommandRunsTheLineAsGiven(t *testing.T) {
if err != nil || strings.Join(argv, " ") != "node show g14" { if err != nil || strings.Join(argv, " ") != "node show g14" {
t.Fatalf("a plain line: %v %v", argv, err) t.Fatalf("a plain line: %v %v", argv, err)
} }
argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`}) argv, err = argvFor("command", map[string]any{"command": `settings show 'dns masq' --node ace`})
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` { if err != nil || len(argv) != 5 || argv[2] != "dns masq" {
t.Fatalf("a quoted word stays one word: %q %v", argv, err) 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`}) argv, err = argvFor("command", map[string]any{"command": `plan "the box" --json`})
if err != nil || len(argv) != 4 || argv[2] != "the box" { if err != nil || len(argv) != 3 || argv[1] != "the box" {
t.Fatalf("double quotes group: %q %v", argv, err) t.Fatalf("double quotes group: %q %v", argv, err)
} }
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil { 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)
}
}
+11 -8
View File
@@ -237,9 +237,9 @@ var ControllerVerbs = []Verb{
"node": "the machine that runs the module", "node": "the machine that runs the module",
"module": "the module's name", "module": "the module's name",
}, []string{"node", "module"})}, }, []string{"node", "module"})},
{Name: "token", Description: "Issue a one-time token for a machine to join with. Give the public half of the " + {Name: "token", Description: "Refused through a verb since novox/hq ADR 0266: the one-time token a machine " +
"tunnel key the machine made (`nox-mesh-host key`): the machine is given its address and made a peer of " + "joins with is answered to its caller, and whoever may call a verb includes agents. Issue it at the " +
"the hub, and joins through the tunnel. The token is shown once, in the answer.", "controller's terminal: `mesh-controller token issue --new <name> --overlay-key <public half>`.",
Input: schema(map[string]string{ Input: schema(map[string]string{
"node": "a machine the mesh already has a record for", "node": "a machine the mesh already has a record for",
"new": "or the name of a machine to create the 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 " + "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)", "machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)",
}, nil, "clear", "replace", "history")}, }, nil, "clear", "replace", "history")},
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " + {Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " +
"shell — `node show ace`, `module list` — and answer what it printed. The generic verb beside the named " + "its shell — `node show ace`, `module list`, `plans`, `conditions show <key>` — and answer what it " +
"ones (novox/hq ADR 0154): what the binary can do, without a verb per command. Held back: every `node` " + "printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " +
"command but `node list` and `node show` — a node's accounts decide who may become root on it, and are " + "status, version, help, seats, healers, hand-acts, durations, collection, images, artifacts, data, builds, " +
"set at the controller's terminal only (ADR 0266).", "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{ Input: schema(map[string]string{
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces", "command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
}, []string{"command"})}, }, []string{"command"})},