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)

This commit is contained in:
jochen
2026-10-09 17:08:17 +02:00
committed by jschoubben
parent c2c75ceb3b
commit debb897c21
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.