diff --git a/cmd/mesh-controller/desk_secret.go b/cmd/mesh-controller/desk_secret.go index d0441c9d..963f3ac5 100644 --- a/cmd/mesh-controller/desk_secret.go +++ b/cmd/mesh-controller/desk_secret.go @@ -10,6 +10,7 @@ import ( "github.com/nats-io/nats.go" + "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/secrets" ) @@ -44,6 +45,9 @@ type deskGive struct { accept func(value string) (untilStart bool, err error) // record writes the act in the hand-act log. record func(link.HandAct) error + // announce raises the condition that says a module's own secret was given (secretGivenObservation), on + // every channel; nil announces nothing (a test that does not look). + announce func(node, module, name, how string) error } // errNothingGiven is a prompt dismissed, or not answered in time: nothing changes. @@ -63,10 +67,12 @@ func (d deskGive) give(node, module, name, desk string) (string, error) { if err != nil { return "", fmt.Errorf("no key could be made to take the value: %w", err) } + // By name, never by words: the holder writes the prompt from these, and says the controller asks, which + // the bus alone makes true (broker.ControllerOnly). raw, err := d.ask(desk, map[string]any{ - "prompt": name + " for " + module, - "message": fmt.Sprintf("The mesh asks for %s, the own secret of %s on %s. What you type is not shown, "+ - "and is sealed before it leaves this machine. Type it only if you asked for this.", name, module, node), + "module": module, + "secret": name, + "node": node, "seal_to": public, "timeout_seconds": deskPromptWithin, }) @@ -113,6 +119,11 @@ func (d deskGive) give(node, module, name, desk string) (string, error) { if err := d.record(act); err != nil { recorded = fmt.Sprintf("\n this act could NOT be recorded in the hand-act log, and is done anyway: %v", err) } + if d.announce != nil { + if err := d.announce(node, module, name, "at the desk on "+desk); err != nil { + recorded += fmt.Sprintf("\n this change could NOT be announced on the operator's channels: %v", err) + } + } words := fmt.Sprintf("%s on %s now holds %q, given at the desk on %s and sealed to %s; the mesh cannot read it "+ "back.\n run `push %s` to send it", module, node, name, desk, node, node) if untilStart { @@ -157,6 +168,7 @@ func giveAtDesk(ctx context.Context, node, module, name, desk string) error { return err }) }, + announce: func(node, module, name, how string) error { return announceSecretGiven(ctx, node, module, name, how) }, } words, err := d.give(node, module, name, desk) if err != nil { @@ -165,3 +177,31 @@ func giveAtDesk(ctx context.Context, node, module, name, desk string) error { fmt.Println(words) return nil } + +// kindSecretGiven is the condition every value given for a module's own secret raises (the review of 2026-10-09, +// M4): on every channel, so a bot token changed by somebody else — a channel that now answers for them — is +// heard of. It stays until the operator silences or clears it. +const kindSecretGiven = "secret-given" + +// secretGivenObservation is that condition: which secret, of which module on which machine, how and when. +func secretGivenObservation(node, module, name, how string, at time.Time) conditions.Observation { + key := node + "." + module + "." + name + return conditions.Observation{Scope: conditions.ScopeMachine, ID: key, Token: kindSecretGiven, Kind: kindSecretGiven, + Machine: node, Severity: conditions.Urgent, Source: kindSecretGiven, + Summary: fmt.Sprintf("%s of %s on %s was given %s at %s", name, module, node, how, + at.Local().Format("2006-01-02 15:04")), + Headline: "Secret of " + module + " changed", + Explanation: fmt.Sprintf("The secret %s of %s on %s was given %s at %s. If you did not do this, "+ + "somebody else holds what %s acts with.", name, module, node, how, at.Local().Format("15:04"), module), + Needs: "silence this if you gave it; if you did not, give the secret again yourself and unlink what it serves.", + Resolved: "You saw that " + name + " of " + module + " was changed", + Actions: []conditions.Action{conditions.SilenceAction(conditions.Key(conditions.ScopeMachine, key, kindSecretGiven))}} +} + +// announceSecretGiven raises it on this controller's keeper. +func announceSecretGiven(ctx context.Context, node, module, name, how string) error { + return withKeeper(ctx, func(k *conditions.Keeper) error { + _, err := k.Observe(ctx, secretGivenObservation(node, module, name, how, time.Now())) + return err + }) +} diff --git a/cmd/mesh-controller/desk_secret_test.go b/cmd/mesh-controller/desk_secret_test.go index 6cd29545..eef0bc04 100644 --- a/cmd/mesh-controller/desk_secret_test.go +++ b/cmd/mesh-controller/desk_secret_test.go @@ -1,12 +1,15 @@ package main import ( + "context" "encoding/json" "errors" "strings" "testing" + "time" "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/secrets" ) @@ -139,3 +142,87 @@ func TestTheGiveVerbRunsTheDeskPathAndTheControllerMayAskTheDesk(t *testing.T) { t.Error("the controller may not ask the desk's prompt") } } + +// The review of 2026-10-09 (M4): the desk's prompt says who asks in words the caller does not choose — the +// controller, which the bus alone lets ask it — and what for, from names the controller checked; the prompt +// carries no free text of the caller's. +func TestThePromptIsAskedByNameNeverByWordsTheCallerChose(t *testing.T) { + d, _, _, asked := aDesk(t, sealedTo(t, typed)) + if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil { + t.Fatal(err) + } + p := (*asked)[0] + if p["module"] != "telegram" || p["secret"] != "telegram-token" || p["node"] != "anchor" { + t.Errorf("the prompt was not asked by name: %v", p) + } + for _, free := range []string{"prompt", "message"} { + if _, there := p[free]; there { + t.Errorf("the prompt carries the caller's %s: %v", free, p) + } + } +} + +// Every value given for a module's own secret is announced as a condition, on every channel (the review of +// 2026-10-09, M4): a bot token changed by somebody else is a channel that now answers for them. +func TestAValueGivenAtTheDeskIsAnnounced(t *testing.T) { + d, _, _, _ := aDesk(t, sealedTo(t, typed)) + var said []string + d.announce = func(node, module, name, how string) error { + said = append(said, node+" "+module+" "+name+" "+how) + return nil + } + if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil { + t.Fatal(err) + } + if len(said) != 1 || !strings.Contains(said[0], "anchor telegram telegram-token") || !strings.Contains(said[0], "laptop") { + t.Fatalf("announced %v", said) + } + o := secretGivenObservation("anchor", "telegram", "telegram-token", "at the desk on laptop", time.Date(2026, 10, 9, 12, 3, 0, 0, time.UTC)) + if o.Severity != conditions.Urgent || !strings.Contains(o.Explanation, "telegram-token") || + len(o.Actions) == 0 || o.Key() == "" { + t.Errorf("the announcement %+v", o) + } + if strings.Contains(o.Summary+o.Explanation+o.Said, typed) { + t.Error("the announcement carries the value") + } +} + +// The bus lets the controller alone ask the desk's prompt (the review of 2026-10-09, M4): the runtime, which +// carries every agent's calls, and a person granted every tool are denied it, however wide their grant. +func TestOnlyTheControllerMayAskTheDesksPrompt(t *testing.T) { + for _, p := range []broker.Principal{ + {Kind: broker.KindNodeTools, Node: "laptop"}, + {Kind: broker.KindPerson, Module: "operator", Invokes: []string{"*"}}, + {Kind: broker.KindModule, Node: "laptop", Module: "lab", Invokes: []string{"seat:node-launcher.secret"}}, + } { + perms, err := broker.PermissionsFor(p) + if err != nil { + t.Fatal(err) + } + for _, subject := range []string{"mesh.seat.node-launcher.tool.secret.laptop", "mesh.seat.node-launcher.tool.secret", + "mesh.mod.rofi.tool.node-launcher.secret", "mesh.mod.rofi.tool.node-launcher.secret.laptop"} { + if broker.MayPublish(perms, subject) { + t.Errorf("%s may publish %s", p.Username(), subject) + } + } + } + perms, _ := broker.PermissionsFor(broker.Principal{Kind: broker.KindController}) + if !broker.MayPublish(perms, "mesh.seat.node-launcher.tool.secret.laptop") { + t.Error("the controller may not ask the desk's prompt") + } +} + +// A value for a secret comes from the terminal or the desk, never through a verb (the review of 2026-10-09, +// M4): `secret accept` with a value, run for a verb, is refused before anything is read. +func TestASecretValueIsNeverAcceptedThroughAVerb(t *testing.T) { + t.Setenv(verbVar, "mesh-controller.command") + for _, args := range [][]string{ + {"accept", "anchor", "telegram", "telegram-token", "--from", "/dev/null"}, + {"accept", "anchor", "app", "db", "--from", "/dev/null", "--provider", "store"}, + } { + err := secretCommand(context.Background(), args) + if err == nil || !strings.Contains(err.Error(), "never through a verb") { + t.Errorf("%v: %v", args, err) + } + } +} diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index 2d489658..8acdbf3d 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -68,6 +68,12 @@ func secretCommand(ctx context.Context, args []string) error { return errors.New(secretUsage) } node, module, name := rest[0], rest[1], rest[2] + // A value comes from the terminal or the desk, never through a verb (the review of 2026-10-09, M4): a + // verb's caller may be an agent, and a value it chose would become what a module acts with. + if verb, through := throughAVerb(); through && *desk == "" { + return fmt.Errorf("a secret's value is given at the controller's terminal or at the desk (`give`), never "+ + "through a verb (this line came through %q): nothing was read or sealed", verb) + } if *desk != "" { if *from != "" || *provider != "" { return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider") @@ -106,6 +112,9 @@ func secretCommand(ctx context.Context, args []string) error { if err != nil { return err } + if err := announceSecretGiven(ctx, node, module, name, "at the controller's terminal"); err != nil { + fmt.Printf(" this change could NOT be announced on the operator's channels: %v\n", err) + } // Not printed back, and there is nowhere it could be printed from: it is sealed to that // machine and the mesh cannot read it again. fmt.Printf("%s on %s now holds %q, sealed to that machine.\n", module, node, name) diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 415059eb..355c8b1d 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -187,6 +187,35 @@ var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}} // desk opens a prompt that does not show what is typed, and answers it sealed to the controller's call. var VerbsTheControllerAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}} +// ControllerOnly are the subjects the controller alone may publish, however wide another's grant (the review +// of 2026-10-09, M4): the desk's hidden prompt, on its seat's subjects and on any holder's own module +// subjects. A grant of every tool — the runtime's, which carries every agent's calls, or a person's `*` — would +// otherwise reach it, and the prompt says the controller asks: only the bus makes that true. +func ControllerOnly() []string { + var out []string + for _, v := range VerbsTheControllerAsksForASecret { + for _, base := range []string{"mesh.seat." + v.Seat + ".tool." + v.Verb, "mesh.mod.*.tool." + v.Seat + "." + v.Verb} { + out = append(out, base, base+".*") + } + } + return out +} + +// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does. +func MayPublish(perms Permissions, subject string) bool { + for _, d := range perms.PublishDeny { + if SubjectsOverlap(d, subject) { + return false + } + } + for _, a := range perms.Publish { + if SubjectsOverlap(a, subject) { + return true + } + } + return false +} + // VerbsTheControllerAsksTheDeliveryOwner are the mesh-delivery seat's verbs the controller calls (novox/hq // ADR 0239): its self-check reads `stalled`, and healer H2 takes the one transition the table allows // through `close`. A mesh seat's verb is flat: no machine in the subject. @@ -257,8 +286,11 @@ func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" } // Permissions is what a principal may publish and subscribe, and whether it may answer. type Permissions struct { - Publish []string - Subscribe []string + Publish []string + // PublishDeny are subjects refused although an allow covers them: the controller's alone (ControllerOnly), + // denied to everybody whose grant is wide enough to reach them. The server's deny outranks its allow. + PublishDeny []string + Subscribe []string // AllowResponses lets a principal reply to a request it received, on the reply subject that // request carried, once. // @@ -804,9 +836,22 @@ func PermissionsFor(p Principal) (Permissions, error) { if err := CheckWriters(p, pub); err != nil { return Permissions{}, err } + // What the controller alone may publish is denied to everybody else whose grant reaches it. + var deny []string + if p.Kind != KindController { + for _, only := range ControllerOnly() { + for _, a := range pub { + if SubjectsOverlap(a, only) { + deny = append(deny, only) + break + } + } + } + } return Permissions{ - Publish: pub, - Subscribe: sub, + Publish: pub, + PublishDeny: deny, + Subscribe: sub, // A module answers what it was asked — a tool call reaches it on its own namespace, so the // authority is bounded by having been asked — and so does the controller. A node and a // person are never asked anything, and are granted nothing here. @@ -1028,7 +1073,11 @@ func ComposeAccounts(principals []Principal) (string, error) { return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username()) } fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", p.Username(), p.PasswordHash) - fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish)) + if len(perms.PublishDeny) > 0 { + fmt.Fprintf(&b, " publish: { allow: [%s], deny: [%s] }\n", quoted(perms.Publish), quoted(perms.PublishDeny)) + } else { + fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish)) + } fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe)) if perms.AllowResponses { fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute)) diff --git a/internal/catalogue/graphical_session.go b/internal/catalogue/graphical_session.go index 09e738ae..b9d767e7 100644 --- a/internal/catalogue/graphical_session.go +++ b/internal/catalogue/graphical_session.go @@ -84,12 +84,16 @@ func graphicalSessionSeats() []Seat { {Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " + "does not show what is typed, and answer it sealed to the key the asker gives — never in " + "the clear — or cancelled when the prompt was dismissed or not answered in time.", + // By name, never by words (the review of 2026-10-09, M4): the holder writes the prompt from the + // module, the secret and the machine, and says the controller asks — the bus lets nobody else + // ask it (broker.ControllerOnly) — so no caller puts words of its own before the operator. Input: schema(map[string]string{ - "prompt": "what the prompt asks", - "message": "a line saying who asks and for what (optional)", + "module": "the module whose own secret is asked for", + "secret": "the own secret's name", + "node": "the machine the module runs on", "seal_to": "the asker's public sealing key: the answer is sealed to it", "timeout_seconds": "give up after this long (optional)", - }, []string{"prompt", "seal_to"})}, + }, []string{"module", "secret", "node", "seal_to"})}, }}, {Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{ {Name: "send", Description: "Show the operator a notification.", diff --git a/internal/inventory/givable_test.go b/internal/inventory/givable_test.go new file mode 100644 index 00000000..308ed1cf --- /dev/null +++ b/internal/inventory/givable_test.go @@ -0,0 +1,28 @@ +package inventory + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// `give` takes only a value nobody but a person has (the review of 2026-10-09, M4): never the module's bus +// account, which `issue` mints, nor a secret the mesh may make itself. +func TestOnlyASecretThePersonHoldsIsGivenAtTheDesk(t *testing.T) { + m := catalogue.Manifest{Module: "telegram", OwnSecrets: catalogue.OwnSecrets{ + "telegram-token": {Path: "/s/telegram-token"}, + "broker": {Path: "/s/broker"}, + "session": {Path: "/s/session", Taken: catalogue.TakenAtStart}, + }} + if err := GivableAtDesk(m, "telegram-token"); err != nil { + t.Errorf("the bot token was refused: %v", err) + } + for _, name := range []string{"broker", "session", "chat-id"} { + if err := GivableAtDesk(m, name); err == nil { + t.Errorf("%s was givable", name) + } else if name != "chat-id" && !strings.Contains(err.Error(), "the mesh makes") { + t.Errorf("%s: %v", name, err) + } + } +} diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index 6e001eeb..843ae7db 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -561,8 +561,28 @@ func (i *Inventory) DeclaresOwnSecret(ctx context.Context, module, name string) if err != nil { return err } - if _, ok := m.OwnSecrets[name]; !ok { - return fmt.Errorf("%s does not declare %q as an own secret; %s", module, name, declaresOwn(m)) + return GivableAtDesk(m, name) +} + +// BrokerSecret is the own secret that is a module's bus account, which `issue` mints. +const BrokerSecret = "broker" + +// GivableAtDesk refuses, in words, an own secret a module does not declare, and one the mesh makes itself +// (the review of 2026-10-09, M4): the module's bus account, which `issue` mints, and any the mesh may make +// in place of a value given (catalogue.OwnSecret.MeshMayMake). The desk takes only what a person holds and +// the mesh cannot make — a bot's token — so nobody is asked to type the mesh's own credential into a prompt. +func GivableAtDesk(m catalogue.Manifest, name string) error { + own, ok := m.OwnSecrets[name] + if !ok { + return fmt.Errorf("%s does not declare %q as an own secret; %s", m.Module, name, declaresOwn(m)) + } + switch { + case name == BrokerSecret: + return fmt.Errorf("%q is %s's account on the bus, which the mesh makes (`issue`), never a value a person gives", + name, m.Module) + case own.MeshMayMake(): + return fmt.Errorf("%q of %s is a secret the mesh makes itself (it may replace a value given at the module's "+ + "start, ADR 0228); a person gives it only at the controller's terminal, with `secret accept`", name, m.Module) } return nil }