carlos / platform Public

release: detect a release's Go program and refuse a change of it at ship (console defences D2)

Adds Program and ProgramChange fields to Manifest to track the Go main package path of release binaries. Implements ArtifactProgram, FileProgram, ReleaseProgram, PreviousProgram, CheckShipProgram, and ProgramMismatchError. Tests verify that program changes are rejected by default but allowed with --program-change flag. This catches the 2026-10-06 incident where the carlos CLI was shipped as the console.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev fee7f2ccbf9cc67ce6214f489707a3923a6326cf parent 8f5ae1d
3 files changed, +232 −0
  • internal/release/program.go +114 −0
  • internal/release/program_test.go +111 −0
  • internal/release/release.go +7 −0
diff --git a/internal/release/program.go b/internal/release/program.go
new file mode 100644
index 000000000..2bcf02525
--- /dev/null
+++ b/internal/release/program.go
@@ -0,0 +1,114 @@
+// The wrong-program check — D2 of the console release defences (spec
+// 2026-10-06-console-release-defences-design.md §3).
+//
+// On 2026-10-06 the public carlos CLI binary was shipped as the console. Its
+// architecture and its version stamp were both right, so nothing objected;
+// what was wrong was the program. A Go binary records its main package path
+// in its build info, readable from stripped bytes for any architecture
+// without running them, so a ship can compare it with the app's previous
+// release and a box can compare it with what its route has run.
+package release
+
+import (
+ "bytes"
+ "context"
+ "debug/buildinfo"
+ "fmt"
+ "sort"
+ "strings"
+)
+
+func ArtifactProgram(b []byte) string {
+ bi, err := buildinfo.Read(bytes.NewReader(b))
+ if err != nil {
+ return ""
+ }
+ return bi.Path
+}
+
+func FileProgram(path string) string {
+ bi, err := buildinfo.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ return bi.Path
+}
+
+// ReleaseProgram is the one program every Go artifact in files reports, ""
+// when none is Go.
+func ReleaseProgram(files map[string][]byte) (string, error) {
+ seen := map[string][]string{}
+ for name, b := range files {
+ if p := ArtifactProgram(b); p != "" {
+ seen[p] = append(seen[p], name)
+ }
+ }
+ if len(seen) > 1 {
+ var parts []string
+ for p, names := range seen {
+ sort.Strings(names)
+ parts = append(parts, fmt.Sprintf("%s is %s", strings.Join(names, ", "), p))
+ }
+ sort.Strings(parts)
+ return "", fmt.Errorf("this release's artifacts are different programs: %s", strings.Join(parts, "; "))
+ }
+ for p := range seen {
+ return p, nil
+ }
+ return "", nil
+}
+
+// PreviousProgram is the program of the newest release (among the 20 newest)
+// whose manifest declares one, and that release's version.
+func PreviousProgram(ctx context.Context, s Store, acct, app string) (string, string, error) {
+ vers, _, err := VersionCandidates(ctx, s, acct, app, 20)
+ if err != nil {
+ return "", "", err
+ }
+ var best *Manifest
+ for _, v := range vers {
+ m, err := GetManifest(ctx, s, acct, app, v)
+ if err != nil || m == nil || m.Program == "" {
+ continue
+ }
+ if best == nil || m.BuiltAt > best.BuiltAt {
+ best = m
+ }
+ }
+ if best == nil {
+ return "", "", nil
+ }
+ return best.Program, best.Version, nil
+}
+
+type ProgramMismatchError struct{ App, Got, Want, WantVersion string }
+
+func (e *ProgramMismatchError) Error() string {
+ return fmt.Sprintf("this artifact is %s; %s's releases are %s (%s). If the app really is changing program, ship again with --program-change",
+ e.Got, e.App, e.Want, e.WantVersion)
+}
+
+// CheckShipProgram is the one ship-side rule both ship doors call before
+// release.Ship. It stamps m.Program (and m.ProgramChange when allowed).
+func CheckShipProgram(ctx context.Context, s Store, acct string, m *Manifest, files map[string][]byte, allowChange bool) error {
+ if m.Kind != "binary" {
+ return nil
+ }
+ prog, err := ReleaseProgram(files)
+ if err != nil || prog == "" {
+ return err
+ }
+ m.Program = prog
+ prev, prevVer, err := PreviousProgram(ctx, s, acct, m.App)
+ if err != nil {
+ return fmt.Errorf("reading %s's previous releases: %w", m.App, err)
+ }
+ if prev == "" || prev == prog {
+ return nil
+ }
+ if !allowChange {
+ return &ProgramMismatchError{App: m.App, Got: prog, Want: prev, WantVersion: prevVer}
+ }
+ m.ProgramChange = true
+ return nil
+}
diff --git a/internal/release/program_test.go b/internal/release/program_test.go
new file mode 100644
index 000000000..a4f2269ea
--- /dev/null
+++ b/internal/release/program_test.go
@@ -0,0 +1,111 @@
+package release
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// tinyProgram builds a stripped linux/arm64 Go binary whose main package is
+// modPath, exactly as the console is built (-s -w), at test time. Nothing is
+// checked in.
+func tinyProgram(t *testing.T, modPath string) []byte {
+ t.Helper()
+ if testing.Short() {
+ t.Skip("builds a binary")
+ }
+ dir := t.TempDir()
+ must := func(err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ must(os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+modPath+"\n\ngo 1.22\n"), 0o644))
+ must(os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644))
+ out := filepath.Join(dir, "bin")
+ cmd := exec.Command("go", "build", "-trimpath", "-ldflags=-s -w", "-o", out, ".")
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false")
+ if b, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("build %s: %v\n%s", modPath, err, b)
+ }
+ b, err := os.ReadFile(out)
+ must(err)
+ return b
+}
+
+func TestArtifactProgram(t *testing.T) {
+ if got := ArtifactProgram(tinyProgram(t, "example.com/console")); got != "example.com/console" {
+ t.Fatalf("program = %q", got)
+ }
+ if got := ArtifactProgram([]byte("#!/bin/sh\necho hi\n")); got != "" {
+ t.Fatalf("non-Go program = %q, want empty", got)
+ }
+}
+
+func TestReleaseProgramDisagreement(t *testing.T) {
+ _, err := ReleaseProgram(map[string][]byte{
+ "linux-arm64": tinyProgram(t, "example.com/console"),
+ "linux-amd64": tinyProgram(t, "example.com/carlos"),
+ })
+ if err == nil || !strings.Contains(err.Error(), "example.com/console") || !strings.Contains(err.Error(), "example.com/carlos") {
+ t.Fatalf("err = %v, want both programs named", err)
+ }
+}
+
+func shipWithProgram(t *testing.T, s Store, ver string, art []byte, allow bool) error {
+ t.Helper()
+ files := map[string][]byte{adoptPlatform: art}
+ m := Manifest{Schema: 1, App: "console", Version: ver, Kind: "binary",
+ Artifacts: map[string]Artifact{adoptPlatform: {}}} // Ship fills file, sha256, size
+ if err := CheckShipProgram(context.Background(), s, "bab", &m, files, allow); err != nil {
+ return err
+ }
+ return Ship(context.Background(), s, "bab", m, files)
+}
+
+func TestCheckShipProgram(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ console := tinyProgram(t, "example.com/console")
+ cli := tinyProgram(t, "example.com/carlos")
+ if err := shipWithProgram(t, s, "v1", console, false); err != nil {
+ t.Fatalf("first release: %v", err)
+ }
+ err := shipWithProgram(t, s, "v2", cli, false)
+ var pm *ProgramMismatchError
+ if !errors.As(err, &pm) || pm.Got != "example.com/carlos" || pm.Want != "example.com/console" {
+ t.Fatalf("wrong program ship = %v, want ProgramMismatchError", err)
+ }
+ if !strings.Contains(err.Error(), "--program-change") {
+ t.Fatalf("refusal %q does not name the override", err)
+ }
+ if err := shipWithProgram(t, s, "v3", cli, true); err != nil {
+ t.Fatalf("--program-change ship: %v", err)
+ }
+ m, err := GetManifest(context.Background(), s, "bab", "console", "v3")
+ if err != nil || m.Program != "example.com/carlos" || !m.ProgramChange {
+ t.Fatalf("v3 manifest = %+v, %v; want program recorded and program_change true", m, err)
+ }
+ if err := shipWithProgram(t, s, "v4", []byte("#!/bin/sh\n"), false); err != nil {
+ t.Fatalf("non-Go artifact refused: %v", err)
+ }
+}
+
+// A release from an old client has no program and must not switch the check
+// off for the next ship.
+func TestPreviousProgramSkipsReleasesWithoutOne(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ if err := shipWithProgram(t, s, "v1", tinyProgram(t, "example.com/console"), false); err != nil {
+ t.Fatal(err)
+ }
+ shipRelease(t, s, "bab", "console", "v2", adoptPlatform, []byte("old client, no program field"))
+ got, ver, err := PreviousProgram(context.Background(), s, "bab", "console")
+ if err != nil || got != "example.com/console" || ver != "v1" {
+ t.Fatalf("PreviousProgram = %q %q %v, want example.com/console from v1", got, ver, err)
+ }
+}
diff --git a/internal/release/release.go b/internal/release/release.go
index c5b300f26..a542170f6 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -60,6 +60,13 @@ type Manifest struct {
// by old binaries. Nothing on the adopt path reads either.
Label string `json:"label,omitempty"`
Notes string `json:"notes,omitempty"`
+ // Program is the Go main package path of the release's binaries
+ // (debug/buildinfo), and ProgramChange records an intended change of it
+ // (`carlos ship --program-change`). ADDITIVE to schema 1 like Label:
+ // omitted when empty, ignored by old binaries. Console release defences
+ // D2 (program.go).
+ Program string `json:"program,omitempty"`
+ ProgramChange bool `json:"program_change,omitempty"`
// Artifacts is keyed "linux-arm64" etc for binaries, or "site" for a
// static tree tar.
Artifacts map[string]Artifact `json:"artifacts"`