Name the account agents run as on a node, and say whether it can become root

On the control node every agent ran as the operator's account, which has
passwordless sudo, so an agent could become root without a person (hq ADR
0266). A node now names an agent account at the controller's terminal only;
the agent's module declares it never to become root, the node-engine judges
that, and the self-check (DA) raises agent-can-become-root while it does not
hold, so ADR 0259's router can rest on it.
This commit is contained in:
jochen
2026-10-09 10:11:21 +02:00
parent 4d60628f37
commit fcfbf7e69e
25 changed files with 846 additions and 13 deletions
+76
View File
@@ -0,0 +1,76 @@
package inventory
import (
"context"
"errors"
"strings"
"testing"
)
// The agent account (novox/hq ADR 0266): recorded and read back with every node, its home derived when
// not stated, cleared by an empty name — and refused when it is root, the operator's own account, or no
// login at all, because each of those would say agents have an account of their own while they do not.
func TestAgentAccountIsRecordedAndRefusedWhereItWouldNotConfine(t *testing.T) {
inv := ForTest(t)
ctx := context.Background()
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
t.Fatal(err)
}
if err := inv.SetAccount(ctx, "anchor", "operator", ""); err != nil {
t.Fatal(err)
}
n, err := inv.NodeByName(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if n.AgentAccount != "" || n.AgentHome() != "" {
t.Fatalf("a node that names none has agent account %q, home %q", n.AgentAccount, n.AgentHome())
}
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
t.Fatal(err)
}
n, _ = inv.NodeByName(ctx, "anchor")
if n.AgentAccount != "agent" || n.AgentHome() != "/home/agent" {
t.Fatalf("agent account %q, home %q; want agent, /home/agent", n.AgentAccount, n.AgentHome())
}
all, err := inv.Nodes(ctx)
if err != nil || len(all) != 1 || all[0].AgentAccount != "agent" {
t.Fatalf("the listing does not carry the agent account: %+v %v", all, err)
}
if err := inv.SetAgentAccount(ctx, "anchor", "agent", "/srv/agent"); err != nil {
t.Fatal(err)
}
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentHome() != "/srv/agent" {
t.Fatalf("the stated home is %q", n.AgentHome())
}
for _, c := range []struct{ account, home, says string }{
{"root", "", "may not run as root"},
{"operator", "", "operator account"},
{"Agent", "", "not a login name"},
{"9agent", "", "not a login name"},
{"agent", "relative", "absolute"},
{"", "/home/x", "without an agent account"},
} {
err := inv.SetAgentAccount(ctx, "anchor", c.account, c.home)
if err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%q %q: %v; want a refusal saying %q", c.account, c.home, err, c.says)
}
}
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentAccount != "agent" {
t.Fatalf("a refusal changed the record: %q", n.AgentAccount)
}
if err := inv.SetAgentAccount(ctx, "anchor", "", ""); err != nil {
t.Fatal(err)
}
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentAccount != "" || n.AgentAccountHome != "" {
t.Fatalf("clearing left %q %q", n.AgentAccount, n.AgentAccountHome)
}
if err := inv.SetAgentAccount(ctx, "nowhere", "agent", ""); !errors.Is(err, ErrNoSuchNode) {
t.Fatalf("an unknown node: %v", err)
}
}
+3
View File
@@ -31,6 +31,9 @@ type ResourceHealth struct {
// Account is the account whose own service manager runs it, or the account a resource of kind account
// is (novox/hq ADR 0254).
Account string `json:"account,omitempty"`
// Root is "never" on an account verdict that judged whether the account can become root without a
// person (novox/hq ADR 0266).
Root string `json:"root,omitempty"`
}
// NodeHealth is a machine's newest statement, as kept.
@@ -0,0 +1,13 @@
-- A node names the account its agents run as (novox/hq ADR 0266).
--
-- On the control node every agent session ran as the operator's account, which may become root without
-- a password: any agent there could become root without a person. The decision is an account of the
-- agents' own, without sudo, beside the operator's, who keeps theirs. Stated by the operator at the
-- controller's terminal, like the operator account (migration 0036), and never by a verb or a setting,
-- so no agent can change which account it is.
--
-- Empty rather than null, as the operator account is: empty is a real state, "agents run as the
-- operator's account here" — a workstation's today. The home is stored only when it is not
-- /home/<account>; empty means derive it.
alter table node add column agent_account text not null default '';
alter table node add column agent_account_home text not null default '';
+79 -2
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"time"
@@ -69,6 +70,14 @@ type Node struct {
Account string
AccountHome string
// AgentAccount is the login agents run as on this machine when it is not the operator's — `agent`
// on the control node (novox/hq ADR 0266): an account of their own, without sudo, so no agent there
// can become root without a person. Empty means agents run as the operator account. Stated at the
// controller's terminal only, never by a verb or a setting. AgentAccountHome is its home when not
// /home/<account>; empty means derive it.
AgentAccount string
AgentAccountHome string
// HostVersion is the version of the host this machine reported running (novox/hq 04-ISSUES/087).
// Empty when it has not said since the mesh began keeping it — which is not the same as running
// no host, so nothing derives "behind" from an empty one.
@@ -91,6 +100,17 @@ func (n Node) Home() string {
}
}
// AgentHome is the agent account's home, derived when not stored; empty when no agent account is named.
func (n Node) AgentHome() string {
if n.AgentAccount == "" {
return ""
}
if n.AgentAccountHome != "" {
return n.AgentAccountHome
}
return "/home/" + n.AgentAccount
}
// Silent is how long since this node was last heard from, and whether it ever was.
func (n Node) Silent() (time.Duration, bool) {
if n.LastSeen.IsZero() {
@@ -147,14 +167,14 @@ func (i *Inventory) AddNodeAs(ctx context.Context, name string, adopted bool) (N
// nodeColumns and scanNode are the one reading of a node row, so every way of finding a node
// says whether it is adopted.
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home,
host_version`
agent_account, agent_account_home, host_version`
func scanNode(row pgx.Row) (Node, error) {
var n Node
var seen, since *time.Time
var host *string
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since,
&n.Account, &n.AccountHome, &host); err != nil {
&n.Account, &n.AccountHome, &n.AgentAccount, &n.AgentAccountHome, &host); err != nil {
return Node{}, err
}
if host != nil {
@@ -184,6 +204,63 @@ func (i *Inventory) SetAccount(ctx context.Context, node, account, home string)
return nil
}
// loginName is what a login may be called: what useradd accepts by default, lower case, a letter or
// an underscore first.
var loginName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
// SetAgentAccount records the account agents run as on a node, and optionally its home (novox/hq ADR
// 0266). An empty account clears it: agents run as the operator account again.
//
// **Refused, rather than recorded and judged later:** root, which is the very thing the account exists
// to keep agents from; the node's operator account, which may become root without a password and is
// what agents ran as before — naming it here would say the agents have an account of their own while
// they do not; and a name no machine would accept as a login.
func (i *Inventory) SetAgentAccount(ctx context.Context, node, account, home string) error {
account, home = strings.TrimSpace(account), strings.TrimSpace(home)
if account == "" && home != "" {
return errors.New("a home without an agent account says nothing; name the account too")
}
if account != "" {
if err := AgentAccountRefusal(account, home); err != nil {
return err
}
n, err := i.NodeByName(ctx, node)
if err != nil {
return err
}
if n.Account != "" && n.Account == account {
return fmt.Errorf("%s is %s's operator account: agents would run as the operator, who may become "+
"root; clear the agent account instead (node agent-account %s --clear)", account, node, node)
}
}
tag, err := i.store.Pool().Exec(ctx,
`update node set agent_account = $1, agent_account_home = $2 where name = $3`, account, home, node)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: %s", ErrNoSuchNode, node)
}
return nil
}
// AgentAccountRefusal is why an agent account cannot be named, or nil: root, a malformed login, or a
// home that is not an absolute path.
func AgentAccountRefusal(account, home string) error {
if account == "root" {
return errors.New("agents may not run as root: the agent account exists to keep them from it " +
"(novox/hq ADR 0266)")
}
if !loginName.MatchString(account) {
return fmt.Errorf("%q is not a login name: lower case letters, digits, _ and -, a letter or _ first, "+
"at most 32", account)
}
if home != "" && !strings.HasPrefix(home, "/") {
return fmt.Errorf("the agent account's home %q is not an absolute path", home)
}
return nil
}
// Nodes are every node record, oldest first.
func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
rows, err := i.store.Pool().Query(ctx,