Run the router as its own account, ask unlinking of the linked account, and take no approval where an agent can become root (hq ADR 0259 §7, §8)
mesh/delivery-group group feat/asks-answered-on-any-channel checking: 3 of 4 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate fail: builds messenger, sudo → ace, g14, novox, shanks; no bus step; a manifest the change touches fails the module check: modules/messenge…
mesh/repo-check fail: its merge-check.sh failed: long-running resources without health: 70

This commit is contained in:
jochen
2026-10-08 18:39:09 +02:00
parent 8f2be28dcb
commit 2989c03f90
20 changed files with 937 additions and 111 deletions
+15 -3
View File
@@ -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.<kind>`, or `allow-link.<kind>` 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
@@ -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
+243 -22
View File
@@ -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 ""
}
+89 -5
View File
@@ -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
+13 -2
View File
@@ -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 `<asker>.<id>`, 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():
@@ -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
}
+74 -51
View File
@@ -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.
+25 -11
View File
@@ -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()
@@ -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)
}
}
@@ -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})
}
+247
View File
@@ -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.<kind>` or `allow-link.<kind>`, 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.<kind>` or `allow-link.<kind>` 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)
}
}
}
@@ -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))
}
}
+1 -1
View File
@@ -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
+2
View File
@@ -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=
+18 -3
View File
@@ -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}"
}
}
]
+89
View File
@@ -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
}
@@ -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)
}
}
+20
View File
@@ -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 " +
+1
View File
@@ -7,6 +7,7 @@
"tools": [
"sudo_rules",
"sudo_check",
"sudo_escalation",
"sudo_drop_ins"
],
"resources": [
BIN
View File
Binary file not shown.