// Package caps is the closed capability vocabulary: what an app may ask the // platform for, and what each name maps to. A manifest never names a path, a // user, a uid, a resource number, or a systemd directive — the directives are // Go constants in this package. There is no passthrough field and no // "extra" escape hatch; the escape hatch is 90-operator.conf, which this // package never writes and never reads. // // See docs/superpowers/specs/2026-08-03-app-capabilities-design.md §2 (the // model: self-service vs operator-consented), §3 (wire shape and which way // each failure falls), §7 (security posture — why each rail exists), and §8 // (the testing shape this file's tests follow). package caps import ( "encoding/json" "fmt" "regexp" "sort" "strings" ) // Version is the capability block version THIS binary understands. A block // declaring a "v" outside 1..Version refuses the version on this box (spec // §3b) — fail closed in the direction that changes nothing: the box keeps // serving what it has rather than materialize a unit missing something the // app needs. Accepting the whole 1..Version range (not just == Version) is // what lets an immutable v1 manifest already sitting in a production bucket // keep validating the day Version becomes 2 — a rollback depends on it. const Version = 1 // Marker is the first line of every drop-in this package generates — the // same SHAPE as the marker `carlos ops litestream` uses (not the same // string): a "# GENERATED by carlos ... — do not hand-edit" line, so a // hand-edit check can look for that shape without two packages having to // agree on one hardcoded literal. A file at a generated path WITHOUT it is // never overwritten and never deleted (spec §3c). const Marker = "# GENERATED by carlos adopt — do not hand-edit" // DataReplicatePlatform and DataReplicateApp are the only two values // Data.Replicate accepts (spec §2, §3a): the box-wide carlos-litestream // unit replicates the named dbs, or the app streams its own WAL and the // platform provisions the directory and replicates nothing. Two // replicators on one WAL fight over checkpointing and corrupt the replica, // so the two are mutually exclusive — see the "dbs with replicate:\"app\"" // rule in validData. const ( DataReplicatePlatform = "platform" DataReplicateApp = "app" ) // maxPorts and maxDBs are bounds, not features (spec §7) — shared between // decode time, where they bound the memory a hostile manifest can force // this binary to allocate, and ValidCapabilities, the semantic gate a // programmatically-built Set (never having passed through UnmarshalJSON) // still has to clear. const ( maxPorts = 4 maxDBs = 8 ) // tokenRe is the charset shared by command, runtime_dir, and ports[].name // (spec §7): one lowercase token, so "../etc", "carlos/apps", absolute // paths, flags, and spaces never parse. It is also, byte for byte, // release.ValidApp's own app-name charset — see ValidCapabilities's // host_role check for why that duplication exists. var tokenRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) // dbNameRe is data.dbs's charset: a bare filename, used only as // filepath.Join(stateDir, name) and never a path (spec §7). "." and ".." // pass this character class and are rejected separately below. var dbNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`) // reservedRuntimeDir names a directory the platform or the OS already owns. // "carlos" is the load-bearing entry: a tenant owning /run/carlos would own // every tenant socket the edge dials — the single most valuable directory // on the box (spec §7). The rest are /run subdirectories systemd, the // kernel, or a common distro tool already owns; the "systemd-" prefix is // checked separately, below. // // static list is the first guard; converge's existence check (Task 6) is // the second. This list can never be exhaustive — a box-specific // /run/tailscale is not on it and never will be — so the converge pass // additionally refuses a runtime_dir grant when /run/ already exists // on the box and is not this route's own prior claim. See the note // appended to the Task 6 section of // docs/superpowers/plans/2026-08-03-app-capabilities.md. var reservedRuntimeDir = map[string]bool{ "carlos": true, "systemd": true, "lock": true, "log": true, "user": true, "dbus": true, "sshd": true, "udev": true, "credentials": true, "sudo": true, "mount": true, "network": true, "initramfs": true, "tmpfiles.d": true, "host": true, "machines": true, "netns": true, "blkid": true, "motd.d": true, "sendsigs.omit.d": true, "console-setup": true, "setrans": true, "shm": true, "dhcpcd": true, "initctl": true, } // reservedPort is bound absolutely and is never grantable, typo or not // (spec §7): the edge's own ports and the box's SSH. var reservedPort = map[int]bool{22: true, 80: true, 443: true} // knownCapability is the closed vocabulary's eight names. A manifest key // under "capabilities" that isn't one of these (or "v") names a capability // this binary does not know, and UnmarshalJSON refuses the whole block. var knownCapability = map[string]bool{ "command": true, "daemon": true, "children": true, "runtime_dir": true, "ports": true, "data": true, "host_role": true, "doc_engine": true, } // Set is a manifest's whole "capabilities" object. It is decoded only // through UnmarshalJSON below — the strict door that tells an unknown // capability NAME (refuse) apart from an unknown FIELD inside a known one // (ignore and note). type Set struct { V int `json:"v"` Command string `json:"command,omitempty"` Daemon bool `json:"daemon,omitempty"` Children bool `json:"children,omitempty"` RuntimeDir string `json:"runtime_dir,omitempty"` Ports []Port `json:"ports,omitempty"` Data *Data `json:"data,omitempty"` HostRole bool `json:"host_role,omitempty"` DocEngine bool `json:"doc_engine,omitempty"` notes []string // unknown-field-inside-a-known-capability lines; see Notes } // Port is one privileged-port ask. Name is an operator/log label and // carries no behaviour (spec §3a, §4c) — it is never a systemd // FileDescriptorName. type Port struct { Port int `json:"port"` Name string `json:"name,omitempty"` // an operator/log label; carries NO behaviour } // Data is the persistent-data-dir ask: which files under the state dir are // databases, and who replicates them. The path itself is never in the // manifest — it is always StateDirectory=carlos-app/%i (spec §2). // // Asymmetry, on purpose: a manifest with no "data" key at all decodes to a // nil *Data on Set — no capability declared, always valid. A manifest that // instead writes an explicit "data": null is NOT the same thing: // UnmarshalJSON still allocates a non-nil *Data (the zero value, an empty // Replicate), which ValidCapabilities then refuses for naming neither // accepted replicate value. Fail-closed on purpose: an explicit-but-empty // "data" is not silently waved through as "no capability" just because an // absent key would have been. type Data struct { Replicate string `json:"replicate"` // DataReplicatePlatform | DataReplicateApp DBs []string `json:"dbs,omitempty"` } // UnmarshalJSON is the strict door (spec §3b), and the three outcomes are // deliberately different: // - an unknown CAPABILITY NAME, or a "v" outside 1..Version: error, // naming the capability and "update the platform binary". This // propagates through json.Unmarshal -> release.GetManifest -> // ensureStaged, which is exactly how the version comes to be refused on // this box. // - an unknown FIELD INSIDE a known capability: accepted, recorded in // notes. // - anything absent: the zero value, which means "not declared". // // It decodes into a map[string]json.RawMessage first so it can check every // key against the eight known names plus "v" before decoding anything // typed, then re-decodes each object-valued capability with a second // map[string]json.RawMessage pass to collect notes AND to decode each known // field one at a time by its exact key. That second point matters: a // whole-object json.Unmarshal into Port or Data would let Go's decoder // case-fold an unrecognised "PORT" or "REPLICATE" onto the typed field via // its default case-insensitive tag matching — silently applying exactly the // value the notes scan just reported as ignored (config-that-lies). Every // field of every object-valued capability is therefore decoded via // decodeIfPresent against the fields map's exact string key, never via a // whole-object unmarshal. // // json.Decoder.DisallowUnknownFields is deliberately NOT used on the whole // block — it cannot distinguish an unknown name (refuse) from an unknown // field (ignore), and that distinction is the entire forward-compatibility // contract (spec §3b). // // Decoding builds into a local Set and assigns to *s only once, at the very // end: a mid-decode error can then never leave *s partially populated, by // construction rather than by careful statement ordering. func (s *Set) UnmarshalJSON(b []byte) error { var raw map[string]json.RawMessage if err := json.Unmarshal(b, &raw); err != nil { return err } // "v" must be present as its own key. An absent "v" on a present block // is refused with an honest message naming the missing key — never // treated as "v 0", which would conflate "the block forgot to say" with // "the block explicitly said an invalid version". vRaw, ok := raw["v"] if !ok { return fmt.Errorf(`capabilities: missing "v" (capability block version) — refusing rather than guessing which one was meant`) } var v int if err := json.Unmarshal(vRaw, &v); err != nil { return fmt.Errorf("capabilities: v: %w", err) } if v < 1 || v > Version { return fmt.Errorf("capabilities: v %d is not supported by this binary (this binary speaks v1..v%d) — update the platform binary", v, Version) } for name := range raw { if name == "v" { continue } if !knownCapability[name] { return fmt.Errorf("capabilities: unknown capability %q — update the platform binary", name) } } var out Set out.V = v var notes []string if err := decodeIfPresent(raw, "command", &out.Command); err != nil { return fmt.Errorf("capabilities: command: %w", err) } if err := decodeIfPresent(raw, "daemon", &out.Daemon); err != nil { return fmt.Errorf("capabilities: daemon: %w", err) } if err := decodeIfPresent(raw, "children", &out.Children); err != nil { return fmt.Errorf("capabilities: children: %w", err) } if err := decodeIfPresent(raw, "runtime_dir", &out.RuntimeDir); err != nil { return fmt.Errorf("capabilities: runtime_dir: %w", err) } if err := decodeIfPresent(raw, "host_role", &out.HostRole); err != nil { return fmt.Errorf("capabilities: host_role: %w", err) } if err := decodeIfPresent(raw, "doc_engine", &out.DocEngine); err != nil { return fmt.Errorf("capabilities: doc_engine: %w", err) } if portsRaw, ok := raw["ports"]; ok { var items []json.RawMessage if err := json.Unmarshal(portsRaw, &items); err != nil { return fmt.Errorf("capabilities: ports: %w", err) } // Bound BEFORE the make below, so the length a hostile manifest // claims bounds the memory this binary allocates for it, not just // the eventual result ValidCapabilities would reject. if len(items) > maxPorts { return fmt.Errorf("capabilities: ports: %d declared, want at most %d", len(items), maxPorts) } ports := make([]Port, len(items)) for i, item := range items { var fields map[string]json.RawMessage if err := json.Unmarshal(item, &fields); err != nil { return fmt.Errorf("capabilities: ports[%d]: %w", i, err) } for f := range fields { if f != "port" && f != "name" { notes = append(notes, fmt.Sprintf("capabilities: ports[%d]: unknown field %q ignored", i, f)) } } if err := decodeIfPresent(fields, "port", &ports[i].Port); err != nil { return fmt.Errorf("capabilities: ports[%d].port: %w", i, err) } if err := decodeIfPresent(fields, "name", &ports[i].Name); err != nil { return fmt.Errorf("capabilities: ports[%d].name: %w", i, err) } } out.Ports = ports } if dataRaw, ok := raw["data"]; ok { var fields map[string]json.RawMessage if err := json.Unmarshal(dataRaw, &fields); err != nil { return fmt.Errorf("capabilities: data: %w", err) } for f := range fields { if f != "replicate" && f != "dbs" { notes = append(notes, fmt.Sprintf("capabilities: data: unknown field %q ignored", f)) } } var d Data if err := decodeIfPresent(fields, "replicate", &d.Replicate); err != nil { return fmt.Errorf("capabilities: data.replicate: %w", err) } if dbsRaw, ok := fields["dbs"]; ok { var dbs []string if err := json.Unmarshal(dbsRaw, &dbs); err != nil { return fmt.Errorf("capabilities: data.dbs: %w", err) } // Same bound-before-allocation discipline as ports above. if len(dbs) > maxDBs { return fmt.Errorf("capabilities: data.dbs: %d entries, want at most %d", len(dbs), maxDBs) } d.DBs = dbs } out.Data = &d } sort.Strings(notes) out.notes = notes *s = out return nil } // decodeIfPresent unmarshals raw[key] into out when key is present, and // leaves out untouched (the zero value, meaning "not declared") otherwise. // Because the lookup is an exact Go map-key comparison rather than a // whole-object json.Unmarshal, it never falls back to encoding/json's // case-insensitive struct-tag matching — a "PORT" or "REPLICATE" the caller // has already flagged as an unknown field via the same fields map stays // unapplied. func decodeIfPresent(raw map[string]json.RawMessage, key string, out any) error { if v, ok := raw[key]; ok { return json.Unmarshal(v, out) } return nil } // Notes are the ignore-with-a-log lines the decoder collected, sorted. // Callers print them once per pass, never per host. A nil Set — every // production manifest before this feature, and what RunAdopt sees on every // route that never declared capabilities — returns nil rather than // panicking. func (s *Set) Notes() []string { if s == nil { return nil } return s.notes } // ValidCapabilities is the semantic gate, shared by SHIP (a human is // present to read the refusal) and ADOPT (the box refuses the version), // exactly as appconfig.ValidExecDelivery is shared by set-time and // sync-time. A nil Set is valid and means no capabilities. app is needed // for the host_role user-name charset and length checks and nothing else. func ValidCapabilities(app string, s *Set) error { if s == nil { return nil } if s.V < 1 || s.V > Version { return fmt.Errorf("capabilities: v %d is not supported by this binary (this binary speaks v1..v%d) — update the platform binary", s.V, Version) } if s.Command != "" && !tokenRe.MatchString(s.Command) { return fmt.Errorf("capabilities: command %q must be one lowercase token matching ^[a-z][a-z0-9-]{0,31}$ — one token, no flags, no spaces", s.Command) } if s.RuntimeDir != "" { if !tokenRe.MatchString(s.RuntimeDir) { return fmt.Errorf("capabilities: runtime_dir %q must match ^[a-z][a-z0-9-]{0,31}$", s.RuntimeDir) } if reservedRuntimeDir[s.RuntimeDir] || strings.HasPrefix(s.RuntimeDir, "systemd-") { return fmt.Errorf("capabilities: runtime_dir %q is a reserved name", s.RuntimeDir) } } if err := validPorts(s.Ports); err != nil { return err } if s.Data != nil { if err := validData(s.Data); err != nil { return err } } if s.HostRole { // release.ValidApp (internal/release/release.go) already refuses an // app name outside this exact charset at every route-creation entry // point. It is duplicated here — rather than imported, since // release comes to depend on this package in Task 2 and importing // it back would cycle — as defense in depth for the // User= sink: a Set can be validated directly, // without ever having passed through a route. if !tokenRe.MatchString(app) { return fmt.Errorf("capabilities: host_role: app %q fails the app-name charset — see release.ValidApp", app) } if u := AppUser(app); len(u) > 32 { return fmt.Errorf("capabilities: host_role: user %q is longer than the 32-character account-name limit — refused at ship, where a human can rename", u) } } if s.HostRole && s.DocEngine { // Refused as a declaration, before any grant is consulted, because the // two contradict in the rendered bytes: host_role emits // IPAddressAllow=169.254.169.254 to re-open the IMDS the template // fenced, and in systemd's BPF firewall an allow-list match WINS over // a deny-list match (internal/caps/render.go). doc_engine's whole // purpose is to fence the engine's egress against private networks it // can be SSRF'd into — so declaring both asks for a fence with a // hole punched in it by the other half of the same drop-in. // // A document-conversion orchestrator has no business holding the // box's cloud instance role anyway: it converts bytes a caller sent // and needs no cloud credentials to do it. Refusing the pair is the // honest answer to a manifest that asks for both. return fmt.Errorf("capabilities: host_role and doc_engine contradict — doc_engine fences the unit's egress away from private networks, and host_role re-opens the cloud metadata address, which in systemd's BPF firewall an IPAddressAllow wins over any IPAddressDeny. A document-conversion engine needs no cloud instance role; drop host_role") } return nil } // validPorts encodes the ports rules: only privileged ports are a // capability, 22/80/443 are reserved absolutely, no duplicates, and a bound // of maxPorts — a bound, not a feature. func validPorts(ports []Port) error { if len(ports) > maxPorts { return fmt.Errorf("capabilities: ports: %d declared, want at most %d", len(ports), maxPorts) } seen := make(map[int]bool, len(ports)) for _, p := range ports { if p.Port < 1 || p.Port > 1023 { return fmt.Errorf("capabilities: ports: port %d is outside 1..1023 — only privileged ports are a capability; a high port needs no help from the platform", p.Port) } if reservedPort[p.Port] { return fmt.Errorf("capabilities: ports: port %d is reserved and not grantable", p.Port) } if seen[p.Port] { return fmt.Errorf("capabilities: ports: port %d declared more than once", p.Port) } seen[p.Port] = true if p.Name != "" && !tokenRe.MatchString(p.Name) { return fmt.Errorf("capabilities: ports: name %q must match ^[a-z][a-z0-9-]{0,31}$ — it lands in logs", p.Name) } } return nil } // validData encodes the data rules: replicate must be one of the two // constants, dbs is a bound list of unique bare filenames, and dbs is // meaningless (and refused) when the app replicates its own WAL. func validData(d *Data) error { if d.Replicate != DataReplicatePlatform && d.Replicate != DataReplicateApp { return fmt.Errorf("capabilities: data.replicate %q must be %q or %q", d.Replicate, DataReplicatePlatform, DataReplicateApp) } if len(d.DBs) > maxDBs { return fmt.Errorf("capabilities: data.dbs: %d entries, want at most %d", len(d.DBs), maxDBs) } if d.Replicate == DataReplicateApp && len(d.DBs) > 0 { return fmt.Errorf("capabilities: data.dbs is meaningless when replicate is %q — the app streams its own WAL, and accepting it would invite the two-replicators hazard", DataReplicateApp) } seen := make(map[string]bool, len(d.DBs)) for _, name := range d.DBs { if seen[name] { return fmt.Errorf("capabilities: data.dbs: %q declared more than once — two replicators on one WAL fight over checkpointing", name) } seen[name] = true if name == "." || name == ".." || !dbNameRe.MatchString(name) { return fmt.Errorf("capabilities: data.dbs: %q must be a bare filename matching ^[A-Za-z0-9._-]{1,64}$", name) } } return nil } // AppUser is the static system user a host_role app runs as, DERIVED by the // platform and never taken from a manifest. func AppUser(app string) string { return "carlos-" + app }