From 2d2fb05d424f09d30a0b54fcbfee317386798edd Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Mon, 27 Jul 2026 12:22:54 +0000 Subject: [PATCH] Sync Interlock from Intelligence Flow @ 455bc0e2f338 --- UPSTREAM.json | 10 +- cmd/interlock/main.go | 10 ++ cmd/interlock/upgrade.go | 224 +++++++++++++++++++++++++++++++++++++++ cmd/interlock/version.go | 19 +++- e2e/coverage_test.go | 1 + e2e/upgrade_test.go | 109 +++++++++++++++++++ 6 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 cmd/interlock/upgrade.go create mode 100644 e2e/upgrade_test.go diff --git a/UPSTREAM.json b/UPSTREAM.json index d1a33fd..e8a1bed 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -36,10 +36,11 @@ "cmd/interlock/derive.go": "6c2eda35cdf73d61f22dd3702999390e84c945416a0f699c709bf59ad42246cc", "cmd/interlock/init.go": "ea4df1ba3bcf0ec2f62cf02752b39598027376096ba974b2e770b0a79dc4ba0f", "cmd/interlock/install.go": "71cdb44ec7467b0d4e8895eb8ba5d4275ff54e898b74e5d3cb4e66ee55c200ae", - "cmd/interlock/main.go": "5b8b0609026ffb1d3db0c23ece1a116a8d69016102f5f2be937035ffeff310ac", + "cmd/interlock/main.go": "e6b13c3441c625db6705cd5f368feee3c8c84f777976f7d1fb1f1ee3809930b4", "cmd/interlock/test.go": "e9b680cde45e061155dcc375b057f8ff4e69559d5f2be7dcd15f3685af0e1079", + "cmd/interlock/upgrade.go": "99753fc44d2f1ff98da3c8aaa340d3debcf0341ff333bf12305ad801f4ac484f", "cmd/interlock/verify.go": "5613c04febf6731fd25a75e615a524d57f99cb762016f458be5243034376b025", - "cmd/interlock/version.go": "262fedc77a86623a48ee5a52940356a399fc71466d6da52f902cd655b9d7303d", + "cmd/interlock/version.go": "3a579f55abf740b427e61932a783bf99203cbbd9d71ee639d782088975069ccf", "compiler/compiler.go": "50487aaa0c24b3c0d041ab5b9a410fa672de7eb77b1cd3df1ec97cd4cf2bccba", "compiler/compiler_test.go": "6f80886aa1bfdf79bca014ad47c7b48d2a6bc24c86863afa4ebeb93658a790aa", "conformance/compat/compat.go": "5db334201f0d701ba1d7cd73916215d08d00bd566ed35477bf331c055edaca37", @@ -80,11 +81,12 @@ "derive/schema.go": "4d10bca81110512a10aaf6306d5a5a2ebc4193058b4500cd92022a99b42d4f1e", "doc.go": "ffda943422fc0104acff178f17f096df5d9d0e9065e598aa0d817c457edfb198", "docs/concepts/enforcement-model.md": "998939bdf003cc0e192fe68ca30d29e5ad76d4682bfeb14d582d40c478dec15d", - "e2e/coverage_test.go": "ba0df1720714b31a5726964ba33868bd06ecadc346f12e8ee50cfd286d1d23e5", + "e2e/coverage_test.go": "92ed6faa9a698bbcb2045b538fc373bed88bcc5fd3f1ceef1071a845ff6d3d42", "e2e/e2e_test.go": "28a8d8c7aa3dfa327b615c00454a438264e2b898abc84fe5c0efb9be0a2fdf3f", "e2e/install_test.go": "a0f65ec294fdc7d032045e214c1f56021f08472f44536b6365df8d66471fd67f", "e2e/isolation_test.go": "9498039e244184c8ce2460742af70dd93af9e5eada183119344f2d4d5df222e2", "e2e/parity_test.go": "e44628e98eeee1285a5722ed0ed5193e1c6d927d75eb87f90d3f524d83e65a7a", + "e2e/upgrade_test.go": "273806d579fe3725063d044098a7006f502cf3e1379805abfffed3925905f39c", "emitspec_test.go": "b669fc73361f275311221ac450008963885801742fe14d47b12e61e677b67545", "engine/engine.go": "8ee1d012bbf9661288507056c7a015ab9d46fb16e9596d4717b38f15af748b8f", "engine/engine_test.go": "0291ca081684cd744dec98089a383e1a52093f1c5125a88623dab06d504f73a8", @@ -119,7 +121,7 @@ "generator": "operatorstack/interlock:project-upstream", "schema_version": 1, "source": { - "commit": "10ef9c03b7e2d83a3564cc302c5eddf4cd8d3ce7", + "commit": "455bc0e2f338eaaec613f9017f4c7079e51622ae", "path": "labs/21-interlock", "repository": "operatorstack/intelligence-flow" } diff --git a/cmd/interlock/main.go b/cmd/interlock/main.go index e2dd4dc..728ba06 100644 --- a/cmd/interlock/main.go +++ b/cmd/interlock/main.go @@ -33,6 +33,8 @@ func main() { err = cmdInit(os.Args[2:]) case "install": err = cmdInstall(os.Args[2:]) + case "upgrade": + err = cmdUpgrade(os.Args[2:]) case "derive": err = cmdDerive(os.Args[2:]) case "compile": @@ -81,6 +83,7 @@ usage: interlock init --authoring json [dir] set up a JSON policy (dir defaults to .interlock) interlock init --authoring go scaffold a programmable Go policy module interlock install [ts|python] install the typed client from your registry (--configure-only writes config) + interlock upgrade [--check] update interlock to the latest published version interlock derive [repo] [--from PATH] [--output DIR] [--review] draft a candidate policy from a repo's existing instructions (never enforces) interlock test [dir] run the policy's tests (dir defaults to .interlock) interlock demo [name] narrate a built-in policy (default repository-policy; --list) @@ -350,6 +353,13 @@ func cmdDoctor(args []string) error { } else { fmt.Printf(" go toolchain : available\n") } + if latest, lerr := latestVersion(resolveGetHost("")); lerr != nil { + fmt.Printf(" updates : could not check (offline?)\n") + } else if upgradeAvailable(releaseVersion(), latest) { + fmt.Printf(" updates : newer available %s -> %s (run: interlock upgrade)\n", releaseVersion(), latest) + } else { + fmt.Printf(" updates : up to date (latest %s)\n", latest) + } fmt.Printf(" note : init --authoring json and test need no toolchain\n") return nil } diff --git a/cmd/interlock/upgrade.go b/cmd/interlock/upgrade.go new file mode 100644 index 0000000..159f54f --- /dev/null +++ b/cmd/interlock/upgrade.go @@ -0,0 +1,224 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +// binaryName is the front-door package id and the on-disk executable name — the +// same "interlock" the shell installer uses. +const binaryName = "interlock" + +// getBaseURL turns a host into a base URL. A bare host gets https:// (the normal +// case); a host that already carries a scheme is used verbatim, so tests can point +// the CLI at a local http server. Shared by upgrade and the doctor drift check. +func getBaseURL(host string) string { + if strings.Contains(host, "://") { + return strings.TrimRight(host, "/") + } + return "https://" + host +} + +func httpGetBytes(url string) ([]byte, error) { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: %s", url, resp.Status) + } + return io.ReadAll(resp.Body) +} + +// latestVersion resolves the highest published version from the front door. +func latestVersion(host string) (string, error) { + b, err := httpGetBytes(getBaseURL(host) + "/" + binaryName + "/latest") + if err != nil { + return "", err + } + var payload struct { + Version string `json:"version"` + } + if err := json.Unmarshal(b, &payload); err != nil || payload.Version == "" { + return "", fmt.Errorf("could not parse latest version") + } + return payload.Version, nil +} + +// cmpSemver returns >0 if a>b, <0 if a 0 +} + +func cmdUpgrade(args []string) error { + host := "" + checkOnly := false + yes := false + i := 0 + for i < len(args) { + switch args[i] { + case "--host": + if i+1 >= len(args) { + return fmt.Errorf("upgrade: --host wants a hostname") + } + host = args[i+1] + i += 2 + case "--check": + checkOnly = true + i++ + case "--yes", "-y": + yes = true + i++ + default: + return fmt.Errorf("upgrade: unexpected argument %q", args[i]) + } + } + host = resolveGetHost(host) + current := releaseVersion() + + latest, err := latestVersion(host) + if err != nil { + return fmt.Errorf("upgrade: resolve latest: %w", err) + } + if !upgradeAvailable(current, latest) { + fmt.Printf("interlock %s is up to date (latest %s)\n", current, latest) + return nil + } + if checkOnly { + fmt.Printf("a newer interlock is available: %s -> %s (run: interlock upgrade)\n", current, latest) + return nil + } + if runtime.GOOS == "windows" { + return fmt.Errorf("upgrade: automatic upgrade is unix-only; on Windows re-run the install.ps1 installer for %s", latest) + } + if !yes { + fmt.Printf("upgrading interlock %s -> %s\n", current, latest) + } + return downloadAndReplace(host, latest) +} + +// downloadAndReplace fetches the OS/arch archive + checksums for version, verifies +// the SHA-256 in-process, and atomically replaces the running executable. +func downloadAndReplace(host, version string) error { + archive := fmt.Sprintf("%s_%s_%s_%s.tar.gz", binaryName, version, runtime.GOOS, runtime.GOARCH) + base := fmt.Sprintf("%s/%s/dl/%s", getBaseURL(host), binaryName, version) + + archiveBytes, err := httpGetBytes(base + "/" + archive) + if err != nil { + return fmt.Errorf("upgrade: download %s: %w", archive, err) + } + checksums, err := httpGetBytes(base + "/checksums.txt") + if err != nil { + return fmt.Errorf("upgrade: download checksums: %w", err) + } + want := checksumFor(string(checksums), archive) + if want == "" { + return fmt.Errorf("upgrade: no checksum listed for %s", archive) + } + sum := sha256.Sum256(archiveBytes) + if got := hex.EncodeToString(sum[:]); got != want { + return fmt.Errorf("upgrade: checksum mismatch for %s (want %s, got %s) — refusing", archive, want, got) + } + + bin, err := extractBinary(archiveBytes, binaryName) + if err != nil { + return fmt.Errorf("upgrade: %w", err) + } + + self, err := os.Executable() + if err != nil { + return err + } + if resolved, rerr := filepath.EvalSymlinks(self); rerr == nil { + self = resolved + } + dir := filepath.Dir(self) + tmp := filepath.Join(dir, "."+binaryName+".upgrade") + if err := os.WriteFile(tmp, bin, 0o755); err != nil { + if os.IsPermission(err) { + return fmt.Errorf("upgrade: cannot write to %s (permission denied). Re-run with sudo, or reinstall: curl -fsSL https://%s/%s | sh", dir, resolveGetHost(host), binaryName) + } + return err + } + if err := os.Rename(tmp, self); err != nil { + os.Remove(tmp) + if os.IsPermission(err) { + return fmt.Errorf("upgrade: cannot replace %s (permission denied). Re-run with sudo, or reinstall: curl -fsSL https://%s/%s | sh", self, resolveGetHost(host), binaryName) + } + return err + } + fmt.Printf("upgraded to interlock %s at %s\n", version, self) + return nil +} + +// checksumFor returns the hex digest listed for name in a "sha name" manifest. +func checksumFor(manifest, name string) string { + for _, line := range strings.Split(manifest, "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == name { + return fields[0] + } + } + return "" +} + +// extractBinary returns the named regular file from a .tar.gz archive. +func extractBinary(data []byte, name string) ([]byte, error) { + gzr, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("open archive: %w", err) + } + defer gzr.Close() + tr := tar.NewReader(gzr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("read archive: %w", err) + } + if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == name { + return io.ReadAll(tr) + } + } + return nil, fmt.Errorf("binary %q not found in archive", name) +} diff --git a/cmd/interlock/version.go b/cmd/interlock/version.go index f3c482d..df3f98c 100644 --- a/cmd/interlock/version.go +++ b/cmd/interlock/version.go @@ -33,8 +33,13 @@ func releaseVersion() string { // prebuilt-binary install, it reports the exact tag and commit the artifact was // built from, alongside the wire contracts. func cmdVersion(args []string) error { - if len(args) > 0 { - return fmt.Errorf("version: unexpected argument %q", args[0]) + check := false + for _, a := range args { + if a == "--check" { + check = true + continue + } + return fmt.Errorf("version: unexpected argument %q", a) } fmt.Printf("interlock %s (%s)\n", releaseVersion(), buildCommit()) if date != "" { @@ -43,5 +48,15 @@ func cmdVersion(args []string) error { fmt.Printf(" policy protocol : %s\n", ir.Protocol) fmt.Printf(" effect protocol : %s\n", protocol.EffectRequestProtocol) fmt.Printf(" receipt schema : %s\n", receipt.Schema) + // --check contacts the front door for a newer release; plain `version` stays offline. + if check { + if latest, err := latestVersion(resolveGetHost("")); err != nil { + fmt.Printf(" updates : could not check (offline?)\n") + } else if upgradeAvailable(releaseVersion(), latest) { + fmt.Printf(" updates : newer available %s -> %s (run: interlock upgrade)\n", releaseVersion(), latest) + } else { + fmt.Printf(" updates : up to date (latest %s)\n", latest) + } + } return nil } diff --git a/e2e/coverage_test.go b/e2e/coverage_test.go index cc0cf67..1461ed7 100644 --- a/e2e/coverage_test.go +++ b/e2e/coverage_test.go @@ -23,6 +23,7 @@ import ( var covered = map[string]string{ "init": "TestJourney_InitTestTamper", "install": "TestJourney_Install", + "upgrade": "TestJourney_Upgrade", "derive": "TestJourney_Derive", "compile": "TestJourney_Derive (promotion) + parity fixtures", "check": "TestSmoke_InfoCommands", diff --git a/e2e/upgrade_test.go b/e2e/upgrade_test.go new file mode 100644 index 0000000..31f3218 --- /dev/null +++ b/e2e/upgrade_test.go @@ -0,0 +1,109 @@ +package e2e + +// control-law: shipped-surface-honors-the-core (upgrade) +// +// `interlock upgrade` self-updates the binary from the front door: resolve /latest, +// download the OS/arch archive + checksums, verify SHA-256, atomically replace the +// running executable. Hermetic — a local stub server stands in for the front door +// (via --host http://…), a throwaway copy of the binary is the upgrade target so the +// shared test binary is never clobbered, and the "fake" payload is asserted in place. + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// stubFrontDoor serves /interlock/latest and /interlock/dl//{archive,checksums.txt} +// for the given version, with a tar.gz whose "interlock" entry contains payload. +func stubFrontDoor(t *testing.T, version string, payload []byte) *httptest.Server { + t.Helper() + archive := fmt.Sprintf("interlock_%s_%s_%s.tar.gz", version, runtime.GOOS, runtime.GOARCH) + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: "interlock", Mode: 0o755, Size: int64(len(payload)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + tw.Write(payload) + tw.Close() + gz.Close() + tgz := buf.Bytes() + sum := sha256.Sum256(tgz) + checksums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), archive) + + mux := http.NewServeMux() + mux.HandleFunc("/interlock/latest", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, `{"version":%q}`, version) + }) + mux.HandleFunc("/interlock/dl/"+version+"/"+archive, func(w http.ResponseWriter, _ *http.Request) { + w.Write(tgz) + }) + mux.HandleFunc("/interlock/dl/"+version+"/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte(checksums)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestJourney_Upgrade(t *testing.T) { + // The test binary is built without ldflags, so its version is "dev" — always + // upgrade-eligible against any published version. + srv := stubFrontDoor(t, "9.9.9", []byte("FAKE-UPGRADED-INTERLOCK\n")) + + t.Run("--check reports a newer version", func(t *testing.T) { + stdout, stderr, code := run(t, "upgrade", "--check", "--host", srv.URL) + if code != 0 { + t.Fatalf("upgrade --check failed (%d): %s", code, stderr) + } + if !strings.Contains(stdout, "9.9.9") || !strings.Contains(stdout, "newer") { + t.Fatalf("--check did not report the newer version: %s", stdout) + } + }) + + t.Run("real upgrade replaces the binary atomically", func(t *testing.T) { + // Copy the test binary so os.Executable() points at a throwaway target. + dir := t.TempDir() + target := filepath.Join(dir, "interlock") + src, err := os.ReadFile(interlockBin) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, src, 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command(target, "upgrade", "--host", srv.URL, "--yes") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("upgrade failed: %v\n%s", err, out) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "FAKE-UPGRADED-INTERLOCK\n" { + t.Fatalf("binary not replaced with the fetched payload; got %q", string(got)) + } + }) + + t.Run("offline resolve fails closed", func(t *testing.T) { + // A host with no server → resolve error, non-zero exit, no partial write. + _, _, code := run(t, "upgrade", "--check", "--host", "http://127.0.0.1:1") + if code == 0 { + t.Fatal("upgrade against an unreachable host should fail") + } + }) +}