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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions UPSTREAM.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
Expand Down
223 changes: 223 additions & 0 deletions cmd/interlock/derive.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions cmd/interlock/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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 <dir> 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 <dir> [-o policy.json] build+run a Go policy module → canonical IR
Expand Down
Loading
Loading