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")
+ }
+ })
+}