diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..4af2367 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,105 @@ +# GoReleaser builds the prebuilt Interlock binaries attached to each GitHub +# Release. Interlock is pure Go / CGO-free / zero-dependency, so cross-compiling +# the five supported targets is a plain matrix build. The release workflow +# (labkit-generated .github/workflows/release.yml) tags a version, then runs +# `goreleaser release --clean` from that tag; GoReleaser produces the archives, +# checksums, and SBOMs and creates the Release. Build provenance is attested by +# the workflow after this runs. +# +# Local check: `goreleaser check` (config only) or `goreleaser release --snapshot +# --clean` (full dry run into ./dist, no publish). +version: 2 + +project_name: interlock + +builds: + - id: interlock + main: ./cmd/interlock + binary: interlock + env: + - CGO_ENABLED=0 + flags: + - -trimpath + # Version identity is stamped into package main (see cmd/interlock/version.go), + # matching GoReleaser's default variable names. + ldflags: + - -s -w -X main.version={{ .Version }} -X main.commit={{ .ShortCommit }} -X main.date={{ .CommitDate }} + mod_timestamp: "{{ .CommitTimestamp }}" + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + # The spec ships Windows amd64 only; drop windows/arm64 to land on exactly the + # five supported targets (darwin arm64/amd64, linux amd64/arm64, windows amd64). + ignore: + - goos: windows + goarch: arm64 + +archives: + - id: interlock + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - README.md + - LICENSE + +checksum: + name_template: checksums.txt + algorithm: sha256 + +# One CycloneDX SBOM per archive, generated by syft (installed by the workflow). +sboms: + - id: archive + artifacts: archive + +release: + github: + owner: operatorstack + name: interlock + # v0.1.0 is the first release; treat a missing prior tag gracefully. + mode: replace + footer: | + ## Protocol versions + + | Contract | Version | + | --- | --- | + | policy protocol | `interlock.policy.v1` | + | effect protocol | `interlock.effect.v1` | + | receipt schema | `interlock.receipt.v1` | + + Verify a downloaded archive against `checksums.txt`, and verify build + provenance with `gh attestation verify --repo operatorstack/interlock`. + +changelog: + use: github + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + +# Homebrew formula generation is wired but DISABLED until the tap exists. +# To enable (follow-up): create the operatorstack/homebrew-tap repository and a +# HOMEBREW_TAP_TOKEN secret, then uncomment this block and pass the token to the +# goreleaser step in the release workflow. No other change is required. +# +# brews: +# - name: interlock +# repository: +# owner: operatorstack +# name: homebrew-tap +# token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" +# homepage: "https://github.com/operatorstack/interlock" +# description: "Code-defined effect-policy runtime for coding agents" +# license: "Apache-2.0" +# test: | +# system "#{bin}/interlock", "version" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..421f661 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 operatorstack + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 37d9710..158a360 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ interlock verify --format json | Receipt chains detect mutation | PASS | replay suite | | Pitot transports decisions | PASS | Controller round-trip | -Verified at commit `07dad7e`. +Verified at version `dev`, commit `d35e1b8-dirty`. The `interlock verify` binary carries these checks with no toolchain required. A @@ -276,11 +276,40 @@ by the broker tests, not just asserted here. ## Install +**Prebuilt binary (no Go toolchain).** The installer detects your platform, +downloads a pinned release, **verifies its SHA-256 checksum**, installs the +binary, and runs `interlock doctor` + the repository-policy demo: + ```bash -go install github.com/operatorstack/interlock/cmd/interlock@latest +# macOS / Linux +curl -fsSL https://raw.githubusercontent.com/operatorstack/interlock/main/install.sh | sh +``` + +```powershell +# Windows (PowerShell) +irm https://raw.githubusercontent.com/operatorstack/interlock/main/install.ps1 | iex ``` -Or build from a checkout and inspect environment readiness: +Prebuilt binaries are published for macOS (arm64/amd64), Linux (amd64/arm64), and +Windows (amd64). Every release attaches `checksums.txt`, a per-archive SBOM, and +signed build provenance you can verify with the GitHub CLI: + +```bash +gh attestation verify interlock___.tar.gz --repo operatorstack/interlock +``` + +Prefer to pin a version or install elsewhere: set `INTERLOCK_VERSION=v0.1.0` or +`INTERLOCK_INSTALL_DIR=$HOME/.local/bin` before running the installer. + +**Homebrew (coming soon).** `brew install operatorstack/tap/interlock` ships in a +follow-up once the tap is published. + +**With a Go toolchain.** Install the latest tagged release, or build from a +checkout and inspect environment readiness: + +```bash +go install github.com/operatorstack/interlock/cmd/interlock@latest +``` ```bash go build -o interlock ./cmd/interlock @@ -289,6 +318,7 @@ interlock doctor ``` interlock doctor + version : dev policy protocol : interlock.policy.v1 effect protocol : interlock.effect.v1 receipt schema : interlock.receipt.v1 @@ -296,6 +326,9 @@ interlock doctor note : init --authoring json and test need no toolchain ``` +`interlock version` prints the release version, embedded commit, build date, and +the three protocol/schema versions. + `interlock init` (bare) sets up the no-toolchain JSON policy described in [Start here](#start-here-no-toolchain); `interlock init --authoring go ` scaffolds a programmable Go module. The positional `interlock init ` is a diff --git a/UPSTREAM.json b/UPSTREAM.json index 6f638b4..ef1a4e5 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,6 +1,8 @@ { "files": { - "README.md": "e471b00e626881e4bc16373d5c42fceb39c6e9e69adeadd622f25da56dda92fa", + ".goreleaser.yml": "cdb7f4f950f7e70c0052205966cb11ef7b9a0b270ff03a670deba696cf1c61b8", + "LICENSE": "bd17d47aefaa7406616179288058001b6532881a2517254fe668d7e9c4965cfb", + "README.md": "77979fdd7a0781305d65bf2576cb7368ea339a640f7fab9d6d8ea4a37888e3b0", "assets/interlock-boundary.png": "9ae73098102d0589bf1bb912af6d39df4d1465eb93e8cea5b7e982e6fa79ef51", "assets/interlock-boundary.svg": "0bbb5aacc90e9311aef732dc2075aa0896ec25d0789b6612a3d98922646a0f1e", "assets/interlock-hero.png": "a30def06d493775458455ed891f1631200a61eafa54fa53d64474a3d340e2757", @@ -12,10 +14,12 @@ "broker/broker_test.go": "5ccf4f6aad1ae846ce405f92fc6c75728e4adec49eaedcdd45f76e6b91dc0a6b", "broker/envelope.go": "a1f6e2333c45f5f15ce1b1d58cd29589cbc35abcdb0f93ea01c56060f7dcafcf", "broker/generality_test.go": "14b83f8dd8e25562cd5d099e7d0b9285d15d2874cddd42f3901303c0553ee596", + "cmd/interlock/demo.go": "4593c47d679b455ae9800e1197f648071f4b85ec14f3a7f8b726414c91783764", "cmd/interlock/init.go": "ea4df1ba3bcf0ec2f62cf02752b39598027376096ba974b2e770b0a79dc4ba0f", - "cmd/interlock/main.go": "8b24090172c4cab9712b22084c607c1a3333841bf4234c4d6ff244e63fb54164", + "cmd/interlock/main.go": "2c775b003351a8242d3b795706360a7a813c9cbe0ac247c60bbd288cb4114889", "cmd/interlock/test.go": "e9b680cde45e061155dcc375b057f8ff4e69559d5f2be7dcd15f3685af0e1079", - "cmd/interlock/verify.go": "9e5a96b45ac0ebc632a7d64f14f1ecc4749a1fbe3e1514733c629b81c2219915", + "cmd/interlock/verify.go": "5613c04febf6731fd25a75e615a524d57f99cb762016f458be5243034376b025", + "cmd/interlock/version.go": "262fedc77a86623a48ee5a52940356a399fc71466d6da52f902cd655b9d7303d", "compiler/compiler.go": "50487aaa0c24b3c0d041ab5b9a410fa672de7eb77b1cd3df1ec97cd4cf2bccba", "compiler/compiler_test.go": "6f80886aa1bfdf79bca014ad47c7b48d2a6bc24c86863afa4ebeb93658a790aa", "conformance/compat/compat.go": "cf64376db6fdec31f6a0d01d039d334e367c9561918f13b6a97cdac33df87be5", @@ -44,6 +48,8 @@ "examples/release-manifest/policy.go": "d2f005addc2842f0ec235feb64315776aa936aa2e49bb82325867b3c9bcb691c", "examples/repository-policy/policy.go": "8c7fe12c487c1369d5ce8ae24e647e050557cd2bf29c56d4010b782603debbd1", "go.mod": "a9a846b064eac2e330c18198dae19044e438bff31dcb26d928cdb2f9b5cae3e1", + "install.ps1": "ada3f94562569929446dd6ca200f993854fa10081785e3ad1a09db285dacca6a", + "install.sh": "7d62cccc9b35280490c3332279743050957ab7e648b4b115c86211ba8995ab4e", "interlock.go": "90243e4c4c0f056cd0b2d8981f299449231b502f2f9e6108923bcbda1aeefd06", "interlock_test.go": "06b56780f7ae90fe12b055bf426aab8b87ed4fe174698a49d729bdc6484299e2", "ir/ir.go": "5cf039998f609e9f13f770bc518ba9127389320c9f5a71dd8ae3e5240762e718", @@ -53,16 +59,17 @@ "protocol/protocol.go": "1d9cc6ba1d59635e9c88ab9a20058491c78d0024e5e87fd25ec87628271c728e", "receipt/receipt.go": "8a21b9054b599965221e08fe158887759473d2f41d28dd3df42a1ffe1c55cb6e", "receipt/receipt_test.go": "a215a205205573bb0fbf6d121aa761bc28510346b92e8bb72dd85ca90b6fa65c", + "scaffold/demo.go": "d496e57f31a17165a540f704704ae341e66641139795263bee2dcfb9ca1de7c7", "scaffold/readme.go": "6f2f7db1e92f7e650072b5b26d29d91c8e229e223b717edecef195b9c0fae940", - "scaffold/scaffold.go": "27a9d642a10bfeab76b5a9972e95804cc37b9d19dc29d12835544df0ce370a6b", - "scaffold/scaffold_test.go": "9fdfa3b1c9bd1043d80919e412a0f3083a9bc87b7121411c4627fe3cb307a570", + "scaffold/scaffold.go": "fcc7c8e68586374a131af560af5f185010aa56d0cf0361fe21f169bc2e7e3f5b", + "scaffold/scaffold_test.go": "8a7304249589863205c386974252d9aba2119723553a54288c55b342d3a75af9", "spec/spec.go": "65634ab25713fcf646fdf35d13fe5effe6752f602e5bf962ddb32c14b36610ce", "workspace/workspace.go": "8d91408ac4bc87a136744360a2e1aede620c44f6050c8845eb3cad39d3be2a54" }, "generator": "operatorstack/interlock:project-upstream", "schema_version": 1, "source": { - "commit": "ceecc2f494505565a230fdeb532c52c1dd29461a", + "commit": "b3c24af484954ef0e5a12a2e89f99748c1d9f125", "path": "labs/21-interlock", "repository": "operatorstack/intelligence-flow" } diff --git a/cmd/interlock/demo.go b/cmd/interlock/demo.go new file mode 100644 index 0000000..1249cbd --- /dev/null +++ b/cmd/interlock/demo.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/operatorstack/interlock/engine" + "github.com/operatorstack/interlock/ir" + "github.com/operatorstack/interlock/scaffold" +) + +// cmdDemo narrates a built-in policy showcase: it compiles the named demo policy +// in-process and runs a scripted sequence of effect requests through the real +// engine, printing each outcome. It needs no Go toolchain and touches no files — +// it is the "prove it works" moment the installers run right after install. +func cmdDemo(args []string) error { + name := "repository-policy" + i := 0 + for i < len(args) { + switch args[i] { + case "--list", "-l": + for _, d := range scaffold.Demos() { + fmt.Printf("%-20s %s\n", d.Key, d.Title) + } + return nil + default: + if strings.HasPrefix(args[i], "-") { + return fmt.Errorf("demo: unexpected flag %q", args[i]) + } + name = args[i] + i++ + } + } + + d, ok := scaffold.DemoByKey(name) + if !ok { + return fmt.Errorf("demo: unknown demo %q (want %s)", name, strings.Join(scaffold.DemoKeys(), "|")) + } + + pol, err := loadDemoPolicy(d) + if err != nil { + return fmt.Errorf("demo: %w", err) + } + h, err := pol.Hash() + if err != nil { + return err + } + + fmt.Printf("interlock demo — %s\n\n", d.Title) + fmt.Printf("%s\n\n", d.Summary) + fmt.Printf("policy %s (%s)\n\n", pol.PolicyID, h) + fmt.Println("Rules (first match wins):") + for _, r := range d.Rules("") { + fmt.Printf(" - %s\n", stripTicks(r)) + } + fmt.Println() + fmt.Println("Decisions (each line is a live engine.Decide result):") + + for _, v := range d.Vectors("") { + reqst := v.Request + if v.UsePolicyHash { + reqst.ClaimedPolicyHash = h + } + dec := engine.Decide(pol, reqst) + rule := dec.RuleID + if rule == "" { + rule = "default-deny" + } + fmt.Printf(" %-40s %-8s (%s) — %s\n", v.Name, strings.ToUpper(string(dec.Outcome)), rule, dec.Reason) + } + fmt.Println() + fmt.Println("Author your own with `interlock init`, then `interlock test`.") + return nil +} + +// loadDemoPolicy compiles a demo's in-process builder to canonical IR and +// decodes it back through the CLI's own decode path. Round-tripping the bytes +// keeps the demo honest: the same compiler that backs `interlock compile` +// produces the policy the engine then decides against. +func loadDemoPolicy(d scaffold.Demo) (ir.Policy, error) { + b, err := d.Policy("") + if err != nil { + return ir.Policy{}, err + } + return decodePolicy(b) +} + +// stripTicks removes the markdown backticks the README rules carry so they read +// cleanly in a terminal. +func stripTicks(s string) string { return strings.ReplaceAll(s, "`", "") } diff --git a/cmd/interlock/main.go b/cmd/interlock/main.go index 58bc01d..04cc384 100644 --- a/cmd/interlock/main.go +++ b/cmd/interlock/main.go @@ -45,10 +45,14 @@ func main() { err = cmdReplay(os.Args[2:]) case "test": err = cmdTest(os.Args[2:]) + case "demo": + err = cmdDemo(os.Args[2:]) case "doctor": err = cmdDoctor(os.Args[2:]) case "verify": err = cmdVerify(os.Args[2:]) + case "version", "-v", "--version": + err = cmdVersion(os.Args[2:]) case "-h", "--help", "help": usage() return @@ -71,6 +75,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 test [dir] run the policy's tests (dir defaults to .interlock) + interlock demo [name] narrate a built-in policy (default repository-policy; --list) interlock compile [-o policy.json] build+run a Go policy module → canonical IR interlock check validate canonical IR and print its hash interlock explain print a human-readable policy summary @@ -80,6 +85,7 @@ usage: interlock replay verify a decision chain interlock doctor report environment readiness interlock verify [--format text|json|markdown] run the release proof + interlock version print the release version, commit, and protocols `) } @@ -304,6 +310,7 @@ func cmdReplay(args []string) error { func cmdDoctor(args []string) error { fmt.Printf("interlock doctor\n") + fmt.Printf(" version : %s\n", releaseVersion()) fmt.Printf(" policy protocol : %s\n", ir.Protocol) fmt.Printf(" effect protocol : %s\n", protocol.EffectRequestProtocol) fmt.Printf(" receipt schema : %s\n", receipt.Schema) diff --git a/cmd/interlock/verify.go b/cmd/interlock/verify.go index 9ba9843..9a73ee6 100644 --- a/cmd/interlock/verify.go +++ b/cmd/interlock/verify.go @@ -154,10 +154,19 @@ func isPitotDir(dir string) bool { // --- Build info ----------------------------------------------------------- -// buildCommit returns the short VCS revision the binary was built from, with a -// "-dirty" suffix when the tree was modified. It reads runtime build info, which -// is populated by `go build` (not `go run`); it returns "unknown" otherwise. +// buildCommit returns the short commit the binary was built from, with a +// "-dirty" suffix when the tree was modified. A release build injects the commit +// via ldflags (`-X main.commit=...`), which wins; otherwise it reads runtime +// build info, populated by `go build` (not `go run`), and returns "unknown" when +// neither source is available. func buildCommit() string { + if commit != "" { + rev := commit + if len(rev) > 7 { + rev = rev[:7] + } + return rev + } info, ok := debug.ReadBuildInfo() if !ok { return "unknown" @@ -209,6 +218,7 @@ func printVerifyText(lines []verifyLine, failed bool, commit string) { } else { fmt.Println("RESULT: PASS") } + fmt.Printf("Version: %s\n", releaseVersion()) fmt.Printf("Commit: %s\n", commit) fmt.Printf("Protocol: %s\n", ir.Protocol) fmt.Println() @@ -229,12 +239,14 @@ func printVerifyJSON(lines []verifyLine, failed bool, commit string) error { Proof string `json:"proof"` Results []verifyLine `json:"results"` Result string `json:"result"` + Version string `json:"version"` Commit string `json:"commit"` Protocol string `json:"protocol"` }{ Proof: "Interlock Release Proof", Results: lines, Result: result, + Version: releaseVersion(), Commit: commit, Protocol: ir.Protocol, }) @@ -294,7 +306,7 @@ func printVerifyMarkdown(lines []verifyLine, failed bool, commit string) { fmt.Printf("| %s | %s | %s |\n", r.claim, statusLabel(rowStatus(lines, r.backing)), r.evidence) } fmt.Println() - fmt.Printf("Verified at commit `%s`.\n", commit) + fmt.Printf("Verified at version `%s`, commit `%s`.\n", releaseVersion(), commit) } // lastLine returns the last non-empty line of s (used to surface a failing diff --git a/cmd/interlock/version.go b/cmd/interlock/version.go new file mode 100644 index 0000000..f3c482d --- /dev/null +++ b/cmd/interlock/version.go @@ -0,0 +1,47 @@ +package main + +import ( + "fmt" + + "github.com/operatorstack/interlock/ir" + "github.com/operatorstack/interlock/protocol" + "github.com/operatorstack/interlock/receipt" +) + +// version, commit, and date carry the release identity of the binary. They are +// injected at build time via ldflags (`-X main.version=...`), matching +// GoReleaser's defaults so its stock build config stamps them without extra +// wiring. In a plain `go build` / `go run` checkout they stay at their defaults, +// and buildCommit() falls back to the VCS revision from debug.ReadBuildInfo(). +var ( + version = "dev" + commit = "" + date = "" +) + +// releaseVersion returns the injected release version, or "dev" for an +// un-stamped build. +func releaseVersion() string { + if version == "" { + return "dev" + } + return version +} + +// cmdVersion prints the binary's release identity and the protocol/schema +// versions it speaks. This is the "what am I running?" command: after a +// 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]) + } + fmt.Printf("interlock %s (%s)\n", releaseVersion(), buildCommit()) + if date != "" { + fmt.Printf(" built : %s\n", date) + } + fmt.Printf(" policy protocol : %s\n", ir.Protocol) + fmt.Printf(" effect protocol : %s\n", protocol.EffectRequestProtocol) + fmt.Printf(" receipt schema : %s\n", receipt.Schema) + return nil +} diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..b584747 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,96 @@ +<# +.SYNOPSIS + Interlock installer (Windows) — no Go toolchain required. + +.DESCRIPTION + Downloads a pinned prebuilt release, verifies its SHA-256 checksum, installs + interlock.exe, adds the install directory to the user PATH, then runs + `interlock doctor` and the repository-policy demo. Fails closed on a checksum + mismatch. + + irm https://raw.githubusercontent.com/operatorstack/interlock/main/install.ps1 | iex + +.PARAMETER Version + Release tag to install. Defaults to $env:INTERLOCK_VERSION, else latest. +.PARAMETER InstallDir + Install directory. Defaults to $env:INTERLOCK_INSTALL_DIR, else + %LOCALAPPDATA%\interlock\bin. +#> +[CmdletBinding()] +param( + [string]$Version = $env:INTERLOCK_VERSION, + [string]$InstallDir = $env:INTERLOCK_INSTALL_DIR +) + +$ErrorActionPreference = 'Stop' +$Repo = 'operatorstack/interlock' +$Binary = 'interlock' + +function Info($m) { Write-Host "interlock-install: $m" } + +# --- detect arch (Windows amd64 only) ------------------------------------ +$arch = switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { 'amd64' } + default { throw "unsupported architecture: $($env:PROCESSOR_ARCHITECTURE) (only windows/amd64 is published)" } +} +$os = 'windows' + +# --- resolve version ------------------------------------------------------ +if ([string]::IsNullOrEmpty($Version)) { + Info 'resolving latest release' + $latest = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -Headers @{ 'User-Agent' = 'interlock-install' } + $Version = $latest.tag_name + if ([string]::IsNullOrEmpty($Version)) { throw 'could not resolve latest release tag' } +} +Info "installing $Binary $Version ($os/$arch)" + +$archive = "${Binary}_${Version}_${os}_${arch}.zip" +$base = "https://github.com/$Repo/releases/download/$Version" + +# --- download + verify ---------------------------------------------------- +$tmp = Join-Path $env:TEMP ("interlock-" + [System.Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tmp -Force | Out-Null +try { + $archivePath = Join-Path $tmp $archive + $sumsPath = Join-Path $tmp 'checksums.txt' + Invoke-WebRequest -Uri "$base/$archive" -OutFile $archivePath -UseBasicParsing + Invoke-WebRequest -Uri "$base/checksums.txt" -OutFile $sumsPath -UseBasicParsing + + $want = (Select-String -Path $sumsPath -Pattern ([regex]::Escape($archive)) | + Select-Object -First 1).Line -split '\s+' | Select-Object -First 1 + if ([string]::IsNullOrEmpty($want)) { throw "no checksum for $archive in checksums.txt" } + $got = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLower() + if ($want.ToLower() -ne $got) { throw "checksum mismatch for $archive (want $want, got $got)" } + Info 'checksum verified' + + # --- install ------------------------------------------------------------ + if ([string]::IsNullOrEmpty($InstallDir)) { + $InstallDir = Join-Path $env:LOCALAPPDATA 'interlock\bin' + } + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + Expand-Archive -Path $archivePath -DestinationPath $tmp -Force + $exe = Join-Path $tmp "$Binary.exe" + if (-not (Test-Path $exe)) { throw "archive did not contain $Binary.exe" } + Copy-Item -Path $exe -Destination (Join-Path $InstallDir "$Binary.exe") -Force + Info "installed $InstallDir\$Binary.exe" + + # add to user PATH if missing + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if (($userPath -split ';') -notcontains $InstallDir) { + [Environment]::SetEnvironmentVariable('Path', "$userPath;$InstallDir", 'User') + $env:Path = "$env:Path;$InstallDir" + Info "added $InstallDir to your user PATH (restart your shell to pick it up)" + } + + # --- prove it works ----------------------------------------------------- + $bin = Join-Path $InstallDir "$Binary.exe" + Write-Host '' + & $bin doctor + Write-Host '' + & $bin demo repository-policy + Write-Host '' + Info "done — author your own policy with '$Binary init'" +} +finally { + Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..270a7c6 --- /dev/null +++ b/install.sh @@ -0,0 +1,124 @@ +#!/bin/sh +# Interlock installer (macOS / Linux) — no Go toolchain required. +# +# curl -fsSL https://raw.githubusercontent.com/operatorstack/interlock/main/install.sh | sh +# +# It detects your OS/arch, downloads a pinned prebuilt release, verifies the +# SHA-256 checksum, installs the `interlock` binary, then runs `interlock doctor` +# and the repository-policy demo. It fails closed on a checksum mismatch. +# +# Environment overrides: +# INTERLOCK_VERSION release tag to install (default: latest) +# INTERLOCK_INSTALL_DIR install directory (default: /usr/local/bin, else ~/.local/bin) +set -eu + +REPO="operatorstack/interlock" +BINARY="interlock" + +info() { printf 'interlock-install: %s\n' "$1" >&2; } +die() { printf 'interlock-install: error: %s\n' "$1" >&2; exit 1; } + +need() { command -v "$1" >/dev/null 2>&1 || die "required tool not found: $1"; } + +# --- detect platform ------------------------------------------------------ +os=$(uname -s) +case "$os" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) die "unsupported OS: $os (Windows: use install.ps1)" ;; +esac + +arch=$(uname -m) +case "$arch" in + x86_64 | amd64) arch=amd64 ;; + arm64 | aarch64) arch=arm64 ;; + *) die "unsupported architecture: $arch" ;; +esac + +need uname +need tar +if command -v curl >/dev/null 2>&1; then + dl() { curl -fsSL "$1" -o "$2"; } + fetch() { curl -fsSL "$1"; } +elif command -v wget >/dev/null 2>&1; then + dl() { wget -qO "$2" "$1"; } + fetch() { wget -qO - "$1"; } +else + die "need curl or wget" +fi + +# sha256 tool differs across platforms. +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum "$1" | awk '{print $1}'; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256 "$1" | awk '{print $1}'; } +else + die "need sha256sum or shasum" +fi + +# --- resolve version ------------------------------------------------------ +version="${INTERLOCK_VERSION:-}" +if [ -z "$version" ]; then + info "resolving latest release" + version=$(fetch "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name":' | head -n1 | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/') + [ -n "$version" ] || die "could not resolve latest release tag" +fi +info "installing ${BINARY} ${version} (${os}/${arch})" + +archive="${BINARY}_${version}_${os}_${arch}.tar.gz" +base="https://github.com/${REPO}/releases/download/${version}" + +# --- download + verify ---------------------------------------------------- +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT INT TERM + +dl "${base}/${archive}" "${tmp}/${archive}" || die "downloading ${archive}" +dl "${base}/checksums.txt" "${tmp}/checksums.txt" || die "downloading checksums.txt" + +want=$(grep " ${archive}\$" "${tmp}/checksums.txt" | awk '{print $1}' | head -n1) +[ -n "$want" ] || die "no checksum for ${archive} in checksums.txt" +got=$(sha256 "${tmp}/${archive}") +if [ "$want" != "$got" ]; then + die "checksum mismatch for ${archive} (want ${want}, got ${got})" +fi +info "checksum verified" + +tar -xzf "${tmp}/${archive}" -C "$tmp" +[ -f "${tmp}/${BINARY}" ] || die "archive did not contain ${BINARY}" +chmod +x "${tmp}/${BINARY}" + +# --- install -------------------------------------------------------------- +dir="${INTERLOCK_INSTALL_DIR:-}" +if [ -z "$dir" ]; then + if [ -w /usr/local/bin ] 2>/dev/null || { [ "$(id -u)" = 0 ] && [ -d /usr/local/bin ]; }; then + dir=/usr/local/bin + else + dir="${HOME}/.local/bin" + fi +fi +mkdir -p "$dir" + +if [ -w "$dir" ]; then + mv "${tmp}/${BINARY}" "${dir}/${BINARY}" +elif command -v sudo >/dev/null 2>&1; then + info "elevating with sudo to write ${dir}" + sudo mv "${tmp}/${BINARY}" "${dir}/${BINARY}" +else + die "cannot write ${dir}; set INTERLOCK_INSTALL_DIR to a writable path" +fi +info "installed ${dir}/${BINARY}" + +case ":${PATH}:" in + *":${dir}:"*) ;; + *) info "note: ${dir} is not on your PATH; add it to use \`${BINARY}\` directly" ;; +esac + +# --- prove it works ------------------------------------------------------- +bin="${dir}/${BINARY}" +echo >&2 +"$bin" doctor +echo >&2 +"$bin" demo repository-policy +echo >&2 +info "done — author your own policy with \`${BINARY} init\`" diff --git a/scaffold/demo.go b/scaffold/demo.go new file mode 100644 index 0000000..09fef8b --- /dev/null +++ b/scaffold/demo.go @@ -0,0 +1,99 @@ +package scaffold + +import ( + il "github.com/operatorstack/interlock" + "github.com/operatorstack/interlock/protocol" +) + +// Demo is a built-in, self-contained policy showcase behind `interlock demo`. It +// reuses Template's machinery — an in-process il builder plus labeled vectors — +// so a demo is compiled by the real compiler and every narrated scenario is a +// live engine.Decide result, not a canned string. Demos are kept separate from +// Templates() so the `interlock init` menu is unaffected: a demo is something you +// watch, a template is something you scaffold. +type Demo = Template + +// Demos returns the ordered set of built-in policy showcases. +func Demos() []Demo { + return []Demo{demoRepositoryPolicy} +} + +// DemoByKey returns the demo with the given key. +func DemoByKey(key string) (Demo, bool) { + for _, d := range Demos() { + if d.Key == key { + return d, true + } + } + return Demo{}, false +} + +// DemoKeys returns the demo keys in order. +func DemoKeys() []string { + ds := Demos() + out := make([]string, len(ds)) + for i, d := range ds { + out[i] = d.Key + } + return out +} + +// demoRepositoryPolicy mirrors examples/repository-policy: a coding agent works +// freely in source, generated files are owned by a verified SDK generator (and +// only refreshed on correlated evidence), and the protected branch cannot be +// force-pushed or pushed to without human approval. The narrated scenarios cover +// all three outcomes — allow, deny, and require (fail-closed on missing +// evidence) — so a just-installed binary demonstrates the engine end to end. +var demoRepositoryPolicy = Demo{ + Key: "repository-policy", + Title: "Repository policy (the flagship coding-agent policy)", + Summary: "A coding agent works freely in source, generated files are owned by the build, and the protected branch is force-push-proof and approval-gated.", + build: func(string) *il.Builder { + return il.Policy("repository-policy.v1"). + Actor("agent"). + Actor("sdk-generator"). + Tree("source", "repo://src/**"). + Tree("generated", "repo://generated/**"). + Branch("main", "repo://branch/main"). + Allow("agent-source").By("agent"). + To(il.Read, il.Write, il.Delete, il.RenameFrom, il.RenameTo).On("source"). + Because("the agent may work freely in ordinary source code").Add(). + Deny("deny-agent-generated").By("agent"). + To(il.Write, il.Delete, il.RenameTo).On("generated"). + Because("generated files must be produced by the verified SDK generator").Add(). + Allow("publish-generated").By("sdk-generator"). + To(il.Publish).On("generated"). + Requiring( + il.PolicyHashMatch(), + il.StagedHashMatch(), + il.ReceiptStatus("sdk-tests", "passed"), + ). + Because("the verified generator may publish generated files on passing tests").Add(). + Deny("deny-force-push-main").By("agent"). + To(il.ForcePush).On("main"). + Because("force-pushing the protected branch is not permitted").Add(). + Allow("push-main").By("agent"). + To(il.Push).On("main"). + Requiring(il.HumanApproval("release-main")). + Because("pushing the protected branch requires human approval").Add() + }, + vectors: func(string) []Vector { + return []Vector{ + {Name: "agent edits source", Request: req("agent", il.Write, il.TreeKind, "repo://src/app.ts"), Expect: protocol.OutcomeAllow, ExpectRuleID: "agent-source"}, + {Name: "agent edits a generated file", Request: req("agent", il.Write, il.TreeKind, "repo://generated/client.ts"), Expect: protocol.OutcomeDeny, ExpectRuleID: "deny-agent-generated"}, + {Name: "generator publishes without evidence", Request: req("sdk-generator", il.Publish, il.TreeKind, "repo://generated/client.ts"), Expect: protocol.OutcomeRequire, ExpectRuleID: "publish-generated"}, + {Name: "agent force-pushes main", Request: req("agent", il.ForcePush, il.BranchKind, "repo://branch/main"), Expect: protocol.OutcomeDeny, ExpectRuleID: "deny-force-push-main"}, + {Name: "agent pushes main without approval", Request: req("agent", il.Push, il.BranchKind, "repo://branch/main"), Expect: protocol.OutcomeRequire, ExpectRuleID: "push-main"}, + {Name: "agent pushes main with approval", Request: req("agent", il.Push, il.BranchKind, "repo://branch/main", approval("release-main")), Expect: protocol.OutcomeAllow, ExpectRuleID: "push-main"}, + } + }, + rules: func(string) []string { + return []string{ + "`agent` may read and write anything under `repo://src/**`.", + "`agent` may not write, delete, or rename anything under `repo://generated/**`.", + "`sdk-generator` may publish under `repo://generated/**` only with a matching policy hash, staged hash, and a passing `sdk-tests` receipt.", + "`agent` may not force-push `repo://branch/main`.", + "`agent` may push `repo://branch/main` only with a `release-main` human approval; absent it the push fails closed (require).", + } + }, +} diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 8806a87..b03d443 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -47,6 +47,9 @@ func (t Template) Policy(path string) ([]byte, error) { return t.build(path).Emi // Vectors returns the template's test vectors for the given custom path. func (t Template) Vectors(path string) []Vector { return t.vectors(path) } +// Rules returns the template's plain-English rule descriptions. +func (t Template) Rules(path string) []string { return t.rules(path) } + // Readme renders the template's README.md. func (t Template) Readme(path string) string { return renderReadme(t, path) diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 4965ffa..5666b02 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -50,3 +50,44 @@ func TestTemplatesSelfProve(t *testing.T) { }) } } + +// TestDemosSelfProve compiles every built-in demo and re-decides its narrated +// scenarios through the real engine. This is the guarantee behind `interlock +// demo`: the outcomes a just-installed binary prints are live decisions, so the +// showcase can never drift from the engine it is meant to demonstrate. +func TestDemosSelfProve(t *testing.T) { + demos := Demos() + if len(demos) == 0 { + t.Fatal("no demos registered") + } + for _, d := range demos { + d := d + t.Run(d.Key, func(t *testing.T) { + pol, err := d.build("").Compile() + if err != nil { + t.Fatalf("demo %q does not compile: %v", d.Key, err) + } + vectors := d.Vectors("") + if len(vectors) == 0 { + t.Fatalf("demo %q has no scenarios", d.Key) + } + for _, v := range vectors { + req := v.Request + if v.UsePolicyHash { + h, err := pol.Hash() + if err != nil { + t.Fatalf("hashing policy for %q: %v", v.Name, err) + } + req.ClaimedPolicyHash = h + } + dec := engine.Decide(pol, req) + if dec.Outcome != v.Expect { + t.Errorf("%s / %q: outcome = %q, want %q", d.Key, v.Name, dec.Outcome, v.Expect) + } + if v.ExpectRuleID != "" && dec.RuleID != v.ExpectRuleID { + t.Errorf("%s / %q: rule = %q, want %q", d.Key, v.Name, dec.RuleID, v.ExpectRuleID) + } + } + }) + } +}