carlos / platform Public

release: the proof policy file, closed on any parse error

Absent file means D3 is off; unreadable, unparseable or unknown-key content is an error the gate reads as on and waiting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev dcafa9da6945a28b4ca8fd59e28f138bc7e91b23 parent f2427ca
2 files changed, +231 −0
  • internal/release/proofpolicy.go +157 −0
  • internal/release/proofpolicy_test.go +74 −0
diff --git a/internal/release/proofpolicy.go b/internal/release/proofpolicy.go
new file mode 100644
index 000000000..b4382d023
--- /dev/null
+++ b/internal/release/proofpolicy.go
@@ -0,0 +1,157 @@
+// 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. Anything else that does not parse, including an unknown
+// key, 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
+// must not open the gate.
+package release
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+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) {
+ b, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ 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)
+ }
+ if dec.More() {
+ 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
+}
diff --git a/internal/release/proofpolicy_test.go b/internal/release/proofpolicy_test.go
new file mode 100644
index 000000000..908af8e24
--- /dev/null
+++ b/internal/release/proofpolicy_test.go
@@ -0,0 +1,74 @@
+package release
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestProofPolicyAbsentIsOff(t *testing.T) {
+ p, err := LoadProofPolicy(filepath.Join(t.TempDir(), "proof-policy.json"))
+ if p != nil || err != nil {
+ t.Fatalf("absent = %+v, %v; want nil, nil (D3 off)", p, err)
+ }
+ if p.Requires("console") != nil || p.Proves("console") || p.ProveAfter() != DefaultProveAfter {
+ t.Fatal("a nil policy is not inert")
+ }
+}
+
+func TestProofPolicyParses(t *testing.T) {
+ _, _, pubB64 := proverKey(t)
+ path := filepath.Join(t.TempDir(), "proof-policy.json")
+ body := fmt.Sprintf(`{"schema":1,
+ "prove":{"apps":["console"],"prover":"dev","after":"20m","key_file":"/etc/carlos/proving.key"},
+ "require":[{"app":"console","prover":"dev","key":%q}]}`, pubB64)
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ p, err := LoadProofPolicy(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !p.Proves("console") || p.Proves("other") || p.ProveAfter() != 20*time.Minute {
+ t.Fatalf("prove block = %+v", p.Prove)
+ }
+ req := p.Requires("console")
+ if len(req) != 1 || req[0].Prover != "dev" || len(req[0].PublicKey()) == 0 {
+ t.Fatalf("require = %+v", req)
+ }
+ if p.Requires("other") != nil {
+ t.Fatal("an unlisted app is required")
+ }
+}
+
+func TestProofPolicyRequireOnlyUsesTheDefaultAfter(t *testing.T) {
+ _, _, pubB64 := proverKey(t)
+ p, err := ParseProofPolicy([]byte(fmt.Sprintf(`{"schema":1,"require":[{"app":"console","prover":"dev","key":%q}]}`, pubB64)))
+ if err != nil || p.ProveAfter() != DefaultProveAfter {
+ t.Fatalf("p = %+v, %v", p, err)
+ }
+}
+
+// A typo must never open the gate: every one of these is an error, which the
+// gate turns into "on and waiting".
+func TestProofPolicyUnparseableIsAnError(t *testing.T) {
+ _, _, pubB64 := proverKey(t)
+ for name, body := range map[string]string{
+ "empty": ``,
+ "torn": `{"schema":1,"require":[`,
+ "unknown key": fmt.Sprintf(`{"schema":1,"requires":[{"app":"console","prover":"dev","key":%q}]}`, pubB64),
+ "schema 2": `{"schema":2}`,
+ "bad key": `{"schema":1,"require":[{"app":"console","prover":"dev","key":"nope"}]}`,
+ "bad prover": fmt.Sprintf(`{"schema":1,"require":[{"app":"console","prover":"Dev","key":%q}]}`, pubB64),
+ "bad after": `{"schema":1,"prove":{"apps":["console"],"prover":"dev","after":"soon","key_file":"/k"}}`,
+ "relative key": `{"schema":1,"prove":{"apps":["console"],"prover":"dev","after":"15m","key_file":"proving.key"}}`,
+ "no apps": `{"schema":1,"prove":{"apps":[],"prover":"dev","after":"15m","key_file":"/k"}}`,
+ "trailing junk": `{"schema":1} {}`,
+ } {
+ if _, err := ParseProofPolicy([]byte(body)); err == nil {
+ t.Fatalf("%s: parsed, want an error", name)
+ }
+ }
+}