diff --git a/UPSTREAM.json b/UPSTREAM.json index 3072df2..e8640ed 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -15,17 +15,32 @@ "broker/broker_test.go": "5ccf4f6aad1ae846ce405f92fc6c75728e4adec49eaedcdd45f76e6b91dc0a6b", "broker/envelope.go": "a1f6e2333c45f5f15ce1b1d58cd29589cbc35abcdb0f93ea01c56060f7dcafcf", "broker/generality_test.go": "14b83f8dd8e25562cd5d099e7d0b9285d15d2874cddd42f3901303c0553ee596", + "clients/.gitignore": "e44a4a6d3c3287bc82212abbac01e1992247c3cb2b3e5bff8ecfd7ee73788bb5", + "clients/gen/main.go": "2a11e7de9fe7eb1ed8a19435ee5421923a399bc32834da94c0497b5d59728016", + "clients/python/examples/parity.py": "8d530dd043ba55b4369f4b3d9945c98bcd9276f84dace7517df4fb3f326cb431", + "clients/python/pyproject.toml": "b93eaaa71294f43cc50608a4a84d1dc8277fb4b69115476a188c55c89933a7b5", + "clients/python/src/interlock_protocol/__init__.py": "6aaeb0f5b560354c36b4e63993d1826d4b70f8120d02044629132156a45156d4", + "clients/python/src/interlock_protocol/canonical.py": "e0f3978baa8195fe49c0711b59c46c9a76469b4c65d259c8ffb926a6473fc3b6", + "clients/python/src/interlock_protocol/protocol.py": "8dd6225bf068269e14290841c2b0c5b88e101978814009e83f25a57f797bf4fb", + "clients/python/src/interlock_protocol/py.typed": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "clients/schema/interlock.schema.json": "b637af7ab11b324f9b018656942552858143b412e5083d18285abadc6d056eba", + "clients/typescript/examples/parity.ts": "2d73ad5d3c9f2b87bf3755fd029f449afcfcc3dd820877f02ae516228196a1b1", + "clients/typescript/package.json": "d6b747a44a20a2377274e7f5cc12bf292fdec10f60fb7c099a17e3b3bd6f0837", + "clients/typescript/src/canonical.ts": "be376928b4563f998a5879201a9cc36407b9c13218ed6357722424e044d1247b", + "clients/typescript/src/index.ts": "b7ccd06a0bfb6e938ff88746ba48b3179b1b855ab6523e9f749658ad514e9728", + "clients/typescript/src/protocol.ts": "a5413e22897330135591f8c44a24eb64cdbfea7762f3f1d2e0866e1424ece145", + "clients/typescript/tsconfig.json": "97647800a147a82d9cdadf00cd2b84d82e07512c9e55971654dd60ba21b4e061", "cmd/interlock/demo.go": "4593c47d679b455ae9800e1197f648071f4b85ec14f3a7f8b726414c91783764", "cmd/interlock/init.go": "ea4df1ba3bcf0ec2f62cf02752b39598027376096ba974b2e770b0a79dc4ba0f", - "cmd/interlock/main.go": "2c775b003351a8242d3b795706360a7a813c9cbe0ac247c60bbd288cb4114889", + "cmd/interlock/main.go": "c7e042f48d25c5b19925b729e6341c41de991a5b5b088cf859cbcf972fc56146", "cmd/interlock/test.go": "e9b680cde45e061155dcc375b057f8ff4e69559d5f2be7dcd15f3685af0e1079", "cmd/interlock/verify.go": "5613c04febf6731fd25a75e615a524d57f99cb762016f458be5243034376b025", "cmd/interlock/version.go": "262fedc77a86623a48ee5a52940356a399fc71466d6da52f902cd655b9d7303d", "compiler/compiler.go": "50487aaa0c24b3c0d041ab5b9a410fa672de7eb77b1cd3df1ec97cd4cf2bccba", "compiler/compiler_test.go": "6f80886aa1bfdf79bca014ad47c7b48d2a6bc24c86863afa4ebeb93658a790aa", - "conformance/compat/compat.go": "cf64376db6fdec31f6a0d01d039d334e367c9561918f13b6a97cdac33df87be5", - "conformance/compat/compat_test.go": "c92856284241e0be9065ff2f903075f43a3389d427514c60bb904ba0ab4362e5", - "conformance/compat/gen/main.go": "1a8468804f4734f55f268ff87e8fa09577a06f8ed4b3d70fcbaf01dc1a688081", + "conformance/compat/compat.go": "5db334201f0d701ba1d7cd73916215d08d00bd566ed35477bf331c055edaca37", + "conformance/compat/compat_test.go": "614b722f9971bb02bef621399795d55aaa733237d15419d12231309519050fc0", + "conformance/compat/gen/main.go": "f3a9649a51f1532aa0eece5486e1e57740d81a26ada48640632cde8c73c97a59", "conformance/compat/v0.1.0/broker.jsonl": "d5d80fdb41d00a136e0516d75211bc969591a453bde693fa2fd3b4be9fa5841f", "conformance/compat/v0.1.0/decisions.jsonl": "d16d8d581b86e2168d6fe12425507571f0543f6f3027beba2a72ae129b2b8e0b", "conformance/compat/v0.1.0/hashes.jsonl": "5c71317c95d397ec67a48603826f128e1dcad821fd39eba556cea57887a1fe3f", @@ -36,12 +51,18 @@ "conformance/compat/v0.1.0/receipts/exclusive-publish.receipts.jsonl": "20f5755ea8493be58830a1ac2f4f0ef3ab772c35ea5373ab9f45d7f0aa7b7670", "conformance/compat/v0.1.0/receipts/exclusive-publish.requests.jsonl": "d243d57942cb68f56a34fb0ebfaf1b14ff054a51f74e5288c1c2a512193e0da0", "conformance/compat/v0.1.0/replay.jsonl": "707d44cee27ae059bd14fd4b8b50545a212bf9c154e4daf9b564e92037964d8e", + "conformance/compat/v0.1.0/specs.jsonl": "8b608d119ef8624c5554217b7ee203d08f4ff93d198ba907921485e0157c2c09", + "conformance/compat/v0.1.0/specs/exclusive-publish.json": "396dc98fd5b6ecb4bc53bc9f2b435f9601bd843680e346f91e5c6a4de07d134a", + "conformance/compat/v0.1.0/specs/generated-file-protection.json": "98841fc51ec344ad25abf314789ef09509a8eeab07e5494e4bc169b5dacd4bc3", + "conformance/compat/v0.1.0/specs/release-manifest.json": "1ba879f871b363e9ab574e34746d0ef2b5133f4997bbb5a0c3c22483677210b8", + "conformance/compat/v0.1.0/specs/repository-policy.json": "9cd33e019bd344c04dc69ad2ec0535a6bbb57bcf3c83ffa976c038f210d49b2c", "conformance/conformance.go": "0e76bab423cad56f1a199fbf595ced0f5760af2be11f5f12f6896e1d298f2c80", "conformance/conformance_test.go": "270f590b4d1a90f5578c3916246e542b79a8b8690aa1b6f8af5ab5a218616413", "conformance/fixtures/hashes.jsonl": "7d33aea8dd5cc961d69f40cda0bf12353552a9e54d39a4e3c0b40e7e53dc2273", "conformance/fixtures/negative.jsonl": "0ef6dbe7df1879564ddb609f4823c440fd43514b92b5a1724babcdc8880a65cc", "conformance/fixtures/positive.jsonl": "0186ae490e1f9cb9ccfadb8f04dd88e1c6e75b1b578918c84314a6305bfc5e64", "doc.go": "ffda943422fc0104acff178f17f096df5d9d0e9065e598aa0d817c457edfb198", + "emitspec_test.go": "b669fc73361f275311221ac450008963885801742fe14d47b12e61e677b67545", "engine/engine.go": "8ee1d012bbf9661288507056c7a015ab9d46fb16e9596d4717b38f15af748b8f", "engine/engine_test.go": "0291ca081684cd744dec98089a383e1a52093f1c5125a88623dab06d504f73a8", "examples/exclusive-publish/policy.go": "d18df2ada6684bc1815d19b4918b7ba8b7b584d7fd75e9fc7f8c0562299cd94c", @@ -51,7 +72,7 @@ "go.mod": "a9a846b064eac2e330c18198dae19044e438bff31dcb26d928cdb2f9b5cae3e1", "install.ps1": "ada3f94562569929446dd6ca200f993854fa10081785e3ad1a09db285dacca6a", "install.sh": "7d62cccc9b35280490c3332279743050957ab7e648b4b115c86211ba8995ab4e", - "interlock.go": "90243e4c4c0f056cd0b2d8981f299449231b502f2f9e6108923bcbda1aeefd06", + "interlock.go": "de4ff7ee8dd15ab12d0bea387b66ad6e7abfbb66553839ecc0befc32ec8fb606", "interlock_test.go": "06b56780f7ae90fe12b055bf426aab8b87ed4fe174698a49d729bdc6484299e2", "ir/ir.go": "5cf039998f609e9f13f770bc518ba9127389320c9f5a71dd8ae3e5240762e718", "ir/ir_test.go": "64a962436ee5cab261093f8cb455ad92075683a7ef58997dac1e022e09d1a46a", @@ -61,16 +82,18 @@ "receipt/receipt.go": "8a21b9054b599965221e08fe158887759473d2f41d28dd3df42a1ffe1c55cb6e", "receipt/receipt_test.go": "a215a205205573bb0fbf6d121aa761bc28510346b92e8bb72dd85ca90b6fa65c", "scaffold/demo.go": "d496e57f31a17165a540f704704ae341e66641139795263bee2dcfb9ca1de7c7", - "scaffold/readme.go": "6f2f7db1e92f7e650072b5b26d29d91c8e229e223b717edecef195b9c0fae940", - "scaffold/scaffold.go": "fcc7c8e68586374a131af560af5f185010aa56d0cf0361fe21f169bc2e7e3f5b", + "scaffold/readme.go": "823551462226576a8b4d18c19d941ec7dd222766d3afb214725db5a1df1b82e4", + "scaffold/scaffold.go": "8352ff8eba0f45d38b6307d4bb5d15e74f94aeda6ae1c7d201907f52d39d998f", "scaffold/scaffold_test.go": "8a7304249589863205c386974252d9aba2119723553a54288c55b342d3a75af9", "spec/spec.go": "65634ab25713fcf646fdf35d13fe5effe6752f602e5bf962ddb32c14b36610ce", + "spec/specdoc.go": "891fa2fc0ba45d721c8a01222b3950b5ab8b421d87687e1be2eca8fc7866cff7", + "spec/specdoc_test.go": "eab60d8ebe4b234b2ec341afafa0cde9500b37d95c0dc6750b7fb3ba7bfdbd37", "workspace/workspace.go": "8d91408ac4bc87a136744360a2e1aede620c44f6050c8845eb3cad39d3be2a54" }, "generator": "operatorstack/interlock:project-upstream", "schema_version": 1, "source": { - "commit": "a3aa03c0de561b2a25d3eda8c85c6e9442ecd677", + "commit": "0584cd08a481c5d9cf49829a8e73158dd2410802", "path": "labs/21-interlock", "repository": "operatorstack/intelligence-flow" } diff --git a/clients/.gitignore b/clients/.gitignore new file mode 100644 index 0000000..5bb1ab5 --- /dev/null +++ b/clients/.gitignore @@ -0,0 +1,12 @@ +# Build/install detritus from the client parity checks (check-clients.sh). +# The clients ship SOURCE only: generated DTOs, the canonical encoder, examples, +# and packaging manifests. Everything below is produced by npm install / pip +# install -e and must never be committed or published. +node_modules/ +package-lock.json +dist/ +build/ +*.egg-info/ +__pycache__/ +*.pyc +.venv/ diff --git a/clients/gen/main.go b/clients/gen/main.go new file mode 100644 index 0000000..7246e84 --- /dev/null +++ b/clients/gen/main.go @@ -0,0 +1,407 @@ +//go:build ignore + +// Command gen is the single source of truth for Interlock's cross-language +// protocol surface. It reflects over the Go wire structs (the authority for what +// the protocol IS) and emits, from those structs alone: +// +// - clients/schema/interlock.schema.json a JSON Schema (draft 2020-12) for +// interlock.spec.v1 plus every protocol.* / ir.* / receipt / broker wire type; +// - clients/typescript/src/protocol.ts generated TypeScript DTOs; +// - clients/python/src/interlock_protocol/protocol.py generated Python DTOs. +// +// These are DATA TYPES ONLY — no decide, no publish, no broker. Enforcement stays +// the trusted Go executable; foreign languages get the shapes and (hand-written, +// parity-gated) canonical encoder, never the decision or broker logic. +// +// Regenerate: +// +// go run ./clients/gen/main.go +package main + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + + "github.com/operatorstack/interlock/broker" + "github.com/operatorstack/interlock/ir" + "github.com/operatorstack/interlock/protocol" + "github.com/operatorstack/interlock/receipt" + "github.com/operatorstack/interlock/spec" +) + +// enumValues maps each named string "enum" type to its closed vocabulary, in +// canonical order. These are the frozen V1 vocabularies; the generator refuses to +// emit a string field of an unregistered named type so a new enum can never slip +// through untyped. +var enumValues = map[reflect.Type][]string{ + reflect.TypeOf(ir.Operation("")): toStrings(ir.Operations), + reflect.TypeOf(ir.ResourceKind("")): toStrings(ir.ResourceKinds), + reflect.TypeOf(ir.Effect("")): {string(ir.EffectAllow), string(ir.EffectDeny)}, + reflect.TypeOf(ir.RequirementKind("")): {string(ir.ReqReceiptStatus), string(ir.ReqStagedHashMatch), string(ir.ReqPolicyHashMatch), string(ir.ReqTargetHashMatch), string(ir.ReqHumanApproval)}, + reflect.TypeOf(protocol.Fidelity("")): {string(protocol.FidelityObserved), string(protocol.FidelityOpaque), string(protocol.FidelityBrokered)}, + reflect.TypeOf(protocol.Outcome("")): {string(protocol.OutcomeAllow), string(protocol.OutcomeDeny), string(protocol.OutcomeRequire), string(protocol.OutcomeFault)}, +} + +// rootStructs are the wire types emitted, in file order. Nested struct fields +// must resolve to a type in this list (checked at generation time). +var rootStructs = []reflect.Type{ + reflect.TypeOf(spec.SpecDoc{}), + reflect.TypeOf(spec.ResourceDoc{}), + reflect.TypeOf(spec.RuleDoc{}), + reflect.TypeOf(ir.Policy{}), + reflect.TypeOf(ir.Resource{}), + reflect.TypeOf(ir.Rule{}), + reflect.TypeOf(ir.Requirement{}), + reflect.TypeOf(protocol.TargetResource{}), + reflect.TypeOf(protocol.Observation{}), + reflect.TypeOf(protocol.Evidence{}), + reflect.TypeOf(protocol.EffectRequest{}), + reflect.TypeOf(protocol.Decision{}), + reflect.TypeOf(receipt.Receipt{}), + reflect.TypeOf(broker.UpstreamReceipt{}), + reflect.TypeOf(broker.PublishRequest{}), +} + +func main() { + root, err := findRoot() + must(err) + + structNames := map[reflect.Type]bool{} + for _, t := range rootStructs { + structNames[t] = true + } + + fields := map[reflect.Type][]fieldInfo{} + for _, t := range rootStructs { + fields[t] = analyze(t, structNames) + } + + writeFile(filepath.Join(root, "clients/schema/interlock.schema.json"), renderSchema(fields)) + writeFile(filepath.Join(root, "clients/typescript/src/protocol.ts"), renderTS(fields)) + writeFile(filepath.Join(root, "clients/python/src/interlock_protocol/protocol.py"), renderPython(fields)) + fmt.Println("generated protocol types for schema / typescript / python") +} + +// fieldInfo is one struct field's resolved shape. +type fieldInfo struct { + json string + optional bool // json:",omitempty" + kind string // "string" | "int" | "bool" | "enum" | "struct" | "array" + ref string // enum/struct type name, when kind is enum/struct + elem *fieldInfo +} + +func analyze(t reflect.Type, structNames map[reflect.Type]bool) []fieldInfo { + var out []fieldInfo + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + name, opts, _ := strings.Cut(f.Tag.Get("json"), ",") + if name == "-" || name == "" { + continue + } + fi := resolve(f.Type, structNames) + fi.json = name + fi.optional = strings.Contains(opts, "omitempty") + out = append(out, fi) + } + return out +} + +func resolve(t reflect.Type, structNames map[reflect.Type]bool) fieldInfo { + switch t.Kind() { + case reflect.String: + if _, ok := enumValues[t]; ok { + return fieldInfo{kind: "enum", ref: t.Name()} + } + if t.Name() != "" && t.Name() != "string" { + fail("string type %q is not a registered enum; add it to enumValues", t.String()) + } + return fieldInfo{kind: "string"} + case reflect.Int, reflect.Int64, reflect.Int32: + return fieldInfo{kind: "int"} + case reflect.Bool: + return fieldInfo{kind: "bool"} + case reflect.Slice: + elem := resolve(t.Elem(), structNames) + return fieldInfo{kind: "array", elem: &elem} + case reflect.Struct: + if !structNames[t] { + fail("struct type %q is referenced but not in rootStructs", t.String()) + } + return fieldInfo{kind: "struct", ref: t.Name()} + default: + fail("unsupported field kind %s (%s)", t.Kind(), t.String()) + return fieldInfo{} + } +} + +// --- JSON Schema (draft 2020-12) ------------------------------------------ + +func renderSchema(fields map[reflect.Type][]fieldInfo) string { + var b strings.Builder + b.WriteString("{\n") + b.WriteString(` "$schema": "https://json-schema.org/draft/2020-12/schema",` + "\n") + b.WriteString(` "$id": "https://interlock.operatorstack.dev/schema/interlock.schema.json",` + "\n") + b.WriteString(` "title": "Interlock protocol types",` + "\n") + b.WriteString(` "$defs": {` + "\n") + + var defs []string + // Enums first (sorted), then structs in declaration order. + var enumNames []string + enumByName := map[string][]string{} + for t, vals := range enumValues { + enumByName[t.Name()] = vals + enumNames = append(enumNames, t.Name()) + } + sort.Strings(enumNames) + for _, name := range enumNames { + var vals []string + for _, v := range enumByName[name] { + vals = append(vals, jsonString(v)) + } + defs = append(defs, fmt.Sprintf(" %s: {\n \"type\": \"string\",\n \"enum\": [%s]\n }", jsonString(name), strings.Join(vals, ", "))) + } + for _, t := range rootStructs { + var props []string + var required []string + for _, f := range fields[t] { + props = append(props, " "+jsonString(f.json)+": "+schemaType(f)) + if !f.optional { + required = append(required, jsonString(f.json)) + } + } + def := " " + jsonString(t.Name()) + ": {\n" + + " \"type\": \"object\",\n" + + " \"additionalProperties\": false,\n" + + " \"properties\": {\n" + strings.Join(props, ",\n") + "\n }" + if len(required) > 0 { + def += ",\n \"required\": [" + strings.Join(required, ", ") + "]" + } + def += "\n }" + defs = append(defs, def) + } + b.WriteString(strings.Join(defs, ",\n")) + b.WriteString("\n }\n}\n") + return b.String() +} + +func schemaType(f fieldInfo) string { + switch f.kind { + case "string": + return `{ "type": "string" }` + case "int": + return `{ "type": "integer" }` + case "bool": + return `{ "type": "boolean" }` + case "enum", "struct": + return `{ "$ref": "#/$defs/` + f.ref + `" }` + case "array": + return `{ "type": "array", "items": ` + schemaType(*f.elem) + ` }` + } + return `{}` +} + +// --- TypeScript ----------------------------------------------------------- + +func renderTS(fields map[reflect.Type][]fieldInfo) string { + var b strings.Builder + b.WriteString(genHeaderTS) + // Enums as string-literal unions, sorted for stability. + var enumNames []string + enumByName := map[string][]string{} + for t, vals := range enumValues { + enumByName[t.Name()] = vals + enumNames = append(enumNames, t.Name()) + } + sort.Strings(enumNames) + for _, name := range enumNames { + var parts []string + for _, v := range enumByName[name] { + parts = append(parts, jsonString(v)) + } + b.WriteString(fmt.Sprintf("export type %s = %s;\n\n", name, strings.Join(parts, " | "))) + } + for _, t := range rootStructs { + b.WriteString(fmt.Sprintf("export interface %s {\n", t.Name())) + for _, f := range fields[t] { + opt := "" + if f.optional { + opt = "?" + } + b.WriteString(fmt.Sprintf(" %s%s: %s;\n", f.json, opt, tsType(f))) + } + b.WriteString("}\n\n") + } + return strings.TrimRight(b.String(), "\n") + "\n" +} + +func tsType(f fieldInfo) string { + switch f.kind { + case "string": + return "string" + case "int": + return "number" + case "bool": + return "boolean" + case "enum", "struct": + return f.ref + case "array": + return tsType(*f.elem) + "[]" + } + return "unknown" +} + +// --- Python --------------------------------------------------------------- + +func renderPython(fields map[reflect.Type][]fieldInfo) string { + var b strings.Builder + b.WriteString(genHeaderPy) + var enumNames []string + enumByName := map[string][]string{} + for t, vals := range enumValues { + enumByName[t.Name()] = vals + enumNames = append(enumNames, t.Name()) + } + sort.Strings(enumNames) + for _, name := range enumNames { + var parts []string + for _, v := range enumByName[name] { + parts = append(parts, pyString(v)) + } + b.WriteString(fmt.Sprintf("%s = Literal[%s]\n", name, strings.Join(parts, ", "))) + } + b.WriteString("\n") + for _, t := range rootStructs { + // total=False when any field is optional, with Required[...] for the rest, + // keeps required/optional exact under TypedDict. + b.WriteString(fmt.Sprintf("class %s(TypedDict, total=False):\n", t.Name())) + for _, f := range fields[t] { + ann := pyType(f) + if !f.optional { + ann = "Required[" + ann + "]" + } + b.WriteString(fmt.Sprintf(" %s: %s\n", pyKey(f.json), ann)) + } + b.WriteString("\n\n") + } + return strings.TrimRight(b.String(), "\n") + "\n" +} + +func pyType(f fieldInfo) string { + switch f.kind { + case "string": + return "str" + case "int": + return "int" + case "bool": + return "bool" + case "enum", "struct": + return `"` + f.ref + `"` + case "array": + return "list[" + strings.Trim(pyType(*f.elem), `"`) + "]" + } + return "object" +} + +// pyKey keeps JSON keys as-is (they are all valid Python identifiers here); a +// TypedDict may use string keys, but these are plain snake_case already. +func pyKey(s string) string { return s } + +// --- helpers -------------------------------------------------------------- + +const genHeaderTS = `// Code generated by clients/gen (go run ./clients/gen/main.go). DO NOT EDIT. +// +// interlock.spec.v1 + protocol DTOs. Data types only — no decide, no publish, no +// broker. Enforcement is the trusted Go executable; this package carries shapes +// and (see canonical.ts) the parity-gated canonical encoder, never decisions. + +` + +const genHeaderPy = `# Code generated by clients/gen (go run ./clients/gen/main.go). DO NOT EDIT. +# +# interlock.spec.v1 + protocol DTOs. Data types only — no decide, no publish, no +# broker. Enforcement is the trusted Go executable; this package carries shapes +# and (see canonical.py) the parity-gated canonical encoder, never decisions. +from __future__ import annotations + +from typing import Literal, Required, TypedDict + +` + +func toStrings[T ~string](in []T) []string { + out := make([]string, len(in)) + for i, v := range in { + out[i] = string(v) + } + return out +} + +func jsonString(s string) string { + // The strings here are identifiers/vocabulary — no special chars — but keep it + // correct anyway. + b, _ := marshalPlain(s) + return b +} + +func pyString(s string) string { return `"` + s + `"` } + +// marshalPlain renders a Go string as a JSON string literal without importing a +// heavyweight path; the inputs are all simple ASCII vocabulary. +func marshalPlain(s string) (string, error) { + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + default: + b.WriteRune(r) + } + } + b.WriteByte('"') + return b.String(), nil +} + +func findRoot() (string, error) { + // Run from the module root (where go.mod is). + wd, err := os.Getwd() + if err != nil { + return "", err + } + for dir := wd; ; { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("go.mod not found from %s", wd) + } + dir = parent + } +} + +func writeFile(path, content string) { + must(os.MkdirAll(filepath.Dir(path), 0o755)) + must(os.WriteFile(path, []byte(content), 0o644)) + fmt.Println("wrote", path) +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "gen: "+format+"\n", args...) + os.Exit(1) +} + +func must(err error) { + if err != nil { + fail("%v", err) + } +} diff --git a/clients/python/examples/parity.py b/clients/python/examples/parity.py new file mode 100644 index 0000000..1d1d8ff --- /dev/null +++ b/clients/python/examples/parity.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +# parity.py is the Python client's executable proof of the support bar: +# +# canonical hash matches · spec round-trips · decision fixtures parse +# +# It reads the FROZEN golden corpus (conformance/compat/v0.1.0) — the same oracle +# the Go compat test uses — and proves the Python canonical encoder reproduces +# Go's canonical policy bytes and hashes exactly. It deliberately does NOT decide +# anything and does NOT compile spec.v1 -> policy.v1: lowering is the Go +# compiler's job and enforcement is the Go executable's; this client only +# reproduces the deterministic canonicalization + hashing. +# +# Run: python examples/parity.py (Python >= 3.11) +# Exits non-zero on any mismatch. +from __future__ import annotations + +import json +import sys +from pathlib import Path + +# Import the installed package if present; otherwise fall back to the src tree so +# the example runs before `pip install -e .`. +try: + from interlock_protocol.canonical import canonical_bytes, hash +except ModuleNotFoundError: # pragma: no cover - convenience for uninstalled runs + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + from interlock_protocol.canonical import canonical_bytes, hash + +# The corpus lives at /conformance/compat/v0.1.0; this file is at +# /clients/python/examples, so it is three directories up. +CORPUS = Path(__file__).resolve().parent.parent.parent.parent / "conformance" / "compat" / "v0.1.0" + + +def read_corpus(rel: str) -> str: + return (CORPUS / rel).read_text(encoding="utf-8") + + +def read_jsonl(rel: str) -> list: + records = [] + for line in read_corpus(rel).splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + records.append(json.loads(line)) + return records + + +failures = 0 + + +def check(ok: bool, msg: str) -> None: + global failures + print(f"{'PASS' if ok else 'FAIL'} {msg}") + if not ok: + failures += 1 + + +# 1. Canonical hash parity — the load-bearing test. Each frozen policy is +# canonical compiled IR; re-canonicalizing the parsed value must reproduce the +# exact frozen bytes and the frozen hash. This is Python == Go == frozen. +hashes = read_jsonl("hashes.jsonl") +check(len(hashes) >= 4, f"loaded {len(hashes)} frozen policy hashes") +for rec in hashes: + raw = read_corpus(rec["policy"]) + parsed = json.loads(raw) + re_canon = canonical_bytes(parsed).decode("utf-8") + check(re_canon == raw, f"{rec['name']}: re-canonicalized bytes match frozen IR") + check( + hash(parsed) == rec["expected_hash"], + f"{rec['name']}: hash == frozen {rec['expected_hash'][:19]}…", + ) + +# 2. spec.v1 round-trip — the client can carry the authoring format. No hash +# assertion: spec.v1 -> policy.v1 lowering is the Go compiler (not ported). +specs = read_jsonl("specs.jsonl") +for rec in specs: + doc = json.loads(read_corpus(rec["spec"])) + check( + doc.get("protocol") == "interlock.spec.v1" + and bool(doc.get("policy_id")) + and isinstance(doc.get("rules"), list), + f"{rec['name']}: spec.v1 parses into SpecDoc", + ) + +# 3. Decision fixtures — every frozen decision vector parses into the generated +# protocol DTOs and stays within the closed vocabularies. The client +# TRANSPORTS and TYPES decisions; it never re-decides them. +OUTCOMES = {"allow", "deny", "require", "fault"} +OPERATIONS = { + "filesystem.read", "filesystem.write", "filesystem.delete", "filesystem.rename_from", + "filesystem.rename_to", "process.execute", "artifact.publish", "vcs.push", "vcs.force_push", +} +KINDS = {"file", "tree", "process", "branch"} + +decisions = read_jsonl("decisions.jsonl") +check(len(decisions) > 0, f"loaded {len(decisions)} frozen decision vectors") +vocab_ok = True +for c in decisions: + # Every expected outcome is a member of the closed Outcome vocabulary. The + # request's operation/kind must be in-vocabulary EXCEPT for fault fixtures, + # which deliberately carry unknown values to prove the engine faults on them — + # the protocol must be able to transport those unknowns verbatim. + good = c["expect"] in OUTCOMES + if c["expect"] != "fault": + good = ( + good + and c["request"]["operation"] in OPERATIONS + and c["request"]["resource"]["kind"] in KINDS + ) + if not good: + vocab_ok = False + print(f" offending fixture: {c['name']}") + # Round-trip the inline policy through the encoder (must not throw). + canonical_bytes(c["policy"]) +check(vocab_ok, "all decision fixtures parse into DTOs within the closed vocabulary") + +print("\nRESULT: PASS" if failures == 0 else f"\nRESULT: FAIL ({failures})") +sys.exit(0 if failures == 0 else 1) diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml new file mode 100644 index 0000000..4c7e611 --- /dev/null +++ b/clients/python/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "interlock-protocol" +version = "0.1.0" +description = "Generated protocol types and canonical encoder for Interlock (interlock.spec.v1 / interlock.policy.v1). Data types only — enforcement stays the Go executable." +license = "Apache-2.0" +requires-python = ">=3.11" +# Zero runtime dependencies: canonicalization uses only the standard library +# (hashlib, json). Typing uses typing.Required (3.11+). +dependencies = [] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +interlock_protocol = ["py.typed"] diff --git a/clients/python/src/interlock_protocol/__init__.py b/clients/python/src/interlock_protocol/__init__.py new file mode 100644 index 0000000..0b3a54c --- /dev/null +++ b/clients/python/src/interlock_protocol/__init__.py @@ -0,0 +1,54 @@ +# interlock-protocol — generated protocol types + the canonical encoder. +# Data types and deterministic canonicalization only; no decide, no broker. +from __future__ import annotations + +from .canonical import canonical_bytes, hash +from .protocol import ( + Decision, + Effect, + EffectRequest, + Evidence, + Fidelity, + Observation, + Operation, + Policy, + PublishRequest, + Receipt, + Requirement, + RequirementKind, + Resource, + ResourceDoc, + ResourceKind, + Rule, + RuleDoc, + SpecDoc, + TargetResource, + UpstreamReceipt, + Outcome, +) + +__all__ = [ + "canonical_bytes", + "hash", + "Decision", + "Effect", + "EffectRequest", + "Evidence", + "Fidelity", + "Observation", + "Operation", + "Outcome", + "Policy", + "PublishRequest", + "Receipt", + "Requirement", + "RequirementKind", + "Resource", + "ResourceDoc", + "ResourceKind", + "Rule", + "RuleDoc", + "SpecDoc", + "TargetResource", + "UpstreamReceipt", +] diff --git a/clients/python/src/interlock_protocol/canonical.py b/clients/python/src/interlock_protocol/canonical.py new file mode 100644 index 0000000..46e757b --- /dev/null +++ b/clients/python/src/interlock_protocol/canonical.py @@ -0,0 +1,114 @@ +# canonical.py reproduces Interlock's ONE canonicalization authority +# (ir.Canonical / ir.writeCanonical in Go) byte-for-byte, so a policy authored +# or transported in Python hashes identically to the Go implementation and to the +# frozen golden corpus. This is deliberately a re-implementation of a +# *deterministic* function — NOT of enforcement. There is no decide and no broker +# here, and there must never be: enforcement stays the trusted Go executable +# (see the "no foreign enforcement" guardrail). +# +# The scheme (must match ir/ir.go exactly): +# - object keys sorted by UTF-8 byte order (Go sort.Strings), recursively; +# - no insignificant whitespace; +# - each string/key escaped like Go's encoding/json default (HTML-escape ON): +# " \\ \n \r \t as short escapes; < > & and other control chars < 0x20 and +# U+2028 / U+2029 as \uXXXX (lowercase hex); everything else verbatim UTF-8; +# - a trailing newline; +# - hash = "sha256:" + hex(sha256(canonical_bytes)). +# +# Stdlib only: hashlib for the digest, and the json module solely to PARSE inputs +# at call sites — the serializer below is hand-written because json.dumps does not +# reproduce Go's HTML-escaping or byte-order key sort. +from __future__ import annotations + +import hashlib + +__all__ = ["canonical_bytes", "hash"] + + +def canonical_bytes(value: object) -> bytes: + """Render a parsed JSON value (dict / list / str / int / float / bool / None) + to Interlock canonical bytes. Input is plain JSON data, never a class + instance — the generated TypedDicts are structural and parse straight to it. + """ + return (_serialize(value) + "\n").encode("utf-8") + + +def hash(value: object) -> str: + """Return the canonical identity of a value: "sha256:" + hex digest of its + canonical bytes, matching ir.Policy.Hash / ir.HashBytes tagging. + """ + digest = hashlib.sha256(canonical_bytes(value)).hexdigest() + return "sha256:" + digest + + +def _serialize(v: object) -> str: + if v is None: + return "null" + # bool is a subclass of int in Python — must be checked BEFORE int. + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, str): + return _encode_string(v) + if isinstance(v, int): + return str(v) + if isinstance(v, float): + return _encode_float(v) + if isinstance(v, list): + return "[" + ",".join(_serialize(x) for x in v) + "]" + if isinstance(v, dict): + return _encode_object(v) + raise TypeError(f"interlock/canonical: unsupported value type {type(v).__name__}") + + +def _encode_object(obj: dict) -> str: + # Sort keys by their UTF-8 byte sequences, matching Go's sort.Strings (which + # compares bytes). Python compares bytes objects lexicographically by byte + # value, so this reproduces Go exactly even for multibyte keys. For ASCII + # keys — all the corpus uses — it coincides with an ordinary string sort. + keys = sorted(obj.keys(), key=lambda k: k.encode("utf-8")) + return "{" + ",".join(_encode_string(k) + ":" + _serialize(obj[k]) for k in keys) + "}" + + +def _encode_float(n: float) -> str: + # Interlock's corpus contains no numbers; this branch is not exercised by the + # parity corpus. Reject non-finite values (not valid JSON) and emit the + # shortest round-trip form otherwise. + if n != n or n in (float("inf"), float("-inf")): + raise ValueError("interlock/canonical: non-finite number is not valid JSON") + return repr(n) + + +_HEX = "0123456789abcdef" + +# Short escapes matching Go encoding/json's default string encoder. +_SHORT = { + '"': '\\"', + "\\": "\\\\", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", + "<": "\\u003c", + ">": "\\u003e", + "&": "\\u0026", +} + + +def _encode_string(s: str) -> str: + out = ['"'] + for ch in s: + short = _SHORT.get(ch) + if short is not None: + out.append(short) + continue + code = ord(ch) + if code < 0x20: + out.append("\\u00" + _HEX[(code >> 4) & 0xF] + _HEX[code & 0xF]) + continue + # U+2028 LINE SEPARATOR / U+2029 PARAGRAPH SEPARATOR: Go escapes these so + # the output is safe to embed in JavaScript. + if code == 0x2028 or code == 0x2029: + out.append("\\u20" + _HEX[(code >> 4) & 0xF] + _HEX[code & 0xF]) + continue + out.append(ch) + out.append('"') + return "".join(out) diff --git a/clients/python/src/interlock_protocol/protocol.py b/clients/python/src/interlock_protocol/protocol.py new file mode 100644 index 0000000..8f9aa3e --- /dev/null +++ b/clients/python/src/interlock_protocol/protocol.py @@ -0,0 +1,138 @@ +# Code generated by clients/gen (go run ./clients/gen/main.go). DO NOT EDIT. +# +# interlock.spec.v1 + protocol DTOs. Data types only — no decide, no publish, no +# broker. Enforcement is the trusted Go executable; this package carries shapes +# and (see canonical.py) the parity-gated canonical encoder, never decisions. +from __future__ import annotations + +from typing import Literal, Required, TypedDict + +Effect = Literal["allow", "deny"] +Fidelity = Literal["observed", "opaque", "brokered"] +Operation = Literal["filesystem.read", "filesystem.write", "filesystem.delete", "filesystem.rename_from", "filesystem.rename_to", "process.execute", "artifact.publish", "vcs.push", "vcs.force_push"] +Outcome = Literal["allow", "deny", "require", "fault"] +RequirementKind = Literal["receipt_status", "staged_hash_match", "policy_hash_match", "target_hash_match", "human_approval"] +ResourceKind = Literal["file", "tree", "process", "branch"] + +class SpecDoc(TypedDict, total=False): + protocol: Required[str] + policy_id: Required[str] + actors: Required[list[str]] + resources: Required[list[ResourceDoc]] + rules: Required[list[RuleDoc]] + + +class ResourceDoc(TypedDict, total=False): + id: Required[str] + kind: Required["ResourceKind"] + uri: Required[str] + + +class RuleDoc(TypedDict, total=False): + id: Required[str] + effect: Required["Effect"] + actor: Required[str] + operations: Required[list[Operation]] + resource: Required[str] + requires: list[Requirement] + reason: str + + +class Policy(TypedDict, total=False): + protocol: Required[str] + policy_id: Required[str] + actors: Required[list[str]] + resources: Required[list[Resource]] + rules: Required[list[Rule]] + + +class Resource(TypedDict, total=False): + id: Required[str] + kind: Required["ResourceKind"] + uri: Required[str] + + +class Rule(TypedDict, total=False): + id: Required[str] + effect: Required["Effect"] + actor: Required[str] + operations: Required[list[Operation]] + resource: Required[str] + requires: list[Requirement] + reason: str + + +class Requirement(TypedDict, total=False): + kind: Required["RequirementKind"] + receipt: str + status: str + approval: str + + +class TargetResource(TypedDict, total=False): + kind: Required["ResourceKind"] + uri: Required[str] + + +class Observation(TypedDict, total=False): + source: Required[str] + fidelity: Required["Fidelity"] + + +class Evidence(TypedDict, total=False): + kind: Required["RequirementKind"] + receipt: str + status: str + value: str + + +class EffectRequest(TypedDict, total=False): + protocol: Required[str] + request_id: Required[str] + run_id: Required[str] + actor: Required[str] + operation: Required["Operation"] + resource: Required["TargetResource"] + observation: Required["Observation"] + claimed_policy_hash: str + evidence: list[Evidence] + + +class Decision(TypedDict, total=False): + protocol: Required[str] + request_id: Required[str] + policy_hash: Required[str] + outcome: Required["Outcome"] + rule_id: str + reason: Required[str] + missing_evidence: list[Requirement] + + +class Receipt(TypedDict, total=False): + schema: Required[str] + sequence: Required[int] + run_id: Required[str] + request_id: Required[str] + request_hash: Required[str] + policy_hash: Required[str] + rule_id: str + outcome: Required["Outcome"] + evidence_hashes: list[str] + prev_receipt_hash: Required[str] + self_hash: Required[str] + + +class UpstreamReceipt(TypedDict, total=False): + path: Required[str] + + +class PublishRequest(TypedDict, total=False): + run_id: Required[str] + request_id: Required[str] + actor: Required[str] + resource_uri: Required[str] + kind: Required["ResourceKind"] + staged_path: Required[str] + target_path: Required[str] + expected_target_hash: str + upstream: list[UpstreamReceipt] diff --git a/clients/python/src/interlock_protocol/py.typed b/clients/python/src/interlock_protocol/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/clients/schema/interlock.schema.json b/clients/schema/interlock.schema.json new file mode 100644 index 0000000..e9a0ff4 --- /dev/null +++ b/clients/schema/interlock.schema.json @@ -0,0 +1,215 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://interlock.operatorstack.dev/schema/interlock.schema.json", + "title": "Interlock protocol types", + "$defs": { + "Effect": { + "type": "string", + "enum": ["allow", "deny"] + }, + "Fidelity": { + "type": "string", + "enum": ["observed", "opaque", "brokered"] + }, + "Operation": { + "type": "string", + "enum": ["filesystem.read", "filesystem.write", "filesystem.delete", "filesystem.rename_from", "filesystem.rename_to", "process.execute", "artifact.publish", "vcs.push", "vcs.force_push"] + }, + "Outcome": { + "type": "string", + "enum": ["allow", "deny", "require", "fault"] + }, + "RequirementKind": { + "type": "string", + "enum": ["receipt_status", "staged_hash_match", "policy_hash_match", "target_hash_match", "human_approval"] + }, + "ResourceKind": { + "type": "string", + "enum": ["file", "tree", "process", "branch"] + }, + "SpecDoc": { + "type": "object", + "additionalProperties": false, + "properties": { + "protocol": { "type": "string" }, + "policy_id": { "type": "string" }, + "actors": { "type": "array", "items": { "type": "string" } }, + "resources": { "type": "array", "items": { "$ref": "#/$defs/ResourceDoc" } }, + "rules": { "type": "array", "items": { "$ref": "#/$defs/RuleDoc" } } + }, + "required": ["protocol", "policy_id", "actors", "resources", "rules"] + }, + "ResourceDoc": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "kind": { "$ref": "#/$defs/ResourceKind" }, + "uri": { "type": "string" } + }, + "required": ["id", "kind", "uri"] + }, + "RuleDoc": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "effect": { "$ref": "#/$defs/Effect" }, + "actor": { "type": "string" }, + "operations": { "type": "array", "items": { "$ref": "#/$defs/Operation" } }, + "resource": { "type": "string" }, + "requires": { "type": "array", "items": { "$ref": "#/$defs/Requirement" } }, + "reason": { "type": "string" } + }, + "required": ["id", "effect", "actor", "operations", "resource"] + }, + "Policy": { + "type": "object", + "additionalProperties": false, + "properties": { + "protocol": { "type": "string" }, + "policy_id": { "type": "string" }, + "actors": { "type": "array", "items": { "type": "string" } }, + "resources": { "type": "array", "items": { "$ref": "#/$defs/Resource" } }, + "rules": { "type": "array", "items": { "$ref": "#/$defs/Rule" } } + }, + "required": ["protocol", "policy_id", "actors", "resources", "rules"] + }, + "Resource": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "kind": { "$ref": "#/$defs/ResourceKind" }, + "uri": { "type": "string" } + }, + "required": ["id", "kind", "uri"] + }, + "Rule": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "effect": { "$ref": "#/$defs/Effect" }, + "actor": { "type": "string" }, + "operations": { "type": "array", "items": { "$ref": "#/$defs/Operation" } }, + "resource": { "type": "string" }, + "requires": { "type": "array", "items": { "$ref": "#/$defs/Requirement" } }, + "reason": { "type": "string" } + }, + "required": ["id", "effect", "actor", "operations", "resource"] + }, + "Requirement": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { "$ref": "#/$defs/RequirementKind" }, + "receipt": { "type": "string" }, + "status": { "type": "string" }, + "approval": { "type": "string" } + }, + "required": ["kind"] + }, + "TargetResource": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { "$ref": "#/$defs/ResourceKind" }, + "uri": { "type": "string" } + }, + "required": ["kind", "uri"] + }, + "Observation": { + "type": "object", + "additionalProperties": false, + "properties": { + "source": { "type": "string" }, + "fidelity": { "$ref": "#/$defs/Fidelity" } + }, + "required": ["source", "fidelity"] + }, + "Evidence": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { "$ref": "#/$defs/RequirementKind" }, + "receipt": { "type": "string" }, + "status": { "type": "string" }, + "value": { "type": "string" } + }, + "required": ["kind"] + }, + "EffectRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "protocol": { "type": "string" }, + "request_id": { "type": "string" }, + "run_id": { "type": "string" }, + "actor": { "type": "string" }, + "operation": { "$ref": "#/$defs/Operation" }, + "resource": { "$ref": "#/$defs/TargetResource" }, + "observation": { "$ref": "#/$defs/Observation" }, + "claimed_policy_hash": { "type": "string" }, + "evidence": { "type": "array", "items": { "$ref": "#/$defs/Evidence" } } + }, + "required": ["protocol", "request_id", "run_id", "actor", "operation", "resource", "observation"] + }, + "Decision": { + "type": "object", + "additionalProperties": false, + "properties": { + "protocol": { "type": "string" }, + "request_id": { "type": "string" }, + "policy_hash": { "type": "string" }, + "outcome": { "$ref": "#/$defs/Outcome" }, + "rule_id": { "type": "string" }, + "reason": { "type": "string" }, + "missing_evidence": { "type": "array", "items": { "$ref": "#/$defs/Requirement" } } + }, + "required": ["protocol", "request_id", "policy_hash", "outcome", "reason"] + }, + "Receipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "schema": { "type": "string" }, + "sequence": { "type": "integer" }, + "run_id": { "type": "string" }, + "request_id": { "type": "string" }, + "request_hash": { "type": "string" }, + "policy_hash": { "type": "string" }, + "rule_id": { "type": "string" }, + "outcome": { "$ref": "#/$defs/Outcome" }, + "evidence_hashes": { "type": "array", "items": { "type": "string" } }, + "prev_receipt_hash": { "type": "string" }, + "self_hash": { "type": "string" } + }, + "required": ["schema", "sequence", "run_id", "request_id", "request_hash", "policy_hash", "outcome", "prev_receipt_hash", "self_hash"] + }, + "UpstreamReceipt": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { "type": "string" } + }, + "required": ["path"] + }, + "PublishRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "run_id": { "type": "string" }, + "request_id": { "type": "string" }, + "actor": { "type": "string" }, + "resource_uri": { "type": "string" }, + "kind": { "$ref": "#/$defs/ResourceKind" }, + "staged_path": { "type": "string" }, + "target_path": { "type": "string" }, + "expected_target_hash": { "type": "string" }, + "upstream": { "type": "array", "items": { "$ref": "#/$defs/UpstreamReceipt" } } + }, + "required": ["run_id", "request_id", "actor", "resource_uri", "kind", "staged_path", "target_path"] + } + } +} diff --git a/clients/typescript/examples/parity.ts b/clients/typescript/examples/parity.ts new file mode 100644 index 0000000..b6f2bc4 --- /dev/null +++ b/clients/typescript/examples/parity.ts @@ -0,0 +1,116 @@ +// parity.ts is the TypeScript client's executable proof of the support bar: +// +// canonical hash matches · spec round-trips · decision fixtures parse +// +// It reads the FROZEN golden corpus (conformance/compat/v0.1.0) — the same +// oracle the Go compat test uses — and proves the TypeScript canonical encoder +// reproduces Go's canonical policy bytes and hashes exactly. It deliberately +// does NOT decide anything and does NOT compile spec.v1 → policy.v1: lowering is +// the Go compiler's job and enforcement is the Go executable's; this client only +// reproduces the deterministic canonicalization + hashing. +// +// Run: node examples/parity.ts (Node >= 22, native TypeScript type stripping) +// Exits non-zero on any mismatch. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { canonicalBytes, hash } from "../src/canonical.ts"; +import type { Policy, SpecDoc } from "../src/protocol.ts"; + +// The corpus lives at /conformance/compat/v0.1.0; this file is at +// /clients/typescript/examples, so it is three directories up. +const CORPUS = join(import.meta.dirname, "..", "..", "..", "conformance", "compat", "v0.1.0"); + +function readCorpus(rel: string): string { + return readFileSync(join(CORPUS, rel), "utf8"); +} + +function readJsonl(rel: string): unknown[] { + return readCorpus(rel) + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("#")) + .map((l) => JSON.parse(l)); +} + +let failures = 0; +function check(ok: boolean, msg: string): void { + console.log(`${ok ? "PASS" : "FAIL"} ${msg}`); + if (!ok) failures++; +} + +// 1. Canonical hash parity — the load-bearing test. Each frozen policy is +// canonical compiled IR; re-canonicalizing the parsed value must reproduce +// the exact frozen bytes and the frozen hash. This is TS == Go == frozen. +interface HashRecord { + name: string; + policy: string; + expected_hash: string; +} +const hashes = readJsonl("hashes.jsonl") as HashRecord[]; +check(hashes.length >= 4, `loaded ${hashes.length} frozen policy hashes`); +for (const rec of hashes) { + const raw = readCorpus(rec.policy); + const parsed = JSON.parse(raw) as Policy; + const reCanon = new TextDecoder().decode(canonicalBytes(parsed)); + check(reCanon === raw, `${rec.name}: re-canonicalized bytes match frozen IR`); + check( + hash(parsed) === rec.expected_hash, + `${rec.name}: hash == frozen ${rec.expected_hash.slice(0, 19)}…`, + ); +} + +// 2. spec.v1 round-trip — the client can carry the authoring format. No hash +// assertion: spec.v1 → policy.v1 lowering is the Go compiler (not ported). +interface SpecRecord { + name: string; + spec: string; + expected_hash: string; +} +const specs = readJsonl("specs.jsonl") as SpecRecord[]; +for (const rec of specs) { + const doc = JSON.parse(readCorpus(rec.spec)) as SpecDoc; + check( + doc.protocol === "interlock.spec.v1" && !!doc.policy_id && Array.isArray(doc.rules), + `${rec.name}: spec.v1 parses into SpecDoc`, + ); +} + +// 3. Decision fixtures — every frozen decision vector parses into the generated +// protocol DTOs and stays within the closed vocabularies. The client +// TRANSPORTS and TYPES decisions; it never re-decides them. +const OUTCOMES = new Set(["allow", "deny", "require", "fault"]); +const OPERATIONS = new Set([ + "filesystem.read", "filesystem.write", "filesystem.delete", "filesystem.rename_from", + "filesystem.rename_to", "process.execute", "artifact.publish", "vcs.push", "vcs.force_push", +]); +const KINDS = new Set(["file", "tree", "process", "branch"]); +interface DecisionCase { + name: string; + policy: Policy; + request: { operation: string; resource: { kind: string } }; + expect: string; +} +const decisions = readJsonl("decisions.jsonl") as DecisionCase[]; +check(decisions.length > 0, `loaded ${decisions.length} frozen decision vectors`); +let vocabOK = true; +for (const c of decisions) { + // Every expected outcome is a member of the closed Outcome vocabulary. The + // request's operation/kind must be in-vocabulary EXCEPT for fault fixtures, + // which deliberately carry unknown values to prove the engine faults on them — + // the protocol must be able to transport those unknowns verbatim. + let good = OUTCOMES.has(c.expect); + if (c.expect !== "fault") { + good = good && OPERATIONS.has(c.request.operation) && KINDS.has(c.request.resource.kind); + } + if (!good) { + vocabOK = false; + console.log(` offending fixture: ${c.name}`); + } + // Round-trip the inline policy through the encoder (must not throw). + canonicalBytes(c.policy); +} +check(vocabOK, "all decision fixtures parse into DTOs within the closed vocabulary"); + +console.log(failures === 0 ? "\nRESULT: PASS" : `\nRESULT: FAIL (${failures})`); +process.exit(failures === 0 ? 0 : 1); diff --git a/clients/typescript/package.json b/clients/typescript/package.json new file mode 100644 index 0000000..aeb339c --- /dev/null +++ b/clients/typescript/package.json @@ -0,0 +1,26 @@ +{ + "name": "@operatorstack/interlock", + "version": "0.1.0", + "private": true, + "description": "Generated protocol types and canonical encoder for Interlock (interlock.spec.v1 / interlock.policy.v1). Data types only — enforcement stays the Go executable.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "example": "node examples/parity.ts", + "parity": "node examples/parity.ts" + }, + "engines": { + "node": ">=22" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } +} diff --git a/clients/typescript/src/canonical.ts b/clients/typescript/src/canonical.ts new file mode 100644 index 0000000..284a182 --- /dev/null +++ b/clients/typescript/src/canonical.ts @@ -0,0 +1,140 @@ +// canonical.ts reproduces Interlock's ONE canonicalization authority +// (ir.Canonical / ir.writeCanonical in Go) byte-for-byte, so a policy authored +// or transported in TypeScript hashes identically to the Go implementation and +// to the frozen golden corpus. This is deliberately a re-implementation of a +// *deterministic* function — NOT of enforcement. There is no Decide and no +// broker here, and there must never be: enforcement stays the trusted Go +// executable (see the "no foreign enforcement" guardrail). +// +// The scheme (must match ir/ir.go exactly): +// - object keys sorted by UTF-8 byte order (Go sort.Strings), recursively; +// - no insignificant whitespace; +// - each string/key escaped like Go's encoding/json default (HTML-escape ON): +// " \\ \n \r \t as short escapes; < > & and other control chars < 0x20 and +// U+2028 / U+2029 as \uXXXX (lowercase hex); everything else verbatim UTF-8; +// - a trailing newline; +// - hash = "sha256:" + hex(sha256(canonicalBytes)). + +import { createHash } from "node:crypto"; + +const encoder = new TextEncoder(); + +// canonicalBytes renders a parsed JSON value (object / array / string / number / +// boolean / null) to Interlock canonical bytes. Input is plain JSON data, never +// a class instance — the generated DTOs are structural and parse straight to it. +export function canonicalBytes(value: unknown): Uint8Array { + return encoder.encode(serialize(value) + "\n"); +} + +// hash returns the canonical identity of a value: "sha256:" + hex digest of its +// canonical bytes, matching ir.Policy.Hash / ir.HashBytes tagging. +export function hash(value: unknown): string { + const digest = createHash("sha256").update(canonicalBytes(value)).digest("hex"); + return "sha256:" + digest; +} + +function serialize(v: unknown): string { + if (v === null || v === undefined) return "null"; + switch (typeof v) { + case "boolean": + return v ? "true" : "false"; + case "number": + return encodeNumber(v); + case "string": + return encodeString(v); + case "object": + if (Array.isArray(v)) { + return "[" + v.map(serialize).join(",") + "]"; + } + return encodeObject(v as Record); + default: + throw new Error(`interlock/canonical: unsupported value type ${typeof v}`); + } +} + +function encodeObject(obj: Record): string { + const keys = Object.keys(obj).sort(compareUtf8); + let out = "{"; + for (let i = 0; i < keys.length; i++) { + if (i > 0) out += ","; + out += encodeString(keys[i]) + ":" + serialize(obj[keys[i]]); + } + return out + "}"; +} + +// compareUtf8 orders two strings by their UTF-8 byte sequences, matching Go's +// sort.Strings (which compares bytes). For ASCII keys — all the corpus uses — +// this is identical to a code-unit compare, but we do the real thing so keys +// with multibyte characters still sort exactly as Go would. +function compareUtf8(a: string, b: string): number { + const ab = encoder.encode(a); + const bb = encoder.encode(b); + const n = Math.min(ab.length, bb.length); + for (let i = 0; i < n; i++) { + if (ab[i] !== bb[i]) return ab[i] - bb[i]; + } + return ab.length - bb.length; +} + +// encodeNumber preserves integer literals; Interlock's corpus contains no +// numbers, but a Receipt.sequence transported through these types stays exact. +// (Go preserves the JSON number literal via json.Number; JSON.parse has already +// collapsed it to a double by the time we see it, so integers round-trip and +// non-integers are emitted with JS's shortest round-trip form. This branch is +// not exercised by the parity corpus.) +function encodeNumber(n: number): string { + if (!Number.isFinite(n)) { + throw new Error("interlock/canonical: non-finite number is not valid JSON"); + } + return String(n); +} + +const HEX = "0123456789abcdef"; + +// encodeString matches Go encoding/json's default string encoder with HTML +// escaping enabled (the mode ir.go's json.Marshal uses). +function encodeString(s: string): string { + let out = '"'; + for (const ch of s) { + const code = ch.codePointAt(0)!; + switch (ch) { + case '"': + out += '\\"'; + continue; + case "\\": + out += "\\\\"; + continue; + case "\n": + out += "\\n"; + continue; + case "\r": + out += "\\r"; + continue; + case "\t": + out += "\\t"; + continue; + case "<": + out += "\\u003c"; + continue; + case ">": + out += "\\u003e"; + continue; + case "&": + out += "\\u0026"; + continue; + } + if (code < 0x20) { + out += "\\u00" + HEX[(code >> 4) & 0xf] + HEX[code & 0xf]; + continue; + } + // U+2028 LINE SEPARATOR / U+2029 PARAGRAPH SEPARATOR: Go escapes these so the + // output is safe to embed in JavaScript. Matched by code point to avoid + // embedding the raw characters in this source file. + if (code === 0x2028 || code === 0x2029) { + out += "\\u20" + HEX[(code >> 4) & 0xf] + HEX[code & 0xf]; + continue; + } + out += ch; + } + return out + '"'; +} diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts new file mode 100644 index 0000000..88f6831 --- /dev/null +++ b/clients/typescript/src/index.ts @@ -0,0 +1,4 @@ +// @operatorstack/interlock — generated protocol types + the canonical encoder. +// Data types and deterministic canonicalization only; no decide, no broker. +export * from "./protocol.ts"; +export * from "./canonical.ts"; diff --git a/clients/typescript/src/protocol.ts b/clients/typescript/src/protocol.ts new file mode 100644 index 0000000..d452305 --- /dev/null +++ b/clients/typescript/src/protocol.ts @@ -0,0 +1,141 @@ +// Code generated by clients/gen (go run ./clients/gen/main.go). DO NOT EDIT. +// +// interlock.spec.v1 + protocol DTOs. Data types only — no decide, no publish, no +// broker. Enforcement is the trusted Go executable; this package carries shapes +// and (see canonical.ts) the parity-gated canonical encoder, never decisions. + +export type Effect = "allow" | "deny"; + +export type Fidelity = "observed" | "opaque" | "brokered"; + +export type Operation = "filesystem.read" | "filesystem.write" | "filesystem.delete" | "filesystem.rename_from" | "filesystem.rename_to" | "process.execute" | "artifact.publish" | "vcs.push" | "vcs.force_push"; + +export type Outcome = "allow" | "deny" | "require" | "fault"; + +export type RequirementKind = "receipt_status" | "staged_hash_match" | "policy_hash_match" | "target_hash_match" | "human_approval"; + +export type ResourceKind = "file" | "tree" | "process" | "branch"; + +export interface SpecDoc { + protocol: string; + policy_id: string; + actors: string[]; + resources: ResourceDoc[]; + rules: RuleDoc[]; +} + +export interface ResourceDoc { + id: string; + kind: ResourceKind; + uri: string; +} + +export interface RuleDoc { + id: string; + effect: Effect; + actor: string; + operations: Operation[]; + resource: string; + requires?: Requirement[]; + reason?: string; +} + +export interface Policy { + protocol: string; + policy_id: string; + actors: string[]; + resources: Resource[]; + rules: Rule[]; +} + +export interface Resource { + id: string; + kind: ResourceKind; + uri: string; +} + +export interface Rule { + id: string; + effect: Effect; + actor: string; + operations: Operation[]; + resource: string; + requires?: Requirement[]; + reason?: string; +} + +export interface Requirement { + kind: RequirementKind; + receipt?: string; + status?: string; + approval?: string; +} + +export interface TargetResource { + kind: ResourceKind; + uri: string; +} + +export interface Observation { + source: string; + fidelity: Fidelity; +} + +export interface Evidence { + kind: RequirementKind; + receipt?: string; + status?: string; + value?: string; +} + +export interface EffectRequest { + protocol: string; + request_id: string; + run_id: string; + actor: string; + operation: Operation; + resource: TargetResource; + observation: Observation; + claimed_policy_hash?: string; + evidence?: Evidence[]; +} + +export interface Decision { + protocol: string; + request_id: string; + policy_hash: string; + outcome: Outcome; + rule_id?: string; + reason: string; + missing_evidence?: Requirement[]; +} + +export interface Receipt { + schema: string; + sequence: number; + run_id: string; + request_id: string; + request_hash: string; + policy_hash: string; + rule_id?: string; + outcome: Outcome; + evidence_hashes?: string[]; + prev_receipt_hash: string; + self_hash: string; +} + +export interface UpstreamReceipt { + path: string; +} + +export interface PublishRequest { + run_id: string; + request_id: string; + actor: string; + resource_uri: string; + kind: ResourceKind; + staged_path: string; + target_path: string; + expected_target_hash?: string; + upstream?: UpstreamReceipt[]; +} diff --git a/clients/typescript/tsconfig.json b/clients/typescript/tsconfig.json new file mode 100644 index 0000000..dcf2106 --- /dev/null +++ b/clients/typescript/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"], + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "examples/**/*.ts"] +} diff --git a/cmd/interlock/main.go b/cmd/interlock/main.go index 04cc384..c3561a6 100644 --- a/cmd/interlock/main.go +++ b/cmd/interlock/main.go @@ -14,10 +14,12 @@ import ( "path/filepath" "github.com/operatorstack/interlock/broker" + "github.com/operatorstack/interlock/compiler" "github.com/operatorstack/interlock/engine" "github.com/operatorstack/interlock/ir" "github.com/operatorstack/interlock/protocol" "github.com/operatorstack/interlock/receipt" + "github.com/operatorstack/interlock/spec" ) func main() { @@ -77,8 +79,9 @@ usage: 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 + interlock compile [-o out] compile an interlock.spec.v1 doc → canonical IR (no toolchain) + interlock check validate a policy (IR or spec.v1) and print its hash + interlock explain print a human-readable policy summary interlock decide evaluate one effect request interlock publish broker a protected publish interlock simulate -o decide a stream → receipt chain @@ -109,12 +112,34 @@ func cmdCompile(args []string) error { } } if dir == "" { - return fmt.Errorf("compile: want ") + return fmt.Errorf("compile: want (Go module) or (interlock.spec.v1)") } abs, err := filepath.Abs(dir) if err != nil { return err } + // A regular file is a spec.v1 (or canonical policy.v1) document: compile it + // in-process, no Go toolchain. This is the language-neutral compile authority + // and the parity reference every non-Go frontend is checked against. + if info, statErr := os.Stat(abs); statErr == nil && !info.IsDir() { + raw, rerr := os.ReadFile(abs) + if rerr != nil { + return rerr + } + pol, derr := decodePolicy(raw) + if derr != nil { + return fmt.Errorf("compile: %w", derr) + } + canon, cerr := pol.CanonicalBytes() + if cerr != nil { + return cerr + } + if out == "" { + os.Stdout.Write(canon) + return nil + } + return os.WriteFile(out, canon, 0o644) + } cmd := exec.Command("go", "run", ".") cmd.Dir = abs cmd.Stderr = os.Stderr @@ -325,15 +350,35 @@ func cmdDoctor(args []string) error { // helpers +// decodePolicy turns policy bytes into an ir.Policy, routing on the protocol tag: +// interlock.policy.v1 is already canonical IR (passed through unchanged), while +// interlock.spec.v1 is authoring input that is run through the real compiler — +// the same authority Go authoring uses. This is what lets the toolchain-free +// binary compile a spec.v1 document without `go run`, and makes spec.v1 the +// language-neutral input every consumer (check/explain/decide/test) accepts. func decodePolicy(b []byte) (ir.Policy, error) { - var p ir.Policy - if err := json.Unmarshal(b, &p); err != nil { + var probe struct { + Protocol string `json:"protocol"` + } + if err := json.Unmarshal(b, &probe); err != nil { return ir.Policy{}, err } - if p.Protocol != ir.Protocol { - return ir.Policy{}, fmt.Errorf("unexpected protocol %q (want %q)", p.Protocol, ir.Protocol) + switch probe.Protocol { + case ir.Protocol: + var p ir.Policy + if err := json.Unmarshal(b, &p); err != nil { + return ir.Policy{}, err + } + return p, nil + case spec.Protocol: + s, err := spec.DecodeToSpec(b) + if err != nil { + return ir.Policy{}, err + } + return compiler.Compile(s) + default: + return ir.Policy{}, fmt.Errorf("unexpected protocol %q (want %q or %q)", probe.Protocol, spec.Protocol, ir.Protocol) } - return p, nil } func loadPolicy(path string) (ir.Policy, error) { diff --git a/conformance/compat/compat.go b/conformance/compat/compat.go index 574d41a..554223a 100644 --- a/conformance/compat/compat.go +++ b/conformance/compat/compat.go @@ -34,6 +34,16 @@ type HashRecord struct { ExpectedHash string `json:"expected_hash"` } +// SpecRecord freezes one policy's interlock.spec.v1 authoring input: the +// on-corpus path to its spec.v1 document and the hash it must compile to. It is +// the cross-language parity manifest — every non-Go frontend reads the spec.v1, +// canonicalizes, and must reproduce ExpectedHash. +type SpecRecord struct { + Name string `json:"name"` + Spec string `json:"spec"` + ExpectedHash string `json:"expected_hash"` +} + // EnvelopeSpec is a frozen upstream-evidence envelope description. Bind controls // how the compat test binds artifact_sha256 to the staged bytes. type EnvelopeSpec struct { @@ -91,6 +101,20 @@ func Hashes(version string) ([]HashRecord, error) { return out, err } +// Specs loads the frozen spec.v1 parity records for a version. +func Specs(version string) ([]SpecRecord, error) { + var out []SpecRecord + err := readJSONL(version+"/specs.jsonl", func(b []byte) error { + var r SpecRecord + if err := json.Unmarshal(b, &r); err != nil { + return err + } + out = append(out, r) + return nil + }) + return out, err +} + // Decisions loads the frozen decision vectors for a version. They reuse the // conformance.Case shape (policy inline, request, expected outcome). func Decisions(version string) ([]conformance.Case, error) { diff --git a/conformance/compat/compat_test.go b/conformance/compat/compat_test.go index 7dbd598..a398d84 100644 --- a/conformance/compat/compat_test.go +++ b/conformance/compat/compat_test.go @@ -9,10 +9,12 @@ import ( "testing" "github.com/operatorstack/interlock/broker" + "github.com/operatorstack/interlock/compiler" "github.com/operatorstack/interlock/engine" "github.com/operatorstack/interlock/ir" "github.com/operatorstack/interlock/protocol" "github.com/operatorstack/interlock/receipt" + "github.com/operatorstack/interlock/spec" ) // TestCompatV010 re-derives the v0.1.0 corpus and fails with breaking-change @@ -23,11 +25,79 @@ import ( // - new additive vocabulary is fine as long as this corpus stays green. func TestCompatV010(t *testing.T) { t.Run("policy hashes", func(t *testing.T) { checkHashes(t, V010) }) + t.Run("spec parity", func(t *testing.T) { checkSpecParity(t, V010) }) t.Run("decisions", func(t *testing.T) { checkDecisions(t, V010) }) t.Run("broker vectors", func(t *testing.T) { checkBroker(t, V010) }) t.Run("replay chains", func(t *testing.T) { checkReplay(t, V010) }) } +// checkSpecParity is the parity oracle for every language authoring frontend: for +// each flagship, decode its frozen interlock.spec.v1 input, compile it through the +// real compiler, and assert the canonical bytes are byte-identical to the frozen +// policy.v1 file AND the hash equals the frozen expected hash. Any non-Go SDK +// reproduces exactly this: read the spec.v1 → canonicalize → hash → must match. +func checkSpecParity(t *testing.T, version string) { + records, err := Specs(version) + if err != nil { + t.Fatalf("load specs: %v", err) + } + if len(records) == 0 { + t.Fatal("no frozen spec parity records") + } + // Index the frozen IR files by policy name so we can compare byte-for-byte. + hashes, err := Hashes(version) + if err != nil { + t.Fatalf("load hashes: %v", err) + } + irFile := map[string]string{} + for _, h := range hashes { + irFile[h.Name] = h.Policy + } + + for _, r := range records { + raw, err := ReadFile(version, r.Spec) + if err != nil { + t.Errorf("%s: read spec: %v", r.Name, err) + continue + } + s, err := spec.DecodeToSpec(raw) + if err != nil { + t.Errorf("%s: decode spec.v1: %v", r.Name, err) + continue + } + pol, err := compiler.Compile(s) + if err != nil { + t.Errorf("%s: compile spec.v1: %v", r.Name, err) + continue + } + canon, err := pol.CanonicalBytes() + if err != nil { + t.Errorf("%s: canonical: %v", r.Name, err) + continue + } + + // Byte-parity with the frozen canonical IR. + if irRel, ok := irFile[r.Name]; ok { + _, frozenIR := loadPolicyFile(t, version, irRel) + if !bytes.Equal(canon, frozenIR) { + t.Errorf("PARITY FAILURE: %s spec.v1 compiles to different canonical bytes than the frozen IR\n"+ + " the spec.v1 authoring input must lower to exactly the frozen policy", r.Name) + continue + } + } + + got, err := pol.Hash() + if err != nil { + t.Errorf("%s: hash: %v", r.Name, err) + continue + } + if got != r.ExpectedHash { + t.Errorf("PARITY FAILURE: %s spec.v1 hash != frozen hash\n want %s\n got %s\n"+ + " every language frontend must reproduce this hash from this spec.v1", r.Name, r.ExpectedHash, got) + } + } +} + func loadPolicyFile(t *testing.T, version, relpath string) (ir.Policy, []byte) { t.Helper() raw, err := ReadFile(version, relpath) diff --git a/conformance/compat/gen/main.go b/conformance/compat/gen/main.go index c98fdf0..53c2216 100644 --- a/conformance/compat/gen/main.go +++ b/conformance/compat/gen/main.go @@ -23,15 +23,18 @@ import ( "github.com/operatorstack/interlock/ir" "github.com/operatorstack/interlock/protocol" "github.com/operatorstack/interlock/receipt" + "github.com/operatorstack/interlock/spec" ) const root = "conformance/compat/v0.1.0" func main() { must(os.MkdirAll(filepath.Join(root, "policies"), 0o755)) + must(os.MkdirAll(filepath.Join(root, "specs"), 0o755)) must(os.MkdirAll(filepath.Join(root, "receipts"), 0o755)) freezePoliciesAndHashes() + freezeSpecs() freezeDecisions() freezeBroker() freezeReceipts() @@ -39,6 +42,26 @@ func main() { fmt.Println("froze compat corpus at", root) } +// freezeSpecs writes each golden policy's interlock.spec.v1 authoring input to +// specs/.json. The spec is derived from the frozen canonical IR, so it +// re-compiles to byte-identical canonical bytes and the same frozen hash — this +// is the cross-language parity manifest: every non-Go frontend reads these +// spec.v1 documents, canonicalizes, and must reproduce the frozen hash. +func freezeSpecs() { + cases, err := conformance.GoldenHashes() + must(err) + f := create(filepath.Join(root, "specs.jsonl")) + defer f.Close() + enc := json.NewEncoder(f) + for _, c := range cases { + doc, err := spec.Encode(spec.FromPolicy(c.Policy)) + must(err) + file := filepath.Join("specs", c.Name+".json") + must(os.WriteFile(filepath.Join(root, file), doc, 0o644)) + must(enc.Encode(specRecord{Name: c.Name, Spec: file, ExpectedHash: c.ExpectedHash})) + } +} + // freezePoliciesAndHashes writes each golden policy's canonical bytes to // policies/.json and records its frozen hash in hashes.jsonl. func freezePoliciesAndHashes() { @@ -165,6 +188,12 @@ type hashRecord struct { ExpectedHash string `json:"expected_hash"` } +type specRecord struct { + Name string `json:"name"` + Spec string `json:"spec"` + ExpectedHash string `json:"expected_hash"` +} + type envelopeSpec struct { Schema string `json:"schema"` RunID string `json:"run_id"` diff --git a/conformance/compat/v0.1.0/specs.jsonl b/conformance/compat/v0.1.0/specs.jsonl new file mode 100644 index 0000000..8a4dbbb --- /dev/null +++ b/conformance/compat/v0.1.0/specs.jsonl @@ -0,0 +1,4 @@ +{"name":"exclusive-publish","spec":"specs/exclusive-publish.json","expected_hash":"sha256:edf4ed0d10b1aa9c0c2a0301688b3c97e34f6c0fc78502f4303466adb4ea82b3"} +{"name":"generated-file-protection","spec":"specs/generated-file-protection.json","expected_hash":"sha256:39906c29d55d59555fc4ffb56aa19d9ba324ec6311b52fe37bc358746f208cd3"} +{"name":"release-manifest","spec":"specs/release-manifest.json","expected_hash":"sha256:05301d110ba57ef7bd4dc9d75ab0b0d347ff9ab7bb96192a7a6bf6bad6f89578"} +{"name":"repository-policy","spec":"specs/repository-policy.json","expected_hash":"sha256:a34324d17a90d425091e921bd74dd0188bb2ae2ca85448f4b6906f6dff2c9303"} diff --git a/conformance/compat/v0.1.0/specs/exclusive-publish.json b/conformance/compat/v0.1.0/specs/exclusive-publish.json new file mode 100644 index 0000000..3ca2377 --- /dev/null +++ b/conformance/compat/v0.1.0/specs/exclusive-publish.json @@ -0,0 +1,67 @@ +{ + "protocol": "interlock.spec.v1", + "policy_id": "exclusive-publish.v1", + "actors": [ + "agent", + "publisher" + ], + "resources": [ + { + "id": "artifact", + "kind": "file", + "uri": "repo://out/result.json" + }, + { + "id": "workspace", + "kind": "tree", + "uri": "repo://work/**" + } + ], + "rules": [ + { + "id": "agent-workspace", + "effect": "allow", + "actor": "agent", + "operations": [ + "filesystem.delete", + "filesystem.write" + ], + "resource": "workspace", + "reason": "the agent may work freely in its own workspace" + }, + { + "id": "deny-agent-artifact", + "effect": "deny", + "actor": "agent", + "operations": [ + "artifact.publish", + "filesystem.write" + ], + "resource": "artifact", + "reason": "the producing agent may not touch the protected artifact" + }, + { + "id": "allow-publisher", + "effect": "allow", + "actor": "publisher", + "operations": [ + "artifact.publish" + ], + "resource": "artifact", + "requires": [ + { + "kind": "policy_hash_match" + }, + { + "kind": "staged_hash_match" + }, + { + "kind": "receipt_status", + "receipt": "deltawire.supervision.receipt.v1", + "status": "released" + } + ], + "reason": "the verified publisher may publish a staged candidate" + } + ] +} diff --git a/conformance/compat/v0.1.0/specs/generated-file-protection.json b/conformance/compat/v0.1.0/specs/generated-file-protection.json new file mode 100644 index 0000000..d51dc16 --- /dev/null +++ b/conformance/compat/v0.1.0/specs/generated-file-protection.json @@ -0,0 +1,82 @@ +{ + "protocol": "interlock.spec.v1", + "policy_id": "generated-file-protection.v1", + "actors": [ + "agent", + "publisher" + ], + "resources": [ + { + "id": "openapi", + "kind": "tree", + "uri": "repo://gen/openapi/**" + }, + { + "id": "pb-descriptors", + "kind": "tree", + "uri": "repo://gen/pb/**" + } + ], + "rules": [ + { + "id": "deny-agent-openapi", + "effect": "deny", + "actor": "agent", + "operations": [ + "artifact.publish", + "filesystem.delete", + "filesystem.write" + ], + "resource": "openapi", + "reason": "generated tree openapi is owned by the build, not the agent" + }, + { + "id": "publish-openapi", + "effect": "allow", + "actor": "publisher", + "operations": [ + "artifact.publish" + ], + "resource": "openapi", + "requires": [ + { + "kind": "policy_hash_match" + }, + { + "kind": "staged_hash_match" + } + ], + "reason": "the build publisher may refresh openapi" + }, + { + "id": "deny-agent-pb-descriptors", + "effect": "deny", + "actor": "agent", + "operations": [ + "artifact.publish", + "filesystem.delete", + "filesystem.write" + ], + "resource": "pb-descriptors", + "reason": "generated tree pb-descriptors is owned by the build, not the agent" + }, + { + "id": "publish-pb-descriptors", + "effect": "allow", + "actor": "publisher", + "operations": [ + "artifact.publish" + ], + "resource": "pb-descriptors", + "requires": [ + { + "kind": "policy_hash_match" + }, + { + "kind": "staged_hash_match" + } + ], + "reason": "the build publisher may refresh pb-descriptors" + } + ] +} diff --git a/conformance/compat/v0.1.0/specs/release-manifest.json b/conformance/compat/v0.1.0/specs/release-manifest.json new file mode 100644 index 0000000..84bf779 --- /dev/null +++ b/conformance/compat/v0.1.0/specs/release-manifest.json @@ -0,0 +1,67 @@ +{ + "protocol": "interlock.spec.v1", + "policy_id": "release-manifest.v1", + "actors": [ + "build-runner", + "release-bot" + ], + "resources": [ + { + "id": "manifest", + "kind": "file", + "uri": "repo://dist/release-manifest.json" + }, + { + "id": "staging", + "kind": "tree", + "uri": "repo://build/**" + } + ], + "rules": [ + { + "id": "runner-staging", + "effect": "allow", + "actor": "build-runner", + "operations": [ + "filesystem.delete", + "filesystem.write" + ], + "resource": "staging", + "reason": "the build runner may assemble the manifest in its own staging tree" + }, + { + "id": "deny-runner-manifest", + "effect": "deny", + "actor": "build-runner", + "operations": [ + "artifact.publish", + "filesystem.write" + ], + "resource": "manifest", + "reason": "the build runner may not touch the protected release manifest" + }, + { + "id": "allow-release-bot", + "effect": "allow", + "actor": "release-bot", + "operations": [ + "artifact.publish" + ], + "resource": "manifest", + "requires": [ + { + "kind": "policy_hash_match" + }, + { + "kind": "staged_hash_match" + }, + { + "kind": "receipt_status", + "receipt": "release.attestation.v1", + "status": "approved" + } + ], + "reason": "the verified release bot may publish an attested manifest" + } + ] +} diff --git a/conformance/compat/v0.1.0/specs/repository-policy.json b/conformance/compat/v0.1.0/specs/repository-policy.json new file mode 100644 index 0000000..3f57afe --- /dev/null +++ b/conformance/compat/v0.1.0/specs/repository-policy.json @@ -0,0 +1,102 @@ +{ + "protocol": "interlock.spec.v1", + "policy_id": "repository-policy.v1", + "actors": [ + "agent", + "sdk-generator" + ], + "resources": [ + { + "id": "generated", + "kind": "tree", + "uri": "repo://generated/**" + }, + { + "id": "main", + "kind": "branch", + "uri": "repo://branch/main" + }, + { + "id": "source", + "kind": "tree", + "uri": "repo://src/**" + } + ], + "rules": [ + { + "id": "agent-source", + "effect": "allow", + "actor": "agent", + "operations": [ + "filesystem.delete", + "filesystem.read", + "filesystem.rename_from", + "filesystem.rename_to", + "filesystem.write" + ], + "resource": "source", + "reason": "the agent may work freely in ordinary source code" + }, + { + "id": "deny-agent-generated", + "effect": "deny", + "actor": "agent", + "operations": [ + "filesystem.delete", + "filesystem.rename_to", + "filesystem.write" + ], + "resource": "generated", + "reason": "generated files must be produced by the verified SDK generator" + }, + { + "id": "publish-generated", + "effect": "allow", + "actor": "sdk-generator", + "operations": [ + "artifact.publish" + ], + "resource": "generated", + "requires": [ + { + "kind": "policy_hash_match" + }, + { + "kind": "staged_hash_match" + }, + { + "kind": "receipt_status", + "receipt": "sdk-tests", + "status": "passed" + } + ], + "reason": "the verified generator may publish generated files on passing tests" + }, + { + "id": "deny-force-push-main", + "effect": "deny", + "actor": "agent", + "operations": [ + "vcs.force_push" + ], + "resource": "main", + "reason": "force-pushing the protected branch is not permitted" + }, + { + "id": "push-main", + "effect": "allow", + "actor": "agent", + "operations": [ + "vcs.push" + ], + "resource": "main", + "requires": [ + { + "kind": "human_approval", + "approval": "release-main" + } + ], + "reason": "pushing the protected branch requires human approval" + } + ] +} diff --git a/emitspec_test.go b/emitspec_test.go new file mode 100644 index 0000000..66efbfc --- /dev/null +++ b/emitspec_test.go @@ -0,0 +1,92 @@ +package interlock + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/operatorstack/interlock/compiler" + "github.com/operatorstack/interlock/spec" +) + +// build is the repository-policy shape, exercising actors, every resource kind +// used in the flagships, allow/deny ordering, and all requirement variants. +func build() *Builder { + return Policy("emitspec.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(Read, Write, Delete, RenameFrom, RenameTo).On("source"). + Because("the agent may work freely in ordinary source code").Add(). + Deny("deny-agent-generated").By("agent"). + To(Write, Delete, RenameTo).On("generated"). + Because("generated files must be produced by the verified SDK generator").Add(). + Allow("publish-generated").By("sdk-generator"). + To(Publish).On("generated"). + Requiring(PolicyHashMatch(), StagedHashMatch(), ReceiptStatus("sdk-tests", "passed")). + Because("the verified generator may publish generated files on passing tests").Add(). + Deny("deny-force-push-main").By("agent"). + To(ForcePush).On("main"). + Because("force-pushing the protected branch is not permitted").Add(). + Allow("push-main").By("agent"). + To(Push).On("main"). + Requiring(HumanApproval("release-main")). + Because("pushing the protected branch requires human approval").Add() +} + +func hashBytes(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// TestEmitSpecCompilesToSameHash is the load-bearing guarantee for the Go +// authoring frontend: emitting spec.v1 and compiling it back must reproduce the +// exact canonical IR (and hash) that Emit() produces directly. spec.v1 is a +// lossless authoring layer over the one authority, not a second one. +func TestEmitSpecCompilesToSameHash(t *testing.T) { + direct, err := build().Emit() + if err != nil { + t.Fatalf("Emit: %v", err) + } + + specBytes, err := build().EmitSpec() + if err != nil { + t.Fatalf("EmitSpec: %v", err) + } + + decoded, err := spec.DecodeToSpec(specBytes) + if err != nil { + t.Fatalf("decode spec.v1: %v", err) + } + pol, err := compiler.Compile(decoded) + if err != nil { + t.Fatalf("compile decoded spec.v1: %v", err) + } + viaSpec, err := pol.CanonicalBytes() + if err != nil { + t.Fatalf("canonical bytes: %v", err) + } + + if hashBytes(direct) != hashBytes(viaSpec) { + t.Fatalf("hash mismatch:\n Emit() = %s\n spec.v1→compile = %s", hashBytes(direct), hashBytes(viaSpec)) + } + if string(direct) != string(viaSpec) { + t.Fatal("canonical bytes differ between Emit() and spec.v1 round-trip") + } +} + +// TestEmitSpecRejectsInvalid ensures EmitSpec runs the compiler first, so a +// structurally broken policy fails at emit time rather than producing a spec.v1 +// document that only breaks a downstream consumer. +func TestEmitSpecRejectsInvalid(t *testing.T) { + // References an undeclared actor — compiler must reject. + b := Policy("bad.v1"). + File("f", "repo://f"). + Allow("r").By("ghost").To(Write).On("f").Add() + if _, err := b.EmitSpec(); err == nil { + t.Fatal("expected EmitSpec to reject a policy with an undeclared actor") + } +} diff --git a/interlock.go b/interlock.go index dc79520..0329f55 100644 --- a/interlock.go +++ b/interlock.go @@ -182,3 +182,15 @@ func (b *Builder) Emit() ([]byte, error) { } return p.CanonicalBytes() } + +// EmitSpec compiles-to-validate, then returns the policy as serialized +// interlock.spec.v1 (the neutral authoring format), not canonical IR. It runs the +// compiler first so a structurally invalid policy fails here rather than emitting +// a spec.v1 document that only breaks downstream. This is the Go frontend's path +// to the same authoring target the JSON/TS/Python frontends emit. +func (b *Builder) EmitSpec() ([]byte, error) { + if _, err := b.Compile(); err != nil { + return nil, err + } + return spec.Encode(spec.FromSpec(b.s)) +} diff --git a/scaffold/readme.go b/scaffold/readme.go index 6990de7..2899154 100644 --- a/scaffold/readme.go +++ b/scaffold/readme.go @@ -14,7 +14,7 @@ func renderReadme(t Template, path string) string { b.WriteString(t.Summary + "\n\n") b.WriteString("This directory is a no-toolchain Interlock setup:\n\n") - b.WriteString("- `policy.json` — the canonical policy (a hashable, first-match decision table).\n") + b.WriteString("- `policy.json` — the policy authored as `interlock.spec.v1` (validated and lowered to a hashable, first-match decision table by the compiler).\n") b.WriteString("- `tests.jsonl` — one effect request per line with the outcome it must produce.\n") b.WriteString("- `README.md` — this file.\n\n") @@ -33,11 +33,11 @@ func renderReadme(t Template, path string) string { b.WriteString("\n") b.WriteString("## Edit it\n\n") - b.WriteString("Edit `policy.json` directly, then add or adjust vectors in `tests.jsonl` and re-run `interlock test`.\n") + b.WriteString("Edit `policy.json` (an `interlock.spec.v1` document) directly, then add or adjust vectors in `tests.jsonl` and re-run `interlock test`. Structural mistakes (unknown actor, unreachable rule) are reported by the compiler when you run it.\n") b.WriteString("A vector is `{\"name\": \"...\", \"request\": {...}, \"expect\": \"allow|deny|require\"}`; add `\"expect_rule_id\"` to also assert which rule fired.\n\n") b.WriteString("## Prefer to author in Go?\n\n") - b.WriteString("`interlock init --authoring go ` scaffolds a Go policy module instead. Go lets you use loops, helpers, tables, and reusable packages at construction time; it compiles to the **same** canonical IR, the **same** policy hash, and is decided by the **same** engine. Run `interlock compile -o policy.json` (needs a Go toolchain) to produce the JSON.\n") + b.WriteString("`interlock init --authoring go ` scaffolds a Go policy module instead. Go lets you use loops, helpers, tables, and reusable packages at construction time; it emits the **same** `interlock.spec.v1`, compiles to the **same** canonical IR, the **same** policy hash, and is decided by the **same** engine. Run `interlock compile -o policy.json` (needs a Go toolchain) to produce the JSON, or author the spec directly here with no toolchain at all.\n") return b.String() } diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index b03d443..37bf2bc 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -40,9 +40,11 @@ type Template struct { rules func(path string) []string // plain-English rule descriptions for the README } -// Policy returns the template's canonical policy bytes. path is used only by the +// Policy returns the template's authoring document as serialized +// interlock.spec.v1 — the neutral, no-toolchain authoring format that `interlock +// test`/`compile` run through the real compiler. path is used only by the // "custom" template (the protected glob); other templates ignore it. -func (t Template) Policy(path string) ([]byte, error) { return t.build(path).Emit() } +func (t Template) Policy(path string) ([]byte, error) { return t.build(path).EmitSpec() } // Vectors returns the template's test vectors for the given custom path. func (t Template) Vectors(path string) []Vector { return t.vectors(path) } diff --git a/spec/specdoc.go b/spec/specdoc.go new file mode 100644 index 0000000..681bd6e --- /dev/null +++ b/spec/specdoc.go @@ -0,0 +1,186 @@ +package spec + +// specdoc.go defines interlock.spec.v1: the serializable, versioned authoring +// format every language frontend emits. It is the neutral target that sits one +// level above the canonical IR (interlock.policy.v1). A SpecDoc is *authoring* +// data — human- or machine-written, not canonicalized; determinism and identity +// belong to the IR the compiler lowers it to. Decoding a SpecDoc yields the +// in-memory spec.Spec the compiler already consumes unchanged, so spec.v1 adds a +// (de)serialization layer around the existing authority, not a second authority. + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/operatorstack/interlock/ir" +) + +// Protocol is the schema tag stamped on every spec.v1 document. It is distinct +// from ir.Protocol ("interlock.policy.v1"): spec.v1 is the authoring input, +// policy.v1 is the compiled, canonical output. +const Protocol = "interlock.spec.v1" + +// SpecDoc is the serializable form of a policy under construction. Its JSON field +// names deliberately match the IR vocabulary already frozen in the corpus (e.g. +// "filesystem.write", "receipt_status") so a spec.v1 document reads naturally +// next to the policy.v1 it compiles to. Actors are a plain string array — the +// no-code authoring ergonomics win over the nested {"id": ...} shape, and the +// bridge to spec.Spec restores the typed form the compiler expects. +type SpecDoc struct { + Protocol string `json:"protocol"` + PolicyID string `json:"policy_id"` + Actors []string `json:"actors"` + Resources []ResourceDoc `json:"resources"` + Rules []RuleDoc `json:"rules"` +} + +// ResourceDoc is the serializable form of a declared capability target. +type ResourceDoc struct { + ID string `json:"id"` + Kind ir.ResourceKind `json:"kind"` + URI string `json:"uri"` +} + +// RuleDoc is the serializable form of one decision-table entry. Requirements +// reuse ir.Requirement, whose JSON tags (kind/receipt/status/approval) are +// already the frozen vocabulary. +type RuleDoc struct { + ID string `json:"id"` + Effect ir.Effect `json:"effect"` + Actor string `json:"actor"` + Operations []ir.Operation `json:"operations"` + Resource string `json:"resource"` + Requires []ir.Requirement `json:"requires,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// FromSpec projects a typed spec.Spec into its serializable spec.v1 form, +// stamping the protocol tag. It is the inverse of (SpecDoc).ToSpec. +func FromSpec(s Spec) SpecDoc { + doc := SpecDoc{ + Protocol: Protocol, + PolicyID: s.PolicyID, + } + for _, a := range s.Actors { + doc.Actors = append(doc.Actors, a.ID) + } + for _, r := range s.Resources { + doc.Resources = append(doc.Resources, ResourceDoc{ID: r.ID, Kind: r.Kind, URI: r.URI}) + } + for _, r := range s.Rules { + doc.Rules = append(doc.Rules, RuleDoc{ + ID: r.ID, + Effect: r.Effect, + Actor: r.Actor, + Operations: append([]ir.Operation(nil), r.Operations...), + Resource: r.Resource, + Requires: append([]ir.Requirement(nil), r.Requires...), + Reason: r.Reason, + }) + } + return doc +} + +// FromPolicy projects a compiled canonical IR policy back into spec.v1 authoring +// form. Because the compiler's lowering is idempotent (it re-sorts actors, +// resources, and each rule's operations, and preserves rule order), recompiling +// the result reproduces the exact same canonical bytes and hash. This makes a +// frozen policy a drift-proof source for its own spec.v1 parity input: the corpus +// spec.v1 documents are derived from the frozen IR, so they can never disagree +// with it. +func FromPolicy(p ir.Policy) SpecDoc { + doc := SpecDoc{ + Protocol: Protocol, + PolicyID: p.PolicyID, + Actors: append([]string(nil), p.Actors...), + } + for _, r := range p.Resources { + doc.Resources = append(doc.Resources, ResourceDoc{ID: r.ID, Kind: r.Kind, URI: r.URI}) + } + for _, r := range p.Rules { + doc.Rules = append(doc.Rules, RuleDoc{ + ID: r.ID, + Effect: r.Effect, + Actor: r.Actor, + Operations: append([]ir.Operation(nil), r.Operations...), + Resource: r.Resource, + Requires: append([]ir.Requirement(nil), r.Requires...), + Reason: r.Reason, + }) + } + return doc +} + +// ToSpec bridges a decoded spec.v1 document back to the in-memory spec.Spec the +// compiler consumes. It performs no validation beyond the shape already enforced +// by Decode — structural validity (unknown actors, bad effects, unreachable +// rules) is the compiler's job, so spec.v1 authoring and Go authoring hit the +// exact same authority. +func (d SpecDoc) ToSpec() Spec { + s := Spec{PolicyID: d.PolicyID} + for _, a := range d.Actors { + s.Actors = append(s.Actors, Actor{ID: a}) + } + for _, r := range d.Resources { + s.Resources = append(s.Resources, Resource{ID: r.ID, Kind: r.Kind, URI: r.URI}) + } + for _, r := range d.Rules { + s.Rules = append(s.Rules, Rule{ + ID: r.ID, + Effect: r.Effect, + Actor: r.Actor, + Operations: append([]ir.Operation(nil), r.Operations...), + Resource: r.Resource, + Requires: append([]ir.Requirement(nil), r.Requires...), + Reason: r.Reason, + }) + } + return s +} + +// Encode renders a SpecDoc as indented JSON with a trailing newline. This is the +// authoring form — readable and diff-friendly — NOT the canonical IR encoding. +// Canonicalization (sorted keys, no whitespace) applies only to the compiled +// policy.v1 the compiler produces, never to the spec.v1 input. +func Encode(doc SpecDoc) ([]byte, error) { + if doc.Protocol == "" { + doc.Protocol = Protocol + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(doc); err != nil { + return nil, fmt.Errorf("interlock/spec: encode: %w", err) + } + return buf.Bytes(), nil +} + +// Decode parses spec.v1 JSON into a SpecDoc, rejecting a missing or wrong +// protocol tag and any unknown field. Unknown-field rejection catches authoring +// typos (e.g. "requirements" for "requires") at decode time rather than letting +// them silently vanish — a no-code author gets a precise error, not a policy +// that quietly means something else. +func Decode(data []byte) (SpecDoc, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + var doc SpecDoc + if err := dec.Decode(&doc); err != nil { + return SpecDoc{}, fmt.Errorf("interlock/spec: decode: %w", err) + } + if doc.Protocol != Protocol { + return SpecDoc{}, fmt.Errorf("interlock/spec: unexpected protocol %q, want %q", doc.Protocol, Protocol) + } + return doc, nil +} + +// DecodeToSpec is the common path: parse spec.v1 JSON and bridge straight to the +// in-memory spec.Spec ready for compiler.Compile. +func DecodeToSpec(data []byte) (Spec, error) { + doc, err := Decode(data) + if err != nil { + return Spec{}, err + } + return doc.ToSpec(), nil +} diff --git a/spec/specdoc_test.go b/spec/specdoc_test.go new file mode 100644 index 0000000..8972389 --- /dev/null +++ b/spec/specdoc_test.go @@ -0,0 +1,133 @@ +package spec + +import ( + "testing" + + "github.com/operatorstack/interlock/ir" +) + +// sampleSpec exercises every field spec.v1 must round-trip: actors, all resource +// kinds, allow/deny rules, multi-op rules, and every requirement variant. +func sampleSpec() Spec { + return Spec{ + PolicyID: "sample.v1", + Actors: []Actor{{ID: "agent"}, {ID: "sdk-generator"}}, + Resources: []Resource{ + {ID: "source", Kind: ir.KindTree, URI: "repo://src/**"}, + {ID: "generated", Kind: ir.KindTree, URI: "repo://generated/**"}, + {ID: "main", Kind: ir.KindBranch, URI: "repo://branch/main"}, + }, + Rules: []Rule{ + { + ID: "agent-source", Effect: ir.EffectAllow, Actor: "agent", + Operations: []ir.Operation{ir.OpRead, ir.OpWrite}, Resource: "source", + Reason: "the agent may work freely in ordinary source code", + }, + { + ID: "deny-agent-generated", Effect: ir.EffectDeny, Actor: "agent", + Operations: []ir.Operation{ir.OpWrite}, Resource: "generated", + Reason: "generated files are owned by the build", + }, + { + ID: "publish-generated", Effect: ir.EffectAllow, Actor: "sdk-generator", + Operations: []ir.Operation{ir.OpPublish}, Resource: "generated", + Requires: []ir.Requirement{ + {Kind: ir.ReqPolicyHashMatch}, + {Kind: ir.ReqStagedHashMatch}, + {Kind: ir.ReqReceiptStatus, Receipt: "sdk-tests", Status: "passed"}, + }, + }, + { + ID: "push-main", Effect: ir.EffectAllow, Actor: "agent", + Operations: []ir.Operation{ir.OpPush}, Resource: "main", + Requires: []ir.Requirement{{Kind: ir.ReqHumanApproval, Approval: "release-main"}}, + }, + }, + } +} + +// TestRoundTrip is the load-bearing keystone check: Spec -> spec.v1 bytes -> +// Spec must reconstruct an identical in-memory spec, so the serializable format +// loses nothing the compiler depends on. +func TestRoundTrip(t *testing.T) { + orig := sampleSpec() + + data, err := Encode(FromSpec(orig)) + if err != nil { + t.Fatalf("encode: %v", err) + } + + got, err := DecodeToSpec(data) + if err != nil { + t.Fatalf("decode: %v", err) + } + + if !specsEqual(orig, got) { + t.Fatalf("round-trip mismatch:\n orig=%+v\n got=%+v", orig, got) + } +} + +// TestEncodeStampsProtocol ensures spec.v1 output always carries its schema tag, +// even if the caller built a SpecDoc without setting it. +func TestEncodeStampsProtocol(t *testing.T) { + data, err := Encode(SpecDoc{PolicyID: "x", Actors: []string{"a"}}) + if err != nil { + t.Fatalf("encode: %v", err) + } + if doc, err := Decode(data); err != nil { + t.Fatalf("decode of encoded doc: %v", err) + } else if doc.Protocol != Protocol { + t.Fatalf("protocol not stamped: %q", doc.Protocol) + } +} + +func TestDecodeRejectsWrongProtocol(t *testing.T) { + // A canonical IR document (interlock.policy.v1) must not decode as spec.v1. + if _, err := Decode([]byte(`{"protocol":"interlock.policy.v1","policy_id":"x"}`)); err == nil { + t.Fatal("expected error decoding policy.v1 as spec.v1") + } +} + +func TestDecodeRejectsUnknownField(t *testing.T) { + // A typo like "requirements" must fail loudly, not silently vanish. + in := `{"protocol":"interlock.spec.v1","policy_id":"x","requirements":[]}` + if _, err := Decode([]byte(in)); err == nil { + t.Fatal("expected error on unknown field") + } +} + +func specsEqual(a, b Spec) bool { + if a.PolicyID != b.PolicyID || len(a.Actors) != len(b.Actors) || + len(a.Resources) != len(b.Resources) || len(a.Rules) != len(b.Rules) { + return false + } + for i := range a.Actors { + if a.Actors[i] != b.Actors[i] { + return false + } + } + for i := range a.Resources { + if a.Resources[i] != b.Resources[i] { + return false + } + } + for i := range a.Rules { + ra, rb := a.Rules[i], b.Rules[i] + if ra.ID != rb.ID || ra.Effect != rb.Effect || ra.Actor != rb.Actor || + ra.Resource != rb.Resource || ra.Reason != rb.Reason || + len(ra.Operations) != len(rb.Operations) || len(ra.Requires) != len(rb.Requires) { + return false + } + for j := range ra.Operations { + if ra.Operations[j] != rb.Operations[j] { + return false + } + } + for j := range ra.Requires { + if ra.Requires[j] != rb.Requires[j] { + return false + } + } + } + return true +}