diff --git a/modules/claude-licence-manager/README.md b/modules/claude-licence-manager/README.md index de0b4c789..afad6f7d2 100644 --- a/modules/claude-licence-manager/README.md +++ b/modules/claude-licence-manager/README.md @@ -5,6 +5,12 @@ rotation source, its consumers moved off a licence near its limit by the manager ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module keeps none. +It runs as an account of its own (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), in a runtime of its +own, never in the machine's tool runner: its service part (the carry-over at start, the usage timer, the switch rule, +the conditions) and its tools beside it, so every state, log and secret below is issued to its own account. Its +account is issued what its manifest names: the asks under `invokes` (the vault, the operator channel, mesh-config and +a node's `claude_code_grant`), the events under `emits` and `consumes`. + ## How a licence comes to exist Nothing is configured. Every node running `claude-code` says what it holds as the event diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index d33c6d033..1d34a8da9 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -1,7 +1,9 @@ -// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by the control -// node's runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It serves the -// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports -// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage. +// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by a runtime of its own under +// its own account (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), never by the machine's tool runner, +// and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). Its service part runs long (the +// carry-over at start, the usage timer, the switch rule, the conditions): it watches what every node reports holding +// and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage. Its tools part, the +// seat's verbs and the module's tools, is served beside it and acts only through that one running manager. // // stdout is the MCP channel; everything this module says, it says on stderr. package main @@ -58,7 +60,7 @@ func manager() (*Manager, error) { if err != nil { return nil, err } - keys, err := keypair(dir) + keys, err := keypair(dir, say) if err != nil { return nil, err } @@ -129,28 +131,60 @@ func configLevels(node string) (map[string]string, error) { } // keypair is this module's own, made once in its state directory; the private half never leaves it. -// Written whole, then linked into place, so a second process reads the first's key and never half of it. -func keypair(dir string) (KeyPair, error) { +// Written whole, then linked into place, so a second process reads the first's key and never half of it. A pair it +// finds and cannot read (another account's, left before the module ran as its own) is made anew in its place and said +// in its journal (design 39 bus-and-switch C2): every use passes the public key per ask, so nothing depends on the old. +func keypair(dir string, log func(string, ...any)) (KeyPair, error) { file := filepath.Join(dir, "manager-key.json") if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { - k, err := GenerateKeyPair() - if err != nil { + if err := writeKeyPair(dir, file, false); err != nil { return KeyPair{}, err } - raw, _ := json.Marshal(k) - tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid())) - if err := os.WriteFile(tmp, raw, 0o600); err != nil { - return KeyPair{}, err - } - _ = os.Link(tmp, file) // fails when another made it first, which is right - _ = os.Remove(tmp) } + k, err := readKeyPair(file) + if err == nil { + return k, nil + } + log("the key pair %s cannot be read (%v); a new one is made in its place", file, err) + if err := writeKeyPair(dir, file, true); err != nil { + return KeyPair{}, err + } + return readKeyPair(file) +} + +func readKeyPair(file string) (KeyPair, error) { raw, err := os.ReadFile(file) if err != nil { return KeyPair{}, err } var k KeyPair - return k, json.Unmarshal(raw, &k) + if err := json.Unmarshal(raw, &k); err != nil { + return KeyPair{}, err + } + if k.PublicKey == "" || k.PrivateKey == "" { + return KeyPair{}, errors.New("it holds no whole key pair") + } + return k, nil +} + +// writeKeyPair writes a new pair whole beside file, then links it into place, or, replacing, renames it over the one +// that cannot be read. +func writeKeyPair(dir, file string, replace bool) error { + k, err := GenerateKeyPair() + if err != nil { + return err + } + raw, _ := json.Marshal(k) + tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid())) + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + if replace { + return os.Rename(tmp, file) + } + _ = os.Link(tmp, file) // fails when another made it first, which is right + _ = os.Remove(tmp) + return nil } // ---- the long-running half --------------------------------------------------------------------------- diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go index e087078ab..8916b3680 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "os" "path/filepath" + "regexp" + "strings" "testing" ) @@ -48,3 +50,105 @@ func TestTheManifestKeepsNoOldForm(t *testing.T) { } } } + +// issued is what the manager's own runtime is issued from its manifest (design 39 bus-and-switch C1): its account, the +// states and logs it keeps (`requires` by template), what it may ask (`invokes`) and say (`emits`). +type issued struct { + RunsAs, SecretsOwner string + OwnSecrets map[string]string + Resources []map[string]any + States, Logs map[string]bool + Invokes, Emits map[string]bool +} + +func manifestIssued(t testing.TB) issued { + t.Helper() + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + RunsAs string `json:"runs-as"` + SecretsOwner string `json:"secrets-owner"` + OwnSecrets map[string]string `json:"own-secrets"` + Requires []json.RawMessage `json:"requires"` + Resources []map[string]any `json:"resources"` + Invokes []string `json:"invokes"` + Emits []string `json:"emits"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + out := issued{RunsAs: m.RunsAs, SecretsOwner: m.SecretsOwner, OwnSecrets: m.OwnSecrets, Resources: m.Resources, + States: map[string]bool{}, Logs: map[string]bool{}, Invokes: map[string]bool{}, Emits: map[string]bool{}} + for _, r := range m.Requires { + var e struct{ Name, Storage string } + if json.Unmarshal(r, &e) != nil { + continue + } + switch e.Storage { + case "key-value": + out.States[e.Name] = true + case "stream": + out.Logs[e.Name] = true + } + } + for _, k := range m.Invokes { + out.Invokes[k] = true + } + for _, e := range m.Emits { + out.Emits[e] = true + } + return out +} + +// mayAsk is whether the manager's own account may ask key: a module tool asked on one node (`.@`) +// is invoked as `.`. +func (i issued) mayAsk(key string) bool { + k, _, _ := strings.Cut(key, "@") + return i.Invokes[k] +} + +// design 39 bus-and-switch C1: the manager runs as an account of its own, which it makes and which owns what it places +// on its machine and its secrets, reaching the bus as itself; and that account is issued every ask its code makes, +// since its runtime is not the machine's tool runner. +func TestTheManagerRunsAsItsOwnAccount(t *testing.T) { + m := manifestIssued(t) + if m.RunsAs != Publisher { + t.Fatalf("the manager runs as %q, not as its own account %s", m.RunsAs, Publisher) + } + if m.SecretsOwner != Publisher || m.OwnSecrets["broker"] == "" { + t.Errorf("its secrets belong to %q and its own broker secret is %q: both are its account's", m.SecretsOwner, + m.OwnSecrets["broker"]) + } + made := false + for _, r := range m.Resources { + switch r["type"] { + case "user": + made = made || r["name"] == Publisher + case "directory", "file": + if r["owner"] != Publisher { + t.Errorf("resource %v is owned by %v, not by the manager's account", r["id"], r["owner"]) + } + } + } + if !made { + t.Errorf("the manager does not make its account: no user resource named %s", Publisher) + } + files, _ := filepath.Glob("*.go") + asked := regexp.MustCompile(`stdio\.Ask\("([^"]+)"`) + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for _, k := range asked.FindAllStringSubmatch(string(src), -1) { + if !m.mayAsk(k[1]) { + t.Errorf("%s asks %s, which the manifest's invokes do not name", f, k[1]) + } + } + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go index 87038f0d7..2f096ba9b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go @@ -2,7 +2,10 @@ package main import ( "encoding/json" + "fmt" "os" + "path/filepath" + "strings" "testing" ) @@ -41,3 +44,29 @@ func TestABoxOpensOnlyForItsRecipient(t *testing.T) { t.Fatal("a box opened for another key") } } + +// design 39 bus-and-switch C2: a key pair the manager finds and cannot read (left by another account, here mode 000) is +// replaced in its place by a new one, said in its journal; the new one is read back by the next start. +func TestAKeyPairItCannotReadIsMadeAnew(t *testing.T) { + if os.Geteuid() == 0 { + t.Fatal("run as root, a file of mode 000 is still read: this test needs an ordinary account") + } + dir := t.TempDir() + file := filepath.Join(dir, "manager-key.json") + if err := os.WriteFile(file, []byte(`{"publicKey":"old","privateKey":"old"}`), 0o000); err != nil { + t.Fatal(err) + } + var said []string + log := func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) } + k, err := keypair(dir, log) + if err != nil { + t.Fatalf("a key pair it cannot read stops the manager: %v", err) + } + if k.PublicKey == "" || k.PublicKey == "old" || len(said) != 1 || !strings.Contains(said[0], "manager-key.json") { + t.Fatalf("not made anew and said: key %q, journal %q", k.PublicKey, said) + } + again, err := keypair(dir, log) + if err != nil || again != k || len(said) != 1 { + t.Fatalf("the new pair is not the one read at the next start: %v, %v, %q", again == k, err, said) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go index 70a3ad73f..f9c750658 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go @@ -287,6 +287,7 @@ type mesh struct { dir string dbURL string vendor *vendor + own issued // what the manager's own runtime is issued from its manifest (C1) mu sync.Mutex said []said @@ -299,11 +300,14 @@ type mesh struct { conditions []oc.Condition } -const busStates = "licences bindings leases offered carried conditions" -const busLogs = "usage audit" - func newWholeMesh(t *testing.T, dbURL string) *mesh { t.Helper() + // The manager runs as its own account, never in the machine's tool runner, whose account is issued none of its + // logs or states (C1): this player plays that runtime of its own, issuing what the manifest names and nothing else. + own := manifestIssued(t) + if own.RunsAs != Publisher { + t.Fatalf("the manager declares runs-as %q: it would run in the machine's tool runner, not as its own account", own.RunsAs) + } ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) dir := t.TempDir() @@ -323,12 +327,12 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh { t.Fatal(err) } // What the bus module creates from the manifest's templates (§1 to §9) before the manager's want moves (§50). - for _, s := range strings.Fields(busStates) { + for s := range own.States { if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: Publisher + "_" + s, History: 8}); err != nil { t.Fatal(err) } } - for _, l := range strings.Fields(busLogs) { + for l := range own.Logs { if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "LOG_" + Publisher + "_" + l, Subjects: []string{"mesh.log." + Publisher + "." + l + ".>"}}); err != nil { t.Fatal(err) @@ -340,7 +344,7 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh { t.Fatal(err) } } - m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t), + m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t), own: own, news: make(chan struct{}, 1), secrets: map[string]string{}, calls: map[string][32]byte{}, callArgs: map[string]map[string]any{}, logins: map[string]FullGrant{}} sub, err := nc.Subscribe("mesh.seat.operator-channel.accept.condition.>", func(msg *nats.Msg) { @@ -521,6 +525,10 @@ func (p *started) handle(w wire) { Pattern string `json:"pattern"` } _ = json.Unmarshal(w.Params, &a) + if why := p.refused(w.Method, w.Params); why != nil { + p.answer(w.ID, nil, why) + return + } if strings.HasPrefix(w.Method, "mesh/log.") { p.log(w) return @@ -620,6 +628,28 @@ func (p *started) handle(w wire) { } } +// refused is the runtime's refusal of what the manager's own account was not issued (C1): a state or a log its +// manifest does not ask for, an ask its invokes do not name, an event its emits do not name; nil when it was issued. +func (p *started) refused(method string, params json.RawMessage) error { + var a struct { + State string `json:"state"` + Log string `json:"log"` + Key string `json:"key"` + } + _ = json.Unmarshal(params, &a) + switch { + case strings.HasPrefix(method, "mesh/state.") && !p.own.States[a.State]: + return fmt.Errorf("%s keeps no state called %q", Publisher, a.State) + case strings.HasPrefix(method, "mesh/log.") && !p.own.Logs[a.Log]: + return fmt.Errorf("%s keeps no log called %q", Publisher, a.Log) + case method == "mesh/ask" && !p.own.mayAsk(a.Key): + return fmt.Errorf("this account may not call %s", a.Key) + case method == "mesh/publish" && !p.own.Emits[a.Key]: + return fmt.Errorf("%s does not emit %s", Publisher, a.Key) + } + return nil +} + func (p *started) say(key string, body json.RawMessage) { p.mu.Lock() p.said = append(p.said, said{key: key, body: append(json.RawMessage(nil), body...)}) @@ -899,7 +929,7 @@ func (m *mesh) written() map[string]uint64 { i, _ := info.Info(m.ctx) out[s] = i.State.LastSeq } - for _, l := range strings.Fields(busLogs) { + for l := range m.own.Logs { info, err := m.js.Stream(m.ctx, "LOG_"+Publisher+"_"+l) if err != nil { m.t.Fatal(err) diff --git a/modules/claude-licence-manager/module.json b/modules/claude-licence-manager/module.json index 3c169a4df..ce212f0a8 100644 --- a/modules/claude-licence-manager/module.json +++ b/modules/claude-licence-manager/module.json @@ -2,6 +2,7 @@ "module": "claude-licence-manager", "version": "1", "slug": "licmgr", + "runs-as": "claude-licence-manager", "requires": [ "postgres-database", "secret", @@ -91,6 +92,10 @@ "grant-key": "${dir:state}/grant.key" } }, + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "claude-licence-manager", "uses": [ "operator-channel" ], @@ -98,7 +103,8 @@ "seat:operator-channel.tell", "seat:mesh-vault.licence-put", "seat:mesh-vault.get", - "seat:mesh-config.config" + "seat:mesh-config.config", + "claude-code.claude_code_grant" ], "seats": [ { @@ -232,10 +238,18 @@ } }, "resources": [ + { + "id": "account", + "type": "user", + "name": "claude-licence-manager", + "shell": "/usr/bin/nologin", + "home": "/var/lib/claude-licence-manager" + }, { "id": "state", "type": "directory", "mode": "0700", + "owner": "claude-licence-manager", "place": ".", "unknown": "program" }, @@ -244,6 +258,7 @@ "type": "file", "path": "${dir:state}/database.url", "mode": "0600", + "owner": "claude-licence-manager", "template": "files/database.url" }, { @@ -251,6 +266,7 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", + "owner": "claude-licence-manager", "takes-config": [ "cadence_minutes", "floor_minutes",