diff --git a/UPSTREAM.json b/UPSTREAM.json index a21910d..b2da47f 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -33,8 +33,9 @@ "clients/typescript/src/protocol.ts": "a5413e22897330135591f8c44a24eb64cdbfea7762f3f1d2e0866e1424ece145", "clients/typescript/tsconfig.json": "97647800a147a82d9cdadf00cd2b84d82e07512c9e55971654dd60ba21b4e061", "cmd/interlock/demo.go": "4593c47d679b455ae9800e1197f648071f4b85ec14f3a7f8b726414c91783764", + "cmd/interlock/derive.go": "6c2eda35cdf73d61f22dd3702999390e84c945416a0f699c709bf59ad42246cc", "cmd/interlock/init.go": "ea4df1ba3bcf0ec2f62cf02752b39598027376096ba974b2e770b0a79dc4ba0f", - "cmd/interlock/main.go": "c7e042f48d25c5b19925b729e6341c41de991a5b5b088cf859cbcf972fc56146", + "cmd/interlock/main.go": "0e4088a462c74963ae5c693b3f37e11dcc9ca0ce91fdc5b39ba6fae82d14ecae", "cmd/interlock/test.go": "e9b680cde45e061155dcc375b057f8ff4e69559d5f2be7dcd15f3685af0e1079", "cmd/interlock/verify.go": "5613c04febf6731fd25a75e615a524d57f99cb762016f458be5243034376b025", "cmd/interlock/version.go": "262fedc77a86623a48ee5a52940356a399fc71466d6da52f902cd655b9d7303d", @@ -63,6 +64,19 @@ "conformance/fixtures/hashes.jsonl": "7d33aea8dd5cc961d69f40cda0bf12353552a9e54d39a4e3c0b40e7e53dc2273", "conformance/fixtures/negative.jsonl": "0ef6dbe7df1879564ddb609f4823c440fd43514b92b5a1724babcdc8880a65cc", "conformance/fixtures/positive.jsonl": "0186ae490e1f9cb9ccfadb8f04dd88e1c6e75b1b578918c84314a6305bfc5e64", + "derive/adapters.go": "24aad0fb25b8a8b089248255387d9fe0112717c22247468558434e6d53df05da", + "derive/candidate.go": "73bd2b4d7fdf42745f5dcb27f51b5c01d02571745d48ad27b075dfbc8ff11471", + "derive/classify.go": "314194eeb06508a3fccc1b16e5c68e82c0e03debdf4bcdf31288d7874ce6fe27", + "derive/conflicts.go": "32d43044030b74c072aadd93a83497ff942ea0955e2d2775b40235e5bceb0184", + "derive/derive.go": "893ef79e63e2bd7fb98494cac99fa3a9b612c64e9f24f92faab6b136fa968ddb", + "derive/derive_conformance_test.go": "0460a3a414dac4510efad5cc4c6269fa141ea4020fbaec785e1722e4d70834f2", + "derive/derive_test.go": "d97c4a8bfbbb9f000de1056b352d52e913360d956563983d7bd3cec0830ed86c", + "derive/discover.go": "2d5f82b60ceb1780d8a117f6572c49ae693b7e710e3d50b9dd68d498c746eaef", + "derive/evidence.go": "406dd709eb9b02f3048be99e147044c93106d7c1e5200f31cb7f82956c589c4c", + "derive/ground.go": "24a9fb01387e6ea9bd732021f444bbd544f35750775bea132ba5f6e263cd4250", + "derive/report.go": "eb81df37497712fa513a35b69b8894e8db284ed138282687b042a7418b53a005", + "derive/review.go": "59349539a8dbb8664b89b0f2a3cabc50da03e3975727ccae36bda70d3968f704", + "derive/schema.go": "4d10bca81110512a10aaf6306d5a5a2ebc4193058b4500cd92022a99b42d4f1e", "doc.go": "ffda943422fc0104acff178f17f096df5d9d0e9065e598aa0d817c457edfb198", "emitspec_test.go": "b669fc73361f275311221ac450008963885801742fe14d47b12e61e677b67545", "engine/engine.go": "8ee1d012bbf9661288507056c7a015ab9d46fb16e9596d4717b38f15af748b8f", @@ -98,7 +112,7 @@ "generator": "operatorstack/interlock:project-upstream", "schema_version": 1, "source": { - "commit": "d06e6a0ab205b05e9ee1ce85ebd4a051b9cd8ff3", + "commit": "59ec27984383d0c956b8b6bc0ec26465d5cdd952", "path": "labs/21-interlock", "repository": "operatorstack/intelligence-flow" } diff --git a/cmd/interlock/derive.go b/cmd/interlock/derive.go new file mode 100644 index 0000000..dcd4111 --- /dev/null +++ b/cmd/interlock/derive.go @@ -0,0 +1,223 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/operatorstack/interlock/derive" +) + +const defaultDerivedDir = ".interlock/derived" + +// cmdDerive is the thin CLI for `interlock derive`: it owns flag parsing and file +// I/O only. All classification and grounding live in the derive package, which +// writes nothing. The command never activates policy — it writes a reviewable +// candidate under --output and prints the "not enforced" framing. +func cmdDerive(args []string) error { + repo := "." + outDir := defaultDerivedDir + format := "text" + force := false + nonInteractive := false + review := false + var from []string + var positional []string + + i := 0 + for i < len(args) { + switch args[i] { + case "--from": + if i+1 >= len(args) { + return fmt.Errorf("derive: --from wants a path") + } + from = append(from, args[i+1]) + i += 2 + case "--output", "-o": + if i+1 >= len(args) { + return fmt.Errorf("derive: --output wants a directory") + } + outDir = args[i+1] + i += 2 + case "--format": + if i+1 >= len(args) { + return fmt.Errorf("derive: --format wants text|json") + } + format = args[i+1] + i += 2 + case "--force", "-f": + force = true + i++ + case "--non-interactive": + nonInteractive = true + i++ + case "--review": + review = true + i++ + default: + if strings.HasPrefix(args[i], "-") { + return fmt.Errorf("derive: unexpected flag %q", args[i]) + } + positional = append(positional, args[i]) + i++ + } + } + if len(positional) > 1 { + return fmt.Errorf("derive: want at most one repository path") + } + if len(positional) == 1 { + repo = positional[0] + } + if format != "text" && format != "json" { + return fmt.Errorf("derive: --format wants text|json, got %q", format) + } + // Refuse to point --output at an active policy file (defense in depth; derive + // never writes a file named policy.json regardless). + if filepath.Base(outDir) == "policy.json" { + return fmt.Errorf("derive: --output must be a directory, not policy.json") + } + + if review { + return runReview(outDir, format, nonInteractive) + } + + res, err := derive.Derive(repo, from) + if err != nil { + return err + } + if err := writeResult(res, outDir, force); err != nil { + return err + } + return report(res, outDir, format) +} + +// runReview re-opens an existing candidate, walks its unresolved questions, and +// rewrites the candidate with the answers applied. It never activates policy. +func runReview(outDir, format string, nonInteractive bool) error { + if nonInteractive { + return fmt.Errorf("derive --review needs interactive stdin (omit --non-interactive)") + } + raw, err := os.ReadFile(filepath.Join(outDir, derive.FileDerivation)) + if err != nil { + return fmt.Errorf("derive --review: reading %s: %w (run `interlock derive` first)", derive.FileDerivation, err) + } + d, err := derive.DecodeDerivation(raw) + if err != nil { + return err + } + // Rebuild once to learn the freeze state (whether to ask the baseline question). + current, err := derive.Rebuild(d) + if err != nil { + return err + } + + answers := promptAnswers(d, current.Candidate.FreezeWarning) + updated := derive.ApplyAnswers(d, answers) + res, err := derive.Rebuild(updated) + if err != nil { + return err + } + if err := writeResult(res, outDir, true); err != nil { + return err + } + return report(res, outDir, format) +} + +// promptAnswers reads one answer per unresolved question from stdin, plus the +// baseline question when the candidate is deny-only. Blank input skips a question. +func promptAnswers(d derive.Derivation, freeze bool) map[string]string { + r := bufio.NewReader(os.Stdin) + answers := map[string]string{} + fmt.Println("Answer each question to ground it into the candidate. Press Enter to skip.") + fmt.Println() + for _, rec := range d.Records { + if rec.Status != derive.StatusUnresolved { + continue + } + fmt.Printf("[%s] %s:%d\n %q\n %s\n", rec.ID, rec.Source.Path, rec.Source.LineStart, rec.Excerpt, rec.Question) + fmt.Print(" answer> ") + line, _ := r.ReadString('\n') + answers[rec.ID] = strings.TrimSpace(line) + fmt.Println() + } + if freeze { + fmt.Println("[baseline] The candidate only denies; under default-deny that blocks everything.") + fmt.Println(" What baseline should the agent be allowed to read/write? (e.g. repo://src/**)") + fmt.Print(" answer> ") + line, _ := r.ReadString('\n') + answers["baseline"] = strings.TrimSpace(line) + fmt.Println() + } + return answers +} + +// writeResult writes all candidate artifacts atomically: it renders every file +// first, guards against overwrite, then writes. A render error leaves the output +// dir untouched (failure-state invariant). It never writes policy.json. +func writeResult(res derive.Result, outDir string, force bool) error { + files, err := res.Files() + if err != nil { + return err + } + policyPath := filepath.Join(outDir, derive.FileCandidatePolicy) + if _, err := os.Stat(policyPath); err == nil && !force { + return fmt.Errorf("derive: %s already exists (use --force to overwrite, or --review to refine)", policyPath) + } + if err := os.MkdirAll(outDir, 0o755); err != nil { + return err + } + // Stable write order for deterministic output. + for _, name := range []string{ + derive.FileCandidatePolicy, derive.FileCandidateTests, + derive.FileDerivation, derive.FileQuestions, derive.FileReadme, + } { + if err := os.WriteFile(filepath.Join(outDir, name), files[name], 0o644); err != nil { + return err + } + } + return nil +} + +// report prints the closing summary. The text form leads with the "not enforced" +// framing so no one mistakes a candidate for an active policy. +func report(res derive.Result, outDir, format string) error { + proposed, unresolved, rejected := 0, 0, 0 + for _, r := range res.Derivation.Records { + switch r.Status { + case derive.StatusProposed: + proposed++ + case derive.StatusUnresolved: + unresolved++ + case derive.StatusRejected: + rejected++ + } + } + if format == "json" { + return printJSON(map[string]any{ + "output": outDir, + "policy_id": res.Derivation.PolicyID, + "rules": res.Candidate.RuleCount, + "proposed": proposed, + "unresolved": unresolved, + "rejected": rejected, + "freeze": res.Candidate.FreezeWarning, + "enforced": false, + }) + } + fmt.Printf("Reviewed your repository and drafted a candidate Interlock policy.\n\n") + fmt.Printf(" %s/\n", outDir) + fmt.Printf(" %-22s %d proposed rule(s) — interlock.spec.v1\n", derive.FileCandidatePolicy, res.Candidate.RuleCount) + fmt.Printf(" %-22s test vectors for each rule\n", derive.FileCandidateTests) + fmt.Printf(" %-22s provenance for every record\n", derive.FileDerivation) + fmt.Printf(" %-22s %d unresolved question(s)\n", derive.FileQuestions, unresolved) + if rejected > 0 { + fmt.Printf("\n %d record(s) were rejected (conflict or would weaken an existing policy) — see %s.\n", rejected, derive.FileDerivation) + } + fmt.Printf("\nThese enforceable rules appear to be implied by your repository. Review and approve them.\n") + fmt.Printf("Nothing is enforced yet. To activate after review:\n") + fmt.Printf(" interlock derive --review\n") + fmt.Printf(" interlock compile %s -o .interlock/policy.json && interlock test\n", filepath.Join(outDir, derive.FileCandidatePolicy)) + return nil +} diff --git a/cmd/interlock/main.go b/cmd/interlock/main.go index c3561a6..f1f65d8 100644 --- a/cmd/interlock/main.go +++ b/cmd/interlock/main.go @@ -31,6 +31,8 @@ func main() { switch os.Args[1] { case "init": err = cmdInit(os.Args[2:]) + case "derive": + err = cmdDerive(os.Args[2:]) case "compile": err = cmdCompile(os.Args[2:]) case "check": @@ -76,6 +78,7 @@ usage: interlock init set up a no-toolchain JSON policy (interactive) interlock init --authoring json [dir] set up a JSON policy (dir defaults to .interlock) interlock init --authoring go scaffold a programmable Go policy module + interlock derive [repo] [--from PATH] [--output DIR] [--review] draft a candidate policy from a repo's existing instructions (never enforces) interlock test [dir] run the policy's tests (dir defaults to .interlock) interlock demo [name] narrate a built-in policy (default repository-policy; --list) interlock compile [-o policy.json] build+run a Go policy module → canonical IR diff --git a/derive/adapters.go b/derive/adapters.go new file mode 100644 index 0000000..f5f42eb --- /dev/null +++ b/derive/adapters.go @@ -0,0 +1,198 @@ +package derive + +import ( + "encoding/json" + "strings" +) + +// adapters.go holds the deterministic V1 source parsers. Each is a pure function +// from (path, content) to raw statements. They deliberately do NOT classify or +// ground — a prose adapter just yields lines and lets classify.go read the +// language; a machine-config adapter yields a Suggest hint + a Note describing the +// decision a human must make, so structured config becomes a question rather than +// a silently widened rule. + +// parseProse emits one statement per non-empty content line. It handles all +// free-text authority sources (AGENTS.md, CLAUDE.md, .cursor/rules/*, +// SKILL.md). Prose is weak evidence: only lines with an explicit imperative +// marker survive classification into an emittable rule. +func parseProse(path string, content []byte) []RawStatement { + var out []RawStatement + inFence := false + for i, line := range splitLines(content) { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { + inFence = !inFence + continue + } + if inFence || trimmed == "" { + continue + } + excerpt := cleanExcerpt(line) + if excerpt == "" { + continue + } + out = append(out, RawStatement{ + Path: path, + LineStart: i + 1, + LineEnd: i + 1, + Text: excerpt, + Strength: StrengthWeak, + }) + } + return out +} + +// parseCodeowners reads a CODEOWNERS file. Ownership is NOT an Interlock approval +// gate — treating it as one would be semantic widening — so each entry becomes an +// unresolved suggestion (Suggest=ClassUnresolved) carrying the owners in Note. +func parseCodeowners(path string, content []byte) []RawStatement { + var out []RawStatement + for i, line := range splitLines(content) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + fields := strings.Fields(trimmed) + if len(fields) < 2 { + continue + } + pattern, owners := fields[0], strings.Join(fields[1:], " ") + out = append(out, RawStatement{ + Path: path, + LineStart: i + 1, + LineEnd: i + 1, + Text: "changes under `" + pattern + "` are owned by " + owners, + Strength: StrengthStrong, + Suggest: ClassUnresolved, + Note: "CODEOWNERS assigns " + owners + " to `" + pattern + "`. Ownership is not the same as an enforced approval gate. " + + "Should writes under `" + pattern + "` require human approval?", + }) + } + return out +} + +// parseWorkflow scans a GitHub Actions workflow for job/step names that read like +// required checks. A CI check *existing* does not tell derive which receipt +// schema/status counts as evidence, so each becomes an unresolved verification +// question rather than a receipt requirement invented from nothing (invariant 5). +func parseWorkflow(path string, content []byte) []RawStatement { + var out []RawStatement + for i, line := range splitLines(content) { + trimmed := strings.TrimSpace(line) + lower := strings.ToLower(trimmed) + // Match "name: " job or step names. + if !strings.HasPrefix(lower, "name:") && !strings.HasPrefix(lower, "- name:") { + continue + } + if !containsWord(lower, "test", "lint", "build", "check", "verify", "ci") { + continue + } + name := strings.TrimSpace(strings.SplitN(trimmed, ":", 2)[1]) + name = strings.Trim(name, `"'`) + if name == "" { + continue + } + out = append(out, RawStatement{ + Path: path, + LineStart: i + 1, + LineEnd: i + 1, + Text: "CI defines a check named " + name, + Strength: StrengthStrong, + Suggest: ClassUnresolved, + Note: "The workflow defines a check named " + name + ". If a passing " + name + + " run should be required evidence before an effect, name the receipt schema and status it produces", + }) + } + return out +} + +// parsePackageScripts reads package.json scripts. A "test"/"publish" script is +// evidence a project *has* those operations, but not what must gate them, so each +// is an unresolved suggestion. +func parsePackageScripts(path string, content []byte) []RawStatement { + var doc struct { + Scripts map[string]string `json:"scripts"` + } + if err := json.Unmarshal(content, &doc); err != nil { + return nil + } + // Deterministic order over map keys. + names := make([]string, 0, len(doc.Scripts)) + for k := range doc.Scripts { + names = append(names, k) + } + sortStrings(names) + + var out []RawStatement + for _, name := range names { + lower := strings.ToLower(name) + if !containsWord(lower, "test", "publish", "release", "deploy", "lint") { + continue + } + out = append(out, RawStatement{ + Path: path, + LineStart: 1, + LineEnd: 1, + Text: "package.json defines a `" + name + "` script", + Strength: StrengthStrong, + Suggest: ClassUnresolved, + Note: "package.json defines a `" + name + "` script. Should any effect be gated on it? " + + "If so, name the operation, resource, and evidence", + }) + } + return out +} + +// parseMakefile reads Makefile targets, applying the same "operation exists, +// gate unknown" treatment as package scripts. +func parseMakefile(path string, content []byte) []RawStatement { + var out []RawStatement + for i, line := range splitLines(content) { + // A target line is "name:" at column 0 (not indented, not a variable). + if line == "" || line[0] == '\t' || line[0] == ' ' || line[0] == '#' { + continue + } + colon := strings.Index(line, ":") + if colon <= 0 { + continue + } + name := strings.TrimSpace(line[:colon]) + lower := strings.ToLower(name) + if strings.ContainsAny(name, " =") || !containsWord(lower, "test", "publish", "release", "deploy", "lint", "build") { + continue + } + out = append(out, RawStatement{ + Path: path, + LineStart: i + 1, + LineEnd: i + 1, + Text: "Makefile defines a `" + name + "` target", + Strength: StrengthStrong, + Suggest: ClassUnresolved, + Note: "The Makefile defines a `" + name + "` target. Should any effect be gated on it? " + + "If so, name the operation, resource, and evidence", + }) + } + return out +} + +// splitLines splits content into lines without a trailing empty element from a +// final newline, so line numbers are stable. +func splitLines(content []byte) []string { + s := strings.ReplaceAll(string(content), "\r\n", "\n") + lines := strings.Split(s, "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + lines = lines[:n-1] + } + return lines +} + +// sortStrings is a tiny local sort to avoid importing sort here (discover.go +// already imports it; keep this file's imports minimal and its behavior obvious). +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j-1] > s[j]; j-- { + s[j-1], s[j] = s[j], s[j-1] + } + } +} diff --git a/derive/candidate.go b/derive/candidate.go new file mode 100644 index 0000000..f2baba9 --- /dev/null +++ b/derive/candidate.go @@ -0,0 +1,295 @@ +package derive + +import ( + "sort" + "strings" + + il "github.com/operatorstack/interlock" + "github.com/operatorstack/interlock/ir" + "github.com/operatorstack/interlock/protocol" + "github.com/operatorstack/interlock/scaffold" +) + +// candidate.go assembles the grounded, proposed records into the SAME artifacts +// interlock init emits: an interlock.spec.v1 document (via il.Builder.EmitSpec, +// which runs the real compiler to validate) and scaffold.Vector test rows. This +// is the crux of "propose, never enforce": the candidate is authoring input that +// only becomes authority when a human runs it back through compiler.Compile. + +// Vector re-exports scaffold.Vector: derive emits the exact same test-row shape +// interlock init does, so `interlock test` reads a derived candidate unchanged. +type Vector = scaffold.Vector + +// Candidate is the emittable output of a derivation. +type Candidate struct { + Spec []byte + Vectors []Vector + // FreezeWarning is set when the candidate contains deny rules but no allow + // rule. Under Interlock's default-deny, such a policy would block everything, + // so derive surfaces a baseline question rather than inventing an allow rule + // (which would be unprovenanced authority). + FreezeWarning bool + RuleCount int +} + +// buildCandidate compiles the proposed records into a Candidate. A record is +// emitted only if it is StatusProposed and its class is Emittable — every other +// status (unresolved, rejected) is excluded, and every emitted rule carries a +// reason citing its source (invariant 1). +func buildCandidate(d Derivation) (Candidate, error) { + b := il.Policy(d.PolicyID).Actor(defaultActor) + + // Deterministic resource registry keyed by URI. + reg := newResReg() + var emitted []Record + for _, rec := range d.Records { + if rec.Status != StatusProposed || !rec.Class.Emittable() { + continue + } + if rec.ResourceURI == "" || len(rec.Operations) == 0 { + continue // defensive: never emit a rule missing scope + } + emitted = append(emitted, rec) + reg.add(rec.ResourceKind, rec.ResourceURI) + } + + // Declare resources in stable (kind-order, then URI) order. + for _, r := range reg.declared() { + switch r.kind { + case ir.KindFile: + b.File(r.id, r.uri) + case ir.KindTree: + b.Tree(r.id, r.uri) + case ir.KindProcess: + b.Process(r.id, r.uri) + case ir.KindBranch: + b.Branch(r.id, r.uri) + } + } + + var vectors []scaffold.Vector + hasAllow, hasDeny := false, false + for _, rec := range emitted { + resID := reg.idFor(rec.ResourceURI) + ruleID := ruleID(rec, resID) + rb := ruleBuilder(b, rec, ruleID, resID) + rb.Add() + if rec.Effect == ir.EffectDeny { + hasDeny = true + } else { + hasAllow = true + } + vectors = append(vectors, vectorsFor(rec, ruleID)...) + } + + specBytes, err := b.EmitSpec() + if err != nil { + return Candidate{}, err + } + return Candidate{ + Spec: specBytes, + Vectors: vectors, + FreezeWarning: hasDeny && !hasAllow, + RuleCount: len(emitted), + }, nil +} + +// ruleBuilder maps a record onto the fluent builder in the closed vocabulary. +func ruleBuilder(b *il.Builder, rec Record, ruleID, resID string) *il.RuleBuilder { + var rb *il.RuleBuilder + if rec.Effect == ir.EffectDeny { + rb = b.Deny(ruleID) + } else { + rb = b.Allow(ruleID) + } + rb = rb.By(rec.Actor).To(rec.Operations...).On(resID) + if rec.Requirement != nil { + rb = rb.Requiring(*rec.Requirement) + } + return rb.Because(rec.Reason) +} + +// ruleID builds a unique, readable rule id: effect-resource-recordID. The record +// id suffix guarantees uniqueness even when two rules touch one resource. +func ruleID(rec Record, resID string) string { + return string(rec.Effect) + "-" + resID + "-" + rec.ID +} + +// vectorsFor generates the conformance vectors for one emitted rule (invariant 5). +// +// - A deny rule gets a blocking vector (the cited effect → deny, attributed to +// this rule) and a scoping vector (a sibling URI this rule must NOT catch → +// default-deny with no rule id), proving the rule neither under- nor +// over-reaches its cited scope. +// - An allow+require rule gets a require vector (no evidence → require) and an +// allowed vector (with the approval evidence → allow), proving the gate both +// holds and opens. +func vectorsFor(rec Record, ruleID string) []scaffold.Vector { + op := rec.Operations[0] + member := memberURI(rec.ResourceKind, rec.ResourceURI) + + if rec.Effect == ir.EffectDeny { + return []scaffold.Vector{ + { + Name: "derived: " + rec.ID + " blocks " + string(op), + Request: request(rec.Actor, op, rec.ResourceKind, member), + Expect: protocol.OutcomeDeny, + ExpectRuleID: ruleID, + }, + { + Name: "derived: " + rec.ID + " does not over-reach", + Request: request(rec.Actor, op, rec.ResourceKind, scopingURI(rec.ResourceKind)), + Expect: protocol.OutcomeDeny, // default-deny, NOT attributed to this rule + }, + } + } + + // allow + human_approval + approval := "" + if rec.Requirement != nil { + approval = rec.Requirement.Approval + } + return []scaffold.Vector{ + { + Name: "derived: " + rec.ID + " requires approval for " + string(op), + Request: request(rec.Actor, op, rec.ResourceKind, member), + Expect: protocol.OutcomeRequire, + ExpectRuleID: ruleID, + }, + { + Name: "derived: " + rec.ID + " allows " + string(op) + " with approval", + Request: request(rec.Actor, op, rec.ResourceKind, member, evidenceApproval(approval)), + Expect: protocol.OutcomeAllow, + ExpectRuleID: ruleID, + }, + } +} + +// request builds a protocol.EffectRequest (local copy of scaffold's unexported +// req helper). +func request(actor string, op ir.Operation, kind ir.ResourceKind, uri string, ev ...protocol.Evidence) protocol.EffectRequest { + return protocol.EffectRequest{ + Protocol: protocol.EffectRequestProtocol, + RunID: "derive", + Actor: actor, + Operation: op, + Resource: protocol.TargetResource{Kind: kind, URI: uri}, + Evidence: ev, + } +} + +func evidenceApproval(id string) protocol.Evidence { + return protocol.Evidence{Kind: ir.ReqHumanApproval, Value: id} +} + +// memberURI turns a tree glob into a concrete member for a request; exact +// resources (files, branches) are used verbatim. +func memberURI(kind ir.ResourceKind, uri string) string { + if kind == ir.KindTree && strings.HasSuffix(uri, "**") { + return uri[:len(uri)-2] + "member" + } + return uri +} + +// scopingURI is a URI outside any declared resource, used to prove a deny rule +// does not over-reach. It matches nothing, so the engine returns default-deny +// with an empty rule id. +func scopingURI(kind ir.ResourceKind) string { + if kind == ir.KindBranch { + return "repo://branch/__derive_unscoped__" + } + return "repo://__derive_unscoped__/probe" +} + +// --- resource registry ---------------------------------------------------- + +type resEntry struct { + id, uri string + kind ir.ResourceKind +} + +type resReg struct { + byURI map[string]resEntry + ids map[string]bool +} + +func newResReg() *resReg { + return &resReg{byURI: map[string]resEntry{}, ids: map[string]bool{}} +} + +func (r *resReg) add(kind ir.ResourceKind, uri string) { + if _, ok := r.byURI[uri]; ok { + return + } + id := uniqueID(r.ids, resourceSlug(uri)) + r.ids[id] = true + r.byURI[uri] = resEntry{id: id, uri: uri, kind: kind} +} + +func (r *resReg) idFor(uri string) string { return r.byURI[uri].id } + +// declared returns the resource entries in a stable order (by kind order, then +// URI) so the emitted policy is deterministic. +func (r *resReg) declared() []resEntry { + out := make([]resEntry, 0, len(r.byURI)) + for _, e := range r.byURI { + out = append(out, e) + } + kindRank := map[ir.ResourceKind]int{ir.KindFile: 0, ir.KindTree: 1, ir.KindProcess: 2, ir.KindBranch: 3} + sort.Slice(out, func(i, j int) bool { + if kindRank[out[i].kind] != kindRank[out[j].kind] { + return kindRank[out[i].kind] < kindRank[out[j].kind] + } + return out[i].uri < out[j].uri + }) + return out +} + +// resourceSlug derives a readable resource id from a URI. +func resourceSlug(uri string) string { + s := strings.TrimPrefix(uri, "repo://") + s = strings.TrimSuffix(s, "/**") + s = strings.TrimSuffix(s, "/") + var b strings.Builder + prevDash := false + for _, c := range strings.ToLower(s) { + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { + b.WriteRune(c) + prevDash = false + } else if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + slug := strings.Trim(b.String(), "-") + if slug == "" { + return "resource" + } + return slug +} + +func uniqueID(taken map[string]bool, base string) string { + if !taken[base] { + return base + } + for i := 2; ; i++ { + candidate := base + "-" + itoa(i) + if !taken[candidate] { + return candidate + } + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/derive/classify.go b/derive/classify.go new file mode 100644 index 0000000..bf3f954 --- /dev/null +++ b/derive/classify.go @@ -0,0 +1,83 @@ +package derive + +import "strings" + +// classify.go is the deterministic V1 classifier: it reads the language of a +// statement and assigns it a Class. It makes no network call and consults no +// model. The Classifier interface (schema.go) exists so a later semantic +// implementation can slot in, but even then ground.go re-validates every proposal +// against the closed vocabulary and compiler.Compile is the final gate — no +// classifier output ever becomes authority on its own. + +// Marker sets, checked in precedence order. Precedence is deliberately biased +// toward the *most restrictive safe* reading: an explicit prohibition wins over +// everything (fail-closed), and advisory language can never be promoted past it. +var ( + // A hard prohibition. "should not"/"shouldn't" are included so plain + // "should" (advisory) does not swallow them. + prohibitionMarkers = []string{ + "must not", "must never", "never ", "do not ", "don't ", "does not ", + "cannot ", "can not ", "may not ", "not allowed", "not permitted", + "forbidden", "prohibited", "off-limits", "off limits", + "should not", "shouldn't", "no one may", "under no circumstances", + } + // An explicit human sign-off gate. + approvalMarkers = []string{ + "ask before", "require approval", "requires approval", "require human", + "human approval", "approval before", "approval to", "approval from", + "get approval", "sign-off", "sign off", "must be approved", + "only with approval", "without approval", "needs approval", + } + // A demand for evidence before an effect. + verificationMarkers = []string{ + "run tests before", "tests must pass", "must pass tests", "before pushing", + "ensure tests pass", "must pass ci", "ci must pass", "tests before you", + "before you push", "require passing", "only after tests", + } + // A preference, never authority. + advisoryMarkers = []string{ + "prefer", "preferably", "usually", "consider", "try to", "should ", + "recommend", "when possible", "ideally", "avoid ", "it's best", + "we like", "tend to", + } +) + +func containsAny(haystack string, needles []string) bool { + for _, n := range needles { + if strings.Contains(haystack, n) { + return true + } + } + return false +} + +// deterministicClassifier is the only V1 Classifier. It is pure. +type deterministicClassifier struct{} + +func (deterministicClassifier) Classify(text string) Class { + // Pad with spaces so word-boundary-ish markers ("never ", "avoid ") match at + // the start/end of the line too. + h := " " + strings.ToLower(text) + " " + switch { + case containsAny(h, prohibitionMarkers): + return ClassEnforceableEffect + case containsAny(h, approvalMarkers): + return ClassHumanDecision + case containsAny(h, verificationMarkers): + return ClassVerificationRequirement + case containsAny(h, advisoryMarkers): + return ClassAdvisoryGuidance + default: + return ClassDomainKnowledge + } +} + +// classify resolves a raw statement's class. An adapter's Suggest hint wins (a +// machine-config adapter knows its own shape); otherwise the deterministic +// classifier reads the prose. +func classify(raw RawStatement, c Classifier) Class { + if raw.Suggest != "" { + return raw.Suggest + } + return c.Classify(raw.Text) +} diff --git a/derive/conflicts.go b/derive/conflicts.go new file mode 100644 index 0000000..5cac34b --- /dev/null +++ b/derive/conflicts.go @@ -0,0 +1,85 @@ +package derive + +import ( + "github.com/operatorstack/interlock/ir" +) + +// conflicts.go enforces two fail-closed invariants: +// - Conflicting sources produce a conflict, never an inferred winner (7). +// - An existing active policy is never silently weakened (6). +// Both work by demoting affected records to StatusRejected with a reason, so they +// are recorded transparently in derivation.json but never emitted. + +// detectConflicts scans the proposed records for the same (actor, operation, +// resource URI) governed by opposing effects. When found, BOTH records are +// rejected — derive does not pick a winner. Mutates records in place. +func detectConflicts(records []Record) { + // key -> effects seen and the record indexes that produced them. + type acc struct { + effects map[ir.Effect]bool + idx []int + } + groups := map[string]*acc{} + for i := range records { + r := &records[i] + if r.Status != StatusProposed || !r.Class.Emittable() { + continue + } + for _, op := range r.Operations { + k := r.Actor + "|" + string(op) + "|" + r.ResourceURI + g := groups[k] + if g == nil { + g = &acc{effects: map[ir.Effect]bool{}} + groups[k] = g + } + g.effects[r.Effect] = true + g.idx = append(g.idx, i) + } + } + for _, g := range groups { + if len(g.effects) < 2 { + continue + } + for _, i := range g.idx { + records[i].Status = StatusRejected + records[i].RejectReason = "conflicting sources declare both allow and deny for the same actor/operation/resource; derive does not infer a winner" + } + } +} + +// checkWeakening rejects any proposed allow that would loosen a rule the existing +// active policy already denies. Derivation may only add restriction on top of an +// existing policy, never remove it. Mutates records in place. +func checkWeakening(records []Record, existing ir.Policy) { + // Build the set of (actor, operation, resourceURI) the existing policy denies. + denied := map[string]bool{} + uriByID := map[string]string{} + for _, res := range existing.Resources { + uriByID[res.ID] = res.URI + } + for _, rule := range existing.Rules { + if rule.Effect != ir.EffectDeny { + continue + } + uri := uriByID[rule.Resource] + for _, op := range rule.Operations { + denied[rule.Actor+"|"+string(op)+"|"+uri] = true + } + } + if len(denied) == 0 { + return + } + for i := range records { + r := &records[i] + if r.Status != StatusProposed || r.Effect != ir.EffectAllow { + continue + } + for _, op := range r.Operations { + if denied[r.Actor+"|"+string(op)+"|"+r.ResourceURI] { + r.Status = StatusRejected + r.RejectReason = "would weaken the existing active policy, which denies this actor/operation/resource; derivation never loosens an existing rule" + break + } + } + } +} diff --git a/derive/derive.go b/derive/derive.go new file mode 100644 index 0000000..189d297 --- /dev/null +++ b/derive/derive.go @@ -0,0 +1,182 @@ +package derive + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/operatorstack/interlock/ir" +) + +// derive.go is the package entrypoint: it runs the full deterministic pipeline +// (discover → classify → ground → conflicts/weakening → candidate) and renders +// the output artifacts. The command layer (cmd/interlock/derive.go) only handles +// flags and file I/O; everything decision-shaped lives here and is pure given the +// repository bytes, so the whole pipeline is unit- and conformance-testable. + +// Output file names. None is "policy.json": derive writes only candidates, never +// an active policy (invariant 8). +const ( + FileCandidatePolicy = "candidate.policy.json" + FileCandidateTests = "candidate.tests.jsonl" + FileDerivation = "derivation.json" + FileQuestions = "QUESTIONS.md" + FileReadme = "README.md" + + // activePolicyRel is the existing active policy derive diffs against for the + // weakening check — and never writes to. + activePolicyRel = ".interlock/policy.json" +) + +// Result is a completed derivation: the typed records and the emittable candidate. +type Result struct { + Derivation Derivation + Candidate Candidate +} + +// Derive runs the pipeline over a repository root. from, when non-empty, is the +// explicit source set; otherwise the default V1 sources are auto-discovered. It +// reads files but writes nothing — rendering and writing are the caller's job. +func Derive(root string, from []string) (Result, error) { + refs, err := discover(root, from) + if err != nil { + return Result{}, err + } + + var raws []RawStatement + explicit := len(from) > 0 + for _, ref := range refs { + content, rerr := os.ReadFile(ref.path) + if rerr != nil { + if explicit { + return Result{}, fmt.Errorf("interlock/derive: reading %s: %w", ref.relPath, rerr) + } + continue // auto-discovery tolerates a missing/unreadable candidate + } + for _, s := range ref.parse(ref.relPath, content) { + raws = append(raws, s) + } + } + + records := recordsFrom(raws) + detectConflicts(records) + if existing, ok := loadActivePolicy(root); ok { + checkWeakening(records, existing) + } + + d := Derivation{Schema: DerivationSchema, PolicyID: DefaultPolicyID, Records: records} + cand, err := buildCandidate(d) + if err != nil { + return Result{}, err + } + return Result{Derivation: d, Candidate: cand}, nil +} + +// Rebuild re-assembles the candidate from a (possibly review-updated) Derivation +// without re-reading the repository. It is the second half of the --review flow: +// load derivation.json → ApplyAnswers → Rebuild → write. It runs the same +// buildCandidate as a fresh derive, so a reviewed candidate is byte-identical to +// one that had been fully grounded from the start. +func Rebuild(d Derivation) (Result, error) { + cand, err := buildCandidate(d) + if err != nil { + return Result{}, err + } + return Result{Derivation: d, Candidate: cand}, nil +} + +// DecodeDerivation parses a derivation.json document (fail-closed) for the +// --review flow. +func DecodeDerivation(b []byte) (Derivation, error) { return decodeDerivation(b) } + +// recordsFrom sorts raw statements into a stable order, then classifies and +// grounds each into a Record with a deterministic id. Sorting before id +// assignment is what makes `--from A B` == `--from B A` (invariant 10). +func recordsFrom(raws []RawStatement) []Record { + sort.SliceStable(raws, func(i, j int) bool { + a, b := raws[i], raws[j] + if a.Path != b.Path { + return a.Path < b.Path + } + if a.LineStart != b.LineStart { + return a.LineStart < b.LineStart + } + if a.LineEnd != b.LineEnd { + return a.LineEnd < b.LineEnd + } + return a.Text < b.Text + }) + + c := deterministicClassifier{} + records := make([]Record, 0, len(raws)) + for i, raw := range raws { + excerpt := cleanExcerpt(raw.Text) + if excerpt == "" { + continue + } + rec := Record{ + ID: "r" + itoa(i+1), + Source: makeSource(raw, excerpt), + Excerpt: excerpt, + Class: classify(raw, c), + Strength: raw.Strength, + } + ground(&rec, raw.Note) + records = append(records, rec) + } + return records +} + +// loadActivePolicy loads the existing canonical active policy for the weakening +// check. A missing file (the common case) or a non-canonical document yields +// ok=false — the check simply does not run, and derive never touches the file. +func loadActivePolicy(root string) (ir.Policy, bool) { + b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(activePolicyRel))) + if err != nil { + return ir.Policy{}, false + } + p, err := ir.LoadPolicy(b) + if err != nil { + return ir.Policy{}, false + } + return p, true +} + +// Files renders the result's output artifacts as a name→bytes map. The caller +// writes them atomically (all-or-nothing) so a failure never leaves a partial +// candidate (invariant 8, failure-state). +func (r Result) Files() (map[string][]byte, error) { + derivationJSON, err := encodeDerivation(r.Derivation) + if err != nil { + return nil, err + } + tests, err := renderTests(r.Candidate.Vectors) + if err != nil { + return nil, err + } + return map[string][]byte{ + FileCandidatePolicy: r.Candidate.Spec, + FileCandidateTests: tests, + FileDerivation: derivationJSON, + FileQuestions: renderQuestions(r.Derivation, r.Candidate.FreezeWarning), + FileReadme: renderReadme(r.Derivation, r.Candidate), + }, nil +} + +// renderTests writes the candidate test vectors as JSONL with a comment header, +// matching the shape `interlock test` reads (it skips lines starting with '#'). +func renderTests(vectors []Vector) ([]byte, error) { + var buf bytes.Buffer + buf.WriteString("# derived candidate tests — review, then: interlock test\n") + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + for _, v := range vectors { + if err := enc.Encode(v); err != nil { + return nil, err + } + } + return buf.Bytes(), nil +} diff --git a/derive/derive_conformance_test.go b/derive/derive_conformance_test.go new file mode 100644 index 0000000..444100f --- /dev/null +++ b/derive/derive_conformance_test.go @@ -0,0 +1,308 @@ +package derive + +// control-law: derivation-proposes-never-enforces +// +// Boundary: the transition from advisory repository intent (prose + machine +// config) to enforceable Interlock authority (a rule in the active canonical +// policy the engine decides on). The boundary has a checkpoint (derive → a +// candidate, never active) and a gate (explicit human promotion → compiler.Compile +// → active). +// +// Control law: a derived artifact may only be a candidate — grounded in cited +// evidence, expressed strictly within the closed V1 vocabulary, neither broader +// nor narrower than its source. Derivation never activates policy, never +// manufactures authority from advisory language, and never weakens an existing +// policy. +// +// This suite realizes the five conformance categories (positive, negative, +// relation, bypass, failure-state) plus determinism/parity, each asserting one or +// more of the decomposed invariants. It exercises the REAL compiler and engine — +// no mock authority — so a passing candidate is one the production decision path +// accepts. + +import ( + "strings" + "testing" + + "github.com/operatorstack/interlock/compiler" + "github.com/operatorstack/interlock/engine" + "github.com/operatorstack/interlock/ir" + "github.com/operatorstack/interlock/protocol" + "github.com/operatorstack/interlock/spec" +) + +// --- POSITIVE: a grounded prohibition becomes a proven deny rule ------------- + +func TestConformance_Positive_GroundedProhibition(t *testing.T) { + root := writeRepo(t, map[string]string{ + "AGENTS.md": "# Agent rules\n\n- Never force-push the main branch.\n", + }) + res := mustDerive(t, root, nil) + + rec := findProposed(res.Derivation, func(r Record) bool { + return r.Effect == ir.EffectDeny && hasOp(r.Operations, ir.OpForcePush) && r.ResourceURI == "repo://branch/main" + }) + if rec == nil { + t.Fatalf("expected a proposed deny on force_push@repo://branch/main, got records: %+v", res.Derivation.Records) + } + // Invariant 1: no emitted rule without source{path,line,sha256} provenance. + if rec.Source.Path == "" || rec.Source.LineStart == 0 || !strings.HasPrefix(rec.Source.SHA256, "sha256:") { + t.Fatalf("emitted rule lacks provenance: %+v", rec.Source) + } + if res.Candidate.RuleCount != 1 { + t.Fatalf("want exactly 1 emitted rule, got %d", res.Candidate.RuleCount) + } + // Invariant 9: the candidate compiles through the real compiler and its + // vectors pass the real engine (blocking + scoping). + decideVectors(t, res.Candidate) +} + +// --- NEGATIVE: advisory/domain/ambiguous never become authority -------------- + +func TestConformance_Negative_NoUnfoundedAuthority(t *testing.T) { + root := writeRepo(t, map[string]string{ + "AGENTS.md": strings.Join([]string{ + "# Agent rules", + "", + "- Prefer functional components over class components.", // advisory (invariant 3) + "- The API uses OAuth 2.0 for authentication.", // domain + "- Ask before publishing a release.", // effect, but resource ambiguous (invariant 4) + "", + }, "\n"), + }) + res := mustDerive(t, root, nil) + + if res.Candidate.RuleCount != 0 { + t.Fatalf("no rule should be emitted from advisory/domain/ambiguous text, got %d", res.Candidate.RuleCount) + } + // Advisory and domain lines must not surface as effect rules at all. + for _, r := range res.Derivation.Records { + if r.Class == ClassAdvisoryGuidance || r.Class == ClassDomainKnowledge { + if r.Status == StatusProposed { + t.Fatalf("advisory/domain became proposed: %+v", r) + } + } + } + // The ambiguous approval line must be an unresolved question, not a rule. + q := findRecord(res.Derivation, func(r Record) bool { + return r.Class == ClassHumanDecision && r.Status == StatusUnresolved + }) + if q == nil || q.Question == "" { + t.Fatalf("ambiguous approval statement should be an unresolved question, got: %+v", res.Derivation.Records) + } +} + +// --- RELATION: request → boundary → decision reaches the real authorities ---- + +func TestConformance_Relation_EndToEndReachesEngine(t *testing.T) { + root := writeRepo(t, map[string]string{ + "AGENTS.md": "- Do not edit generated files.\n", + }) + res := mustDerive(t, root, nil) + if res.Candidate.RuleCount == 0 { + t.Fatal("expected a rule from an explicit prohibition") + } + // Compile the candidate spec through the REAL compiler, then decide a request + // that the derived rule must block — proving the artifact reaches production + // authority, not a stand-in. + pol := compileCandidate(t, res.Candidate) + d := engine.Decide(pol, request("agent", ir.OpWrite, ir.KindTree, "repo://generated/client.ts")) + if d.Outcome != protocol.OutcomeDeny { + t.Fatalf("derived rule did not block a generated-file write: %s", d.Outcome) + } +} + +// --- BYPASS: a candidate cannot reach the active table except via the compiler - + +func TestConformance_Bypass_NeverWritesActivePolicy(t *testing.T) { + root := writeRepo(t, map[string]string{ + "AGENTS.md": "- Never force-push the main branch.\n", + }) + res := mustDerive(t, root, nil) + files, err := res.Files() + if err != nil { + t.Fatal(err) + } + // Invariant 8: derive emits candidates only; no artifact is a policy.json. + for name := range files { + if name == "policy.json" || strings.HasSuffix(name, "/policy.json") { + t.Fatalf("derive emitted an active policy file %q", name) + } + } + if _, ok := files[FileCandidatePolicy]; !ok { + t.Fatal("candidate policy missing from output") + } + // The candidate is authoring input (spec.v1), not canonical IR — it only + // becomes authority by running through compiler.Compile. + if _, err := ir.LoadPolicy(files[FileCandidatePolicy]); err == nil { + t.Fatal("candidate.policy.json decoded as canonical IR; it must be spec.v1 that requires compilation") + } + if _, err := spec.DecodeToSpec(files[FileCandidatePolicy]); err != nil { + t.Fatalf("candidate.policy.json is not valid spec.v1: %v", err) + } +} + +// --- FAILURE-STATE: rejection/conflict leaves existing authority untouched ---- + +func TestConformance_FailureState_ConflictRejectsBothNoEmit(t *testing.T) { + root := writeRepo(t, map[string]string{ + "AGENTS.md": strings.Join([]string{ + "- Never force-push the main branch.", + "- Require approval to force-push the main branch.", + "", + }, "\n"), + }) + res := mustDerive(t, root, nil) + + // Invariant 7: conflicting sources → both rejected, no inferred winner. + if res.Candidate.RuleCount != 0 { + t.Fatalf("conflict must not emit any rule, got %d", res.Candidate.RuleCount) + } + rejected := 0 + for _, r := range res.Derivation.Records { + if r.Status == StatusRejected { + rejected++ + if r.RejectReason == "" { + t.Fatalf("rejected record missing reason: %+v", r) + } + } + } + if rejected < 2 { + t.Fatalf("expected both conflicting records rejected, got %d rejected", rejected) + } +} + +func TestConformance_FailureState_ExistingPolicyUnchanged(t *testing.T) { + // An active policy that denies the agent writing generated files. + existing := activeGeneratedPolicy(t) + hashBefore, err := existing.Hash() + if err != nil { + t.Fatal(err) + } + canon, err := existing.CanonicalBytes() + if err != nil { + t.Fatal(err) + } + root := writeRepo(t, map[string]string{ + "AGENTS.md": "- Never force-push the main branch.\n", + ".interlock/policy.json": string(canon), + }) + + // Derive reads the active policy (for the weakening check) but writes nothing. + _ = mustDerive(t, root, nil) + + after := readFile(t, root, ".interlock/policy.json") + reloaded, err := ir.LoadPolicy(after) + if err != nil { + t.Fatal(err) + } + hashAfter, err := reloaded.Hash() + if err != nil { + t.Fatal(err) + } + if hashBefore != hashAfter { + t.Fatalf("active policy hash changed: %s -> %s", hashBefore, hashAfter) + } + if string(after) != string(canon) { + t.Fatal("active policy bytes changed during derive") + } +} + +// --- DETERMINISM / PARITY ----------------------------------------------------- + +func TestConformance_Determinism_StableAndOrderIndependent(t *testing.T) { + files := map[string]string{ + "AGENTS.md": "- Never force-push the main branch.\n", + "CLAUDE.md": "- Do not edit generated files.\n", + } + root := writeRepo(t, files) + + // Repeated derive → byte-identical candidate + derivation (invariant 10). + a := mustDerive(t, root, nil) + b := mustDerive(t, root, nil) + if string(a.Candidate.Spec) != string(b.Candidate.Spec) { + t.Fatal("candidate spec not deterministic across runs") + } + da, _ := encodeDerivation(a.Derivation) + db, _ := encodeDerivation(b.Derivation) + if string(da) != string(db) { + t.Fatal("derivation not deterministic across runs") + } + + // --from order independence. + o1 := mustDerive(t, root, []string{"AGENTS.md", "CLAUDE.md"}) + o2 := mustDerive(t, root, []string{"CLAUDE.md", "AGENTS.md"}) + if string(o1.Candidate.Spec) != string(o2.Candidate.Spec) { + t.Fatal("candidate spec depends on --from order") + } +} + +// --- shared helpers ----------------------------------------------------------- + +func decideVectors(t *testing.T, cand Candidate) { + t.Helper() + pol := compileCandidate(t, cand) + for _, v := range cand.Vectors { + req := v.Request + if v.UsePolicyHash { + h, err := pol.Hash() + if err != nil { + t.Fatal(err) + } + req.ClaimedPolicyHash = h + } + d := engine.Decide(pol, req) + if d.Outcome != v.Expect { + t.Fatalf("vector %q: outcome %s, want %s", v.Name, d.Outcome, v.Expect) + } + if v.ExpectRuleID != "" && d.RuleID != v.ExpectRuleID { + t.Fatalf("vector %q: rule %q, want %q", v.Name, d.RuleID, v.ExpectRuleID) + } + // A scoping vector (empty ExpectRuleID, deny) must be default-deny — proof + // the deny rule does not over-reach (invariant 2). + if v.ExpectRuleID == "" && v.Expect == protocol.OutcomeDeny && d.RuleID != "" { + t.Fatalf("vector %q: scoping request matched rule %q; deny over-reaches", v.Name, d.RuleID) + } + } +} + +func compileCandidate(t *testing.T, cand Candidate) ir.Policy { + t.Helper() + s, err := spec.DecodeToSpec(cand.Spec) + if err != nil { + t.Fatalf("candidate spec.v1 invalid: %v", err) + } + pol, err := compiler.Compile(s) + if err != nil { + t.Fatalf("candidate does not compile through the real compiler: %v", err) + } + return pol +} + +func activeGeneratedPolicy(t *testing.T) ir.Policy { + t.Helper() + s := spec.Spec{ + PolicyID: "active.v1", + Actors: []spec.Actor{{ID: "agent"}}, + Resources: []spec.Resource{ + {ID: "generated", Kind: ir.KindTree, URI: "repo://generated/**"}, + }, + Rules: []spec.Rule{ + {ID: "deny-generated", Effect: ir.EffectDeny, Actor: "agent", Operations: []ir.Operation{ir.OpWrite}, Resource: "generated", Reason: "active policy"}, + }, + } + pol, err := compiler.Compile(s) + if err != nil { + t.Fatal(err) + } + return pol +} + +func hasOp(ops []ir.Operation, want ir.Operation) bool { + for _, o := range ops { + if o == want { + return true + } + } + return false +} diff --git a/derive/derive_test.go b/derive/derive_test.go new file mode 100644 index 0000000..a27fa37 --- /dev/null +++ b/derive/derive_test.go @@ -0,0 +1,262 @@ +package derive + +import ( + "os" + "path/filepath" + "testing" + + "github.com/operatorstack/interlock/ir" +) + +// derive_test.go holds the unit tests for the pure pipeline stages (classify, +// ground, conflicts, weakening, review) plus the shared test helpers the +// conformance suite also uses. Everything here runs against in-memory bytes; no +// stage reaches the network or a model. + +// --- classify --------------------------------------------------------------- + +func TestClassify_Precedence(t *testing.T) { + c := deterministicClassifier{} + cases := []struct { + text string + want Class + }{ + {"Never force-push the main branch.", ClassEnforceableEffect}, + {"Do not edit generated files.", ClassEnforceableEffect}, + {"Require approval before publishing.", ClassHumanDecision}, + {"Run tests before pushing.", ClassVerificationRequirement}, + {"Prefer functional components over class components.", ClassAdvisoryGuidance}, + {"The API uses OAuth 2.0 for authentication.", ClassDomainKnowledge}, + } + for _, tc := range cases { + if got := c.Classify(tc.text); got != tc.want { + t.Errorf("Classify(%q) = %s, want %s", tc.text, got, tc.want) + } + } +} + +func TestClassify_SuggestOverrides(t *testing.T) { + c := deterministicClassifier{} + raw := RawStatement{Text: "Never force-push main.", Suggest: ClassUnresolved} + if got := classify(raw, c); got != ClassUnresolved { + t.Fatalf("Suggest should override classifier, got %s", got) + } +} + +// --- ground ----------------------------------------------------------------- + +func TestGround_Effect_Prohibition(t *testing.T) { + rec := Record{Class: ClassEnforceableEffect, Excerpt: "Never force-push the main branch."} + ground(&rec, "") + if rec.Status != StatusProposed { + t.Fatalf("grounded prohibition should be proposed, got %s (%s)", rec.Status, rec.Question) + } + if rec.Effect != ir.EffectDeny { + t.Fatalf("effect = %s, want deny", rec.Effect) + } + if !hasOp(rec.Operations, ir.OpForcePush) { + t.Fatalf("operations = %v, want force_push", rec.Operations) + } + if rec.ResourceKind != ir.KindBranch || rec.ResourceURI != "repo://branch/main" { + t.Fatalf("resource = %s %s, want branch repo://branch/main", rec.ResourceKind, rec.ResourceURI) + } +} + +func TestGround_HumanDecision_Grounded(t *testing.T) { + rec := Record{Class: ClassHumanDecision, Excerpt: "Require approval to force-push the main branch."} + ground(&rec, "") + if rec.Status != StatusProposed { + t.Fatalf("grounded approval should be proposed, got %s (%s)", rec.Status, rec.Question) + } + if rec.Effect != ir.EffectAllow { + t.Fatalf("effect = %s, want allow", rec.Effect) + } + if rec.Requirement == nil || rec.Requirement.Kind != ir.ReqHumanApproval { + t.Fatalf("requirement = %+v, want human_approval", rec.Requirement) + } +} + +func TestGround_HumanDecision_AmbiguousResource(t *testing.T) { + rec := Record{Class: ClassHumanDecision, Excerpt: "Ask before publishing a release."} + ground(&rec, "") + if rec.Status != StatusUnresolved { + t.Fatalf("approval with no citable resource should be unresolved, got %s", rec.Status) + } + if rec.Question == "" { + t.Fatal("unresolved record must carry a question") + } +} + +func TestGround_Verification_AlwaysUnresolved(t *testing.T) { + // v1 never infers a verifier — a verification requirement is always a question. + rec := Record{Class: ClassVerificationRequirement, Excerpt: "All changes must pass the test suite."} + ground(&rec, "") + if rec.Status != StatusUnresolved { + t.Fatalf("verification should be unresolved in v1, got %s", rec.Status) + } +} + +func TestGround_Advisory_NotEmittable(t *testing.T) { + rec := Record{Class: ClassAdvisoryGuidance, Excerpt: "Prefer functional components."} + ground(&rec, "") + if rec.Status == StatusProposed { + t.Fatal("advisory guidance must never be proposed") + } +} + +// --- conflicts / weakening -------------------------------------------------- + +func TestDetectConflicts_RejectsBoth(t *testing.T) { + records := []Record{ + {ID: "r1", Status: StatusProposed, Class: ClassEnforceableEffect, Actor: "agent", + Effect: ir.EffectDeny, Operations: []ir.Operation{ir.OpForcePush}, ResourceURI: "repo://branch/main"}, + {ID: "r2", Status: StatusProposed, Class: ClassHumanDecision, Actor: "agent", + Effect: ir.EffectAllow, Operations: []ir.Operation{ir.OpForcePush}, ResourceURI: "repo://branch/main"}, + } + detectConflicts(records) + for _, r := range records { + if r.Status != StatusRejected || r.RejectReason == "" { + t.Fatalf("record %s not rejected on conflict: %+v", r.ID, r) + } + } +} + +func TestCheckWeakening_RejectsAllowOverExistingDeny(t *testing.T) { + existing := activeGeneratedPolicy(t) // denies agent write on repo://generated/** + records := []Record{ + {ID: "r1", Status: StatusProposed, Class: ClassHumanDecision, Actor: "agent", + Effect: ir.EffectAllow, Operations: []ir.Operation{ir.OpWrite}, ResourceURI: "repo://generated/**"}, + } + checkWeakening(records, existing) + if records[0].Status != StatusRejected || records[0].RejectReason == "" { + t.Fatalf("allow over an existing deny must be rejected: %+v", records[0]) + } +} + +func TestCheckWeakening_LeavesUnrelatedAllow(t *testing.T) { + existing := activeGeneratedPolicy(t) + records := []Record{ + {ID: "r1", Status: StatusProposed, Class: ClassHumanDecision, Actor: "agent", + Effect: ir.EffectAllow, Operations: []ir.Operation{ir.OpWrite}, ResourceURI: "repo://src/**"}, + } + checkWeakening(records, existing) + if records[0].Status != StatusProposed { + t.Fatalf("unrelated allow should be untouched, got %s", records[0].Status) + } +} + +// --- review ----------------------------------------------------------------- + +func TestApplyAnswers_GroundsUnresolvedResource(t *testing.T) { + root := writeRepo(t, map[string]string{ + "AGENTS.md": "- Ask before publishing a release.\n", + }) + res := mustDerive(t, root, nil) + q := findRecord(res.Derivation, func(r Record) bool { return r.Status == StatusUnresolved }) + if q == nil { + t.Fatal("expected an unresolved approval record") + } + // Answer with a concrete resource; ApplyAnswers should ground it to proposed. + updated := ApplyAnswers(res.Derivation, map[string]string{q.ID: "repo://dist/**"}) + got := findRecord(updated, func(r Record) bool { return r.ID == q.ID }) + if got == nil || got.Status != StatusProposed { + t.Fatalf("answered record should become proposed, got %+v", got) + } + // And the rebuilt candidate must now compile + decide cleanly. + rebuilt, err := Rebuild(updated) + if err != nil { + t.Fatal(err) + } + if rebuilt.Candidate.RuleCount != 1 { + t.Fatalf("rebuilt candidate should have 1 rule, got %d", rebuilt.Candidate.RuleCount) + } + decideVectors(t, rebuilt.Candidate) +} + +func TestApplyAnswers_BlankSkips(t *testing.T) { + root := writeRepo(t, map[string]string{"AGENTS.md": "- Ask before publishing a release.\n"}) + res := mustDerive(t, root, nil) + q := findRecord(res.Derivation, func(r Record) bool { return r.Status == StatusUnresolved }) + if q == nil { + t.Fatal("expected an unresolved record") + } + updated := ApplyAnswers(res.Derivation, map[string]string{q.ID: ""}) + got := findRecord(updated, func(r Record) bool { return r.ID == q.ID }) + if got == nil || got.Status != StatusUnresolved { + t.Fatalf("blank answer should leave record unresolved, got %+v", got) + } +} + +// --- report round-trip ------------------------------------------------------ + +func TestDerivation_EncodeDecodeRoundTrip(t *testing.T) { + root := writeRepo(t, map[string]string{"AGENTS.md": "- Never force-push the main branch.\n"}) + res := mustDerive(t, root, nil) + b, err := encodeDerivation(res.Derivation) + if err != nil { + t.Fatal(err) + } + back, err := decodeDerivation(b) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + if back.Schema != res.Derivation.Schema || len(back.Records) != len(res.Derivation.Records) { + t.Fatalf("round-trip mismatch: %+v vs %+v", back, res.Derivation) + } +} + +func TestDecodeDerivation_FailsClosedOnUnknownField(t *testing.T) { + if _, err := decodeDerivation([]byte(`{"schema":"interlock.derivation.v1","surprise":true}`)); err == nil { + t.Fatal("decode should reject unknown fields") + } +} + +// --- shared helpers --------------------------------------------------------- + +// writeRepo materializes a temp repository from a path→content map and returns +// its root. Nested paths (e.g. ".interlock/policy.json") are created as needed. +func writeRepo(t *testing.T, files map[string]string) string { + t.Helper() + root := t.TempDir() + for rel, content := range files { + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +func readFile(t *testing.T, root, rel string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + return b +} + +func mustDerive(t *testing.T, root string, from []string) Result { + t.Helper() + res, err := Derive(root, from) + if err != nil { + t.Fatalf("Derive(%s): %v", root, err) + } + return res +} + +func findRecord(d Derivation, pred func(Record) bool) *Record { + for i := range d.Records { + if pred(d.Records[i]) { + return &d.Records[i] + } + } + return nil +} + +func findProposed(d Derivation, pred func(Record) bool) *Record { + return findRecord(d, func(r Record) bool { return r.Status == StatusProposed && pred(r) }) +} diff --git a/derive/discover.go b/derive/discover.go new file mode 100644 index 0000000..4fa2517 --- /dev/null +++ b/derive/discover.go @@ -0,0 +1,115 @@ +package derive + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +// discover.go resolves which sources to read and dispatches each to its adapter. +// Discovery is deterministic: the candidate path set is fixed and each glob is +// sorted, so a given repository always yields the same statements in the same +// order regardless of filesystem enumeration order. + +// adapter parses one source file's bytes into raw statements. Adapters never +// touch the filesystem themselves (discover reads the bytes) so they are pure and +// unit-testable. +type adapter func(path string, content []byte) []RawStatement + +// sourceRef is a resolved source: an absolute path and the adapter that parses it. +type sourceRef struct { + path string + relPath string + parse adapter +} + +// discover returns the ordered source set. When from is non-empty it is the +// explicit, user-chosen set (each dispatched by filename); otherwise the default +// V1 set is auto-discovered under root. Order is stable and sorted by relPath. +func discover(root string, from []string) ([]sourceRef, error) { + var refs []sourceRef + seen := map[string]bool{} + + add := func(abs string) { + abs = filepath.Clean(abs) + if seen[abs] { + return + } + info, err := os.Stat(abs) + if err != nil || info.IsDir() { + return + } + rel, rerr := filepath.Rel(root, abs) + if rerr != nil { + rel = abs + } + refs = append(refs, sourceRef{path: abs, relPath: filepath.ToSlash(rel), parse: adapterFor(abs)}) + seen[abs] = true + } + + if len(from) > 0 { + for _, p := range from { + if !filepath.IsAbs(p) { + p = filepath.Join(root, p) + } + add(p) + } + } else { + for _, c := range defaultCandidates(root) { + add(c) + } + } + + sort.Slice(refs, func(i, j int) bool { return refs[i].relPath < refs[j].relPath }) + return refs, nil +} + +// defaultCandidates is the fixed V1 auto-discovery set, in a stable order. Globs +// are expanded and sorted so enumeration is deterministic. +func defaultCandidates(root string) []string { + var out []string + fixed := []string{ + "AGENTS.md", "CLAUDE.md", + "CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS", + "package.json", "Makefile", + } + for _, f := range fixed { + out = append(out, filepath.Join(root, filepath.FromSlash(f))) + } + globs := []string{ + ".cursor/rules/*", + ".claude/skills/*/SKILL.md", + ".github/workflows/*.yml", + ".github/workflows/*.yaml", + } + for _, g := range globs { + matches, _ := filepath.Glob(filepath.Join(root, filepath.FromSlash(g))) + sort.Strings(matches) + out = append(out, matches...) + } + return out +} + +// adapterFor selects a parser by filename. Unknown files fall back to the prose +// adapter, which is safe: prose statements without an explicit imperative marker +// classify as advisory/domain and are dropped, never emitted. +func adapterFor(path string) adapter { + base := filepath.Base(path) + lower := strings.ToLower(base) + switch { + case base == "CODEOWNERS": + return parseCodeowners + case lower == "package.json": + return parsePackageScripts + case lower == "makefile": + return parseMakefile + case strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml"): + if strings.Contains(filepath.ToSlash(path), "/.github/workflows/") { + return parseWorkflow + } + return parseProse + default: + return parseProse + } +} diff --git a/derive/evidence.go b/derive/evidence.go new file mode 100644 index 0000000..72ece37 --- /dev/null +++ b/derive/evidence.go @@ -0,0 +1,42 @@ +package derive + +import ( + "strings" + + "github.com/operatorstack/interlock/ir" +) + +// evidence.go turns an adapter's raw line span into a fail-closed Source. The +// provenance hash is always computed here from the excerpt bytes — never accepted +// from a caller — mirroring broker/envelope.go: hash-binding, not authenticity. + +// cleanExcerpt normalizes a line of source into the stored excerpt: trimmed, with +// leading Markdown bullet/heading/quote punctuation removed so the same sentence +// hashes identically whether it appears as prose, a list item, or a heading. +func cleanExcerpt(text string) string { + s := strings.TrimSpace(text) + // Strip a leading run of markdown list/heading/quote markers. + for { + trimmed := strings.TrimLeft(s, "#>-*+ \t") + if trimmed == s { + break + } + s = trimmed + } + // Collapse internal runs of whitespace to single spaces for stable hashing. + return strings.Join(strings.Fields(s), " ") +} + +// makeSource binds a raw statement to its exact source bytes. SHA256 is +// ir.HashBytes(excerpt): tagged "sha256:"+hex, computed internally. There is no +// timestamp, so the same repository always produces the same provenance (the +// determinism invariant). This proves the record refers to these exact excerpt +// bytes at this path — it is NOT a claim that a trusted author wrote them. +func makeSource(raw RawStatement, excerpt string) Source { + return Source{ + Path: raw.Path, + LineStart: raw.LineStart, + LineEnd: raw.LineEnd, + SHA256: ir.HashBytes([]byte(excerpt)), + } +} diff --git a/derive/ground.go b/derive/ground.go new file mode 100644 index 0000000..3d44532 --- /dev/null +++ b/derive/ground.go @@ -0,0 +1,317 @@ +package derive + +import ( + "regexp" + "strings" + + "github.com/operatorstack/interlock/ir" +) + +// ground.go maps a classified statement onto the closed Interlock vocabulary +// (ir.Operations / ir.ResourceKinds / ir.RequirementKinds). It is where the "no +// invented authority" and "no semantic widening" invariants live: a field is +// filled ONLY when the source text literally cites it. Anything not cited becomes +// a Missing entry and a Question — never a guessed default. A statement that +// cannot be fully grounded stays StatusUnresolved and is not emitted. + +// defaultActor is the coding agent — the principal these repository instructions +// address. V1 does not infer other actors; a rule about a different principal is +// surfaced as a question, not guessed. +const defaultActor = "agent" + +var ( + repoURIRe = regexp.MustCompile(`repo://[^\s"'` + "`" + `]+`) + backtickRe = regexp.MustCompile("`([^`]+)`") +) + +// ground turns a classified record into either a fully-grounded proposal +// (StatusProposed) or an unresolved question (StatusUnresolved). It mutates rec. +func ground(rec *Record, note string) { + rec.Actor = defaultActor + text := rec.Excerpt + + switch rec.Class { + case ClassEnforceableEffect: + groundEffect(rec, text) + case ClassHumanDecision: + groundHumanDecision(rec, text) + case ClassVerificationRequirement: + groundVerification(rec, text, note) + default: + // Advisory / domain / caller-suggested unresolved: never a rule. + if rec.Class == ClassUnresolved { + rec.Status = StatusUnresolved + if note != "" { + rec.Question = note + } else { + rec.Question = "This statement implies an effect restriction, but its operation and resource are not stated explicitly. Which operation and resource (repo:// URI) should it govern?" + } + // Record whatever we could detect, to help the reviewer. + rec.Operations = detectOperations(text) + if kind, uri, ok := detectResource(text, rec.Operations); ok { + rec.ResourceKind, rec.ResourceURI = kind, uri + } + } + } +} + +// groundEffect handles an explicit prohibition → a deny rule. +func groundEffect(rec *Record, text string) { + rec.Effect = ir.EffectDeny + rec.Operations = detectOperations(text) + kind, uri, hasRes := detectResource(text, rec.Operations) + rec.ResourceKind, rec.ResourceURI = kind, uri + rec.Reason = deriveReason(rec) + + var missing []string + if len(rec.Operations) == 0 { + missing = append(missing, "operation") + } + if !hasRes { + missing = append(missing, "resource") + } + finalize(rec, missing, + "This prohibition is clear but its "+strings.Join(missing, " and ")+ + " is not stated in the closed vocabulary. Which "+strings.Join(missing, " and ")+" does it govern?") +} + +// groundHumanDecision handles an explicit approval gate → an allow rule requiring +// human_approval. +func groundHumanDecision(rec *Record, text string) { + rec.Effect = ir.EffectAllow + rec.Operations = detectOperations(text) + kind, uri, hasRes := detectResource(text, rec.Operations) + rec.ResourceKind, rec.ResourceURI = kind, uri + + var missing []string + if len(rec.Operations) == 0 { + missing = append(missing, "operation") + } + if !hasRes { + missing = append(missing, "resource") + } + if len(missing) == 0 { + req := ir.Requirement{Kind: ir.ReqHumanApproval, Approval: approvalID(rec.Operations)} + rec.Requirement = &req + } + rec.Reason = deriveReason(rec) + finalize(rec, missing, + "This requires human approval, but its "+strings.Join(missing, " and ")+ + " is not stated. Which "+strings.Join(missing, " and ")+" needs approval?") +} + +// groundVerification handles a "requires passing evidence" statement. In V1 the +// *verifier* (which receipt schema and status count as passing) is never inferred +// — a receipt requirement without a real verifier would be authority with no +// meaning (invariant 5) — so these always resolve to a question until --review +// supplies the schema. +func groundVerification(rec *Record, text, note string) { + rec.Effect = ir.EffectAllow + rec.Operations = detectOperations(text) + kind, uri, hasRes := detectResource(text, rec.Operations) + rec.ResourceKind, rec.ResourceURI = kind, uri + rec.Reason = deriveReason(rec) + + missing := []string{"verifier"} + if len(rec.Operations) == 0 { + missing = append(missing, "operation") + } + if !hasRes { + missing = append(missing, "resource") + } + q := "This demands evidence before an effect, but no receipt schema/status is named" + if note != "" { + q = note + } + rec.Status = StatusUnresolved + rec.Missing = missing + rec.Question = q + ". Which receipt schema and passing status count as evidence?" +} + +// finalize sets a record's status from its Missing list: empty → proposed, +// otherwise unresolved with the given question. +func finalize(rec *Record, missing []string, question string) { + if len(missing) == 0 { + rec.Status = StatusProposed + return + } + rec.Status = StatusUnresolved + rec.Missing = missing + rec.Question = question +} + +// detectOperations returns the closed-vocabulary operations literally cited in +// the text, in canonical (ir.Operations) order, deduplicated. +func detectOperations(text string) []ir.Operation { + h := strings.ToLower(text) + set := map[ir.Operation]bool{} + + // VCS: force-push must be checked before push (it contains "push"). + if strings.Contains(h, "force-push") || strings.Contains(h, "force push") || strings.Contains(h, "force-pushing") { + set[ir.OpForcePush] = true + } else if strings.Contains(h, "push") { + set[ir.OpPush] = true + } + if containsWord(h, "publish", "release", "deploy", "ship", "publishing", "releasing", "deploying") { + set[ir.OpPublish] = true + } + // Filesystem. + if containsWord(h, "edit", "edited", "editing", "modify", "modified", "modifying", + "change", "changed", "changing", "write", "writing", "touch", "touching", "alter", "update", "updating") { + set[ir.OpWrite] = true + } + if containsWord(h, "delete", "deleting", "remove", "removing") { + set[ir.OpDelete] = true + } + if containsWord(h, "rename", "renaming", "move", "moving") { + set[ir.OpRenameFrom] = true + set[ir.OpRenameTo] = true + } + if containsWord(h, "execute", "executing", "run ", "running") { + set[ir.OpExecute] = true + } + if containsWord(h, "read", "reading") { + set[ir.OpRead] = true + } + + var out []ir.Operation + for _, op := range ir.Operations { + if set[op] { + out = append(out, op) + } + } + return out +} + +// containsWord reports whether any of the substrings appear in h. (Names it +// "word" for intent; matching is substring, which is sufficient for the fixed +// keyword set above.) +func containsWord(h string, subs ...string) bool { + for _, s := range subs { + if strings.Contains(h, s) { + return true + } + } + return false +} + +// detectResource extracts the resource a statement governs, only from what the +// text literally cites. Order: an explicit repo:// URI, then a protected branch +// (main/master) when the ops are branch ops, then a generated-files reference, +// then a backtick-quoted path. Returns ok=false when nothing is cited — the +// caller must then raise a question, never assume a scope. +func detectResource(text string, ops []ir.Operation) (ir.ResourceKind, string, bool) { + // 1. Explicit repo:// URI wins — no interpretation needed. + if m := repoURIRe.FindString(text); m != "" { + return classifyURI(m) + } + + h := strings.ToLower(text) + branchOps := hasAny(ops, ir.OpPush, ir.OpForcePush) + + // 2. A named protected branch, when the operation is a branch operation. + if branchOps { + for _, b := range []string{"main", "master", "release", "production"} { + if strings.Contains(h, b+" branch") || strings.Contains(h, "branch "+b) || + strings.Contains(h, "the "+b) || strings.Contains(h, " "+b+" ") { + return ir.KindBranch, "repo://branch/" + b, true + } + } + } + + // 3. Generated files — a well-known machine-owned tree. + if strings.Contains(h, "generated") { + return ir.KindTree, "repo://generated/**", true + } + + // 4. A backtick-quoted path (e.g. `src/**`, `config/prod.yaml`). + for _, m := range backtickRe.FindAllStringSubmatch(text, -1) { + p := strings.TrimSpace(m[1]) + if looksLikePath(p) { + return pathToResource(p) + } + } + + return "", "", false +} + +func hasAny(ops []ir.Operation, want ...ir.Operation) bool { + for _, o := range ops { + for _, w := range want { + if o == w { + return true + } + } + } + return false +} + +// looksLikePath is a conservative filter so an inline code span for a symbol +// (`someFunc`) is not mistaken for a file path. +func looksLikePath(p string) bool { + if p == "" || strings.Contains(p, " ") { + return false + } + return strings.HasPrefix(p, "repo://") || strings.Contains(p, "/") || + strings.Contains(p, "*") || strings.Contains(p, ".") +} + +// classifyURI infers a resource kind from an explicit repo:// URI without +// widening it: a branch path is a branch, a glob/dir is a tree, a file with an +// extension is a file, everything else defaults to a tree (the least surprising +// containing scope for a bare directory name). +func classifyURI(uri string) (ir.ResourceKind, string, bool) { + uri = strings.TrimRight(uri, ".,;:)") + switch { + case strings.Contains(uri, "/branch/"): + return ir.KindBranch, uri, true + case strings.HasSuffix(uri, "/"): + return ir.KindTree, uri + "**", true + case strings.HasSuffix(uri, "**"): + return ir.KindTree, uri, true + case hasFileExt(uri): + return ir.KindFile, uri, true + default: + return ir.KindTree, strings.TrimRight(uri, "/") + "/**", true + } +} + +// pathToResource converts a repo-relative or repo:// path into a resource. +func pathToResource(p string) (ir.ResourceKind, string, bool) { + if !strings.HasPrefix(p, "repo://") { + p = "repo://" + strings.TrimPrefix(strings.TrimPrefix(p, "./"), "/") + } + return classifyURI(p) +} + +func hasFileExt(uri string) bool { + base := uri + if i := strings.LastIndex(uri, "/"); i >= 0 { + base = uri[i+1:] + } + dot := strings.LastIndex(base, ".") + return dot > 0 && dot < len(base)-1 && !strings.Contains(base, "*") +} + +// approvalID derives a stable approval id from the governed operations, e.g. +// publish → "approve-publish". It is a label on the requirement, not authority. +func approvalID(ops []ir.Operation) string { + if len(ops) == 0 { + return "approve" + } + seg := string(ops[0]) + if i := strings.LastIndex(seg, "."); i >= 0 { + seg = seg[i+1:] + } + return "approve-" + seg +} + +// deriveReason renders the human-facing reason carried into the candidate rule. +// It cites the provenance so a reviewer can trace every rule to its source line. +func deriveReason(rec *Record) string { + verb := "restricted" + if rec.Effect == ir.EffectAllow { + verb = "gated" + } + return "derived (" + verb + ") from " + rec.Source.Path + " — review before enforcing" +} diff --git a/derive/report.go b/derive/report.go new file mode 100644 index 0000000..a106baf --- /dev/null +++ b/derive/report.go @@ -0,0 +1,128 @@ +package derive + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +// report.go renders the human-facing artifacts and decodes derivation.json for +// --review. The framing is load-bearing: every artifact says "candidate, not +// enforced" so the control law is visible to the reader, not just the code. + +// encodeDerivation renders the typed derivation record as indented JSON with a +// trailing newline (diff-friendly authoring form, like spec.Encode). +func encodeDerivation(d Derivation) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(d); err != nil { + return nil, fmt.Errorf("interlock/derive: encode derivation: %w", err) + } + return buf.Bytes(), nil +} + +// decodeDerivation parses derivation.json, rejecting a wrong schema tag and any +// unknown field (fail closed, mirroring spec.Decode). This is the --review entry. +func decodeDerivation(data []byte) (Derivation, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + var d Derivation + if err := dec.Decode(&d); err != nil { + return Derivation{}, fmt.Errorf("interlock/derive: decode derivation: %w", err) + } + if d.Schema != DerivationSchema { + return Derivation{}, fmt.Errorf("interlock/derive: unexpected schema %q, want %q", d.Schema, DerivationSchema) + } + return d, nil +} + +// renderQuestions renders QUESTIONS.md: one entry per unresolved record, plus the +// baseline-freeze question when the candidate is deny-only. Each entry names the +// record id so --review (and a human) can map an answer back to it. +func renderQuestions(d Derivation, freeze bool) []byte { + var b strings.Builder + b.WriteString("# Unresolved questions\n\n") + b.WriteString("These are decisions `interlock derive` could not make for you without guessing. ") + b.WriteString("Nothing here is enforced. Answer them with `interlock derive --review` (or edit ") + b.WriteString("`candidate.policy.json` directly), then compile the candidate to activate it.\n\n") + + any := false + for _, r := range d.Records { + if r.Status != StatusUnresolved { + continue + } + any = true + fmt.Fprintf(&b, "## %s\n\n", r.ID) + fmt.Fprintf(&b, "- Source: `%s:%d`\n", r.Source.Path, r.Source.LineStart) + fmt.Fprintf(&b, "- Excerpt: %q\n", r.Excerpt) + fmt.Fprintf(&b, "- Classification: `%s`\n", r.Class) + if len(r.Missing) > 0 { + fmt.Fprintf(&b, "- Missing: %s\n", strings.Join(r.Missing, ", ")) + } + fmt.Fprintf(&b, "- Question: %s\n\n", r.Question) + } + + if freeze { + any = true + b.WriteString("## baseline\n\n") + b.WriteString("- Question: The candidate only *denies*. Under Interlock's default-deny, every\n") + b.WriteString(" other request is also blocked, which would freeze the repository. What baseline\n") + b.WriteString(" should the agent be allowed to do (e.g. read/write `repo://src/**`)? Answering\n") + b.WriteString(" adds a grounded allow rule; leaving it unanswered keeps the candidate deny-only.\n\n") + } + + if !any { + b.WriteString("_None — every classified statement was either grounded into the candidate or is advisory._\n") + } + return []byte(b.String()) +} + +// renderReadme renders README.md: what this directory is, the "not enforced" +// framing, and the exact promotion command through the real compiler. +func renderReadme(d Derivation, c Candidate) []byte { + var b strings.Builder + b.WriteString("# Derived Interlock policy (candidate — NOT enforced)\n\n") + b.WriteString("`interlock derive` read your repository's existing instructions and produced the\n") + b.WriteString("enforceable rules they appear to imply. **This is a proposal for you to review, not\n") + b.WriteString("an active policy.** Nothing here changes what your agent can do until you compile it.\n\n") + + proposed, unresolved, rejected := countByStatus(d) + b.WriteString("## What's here\n\n") + fmt.Fprintf(&b, "- `candidate.policy.json` — %d proposed rule(s) as `interlock.spec.v1`\n", c.RuleCount) + b.WriteString("- `candidate.tests.jsonl` — a blocking + allowed test vector for each rule\n") + fmt.Fprintf(&b, "- `derivation.json` — every record with source provenance (%d proposed, %d unresolved, %d rejected)\n", proposed, unresolved, rejected) + b.WriteString("- `QUESTIONS.md` — decisions we would not guess\n\n") + + if c.FreezeWarning { + b.WriteString("> ⚠️ The candidate only denies. Under default-deny that blocks everything else —\n") + b.WriteString("> see the `baseline` question in `QUESTIONS.md` before compiling.\n\n") + } + + b.WriteString("## Review, then enforce\n\n") + b.WriteString("1. Read `derivation.json` — every rule cites the source line it came from.\n") + b.WriteString("2. Answer `QUESTIONS.md`: `interlock derive --review`.\n") + b.WriteString("3. Compile the candidate through the real compiler and run its tests:\n\n") + b.WriteString(" ```\n") + b.WriteString(" interlock compile .interlock/derived/candidate.policy.json -o .interlock/policy.json\n") + b.WriteString(" interlock test\n") + b.WriteString(" ```\n\n") + b.WriteString("Only step 3 activates anything, and only because *you* ran the compiler.\n") + return []byte(b.String()) +} + +func countByStatus(d Derivation) (proposed, unresolved, rejected int) { + for _, r := range d.Records { + switch r.Status { + case StatusProposed: + proposed++ + case StatusUnresolved: + unresolved++ + case StatusRejected: + rejected++ + } + } + return +} diff --git a/derive/review.go b/derive/review.go new file mode 100644 index 0000000..3d905a3 --- /dev/null +++ b/derive/review.go @@ -0,0 +1,145 @@ +package derive + +import ( + "strings" + + "github.com/operatorstack/interlock/ir" +) + +// review.go implements the pure answer-application step behind `--review`. It has +// no I/O: the command layer reads derivation.json, collects answers from stdin, +// calls ApplyAnswers, and re-emits the candidate. Keeping it pure makes it +// unit- and conformance-testable and deterministic (same answers → same output). +// +// ApplyAnswers only ever moves a record unresolved → proposed (or adds a +// reviewer-authored baseline allow). It never activates policy and never +// weakens one — promotion is still the separate compile step. + +// ApplyAnswers returns a new Derivation with the given answers applied. answers +// is keyed by record ID (as printed in QUESTIONS.md); the special key "baseline" +// adds a reviewer-authored allow rule for the deny-only freeze case. An empty or +// "skip" answer leaves a record unresolved. The input is not mutated. +func ApplyAnswers(d Derivation, answers map[string]string) Derivation { + out := Derivation{Schema: d.Schema, PolicyID: d.PolicyID} + out.Records = make([]Record, len(d.Records)) + copy(out.Records, d.Records) + + for i := range out.Records { + r := &out.Records[i] + if r.Status != StatusUnresolved { + continue + } + ans := strings.TrimSpace(answers[r.ID]) + if ans == "" || strings.EqualFold(ans, "skip") { + continue + } + applyAnswer(r, ans) + } + + if base := strings.TrimSpace(answers["baseline"]); base != "" && !strings.EqualFold(base, "skip") { + if rec, ok := baselineRecord(base); ok { + out.Records = append(out.Records, rec) + } + } + return out +} + +// applyAnswer fills a single unresolved record's missing fields from one answer, +// then re-checks completeness. The answer is interpreted by what is missing: +// verifier → "schema:status"; resource → a repo:// URI or path; operation → an +// operation keyword. +func applyAnswer(r *Record, ans string) { + missing := map[string]bool{} + for _, m := range r.Missing { + missing[m] = true + } + + if missing["verifier"] { + schema, status := parseVerifier(ans) + req := ir.Requirement{Kind: ir.ReqReceiptStatus, Receipt: schema, Status: status} + r.Requirement = &req + delete(missing, "verifier") + // A verifier answer resolves only the verifier; other fields, if still + // missing, keep the record unresolved below. + } else if missing["resource"] { + if kind, uri, ok := pathToResource(ans); ok { + r.ResourceKind, r.ResourceURI = kind, uri + delete(missing, "resource") + } + } else if missing["operation"] { + if ops := parseOperations(ans); len(ops) > 0 { + r.Operations = ops + delete(missing, "operation") + } + } + + // Rebuild the remaining-missing list in a stable order. + r.Missing = r.Missing[:0] + for _, m := range []string{"verifier", "operation", "resource"} { + if missing[m] { + r.Missing = append(r.Missing, m) + } + } + if len(r.Missing) > 0 { + return + } + + // Fully grounded: attach a human_approval requirement if this was a human + // decision and none is set, refresh the reason, and mark proposed. + if r.Class == ClassHumanDecision && r.Requirement == nil { + req := ir.Requirement{Kind: ir.ReqHumanApproval, Approval: approvalID(r.Operations)} + r.Requirement = &req + } + if r.Reason == "" { + r.Reason = deriveReason(r) + } + r.Question = "" + r.Status = StatusProposed +} + +// baselineRecord builds the reviewer-authored allow rule for the freeze case. Its +// provenance is the human answer itself (path "review:baseline"), hash-bound like +// any other source — authority the reviewer explicitly granted, not invented by +// derive. +func baselineRecord(ans string) (Record, bool) { + kind, uri, ok := pathToResource(ans) + if !ok { + return Record{}, false + } + excerpt := "baseline allow: " + ans + rec := Record{ + ID: "baseline", + Source: Source{Path: "review:baseline", SHA256: ir.HashBytes([]byte(excerpt))}, + Excerpt: excerpt, + Class: ClassEnforceableEffect, + Strength: StrengthStrong, + Status: StatusProposed, + Actor: defaultActor, + Operations: []ir.Operation{ir.OpRead, ir.OpWrite}, + Effect: ir.EffectAllow, + ResourceKind: kind, + ResourceURI: uri, + Reason: "baseline access granted by reviewer via --review", + } + return rec, true +} + +// parseVerifier splits a "schema:status" answer; a bare schema defaults to +// status "pass". +func parseVerifier(ans string) (schema, status string) { + if i := strings.Index(ans, ":"); i >= 0 { + return strings.TrimSpace(ans[:i]), strings.TrimSpace(ans[i+1:]) + } + return ans, "pass" +} + +// parseOperations reads an operation answer: either a full ir.Operation value or +// a keyword the detector understands (e.g. "publish", "force-push"). +func parseOperations(ans string) []ir.Operation { + for _, op := range ir.Operations { + if string(op) == ans { + return []ir.Operation{op} + } + } + return detectOperations(ans) +} diff --git a/derive/schema.go b/derive/schema.go new file mode 100644 index 0000000..d472a8e --- /dev/null +++ b/derive/schema.go @@ -0,0 +1,163 @@ +// Package derive is Interlock's policy-authoring frontend. It reads the intent a +// repository already encodes — agent instructions, skills, CODEOWNERS, CI +// workflows, package scripts, generated-file markers — and produces a *candidate* +// Interlock policy for a human to review, never an active one. +// +// Control law (control-law: derivation-proposes-never-enforces): a derived +// artifact may only be a candidate. Every emitted rule is grounded in cited +// repository evidence, expressed strictly within the closed V1 vocabulary +// (ir.Operations / ir.ResourceKinds / ir.RequirementKinds), and neither broader +// nor narrower than its source. Derivation never activates policy, never +// manufactures authority from advisory language, and never weakens an existing +// policy. Promotion to the active decision table happens only through a separate, +// explicit human step that re-runs the real compiler (compiler.Compile) and +// engine (engine.Decide). +// +// This package is strictly upstream of the deterministic authorities. It depends +// on ir/spec/scaffold for vocabulary and emission, but engine/compiler/broker +// never depend on it: a bug here can at worst produce a bad *draft*, which the +// human review + compile gate rejects. +package derive + +import "github.com/operatorstack/interlock/ir" + +// DerivationSchema tags the derivation.json document. +const DerivationSchema = "interlock.derivation.v1" + +// DefaultPolicyID is the policy_id stamped on a derived candidate. It is +// deliberately generic: derivation proposes structure, the human names it. +const DefaultPolicyID = "derived-policy.v1" + +// Class is the classification of an extracted statement. Only the first three +// classes may become candidate policy rules; the rest are recorded (or dropped) +// but never emitted as authority. +type Class string + +const ( + // ClassEnforceableEffect is an explicit prohibition or permission on an + // effect (e.g. "never force-push main") — maps to an allow/deny rule. + ClassEnforceableEffect Class = "enforceable_effect" + // ClassVerificationRequirement demands evidence before an effect (e.g. "run + // tests before pushing") — maps to an allow rule with a receipt requirement. + ClassVerificationRequirement Class = "verification_requirement" + // ClassHumanDecision demands human sign-off (e.g. "ask before publishing") — + // maps to an allow rule with a human_approval requirement. + ClassHumanDecision Class = "human_decision" + // ClassAdvisoryGuidance is a preference ("prefer functional components") — + // never an Interlock rule. + ClassAdvisoryGuidance Class = "advisory_guidance" + // ClassDomainKnowledge is a fact ("the API uses OAuth") — never a rule. + ClassDomainKnowledge Class = "domain_knowledge" + // ClassUnresolved is effect-related but not yet groundable — becomes a + // question, never a rule until resolved. + ClassUnresolved Class = "unresolved" +) + +// Emittable reports whether a class may become a candidate policy rule. +func (c Class) Emittable() bool { + switch c { + case ClassEnforceableEffect, ClassVerificationRequirement, ClassHumanDecision: + return true + default: + return false + } +} + +// Status is the lifecycle state of a derivation record. +type Status string + +const ( + // StatusProposed: fully grounded and emitted into the candidate. + StatusProposed Status = "proposed" + // StatusUnresolved: effect-related but missing a material decision; carries a + // Question and is NOT emitted. + StatusUnresolved Status = "unresolved" + // StatusRejected: cannot be admitted (conflict, or would weaken an existing + // policy). Carries a RejectReason and is NOT emitted. + StatusRejected Status = "rejected" +) + +// Strength grades how much authority a source carries. Machine-readable config +// is strong; aspirational prose is weak. Weak evidence may create a suggestion +// (a question) but never, on its own, an activated rule. +type Strength string + +const ( + StrengthStrong Strength = "strong" + StrengthWeak Strength = "weak" +) + +// Source is fail-closed provenance that binds a record to the exact source bytes +// it was derived from. It mirrors broker/envelope.go's conventions: the SHA256 is +// always computed internally via ir.HashBytes (tagged "sha256:"+hex, never a +// caller-supplied value), it carries no timestamps (replay-safe), and it is +// decoded with DisallowUnknownFields (see report.go). Like the broker envelope, +// this is HASH-BINDING, not authenticity: it proves the record refers to these +// exact excerpt bytes at this path, not that a trusted author wrote them. +type Source struct { + Path string `json:"path"` + LineStart int `json:"line_start"` + LineEnd int `json:"line_end"` + SHA256 string `json:"sha256"` +} + +// RawStatement is a single unit of intent an adapter extracts from a source. The +// adapter fills Text, the line span, and Strength; evidence.go computes the +// provenance hash; classify/ground turn it into a Record. +// +// Suggest and Note are the adapter's optional structured hints. A prose adapter +// leaves them empty and lets classify.go read the language. A machine-config +// adapter (CODEOWNERS, workflows) that knows the *shape* of what it found but not +// the operator's intent sets Suggest=ClassUnresolved and a Note explaining the +// decision the human must make — this is how "ownership" or "a CI check exists" +// becomes a QUESTIONS.md entry rather than a silently widened rule (invariant 2). +type RawStatement struct { + Path string + LineStart int + LineEnd int + Text string + Strength Strength + Suggest Class + Note string +} + +// Record is one reviewed unit written to derivation.json: the cited source, the +// classification, and — when emittable and grounded — the proposed rule fields. +type Record struct { + ID string `json:"id"` + Source Source `json:"source"` + Excerpt string `json:"excerpt"` + Class Class `json:"class"` + Strength Strength `json:"strength"` + Status Status `json:"status"` + + // Proposed rule (populated only for grounded, emittable records). + Actor string `json:"actor,omitempty"` + Operations []ir.Operation `json:"operations,omitempty"` + Effect ir.Effect `json:"effect,omitempty"` + ResourceKind ir.ResourceKind `json:"resource_kind,omitempty"` + ResourceURI string `json:"resource_uri,omitempty"` + Requirement *ir.Requirement `json:"requirement,omitempty"` + Reason string `json:"reason,omitempty"` + + // Resolution. + Missing []string `json:"missing,omitempty"` + Question string `json:"question,omitempty"` + RejectReason string `json:"reject_reason,omitempty"` +} + +// Derivation is the typed derivation.json document. +type Derivation struct { + Schema string `json:"schema"` + PolicyID string `json:"policy_id"` + Records []Record `json:"records"` +} + +// Classifier is the seam for an optional semantic (LLM-assisted) classifier. V1 +// ships only the deterministic implementation (see classify.go) and never makes a +// network call. Any future model implementation returns a proposal that ground.go +// still validates against the closed vocabulary — no model output becomes a rule +// without passing the same deterministic grounding + compiler gate. +type Classifier interface { + Classify(text string) Class +}