diff --git a/cmd/mesh-host/engineseat.go b/cmd/mesh-host/engineseat.go new file mode 100644 index 0000000..eed00f2 --- /dev/null +++ b/cmd/mesh-host/engineseat.go @@ -0,0 +1,277 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + meshasker "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/asks" + "github.com/novox/mesh-host/internal/accounts" + "github.com/novox/mesh-host/internal/apply" + "github.com/novox/mesh-host/internal/link" + "github.com/novox/mesh-host/internal/store" +) + +// The node seat node-engine's judgements on this machine (novox/hq ADR 0563): what a hand-over and a library's take +// would set and what the disk holds now, and whether this machine is root-free. + +// diskOf is what is on disk at a directory, as an act compares it when the operator's yes arrives: its owner and +// group, its mode, and which file it is. +func diskOf(info os.FileInfo) string { + owner := "unknown" + if uid, gid, ok := apply.OwnerOf(info); ok { + owner = fmt.Sprintf("%d:%d", uid, gid) + } + which := "" + if st, ok := info.Sys().(*syscall.Stat_t); ok { + which = fmt.Sprintf(" %v:%v", st.Dev, st.Ino) + } + return fmt.Sprintf("%s %o%s", owner, info.Mode().Perm(), which) +} + +// handOverJudgeFrom judges a hand-over through the node seat as the controller's ask is judged (judgeFound): the +// declared owner and mode it will set, and the disk as it is. +func handOverJudgeFrom(statePath string) func(string) (link.Judged, error) { + return func(path string) (link.Judged, error) { + found, info, err := judgeFound(statePath, path) + if err != nil { + return link.Judged{}, err + } + return link.Judged{Subject: path, Path: path, Sets: orUnsaid(found.Declared), Disk: diskOf(info), + Words: "A directory of " + moduleOf(found.ID) + " is used as found: the mesh left its owner and mode."}, nil + } +} + +func moduleOf(id string) string { + if at := strings.LastIndex(id, "."); at > 0 { + return id[:at] + } + return id +} + +// accessJudgeFrom judges the take act on a library through the node seat (ADR 0328 §7, ADR 0563 §4): declared on this +// machine, its home, and its root holding data the mesh has not taken; what the take sets, and the disk as it is. +func accessJudgeFrom(statePath string) func(string, string) (link.Judged, error) { + return func(act, access string) (link.Judged, error) { + d, err := declaredNow(statePath, nil) + if errors.Is(err, store.ErrNothingDeclared) { + return link.Judged{}, errors.New("this node keeps no declaration, so it is declared no library") + } + if err != nil { + return link.Judged{}, fmt.Errorf("what runs on this node does not read: %w", err) + } + for _, l := range d.Libraries { + if apply.LibraryAccess(l.Library) != access { + continue + } + if !l.Home { + return link.Judged{}, fmt.Errorf("this machine is not the home of the library %s, and a library is "+ + "taken on its home", l.Library) + } + ctx, cancel := context.WithTimeout(context.Background(), lookBound) + defer cancel() + if v := apply.JudgeLibrary(ctx, l); v.Kind != apply.VerdictWaitKeep { + return link.Judged{}, fmt.Errorf("the library %s does not wait for the operator's word to be kept "+ + "(it is judged %s), so there is nothing to take", l.Library, v.Kind) + } + info, err := os.Lstat(l.Root) + if err != nil { + return link.Judged{}, fmt.Errorf("the library %s's root cannot be read: %v", l.Library, err) + } + return link.Judged{Subject: access, Path: filepath.Clean(l.Root), + Sets: "the group " + l.Group + ", group write and setgid on everything under its root", + Disk: diskOf(info), Words: "The library " + l.Library + " holds data the mesh has not taken."}, nil + } + return link.Judged{}, fmt.Errorf("the access %s names no library declared on this machine", access) + } +} + +// askTakes asks the take of every library the look found waiting for the operator's word, once per library (ADR 0563 +// §4): asked by the engine itself, never a second time while one is open or after one was asked; access-act asks again +// by hand. +func askTakes(acts *link.HandActs, waiting []string, say func(string)) { + if acts == nil { + return + } + for _, access := range waiting { + if acts.AskedFor(link.ActAccess, access) { + continue + } + if _, err := acts.AskAccess(link.EngineSeat+"-"+acts.Node, link.ActTake, access, + "its root holds data outside its group", true); err != nil && say != nil { + say("the take of " + access + " could not be asked: " + err.Error()) + } + } +} + +// lastAccounts is the newest look at every account (the health loop's), which root-free judges from. +var lastAccounts atomic.Pointer[accounts.Statement] + +// rootFreshFor is how old the verdict root-free judges from may be (ADR 0563 §6, the controller's verdictFreshFor). +const rootFreshFor = 15 * time.Minute + +// judgeRootFree is this machine's root-free (ADR 0259 §8, ADR 0563 §6): free only on the engine's own verdict, at most +// rootFreshFor old, that every account declared never to become root without a person cannot, at least one such +// account declared, and the login shell's execute not served here. Anything else is no pass, and says why. +func judgeRootFree(node string, st *accounts.Statement, now time.Time, served bool, servedWhy string) link.RootVerdict { + v := link.RootVerdict{Machine: node, Judged: now.UTC()} + var not []string + confined := "" + switch { + case st == nil: + not = append(not, "this machine's engine has judged no account yet") + case now.Sub(st.At) > rootFreshFor: + not = append(not, fmt.Sprintf("the engine's newest judgement of its accounts was made at %s, more than %d "+ + "minutes ago, and a verdict that old is not a verdict on now", st.At.UTC().Format(time.RFC3339), + int(rootFreshFor.Minutes()))) + default: + var named []string + for _, a := range st.Accounts { + if !a.Root { + continue + } + named = append(named, a.Name) + if a.State != accounts.Healthy { + not = append(not, "the account "+a.Name+" "+orNoneKnownWord(a.Reason)) + } + } + if len(named) == 0 { + not = append(not, "no account here is declared never to become root, so agents run as the operator's "+ + "account, which may become root") + } else if len(not) == 0 { + confined = "the account " + strings.Join(named, ", ") + " cannot become root without a person (judged " + + st.At.UTC().Format(time.RFC3339) + ")" + } + } + if served { + not = append(not, "the login shell runs any command an agent gives it as the machine's runtime account, which "+ + "can become root ("+servedWhy+")") + } + if len(not) > 0 { + v.Why = strings.Join(not, "; ") + return v + } + v.Free, v.Why = true, confined+"; the login shell's execute is not served here" + return v +} + +func orNoneKnownWord(s string) string { + if s == "" { + return "is not judged" + } + return s +} + +// settingFileSuffix is the resource the login shell's holder renders its setting execute into (ADR 0268: zsh's +// tools-conf, `execute=serve` or `execute=withhold`), read beside the bus's discovery. +const settingFileSuffix = ".tools-conf" + +// executeServedHere is whether the login shell's execute is served on this machine (ADR 0268), failing closed: served +// while the bus's discovery hears it answered here or could not be asked, or while the holder's setting file applied +// here says anything but withhold or cannot be read. why says which. +func executeServedHere(statePath string, heard func() (bool, error)) (bool, string) { + var why []string + if heard == nil { + why = append(why, "the bus could not be asked whether execute is answered here") + } else if h, err := heard(); err != nil { + why = append(why, "the bus could not be asked whether execute is answered here: "+err.Error()) + } else if h { + why = append(why, "the bus hears execute answered here") + } + known, err := store.Load(statePath) + if err != nil { + why = append(why, "what this machine applied could not be read: "+err.Error()) + } + for _, r := range known.Resources { + if r.Type != "file" || !strings.HasSuffix(r.ID, settingFileSuffix) { + continue + } + if value, err := settingIn(r.Target); err != nil { + why = append(why, moduleOf(r.ID)+"'s setting execute cannot be read: "+err.Error()) + } else if value != "withhold" { + why = append(why, moduleOf(r.ID)+"'s setting execute here is "+value) + } + } + return len(why) > 0, strings.Join(why, "; ") +} + +// settingIn is the value of execute in a setting file, "" when it says none. +func settingIn(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + value := "" + lines := bufio.NewScanner(f) + for lines.Scan() { + t := strings.TrimSpace(lines.Text()) + if key, v, ok := strings.Cut(t, "="); ok && !strings.HasPrefix(t, "#") && strings.TrimSpace(key) == "execute" { + value = strings.TrimSpace(v) + } + } + if value == "" { + value = "not said" + } + return value, lines.Err() +} + +// keepWaiting is every library a look found waiting for the operator's word to keep it (ADR 0328 §8). +func keepWaiting(vs []link.ResourceHealth) []string { + var out []string + for _, v := range vs { + for _, w := range v.AccessWaits { + if w.Reason == link.WaitKeep { + out = append(out, w.Access) + } + } + } + return out +} + +// engineActs is the node seat this engine holds (ADR 0563): judged and performed by the same code as the controller's +// signed asks, its asks to the operator over the session's link, its acts kept beside the state. +func engineActs(opts options, node string, queue *link.Session, search *accounts.SetuidCache) *link.HandActs { + state := &link.FileState{Path: filepath.Join(filepath.Dir(opts.state), link.HandActsFile)} + h := &link.HandActs{Node: node, State: state, + JudgeHandOver: handOverJudgeFrom(opts.state), HandOver: handOverFrom(opts.state), + JudgeAccess: accessJudgeFrom(opts.state), AccessAct: accessActFrom(opts.state, opts.timeout), + JudgeDirectories: judgeDirectoriesFrom(opts.state), SetuidSearch: queue.SetuidSearch, Searching: search.Running, + RootFree: func() link.RootVerdict { + served, why := executeServedHere(opts.state, func() (bool, error) { + return queue.HeardServed("node-login-shell", "execute") + }) + return judgeRootFree(node, lastAccounts.Load(), time.Now(), served, why) + }, + Say: queue.SayEngine} + asker := asks.EngineAsker(node) + h.Asker = &meshasker.Client{Module: asker, State: state, Publish: queue.PublishAsk, + Record: func(id string) (*stdio.StateEntry, error) { return queue.RouterRecord(asker + "." + id) }} + return h +} + +// reconcileEvery is how often the engine catches up its open asks from the router's records (a warrant heard while no +// link was open, an ask past its expiry): besides each link's opening. +const reconcileEvery = 5 * time.Minute + +func reconcileActs(ctx context.Context, h *link.HandActs) { + t := time.NewTicker(reconcileEvery) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + h.Reconcile() + } + } +} diff --git a/cmd/mesh-host/found.go b/cmd/mesh-host/found.go index 48774d3..2301fb6 100644 --- a/cmd/mesh-host/found.go +++ b/cmd/mesh-host/found.go @@ -129,16 +129,30 @@ func foundNow() []store.Applied { // never follows a path it did not state: a hand-over is root giving a directory to an account at the next // apply, and a path that resolves elsewhere would give that elsewhere. func handOverFound(statePath, path, by string) (string, error) { + found, _, err := judgeFound(statePath, path) + if err != nil { + return "", err + } + if err := store.AddHandedOver(statePath, store.HandOver{ID: found.ID, Path: path, At: time.Now().UTC(), By: by}); err != nil { + return "", err + } + return fmt.Sprintf("%s (%s) is handed to the mesh by %s: the next apply gives it %s, and it is the mesh's from "+ + "then on — removed when empty and no longer declared", path, found.ID, by, orUnsaid(found.Declared)), nil +} + +// judgeFound is the judgement of a hand-over's path, recording nothing: the directory this machine uses as found at +// that path, as the engine states it, and what is on disk there. +func judgeFound(statePath, path string) (store.Applied, os.FileInfo, error) { if !filepath.IsAbs(path) { - return "", fmt.Errorf("hand-over takes the directory's absolute path, and %q is not one; nothing was handed over", path) + return store.Applied{}, nil, fmt.Errorf("hand-over takes the directory's absolute path, and %q is not one; nothing was handed over", path) } if filepath.Clean(path) != path { - return "", fmt.Errorf("%q is not the directory's path as the engine states it (no `..`, no doubled or trailing "+ + return store.Applied{}, nil, fmt.Errorf("%q is not the directory's path as the engine states it (no `..`, no doubled or trailing "+ "separator); nothing was handed over", path) } known, err := store.Load(statePath) if err != nil { - return "", err + return store.Applied{}, nil, err } var found *store.Applied for _, r := range foundDirectories(known) { @@ -149,31 +163,27 @@ func handOverFound(statePath, path, by string) (string, error) { } } if found == nil { - return "", errors.New(path + " is not a directory this machine uses as found (the module's condition names " + + return store.Applied{}, nil, errors.New(path + " is not a directory this machine uses as found (the module's condition names " + "the ones that are); nothing was handed over") } info, err := os.Lstat(path) switch { case err != nil: - return "", fmt.Errorf("%s cannot be read on this machine: %v; nothing was handed over", path, err) + return store.Applied{}, nil, fmt.Errorf("%s cannot be read on this machine: %v; nothing was handed over", path, err) case info.Mode()&os.ModeSymlink != 0: - return "", fmt.Errorf("%s is a link, and the mesh hands over no link (novox/hq ADR 0012); nothing was handed over", path) + return store.Applied{}, nil, fmt.Errorf("%s is a link, and the mesh hands over no link (novox/hq ADR 0012); nothing was handed over", path) case !info.IsDir(): - return "", fmt.Errorf("%s is not a directory on this machine; nothing was handed over", path) + return store.Applied{}, nil, fmt.Errorf("%s is not a directory on this machine; nothing was handed over", path) } resolved, err := filepath.EvalSymlinks(path) if err != nil { - return "", fmt.Errorf("%s cannot be resolved on this machine: %v; nothing was handed over", path, err) + return store.Applied{}, nil, fmt.Errorf("%s cannot be resolved on this machine: %v; nothing was handed over", path, err) } if resolved != path { - return "", fmt.Errorf("%s is reached through a link (it is %s), and the mesh hands over nothing through one "+ + return store.Applied{}, nil, fmt.Errorf("%s is reached through a link (it is %s), and the mesh hands over nothing through one "+ "(novox/hq issue 339); nothing was handed over", path, resolved) } - if err := store.AddHandedOver(statePath, store.HandOver{ID: found.ID, Path: path, At: time.Now().UTC(), By: by}); err != nil { - return "", err - } - return fmt.Sprintf("%s (%s) is handed to the mesh by %s: the next apply gives it %s, and it is the mesh's from "+ - "then on — removed when empty and no longer declared", path, found.ID, by, orUnsaid(found.Declared)), nil + return *found, info, nil } // handOverFrom is the engine's hand-over for the controller's ask: the state beside statePath, the path as asked. diff --git a/cmd/mesh-host/libraries_test.go b/cmd/mesh-host/libraries_test.go index 2f3aafb..b171db6 100644 --- a/cmd/mesh-host/libraries_test.go +++ b/cmd/mesh-host/libraries_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + meshasker "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/declaration" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/link" @@ -53,4 +55,33 @@ func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) { if strings.Contains(media.Reason, dir) || len(books.AccessWaits) != 1 || books.AccessWaits[0].Reason != link.WaitDisk { t.Fatalf("a missing root does not wait for its disk, or a reason names a path: %+v %+v", books, media) } + // 774 (ADR 0563 §4): the library's home engine asks its take of the operator itself, once: no second ask while one is + // open, nor after it ended; and a library waiting for its disk is not asked. + t.Run("the home engine asks the take once", func(t *testing.T) { + var asked []asks.Ask + state := &link.FileState{Path: filepath.Join(t.TempDir(), link.HandActsFile)} + h := &link.HandActs{Node: "n1", State: state, + JudgeAccess: func(act, access string) (link.Judged, error) { + return link.Judged{Subject: access, Path: root, Sets: "the group mesh-media", Disk: "0:0 755"}, nil + }, + AccessAct: func(link.AccessAct) (string, error) { return "taken", nil }} + h.Asker = &meshasker.Client{Module: asks.EngineAsker("n1"), State: state, + Publish: func(subject string, body any, id string) error { + if a, ok := body.(asks.Ask); ok { + asked = append(asked, a) + } + return nil + }} + waiting := keepWaiting(vs) + askTakes(h, waiting, nil) + askTakes(h, waiting, nil) + if len(waiting) != 1 || waiting[0] != "library:media" || len(asked) != 1 { + t.Fatalf("waiting %v asked %d times", waiting, len(asked)) + } + h.Heard(asks.Warrant{Ask: asked[0].ID, Asker: asks.EngineAsker("n1"), Outcome: asks.OutcomeExpired, At: time.Now()}) + askTakes(h, waiting, nil) + if len(asked) != 1 { + t.Fatalf("the take was asked again after its ask ended: %d", len(asked)) + } + }) } diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index e0696c1..9a39745 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1132,6 +1132,10 @@ func runLink(ctx context.Context, opts options) error { Say: say, Timeout: opts.timeout, } + // And the node seat node-engine this engine holds (novox/hq ADR 0563): its verbs on every link, its own asks to the + // operator under node-engine-, and its acts kept beside its state. + queue.Acts = engineActs(opts, mine.Node, queue, setuidSearch) + go reconcileActs(ctx, queue.Acts) current := &agentNow{} setRunsHere(runsFromAgent(current)) factsOfNow := func(ctx context.Context, ag *agent.Agent) { @@ -1376,6 +1380,7 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Session, if a := accountJudge; a != nil { as, accountsChanged := a.Look(ctx) accountSt = &as + lastAccounts.Store(&as) owed = owed || accountsChanged if accountsChanged { for _, v := range as.Accounts { @@ -1390,6 +1395,9 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Session, accessSt, accessesChanged := accesses.look(time.Now()) keeping.poke(ctx) owed = owed || accessesChanged + // And a library whose root holds data outside its group is asked of the operator by this engine, once (novox/hq + // ADR 0563 §4). + askTakes(queue.Acts, keepWaiting(accessSt), say) if accessesChanged { for _, v := range accessSt { if v.State != link.StateHealthy { diff --git a/cmd/mesh-host/units_test.go b/cmd/mesh-host/units_test.go index 22ce465..bbbcf32 100644 --- a/cmd/mesh-host/units_test.go +++ b/cmd/mesh-host/units_test.go @@ -1,6 +1,9 @@ package main import ( + "errors" + "os" + "path/filepath" "strings" "testing" "time" @@ -9,6 +12,7 @@ import ( "github.com/novox/mesh-host/internal/accounts" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/liveness" + "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/units" ) @@ -103,6 +107,61 @@ func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) { if got["claude-code.agent"] != "never" || got["openrazer.account"] != "" { t.Fatalf("root said: %v", got) } + // 774 (ADR 0563 §6): the node seat's root-free passes only on the engine's own positive verdict at most 15 minutes old + // with execute not served here; a stale, missing or negative verdict, or execute served, is no pass and says why. + t.Run("this machine's root-free", func(t *testing.T) { + agent := accounts.Verdict{Account: accounts.Account{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true}, + State: accounts.Healthy, Since: at} + free := &accounts.Statement{At: at, Accounts: []accounts.Verdict{agent}} + for _, row := range []struct { + name string + st *accounts.Statement + after time.Duration + served bool + free bool + why string + }{ + {name: "a positive verdict", st: free, after: 14 * time.Minute, free: true, why: "the account agent cannot become root"}, + {name: "a stale verdict", st: free, after: 16 * time.Minute, why: "more than 15 minutes ago"}, + {name: "no verdict", st: nil, why: "judged no account yet"}, + {name: "a way to root", st: as, why: "in the group docker, which grants root"}, + {name: "no agent account", st: &accounts.Statement{At: at}, why: "agents run as the operator's account"}, + {name: "execute served", st: free, served: true, why: "the login shell runs any command"}, + } { + v := judgeRootFree("ace", row.st, at.Add(row.after), row.served, "the bus hears execute answered here") + if v.Free != row.free || !strings.Contains(v.Why, row.why) || v.Machine != "ace" { + t.Errorf("%s: %+v", row.name, v) + } + } + // execute is served while the holder's setting file says anything but withhold, or the bus hears it. + dir := t.TempDir() + statePath, conf := filepath.Join(dir, "state.json"), filepath.Join(dir, "tools.conf") + if err := store.Save(statePath, store.State{Resources: []store.Applied{{ID: "zsh.tools-conf", Type: "file", Target: conf}}}); err != nil { + t.Fatal(err) + } + quiet := func() (bool, error) { return false, nil } + for _, row := range []struct { + name, setting string + heard func() (bool, error) + served bool + }{ + {name: "withheld and not heard", setting: "# the mesh's\nexecute=withhold\n", heard: quiet}, + {name: "served", setting: "execute=serve\n", heard: quiet, served: true}, + {name: "withheld and heard", setting: "execute=withhold\n", heard: func() (bool, error) { return true, nil }, served: true}, + {name: "the bus not asked", setting: "execute=withhold\n", heard: func() (bool, error) { return false, errors.New("no link") }, served: true}, + {name: "no setting file", heard: quiet, served: true}, + } { + _ = os.Remove(conf) + if row.setting != "" { + if err := os.WriteFile(conf, []byte(row.setting), 0o644); err != nil { + t.Fatal(err) + } + } + if served, why := executeServedHere(statePath, row.heard); served != row.served { + t.Errorf("%s: served %v (%s)", row.name, served, why) + } + } + }) } // A verdict waiting for the setuid search says how far it got, in the reason the root-free verb repeats and as