From 3ebcb15a4b05c08c69ff1308b25b14dc7c92ad3b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 02:17:28 +0100 Subject: [PATCH 1/5] systemd: serve kernel-log, the kernel's messages of one boot (issue 1975) journalctl --dmesg --boot=N under the same escalation, window, priority, fixed-string match, 2000-line cap and redaction as the journal verb (shapeLines is the one filter both use); a boot is 0 or a negative whole number, refused otherwise before anything runs. No scope: the kernel belongs to no manager. Builds: issue 1975, the operator's request of 2026-11-12 (the lead's handover; no design line names the verb) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/systemd/cmd/systemd-tools/client.go | 91 ++++++++++++++----- modules/systemd/cmd/systemd-tools/journal.go | 31 ++++++- .../systemd/cmd/systemd-tools/journal_test.go | 72 +++++++++++++++ modules/systemd/cmd/systemd-tools/main.go | 18 ++++ .../systemd/cmd/systemd-tools/wanted_test.go | 4 +- modules/systemd/module.json | 1 + 6 files changed, 190 insertions(+), 27 deletions(-) diff --git a/modules/systemd/cmd/systemd-tools/client.go b/modules/systemd/cmd/systemd-tools/client.go index c7c837e3b..3dd64ecbe 100644 --- a/modules/systemd/cmd/systemd-tools/client.go +++ b/modules/systemd/cmd/systemd-tools/client.go @@ -332,28 +332,7 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string] return nil, err } known, envErr := m.unitSecrets(scope, unit) - all := []string{} - for _, l := range strings.Split(out, "\n") { - if l != "" { - all = append(all, l) - } - } - scanned := len(all) - kept, redacted := []string{}, 0 - for _, l := range all { - l, n := redact(l, known) - redacted += n - if q.Match != "" && !strings.Contains(l, q.Match) { - continue - } - if len(l) > LongestLine { - l = l[:LongestLine] + "…" - } - kept = append(kept, l) - } - if len(kept) > q.Lines { - kept = kept[len(kept)-q.Lines:] - } + kept, scanned, redacted := shapeLines(out, known, q) answer := map[string]any{"unit": unit, "scope": string(scope), "lines": kept, "count": len(kept)} for k, v := range map[string]string{"since": q.Since, "until": q.Until, "match": q.Match, "priority": q.Priority} { if v != "" { @@ -376,6 +355,74 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string] return answer, nil } +// shapeLines is the lines of journalctl's output that a query keeps, each redacted and cut, the last q.Lines of +// those holding the match; and how many lines were read and how many secrets replaced. +func shapeLines(out string, known []knownSecret, q JournalQuery) (kept []string, scanned, redacted int) { + all := []string{} + for _, l := range strings.Split(out, "\n") { + if l != "" { + all = append(all, l) + } + } + scanned = len(all) + kept = []string{} + for _, l := range all { + l, n := redact(l, known) + redacted += n + if q.Match != "" && !strings.Contains(l, q.Match) { + continue + } + if len(l) > LongestLine { + l = l[:LongestLine] + "…" + } + kept = append(kept, l) + } + if len(kept) > q.Lines { + kept = kept[len(kept)-q.Lines:] + } + return kept, scanned, redacted +} + +// KernelLog is the kernel's messages of one boot (journalctl -k, dmesg's, of this boot or an earlier one) that a +// query keeps: the same window, priority, match, cap and redaction as Journal. The kernel's messages belong to +// no unit and no manager, so it has no scope; the read is the system journal's, escalated as Journal's is. +func (m *Manager) KernelLog(q JournalQuery) (map[string]any, error) { + q, err := q.valid() + if err != nil { + return nil, err + } + read := q.Lines + if q.Match != "" { + read = MatchScan + } + out, err := m.call(System, "journalctl", q.kernelArgv(read)...) + if err != nil { + return nil, err + } + kept, scanned, redacted := shapeLines(out, nil, q) + boot := q.Boot + if boot == "" { + boot = "0" + } + answer := map[string]any{"boot": boot, "lines": kept, "count": len(kept)} + for k, v := range map[string]string{"since": q.Since, "until": q.Until, "match": q.Match, "priority": q.Priority} { + if v != "" { + answer[k] = v + } + } + if q.Match != "" { + answer["scanned"] = scanned + if scanned >= MatchScan { + answer["note"] = fmt.Sprintf("the match was looked for in the window's last %d lines only: narrow the window to reach earlier ones", MatchScan) + } + } + if redacted > 0 { + answer["redacted"] = redacted + answer["leak"] = "the kernel's messages hold secrets, shown as [redacted: ]" + } + return answer, nil +} + // unitSecrets are the values of the unit's own Environment= that must not be answered. Read with // systemctl show, which needs no escalation; a unit that does not exist has none. func (m *Manager) unitSecrets(scope Scope, unit string) ([]knownSecret, error) { diff --git a/modules/systemd/cmd/systemd-tools/journal.go b/modules/systemd/cmd/systemd-tools/journal.go index 30fc3a69b..f23669824 100644 --- a/modules/systemd/cmd/systemd-tools/journal.go +++ b/modules/systemd/cmd/systemd-tools/journal.go @@ -28,6 +28,8 @@ const ( LongestLine = 4096 // LongestMatch is the longest text a match may be. LongestMatch = 256 + // MostBoots is how far back a kernel-log read may name a boot. + MostBoots = 9999 ) // JournalQuery is what a journal read is narrowed by. @@ -37,6 +39,8 @@ type JournalQuery struct { Until string Match string Priority string + // Boot is which boot a kernel-log read is of: "0" this boot, "-1" the one before, and so on (empty is "0"). + Boot string } // relative is a time journalctl reads relative to now: -30min, +1h, 2h30min ago, and the day words. @@ -101,6 +105,13 @@ func (q JournalQuery) valid() (JournalQuery, error) { return q, fmt.Errorf("priority %q: 0-7, or emerg, alert, crit, err, warning, notice, info, debug", q.Priority) } } + if q.Boot != "" { + n, err := strconv.Atoi(q.Boot) + if err != nil || n > 0 || n < -MostBoots { + return q, fmt.Errorf("boot %q: 0 for this boot, -1 for the one before, and so on (down to -%d)", q.Boot, MostBoots) + } + q.Boot = strconv.Itoa(n) + } if len(q.Match) > LongestMatch { return q, fmt.Errorf("a match is at most %d bytes", LongestMatch) } @@ -112,7 +123,21 @@ func (q JournalQuery) valid() (JournalQuery, error) { // argv is journalctl's words for a valid query: each value inside the word of its own option. func (q JournalQuery) argv(unit string, lines int) []string { - args := []string{"--no-pager", "--output=short-iso", "--unit=" + unit} + return q.window([]string{"--no-pager", "--output=short-iso", "--unit=" + unit}, lines) +} + +// kernelArgv is journalctl's words for a valid query of the kernel's messages of one boot (-k, as dmesg +// reads them, but also of an earlier boot and in a window). +func (q JournalQuery) kernelArgv(lines int) []string { + boot := q.Boot + if boot == "" { + boot = "0" + } + return q.window([]string{"--no-pager", "--output=short-iso", "--dmesg", "--boot=" + boot}, lines) +} + +// window adds the window, the priority and the line count to the words that choose what is read. +func (q JournalQuery) window(args []string, lines int) []string { if q.Since != "" { v, _, _ := when("since", q.Since, false) args = append(args, "--since="+v) @@ -149,7 +174,7 @@ func journalQuery(a map[string]any) (JournalQuery, error) { default: return q, fmt.Errorf("lines: a whole number") } - for name, into := range map[string]*string{"since": &q.Since, "until": &q.Until, "match": &q.Match, "priority": &q.Priority} { + for name, into := range map[string]*string{"since": &q.Since, "until": &q.Until, "match": &q.Match, "priority": &q.Priority, "boot": &q.Boot} { switch v := a[name].(type) { case nil: case string: @@ -159,7 +184,7 @@ func journalQuery(a map[string]any) (JournalQuery, error) { *into = strings.TrimSpace(v) } case float64: - if name != "priority" { + if name != "priority" && name != "boot" { return q, fmt.Errorf("%s: text", name) } *into = strconv.FormatFloat(v, 'f', -1, 64) diff --git a/modules/systemd/cmd/systemd-tools/journal_test.go b/modules/systemd/cmd/systemd-tools/journal_test.go index 2a8e15c51..d7be03174 100644 --- a/modules/systemd/cmd/systemd-tools/journal_test.go +++ b/modules/systemd/cmd/systemd-tools/journal_test.go @@ -241,3 +241,75 @@ func TestFailedIsOnTheSeatAndNarrowsToAScope(t *testing.T) { t.Fatal("a scope that is none was accepted") } } + +// The kernel-log verb (issue 1975): the kernel's messages of one boot, in the journal verb's window, priority, +// match, cap and redaction, and a boot named 0, -1, and so on. +func TestTheKernelLogIsOneBootsMessagesInTheJournalsForms(t *testing.T) { + var calls []call + m := journalOf([]string{"2026-11-12T10:00:00+0100 host kernel: usb 1-1: new device"}, "", &calls) + got, err := m.KernelLog(JournalQuery{Lines: 50, Since: "-2h", Priority: "err", Boot: "-1"}) + if err != nil { + t.Fatal(err) + } + want := "--no-pager --output=short-iso --dmesg --boot=-1 --since=-2h --priority=3 --lines=50" + if a := strings.Join(journalArgs(t, calls), " "); a != want { + t.Fatalf("journalctl was given\n %s\nnot\n %s", a, want) + } + if calls[0].cmd != "sudo" || got["boot"] != "-1" || got["count"] != 1 { + t.Fatalf("not escalated, or the answer is wrong: %+v %+v", calls[0], got) + } + calls = nil + if _, err := m.KernelLog(JournalQuery{}); err != nil { + t.Fatal(err) + } + if a := strings.Join(journalArgs(t, calls), " "); a != "--no-pager --output=short-iso --dmesg --boot=0 --lines=100" { + t.Fatalf("the default is this boot and a hundred lines: %s", a) + } + // Arguments arrive as the seat's strings or as numbers. + for _, a := range []map[string]any{{"boot": float64(-2)}, {"boot": "-2"}} { + q, err := journalQuery(a) + if err != nil || q.Boot != "-2" { + t.Errorf("%v: %+v %v", a, q, err) + } + } +} + +func TestAKernelLogBootIsRefusedBeforeAnythingRunsUnlessItIsZeroOrBefore(t *testing.T) { + for _, bad := range []string{"1", "abc", "--user", "-1 --merge", "-10000", "0x1", "-1;id"} { + var calls []call + m := journalOf(nil, "", &calls) + if _, err := m.KernelLog(JournalQuery{Boot: bad}); err == nil { + t.Errorf("boot %q accepted", bad) + } + if len(calls) != 0 { + t.Errorf("boot %q reached journalctl: %v", bad, calls) + } + } +} + +func TestTheKernelLogKeepsTheCapTheMatchAndTheRedaction(t *testing.T) { + var calls []call + m := journalOf([]string{"k one", "k password=hunter2secretvalue here", "k two"}, "", &calls) + got, err := m.KernelLog(JournalQuery{Match: "k t", Lines: 5000}) + if err != nil { + t.Fatal(err) + } + if a := strings.Join(journalArgs(t, calls), " "); !strings.HasSuffix(a, fmt.Sprintf("--lines=%d", MatchScan)) { + t.Errorf("a match scans the window's last %d lines: %s", MatchScan, a) + } + if lines := got["lines"].([]string); len(lines) != 1 || lines[0] != "k two" { + t.Errorf("the match kept %v", lines) + } + calls = nil + m = journalOf([]string{"k password=hunter2secretvalue here"}, "", &calls) + got, err = m.KernelLog(JournalQuery{Lines: 5000}) + if err != nil { + t.Fatal(err) + } + if a := strings.Join(journalArgs(t, calls), " "); !strings.HasSuffix(a, "--lines=2000") { + t.Errorf("the cap is 2000: %s", a) + } + if strings.Contains(fmt.Sprint(got["lines"]), "hunter2") || got["redacted"] == nil { + t.Errorf("a secret in a kernel line was answered: %v", got) + } +} diff --git a/modules/systemd/cmd/systemd-tools/main.go b/modules/systemd/cmd/systemd-tools/main.go index 4b9f4280e..3d36389cf 100644 --- a/modules/systemd/cmd/systemd-tools/main.go +++ b/modules/systemd/cmd/systemd-tools/main.go @@ -124,6 +124,24 @@ func tools(m *Manager) []stdio.Tool { } return m.Journal(scope, unit, q) }}, + {Name: seat + ".kernel-log", + Description: "The kernel's messages (what journalctl -k and dmesg give) of this boot or an earlier one, at most 2000 lines, " + + "in a time window and narrowed to a priority and to lines holding a text when asked. A secret a line held is " + + "shown as [redacted: ].", + Input: map[string]any{ + "boot": str("which boot: 0 this boot (the default), -1 the one before, -2 the one before that, and so on (optional)"), + "lines": str("how many lines from the end of what matches (default 100, at most 2000)"), + "since": str("the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)"), + "until": str("the window's end, in the same forms (optional; now when absent)"), + "match": str("only the lines holding this text, as written — a fixed string, not a pattern (optional)"), + "priority": str("only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)")}, + Run: func(a map[string]any) (any, error) { + q, err := journalQuery(a) + if err != nil { + return nil, err + } + return m.KernelLog(q) + }}, // **Clearing a failed record** (novox/hq issue 332): a unit whose file is gone keeps its failed state, // and its start-limit count, in its manager until it is reset. A unit's name is required, so the // verb never resets every failed unit at once. diff --git a/modules/systemd/cmd/systemd-tools/wanted_test.go b/modules/systemd/cmd/systemd-tools/wanted_test.go index 5013d68a6..c8fa6a218 100644 --- a/modules/systemd/cmd/systemd-tools/wanted_test.go +++ b/modules/systemd/cmd/systemd-tools/wanted_test.go @@ -274,8 +274,8 @@ func TestAGlobIsRefusedAsAUnitByEveryVerb(t *testing.T) { m.State = t.TempDir() for _, tool := range tools(m) { verb := strings.TrimPrefix(tool.Name, seat+".") - if verb == "units" || verb == "failed" { - continue // a pattern, and no unit + if verb == "units" || verb == "failed" || verb == "kernel-log" { + continue // a pattern, or no unit at all } _, err := tool.Run(map[string]any{"unit": "*", "scope": "user", "why": "test"}) if err == nil { diff --git a/modules/systemd/module.json b/modules/systemd/module.json index 8c9403d2d..7d453c8ce 100644 --- a/modules/systemd/module.json +++ b/modules/systemd/module.json @@ -18,6 +18,7 @@ "enable", "disable", "journal", + "kernel-log", "failed", "reset-failed", "wanted-by", From ebcc7761f178824c4417a32039c8dabc39221323 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 02:24:44 +0100 Subject: [PATCH 2/5] journal refuses a boot argument it does not use (issue 1975, AC4); tests cite their acceptance labels Builds: issue 1975 note 2: lead answer L4, acceptance 1975/AC1-AC4 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/systemd/cmd/systemd-tools/client.go | 3 +++ .../systemd/cmd/systemd-tools/journal_test.go | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/modules/systemd/cmd/systemd-tools/client.go b/modules/systemd/cmd/systemd-tools/client.go index 3dd64ecbe..4eaf14651 100644 --- a/modules/systemd/cmd/systemd-tools/client.go +++ b/modules/systemd/cmd/systemd-tools/client.go @@ -319,6 +319,9 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string] if err := unitArg(unit); err != nil { return nil, err } + if q.Boot != "" { + return nil, fmt.Errorf("boot %q: the journal of a unit is not read by boot; kernel-log takes a boot", q.Boot) + } q, err := q.valid() if err != nil { return nil, err diff --git a/modules/systemd/cmd/systemd-tools/journal_test.go b/modules/systemd/cmd/systemd-tools/journal_test.go index d7be03174..bf005b220 100644 --- a/modules/systemd/cmd/systemd-tools/journal_test.go +++ b/modules/systemd/cmd/systemd-tools/journal_test.go @@ -242,7 +242,7 @@ func TestFailedIsOnTheSeatAndNarrowsToAScope(t *testing.T) { } } -// The kernel-log verb (issue 1975): the kernel's messages of one boot, in the journal verb's window, priority, +// 1975/AC1: kernel-log reads journalctl --dmesg --boot=N under journal's escalation (issue 1975): the kernel's messages of one boot, in the journal verb's window, priority, // match, cap and redaction, and a boot named 0, -1, and so on. func TestTheKernelLogIsOneBootsMessagesInTheJournalsForms(t *testing.T) { var calls []call @@ -274,6 +274,7 @@ func TestTheKernelLogIsOneBootsMessagesInTheJournalsForms(t *testing.T) { } } +// 1975/AC2: a boot that is not 0 to -9999 is refused and no command runs. func TestAKernelLogBootIsRefusedBeforeAnythingRunsUnlessItIsZeroOrBefore(t *testing.T) { for _, bad := range []string{"1", "abc", "--user", "-1 --merge", "-10000", "0x1", "-1;id"} { var calls []call @@ -287,6 +288,7 @@ func TestAKernelLogBootIsRefusedBeforeAnythingRunsUnlessItIsZeroOrBefore(t *test } } +// 1975/AC3: the window, priority, match, 2000-line cap and redaction are journal's. func TestTheKernelLogKeepsTheCapTheMatchAndTheRedaction(t *testing.T) { var calls []call m := journalOf([]string{"k one", "k password=hunter2secretvalue here", "k two"}, "", &calls) @@ -313,3 +315,15 @@ func TestTheKernelLogKeepsTheCapTheMatchAndTheRedaction(t *testing.T) { t.Errorf("a secret in a kernel line was answered: %v", got) } } + +// 1975/AC4: journal refuses a boot argument it does not use, never reading this boot quietly. +func TestJournalRefusesABootArgument(t *testing.T) { + var calls []call + m := journalOf([]string{"a"}, "", &calls) + if _, err := m.Journal(System, "mail.service", JournalQuery{Boot: "-1"}); err == nil { + t.Fatal("journal accepted a boot and read this boot") + } + if len(calls) != 0 { + t.Fatalf("a refused boot reached the manager: %v", calls) + } +} From 15db34169e2490db8e6ad440b85c7949d0699c84 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 03:04:00 +0100 Subject: [PATCH 3/5] distribution: the holder's test follows the rule that a chunk 0 again starts a part afresh mesh-delivery's parts.Receiver.Take took a chunk 0 again on purpose (276412d7, design 51 Publishing, 1966/AC203-1, AC203-2); the holder's test still expected it refused and failed on every run, on trunk too. The test now expects a chunk out of sequence refused and a chunk 0 again taken. Builds: design 51 Publishing, the bullet "The file travels in parts through the seat"; issue 1975's group check Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/distribution/internal/holder/holder_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/modules/distribution/internal/holder/holder_test.go b/modules/distribution/internal/holder/holder_test.go index d3cbe2bac..6c06fe9a1 100644 --- a/modules/distribution/internal/holder/holder_test.go +++ b/modules/distribution/internal/holder/holder_test.go @@ -242,8 +242,13 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) { t.Errorf("resumed publish = %+v", a) } - if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err == nil { - t.Error("a chunk sent twice was taken: the transfer goes on from the chunks held") + // A chunk that is neither the cursor nor 0 is refused; a chunk 0 again is a part lacked travelling whole again + // (mesh-delivery's parts.Receiver.Take, 1966/AC203-1, AC203-2): its earlier chunks are dropped and it is taken. + if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 2, Data: blob[:1000]}); err == nil { + t.Error("a chunk out of sequence was taken: the transfer goes on from the chunks held") + } + if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err != nil { + t.Errorf("a chunk 0 again was refused: %v", err) } a, err = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 1, Last: true, Data: blob[1000:]}) if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" { From 8d2497b65ef435d27d318f43536233393dd9a5a1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:25:57 +0100 Subject: [PATCH 4/5] C1: the licence manager runs as its own account, its service and its tools in its own runtime (issue 1409) Run inside the machine's tool runner, the manager was issued none of its own logs and states, so the carry-over failed live ("keeps no log called usage ... it was issued: none"). It now declares runs-as claude-licence-manager, makes that account, owns what it places on its machine and its secrets, reaches the bus with its own broker secret, and invokes a node's claude_code_grant, which the tool runner's account could ask and its own could not. The whole tests play the manager's own runtime: they issue only the states, logs, asks and events its manifest names, and fail on a manifest without runs-as; a unit test holds every ask in the code to the manifest's invokes. Builds: design 39 bus-and-switch C1 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- modules/claude-licence-manager/README.md | 6 + .../cmd/claude-licence-manager/main.go | 10 +- .../claude-licence-manager/manifest_test.go | 104 ++++++++++++++++++ .../whole_integration_test.go | 44 ++++++-- modules/claude-licence-manager/module.json | 18 ++- 5 files changed, 170 insertions(+), 12 deletions(-) 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..7e8ddc6f4 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 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/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", From 8291502cbbafe6460ca0ce865ad512f2a09949ef Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:38:03 +0100 Subject: [PATCH 5/5] C2: a key pair the manager cannot read is made anew in its place (issue 1409) Moved to its own account, the manager finds manager-key.json still owned by the tool runner's account, which the node-engine does not hand over; it skipped making a pair because the file existed, and every verb answered the read error. Now a pair it cannot read is replaced by a new one, renamed over it, and said in its journal; every use passes the public key per ask, so nothing depends on the old pair. Builds: design 39 bus-and-switch C2 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/main.go | 58 ++++++++++++++----- .../cmd/claude-licence-manager/seal_test.go | 29 ++++++++++ 2 files changed, 74 insertions(+), 13 deletions(-) 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 7e8ddc6f4..1d34a8da9 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -60,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 } @@ -131,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/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) + } +}