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
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:
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 " +
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"tools": [
|
||||
"sudo_rules",
|
||||
"sudo_check",
|
||||
"sudo_escalation",
|
||||
"sudo_drop_ins"
|
||||
],
|
||||
"resources": [
|
||||
|
||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user