// The proof policy file — where D3 is switched on (spec // 2026-10-06-console-release-defences-design.md §4.5, codex 6). // // It is not host.env. host.env is root 0600 and reaches the agent only when // carlos-edge.service starts, while every root pass starts fresh, so an edit // there would reach one process and not the other. This file is root-owned // 0644 and holds no secrets: app names, the prover name, public keys and a // duration. The agent and root both read it at the start of every pass. // // Absent: D3 is off. Absent means nothing at all is at the path (Lstat says // it does not exist). Anything else that cannot be read or does not parse, // including a dangling symlink, a directory, an unknown key or data after the // policy object, is an error, and the gate reads an error as "on and waiting // for every named-unit route": the box cannot tell which apps were listed, // and a typo or a broken link must not open the gate. package release import ( "bytes" "crypto/ed25519" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "time" "github.com/carlosframework/platform/internal/safefile" ) const ( ProofPolicyPath = "/etc/carlos/proof-policy.json" DefaultProveAfter = 15 * time.Minute ) type ProofPolicy struct { Schema int `json:"schema"` Prove *ProveBlock `json:"prove,omitempty"` Require []RequireEntry `json:"require,omitempty"` } // ProveBlock makes this box a prover: it mints proofs for Apps once a build // has run healthily for After, signed with the root-only key at KeyFile. type ProveBlock struct { Apps []string `json:"apps"` Prover string `json:"prover"` After string `json:"after"` KeyFile string `json:"key_file"` after time.Duration } // RequireEntry makes App on this box adopt only builds Prover has proven. type RequireEntry struct { App string `json:"app"` Prover string `json:"prover"` Key string `json:"key"` pub ed25519.PublicKey } func (e RequireEntry) PublicKey() ed25519.PublicKey { return e.pub } func LoadProofPolicy(path string) (*ProofPolicy, error) { // Lstat, not the read's error, decides absence: a symlink whose target // has gone reads as ENOENT, and reading that as "D3 off" would let an // unproven build in because of a broken config. if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { return nil, nil } b, err := safefile.ReadFile(path) if err != nil { return nil, fmt.Errorf("proof policy %s: %w", path, err) } p, err := ParseProofPolicy(b) if err != nil { return nil, fmt.Errorf("proof policy %s: %w", path, err) } return p, nil } func ParseProofPolicy(b []byte) (*ProofPolicy, error) { dec := json.NewDecoder(bytes.NewReader(b)) dec.DisallowUnknownFields() var p ProofPolicy if err := dec.Decode(&p); err != nil { return nil, fmt.Errorf("does not parse: %v", err) } // More() is false before a stray '}' or ']', so it cannot prove the end // of input; only a second Decode returning io.EOF does. if err := dec.Decode(&json.RawMessage{}); err != io.EOF { return nil, fmt.Errorf("does not parse: data after the policy object") } if p.Schema != 1 { return nil, fmt.Errorf("schema %d, want 1", p.Schema) } if pr := p.Prove; pr != nil { if len(pr.Apps) == 0 { return nil, fmt.Errorf("prove: apps is empty") } for _, a := range pr.Apps { if !ValidApp(a) { return nil, fmt.Errorf("prove: invalid app %q", a) } } if !ValidProver(pr.Prover) { return nil, fmt.Errorf("prove: invalid prover %q", pr.Prover) } d, err := time.ParseDuration(pr.After) if err != nil || d <= 0 { return nil, fmt.Errorf("prove: after %q is not a positive duration", pr.After) } pr.after = d if !filepath.IsAbs(pr.KeyFile) { return nil, fmt.Errorf("prove: key_file %q is not an absolute path", pr.KeyFile) } } for i := range p.Require { e := &p.Require[i] if !ValidApp(e.App) { return nil, fmt.Errorf("require[%d]: invalid app %q", i, e.App) } if !ValidProver(e.Prover) { return nil, fmt.Errorf("require[%d]: invalid prover %q", i, e.Prover) } k, err := ParsePublicKey(e.Key, fmt.Sprintf("require[%d].key", i)) if err != nil { return nil, err } e.pub = k } return &p, nil } func (p *ProofPolicy) Requires(app string) []RequireEntry { if p == nil { return nil } var out []RequireEntry for _, e := range p.Require { if e.App == app { out = append(out, e) } } return out } func (p *ProofPolicy) Proves(app string) bool { if p == nil || p.Prove == nil { return false } for _, a := range p.Prove.Apps { if a == app { return true } } return false } // ProveAfter is how long a build must run before it is proven, and before a // root pass records it as healthy (spec §4.5): the prove block's after, else // DefaultProveAfter. func (p *ProofPolicy) ProveAfter() time.Duration { if p == nil || p.Prove == nil { return DefaultProveAfter } return p.Prove.after }