From b6dbe0a7b9cb9bfc40cf395c652380fa80dbcd68 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 13:27:34 +0200 Subject: [PATCH] A container may declare the capabilities it is granted (hq ADR 0169) Exactly the names declared reach the runtime, named in the spec so a change recreates the container; a name that is not a capability's is refused and a privileged container stays undeclarable. For a seat holder whose runtime changes the machine's packet filter. --- internal/apply/apply.go | 8 +++++ internal/apply/left_out_test.go | 39 ++++++++++++++++++++++++ internal/declaration/declaration.go | 16 ++++++++++ internal/declaration/declaration_test.go | 20 ++++++++++++ 4 files changed, 83 insertions(+) diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 5efd3d4..a64123c 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -1583,6 +1583,11 @@ func containerSpecReading(r *declaration.Container, declares, reads map[string]s for _, n := range r.Networks { b.WriteString("also-on " + n + "\n") } + // And the capabilities it was granted (ADR 0169): one gained or dropped is a different + // container, and the runtime cannot change a running one's. + for _, c := range r.Capabilities { + b.WriteString("cap " + c + "\n") + } // The cadence is part of what was declared, so a changed schedule is a changed spec — the marker // moves and the install is reported "updated" and re-established. Added only when present, so no // ordinary container's or run-once step's digest moves for a field it does not set. @@ -1777,6 +1782,9 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner, if r.Network != "" { args = append(args, "--network", r.Network) } + for _, c := range r.Capabilities { + args = append(args, "--cap-add", c) + } for _, d := range r.Dns { args = append(args, "--dns", d) } diff --git a/internal/apply/left_out_test.go b/internal/apply/left_out_test.go index f1d1067..a4d2d5c 100644 --- a/internal/apply/left_out_test.go +++ b/internal/apply/left_out_test.go @@ -134,3 +134,42 @@ func TestALeftOutModuleIsNeitherRemovedNorForgotten(t *testing.T) { t.Fatalf("keeping the left-out module's container was not said: %+v", report.Outcomes) } } + +// A container's capabilities reach the runtime and are part of its spec (novox/hq ADR 0169). +func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) { + var ran []string + run := func(_ context.Context, name string, args ...string) (string, error) { + if name != "docker" { + return "", errors.New("not installed") + } + switch args[0] { + case "info": + return "29.0.0\n", nil + case "container": + return "false\t\n", errors.New("no such container") + case "run": + ran = args + return "deadbeef\n", nil + } + return "", nil + } + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"fw","type":"container","name":"fw","image":"`+pinned+`","network":"host","capabilities":["NET_ADMIN"]} + ]}`) + _, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil) + granted := false + for i, a := range ran { + if a == "--cap-add" && i+1 < len(ran) && ran[i+1] == "NET_ADMIN" { + granted = true + } + } + if !granted { + t.Fatalf("the capability was not granted: %v", ran) + } + with := d.Resources[0].(*declaration.Container) + without := *with + without.Capabilities = nil + if containerSpec(with, inputs{}) == containerSpec(&without, inputs{}) { + t.Fatal("a capability is not part of the container's spec") + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 4272865..ec02074 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -940,6 +940,12 @@ type Container struct { // its siblings can name before any of them can resolve anything. Dns []string `json:"dns,omitempty"` + // Capabilities are the Linux capabilities this container is granted beyond the runtime's + // default set, by name (novox/hq ADR 0169): a holder's runtime that changes the machine's packet + // filter asks for NET_ADMIN. Exactly these, named in the spec so a change recreates the + // container; a privileged container stays undeclarable. + Capabilities []string `json:"capabilities,omitempty"` + // Networks are networks this container also joins once created, by name — a found network a // per-machine setting keeps for a taken container (novox/hq ADR 0163, rule 4), so a // neighbour that resolves it there keeps resolving it until the neighbour is taken too. @@ -1039,6 +1045,12 @@ func (c *Container) validate(where string, _ bool) []string { "static address anywhere but a user-defined one") } } + for _, cap := range c.Capabilities { + if !capabilityName.MatchString(cap) { + problems = append(problems, where+": capabilities names "+strconv.Quote(cap)+", which is not a "+ + "capability's name (CAP_NET_ADMIN or NET_ADMIN)") + } + } for _, n := range c.Networks { problems = append(problems, (&Network{Name: n}).validate(where+": networks", false)...) if n == c.Network { @@ -1209,6 +1221,10 @@ type Adoption struct { Untaken map[string][]string `json:"untaken,omitempty"` } +// capabilityName is what a Linux capability is called: upper case, underscores, an optional CAP_ +// prefix. The runtime accepts either spelling. +var capabilityName = regexp.MustCompile(`^(CAP_)?[A-Z][A-Z0-9_]*$`) + // AdoptionPrefix is the id prefix of what the mesh itself declares because a node is adopted — // its openings and its guard. Nothing under it belongs to a module, so none of it is ever held. const AdoptionPrefix = "adoption." diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index 98e6c92..12e5a54 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -504,3 +504,23 @@ func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) { t.Fatalf("a carried bundle leaving modules out was accepted: %v", err) } } + +// A container may ask for a capability by name, and nothing else (novox/hq ADR 0169). +func TestACapabilityIsNamedOrRefused(t *testing.T) { + image := "postgres@sha256:" + strings.Repeat("a", 64) + d, err := Parse([]byte(`{"declaration":1,"resources":[ + {"id":"fw","type":"container","name":"fw","image":"` + image + `","network":"host","capabilities":["NET_ADMIN","CAP_NET_RAW"]} + ]}`)) + if err != nil { + t.Fatal(err) + } + if got := d.Resources[0].(*Container).Capabilities; len(got) != 2 || got[0] != "NET_ADMIN" { + t.Fatalf("capabilities read as %v", got) + } + for _, bad := range []string{`"net_admin"`, `"ALL;rm -rf /"`, `"privileged"`} { + if _, err := Parse([]byte(`{"declaration":1,"resources":[ + {"id":"fw","type":"container","name":"fw","image":"` + image + `","capabilities":[` + bad + `]}]}`)); err == nil { + t.Errorf("%s was accepted as a capability", bad) + } + } +}