diff --git a/UPSTREAM.json b/UPSTREAM.json index b2da47f..1055f38 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -78,6 +78,10 @@ "derive/review.go": "59349539a8dbb8664b89b0f2a3cabc50da03e3975727ccae36bda70d3968f704", "derive/schema.go": "4d10bca81110512a10aaf6306d5a5a2ebc4193058b4500cd92022a99b42d4f1e", "doc.go": "ffda943422fc0104acff178f17f096df5d9d0e9065e598aa0d817c457edfb198", + "e2e/coverage_test.go": "1240b8a56703d3c2b2492ef63049dc4573b769c444549cd693b178c06d3ab136", + "e2e/e2e_test.go": "28a8d8c7aa3dfa327b615c00454a438264e2b898abc84fe5c0efb9be0a2fdf3f", + "e2e/isolation_test.go": "9498039e244184c8ce2460742af70dd93af9e5eada183119344f2d4d5df222e2", + "e2e/parity_test.go": "e44628e98eeee1285a5722ed0ed5193e1c6d927d75eb87f90d3f524d83e65a7a", "emitspec_test.go": "b669fc73361f275311221ac450008963885801742fe14d47b12e61e677b67545", "engine/engine.go": "8ee1d012bbf9661288507056c7a015ab9d46fb16e9596d4717b38f15af748b8f", "engine/engine_test.go": "0291ca081684cd744dec98089a383e1a52093f1c5125a88623dab06d504f73a8", @@ -112,7 +116,7 @@ "generator": "operatorstack/interlock:project-upstream", "schema_version": 1, "source": { - "commit": "59ec27984383d0c956b8b6bc0ec26465d5cdd952", + "commit": "4006eba0241ef7c9e0df634d083be7f2888e6fdb", "path": "labs/21-interlock", "repository": "operatorstack/intelligence-flow" } diff --git a/e2e/coverage_test.go b/e2e/coverage_test.go new file mode 100644 index 0000000..33f4d6d --- /dev/null +++ b/e2e/coverage_test.go @@ -0,0 +1,98 @@ +package e2e + +// control-law: shipped-surface-honors-the-core (coverage obligation) +// +// The coverage law that keeps the control law obeyed as the surface grows: read +// the CLI dispatch switch in cmd/interlock/main.go, extract every subcommand it +// serves, and require each one to be named in the `covered` map below (each value +// naming the e2e that exercises it). A new subcommand cannot merge without an e2e +// entry — the shipped surface can never outgrow its proof. Mirrors Pitot's +// TestAllAdaptersHaveE2EScripts. + +import ( + "os" + "regexp" + "sort" + "strings" + "testing" +) + +// covered maps each real CLI subcommand to the e2e that drives it through the +// shipped binary. Alias flags (-v, --version, -h, --help) and `help` are not +// commands and are excluded from the dispatch set below. +var covered = map[string]string{ + "init": "TestJourney_InitTestTamper", + "derive": "TestJourney_Derive", + "compile": "TestJourney_Derive (promotion) + parity fixtures", + "check": "TestSmoke_InfoCommands", + "explain": "TestSmoke_InfoCommands", + "decide": "TestDecideParity_Corpus + TestIsolation_AuthoritySeparation", + "publish": "TestJourney_PublishRoundTrip + TestBrokerParity_Corpus", + "simulate": "TestJourney_SimulateReplay", + "replay": "TestJourney_SimulateReplay", + "test": "TestJourney_InitTestTamper + TestJourney_Derive", + "demo": "TestSmoke_InfoCommands", + "doctor": "TestSmoke_InfoCommands", + "verify": "TestSmoke_InfoCommands", + "version": "TestSmoke_InfoCommands", +} + +var caseLabel = regexp.MustCompile(`case\s+((?:"[^"]+"\s*,\s*)*"[^"]+")\s*:`) +var quoted = regexp.MustCompile(`"([^"]+)"`) + +func TestEveryCommandHasE2E(t *testing.T) { + src, err := os.ReadFile("../cmd/interlock/main.go") + if err != nil { + t.Fatalf("read main.go: %v", err) + } + // Only scan the dispatch switch (main's `switch os.Args[1]`), not the helper + // switches (decodePolicy's protocol switch, flag parsing) further down. + body := string(src) + start := strings.Index(body, "switch os.Args[1]") + if start < 0 { + t.Fatal("could not locate the CLI dispatch switch") + } + end := strings.Index(body[start:], "\nfunc ") + if end < 0 { + end = len(body) - start + } + dispatch := body[start : start+end] + + commands := map[string]bool{} + for _, m := range caseLabel.FindAllStringSubmatch(dispatch, -1) { + for _, q := range quoted.FindAllStringSubmatch(m[1], -1) { + label := q[1] + if strings.HasPrefix(label, "-") || label == "help" { + continue // alias flags and help are not commands + } + commands[label] = true + } + } + if len(commands) == 0 { + t.Fatal("extracted no commands from the dispatch switch — regex drift?") + } + + var missing []string + for cmd := range commands { + if _, ok := covered[cmd]; !ok { + missing = append(missing, cmd) + } + } + sort.Strings(missing) + if len(missing) > 0 { + t.Fatalf("these CLI commands have no e2e entry in covered{}: %v\n"+ + "add an e2e that drives the shipped binary and register it in coverage_test.go", missing) + } + + // Also flag stale entries so the map does not rot as commands are removed. + var stale []string + for cmd := range covered { + if !commands[cmd] { + stale = append(stale, cmd) + } + } + sort.Strings(stale) + if len(stale) > 0 { + t.Fatalf("covered{} names commands no longer in the dispatch switch: %v", stale) + } +} diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..7e7d849 --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,385 @@ +package e2e + +// control-law: shipped-surface-honors-the-core +// +// Boundary: the customer's real contact with Interlock. A customer never calls +// the library — they install and run the `interlock` binary (argv, stdin JSON, +// files under --output/.interlock/, stdout, exit code) and delegate one protected +// write to the broker. That argv/exit-code/file surface, plus the broker's promote +// seam resting on OS workspace isolation, is the enforcement boundary ("authority, +// not interception" — docs/04-enforcement-boundary.md). There is no git hook or +// interceptor to test; the artifact itself is the boundary. +// +// Control law: every capability a customer invokes through the installed binary +// produces exactly the decision, artifact, and failure behavior the pure engine / +// compiler / broker produce for the same inputs. Wrong inputs fail closed with a +// non-zero exit; nothing a customer can do through the CLI activates authority the +// library would not. +// +// This package builds the binary once and drives it via os/exec on tiny hermetic +// fixtures — no agents, no network, no VM. The decomposed obligations are realized +// across four files: customer journeys (here), decision/broker parity against the +// frozen conformance corpus (parity_test.go), a coverage law that forces every +// subcommand to have an e2e (coverage_test.go), and the POSIX authority-separation +// & fail-closed contract (isolation_test.go). It runs under the ordinary +// `go test ./...` and the projection build, so the law is enforced by gates that +// already run — no new CI job. + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/operatorstack/interlock/broker" + "github.com/operatorstack/interlock/ir" + "github.com/operatorstack/interlock/protocol" +) + +// interlockBin is the path to the binary built once by TestMain. +var interlockBin string + +// TestMain builds the shipped binary a single time from the module root, so every +// test drives the exact artifact a customer would install rather than an in-process +// stand-in. +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp("", "interlock-e2e-bin") + if err != nil { + panic("e2e: mkdir temp: " + err.Error()) + } + bin := filepath.Join(tmp, "interlock") + if runtime.GOOS == "windows" { + bin += ".exe" + } + build := exec.Command("go", "build", "-o", bin, "./cmd/interlock") + build.Dir = ".." // the interlock module root (parent of e2e/) + build.Stdout = os.Stderr + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + panic("e2e: build interlock: " + err.Error()) + } + interlockBin = bin + code := m.Run() + os.RemoveAll(tmp) + os.Exit(code) +} + +// run executes the built binary and returns stdout, stderr, and the exit code. +// A non-zero exit is a normal outcome (fail-closed paths), not a test error. +func run(t *testing.T, args ...string) (stdout, stderr string, code int) { + t.Helper() + cmd := exec.Command(interlockBin, args...) + var out, errb bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errb + err := cmd.Run() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + return out.String(), errb.String(), ee.ExitCode() + } + t.Fatalf("run %v: %v", args, err) + } + return out.String(), errb.String(), 0 +} + +// sampleAgentsMD is the fixture repository's intent: a force-push prohibition and +// a generated-file prohibition (both should ground into proposed deny rules), an +// advisory line (never emittable), and an ambiguous approval (an unresolved +// question). It is written inline rather than kept under testdata/ so the journey +// runs identically in the projected public tree, which excludes testdata/. +const sampleAgentsMD = `# Agent rules + +- Never force-push the main branch. +- Do not edit generated files. +- Prefer functional components over class components. +- Ask before publishing a release. +` + +// sampleRepo materializes the fixture repository in a temp dir and returns its +// path. +func sampleRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeFile(t, filepath.Join(root, "AGENTS.md"), []byte(sampleAgentsMD)) + return root +} + +func writeFile(t *testing.T, path string, b []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, b, 0o644); err != nil { + t.Fatal(err) + } +} + +// writeJSON marshals v and writes it to path. +func writeJSON(t *testing.T, path string, v any) { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + writeFile(t, path, b) +} + +// decodeDecision parses a protocol.Decision from CLI stdout. +func decodeDecision(t *testing.T, stdout string) protocol.Decision { + t.Helper() + var d protocol.Decision + if err := json.Unmarshal([]byte(stdout), &d); err != nil { + t.Fatalf("decode decision: %v\n%s", err, stdout) + } + return d +} + +// --- FLAGSHIP: the derive journey, end to end through the binary --------------- + +// TestJourney_Derive proves derive safety at the shipped boundary: the command +// writes only reviewable candidates (never an active policy), and the candidate it +// produces is real — it compiles through the binary's own compiler and its vectors +// pass the binary's own test runner. Then it proves the vectors are live by +// tampering the promoted policy and watching `test` turn red. +func TestJourney_Derive(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "derived") + + stdout, stderr, code := run(t, "derive", sampleRepo(t), "--output", out) + if code != 0 { + t.Fatalf("derive exit %d\nstdout:%s\nstderr:%s", code, stdout, stderr) + } + // Derive safety: no active policy anywhere under --output. + err := filepath.Walk(out, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && info.Name() == "policy.json" { + t.Fatalf("derive wrote an active policy file: %s", p) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"candidate.policy.json", "QUESTIONS.md"} { + if _, err := os.Stat(filepath.Join(out, name)); err != nil { + t.Fatalf("derive did not emit %s: %v", name, err) + } + } + if !strings.Contains(stdout, "Nothing is enforced yet") { + t.Fatalf("derive output missing the not-enforced framing:\n%s", stdout) + } + + // Promote through the binary itself: the candidate must compile (proving it is + // real spec.v1, not a decorative draft). + candidate := filepath.Join(out, "candidate.policy.json") + policy := filepath.Join(out, "policy.json") + if _, se, c := run(t, "compile", candidate, "-o", policy); c != 0 { + t.Fatalf("compiling the derived candidate failed (exit %d): %s", c, se) + } + // Wire the candidate's vectors next to the promoted policy and run the binary's + // own test runner — these are live engine decisions. + tests, err := os.ReadFile(filepath.Join(out, "candidate.tests.jsonl")) + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(out, "tests.jsonl"), tests) + if so, se, c := run(t, "test", out); c != 0 { + t.Fatalf("derived candidate's own vectors did not pass (exit %d):\n%s\n%s", c, so, se) + } + + // Test honesty: tamper the promoted policy and the vectors must turn red. + raw, err := os.ReadFile(policy) + if err != nil { + t.Fatal(err) + } + tampered := bytes.ReplaceAll(raw, []byte(`"effect":"deny"`), []byte(`"effect":"allow"`)) + if bytes.Equal(tampered, raw) { + t.Fatal("expected the derived policy to contain a deny rule to tamper") + } + writeFile(t, policy, tampered) + if so, _, c := run(t, "test", out); c == 0 { + t.Fatalf("tampered policy still passed its tests — vectors are not live:\n%s", so) + } +} + +// TestJourney_InitTestTamper proves the accessible onboarding path: init a +// no-toolchain JSON policy, its scaffolded tests pass through the binary, and a +// tamper makes them fail closed with a non-zero exit. +func TestJourney_InitTestTamper(t *testing.T) { + dir := filepath.Join(t.TempDir(), "proj") + if so, se, c := run(t, "init", "--authoring", "json", "--template", initTemplateKey(t), dir); c != 0 { + t.Fatalf("init exit %d\n%s\n%s", c, so, se) + } + if so, se, c := run(t, "test", dir); c != 0 { + t.Fatalf("scaffolded tests did not pass (exit %d):\n%s\n%s", c, so, se) + } + policy := filepath.Join(dir, "policy.json") + raw, err := os.ReadFile(policy) + if err != nil { + t.Fatal(err) + } + tampered := bytes.ReplaceAll(raw, []byte(`"effect":"deny"`), []byte(`"effect":"allow"`)) + if bytes.Equal(tampered, raw) { + t.Skip("scaffold template has no deny rule to tamper") + } + writeFile(t, policy, tampered) + if so, _, c := run(t, "test", dir); c == 0 { + t.Fatalf("tampered scaffold still passed:\n%s", so) + } +} + +// TestJourney_PublishRoundTrip proves broker fidelity at the CLI: a hash-bound +// envelope lands the byte-exact target with exit 0, and an envelope not bound to +// the staged bytes fails closed (non-zero exit, target never created). +func TestJourney_PublishRoundTrip(t *testing.T) { + dir := t.TempDir() + policy := filepath.Join(dir, "policy.json") + writeFile(t, policy, exclusivePublishPolicy(t)) + + staged := filepath.Join(dir, "stage", "result.json") + stagedBytes := []byte(`{"ok":true}`) + writeFile(t, staged, stagedBytes) + + // Happy path: envelope bound to the staged bytes, correlated to the run. + good := filepath.Join(dir, "envelope.json") + writeFile(t, good, envelope("deltawire.supervision.receipt.v1", "run1", "released", ir.HashBytes(stagedBytes))) + target := filepath.Join(dir, "out", "result.json") + pub := filepath.Join(dir, "pub.json") + writeFile(t, pub, publishRequest("run1", "pub-1", "publisher", "repo://out/result.json", staged, target, good)) + if so, se, c := run(t, "publish", policy, pub); c != 0 { + t.Fatalf("verified publish failed (exit %d):\n%s\n%s", c, so, se) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("publish did not create the target: %v", err) + } + if !bytes.Equal(got, stagedBytes) { + t.Fatalf("published bytes differ from staged: %q", got) + } + + // Fail-closed: an envelope carrying a wrong artifact hash must not publish. + bad := filepath.Join(dir, "envelope-unbound.json") + writeFile(t, bad, envelope("deltawire.supervision.receipt.v1", "run1", "released", ir.HashBytes([]byte("wrong")))) + badTarget := filepath.Join(dir, "out", "unbound.json") + badPub := filepath.Join(dir, "pub-unbound.json") + writeFile(t, badPub, publishRequest("run1", "pub-2", "publisher", "repo://out/result.json", staged, badTarget, bad)) + if _, _, c := run(t, "publish", policy, badPub); c == 0 { + t.Fatal("publish accepted an envelope not bound to the staged bytes") + } + if _, err := os.Stat(badTarget); !os.IsNotExist(err) { + t.Fatal("fail-closed publish created a target") + } +} + +// TestJourney_SimulateReplay proves chain integrity: a request stream simulates +// into a receipt chain that replays clean under its policy, and replay against a +// mutated policy identity fails closed. +func TestJourney_SimulateReplay(t *testing.T) { + dir := t.TempDir() + policy := filepath.Join(dir, "policy.json") + writeFile(t, policy, exclusivePublishPolicy(t)) + + reqs := filepath.Join(dir, "reqs.jsonl") + writeFile(t, reqs, []byte(strings.Join([]string{ + `{"protocol":"interlock.effect.v1","request_id":"s-1","run_id":"sim","actor":"agent","operation":"filesystem.write","resource":{"kind":"tree","uri":"repo://work/a.txt"}}`, + `{"protocol":"interlock.effect.v1","request_id":"s-2","run_id":"sim","actor":"agent","operation":"artifact.publish","resource":{"kind":"file","uri":"repo://out/result.json"}}`, + "", + }, "\n"))) + receipts := filepath.Join(dir, "receipts.jsonl") + if so, se, c := run(t, "simulate", policy, reqs, "sim", "-o", receipts); c != 0 { + t.Fatalf("simulate failed (exit %d):\n%s\n%s", c, so, se) + } + if so, se, c := run(t, "replay", policy, reqs, receipts); c != 0 { + t.Fatalf("replay of a fresh chain failed (exit %d):\n%s\n%s", c, so, se) + } + + // Mutate the policy identity: the same chain must no longer verify. + mutated := filepath.Join(dir, "policy-mutated.json") + raw, err := os.ReadFile(policy) + if err != nil { + t.Fatal(err) + } + writeFile(t, mutated, bytes.ReplaceAll(raw, []byte(`"exclusive-publish.v1"`), []byte(`"exclusive-publish.v1.tampered"`))) + if _, _, c := run(t, "replay", mutated, reqs, receipts); c == 0 { + t.Fatal("replay accepted a chain under a mutated policy identity") + } +} + +// TestSmoke_InfoCommands proves the read-only surface runs and exits 0 with the +// expected framing. These are the commands an installer runs to prove the artifact +// works right after install. +func TestSmoke_InfoCommands(t *testing.T) { + dir := t.TempDir() + policy := filepath.Join(dir, "policy.json") + writeFile(t, policy, exclusivePublishPolicy(t)) + + cases := []struct { + name string + args []string + want string + }{ + {"version", []string{"version"}, "interlock"}, + {"doctor", []string{"doctor"}, "interlock doctor"}, + {"demo", []string{"demo"}, "interlock demo"}, + {"explain", []string{"explain", policy}, "exclusive-publish.v1"}, + {"check", []string{"check", policy}, "hash="}, + {"verify", []string{"verify", "--format", "json"}, "claim"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + so, se, c := run(t, tc.args...) + if c != 0 { + t.Fatalf("%s exit %d\n%s\n%s", tc.name, c, so, se) + } + if !strings.Contains(so, tc.want) { + t.Fatalf("%s output missing %q:\n%s", tc.name, tc.want, so) + } + }) + } +} + +// --- shared fixture builders -------------------------------------------------- + +// exclusivePublishPolicy returns the frozen exclusive-publish canonical policy +// from the conformance corpus — the same artifact the broker vectors use. +func exclusivePublishPolicy(t *testing.T) []byte { + t.Helper() + b := corpusPolicy(t, "policies/exclusive-publish.json") + return b +} + +// envelope renders an upstream evidence envelope in the broker's on-disk shape. +func envelope(schema, runID, status, artifactHash string) []byte { + e := map[string]string{ + "schema": schema, + "run_id": runID, + "status": status, + "artifact_sha256": artifactHash, + } + b, _ := json.Marshal(e) + return b +} + +// publishRequest renders a broker.PublishRequest JSON document. +func publishRequest(runID, reqID, actor, resourceURI, staged, target, envPath string) []byte { + pr := broker.PublishRequest{ + RunID: runID, + RequestID: reqID, + Actor: actor, + ResourceURI: resourceURI, + Kind: ir.KindFile, + StagedPath: staged, + TargetPath: target, + Upstream: []broker.UpstreamReceipt{{Path: envPath}}, + } + b, _ := json.Marshal(pr) + return b +} diff --git a/e2e/isolation_test.go b/e2e/isolation_test.go new file mode 100644 index 0000000..3888316 --- /dev/null +++ b/e2e/isolation_test.go @@ -0,0 +1,115 @@ +package e2e + +// control-law: shipped-surface-honors-the-core (authority-separation obligation) +// +// The filesystem-authority contract. Interlock is "authority, not interception": +// it does not intercept an agent's syscalls, it withholds authority for the one +// protected write and lends it only to the broker on truthful evidence. This suite +// proves that separation at the shipped boundary: +// +// (a) the agent's own attempt to write the protected artifact is DENIED by the +// engine (authority is not the agent's to exercise); +// (b) the publisher CAN land it through the broker on truthful, hash-bound +// evidence; +// (c) the broker refuses to overwrite an existing target (fail closed, bytes +// untouched); +// (d) a real filesystem fault on the staged file fails closed — the target is +// never created. +// +// Scope: this is a single-process hermetic proxy for the authority split. TRUE +// two-principal OS isolation (agent and publisher as separate users/namespaces so +// the agent cannot even reach the broker's inputs) is a deployment property +// (docs/04-enforcement-boundary.md, workspace/workspace.go), out of scope for a +// fast test and deferred to an install-fidelity tier. + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/operatorstack/interlock/ir" + "github.com/operatorstack/interlock/protocol" +) + +func TestIsolation_AuthoritySeparation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission semantics; skipped on Windows") + } + dir := t.TempDir() + policy := filepath.Join(dir, "policy.json") + writeFile(t, policy, exclusivePublishPolicy(t)) + + // (a) The agent's direct write on the protected artifact is denied — authority + // for that effect is not the agent's. + req := protocol.EffectRequest{ + Protocol: protocol.EffectRequestProtocol, + RequestID: "iso-agent", + RunID: "iso", + Actor: "agent", + Operation: ir.OpWrite, + Resource: protocol.TargetResource{Kind: ir.KindFile, URI: "repo://out/result.json"}, + } + reqFile := filepath.Join(dir, "agent-write.json") + writeJSON(t, reqFile, req) + stdout, _, code := run(t, "decide", policy, reqFile) + if code != 0 { + t.Fatalf("decide exit %d", code) + } + if d := decodeDecision(t, stdout); d.Outcome != protocol.OutcomeDeny { + t.Fatalf("agent write on the protected artifact: outcome %s, want deny", d.Outcome) + } + + // (b) The publisher lands it through the broker on truthful, hash-bound evidence. + staged := filepath.Join(dir, "stage", "result.json") + stagedBytes := []byte(`{"ok":true}`) + writeFile(t, staged, stagedBytes) + env := filepath.Join(dir, "envelope.json") + writeFile(t, env, envelope("deltawire.supervision.receipt.v1", "iso", "released", ir.HashBytes(stagedBytes))) + target := filepath.Join(dir, "out", "result.json") + pub := filepath.Join(dir, "pub.json") + writeFile(t, pub, publishRequest("iso", "iso-pub", "publisher", "repo://out/result.json", staged, target, env)) + if so, se, c := run(t, "publish", policy, pub); c != 0 { + t.Fatalf("publisher could not land the artifact (exit %d):\n%s\n%s", c, so, se) + } + if got, _ := os.ReadFile(target); string(got) != string(stagedBytes) { + t.Fatalf("published bytes wrong: %q", got) + } + + // (c) The broker refuses to overwrite an existing target (no prior hash + // expected) — fail closed, existing bytes untouched. + occupied := filepath.Join(dir, "occupied", "result.json") + writeFile(t, occupied, []byte("PRE-EXISTING")) + pubOver := filepath.Join(dir, "pub-overwrite.json") + writeFile(t, pubOver, publishRequest("iso", "iso-over", "publisher", "repo://out/result.json", staged, occupied, env)) + if _, _, c := run(t, "publish", policy, pubOver); c == 0 { + t.Fatal("broker overwrote an existing target") + } + if got, _ := os.ReadFile(occupied); string(got) != "PRE-EXISTING" { + t.Fatalf("fail-closed overwrite mutated the target: %q", got) + } + + // (d) A real filesystem fault (unreadable staged file) fails closed. Skipped + // when running as root, where mode 0000 is still readable. + if os.Geteuid() == 0 { + t.Log("running as root; skipping the unreadable-staged-file case") + return + } + locked := filepath.Join(dir, "locked", "result.json") + writeFile(t, locked, stagedBytes) + if err := os.Chmod(locked, 0o000); err != nil { + t.Fatal(err) + } + defer os.Chmod(locked, 0o644) + lockedEnv := filepath.Join(dir, "locked-env.json") + writeFile(t, lockedEnv, envelope("deltawire.supervision.receipt.v1", "iso", "released", ir.HashBytes(stagedBytes))) + lockedTarget := filepath.Join(dir, "locked-out", "result.json") + pubLocked := filepath.Join(dir, "pub-locked.json") + writeFile(t, pubLocked, publishRequest("iso", "iso-locked", "publisher", "repo://out/result.json", locked, lockedTarget, lockedEnv)) + if _, _, c := run(t, "publish", policy, pubLocked); c == 0 { + t.Fatal("publish succeeded despite an unreadable staged file") + } + if _, err := os.Stat(lockedTarget); !os.IsNotExist(err) { + t.Fatal("fail-closed publish created a target from an unreadable staged file") + } +} diff --git a/e2e/parity_test.go b/e2e/parity_test.go new file mode 100644 index 0000000..1c55efa --- /dev/null +++ b/e2e/parity_test.go @@ -0,0 +1,173 @@ +package e2e + +// control-law: shipped-surface-honors-the-core (parity obligation) +// +// The wind tunnel: drive the frozen conformance corpus through the SHIPPED binary +// and assert the binary reproduces both the frozen expectation AND the in-process +// library result for every vector. This makes decision parity and broker fidelity +// structural — the binary cannot silently diverge from engine.Decide / broker.Publish +// on any corpus vector without turning this suite red. + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/operatorstack/interlock/broker" + "github.com/operatorstack/interlock/conformance/compat" + "github.com/operatorstack/interlock/engine" + "github.com/operatorstack/interlock/ir" + "github.com/operatorstack/interlock/protocol" + "github.com/operatorstack/interlock/scaffold" +) + +// TestDecideParity_Corpus runs every frozen decision vector through `interlock +// decide` and asserts the CLI's outcome/rule matches the frozen expectation and +// the in-process engine for the identical inputs. +func TestDecideParity_Corpus(t *testing.T) { + cases, err := compat.Decisions(compat.V010) + if err != nil { + t.Fatalf("load decision corpus: %v", err) + } + if len(cases) == 0 { + t.Fatal("empty decision corpus") + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + dir := t.TempDir() + polBytes, err := json.Marshal(c.Policy) + if err != nil { + t.Fatal(err) + } + polFile := filepath.Join(dir, "policy.json") + writeFile(t, polFile, polBytes) + + req := c.Request + if c.UsePolicyHash { + h, herr := c.Policy.Hash() + if herr != nil { + t.Fatal(herr) + } + req.ClaimedPolicyHash = h + } + reqBytes, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + reqFile := filepath.Join(dir, "request.json") + writeFile(t, reqFile, reqBytes) + + // `decide` prints the decision and exits 0 even on a deny — the outcome + // is in the payload, not the exit code. + stdout, stderr, code := run(t, "decide", polFile, reqFile) + if code != 0 { + t.Fatalf("decide exit %d\n%s\n%s", code, stdout, stderr) + } + var got protocol.Decision + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("decode decision: %v\n%s", err, stdout) + } + + // The binary must equal the pure engine on the same inputs. + want := engine.Decide(c.Policy, req) + if got.Outcome != want.Outcome || got.RuleID != want.RuleID { + t.Fatalf("binary != library: got (%s,%s), engine (%s,%s)", + got.Outcome, got.RuleID, want.Outcome, want.RuleID) + } + // And it must equal the frozen contract. + if got.Outcome != c.Expect { + t.Fatalf("outcome %s, frozen expects %s", got.Outcome, c.Expect) + } + if c.ExpectRuleID != "" && got.RuleID != c.ExpectRuleID { + t.Fatalf("rule %q, frozen expects %q", got.RuleID, c.ExpectRuleID) + } + }) + } +} + +// TestBrokerParity_Corpus runs every frozen broker vector through `interlock +// publish` and asserts the CLI's publish/deny outcome matches the frozen vector — +// and that a denied publish never creates the target. +func TestBrokerParity_Corpus(t *testing.T) { + vectors, err := compat.BrokerVectors(compat.V010) + if err != nil { + t.Fatalf("load broker corpus: %v", err) + } + if len(vectors) == 0 { + t.Fatal("empty broker corpus") + } + for _, v := range vectors { + t.Run(v.Name, func(t *testing.T) { + dir := t.TempDir() + polFile := filepath.Join(dir, "policy.json") + writeFile(t, polFile, corpusPolicy(t, v.Policy)) + + staged := filepath.Join(dir, "staged") + writeFile(t, staged, []byte(v.Staged)) + + var artifactHash string + switch v.Env.Bind { + case compat.BindStaged: + artifactHash = ir.HashBytes([]byte(v.Staged)) + case compat.BindWrong: + artifactHash = ir.HashBytes([]byte("deliberately-wrong")) + default: + t.Fatalf("unknown bind mode %q", v.Env.Bind) + } + envFile := filepath.Join(dir, "envelope.json") + writeFile(t, envFile, envelope(v.Env.Schema, v.Env.RunID, v.Env.Status, artifactHash)) + + target := filepath.Join(dir, "out", "target") + pr := broker.PublishRequest{ + RunID: v.RunID, + RequestID: v.Name, + Actor: v.Actor, + ResourceURI: v.ResourceURI, + Kind: ir.KindFile, + StagedPath: staged, + TargetPath: target, + Upstream: []broker.UpstreamReceipt{{Path: envFile}}, + } + prBytes, err := json.Marshal(pr) + if err != nil { + t.Fatal(err) + } + pubFile := filepath.Join(dir, "pub.json") + writeFile(t, pubFile, prBytes) + + _, stderr, code := run(t, "publish", polFile, pubFile) + gotOK := code == 0 + if gotOK != v.ExpectOK { + t.Fatalf("publish ok=%v, frozen expects ok=%v (exit %d)\n%s", gotOK, v.ExpectOK, code, stderr) + } + if !v.ExpectOK { + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatal("fail-closed vector created a target") + } + } + }) + } +} + +// corpusPolicy returns the raw bytes of a frozen corpus policy by version-relative +// path (e.g. "policies/exclusive-publish.json"). +func corpusPolicy(t *testing.T, relpath string) []byte { + t.Helper() + b, err := compat.ReadFile(compat.V010, relpath) + if err != nil { + t.Fatalf("read corpus policy %s: %v", relpath, err) + } + return b +} + +// initTemplateKey returns a valid scaffold template key for the init journey, +// preferring one whose policy carries a deny rule so the tamper assertion bites. +func initTemplateKey(t *testing.T) string { + t.Helper() + keys := scaffold.Keys() + if len(keys) == 0 { + t.Fatal("no scaffold templates registered") + } + return keys[0] +}