diff --git a/modules/messenger/README.md b/modules/messenger/README.md index 3932f4c2..cd851c2d 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -36,9 +36,21 @@ channel's client and no channel's secret. linked an hour or more. The ask is then closed by compare-and-set — a second answer loses — and the warrant published to the asker alone. A warrant the bus did not take is published again every minute. - **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would. -- **Linking** (`/start` to the bot): a six-digit code is shown on the desk for ten minutes, the operator - sends it to the bot, and the account is linked; it acknowledges at once and approves after an hour. - Every link and unlink is said on every channel. `messenger_unlink` unlinks. +- **Linking** (`/start` to the bot): a six-digit code is shown, with the account's name and number, on a + channel whose words stay private (the desk) for ten minutes; the operator sends it to the bot, and the + account is linked. It acknowledges at once and approves after an hour. At most three links are asked of a + kind in an hour. Every link and unlink is said on every channel. +- **Unlinking is asked of the account that is linked** (`messenger_unlink` asks it): an approve question put + to that account alone. Its approval unlinks it, told to it first, by name, then everywhere. **A link within + a day of an unlink waits for the replaced account's approval**, asked of it by name. +- **When that account is lost with its phone**, root at the router's machine leaves a file in this module's + state directory — `unlink.`, or `allow-link.` for a link that waits — and the router does it, + announced as done at the machine. A file not made by root is refused and said. +- **A channel proving its sender says only to the linked account**: none linked, it is not ready, and asks + for approval are not offered on it. While the controller says an agent can become root without a person + where the router or the channel runs (`agent-root`), no approval is offered or taken there. +- **The router runs as its own account `messenger`, on a bus account of its own**, never carried by the + machine's runtime (ADR 0259 §8): its state and its bus credential are that account's. ## What is sent, and when diff --git a/modules/messenger/cmd/messenger/answers.go b/modules/messenger/cmd/messenger/answers.go index 168707e4..55ba0fc6 100644 --- a/modules/messenger/cmd/messenger/answers.go +++ b/modules/messenger/cmd/messenger/answers.go @@ -229,7 +229,17 @@ func (h *Holder) Failed(kind string, f asks.Failed) { r.Pending = "raised" } } + desk := h.Bench != nil && h.Bench.roleOf(kind) == RoleDesk h.mu.Unlock() + // A warning the desk could not show goes to the channel that reaches the operator away, as it did when the + // desk was the router's own (the correctness review of 2026-10-08). + if desk && r != nil && r.Cleared.IsZero() && r.Severity != Urgent && h.Away != nil && h.Away.Ready() == nil { + h.enqueue(h.Away.Name(), item{Key: r.Key, What: "raised", Severity: r.Severity, At: h.Now()}) + h.mu.Lock() + r.Pending = "" + h.mu.Unlock() + h.flush(h.Now(), false) + } outcome := "failed: " + f.Why if f.Permanent { outcome = "failed for good: " + f.Why diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index 41acebc1..156abef0 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -2,6 +2,9 @@ package main import ( "encoding/json" + "fmt" + "os" + "path/filepath" "strings" "testing" "time" @@ -14,16 +17,17 @@ import ( // memAsks is the router's state in memory, with the compare-and-set the bus gives. type memAsks struct { - asks map[string]AskRecord - revs map[string]uint64 - ids map[string]Identity - links map[string]PendingLink - rev uint64 + asks map[string]AskRecord + revs map[string]uint64 + ids map[string]Identity + links map[string]PendingLink + unlinked map[string]Unlinked + rev uint64 } func newMemAsks() *memAsks { return &memAsks{asks: map[string]AskRecord{}, revs: map[string]uint64{}, ids: map[string]Identity{}, - links: map[string]PendingLink{}} + links: map[string]PendingLink{}, unlinked: map[string]Unlinked{}} } func clone(r AskRecord) AskRecord { @@ -89,6 +93,14 @@ func (m *memAsks) GetLink(kind string) (*PendingLink, error) { } func (m *memAsks) PutLink(kind string, l PendingLink) error { m.links[kind] = l; return nil } func (m *memAsks) DeleteLink(kind string) error { delete(m.links, kind); return nil } +func (m *memAsks) GetUnlinked(kind string) (*Unlinked, error) { + u, ok := m.unlinked[kind] + if !ok { + return nil, nil + } + return &u, nil +} +func (m *memAsks) PutUnlinked(kind string, u Unlinked) error { m.unlinked[kind] = u; return nil } // told is what the router published to askers, by subject. type told struct{ by map[string][]asks.Warrant } @@ -104,9 +116,16 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA w := &told{by: map[string][]asks.Warrant{}} bench := NewBench(c.now) bench.SetKinds([]KindInfo{ - {Kind: "telegram", Module: "telegram", Capabilities: []string{"deliver", "reaches-away", "choice", "edit", "verified-sender"}}, - {Kind: "desktop", Module: "desk-channel", Capabilities: []string{"deliver", "choice", "edit"}}, + {Kind: "telegram", Module: "telegram", Node: "anchor", Capabilities: []string{"deliver", "reaches-away", "choice", "edit", + "verified-sender", "exact-render"}}, + {Kind: "desktop", Module: "desk-channel", Node: "anchor", Capabilities: []string{"deliver", "choice", "edit", "exact-render", "private"}}, }) + bench.To = func(kind string) string { + if ids := h.identitiesOf(kind); len(ids) > 0 { + return ids[0].Identity + } + return "" + } bench.Stand("telegram", Standing{Ready: true, At: c.now()}) bench.Stand("desktop", Standing{Ready: true, At: c.now()}) h.Bench, h.Asks = bench, store @@ -198,16 +217,22 @@ func TestAnAskIsOfferedOnEachKindAsFarAsItCanCarryIt(t *testing.T) { } func TestWithoutALinkedAccountOnlyTheMeshMCPServerApproves(t *testing.T) { - h, tg, _, c, _, _ := newRouter(t) + h, tg, dt, c, _, _ := newRouter(t) submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) - c.pass(BurstWindow) - h.Flush() - if len(tg.sends) == 0 { - t.Fatal("nothing was said") + settle(h, c) + // Telegram says only to the account linked as the operator: none linked, it is not used at all. + if len(tg.sends) != 0 { + t.Errorf("an unlinked Telegram was sent %d messages", len(tg.sends)) } - m := tg.sends[0] + if err := h.Bench.Ready("telegram"); err == nil || !strings.Contains(err.Error(), "send /start to the bot") { + t.Errorf("an unlinked Telegram is ready: %v", err) + } + if len(dt.sends) == 0 { + t.Fatal("nothing was said on the desk") + } + m := dt.sends[0] if got := labelsOf(m); got != "Silence for a week" || !strings.Contains(m.Body, "Release or Stop: from the mesh MCP server.") { - t.Errorf("telegram without a link offers %q and says %q", got, m.Body) + t.Errorf("the desk without a link offers %q and says %q", got, m.Body) } } @@ -561,13 +586,6 @@ func TestLinkingShowsACodeOnTheDeskAndLinksOnlyWhoAsked(t *testing.T) { if _, pending := store.links["telegram"]; pending { t.Error("a second link was begun while one is linked") } - // Unlinking is said everywhere, and the account's answers are refused from then. - if _, err := h.Unlink("telegram"); err != nil { - t.Fatal(err) - } - if len(store.ids) != 0 || !strings.Contains(tg.sends[len(tg.sends)-1].Title, "No Telegram account is linked") { - t.Errorf("not unlinked and said: %v %q", store.ids, tg.sends[len(tg.sends)-1].Title) - } } func TestALinkCodeExpiresAndFiveWrongTriesEndIt(t *testing.T) { @@ -591,3 +609,206 @@ func TestALinkCodeExpiresAndFiveWrongTriesEndIt(t *testing.T) { t.Error("the desk began a link") } } + +// novox/hq ADR 0259 §7 (revised): unlinking is an approve ask put to the account that is linked; its notice +// reaches that account first, by name; nobody else's answer unlinks it. +func TestUnlinkingIsAskedOfTheLinkedAccountAndToldToItFirst(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + got, err := h.Unlink("telegram") + if err != nil || got["asked"] != true { + t.Fatalf("%v %v", got, err) + } + if len(store.ids) != 1 { + t.Fatal("unlinked before the account answered") + } + ask := tg.sends[len(tg.sends)-1] + if ask.Title != "Urgent: Unlink this account?" || tg.to[len(tg.to)-1] != "42" || labelsOf(ask) != "Unlink" { + t.Fatalf("the question: %q to %q offers %q", ask.Title, tg.to[len(tg.to)-1], labelsOf(ask)) + } + if last := dt.sends[len(dt.sends)-1]; !strings.Contains(last.Title, "An unlink of your Telegram account was asked") { + t.Errorf("the desk was not told: %q", last.Title) + } + unlink := ticketFor(ask, "Unlink") + // Another account cannot answer it. + if out, _ := h.Chosen("telegram", choiceOf(unlink, "666", c.now())); out != "refused" || len(store.ids) != 1 { + t.Fatalf("another account unlinked it: %s", out) + } + before := len(tg.sends) + if out, err := h.Chosen("telegram", choiceOf(unlink, "42", c.now())); err != nil || out != "chosen" { + t.Fatalf("%s %v", out, err) + } + if len(store.ids) != 0 { + t.Fatal("the account's own approval did not unlink it") + } + notice := tg.sends[before] + if notice.Title != "This account is no longer linked as you" || tg.to[before] != "42" { + t.Errorf("the account was not told first, by name: %q to %q", notice.Title, tg.to[before]) + } + if last := dt.sends[len(dt.sends)-1]; !strings.Contains(last.Title, "No Telegram account is linked as you") { + t.Errorf("the desk was not told: %q", last.Title) + } +} + +// A link within a day of an unlink waits for the replaced account's approval. +func TestARelinkWithinADayWaitsForTheReplacedAccount(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + _, _ = h.Unlink("telegram") + _, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now())) + h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}}) + code := codeOf(t, dt.sends[len(dt.sends)-1]) + a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code}) + if !a.Accepted || !strings.Contains(a.Words, "waits for that account's approval") || len(store.ids) != 0 { + t.Fatalf("linked at once within a day of an unlink: %+v %v", a, store.ids) + } + asked := tg.sends[len(tg.sends)-1] + if asked.Title != "Urgent: Allow another account in your place?" || tg.to[len(tg.to)-1] != "42" { + t.Fatalf("the replaced account was not asked: %q to %q", asked.Title, tg.to[len(tg.to)-1]) + } + if out, _ := h.Chosen("telegram", choiceOf(ticketFor(asked, "Allow"), "666", c.now())); out != "refused" { + t.Error("the new account allowed itself") + } + if out, err := h.Chosen("telegram", choiceOf(ticketFor(asked, "Allow"), "42", c.now())); err != nil || out != "chosen" { + t.Fatalf("%s %v", out, err) + } + if id, ok := store.ids[identityKey("telegram", "666")]; !ok || id.ApproveFrom.Sub(id.Linked) != time.Hour { + t.Errorf("not linked on the replaced account's approval: %+v", store.ids) + } +} + +// Root at the router's machine unlinks, or allows a waiting link, by a file only root makes. +func TestBreakGlassIsAFileRootMade(t *testing.T) { + h, _, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + h.StateDir = t.TempDir() + if err := os.WriteFile(filepath.Join(h.StateDir, "unlink.telegram"), nil, 0o600); err != nil { + t.Fatal(err) + } + h.BreakGlass() + if len(store.ids) != 1 || !strings.Contains(dt.sends[len(dt.sends)-1].Title, "was refused") { + t.Fatalf("a file not made by root unlinked: %v", store.ids) + } + was := fileOwner + t.Cleanup(func() { fileOwner = was }) + fileOwner = func(os.FileInfo) uint32 { return 0 } + _ = os.WriteFile(filepath.Join(h.StateDir, "unlink.telegram"), nil, 0o600) + h.BreakGlass() + if len(store.ids) != 0 { + t.Fatal("root's file did not unlink") + } + if _, err := os.Stat(filepath.Join(h.StateDir, "unlink.telegram")); err == nil { + t.Error("the file was left to act again") + } +} + +// While an agent can become root where the router or the channel runs, nothing proven there approves. +func TestNoApprovalWhileAnAgentCanBecomeRootThere(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + release := ticketFor(tg.sends[0], "Release") + h.mu.Lock() + h.open["machine.anchor.agent-root"] = &Record{Key: "machine.anchor.agent-root", Kind: KindAgentRoot, Severity: Urgent, + Origin: "condition", Raised: c.now()} + h.mu.Unlock() + if out, err := h.Chosen("telegram", choiceOf(release, "42", c.now())); err == nil || out != "refused" || + !strings.Contains(err.Error(), "can become root on anchor") { + t.Fatalf("approved while an agent can become root there: %s %v", out, err) + } + if len(w.of("mesh-delivery")) != 0 { + t.Error("a warrant was issued") + } + if got := h.carriers(asks.Approve); len(got) != 0 { + t.Errorf("an approval is still offered on %v", got) + } +} + +// Another asker naming a condition's key does not take that condition's message over. +func TestOnlyTheConditionsOwnerAsksOnItsMessage(t *testing.T) { + h, _, _, c, store, _ := newRouter(t) + k := cond("delivery.hq.held", Warning, "the delivery of hq is held") + k.Raised = c.now() + h.Condition(EventRaised, k) + a := heldAsk(c, "d-1") + a.About = "delivery.hq.held" + submit(t, h, "mesh-delivery", a) + if got := store.asks["mesh-delivery.d-1"].Record; got == "delivery.hq.held" { + t.Error("another asker took the condition's message") + } +} + +// A channel that does not render exactly as the router does carries no answer. +func TestAnAnswerNeedsAChannelThatRendersExactly(t *testing.T) { + h, _, _, c, _, _ := newRouter(t) + h.Bench.SetKinds([]KindInfo{{Kind: "desktop", Module: "desk-channel", Capabilities: []string{"deliver", "choice", "private"}}}) + h.Bench.Stand("desktop", Standing{Ready: true, At: c.now()}) + if got := h.carriers(asks.Acknowledge); len(got) != 0 { + t.Errorf("a channel without exact-render carries %v", got) + } +} + +// At most three links an hour per kind, and the code is shown only where the words stay private. +func TestLinksAreBoundedAndTheCodeShownOnlyInPrivate(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + jo := asks.Sender{Identity: "42", Display: "Jo", Authenticated: true} + for i := 0; i < LinksAnHour; i++ { + h.Link("telegram", asks.LinkAsked{ID: fmt.Sprint("l", i), At: c.now(), Sender: jo}) + } + shown := len(dt.sends) + h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: jo}) + if len(dt.sends) != shown || !strings.Contains(tg.sends[len(tg.sends)-1].Body, "Try again in an hour") { + t.Error("a fourth link this hour showed a code") + } + if !strings.Contains(dt.sends[0].Body, "(number 42)") { + t.Errorf("the notice does not name the account by its number: %q", dt.sends[0].Body) + } + h2, tg2, _, c2, store2, _ := newRouter(t) + h2.Bench.SetKinds([]KindInfo{ + {Kind: "telegram", Module: "telegram", Capabilities: []string{"choice", "verified-sender", "exact-render", "reaches-away"}}, + {Kind: "desktop", Module: "desk-channel", Capabilities: []string{"choice", "exact-render"}}}) + h2.Link("telegram", asks.LinkAsked{ID: "l1", At: c2.now(), Sender: jo}) + if _, pending := store2.links["telegram"]; pending || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "No private place") { + t.Error("a code was shown where the words are not private") + } + _ = store +} + +// A warning the desk could not show goes to the channel that reaches the operator away. +func TestAWarningTheDeskCouldNotShowGoesAway(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + k := cond("module.x.unhealthy", Warning, "x is not healthy") + k.Raised = c.now() + h.Condition(EventRaised, k) + settle(h, c) + if len(dt.sends) != 1 { + t.Fatalf("the desk was sent %d", len(dt.sends)) + } + h.Failed("desktop", asks.Failed{Handle: "g14=1", Why: "no session answers", At: c.now()}) + c.pass(BurstWindow) + h.Flush() + if len(tg.sends) == 0 { + t.Error("a warning the desk could not show did not go to Telegram") + } +} + +// codeOf reads the six digits a link's notice shows. +func codeOf(t *testing.T, m Message) string { + t.Helper() + i := strings.Index(m.Body, ": ") + for i >= 0 { + rest := m.Body[i+2:] + if len(rest) >= 7 && rest[3] == ' ' { + return rest[:3] + rest[4:7] + } + j := strings.Index(rest, ": ") + if j < 0 { + break + } + i += 2 + j + } + t.Fatalf("no code in %q", m.Body) + return "" +} diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 89b54d3c..59b23355 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -62,6 +62,11 @@ type AskRecord struct { Told bool `json:"told,omitempty"` // Record is the message the ask is shown on: a condition's key, or its own. Record string `json:"record,omitempty"` + // Only is the one account that may answer it, on its kind: an ask of the router's own, put to the account + // an unlink or a relink concerns (novox/hq ADR 0259 §7). + Only *Identity `json:"only,omitempty"` + // Act is what the router does on its own ask's warrant: unlink, or link (selfact.go). + Act *SelfAct `json:"act,omitempty"` } // Identity is one account linked as the operator on a channel kind. @@ -86,6 +91,8 @@ type AskStore interface { GetLink(kind string) (*PendingLink, error) PutLink(kind string, l PendingLink) error DeleteLink(kind string) error + GetUnlinked(kind string) (*Unlinked, error) + PutUnlinked(kind string, u Unlinked) error } type askEntry struct { @@ -161,14 +168,15 @@ func (h *Holder) carriers(level asks.Level) []string { } var out []string for _, k := range h.Bench.Kinds() { - if h.Bench.Ready(k.Kind) != nil || !h.Bench.Promises(k.Kind, "choice") { + // Every level needs a choice made in one act, shown exactly as the router rendered it (ADR 0234 §8). + if h.Bench.Ready(k.Kind) != nil || !h.Bench.Promises(k.Kind, "choice") || !h.Bench.Promises(k.Kind, "exact-render") { continue } switch level { case asks.Acknowledge: out = append(out, k.Kind) case asks.Approve: - if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) { + if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) && h.rootFreeFor(k) == "" { out = append(out, k.Kind) } } @@ -177,6 +185,32 @@ func (h *Holder) carriers(level asks.Level) []string { return out } +// rootFreeFor is why a kind's proof is not believed now, or "": an agent can become root without a person +// where the router or the kind's holder runs, which the controller's self-check says as an open condition +// (novox/hq ADR 0259 §8, rule 2). +func (h *Holder) rootFreeFor(k KindInfo) string { + h.mu.Lock() + defer h.mu.Unlock() + for _, r := range h.open { + if r.Kind != KindAgentRoot || !r.Cleared.IsZero() { + continue + } + machine := strings.TrimPrefix(strings.TrimSuffix(r.Key, "."+KindAgentRoot), "machine.") + if machine == k.Node || (h.Node != "" && machine == h.Node) { + return "a program working for you can become root on " + machine + " without asking you, so an answer " + + "proven there proves nothing until that changes" + } + } + return "" +} + +// ConditionOwner is the module whose asks may be shown on a condition's message: the controller, which +// raises the conditions. +const ConditionOwner = "mesh-controller" + +// KindAgentRoot is the controller's condition while an agent can become root without a person on a machine. +const KindAgentRoot = "agent-root" + // TakeAsk takes one ask or cancel from the seat's queue. asker is the subject's last token, which the bus // grants that module alone. An error is returned only when the ask could not be recorded, so the queue // offers it again; a refusal is said to the asker and the work is taken. @@ -257,8 +291,10 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { // Shown on the condition it is about, when that is open here; otherwise as a message of its own. h.mu.Lock() target := h.open["ask."+key] - if a.About != "" { - if r := h.open[a.About]; r != nil && r.Cleared.IsZero() { + // Shown on a condition's message only when asked by the condition's owner: another asker naming a + // condition's key does not take its message over. + if a.About != "" && asker == ConditionOwner { + if r := h.open[a.About]; r != nil && r.Cleared.IsZero() && r.Origin == "condition" { target = r } } @@ -416,6 +452,13 @@ func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *ask rev = newRev } h.keepAsk(key, rec, rev) + if rec.Asker == SelfAsker { + // The router's own ask: it does what was decided, and tells nobody else (selfact.go). + h.markTold(key) + h.showClosed(rec) + h.selfAct(rec, *w) + return nil + } if h.tell(*w) == nil { h.markTold(key) } @@ -590,7 +633,7 @@ func (h *Holder) judge(kind string, c asks.Choice, key string) (string, error) { return h.refuseChoice(kind, c, "it names an answer the question does not offer") } info, held := h.Bench.Kind(kind) - if !held || !h.Bench.Promises(kind, "choice") { + if !held || !h.Bench.Promises(kind, "choice") || !h.Bench.Promises(kind, "exact-render") { return h.refuseChoice(kind, c, "this channel is not one the mesh holds for answers") } person := asks.Person{Who: asks.Operator, Kind: kind, Identity: c.Sender.Identity, Display: c.Sender.Display} @@ -601,6 +644,14 @@ func (h *Holder) judge(kind string, c asks.Choice, key string) (string, error) { return h.refuseChoice(kind, c, "the service did not authenticate who sent it") } id = h.identity(kind, c.Sender.Identity) + if rec.Only != nil { + // An ask put to one account is answered by that account alone, linked or not any more. + if rec.Only.Kind != kind || rec.Only.Identity != c.Sender.Identity { + return h.refuseChoice(kind, c, "this question was put to another account") + } + only := *rec.Only + id = &only + } if id == nil { return h.refuseChoice(kind, c, "this account is not linked as the operator") } @@ -619,6 +670,9 @@ func (h *Holder) judge(kind string, c asks.Choice, key string) (string, error) { return h.refuseChoice(kind, c, "this account was linked at "+id.Linked.In(h.zone()).Format("15:04")+ " and can approve from "+id.ApproveFrom.In(h.zone()).Format("15:04")) } + if why := h.rootFreeFor(info); why != "" { + return h.refuseChoice(kind, c, why) + } proofs = append(proofs, "P1") default: return h.refuseChoice(kind, c, "it needs a code from the authenticator app, which the mesh does not take yet") @@ -645,6 +699,30 @@ func (h *Holder) refuseChoice(kind string, c asks.Choice, why string) (string, e return "refused", errorf("%s", why) } +// showOnHandle is showOn, answering the handle the message is known by. +func (h *Holder) showOnHandle(kind string, m Message) (string, error) { + for _, ch := range h.channels() { + if ch.Name() == kind { + id, err := ch.Send(m) + h.noteChannel(kind, err) + return id, err + } + } + if h.Publish == nil { + return "", errors.New("this router cannot reach the bus") + } + out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To} + if out.To == "" && h.Bench != nil && h.Bench.To != nil { + out.To = h.Bench.To(kind) + } + for _, a := range m.Actions { + out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token}) + } + err := h.Publish(asks.ChannelSubject(asks.Show, kind), out, out.Handle) + h.noteChannel(kind, err) + return out.Handle, err +} + // showOn says a message of the router's own on one kind, at once: an answer's refusal, details, a link's code. func (h *Holder) showOn(kind string, m Message) error { for _, ch := range h.channels() { @@ -661,6 +739,12 @@ func (h *Holder) showOn(kind string, m Message) error { if h.Bench != nil && h.Bench.To != nil { out.To = h.Bench.To(kind) } + if m.To != "" { + out.To = m.To + } + for _, a := range m.Actions { + out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token}) + } err := h.Publish(asks.ChannelSubject(asks.Send, kind), out, out.Handle) h.noteChannel(kind, err) return err diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 0fbd5803..75db0160 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -91,6 +91,9 @@ type Message struct { Actions []Choice // Reply is the answer this message answers, by its id: the channel shows it where that was given. Reply string + // To is the account it is for, when not the one linked: a notice to an account being unlinked, or an + // ask put to the account a relink replaces. + To string } // Choice is one answer a message offers: the token a channel hands back when it is chosen, and its label. @@ -229,8 +232,12 @@ type Holder struct { Asks AskStore Bench *Bench Publish func(subject string, body any, id string) error - Now func() time.Time - Logf func(string, ...any) + // Node is the machine this router runs on; StateDir its own directory, where root leaves a break-glass + // request (selfact.go). + Node string + StateDir string + Now func() time.Time + Logf func(string, ...any) // Emit states a fact as this module (refused); nil states nothing. Emit func(event string, body any) error // Ask calls a seat verb through the mesh, as this module: the answers the operator chooses. @@ -270,6 +277,9 @@ type Holder struct { // asks are the asks this router holds, by `.`, and tickets each ask's tickets to it. asks map[string]*askEntry tickets map[string]string + // linksAsked are when links were asked, by kind, for the hourly bound; lastUnlink the last unlink of each. + linksAsked map[string][]time.Time + lastUnlink map[string]Unlinked } func (h *Holder) init() { @@ -551,6 +561,7 @@ func (h *Holder) Tick() { h.mu.Unlock() sort.Slice(recs, func(i, j int) bool { return recs[i].Raised.Before(recs[j].Raised) }) h.sweepAsks(now) + h.BreakGlass() for _, r := range recs { switch { case !r.Cleared.IsZero(): diff --git a/modules/messenger/cmd/messenger/holder_test.go b/modules/messenger/cmd/messenger/holder_test.go index f96281fd..1f11b367 100644 --- a/modules/messenger/cmd/messenger/holder_test.go +++ b/modules/messenger/cmd/messenger/holder_test.go @@ -9,6 +9,7 @@ import ( ) type fakeChannel struct { + to []string // whom each send was for, by account; "" for the linked one name string notReady error fail error @@ -29,6 +30,7 @@ func (f *fakeChannel) Send(m Message) (string, error) { } f.n++ f.sends = append(f.sends, m) + f.to = append(f.to, m.To) if f.name == "desktop" { return fmt.Sprintf("g14=%d", f.n), nil // as the desktop answers: the machine and the notification } diff --git a/modules/messenger/cmd/messenger/link.go b/modules/messenger/cmd/messenger/link.go index d321c848..8f11c9de 100644 --- a/modules/messenger/cmd/messenger/link.go +++ b/modules/messenger/cmd/messenger/link.go @@ -49,6 +49,8 @@ type PendingLink struct { CodeHash string `json:"code-hash"` Expires time.Time `json:"expires"` Tries int `json:"tries"` + // Confirmed: the code was right, and the link waits for the account it replaces. + Confirmed bool `json:"confirmed,omitempty"` } func identityKey(kind, identity string) string { return kind + "." + kvKey(identity) } @@ -93,7 +95,11 @@ func (h *Holder) identitiesOf(kind string) []Identity { func (h *Holder) linked(kind string) bool { return len(h.identitiesOf(kind)) > 0 } -// Link takes a link asked for on a kind: a code shown on the desk, or a refusal said on the kind. +// LinksAnHour is how many links may be asked of one kind in an hour. +const LinksAnHour = 3 + +// Link takes a link asked for on a kind: a code shown where only the operator sees it, or a refusal said on +// the kind. func (h *Holder) Link(kind string, l asks.LinkAsked) string { h.work.Lock() defer h.work.Unlock() @@ -107,7 +113,8 @@ func (h *Holder) Link(kind string, l asks.LinkAsked) string { if !l.Sender.Authenticated || l.Sender.Identity == "" { return reply("Not linked", "The service did not say who you are.") } - if now := h.Now(); now.Sub(l.At) > FreshFor { + now := h.Now() + if now.Sub(l.At) > FreshFor { h.Logf("[messenger] a link asked on %s at %s is too old to act on", kind, l.At.Format(time.RFC3339)) return "too old" } @@ -115,10 +122,38 @@ func (h *Holder) Link(kind string, l asks.LinkAsked) string { if id.Identity == l.Sender.Identity { return reply("Already linked", "This account is already linked as the operator.") } - return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked from the mesh MCP server (messenger_unlink), and then this one can be linked.") + return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked "+ + "with that account's own approval, and then this one can be linked.") } - if h.Desk == nil || h.Desk.Ready() != nil { - return reply("Not linked", "No desk can show the code now. Try again when you are at your machine.") + h.mu.Lock() + if h.linksAsked == nil { + h.linksAsked = map[string][]time.Time{} + } + var recent []time.Time + for _, t := range h.linksAsked[kind] { + if now.Sub(t) < time.Hour { + recent = append(recent, t) + } + } + full := len(recent) >= LinksAnHour + if !full { + recent = append(recent, now) + } + h.linksAsked[kind] = recent + h.mu.Unlock() + if full { + return reply("Not linked", fmt.Sprintf("Links were asked %d times this hour. Try again in an hour.", LinksAnHour)) + } + // A code is shown only where the words stay on the operator's machines: a kind declaring `private`. + shows := "" + for _, k := range h.Bench.Kinds() { + if k.Kind != kind && h.Bench.Promises(k.Kind, "private") && h.Bench.Ready(k.Kind) == nil { + shows = k.Kind + break + } + } + if shows == "" { + return reply("Not linked", "No private place can show the code now. Try again when you are at your machine.") } code, err := sixDigits() if err != nil { @@ -126,7 +161,7 @@ func (h *Holder) Link(kind string, l asks.LinkAsked) string { } salt := ticket() p := PendingLink{Kind: kind, Identity: l.Sender.Identity, Display: l.Sender.Display, Salt: salt, - CodeHash: hashCode(salt, code), Expires: h.Now().Add(LinkCodeFor)} + CodeHash: hashCode(salt, code), Expires: now.Add(LinkCodeFor)} if err := h.Asks.PutLink(kind, p); err != nil { return reply("Not linked", "The link could not be recorded: "+err.Error()) } @@ -134,16 +169,16 @@ func (h *Holder) Link(kind string, l asks.LinkAsked) string { if who == "" { who = "an account" } - desk := Message{Title: "Link a " + Display(kind) + " account as you", - Body: fmt.Sprintf("The %s account %q asked to be linked as you. If that is you, send this code to the bot "+ - "before %s: %s %s. If it is not you, do nothing: nothing is linked.", Display(kind), who, - p.Expires.In(h.zone()).Format("15:04"), code[:3], code[3:]), + notice := Message{Title: "Link a " + Display(kind) + " account as you", + Body: fmt.Sprintf("The %s account %q (number %s) asked to be linked as you. If that is you, send this code to "+ + "the bot before %s: %s %s. If it is not you, do nothing: nothing is linked.", Display(kind), who, + l.Sender.Identity, p.Expires.In(h.zone()).Format("15:04"), code[:3], code[3:]), Urgent: true} - if _, err := h.Desk.Send(desk); err != nil { + if err := h.showOn(shows, notice); err != nil { _ = h.Asks.DeleteLink(kind) - return reply("Not linked", "The desk could not show the code: "+err.Error()) + return reply("Not linked", "The code could not be shown: "+err.Error()) } - h.Logf("[messenger] a link was asked on %s by %q; its code is shown on the desk", kind, who) + h.Logf("[messenger] a link was asked on %s by %q (%s); its code is shown on %s", kind, who, l.Sender.Identity, shows) return reply("Send the code shown on your desk", "Six digits, within ten minutes.") } @@ -159,8 +194,8 @@ func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer { return no("Nothing can be linked here.") } p, err := h.Asks.GetLink(kind) - if err != nil || p == nil { - return no("No link was asked for. Send /start to begin.") + if err != nil || p == nil || p.Confirmed { + return no("No link waits for a code. Send /start to begin.") } now := h.Now() if !now.Before(p.Expires) { @@ -181,44 +216,32 @@ func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer { _ = h.Asks.PutLink(kind, *p) return no("That code is not right.") } - id := Identity{Kind: kind, Identity: p.Identity, Display: p.Display, Linked: now, ApproveFrom: now.Add(LinkedApproveAfter)} - if err := h.Asks.PutIdentity(identityKey(kind, p.Identity), id); err != nil { - return no("The link could not be recorded: " + err.Error()) - } - _ = h.Asks.DeleteLink(kind) - from := id.ApproveFrom.In(h.zone()).Format("15:04") - h.announce(Message{Title: "A " + Display(kind) + " account is now linked as you", - Body: fmt.Sprintf("The %s account %q can answer acknowledgements now, and approve from %s. If this was not "+ - "you, unlink it from the mesh MCP server (messenger_unlink) before then.", Display(kind), p.Display, from), - Urgent: true}) - h.record(Sent{At: now, Channel: kind, Key: "(link)", What: "linked " + p.Display, Outcome: "linked"}) - h.Logf("[messenger] linked %q on %s; it approves from %s", p.Display, kind, from) - return asks.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."} -} - -// Unlink removes every account linked on a kind, said on every channel. -func (h *Holder) Unlink(kind string) (map[string]any, error) { - h.work.Lock() - defer h.work.Unlock() - if h.Asks == nil { - return nil, errorf("nothing is linked here") - } - ids := h.identitiesOf(kind) - if len(ids) == 0 { - return map[string]any{"kind": kind, "unlinked": []string{}}, nil - } - var names []string - for _, id := range ids { - if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil { - return nil, err + id := Identity{Kind: kind, Identity: p.Identity, Display: p.Display} + if u := h.recentUnlink(kind); u != nil && u.Identity.Identity != p.Identity { + // Within a day of an unlink: the account it replaces decides (novox/hq ADR 0259 §7). + p.Confirmed = true + p.Expires = now.Add(RelinkWaits) + _ = h.Asks.PutLink(kind, *p) + a := asks.Ask{ID: "relink-" + kind + "-" + fmt.Sprint(now.Unix()), + Headline: "Allow another account in your place?", + Explanation: fmt.Sprintf("This account was unlinked from being you less than a day ago. The %s account %s "+ + "(number %s) now asks to be linked as you. If you choose Allow, it is.", Display(kind), p.Display, p.Identity), + Who: asks.Operator, Expires: now.Add(RelinkWaits - time.Minute), OnExpiry: "nothing is linked", + Options: []asks.Option{{ID: "allow", Label: "Allow", Does: "the other account is linked as you", Level: asks.Approve}}} + prior := u.Identity + prior.ApproveFrom = prior.Linked // an account linked before: its hour passed while it was linked + if err := h.askSelf(a, prior, SelfAct{Do: "link", Kind: kind, Link: &id}); err != nil { + return no("The link could not be asked of the account it replaces: " + err.Error()) } - names = append(names, id.Display) + h.announce(Message{Title: "A new " + Display(kind) + " account asks to be linked as you", + Body: fmt.Sprintf("%s (number %s), less than a day after an unlink. It waits for the account it replaces, "+ + "or for root at the router's machine.", p.Display, p.Identity), Urgent: true}) + return asks.ProofAnswer{Accepted: true, Words: "The code is right. Because an account was unlinked here less " + + "than a day ago, the link waits for that account's approval, or for you at the router's machine."} } - h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more", - Body: "Unlinked: " + strings.Join(names, ", ") + ". Answers that need you to be proven cannot be given on " + - Display(kind) + " until an account is linked again.", Urgent: true}) - h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: strings.Join(names, ", ")}) - return map[string]any{"kind": kind, "unlinked": names}, nil + h.linkNow(id, "with the code shown on the desk") + from := now.Add(LinkedApproveAfter).In(h.zone()).Format("15:04") + return asks.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."} } // Identities is every linked account, by kind. diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index 63bd28fd..6ae6fe59 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -126,9 +126,9 @@ func (stateStore) Recent() ([]Sent, error) { // `identities` (novox/hq ADR 0259). Each write that must win once is a compare-and-set. type askStore struct{} -// changed reads the runtime's refusal of a compare-and-set that lost as ErrChanged. +// changed reads the runtime's refusal of a compare-and-set that lost, told by its code, as ErrChanged. func changed(err error) error { - if err != nil && strings.Contains(err.Error(), "written by another writer first") { + if errors.Is(err, stdio.ErrChanged) { return ErrChanged } return err @@ -171,7 +171,7 @@ func (askStore) IdentityKeys() ([]string, error) { keys, err := stdio.State("identities").Keys() var out []string for _, k := range keys { - if !strings.HasPrefix(k, "pending.") { + if !strings.HasPrefix(k, "pending.") && !strings.HasPrefix(k, "unlinked.") { out = append(out, k) } } @@ -192,6 +192,18 @@ func (askStore) PutLink(kind string, l PendingLink) error { func (askStore) DeleteLink(kind string) error { return stdio.State("identities").Delete("pending." + kind) } +func (askStore) GetUnlinked(kind string) (*Unlinked, error) { + e, err := stdio.State("identities").Get("unlinked." + kind) + if err != nil || e == nil { + return nil, err + } + var u Unlinked + return &u, json.Unmarshal(e.Value, &u) +} +func (askStore) PutUnlinked(kind string, u Unlinked) error { + _, err := stdio.State("identities").Put("unlinked."+kind, u) + return err +} // listening is whether condition events reach this holder, in words. type listening struct { @@ -222,14 +234,16 @@ func main() { bench := NewBench(time.Now) publish := func(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) } h := &Holder{ - Away: &SeatChannel{Bench: bench, Role: RoleAway, Publish: publish}, - Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish}, - Store: stateStore{}, - Asks: askStore{}, - Bench: bench, - Publish: publish, - Ask: stdio.Ask, - Logf: logf, + Away: &SeatChannel{Bench: bench, Role: RoleAway, Publish: publish}, + Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish}, + Store: stateStore{}, + Asks: askStore{}, + Node: os.Getenv("MESH_NODE"), + StateDir: os.Getenv("MESH_MESSENGER_STATE"), + Bench: bench, + Publish: publish, + Ask: stdio.Ask, + Logf: logf, Zone: func() *time.Location { loc, err := zoneOf(settings().TimeZone) said.Lock() diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 48bef375..f84413e0 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -18,17 +18,19 @@ import ( // no channel's secret, and names nothing of one installation. type manifest struct { - Module string `json:"module"` - Seats []seat `json:"seats"` - Claims []seat `json:"claims"` - Uses []string - Consumes []string - Emits []string - Invokes []string - State []json.RawMessage - Own map[string]string `json:"own-secrets"` - Tools []string - Build struct { + Module string `json:"module"` + Seats []seat `json:"seats"` + Claims []seat `json:"claims"` + Uses []string + Consumes []string + Emits []string + Invokes []string + State []json.RawMessage + Own map[string]string `json:"own-secrets"` + RunsAs string `json:"runs-as"` + SecretsOwner string `json:"secrets-owner"` + Tools []string + Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } @@ -99,8 +101,9 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { if !reflect.DeepEqual(state, []string{"open", "sent", "asks", "identities"}) { t.Fatalf("state %v", state) } - if len(m.Own) != 0 { - t.Fatalf("it holds a channel's secret: %v", m.Own) + // It holds no channel's secret: only its own bus account, read by its own account alone (ADR 0259 §8). + if len(m.Own) != 1 || m.Own["broker"] == "" || m.RunsAs != "messenger" || m.SecretsOwner != "messenger" { + t.Fatalf("own secrets %v, runs as %q, secrets owned by %q", m.Own, m.RunsAs, m.SecretsOwner) } } diff --git a/modules/messenger/cmd/messenger/seatchannel.go b/modules/messenger/cmd/messenger/seatchannel.go index 56049858..12f1c09f 100644 --- a/modules/messenger/cmd/messenger/seatchannel.go +++ b/modules/messenger/cmd/messenger/seatchannel.go @@ -137,6 +137,10 @@ func (b *Bench) Ready(kind string) error { return fmt.Errorf("%s says it cannot send", kind) } return errors.New(s.Why) + case b.Promises(kind, "verified-sender") && b.To != nil && b.To(kind) == "": + // A channel proving its sender says only to the account linked as the operator: none linked, it has + // nobody to say anything to (the correctness review of 2026-10-08). + return fmt.Errorf("no %s account is linked as the operator: send /start to the bot", Display(kind)) } return nil } @@ -255,6 +259,9 @@ func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error { if c.Bench.To != nil { out.To = c.Bench.To(kind) } + if m.To != "" { + out.To = m.To + } for _, a := range m.Actions { out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token}) } diff --git a/modules/messenger/cmd/messenger/selfact.go b/modules/messenger/cmd/messenger/selfact.go new file mode 100644 index 00000000..c631e62c --- /dev/null +++ b/modules/messenger/cmd/messenger/selfact.go @@ -0,0 +1,247 @@ +package main + +// The router's own asks (novox/hq ADR 0259 §7, as revised after the security review of 2026-10-08): who the +// operator is on a channel changes only with the word of the account it is now. +// +// - **Unlinking is an approve ask, put to the account that is linked.** Whoever could unlink the +// operator's account without it could link their own in its place. The notice of the unlink reaches that +// account first, by name, and then every channel. +// - **A link within a day of an unlink waits for the replaced account's approval**, asked of it by name. +// - **When that account is lost with its phone**, the operator, as root at the router's machine, leaves a +// file in the router's directory — `unlink.` or `allow-link.`, owned by root — and the router +// does it, announced as done at the machine. A file of any other owner is refused and said: an agent +// without root cannot make one. + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// SelfAsker is the name the router asks its own questions under. +const SelfAsker = "messenger" + +// RelinkWaits is how long after an unlink a new link waits for the replaced account's approval. +const RelinkWaits = 24 * time.Hour + +// SelfAct is what the router does on its own ask's warrant. +type SelfAct struct { + Do string `json:"do"` // "unlink" or "link" + Kind string `json:"kind"` + Link *Identity `json:"link,omitempty"` // the account a link makes the operator's +} + +// Unlinked is the last account unlinked on a kind, kept a day so a relink is asked of it. +type Unlinked struct { + Identity Identity `json:"identity"` + At time.Time `json:"at"` +} + +// askSelf puts a question of the router's own to one account, on its kind, and keeps it like any ask. +func (h *Holder) askSelf(a asks.Ask, only Identity, act SelfAct) error { + key := askKey(SelfAsker, a.ID) + now := h.Now() + rec := AskRecord{Asker: SelfAsker, Ask: a, State: StateOpen, Opened: now, Tickets: map[string]Ticket{}, + Only: &only, Act: &act, Record: "ask." + key} + for _, o := range a.Options { + rec.Tickets[ticket()] = Ticket{Option: o.ID, Kind: only.Kind} + } + if h.Asks != nil { + rev, err := h.Asks.CreateAsk(key, rec) + if err != nil { + return err + } + h.keepAsk(key, rec, rev) + } else { + h.keepAsk(key, rec, 0) + } + r := Record{Key: rec.Record, Kind: "ask", Subject: SelfAsker, Severity: Urgent, Summary: a.Headline, + Headline: a.Headline, Explanation: a.Explanation, Origin: "ask", More: "operator-channel.asks", + Raised: now, Sent: map[string]string{}, Seen: now, Count: 1, Ask: key} + h.mu.Lock() + h.open[r.Key] = &r + h.mu.Unlock() + m := h.words(&r, "raised") + m.Actions = h.offers(&rec, only.Kind) + m.To = only.Identity + handle, err := h.showOnHandle(only.Kind, m) + if err == nil { + h.mu.Lock() + r.Sent[only.Kind] = handle + r.FirstSent = now + h.mu.Unlock() + } + h.persist(&r) + return err +} + +// selfAct does what the router's own ask decided. +func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) { + if rec.Act == nil || w.Outcome != asks.OutcomeChosen { + return + } + switch rec.Act.Do { + case "unlink": + h.unlinkNow(rec.Act.Kind, "approved by the account itself") + case "link": + if rec.Act.Link != nil { + h.linkNow(*rec.Act.Link, "approved by the account it replaces") + } + } +} + +// Unlink asks the account linked on a kind whether it is to be unlinked: an approve ask put to it alone. +func (h *Holder) Unlink(kind string) (map[string]any, error) { + h.work.Lock() + defer h.work.Unlock() + ids := h.identitiesOf(kind) + if len(ids) == 0 { + return map[string]any{"kind": kind, "linked": false, "asked": false}, nil + } + id := ids[0] + now := h.Now() + a := asks.Ask{ID: "unlink-" + kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink this account?", + Explanation: fmt.Sprintf("Unlinking this %s account (%s, number %s) was asked. If you choose Unlink, answers "+ + "from here stop counting as yours until an account is linked again.", Display(kind), id.Display, id.Identity), + Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the account stays linked", + Options: []asks.Option{{ID: "unlink", Label: "Unlink", Does: "this account stops answering as you", Level: asks.Approve}}} + if err := h.askSelf(a, id, SelfAct{Do: "unlink", Kind: kind}); err != nil { + return nil, err + } + h.announceExcept(kind, Message{Title: "An unlink of your " + Display(kind) + " account was asked", + Body: fmt.Sprintf("It waits for the account itself (%s, number %s) to approve it within the hour. If you "+ + "cannot answer there, you unlink it as root at the router's machine.", id.Display, id.Identity), Urgent: true}) + return map[string]any{"kind": kind, "asked": true, "of": id.Display}, nil +} + +// unlinkNow unlinks a kind's account: told first, by name, then every channel. +func (h *Holder) unlinkNow(kind, how string) { + ids := h.identitiesOf(kind) + if len(ids) == 0 || h.Asks == nil { + return + } + id := ids[0] + _ = h.showOn(kind, Message{Title: "This account is no longer linked as you", + Body: "Unlinked, " + how + ". Answers from here no longer count as yours.", Urgent: true, To: id.Identity}) + if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil { + h.Logf("[messenger] the unlink of %s on %s could not be recorded: %v", id.Display, kind, err) + return + } + h.unlinked(kind, Unlinked{Identity: id, At: h.Now()}) + h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more", + Body: fmt.Sprintf("Unlinked: %s (number %s), %s. A new link within a day waits for that account's approval.", + id.Display, id.Identity, how), Urgent: true}) + h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: id.Display + ", " + how}) +} + +// linkNow links an account as the operator's: acknowledges at once, approves after the hour. +func (h *Holder) linkNow(id Identity, how string) { + if h.Asks == nil { + return + } + now := h.Now() + id.Linked, id.ApproveFrom = now, now.Add(LinkedApproveAfter) + if err := h.Asks.PutIdentity(identityKey(id.Kind, id.Identity), id); err != nil { + h.Logf("[messenger] the link of %s on %s could not be recorded: %v", id.Display, id.Kind, err) + return + } + _ = h.Asks.DeleteLink(id.Kind) + from := id.ApproveFrom.In(h.zone()).Format("15:04") + h.announce(Message{Title: "A " + Display(id.Kind) + " account is now linked as you", + Body: fmt.Sprintf("The %s account %s (number %s) is linked, %s. It can answer acknowledgements now, and "+ + "approve from %s. If this was not you, unlink it before then.", Display(id.Kind), id.Display, id.Identity, how, from), + Urgent: true}) + h.record(Sent{At: now, Channel: id.Kind, Key: "(link)", What: "linked " + id.Display, Outcome: how}) +} + +// BreakGlass does what root asked at the router's machine: a file `unlink.` or `allow-link.` in the +// router's directory, owned by root. Called every minute. +func (h *Holder) BreakGlass() { + if h.StateDir == "" { + return + } + for _, pattern := range []string{"unlink.*", "allow-link.*"} { + files, _ := filepath.Glob(filepath.Join(h.StateDir, pattern)) + for _, f := range files { + name := filepath.Base(f) + what, kind, _ := strings.Cut(name, ".") + info, err := os.Lstat(f) + if err != nil { + continue + } + owner := fileOwner(info) + _ = os.Remove(f) + if !info.Mode().IsRegular() || owner != 0 { + h.Logf("[messenger] %s was refused: it is not a file root made", name) + h.announce(Message{Title: "A request to change who is you was refused", + Body: "A file asking it was left at the router's machine by someone other than root. Nothing changed.", + Urgent: true}) + continue + } + h.work.Lock() + switch what { + case "unlink": + h.unlinkNow(kind, "at the router's machine, as root") + case "allow-link": + if p, err := h.Asks.GetLink(kind); err == nil && p != nil && p.Confirmed { + h.linkNow(Identity{Kind: kind, Identity: p.Identity, Display: p.Display}, "allowed at the router's machine, as root") + } + } + h.work.Unlock() + } + } +} + +// fileOwner is the uid owning a file; a variable so a test, which is not root, can stand for root. +var fileOwner = func(info os.FileInfo) uint32 { + if st, ok := info.Sys().(*syscall.Stat_t); ok { + return st.Uid + } + return 1 +} + +// unlinked keeps the last unlink of a kind, a day. +func (h *Holder) unlinked(kind string, u Unlinked) { + h.mu.Lock() + defer h.mu.Unlock() + if h.lastUnlink == nil { + h.lastUnlink = map[string]Unlinked{} + } + h.lastUnlink[kind] = u + if h.Asks != nil { + _ = h.Asks.PutUnlinked(kind, u) + } +} + +// recentUnlink is the account unlinked on a kind less than a day ago, if any. +func (h *Holder) recentUnlink(kind string) *Unlinked { + h.mu.Lock() + u, ok := h.lastUnlink[kind] + h.mu.Unlock() + if !ok && h.Asks != nil { + if got, err := h.Asks.GetUnlinked(kind); err == nil && got != nil { + u, ok = *got, true + } + } + if !ok || h.Now().Sub(u.At) >= RelinkWaits { + return nil + } + return &u +} + +// announceExcept says a message on every kind that can send but one. +func (h *Holder) announceExcept(kind string, m Message) { + if h.Bench == nil { + return + } + for _, k := range h.Bench.Kinds() { + if k.Kind != kind && h.Bench.Ready(k.Kind) == nil { + _ = h.showOn(k.Kind, m) + } + } +} diff --git a/modules/messenger/cmd/messenger/words_test.go b/modules/messenger/cmd/messenger/words_test.go index 878d911a..32d2e474 100644 --- a/modules/messenger/cmd/messenger/words_test.go +++ b/modules/messenger/cmd/messenger/words_test.go @@ -314,3 +314,34 @@ func TestTimesAreSaidInTheOperatorsZone(t *testing.T) { t.Errorf("a zone that does not exist was not said: %v", err) } } + +// A warning kept quiet is said once after a day, and not again (novox/hq ADR 0258 §6). +func TestAQuietWarningIsSaidOnceAfterADay(t *testing.T) { + h, tg, dt, c, _ := newHolder(t) + c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC) + e := examples[0] + raised, _ := DecodeCondition(EventRaised, e.event(c.now(), false)) + h.Condition(EventRaised, raised) + settle(h, c) + if len(dt.sends)+len(tg.sends) != 0 { + t.Fatal("said while it needed nothing") + } + c.pass(QuietRemindAfter - time.Hour) + h.Tick() + settle(h, c) + if len(dt.sends)+len(tg.sends) != 0 { + t.Fatal("said before a day") + } + c.pass(time.Hour) + h.Tick() + settle(h, c) + if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Still open after a day: ") { + t.Fatalf("not said once after a day: %+v", dt.sends) + } + c.pass(QuietRemindAfter) + h.Tick() + settle(h, c) + if len(dt.sends) != 1 { + t.Errorf("said again: %d", len(dt.sends)) + } +} diff --git a/modules/messenger/go.mod b/modules/messenger/go.mod index 9095a3a7..d772907f 100644 --- a/modules/messenger/go.mod +++ b/modules/messenger/go.mod @@ -2,4 +2,4 @@ module messenger go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6 +require git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f diff --git a/modules/messenger/go.sum b/modules/messenger/go.sum index f4996b6c..98ffa640 100644 --- a/modules/messenger/go.sum +++ b/modules/messenger/go.sum @@ -6,3 +6,5 @@ git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008144136-2f2840488818 h1:o0yf4yyH8 git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008144136-2f2840488818/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6 h1:JT7xM1bnLNInW7/oImV2OlXTrcQ4/GSM0Y8tAb+AhmY= git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f h1:BNvyWq899GwP7F3sY4ACieB5a5fnFAq+sJ9lP6HQ5qI= +git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/messenger/module.json b/modules/messenger/module.json index bf189cec..f6f2a13d 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -2,6 +2,7 @@ "module": "messenger", "version": "1", "slug": "msgr", + "runs-as": "messenger", "seats": [ { "name": "operator-channel", @@ -92,6 +93,10 @@ }, "identities" ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "messenger", "tools": [ "messenger_status", "messenger_recent", @@ -101,11 +106,19 @@ "messenger_unlink" ], "resources": [ + { + "id": "account", + "type": "user", + "name": "messenger", + "shell": "/usr/bin/nologin", + "home": "/var/lib/messenger" + }, { "id": "state", "type": "directory", "mode": "0700", - "place": "." + "place": ".", + "owner": "messenger" }, { "id": "settings", @@ -113,7 +126,8 @@ "path": "${dir:state}/settings.json", "mode": "0600", "merge": "json", - "content": "{\n \"time-zone\": \"\"\n}\n" + "content": "{\n \"time-zone\": \"\"\n}\n", + "owner": "messenger" } ], "build": { @@ -129,7 +143,8 @@ "messenger" ], "env": { - "MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json" + "MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json", + "MESH_MESSENGER_STATE": "${dir:state}" } } ] diff --git a/modules/sudo/cmd/sudo-tools/escalation.go b/modules/sudo/cmd/sudo-tools/escalation.go new file mode 100644 index 00000000..6bbd01be --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/escalation.go @@ -0,0 +1,89 @@ +package main + +// Who can become root here without a person (novox/hq ADR 0259 §8): the controller's self-check asks it of +// every machine where the router or a channel proving its sender runs, for the accounts agents run as and +// the account the machine's runtime runs commands as. +// +// Root without a person is any of: a sudo rule letting the account run every command as root with no +// password; membership of a group that is root by another name (docker, which mounts the host; disk, which +// reads it raw; wheel or sudo only where a rule grants them without a password, which the listing shows); or +// the account being root itself. + +import ( + "fmt" + "regexp" + "strings" +) + +// Escalation is one account's answer. +type Escalation struct { + Account string `json:"account"` + Root bool `json:"root_without_a_person"` + Why string `json:"why"` +} + +// rootGroups are groups that are root by another name. +var rootGroups = map[string]string{"docker": "the docker group, which can mount the machine's root file system", + "disk": "the disk group, which reads and writes the drives raw", "root": "the root group"} + +var accountShape = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`) + +// Escalations answers, for each account, whether it can become root here without a person. +func (m *Machine) Escalations(accounts []string) ([]Escalation, error) { + if len(accounts) == 0 { + return nil, fmt.Errorf("name the accounts to judge") + } + var out []Escalation + for _, a := range accounts { + if !accountShape.MatchString(a) { + return nil, fmt.Errorf("%q is not an account name", a) + } + out = append(out, m.escalation(a)) + } + return out, nil +} + +func (m *Machine) escalation(account string) Escalation { + e := Escalation{Account: account} + if account == "root" { + e.Root, e.Why = true, "it is root" + return e + } + id := m.Run(bg(), "id", "-nG", account) + if id.Status != 0 || id.Err != "" { + e.Why = "no such account here" + return e + } + for _, g := range strings.Fields(id.Stdout) { + if why, ok := rootGroups[g]; ok { + e.Root, e.Why = true, "a member of "+why + return e + } + } + // sudo's own word, for that account: listing another account's rules needs root, which the runtime's + // account escalates to as the mesh's acting tools do. + r := m.Run(bg(), "sudo", "-n", "-l", "-U", account) + if r.Status != 0 || r.Err != "" { + if strings.Contains(r.Stdout+r.Stderr, "not allowed to run sudo") { + e.Why = "no sudo rule names it" + return e + } + e.Why = "sudo could not be asked: " + strings.TrimSpace(firstLine(r.Stderr+r.Err)) + return e + } + if strings.Contains(r.Stdout, "is not allowed to run sudo") { + e.Why = "no sudo rule names it" + return e + } + rules := ParseList(r.Stdout, account) + if rules.PasswordlessAll { + for _, rule := range rules.Rules { + if hasTag(rule.Tags, "NOPASSWD") && contains(rule.Commands, "ALL") && runsAsRoot(rule.RunAs) { + e.Root, e.Why = true, "sudo lets it run every command as root without a password ("+rule.Line+")" + return e + } + } + } + e.Why = "sudo asks it for a password, or lets it run named commands only" + return e +} diff --git a/modules/sudo/cmd/sudo-tools/escalation_test.go b/modules/sudo/cmd/sudo-tools/escalation_test.go new file mode 100644 index 00000000..d16434ee --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/escalation_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "strings" + "testing" +) + +// novox/hq ADR 0259 §8: who can become root here without a person, as the self-check asks it. +func TestWhoCanBecomeRootWithoutAPerson(t *testing.T) { + var calls []call + run := byLine(map[string]Ran{ + "id -nG ops": {Stdout: "ops wheel\n"}, + "sudo -n -l -U ops": {Stdout: "User ops may run the following commands on anchor:\n (ALL : ALL) NOPASSWD: ALL\n"}, + "id -nG agents": {Stdout: "agents\n"}, + "sudo -n -l -U agents": {Stdout: "User agents is not allowed to run sudo on anchor.\n"}, + "id -nG builder": {Stdout: "builder docker\n"}, + "id -nG careful": {Stdout: "careful wheel\n"}, + "sudo -n -l -U careful": {Stdout: "User careful may run the following commands on anchor:\n (ALL : ALL) ALL\n"}, + "id -nG ghost": {Status: 1, Stderr: "id: 'ghost': no such user"}, + }, &calls) + got, err := machine(run, 1000).Escalations([]string{"ops", "agents", "builder", "careful", "ghost", "root"}) + if err != nil { + t.Fatal(err) + } + want := map[string]bool{"ops": true, "agents": false, "builder": true, "careful": false, "ghost": false, "root": true} + for _, e := range got { + if e.Root != want[e.Account] || e.Why == "" { + t.Errorf("%s: root %v (%s), want %v", e.Account, e.Root, e.Why, want[e.Account]) + } + } + if _, err := machine(run, 1000).Escalations([]string{"a b"}); err == nil || !strings.Contains(err.Error(), "not an account name") { + t.Errorf("an account name that is not one was asked: %v", err) + } +} diff --git a/modules/sudo/cmd/sudo-tools/main.go b/modules/sudo/cmd/sudo-tools/main.go index 34217b98..42b79a6a 100644 --- a/modules/sudo/cmd/sudo-tools/main.go +++ b/modules/sudo/cmd/sudo-tools/main.go @@ -44,6 +44,26 @@ func tools(m *Machine) []stdio.Tool { Input: schema(map[string]any{}), Run: func(map[string]any) (any, error) { return m.CheckEscalation() }, }, + { + Name: "sudo_escalation", + Description: "Can these accounts become root on this machine without a person: a sudo rule running every " + + "command as root with no password, or a group that is root by another name (docker, disk). Each " + + "account with yes or no and why. Asked by the self-check where the router or a channel proving its " + + "sender runs (novox/hq ADR 0259).", + Input: schema(map[string]any{"accounts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "the accounts to judge"}}), + Run: func(a map[string]any) (any, error) { + var accounts []string + if list, ok := a["accounts"].([]any); ok { + for _, x := range list { + if s, ok := x.(string); ok { + accounts = append(accounts, s) + } + } + } + return m.Escalations(accounts) + }, + }, { Name: "sudo_drop_ins", Description: "The files of /etc/sudoers.d with owner, mode and size, whether sudo reads each (a name with a dot " + diff --git a/modules/sudo/module.json b/modules/sudo/module.json index a9a5fc93..4ae66d64 100644 --- a/modules/sudo/module.json +++ b/modules/sudo/module.json @@ -7,6 +7,7 @@ "tools": [ "sudo_rules", "sudo_check", + "sudo_escalation", "sudo_drop_ins" ], "resources": [ diff --git a/modules/sudo/sudo-tools b/modules/sudo/sudo-tools new file mode 100755 index 00000000..60c4fb4f Binary files /dev/null and b/modules/sudo/sudo-tools differ