Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions UPSTREAM.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"assets/interlock-two-roles.svg": "9fd03b2654226824b733617d1069ae831739141e2ee0ec6b67d4c4749689751e",
"broker/broker.go": "c4250c3f2afce78e060b31fc165cc1d37fb181cf3fce07392b90ea98c2b548f0",
"broker/broker_test.go": "5ccf4f6aad1ae846ce405f92fc6c75728e4adec49eaedcdd45f76e6b91dc0a6b",
"broker/envelope.go": "a1f6e2333c45f5f15ce1b1d58cd29589cbc35abcdb0f93ea01c56060f7dcafcf",
"broker/envelope.go": "53a5203c9650511083481d8ca1db6e6424b5be92e9ec7d90347ca0723c958447",
"broker/generality_test.go": "14b83f8dd8e25562cd5d099e7d0b9285d15d2874cddd42f3901303c0553ee596",
"clients/.gitignore": "e44a4a6d3c3287bc82212abbac01e1992247c3cb2b3e5bff8ecfd7ee73788bb5",
"clients/gen/main.go": "2a11e7de9fe7eb1ed8a19435ee5421923a399bc32834da94c0497b5d59728016",
Expand Down Expand Up @@ -68,17 +68,19 @@
"examples/exclusive-publish/policy.go": "d18df2ada6684bc1815d19b4918b7ba8b7b584d7fd75e9fc7f8c0562299cd94c",
"examples/generated-file-protection/policy.go": "0a9a5b33990adb770c74382bbab7e618582fd8c2ce6632deba5ca9cc652696ee",
"examples/release-manifest/policy.go": "d2f005addc2842f0ec235feb64315776aa936aa2e49bb82325867b3c9bcb691c",
"examples/release-manifest/publish_test.go": "25bad210cd30e1d0e2ef4f6328856d18919a5fcda3bfd6d72ce2d770ffeb4868",
"examples/repository-policy/policy.go": "8c7fe12c487c1369d5ce8ae24e647e050557cd2bf29c56d4010b782603debbd1",
"go.mod": "a9a846b064eac2e330c18198dae19044e438bff31dcb26d928cdb2f9b5cae3e1",
"install.ps1": "ada3f94562569929446dd6ca200f993854fa10081785e3ad1a09db285dacca6a",
"install.sh": "7d62cccc9b35280490c3332279743050957ab7e648b4b115c86211ba8995ab4e",
"interlock.go": "de4ff7ee8dd15ab12d0bea387b66ad6e7abfbb66553839ecc0befc32ec8fb606",
"interlock_test.go": "06b56780f7ae90fe12b055bf426aab8b87ed4fe174698a49d729bdc6484299e2",
"ir/ir.go": "5cf039998f609e9f13f770bc518ba9127389320c9f5a71dd8ae3e5240762e718",
"ir/ir.go": "20fe77a927c8f89ac90b5a806579854934ff5d3977a014c236d30377860ec310",
"ir/ir_test.go": "64a962436ee5cab261093f8cb455ad92075683a7ef58997dac1e022e09d1a46a",
"proof/proof.go": "9aec2cb135b09467445d3e46880913d65ca3076860d168f5fc98bb1aa5590948",
"proof/proof.go": "22a3baeee05bfb1e9f2be549a07b873ddc51d36037711ffef31e77ee161c24fb",
"proof/proof_test.go": "8151c10b3efc95e8eb66841ce979093a35263adaf0b6f337b1fe5a3184769123",
"protocol/protocol.go": "1d9cc6ba1d59635e9c88ab9a20058491c78d0024e5e87fd25ec87628271c728e",
"publish/publish.go": "2434a5c00da06f97a27aae77f6b9ca8a006027e37123eac627032cce0a7f3434",
"receipt/receipt.go": "8a21b9054b599965221e08fe158887759473d2f41d28dd3df42a1ffe1c55cb6e",
"receipt/receipt_test.go": "a215a205205573bb0fbf6d121aa761bc28510346b92e8bb72dd85ca90b6fa65c",
"scaffold/demo.go": "d496e57f31a17165a540f704704ae341e66641139795263bee2dcfb9ca1de7c7",
Expand All @@ -93,7 +95,7 @@
"generator": "operatorstack/interlock:project-upstream",
"schema_version": 1,
"source": {
"commit": "0584cd08a481c5d9cf49829a8e73158dd2410802",
"commit": "9ec0e3c0251ee23dd771b8a42d0f9f7b34d4d4d7",
"path": "labs/21-interlock",
"repository": "operatorstack/intelligence-flow"
}
Expand Down
68 changes: 68 additions & 0 deletions broker/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"

"github.com/operatorstack/interlock/ir"
)
Expand All @@ -38,6 +39,73 @@ type upstreamEnvelope struct {
ArtifactSHA256 string `json:"artifact_sha256"`
}

// UpstreamEvidence is the tenant-owned data a caller supplies to write an
// upstream evidence envelope. The tenant owns the meaning of Schema and Status;
// Interlock never interprets either. Deliberately absent is the artifact hash:
// WriteUpstreamEnvelope computes it from the staged bytes themselves, so the
// tagged-vs-bare-hex footgun is unrepresentable — a caller cannot supply a hash
// at all, let alone the wrong format.
type UpstreamEvidence struct {
Schema string
RunID string
Status string
}

// WriteUpstreamEnvelope writes the durable upstream evidence envelope that
// readUpstreamEnvelope re-reads. It binds the envelope to the exact staged bytes
// by computing ir.HashBytes(staged) internally (never a caller-supplied hash),
// and emits byte-identical output to what the reader decodes: the four
// upstreamEnvelope fields in struct order via json.Marshal plus a trailing
// newline. Co-locating the writer with the reader is why they can never drift.
func WriteUpstreamEnvelope(path string, ev UpstreamEvidence, staged []byte) error {
data, err := json.Marshal(upstreamEnvelope{
Schema: ev.Schema,
RunID: ev.RunID,
Status: ev.Status,
ArtifactSHA256: ir.HashBytes(staged),
})
if err != nil {
return fmt.Errorf("interlock/broker: marshal upstream envelope: %w", err)
}
if err := WriteFileAtomic(path, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("interlock/broker: write upstream envelope: %w", err)
}
return nil
}

// WriteFileAtomic writes data to a same-directory temp file (fsync'd, chmod'd)
// and renames it into place, so a reader never observes a partial file. This is a
// generic durable-write utility — NOT the broker's protected effect, which is the
// policy-gated atomic publish in Publish. It is exported so the publishing façade
// can persist evidence through the same implementation rather than duplicating it.
func WriteFileAtomic(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
file, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
name := file.Name()
defer os.Remove(name)
if _, err = file.Write(data); err != nil {
file.Close()
return err
}
if err = file.Sync(); err != nil {
file.Close()
return err
}
if err = file.Chmod(mode); err != nil {
file.Close()
return err
}
if err = file.Close(); err != nil {
return err
}
return os.Rename(name, path)
}

// readUpstreamEnvelope reads the envelope at path, verifies it correlates to the
// expected run and is hash-bound to the expected artifact bytes, and returns the
// envelope plus its own content hash (in ir.HashBytes tagged format) as an
Expand Down
119 changes: 119 additions & 0 deletions examples/release-manifest/publish_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package main

// Slice 2 of the interface-optimization exercise: a SECOND, non-DeltaWire tenant
// publishes through the exact same interlock/publish façade DeltaWire uses. The
// only tenant-specific inputs are this tenant's own policy, schema, status, actor,
// and resource — there is no DeltaWire branch anywhere in the façade or broker.
// This proves the M3 generality claim at the ergonomic layer, not just the core.

import (
"os"
"path/filepath"
"testing"

"github.com/operatorstack/interlock/publish"
)

// releaseManifestPolicy compiles this example's builder to canonical IR and loads
// it through the same exported loader a tenant would use.
func releaseManifestPolicy(t *testing.T) publish.Policy {
t.Helper()
ir, err := Build().Emit()
if err != nil {
t.Fatalf("emit policy: %v", err)
}
p, err := publish.LoadPolicy(ir)
if err != nil {
t.Fatalf("load policy: %v", err)
}
return p
}

// The release bot publishes an attested manifest through the shared façade, using
// its own schema and status — no DeltaWire assumptions.
func TestReleaseManifestPublishesViaFacade(t *testing.T) {
policy := releaseManifestPolicy(t)
dir := t.TempDir()

const content = `{"version":"1.2.3"}`
staged := filepath.Join(dir, "staging", "release-manifest.json")
if err := os.MkdirAll(filepath.Dir(staged), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(staged, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
target := filepath.Join(dir, "dist", "release-manifest.json")
evidenceDir := filepath.Join(dir, "evidence")

res, err := publish.Publish(publish.Request{
Policy: policy,
RunID: "rel-run",
RequestID: "rel-1",
Actor: "release-bot",
ResourceURI: "repo://dist/release-manifest.json",
Kind: publish.KindFile,
StagedPath: staged,
TargetPath: target,
// Target must not exist yet.
Upstream: []publish.Evidence{{
Schema: ReleaseAttestationSchema,
Status: "approved",
}},
EvidenceDir: evidenceDir,
})
if err != nil {
t.Fatalf("publish: %v", err)
}

got, err := os.ReadFile(target)
if err != nil {
t.Fatalf("read published: %v", err)
}
if string(got) != content {
t.Fatalf("published bytes = %q, want %q", got, content)
}
if res.PublishedHash != publish.HashBytes([]byte(content)) {
t.Fatalf("published hash %q != content hash", res.PublishedHash)
}
}

// A foreign receipt schema must fail closed even through the ergonomic façade:
// schema is policy data, and the façade never interprets or privileges it.
func TestReleaseManifestForeignSchemaFailsClosed(t *testing.T) {
policy := releaseManifestPolicy(t)
dir := t.TempDir()

const content = `{"version":"1.2.3"}`
staged := filepath.Join(dir, "staging", "release-manifest.json")
if err := os.MkdirAll(filepath.Dir(staged), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(staged, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
target := filepath.Join(dir, "dist", "release-manifest.json")

_, err := publish.Publish(publish.Request{
Policy: policy,
RunID: "rel-run",
RequestID: "rel-2",
Actor: "release-bot",
ResourceURI: "repo://dist/release-manifest.json",
Kind: publish.KindFile,
StagedPath: staged,
TargetPath: target,
Upstream: []publish.Evidence{{
// DeltaWire's schema — foreign to this tenant's policy.
Schema: "deltawire.supervision.receipt.v1",
Status: "released",
}},
EvidenceDir: filepath.Join(dir, "evidence"),
})
if err == nil {
t.Fatal("publish accepted a foreign receipt schema")
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Fatalf("fail-closed publish still created the target")
}
}
16 changes: 16 additions & 0 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,19 @@ func HashBytes(b []byte) string {
sum := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(sum[:])
}

// LoadPolicy decodes canonical policy bytes and verifies the protocol tag. It
// does not re-canonicalize: policy identity is still established by Policy.Hash
// at decide time, so round-tripping already-canonical bytes changes no canonical
// output. This is the exported loader every tenant needs; without it each one
// hand-rolls the same json.Unmarshal + protocol check.
func LoadPolicy(b []byte) (Policy, error) {
var p Policy
if err := json.Unmarshal(b, &p); err != nil {
return Policy{}, fmt.Errorf("interlock/ir: decode policy: %w", err)
}
if p.Protocol != Protocol {
return Policy{}, fmt.Errorf("interlock/ir: policy protocol %q != %q", p.Protocol, Protocol)
}
return p, nil
}
33 changes: 19 additions & 14 deletions proof/proof.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,15 +300,20 @@ func brokerPublish(policy ir.Policy, req broker.PublishRequest) (broker.Result,
return broker.Publish(policy, req, receipt.NewChain(req.RunID))
}

// writeEnvelope writes a 4-field upstream evidence envelope (the exact shape
// broker/envelope.go re-reads) and returns its path. artifactHash must already
// be in ir.HashBytes tagged form ("sha256:"+hex).
func writeEnvelope(dir, name, schema, runID, status, artifactHash string) (string, error) {
body := fmt.Sprintf(
`{"schema":%q,"run_id":%q,"status":%q,"artifact_sha256":%q}`,
schema, runID, status, artifactHash,
)
return writeFile(dir, name, body)
// writeEnvelope writes an upstream evidence envelope via Interlock's own exported
// writer, which hash-binds it to the staged bytes, and returns its path. Using
// broker.WriteUpstreamEnvelope (rather than hand-formatting the JSON) means the
// proofs exercise the same writer tenants use and cannot drift from the reader.
func writeEnvelope(dir, name, schema, runID, status string, staged []byte) (string, error) {
path := filepath.Join(dir, name)
if err := broker.WriteUpstreamEnvelope(path, broker.UpstreamEvidence{
Schema: schema,
RunID: runID,
Status: status,
}, staged); err != nil {
return "", err
}
return path, nil
}

// --- Proof 6: broker publishes byte-exact staged content ------------------
Expand All @@ -326,7 +331,7 @@ func proofBrokerByteExact() (string, error) {
return "", err
}
env, err := writeEnvelope(dir, "envelope.json",
"deltawire.supervision.receipt.v1", "run1", "released", ir.HashBytes([]byte(content)))
"deltawire.supervision.receipt.v1", "run1", "released", []byte(content))
if err != nil {
return "", err
}
Expand Down Expand Up @@ -365,7 +370,7 @@ func proofStaleTargetFailsClosed() (string, error) {
return "", err
}
env, err := writeEnvelope(dir, "envelope.json",
"deltawire.supervision.receipt.v1", "run1", "released", ir.HashBytes([]byte(content)))
"deltawire.supervision.receipt.v1", "run1", "released", []byte(content))
if err != nil {
return "", err
}
Expand Down Expand Up @@ -401,7 +406,7 @@ func proofCrossRunEvidenceFailsClosed() (string, error) {
// Envelope hash-bound to the staged bytes but correlated to a DIFFERENT run —
// the shape of a copied receipt reused across runs.
env, err := writeEnvelope(dir, "envelope.json",
"deltawire.supervision.receipt.v1", "other-run", "released", ir.HashBytes([]byte(content)))
"deltawire.supervision.receipt.v1", "other-run", "released", []byte(content))
if err != nil {
return "", err
}
Expand Down Expand Up @@ -475,7 +480,7 @@ func proofSecondTenantSameBroker() (string, error) {

// This tenant's own schema/status, hash-bound to its own artifact.
env, err := writeEnvelope(dir, "attestation.json",
"release.attestation.v1", "rel-run", "approved", ir.HashBytes([]byte(content)))
"release.attestation.v1", "rel-run", "approved", []byte(content))
if err != nil {
return "", err
}
Expand All @@ -494,7 +499,7 @@ func proofSecondTenantSameBroker() (string, error) {
// A foreign receipt schema must fail closed even when hash-bound and
// run-correlated: schema is policy data, not broker-privileged.
badEnv, err := writeEnvelope(dir, "foreign.json",
"deltawire.supervision.receipt.v1", "rel-run", "released", ir.HashBytes([]byte(content)))
"deltawire.supervision.receipt.v1", "rel-run", "released", []byte(content))
if err != nil {
return "", err
}
Expand Down
Loading
Loading