diff --git a/go/catalogue/declaration.go b/go/catalogue/declaration.go index 6a96e6e..6389454 100644 --- a/go/catalogue/declaration.go +++ b/go/catalogue/declaration.go @@ -700,13 +700,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string, handsSecrets := with.Handing(m) for _, name := range sortedKeys(m.OwnSecrets.Plain()) { if handsSecrets { - owner := m.SecretsOwner - if m.Module == RuntimeModule && r.Account != "" { - owner = r.Account - } - if o := m.OwnSecrets[name].Owner; o != "" { - owner = o - } + owner := r.ownSecretOwner(m, name) first = append(first, ownedBy(owner, map[string]any{ "id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, HandedSecretField: name, })) @@ -718,13 +712,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string, if string(box) == string(ForeseenVaultBox) { *foreseen = append(*foreseen, m.Module+"/"+name) } - owner := m.SecretsOwner - if m.Module == RuntimeModule && r.Account != "" { - owner = r.Account - } - if o := m.OwnSecrets[name].Owner; o != "" { - owner = o - } + owner := r.ownSecretOwner(m, name) first = append(first, ownedBy(owner, map[string]any{ "id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, VaultSealedField: box, })) @@ -754,14 +742,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string, // the manifest, because a manifest cannot say ${machine:account} safely — a node with no // account has nothing to resolve it to, and then the runtime runs as root and the file // stays root's. - owner := m.SecretsOwner - if m.Module == RuntimeModule && r.Account != "" { - owner = r.Account - } - if o := m.OwnSecrets[name].Owner; o != "" { - owner = o - } - first = append(first, ownedBy(owner, map[string]any{ + first = append(first, ownedBy(r.ownSecretOwner(m, name), map[string]any{ "id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed, })) } @@ -1677,6 +1658,19 @@ func (r Resolution) provisionsAs(m Manifest) string { return m.SecretsOwner } +// ownSecretOwner is who one of a module's own secret files belongs to: the account the manifest names for it, else the +// runtime's account for the runtime's own credential and for a tools bundle's bus account (ToolsBusSecret; novox/hq ADR +// 0564 Limits 4), which the runtime runs that bundle as, else the module's secrets-owner (root when none). +func (r Resolution) ownSecretOwner(m Manifest, name string) string { + if o := m.OwnSecrets[name].Owner; o != "" { + return o + } + if r.Account != "" && (m.Module == RuntimeModule || ToolsBusSecret(m, name)) { + return r.Account + } + return m.SecretsOwner +} + // grantPath is where one consumer's sealed credential lands on the providing machine. // // Suffixed, so the directory can also hold whatever the module writing it keeps there and so a diff --git a/go/catalogue/process_bus.go b/go/catalogue/process_bus.go index c48f11c..3477691 100644 --- a/go/catalogue/process_bus.go +++ b/go/catalogue/process_bus.go @@ -85,6 +85,52 @@ func (m Manifest) BusAccounts() []BusAccount { return out } +// ToolsBusSecret says name is the own secret a tools bundle's bus account is held in: `broker-` for a bundle +// whose bus the manifest declares (ADR 0564 Limits 4). Its file belongs to the runtime's account, which runs the bundle. +func ToolsBusSecret(m Manifest, name string) bool { + for _, a := range m.BusAccounts() { + if a.Tools && name == "broker-"+a.ID { + return true + } + } + return false +} + +// toolsSecretProblems refuses a tools bundle's bus account secret the runtime's account could not read: it is declared +// as an own secret `broker-`, at a path in a directory resource of the module that others may pass through +// (mode with its last digit odd, such as 0711 or 0755); the file itself is 0600 and the runtime account's. +func toolsSecretProblems(m Manifest) []string { + var problems []string + for _, a := range m.BusAccounts() { + if !a.Tools { + continue + } + name := "broker-" + a.ID + own, declared := m.OwnSecrets.Plain()[name] + if !declared { + problems = append(problems, fmt.Sprintf("%s's tools bundle %s declares bus and no own secret %s to hold its "+ + "account in (novox/hq ADR 0564 Limits 4)", m.Module, a.ID, name)) + continue + } + dir, _, _ := strings.Cut(strings.TrimPrefix(own.Path, "${dir:"), "}") + passable := false + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "directory" || fmt.Sprint(r["id"]) != dir || !strings.HasPrefix(own.Path, "${dir:"+dir+"}/") { + continue + } + mode, _ := r["mode"].(string) + passable = mode != "" && strings.ContainsAny(mode[len(mode)-1:], "1357") + } + if !passable { + problems = append(problems, fmt.Sprintf("%s's own secret %s is at %s; a tools bundle's account is read by the "+ + "node's tool runtime, so it lies in a directory resource of the module that others may pass through "+ + "(mode 0711 or 0755, `${dir:}/%s`), the file itself the runtime account's, 0600 (novox/hq ADR 0564 "+ + "Limits 4)", m.Module, name, own.Path, name)) + } + } + return problems +} + // toolsBusProblems is what is wrong with a tools bundle's bus (ADR 0564 Limits 4): a bus on anything but a bundle // compiled to a binary the runtime loads, a removal outside the closed set, or a removal beside any other grant or by a // module that does not hold mesh-broker. Every other field is judged as a process's. @@ -191,7 +237,7 @@ func (m Manifest) ProcessBuses() map[string]ProcessBus { // §3): the account rule is modelled on RunsAsProblems (seats_declared.go:522-550), and an invoked tool or // seat verb's shape on invokeProblems. func ProcessBusProblems(m Manifest) []string { - problems := toolsBusProblems(m) + problems := append(toolsBusProblems(m), toolsSecretProblems(m)...) streams := map[string]bool{} for _, s := range m.Streams { streams[s.Name] = true diff --git a/go/catalogue/process_bus_test.go b/go/catalogue/process_bus_test.go index bd59506..cbd9b37 100644 --- a/go/catalogue/process_bus_test.go +++ b/go/catalogue/process_bus_test.go @@ -51,10 +51,14 @@ func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) { // 1952 S5 (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): a tools bundle declares its own bus account, read as a // process's, named after its binary and kept through resolution; a removal is from the closed set, alone, and the // bus module's (mesh-broker) alone. - tools := func(claims, bus string) string { - return `{"module":"nats","version":"1","claims":` + claims + `,"build":{"artifacts":[{"name":"tools","kind":"bundle",` + + toolsIn := func(claims, bus, mode string) string { + return `{"module":"nats","version":"1","claims":` + claims + `,"own-secrets":{"broker":"${dir:mesh-state}/broker",` + + `"broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"},"resources":[{"id":"mesh-state","type":"directory",` + + `"mode":"0700","place":"mesh","unknown":"program"},{"id":"tools-secret","type":"directory","mode":"` + mode + + `","place":"mesh","unknown":"program"}],"build":{"artifacts":[{"name":"tools","kind":"bundle",` + `"language":"go","system":"arch","from":"cmd/nats-tools","binary":"nats-tools","loads":["nats-tools"],"bus":` + bus + `}]}}` } + tools := func(claims, bus string) string { return toolsIn(claims, bus, "0711") } broker := `[{"name":"mesh-broker","scope":"mesh"}]` for _, c := range []struct { name, manifest, refused string @@ -65,6 +69,9 @@ func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) { {name: "another module's tools removing", manifest: tools(`[]`, `{"removes":["mesh-host_acts"]}`), refused: "only the bus module"}, {name: "a tools bundle consuming a queue", manifest: tools(broker, `{"queues":["x"]}`), refused: "holds no consumer"}, {name: "a process removing", manifest: mailSenderProcessManifest(t, `{"removes":["mesh-host_acts"]}`), refused: "only a tools bundle"}, + // The runtime's account reads the account's secret: it lies where others may pass (the host agent, 2026-10-29). + {name: "a tools secret the runtime cannot reach", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0700"), + refused: "others may pass through"}, } { m, err := ParseManifest([]byte(c.manifest)) if c.refused != "" { @@ -81,6 +88,12 @@ func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) { !strSliceEqual(accounts[0].Bus.Removes, []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs}) { t.Errorf("%s: the accounts are %+v", c.name, accounts) } + // Its secret's file is the runtime account's, as the runtime's own credential is; the module's other secrets stay + // its own. + if (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools") != "ops" || + (Resolution{Account: "ops"}).ownSecretOwner(m, "broker") != "" || (Resolution{}).ownSecretOwner(m, "broker-nats-tools") != "" { + t.Errorf("%s: the tools secret's owner is %q", c.name, (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools")) + } // Resolved, the build goes and its bundles keep the bus (build.go). resolved := Manifest{Module: "nats", Bundles: []Bundle{{Name: "tools", Binary: "nats-tools", Bus: m.Build.Artifacts[0].Bus}}} if got := resolved.BusAccounts(); len(got) != 1 || got[0].ID != "nats-tools" {