diff --git a/go/asks/asks.go b/go/asks/asks.go index 596bb8f..480f212 100644 --- a/go/asks/asks.go +++ b/go/asks/asks.go @@ -571,3 +571,8 @@ type ProofAnswer struct { Accepted bool `json:"accepted"` Words string `json:"words"` } + +// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR 0259 +// §2 to admit a node's engine as an asker): `node-engine-`, the last token of the by-caller subjects granted to that +// node's engine account alone, so no engine acts on another node's warrant. +func EngineAsker(node string) string { return "node-engine-" + node } diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index c0bd6c0..ed0b460 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -55,6 +55,18 @@ func mayBeyondTheControllers(user string) map[string]bool { VaultSecretChanged: true} } +// engineBeyond is a node's engine holding its node seat and asking the operator in its own name (novox/hq ADR 0563 §1, +// §3), beyond the controller's on purpose: its own node's verbs, events and discovery, the bus's discovery asked, and +// the router's seat traffic under node-engine-. +func engineBeyond(user, g string) bool { + node, ok := strings.CutPrefix(user, "node.") + if !ok { + return false + } + return strings.HasPrefix(g, "mesh.seat."+EngineSeat+".") && strings.Contains(g, "."+node) || + strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) +} + // createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream // and name (novox/hq ADR 0561 §6): beyond the controller's on purpose, since the controller made them alone. func createsAnAccountsConsumer(user, g string) bool { @@ -114,7 +126,7 @@ func grantsAsTheControllers(t *testing.T, got, want string) { } for g := range now { if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) && - !createsAnAccountsConsumer(user, g) { + !createsAnAccountsConsumer(user, g) && !engineBeyond(user, g) { t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g) } } @@ -221,6 +233,55 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS") } }) + // ADR 0563 §1, §3, §6: each node's engine alone answers its own node's node-engine verbs and says its events, any + // runtime may call them, and it asks the operator under node-engine- alone. + t.Run("a node's engine holds its node seat and asks in its own name", func(t *testing.T) { + router := Seat{Name: AsksSeat, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, + ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}} + users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{ + "one": {{Module: "messenger", Holds: []Seat{router}, Invokes: []string{"seat:" + EngineSeat + ".root-free"}}}, + "two": {{Module: RuntimeModule}, {Module: "shop", Invokes: []string{"*"}}}}}) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + perms, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + answers := MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") || + MaySubscribe(perms, "mesh.seat."+EngineSeat+".call.root-free.one.messenger~one") + if engineOne := u.Kind == KindNode && u.Node == "one"; answers != engineOne { + t.Errorf("%s answers one's root-free: %v", u.Username(), answers) + } + switch { + case u.Kind == KindNode && u.Node == "one": + for subject, want := range map[string]bool{ + "mesh.seat.operator-channel.accept.ask.node-engine-one": true, + "mesh.seat.operator-channel.accept.cancel.node-engine-one": true, + "mesh.seat.operator-channel.accept.ask.node-engine-two": false, + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-one.hand-over-1": true, + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-two.hand-over-1": false, + "mesh.seat." + EngineSeat + ".event.done.one": true, + "mesh.seat." + EngineSeat + ".event.done.two": false, + "$SRV.INFO": true, + } { + if got := MayPublish(perms, subject); got != want { + t.Errorf("node one may publish %s: %v, want %v", subject, got, want) + } + } + if !MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-one") || + MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-two") || + !MaySubscribe(perms, "$SRV.INFO."+EngineSeat) || MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.hand-over.two") { + t.Errorf("node one hears %v", perms.Subscribe) + } + case u.Kind == KindNodeTools || u.Module == "messenger": + if !MayPublish(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") { + t.Errorf("%s cannot call one's root-free", u.Username()) + } + } + } + }) // Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no // other node's. t.Run("a node asks the vault its own secrets", func(t *testing.T) { diff --git a/go/buscompose/engine.go b/go/buscompose/engine.go new file mode 100644 index 0000000..95fa03d --- /dev/null +++ b/go/buscompose/engine.go @@ -0,0 +1,30 @@ +package buscompose + +import ( + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module +// claims it. Its verbs and events are the catalogue's (catalogue.EngineVerbs), read here rather than copied. + +// EngineSeat is the seat. +const EngineSeat = catalogue.EngineSeat + +// engineGrants is what a node's engine is granted to hold its node seat and to ask the operator (ADR 0563 §1, §3): it +// answers its own node's verbs (the caller-named subjects are added beside them with every principal's), says the +// seat's events about its node, answers discovery for the seat, asks who answers what (the login shell's execute, ADR +// 0563 §6), and asks the operator under asks.EngineAsker(node) on the router's seat as its holder declares it, as any +// user of that seat is granted. +func engineGrants(p Principal) (pub, sub []string) { + for _, v := range catalogue.EngineVerbs() { + sub = append(sub, "mesh.seat."+EngineSeat+".tool."+v.Name+"."+p.Node) + } + for _, e := range catalogue.EngineEvents { + pub = append(pub, "mesh.seat."+EngineSeat+".event."+e+"."+p.Node) + } + sub = append(sub, announcing(EngineSeat)...) + pub = append(pub, discovering()...) + tp, ts := seatTrafficGrants(SeatTrafficOf(asks.EngineAsker(p.Node), nil, p.Uses, nil)) + return append(pub, tp...), append(sub, ts...) +} diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index fb5f6a5..f196a17 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -970,6 +970,10 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, readGrants(delivery.SignersBucket, "")...) pub = append(pub, readGrants(LeaseBucket, leaseKey)...) } + // And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3). + ep, es := engineGrants(p) + pub = append(pub, ep...) + sub = append(sub, es...) // And, on the node holding mesh-broker, the grants part's sealed user list it follows from take-over (novox/hq ADR // 0547 clause 7): one key, and an object by its name. if p.FollowsGrantsUserList { diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 2424fcf..8f7bc17 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -166,6 +166,7 @@ func Users(r Records) ([]Principal, error) { out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks, MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), + Uses: asksSeatOf(r), FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)}) // **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one // per machine that runs it, delivered to the module running it. diff --git a/go/caller_test.go b/go/caller_test.go index 7fae75c..8f87cb1 100644 --- a/go/caller_test.go +++ b/go/caller_test.go @@ -75,6 +75,21 @@ func TestACallerInTheArgumentsIsNeverTheCaller(t *testing.T) { if got != "shanks.node-tools" { t.Fatalf("with a caller in the arguments the tool read %q, want the runtime's", got) } + // A module's trusted list of callers (issue 774, ADR 0563 §8) grants the callers it names as the runtime proved them: + // a module on any machine, . exactly, person. exactly; no wildcard, never one unproven. + callers, err := ParseCallers([]byte(`{"callers":["node-tools","ace.claude-code","person.jo","*"]}`)) + if err != nil { + t.Fatal(err) + } + for caller, want := range map[string]bool{"shanks.node-tools": true, "ace.claude-code": true, "g14.claude-code": false, + "person.jo": true, "person.node-tools": false, Unproven: false, "": false, "novox.mesh-vault": false} { + if callers.Allows(caller) != want { + t.Errorf("%q allowed: %v, want %v", caller, !want, want) + } + } + if _, err := ParseCallers([]byte(`{"callers":["x"],"more":1}`)); err == nil { + t.Error("a list with a field it does not know was read") + } } // A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is diff --git a/go/callers.go b/go/callers.go new file mode 100644 index 0000000..74a7ce3 --- /dev/null +++ b/go/callers.go @@ -0,0 +1,48 @@ +package stdio + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +// Callers is a module's trusted list of who may call a verb of its (novox/hq ADR 0459 §2, ADR 0563 §8), as the mesh +// writes a list setting into a file the module reads: `{"callers": [...]}`. An entry is a module's name (that module on +// any machine), exactly ., or exactly person.. Each caller is the runtime's proof (CallerOf), never +// an argument; nobody beyond the list is granted, a wildcard is no entry, and an unproven caller never matches. +type Callers []string + +// ParseCallers reads a callers file: the list under `callers`, and nothing else. +func ParseCallers(raw []byte) (Callers, error) { + var f struct { + Callers Callers `json:"callers"` + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&f); err != nil { + return nil, fmt.Errorf("the callers setting is not a list of callers: %v", err) + } + return f.Callers, nil +} + +// Allows says whether caller, as the runtime proved it, is one the list names. +func (c Callers) Allows(caller string) bool { + if caller == "" || caller == Unproven { + return false + } + for _, entry := range c { + if entry == "" || strings.ContainsAny(entry, "*> ") { + continue + } + if entry == caller { + return true + } + if !strings.Contains(entry, ".") { + if _, module, ok := strings.Cut(caller, "."); ok && module == entry && !strings.HasPrefix(caller, "person.") { + return true + } + } + } + return false +} diff --git a/go/catalogue/engine_seat.go b/go/catalogue/engine_seat.go new file mode 100644 index 0000000..71a8afd --- /dev/null +++ b/go/catalogue/engine_seat.go @@ -0,0 +1,57 @@ +package catalogue + +// The node seat node-engine (novox/hq ADR 0563 §1): held on every node by that node's engine (mesh-host), which serves +// its verbs on its own bus account. No module claims it and no assignment holds it: what the mesh knows of its holder +// is the node's record and the engine's haves (ADR 0563 §1 changes ADR 0110 §6 and §7 for this one seat). +// +// Its verbs carry no signature (§7): the runtime proves the caller, which matters only for the record. The read-only +// ones change nothing or are bounded in the engine's own state (§2); hand-over and access-act perform nothing on the +// call, and act only on the operator's warrant to the engine's own ask (§3). + +// EngineSeat is the node seat every node's engine holds. +const EngineSeat = "node-engine" + +// EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them. +func EngineVerbs() []Verb { + return []Verb{ + {Name: "hand-over", Description: "Hand a directory this machine uses as found to the mesh (novox/hq ADR 0563 §3, " + + "§5): the engine judges the path as it does every hand-over (absolute, as it states it, used as found, " + + "reached through no link) and refuses in words, or asks the operator on their channel at the level approve, " + + "binding the owner, group and mode it will set. Nothing changes on the call: only the operator's yes hands " + + "it over. One ask is open per path; acts shows where it stands.", + Input: schema(map[string]string{"path": "the directory's absolute path, as the engine states it", + "why": "why it is asked, in words the operator reads"}, []string{"path"})}, + {Name: "access-act", Description: "Ask the operator for the take act on a library whose root holds data outside its " + + "group (novox/hq ADR 0328 §7, ADR 0563 §3, §4), on the library's home engine: the access as declared and its " + + "root as last declared, judged and refused in words or asked at the level approve. Only the operator's yes " + + "sets the library's group, group write and setgid under its root. One ask is open per access.", + Input: schema(map[string]string{"act": "the act: take", "access": "the access, library:", + "why": "why it is asked, in words the operator reads"}, []string{"act", "access"})}, + {Name: "judge-directories", Description: "Which of this machine's declared directories the engine would refuse to " + + "make or keep, one line each (novox/hq ADR 0563 §2). Read-only: changes no owner or mode.", + Input: schema(map[string]string{}, nil)}, + {Name: "setuid-search", Description: "Throw away the engine's last search for setuid programs and start a full one " + + "(novox/hq ADR 0563 §2); until it completes root-free answers that the machine is not judged yet. Refused while " + + "one runs, and when one was started within the hour, naming when the next may start.", + Input: schema(map[string]string{"why": "why it is asked, in words"}, nil)}, + {Name: "root-free", Description: "Whether this machine is root-free (novox/hq ADR 0259 §8, ADR 0563 §6): free only " + + "on the engine's own verdict, at most fifteen minutes old, that the account agents run as cannot become root " + + "without a person, and the login shell's execute not served here. Anything else is no pass, and says why. " + + "Answered by this machine's engine alone.", + Input: schema(map[string]string{}, nil)}, + {Name: "acts", Description: "Every act this machine's engine was asked through this seat (novox/hq ADR 0563 §9): " + + "each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the " + + "operator's warrant. Read-only.", + Input: schema(map[string]string{"limit": "at most this many, newest"}, nil)}, + } +} + +// EngineEvents are what the seat says of an act, each naming the act, the path or access, the caller as the runtime +// proved it and the ask (ADR 0563 §1). +var EngineEvents = []string{"asked", "done", "refused"} + +// engineSeat is the seat's row in the closed set. +func engineSeat() Seat { + return Seat{Name: EngineSeat, Scope: ScopeNode, Serves: EngineVerbs(), Emits: append([]string(nil), EngineEvents...), + HeldByTheEngine: true, Decision: "novox/hq ADR 0563"} +} diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index ba5ff8f..493d5f3 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -67,6 +67,10 @@ type Seat struct { // names paths. Compiled, never stored, like Receives: adding or taking away a path is a decision, // recorded, and the store's rows carry no column for it. Paths []SeatPath + // HeldByTheEngine says the seat is held on every node by that node's engine, which serves its verbs on its own bus + // account (novox/hq ADR 0563 §1): no module claims it and no assignment holds it. Compiled, never stored, like + // Receives. + HeldByTheEngine bool // Decision is the record that made it a seat. Decision string } @@ -171,7 +175,8 @@ var defaultSeats = append([]Seat{ "origin": "issued for a bus account the controller minted for a core or moved module and hands " + "at once (ADR 0374, issue 787); left out for a value given at the terminal"}), nil)}, {Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " + - "from the controller alone: the ask is recorded in the vault's store and bounded before the prompt " + + "from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR " + + "0563 §8): the ask is recorded in the vault's store and bounded before the prompt " + "opens, and what is typed is sealed to a key the vault made for the call and opens there alone.", Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for", "module": "the module", "name": "the secret's name in the module", @@ -453,6 +458,8 @@ var defaultSeats = append([]Seat{ // A node's files, read and managed (novox/hq ADR 0394, files.go): beside node-reads until mesh-files is assigned // on every node, then node-reads goes (ADR 0394 §9). {Name: FilesSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()}, + // The node's engine's own verbs and asks (novox/hq ADR 0563 §1): held by the engine on every node, by no module. + engineSeat(), }, // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. graphicalSessionSeats()...) @@ -526,7 +533,8 @@ func UseSeats(s []Seat) { row.Paths = d.Paths row.Replicated = d.Replicated row.PulledByKind = d.PulledByKind - row.Yields = d.Yields // never stored (ADR 0553) + row.Yields = d.Yields // never stored (ADR 0553) + row.HeldByTheEngine = d.HeldByTheEngine // never stored (ADR 0563) row.Serves = optionalAsCompiled(row.Serves, d.Serves) } merged = append(merged, row) @@ -639,6 +647,13 @@ func claimProblems(m Manifest) []string { } for _, c := range m.Claims { + if s, known := SeatNamed(c.Name); known && s.HeldByTheEngine { + // The node's engine holds it on every node, and no module may (novox/hq ADR 0563 §1): a module answering + // root-free or a hand-over would be an agent answering it. + problems = append(problems, fmt.Sprintf("%s claims %s, which every node's engine holds itself and no "+ + "module may (novox/hq ADR 0563 §1)", m.Module, c.Name)) + continue + } if _, known := SeatNamed(c.Name); known { // **A seat's scope and what it delivers are not judged here** (novox/hq ADR 0122). // This function runs wherever a manifest is parsed, and one of those places is the diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index e8b5a51..06561a1 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -58,6 +58,24 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { if strings.Join(checkerVerbs, ",") != "try,try-read,try-end" { t.Errorf("the checker seat serves %v", checkerVerbs) } + // The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its six + // verbs and three events, held by no assignment, and a module's claim of it refused. + if engine, ok := SeatNamed(EngineSeat); !ok || engine.Scope != ScopeNode || engine.Delivers != "" || !engine.HeldByTheEngine { + t.Errorf("node-engine is %+v, not a node seat delivering nothing that the engine holds", engine) + } else { + var verbs []string + for _, v := range engine.Serves { + verbs = append(verbs, v.Name) + } + if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts" || + strings.Join(engine.Emits, ",") != "asked,done,refused" { + t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits) + } + } + if _, err := ParseManifest([]byte(`{"module":"thing","version":"1","claims":[{"name":"node-engine","scope":"node"}]}`)); err == nil || + !strings.Contains(err.Error(), "node-engine") { + t.Errorf("a module's claim of node-engine was not refused: %v", err) + } // Forty-six with the three build seats replacing node-build-agent and the retired mesh-build-machine (novox/hq // ADR 0525 §2, §10); forty-five with node-files (novox/hq ADR 0394); forty-four again once node-reads goes. // Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired @@ -71,9 +89,9 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { // node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203, // ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired // mesh-build-machine row goes, when no registered manifest claims it. - // Forty-seven with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774). - if len(Seats()) != 47 { - t.Errorf("the mesh defines %d seats rather than 47; the set is closed, so a change here is "+ + // Forty-seven with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774); forty-eight with node-engine (novox/hq ADR 0563 §1). + if len(Seats()) != 48 { + t.Errorf("the mesh defines %d seats rather than 48; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } }