diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57a65fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +/made diff --git a/cmd/made/daemon.go b/cmd/made/daemon.go index 4ebd5a3..72859e1 100644 --- a/cmd/made/daemon.go +++ b/cmd/made/daemon.go @@ -350,6 +350,7 @@ func validateBareGateRepo(path string) error { res, err := exec.Run(ctx, exec.Command{ Name: "git", Args: []string{"-C", path, "rev-parse", "--is-bare-repository"}, + Env: gitEnv(), }) if err != nil { return fmt.Errorf("check bare repository at %s: %w", path, err) @@ -559,6 +560,7 @@ func validateGateSubmission(ctx context.Context, spoolPath, gatePath, ref, newSH res, err := exec.Run(ctx, exec.Command{ Name: "git", Args: []string{"-C", absGate, "rev-parse", "--verify", ref + "^{commit}"}, + Env: gitEnv(), }) if err != nil { return fmt.Errorf("inspect pushed head: %w", err) diff --git a/cmd/made/gate.go b/cmd/made/gate.go index 8900eef..4c7be43 100644 --- a/cmd/made/gate.go +++ b/cmd/made/gate.go @@ -19,6 +19,16 @@ const gateCommandTimeout = 30 * time.Second const gitZeroSHAValue = "0000000000000000000000000000000000000000" +func gitEnv() []string { + return append(os.Environ(), + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", + ) +} + func runGateCommand(args []string, stdout, stderr *os.File) int { if len(args) < 1 { _, _ = fmt.Fprintln(stderr, "usage: made gate init ") @@ -251,7 +261,7 @@ func gateInit(ctx context.Context, madeHomeDir, madeBinaryPath, targetRepoPath, } func ensureRemote(ctx context.Context, repoDir, name, url string) error { - res, err := exec.Run(ctx, exec.Command{Name: "git", Args: []string{"remote", "get-url", name}, Dir: repoDir}) + res, err := exec.Run(ctx, exec.Command{Name: "git", Args: []string{"remote", "get-url", name}, Dir: repoDir, Env: gitEnv()}) if err != nil { return fmt.Errorf("git remote get-url %s: %w", name, err) } @@ -262,7 +272,7 @@ func ensureRemote(ctx context.Context, repoDir, name, url string) error { } func resolveDefaultBranch(ctx context.Context, barePath string) (string, error) { - res, err := exec.Run(ctx, exec.Command{Name: "git", Args: []string{"remote", "show", "origin"}, Dir: barePath}) + res, err := exec.Run(ctx, exec.Command{Name: "git", Args: []string{"remote", "show", "origin"}, Dir: barePath, Env: gitEnv()}) if err != nil { return "", fmt.Errorf("git remote show origin: %w", err) } @@ -285,7 +295,7 @@ func resolveDefaultBranch(ctx context.Context, barePath string) (string, error) } func runGit(ctx context.Context, dir string, args ...string) error { - res, err := exec.Run(ctx, exec.Command{Name: "git", Args: args, Dir: dir}) + res, err := exec.Run(ctx, exec.Command{Name: "git", Args: args, Dir: dir, Env: gitEnv()}) if err != nil { return fmt.Errorf("git %s: %w", strings.Join(args, " "), err) } diff --git a/cmd/made/gate_test.go b/cmd/made/gate_test.go index 11245dd..b823445 100644 --- a/cmd/made/gate_test.go +++ b/cmd/made/gate_test.go @@ -148,6 +148,13 @@ func testGit(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", + ) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %s (dir=%s): %v: %s", strings.Join(args, " "), dir, err, out) } @@ -157,6 +164,13 @@ func testGitOutput(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", + ) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %s (dir=%s): %v: %s", strings.Join(args, " "), dir, err, out) diff --git a/cmd/made/main.go b/cmd/made/main.go index e757fbf..57f8f7b 100644 --- a/cmd/made/main.go +++ b/cmd/made/main.go @@ -16,6 +16,8 @@ func run(args []string, stdout, stderr *os.File) int { } switch args[0] { + case "validate": + return runValidateCommand(args[1:], stdout, stderr) case "capabilities": return runCapabilitiesCommand(args[1:], stdout, stderr) case "run": diff --git a/cmd/made/runcommands.go b/cmd/made/runcommands.go index 182f035..5e07ced 100644 --- a/cmd/made/runcommands.go +++ b/cmd/made/runcommands.go @@ -29,7 +29,7 @@ func runCapabilitiesCommand(args []string, stdout, stderr *os.File) int { } return writeJSON(stdout, capabilitiesReport{ SchemaVersion: 1, ProtocolVersion: api.Version, - Commands: []string{"run.submit", "run.status", "run.list", "run.cancel", "review.decide", "doctor"}, + Commands: []string{"run.submit", "run.status", "run.list", "run.cancel", "review.decide", "doctor", "validate.managed.v1"}, Agents: supportedAgentNames(), }, stderr, "made capabilities") } diff --git a/cmd/made/validate.go b/cmd/made/validate.go new file mode 100644 index 0000000..f41a152 --- /dev/null +++ b/cmd/made/validate.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/douglasjarquin/made/internal/managed" +) + +// runValidateCommand is the entry point for `made validate --managed --json-events ...`. +func runValidateCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made validate", flag.ContinueOnError) + fs.SetOutput(stderr) + + managedMode := fs.Bool("managed", false, "run in managed-validation mode") + jsonEvents := fs.Bool("json-events", false, "emit JSON-Lines events to stdout") + + runID := fs.String("run-id", "", "opaque run identifier echoed in every event") + missionID := fs.String("mission-id", "", "opaque mission identifier echoed in every event") + workspace := fs.String("workspace", "", "absolute path to Git working tree") + baseSHA := fs.String("base-sha", "", "full 40-hex base commit SHA") + inputSHA := fs.String("input-sha", "", "full 40-hex input commit SHA (must equal workspace HEAD)") + trustedConfig := fs.String("trusted-config", "", "absolute path to trusted .made.yml") + policyHash := fs.String("policy-hash", "", "sha256:<64-hex> of trusted-config bytes") + evidenceDir := fs.String("evidence-dir", "", "absolute path outside workspace for evidence output") + decisions := fs.String("decisions", "", "optional absolute path to Decisions JSON file") + + if err := fs.Parse(args); err != nil { + return 2 + } + + if !*managedMode { + _, _ = fmt.Fprintln(stderr, "made validate: --managed is required") + return 2 + } + if !*jsonEvents { + _, _ = fmt.Fprintln(stderr, "made validate: --json-events is required") + return 2 + } + + // Validate required flags. + missing := []string{} + if *runID == "" { + missing = append(missing, "--run-id") + } + if *missionID == "" { + missing = append(missing, "--mission-id") + } + if *workspace == "" { + missing = append(missing, "--workspace") + } + if *baseSHA == "" { + missing = append(missing, "--base-sha") + } + if *inputSHA == "" { + missing = append(missing, "--input-sha") + } + if *trustedConfig == "" { + missing = append(missing, "--trusted-config") + } + if *policyHash == "" { + missing = append(missing, "--policy-hash") + } + if *evidenceDir == "" { + missing = append(missing, "--evidence-dir") + } + if len(missing) > 0 { + for _, flag := range missing { + _, _ = fmt.Fprintf(stderr, "made validate: missing required flag %s\n", flag) + } + return 2 + } + + opts := &managed.Options{ + RunID: *runID, + MissionID: *missionID, + Workspace: *workspace, + BaseSHA: *baseSHA, + InputSHA: *inputSHA, + TrustedConfig: *trustedConfig, + PolicyHash: *policyHash, + EvidenceDir: *evidenceDir, + DecisionsPath: *decisions, + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + return managed.Run(ctx, opts, stdout, stderr) +} diff --git a/docs/managed-validation-integration.md b/docs/managed-validation-integration.md new file mode 100644 index 0000000..3b0bb7c --- /dev/null +++ b/docs/managed-validation-integration.md @@ -0,0 +1,217 @@ +# Made Managed Validation — Integration Reference + +This document is written for Consigliere implementers. It describes the exact +Made interface without requiring knowledge of Made internals. + +--- + +## Command + +``` +made validate --managed --json-events \ + --run-id \ + --mission-id \ + --workspace /absolute/path/to/workspace \ + --base-sha <40-hex> \ + --input-sha <40-hex> \ + --trusted-config /absolute/path/to/.made.yml \ + --policy-hash sha256:<64-lowercase-hex> \ + --evidence-dir /absolute/path/outside/workspace \ + [--decisions /absolute/path/to/decisions.json] +``` + +## Required inputs + +| Input | Type | Notes | +|---|---|---| +| `run_id` | opaque string | Echoed in every event; never interpreted by Made | +| `mission_id` | opaque string | Echoed in every event; never interpreted by Made | +| `workspace` | absolute path | Must be a Git working tree; HEAD must equal `input_sha` | +| `base_sha` | 40-hex SHA | Ancestor of `input_sha`; used for diff range | +| `input_sha` | 40-hex SHA | Immutable; must exactly equal workspace HEAD | +| `trusted_config` | absolute path | Regular file (not symlink); hash-verified before parsing | +| `policy_hash` | `sha256:<64-hex>` | SHA-256 of `trusted_config` bytes | +| `evidence_dir` | absolute path | Outside `workspace`; created if absent | +| `decisions` | absolute path | Optional; JSON Decisions file | + +## Output stream + +Stdout contains only JSON Lines (one event per line). +Stderr contains human-readable diagnostics. +Do not parse stderr. + +## Exit codes + +| Code | Terminal outcome | +|---|---| +| 0 | `passed` | +| 1 | `infrastructure_error` | +| 2 | Usage or contract error (no terminal JSON event emitted) | +| 3 | `needs_decision` | +| 4 | `failed_retryable` | +| 5 | `failed_terminal` | +| 130 | `canceled` | + +The JSON `run.completed` event is authoritative for outcomes 0, 1, 3, 4, 5, 130. +Exit code 2 indicates argument or contract errors before any events are emitted. + +## Terminal outcomes + +| Outcome | When | Next action | +|---|---|---| +| `passed` | All stages passed | Consigliere proceeds to delivery | +| `needs_decision` | Ask-user finding with no Decision | Consigliere asks human; rerun with Decisions file | +| `failed_retryable` | Auto-fixable finding, test/lint failure | Consigliere schedules repair Attempt | +| `failed_terminal` | Blocking finding or rejected Decision | Consigliere notifies; no repair | +| `infrastructure_error` | Config hash mismatch, workspace mutation, etc. | Consigliere quarantines workspace | +| `canceled` | Signal or context cancellation | Consigliere retries or aborts Mission | + +## Evidence locations + +Evidence is written to `///`: + +``` +/ + / (SHA-256 of run_id, lowercase hex) + / (unique per invocation; lowercase hex) + review/ + findings.json — agent response and structured findings + test/ + stdout.log — test stage output + stderr.log — test stage errors + document/ + findings.json — documentation findings + lint/ + stdout.log — lint stage output + stderr.log — lint stage errors + terminal.json — run summary and outcome +``` + +Evidence paths are relative to `` and must be followed from the events. +`` allows multiple Made invocations (reruns) to share the same hashed run +directory while isolating evidence by invocation instance. + +Evidence is available after the process exits with any code ≥ 0. + +## Cancellation behavior + +Send SIGTERM or SIGINT. Made emits one `run.completed` event with outcome +`canceled` and exits 130. Evidence collected before cancellation is preserved. +The workspace state after cancellation is undefined; treat it as potentially dirty. + +## Subprocess and environment isolation (Blocker 6 requirement) + +**Critical requirement for security**: `made validate --managed` must execute in an +unprivileged, isolated process environment. The process must NOT inherit sensitive +credentials or configuration from the host environment. + +### Required isolation boundaries + +The validator process MUST be confined to: + +- **Filesystem**: Read-only access to `trusted_config` and workspace; writable only to `evidence_dir` +- **Environment**: Only essential build environment variables (e.g., `PATH`, `HOME` pointing to a temporary sandbox) +- **Secrets**: No access to Made daemon state, delivery credentials, GitHub credentials, or any production secrets +- **Network**: No network access unless explicitly required for build/test commands +- **Privileges**: Non-root; no special capabilities; no capability escalation + +### Why this matters + +Test and lint stages execute candidate-written code in the workspace. A malicious or +compromised candidate can cause test processes to: + +- Read environment variables containing credentials (GitHub tokens, API keys) +- Exfiltrate code or secrets to external services +- Access other workspaces or Made daemon state +- Modify or escape the intended sandbox + +### Implementation responsibility + +**Option A (Recommended)**: Consigliere enforces isolation +- Run Made process in a containerized or VM-based validator sandbox +- Mount only necessary paths with correct permissions +- Supply only non-sensitive environment variables +- Example: `docker run --rm -v workspace:/ws -v evidence:/evidence -v trusted-config:/config:ro -- made validate --managed ...` + +**Option B**: Made enforces environment allowlist +- Made validates or filters the process environment before spawning test/lint subprocesses +- Not recommended; Consigliere has better visibility into process context + +Consigliere should prefer **Option A**: establish the sandbox boundary before invoking Made, +ensuring isolation is the default behavior independent of Made changes. + +## Version negotiation + +Check `made capabilities --json`. The `commands` array contains `"validate.managed.v1"` when managed validation is supported. + +```bash +made capabilities --json | jq '.commands | contains(["validate.managed.v1"])' +``` + +## Opaque fields + +The following fields are echoed exactly as supplied and are never interpreted by Made: + +- `run_id` +- `mission_id` + +## Values that must be exact + +- `input_sha` — must exactly match workspace HEAD (full 40-hex) +- `policy_hash` — must exactly match SHA-256 of `trusted_config` bytes (lowercase hex) +- Decisions file `run_id`, `mission_id`, `input_sha`, `policy_hash` — must match CLI flags + +## What Made never does + +In managed mode, Made never: + +- Creates commits or applies patches +- Pushes to any remote +- Creates pull requests +- Monitors CI +- Merges +- Waits for human input +- Reads workspace `.made.yml` +- Contacts the Made daemon +- Creates durable run records +- Writes inside the workspace directory + +## Rerunning with a Decisions file + +To resolve `needs_decision`: + +1. Extract unresolved findings from the `run.completed` payload `findings` array +2. Collect boss Decisions (approved/rejected) for each finding fingerprint +3. Write a Decisions JSON file (see schema below) +4. Re-invoke `made validate --managed` with the same arguments plus `--decisions` + +The `input_sha` and `policy_hash` in the Decisions file must match the original run. +A Decisions file from a different SHA is rejected at preflight. + +### Decisions file schema + +```json +{ + "schema_version": 1, + "run_id": "G-229", + "mission_id": "M-402", + "input_sha": "2222222222222222222222222222222222222222", + "base_sha": "1111111111111111111111111111111111111111", + "invocation_id": "0987654321fedcba", + "policy_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "decisions": [ + { + "decision_id": "D-184", + "finding_fingerprint": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "outcome": "approved", + "scope": "sha_bound", + "rationale": "Accepted for this validation input" + } + ] +} +``` + +Supported `outcome` values: `approved`, `rejected` +Supported `scope` values: `one_shot`, `sha_bound`, `mission_finding_waiver` + +Made validates and echoes scope metadata; Consigliere owns whether a Decision was authorized. diff --git a/docs/managed-validation-v1.md b/docs/managed-validation-v1.md new file mode 100644 index 0000000..99564c0 --- /dev/null +++ b/docs/managed-validation-v1.md @@ -0,0 +1,559 @@ +# Made Managed Validation V1 + +Version: 1 +Protocol version: 1 +Schema version: 1 + +--- + +## 1. Purpose + +`made validate --managed` is an additive, short-lived, daemonless execution shape +that Consigliere invokes to validate an immutable input commit SHA. + +Made validates. Consigliere orchestrates. + +--- + +## 2. Ownership boundary + +### Made owns + +- Loading and verifying a trusted Made policy snapshot +- Verifying the immutable workspace HEAD equals the supplied input SHA +- Executing validation stages: review, test, document, lint +- Running the configured review Agent in report-only mode +- Producing structured findings with stable fingerprints +- Applying supplied Decisions to matching ask-user findings +- Writing validation evidence outside the Agent workspace +- Emitting a versioned JSON event stream to stdout +- Returning one terminal validation outcome + +### Consigliere owns + +- Missions, Attempts, workspaces, Agent lifecycle +- Human Questions and boss Decisions +- Repair budgets and repair Attempts +- Retries, scheduling +- Push, pull requests, CI lifecycle, merge authorization, merge +- Notifications + +--- + +## 3. CLI contract + +``` +made validate --managed --json-events \ + --run-id \ + --mission-id \ + --workspace /absolute/path/to/workspace \ + --base-sha <40-hex-sha> \ + --input-sha <40-hex-sha> \ + --trusted-config /absolute/path/to/.made.yml \ + --policy-hash sha256:<64-lowercase-hex> \ + --evidence-dir /absolute/path/to/evidence \ + [--decisions /absolute/path/to/decisions.json] +``` + +All flags are required except `--decisions`. + +### Flag semantics + +| Flag | Requirement | +|---|---| +| `--managed` | Required; identifies managed mode | +| `--json-events` | Required; enables JSON-lines stdout protocol | +| `--run-id` | Opaque; echoed in every event | +| `--mission-id` | Opaque; echoed in every event | +| `--workspace` | Absolute canonical path to Git working tree | +| `--base-sha` | Full 40-hex commit SHA; ancestor of input | +| `--input-sha` | Full 40-hex commit SHA; must equal workspace HEAD | +| `--trusted-config` | Absolute path to trusted policy file | +| `--policy-hash` | `sha256:<64-lowercase-hex>` of trusted-config bytes | +| `--evidence-dir` | Absolute path outside workspace for evidence output | +| `--decisions` | Optional; path to Decisions JSON file | + +--- + +## 4. Preflight checks + +Before any stage begins, managed mode verifies: + +1. `workspace` is an absolute canonical path +2. It is an existing Git working tree +3. `HEAD^{commit}` exactly equals `input_sha` +4. `input_sha` is a full 40-hex commit SHA +5. `base_sha` is a full 40-hex commit SHA +6. Both commits exist locally in the worktree +7. `base_sha` is an ancestor of `input_sha` +8. The worktree has no tracked or non-ignored untracked changes (`git status --porcelain --untracked-files=all` is empty) +9. `trusted-config` is an absolute path to a regular file (not a symlink) +10. The trusted config bytes are read exactly once +11. `SHA-256` of those bytes matches `policy_hash` +12. The verified bytes (not a second read) are parsed as the Made config +13. `evidence-dir` is an absolute path +14. `evidence-dir` is outside the Agent workspace (no prefix relationship) +15. The Decisions file, when supplied, matches run_id, mission_id, input_sha, and policy_hash + +A preflight failure emits an `infrastructure_error` or usage-error terminal event and exits with code 1 or 2. + +--- + +## 5. Stages + +Managed V1 executes exactly these stages in order: + +``` +review → test → document → lint +``` + +Managed V1 never executes: intent, rebase, push, pr, ci, merge. + +### Stop-at-first-action rule + +Managed V1 stops after the first stage that produces a non-pass outcome. +All findings from that stage are reported before stopping. +Later stages do not run. + +### Review (report-only) + +- Spawns the configured Codex review Agent +- Requires structured JSON output +- Does NOT apply auto-fix patches +- Does NOT create commits +- Emits all findings as `finding.reported` events +- Applies supplied Decisions to ask-user findings +- Classify: unresolved ask-user → `needs_decision`; rejected ask-user → `failed_terminal`; auto-fixable → `failed_retryable`; blocking → `failed_terminal` + +### Test + +- Runs the trusted configured test command +- Command non-zero exit → `failed_retryable` +- Spawn / evidence failure → `infrastructure_error` + +### Document + +- Uses exact `base_sha..input_sha`, not mutable branch names +- Unresolved ask-user → `needs_decision`; rejected → `failed_terminal`; approved → continue + +### Lint + +- Runs the trusted configured lint command +- Command non-zero exit → `failed_retryable` +- Infrastructure failure → `infrastructure_error` +- Pass → `passed` (if all earlier stages also passed) + +--- + +## 6. Nonmutation guarantee + +Managed mode must not modify the workspace. + +Before and after every stage, managed mode captures: + +``` +HEAD=$(git rev-parse HEAD) +STATUS=$(git status --porcelain --untracked-files=all) +``` + +If either changes, managed mode: + +1. Stops immediately +2. Emits an `infrastructure_error` terminal event +3. Preserves all collected evidence +4. Does NOT attempt to reset or conceal the mutation +5. Reports that the caller must quarantine or replace the workspace + +--- + +## 7. Trusted configuration contract + +1. The caller supplies `--trusted-config` and `--policy-hash` +2. The file is read exactly once with `os.Open` on a regular file (symlinks rejected) +3. SHA-256 is computed over the exact bytes read +4. Hash is compared against `--policy-hash` (format: `sha256:<64-lowercase-hex>`) +5. The verified bytes (not a second read) are parsed +6. No workspace `.made.yml` is read or merged +7. Repository prose cannot enable commands not authorized by the trusted snapshot +8. The verified policy hash appears in every emitted event + +--- + +## 8. Safe Git execution + +All Git invocations used by managed mode: + +- Strip all `GIT_*` environment variables +- Strip `SSH_AUTH_SOCK`, `SSH_ASKPASS`, `GIT_SSH_COMMAND`, `GIT_ASKPASS` +- Override `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null` +- Set `GIT_TERMINAL_PROMPT=0` +- Pass `-c core.hooksPath=/dev/null` +- Pass `-c core.fsmonitor=false` +- Use explicit argv (no shell interpolation) +- Perform no network Git operation + +--- + +## 9. JSON event protocol + +Managed mode writes JSON Lines to stdout only. Diagnostics go to stderr. + +### Event envelope + +```json +{ + "schema_version": 1, + "protocol_version": 1, + "sequence": 1, + "run_id": "G-229", + "mission_id": "M-402", + "invocation_id": "1234567890abcdef", + "base_sha": "1111111111111111111111111111111111111111", + "input_sha": "2222222222222222222222222222222222222222", + "policy_hash": "sha256:64aec94d8e1fade3975101ba87f44076e4487016c87c6cf8d24857aad2e28d27", + "event": "run.started", + "timestamp": "2026-08-18T21:00:00.000000000Z", + "payload": {} +} +``` + +### Protocol rules + +- `sequence` begins at 1 and increases by exactly 1 +- `invocation_id` is a unique lowercase hex string, constant within a single invocation but different on each rerun +- `base_sha` is the immutable base commit SHA (40-hex), used for diff ranges +- `input_sha` is the immutable input commit SHA (40-hex), equal to workspace HEAD +- Timestamps are UTC RFC3339 nanosecond precision +- `run_id`, `mission_id`, `base_sha`, `input_sha`, `policy_hash`, and `invocation_id` are constant across all events +- Exactly one terminal event is emitted per invocation +- No event is emitted after the terminal event + +### Required event types + +| Event | When | +|---|---| +| `run.started` | At process start, before preflight validation begins | +| `stage.started` | Before each stage begins (review, test, document, lint) | +| `finding.reported` | For each finding discovered by a stage | +| `evidence.created` | After evidence is written for a stage | +| `stage.completed` | After each stage finishes (even on failure) | +| `run.completed` | Terminal; exactly once | + +Not implemented in V1: `run.checkpointed`, `run.resumed`, `decision.waiting` + +--- + +## 10. Terminal outcomes + +The terminal event `run.completed` carries: + +```json +{ + "outcome": "passed", + "stage": "lint", + "message": "all managed validation stages passed", + "findings": [], + "evidence_refs": [] +} +``` + +### Outcome values + +| Outcome | Meaning | +|---|---| +| `passed` | All stages passed; all ask-user findings have approving Decisions | +| `needs_decision` | At least one ask-user finding has no applicable Decision | +| `failed_retryable` | Auto-fixable finding, test failure, or lint failure | +| `failed_terminal` | Blocking finding, rejected Decision, or policy violation | +| `infrastructure_error` | Config hash mismatch, malformed agent output, workspace mutation, evidence failure, etc. | +| `canceled` | Context or process cancellation observed; cleanup complete | + +### Exit codes + +| Code | Outcome | +|---|---| +| 0 | passed | +| 1 | infrastructure_error | +| 2 | usage / contract error | +| 3 | needs_decision | +| 4 | failed_retryable | +| 5 | failed_terminal | +| 130 | canceled | + +The JSON terminal event is authoritative. Exit codes are a process-level summary. + +--- + +## 11. Finding contract + +```json +{ + "fingerprint": "sha256:<64-hex>", + "stage": "review", + "kind": "ask-user", + "code": "review.architecture_choice", + "class": "project-judgment", + "description": "Human-readable explanation", + "paths": ["internal/example.go"], + "symbol": "ExampleFunction", + "patch": null, + "evidence_refs": [] +} +``` + +### Finding kinds + +| Kind | Classification | +|---|---| +| `auto-fixable` | `failed_retryable`; patch reported but not applied | +| `ask-user` | `needs_decision` (no Decision) or continue (approved Decision) | +| `blocking` | `failed_terminal` | + +### Fingerprint construction + +**For managed validation**, fingerprints use structural identity only: + +Components (in order): + +1. `"fpv1"` — fingerprint protocol version prefix +2. stage name +3. finding code (required; stable rule/defect identifier) +4. finding class (required; stable category) +5. finding kind +6. sorted, deduplicated, normalized repository-relative paths (required; separator normalized to `/`) +7. finding symbol/locus (strongly recommended when applicable; e.g., function name) + +Each component is separated by `\x00`. The fingerprint is `sha256:` of the UTF-8 joined string. + +**Important**: The description is intentionally omitted from managed fingerprints to ensure stability +across paraphrasing. This requires all managed findings to provide stable structural fields (code, class, paths). +A finding missing any required structural field is rejected at preflight with `infrastructure_error`. + +### Finding identity requirements + +For managed validation, every finding must include: + +- **code**: Stable, rule- or defect-specific identifier (e.g., `review.security_issue`, `style.naming`) +- **class**: Stable category (e.g., `security`, `style`, `architecture`) +- **paths**: One or more repository-relative paths affected by the finding +- **symbol**: Strongly recommended when applicable (e.g., function name, class name, line range) +- **description**: Human-readable explanation (not used in fingerprint; serves as evidence) + +--- + +## 12. Decision input contract + +Optional `--decisions` file: + +```json +{ + "schema_version": 1, + "run_id": "G-229", + "mission_id": "M-402", + "base_sha": "1111111111111111111111111111111111111111", + "input_sha": "2222222222222222222222222222222222222222", + "policy_hash": "sha256:64aec94d8e1fade3975101ba87f44076e4487016c87c6cf8d24857aad2e28d27", + "decisions": [ + { + "decision_id": "D-184", + "finding_fingerprint": "sha256:aaabbbcccddd...", + "outcome": "approved", + "scope": "sha_bound", + "rationale": "Accepted for this validation input" + } + ] +} +``` + +### Supported decision outcomes + +- `approved` — permits ask-user finding to continue +- `rejected` — produces `failed_terminal` + +### Binding rules + +The Decisions file is rejected when: + +- Schema version is unsupported +- `run_id`, `mission_id`, `base_sha`, `input_sha`, or `policy_hash` differ from CLI flags +- Duplicate `decision_id` values conflict +- Duplicate fingerprints contain conflicting outcomes +- A Decision references a malformed fingerprint + +### Application rules + +- Approved Decision permits ask-user finding to continue +- Rejected Decision → `failed_terminal` +- Missing Decision for ask-user → `needs_decision` +- A Decision cannot approve an auto-fixable finding +- A Decision cannot override a blocking finding +- Unused Decisions are reported in evidence + +--- + +## 13. Evidence layout + +``` +/ + / (SHA-256 of run_id, lowercase hex, 64 chars) + / (unique per invocation; lowercase hex, 16 chars) + review/ + findings.json (structured findings from review Agent) + test/ + stdout.log (test stage output) + stderr.log (test stage errors) + document/ + findings.json (documentation findings) + lint/ + stdout.log (lint stage output) + stderr.log (lint stage errors) + terminal.json (run summary and terminal outcome) +``` + +### Referencing evidence + +Evidence paths in events are relative to `` and include both the hashed run ID and invocation ID: + +``` +//stage/file +``` + +Example: `64aec94d8e1fade.../1234567890abcdef/review/findings.json` + +To resolve an evidence reference, use: `/` + +The hashed run ID (`sha256:.hex()`) allows multiple invocations (reruns) to share the same hashed +directory while isolating evidence by invocation instance. This enables efficient batch review of reruns +without requiring separate run ID directories. + +### terminal.json + +Summarizes the complete run: + +```json +{ + "run_id": "G-229", + "mission_id": "M-402", + "base_sha": "1111111111111111111111111111111111111111", + "input_sha": "2222222222222222222222222222222222222222", + "policy_hash": "sha256:...", + "stage_results": [], + "findings": [], + "decisions_applied": [], + "outcome": "passed", + "event_count": 12, + "evidence_refs": [], + "made_version": "..." +} +``` + +- No evidence commit is created +- No evidence branch is pushed +- Evidence writes are atomic where practical (write-temp-then-rename) +- An evidence-write failure cannot be reported as validation success + +--- + +## 14. Compatibility guarantees + +Managed mode does not modify: + +- `made run submit` / `status` / `list` / `cancel` +- `made review decide` +- `made daemon` +- `made gate` +- `made doctor` +- `made capabilities` +- The standalone review auto-fix behavior +- The standalone pipeline's `parkForApproval` wait +- Any daemon persistence + +`made capabilities --json` is extended additively: `"validate.managed.v1"` is added to the `commands` list. + +--- + +## 15. Crash and cancellation behavior + +On OS signal or context cancellation: + +1. Made emits one `run.completed` event with outcome `canceled` +2. Exits with code 130 +3. Evidence collected up to cancellation is preserved +4. No cleanup of the workspace is attempted +5. The workspace state after cancellation is undefined; the caller should treat it as potentially dirty + +On an unexpected panic: evidence is best-effort. The exit code is non-zero (not 130). The caller should treat the run as `infrastructure_error`. + +--- + +## 16. Explicit non-goals + +Managed V1 does not implement: + +- Consigliere integration code or client +- Mission repair budgets or Mission-level waiver authorization +- Workspace creation or trusted mirror creation +- Privileged Git push, PR creation, CI monitoring, merge +- Made checkpoint/resume +- Made stage caching +- Bidirectional tracker synchronization +- Herdr integration +- A second Agent kind +- Generic SCM adapters +- A TUI or new persistence database +- A replacement for the standalone daemon + +--- + +## 17. Sample invocations and streams + +### Sample invocation + +```bash +made validate --managed --json-events \ + --run-id G-229 \ + --mission-id M-402 \ + --workspace /tmp/ws/repo \ + --base-sha 1111111111111111111111111111111111111111 \ + --input-sha 2222222222222222222222222222222222222222 \ + --trusted-config /trusted/.made.yml \ + --policy-hash sha256:aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899 \ + --evidence-dir /evidence \ + --decisions /decisions/G-229.json +``` + +### Sample passing stream + +```jsonl +{"schema_version":1,"protocol_version":1,"sequence":1,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"run.started","timestamp":"2026-08-18T21:00:00.000000000Z","payload":{}} +{"schema_version":1,"protocol_version":1,"sequence":2,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"stage.started","timestamp":"2026-08-18T21:00:00.100000000Z","payload":{"stage":"review"}} +{"schema_version":1,"protocol_version":1,"sequence":3,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"evidence.created","timestamp":"2026-08-18T21:00:05.000000000Z","payload":{"stage":"review","path":"G-229/review/findings.json"}} +{"schema_version":1,"protocol_version":1,"sequence":4,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"stage.completed","timestamp":"2026-08-18T21:00:05.100000000Z","payload":{"stage":"review","outcome":"passed"}} +{"schema_version":1,"protocol_version":1,"sequence":5,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"stage.started","timestamp":"2026-08-18T21:00:05.200000000Z","payload":{"stage":"test"}} +{"schema_version":1,"protocol_version":1,"sequence":6,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"evidence.created","timestamp":"2026-08-18T21:00:10.000000000Z","payload":{"stage":"test","path":"G-229/test/stdout.log"}} +{"schema_version":1,"protocol_version":1,"sequence":7,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"stage.completed","timestamp":"2026-08-18T21:00:10.100000000Z","payload":{"stage":"test","outcome":"passed"}} +{"schema_version":1,"protocol_version":1,"sequence":8,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"stage.started","timestamp":"2026-08-18T21:00:10.200000000Z","payload":{"stage":"document"}} +{"schema_version":1,"protocol_version":1,"sequence":9,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"stage.completed","timestamp":"2026-08-18T21:00:10.300000000Z","payload":{"stage":"document","outcome":"passed"}} +{"schema_version":1,"protocol_version":1,"sequence":10,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"stage.started","timestamp":"2026-08-18T21:00:10.400000000Z","payload":{"stage":"lint"}} +{"schema_version":1,"protocol_version":1,"sequence":11,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"evidence.created","timestamp":"2026-08-18T21:00:11.000000000Z","payload":{"stage":"lint","path":"G-229/lint/stdout.log"}} +{"schema_version":1,"protocol_version":1,"sequence":12,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"stage.completed","timestamp":"2026-08-18T21:00:11.100000000Z","payload":{"stage":"lint","outcome":"passed"}} +{"schema_version":1,"protocol_version":1,"sequence":13,"run_id":"G-229","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","policy_hash":"sha256:aabb...","event":"run.completed","timestamp":"2026-08-18T21:00:11.200000000Z","payload":{"outcome":"passed","stage":"lint","message":"all managed validation stages passed","findings":[],"evidence_refs":[]}} +``` + +### Sample needs-decision stream (review ask-user, no Decision supplied) + +```jsonl +{"schema_version":1,"protocol_version":1,"sequence":1,...,"event":"run.started","payload":{}} +{"schema_version":1,"protocol_version":1,"sequence":2,...,"event":"stage.started","payload":{"stage":"review"}} +{"schema_version":1,"protocol_version":1,"sequence":3,...,"event":"finding.reported","payload":{"fingerprint":"sha256:1234...","stage":"review","kind":"ask-user","code":"review.architecture_choice","description":"New dependency added without ADR","paths":["go.mod"]}} +{"schema_version":1,"protocol_version":1,"sequence":4,...,"event":"run.completed","payload":{"outcome":"needs_decision","stage":"review","message":"1 ask-user finding(s) require a Decision","findings":[...],"evidence_refs":[]}} +``` + +### Sample failed-retryable stream (auto-fixable review finding) + +```jsonl +{"schema_version":1,"protocol_version":1,"sequence":1,...,"event":"run.started","payload":{}} +{"schema_version":1,"protocol_version":1,"sequence":2,...,"event":"stage.started","payload":{"stage":"review"}} +{"schema_version":1,"protocol_version":1,"sequence":3,...,"event":"finding.reported","payload":{"fingerprint":"sha256:abcd...","stage":"review","kind":"auto-fixable","code":"review.formatting","description":"gofmt needed","paths":["internal/foo.go"],"patch":"--- a/internal/foo.go\n+++ b/internal/foo.go\n..."}} +{"schema_version":1,"protocol_version":1,"sequence":4,...,"event":"run.completed","payload":{"outcome":"failed_retryable","stage":"review","message":"1 auto-fixable finding(s) require repair","findings":[...],"evidence_refs":[]}} +``` diff --git a/internal/agent/findings.go b/internal/agent/findings.go index 36b0fea..4f667d9 100644 --- a/internal/agent/findings.go +++ b/internal/agent/findings.go @@ -19,6 +19,11 @@ type Finding struct { Description string `json:"description"` Patch string `json:"patch,omitempty"` Paths []string `json:"paths,omitempty"` + // Optional fields added for managed-validation structured output. + // Existing agents that do not emit these fields continue to work. + Code string `json:"code,omitempty"` + Class string `json:"class,omitempty"` + Symbol string `json:"symbol,omitempty"` } func (f Finding) MarshalJSON() ([]byte, error) { @@ -35,11 +40,17 @@ func (f Finding) MarshalJSON() ([]byte, error) { Description string `json:"description"` Patch *string `json:"patch"` Paths []string `json:"paths"` + Code string `json:"code,omitempty"` + Class string `json:"class,omitempty"` + Symbol string `json:"symbol,omitempty"` }{ Kind: f.Kind, Description: f.Description, Patch: patch, Paths: paths, + Code: f.Code, + Class: f.Class, + Symbol: f.Symbol, }) } @@ -49,6 +60,9 @@ func (f *Finding) UnmarshalJSON(data []byte) error { Description *string `json:"description"` Patch json.RawMessage `json:"patch"` Paths json.RawMessage `json:"paths"` + Code string `json:"code"` + Class string `json:"class"` + Symbol string `json:"symbol"` } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() @@ -62,6 +76,9 @@ func (f *Finding) UnmarshalJSON(data []byte) error { f.Description = *wire.Description f.Patch = "" f.Paths = nil + f.Code = wire.Code + f.Class = wire.Class + f.Symbol = wire.Symbol if !bytes.Equal(bytes.TrimSpace(wire.Patch), []byte("null")) { if err := json.Unmarshal(wire.Patch, &f.Patch); err != nil { return fmt.Errorf("finding patch must be a string or null: %w", err) diff --git a/internal/agent/review_schema_test.go b/internal/agent/review_schema_test.go index 1062566..437f9c6 100644 --- a/internal/agent/review_schema_test.go +++ b/internal/agent/review_schema_test.go @@ -20,8 +20,11 @@ func TestReviewSchemaRequiresEveryFindingProperty(t *testing.T) { if err := json.Unmarshal([]byte(reviewSchema), &schema); err != nil { t.Fatalf("review schema is not JSON: %v", err) } - if len(schema.Properties.Findings.Items.Properties) != len(schema.Properties.Findings.Items.Required) { - t.Fatalf("strict output schema must require every finding property: properties=%v required=%v", schema.Properties.Findings.Items.Properties, schema.Properties.Findings.Items.Required) + // Required properties must all appear in properties. + for _, req := range schema.Properties.Findings.Items.Required { + if _, ok := schema.Properties.Findings.Items.Properties[req]; !ok { + t.Fatalf("required finding property %q is not in properties", req) + } } for _, property := range []string{"kind", "description", "patch", "paths"} { if _, ok := schema.Properties.Findings.Items.Properties[property]; !ok { @@ -31,6 +34,15 @@ func TestReviewSchemaRequiresEveryFindingProperty(t *testing.T) { t.Fatalf("review schema does not require finding property %q", property) } } + // Optional fields may appear in properties but not in required. + for _, optional := range []string{"code", "class", "symbol"} { + if _, ok := schema.Properties.Findings.Items.Properties[optional]; !ok { + t.Fatalf("review schema missing optional finding property %q", optional) + } + if slices.Contains(schema.Properties.Findings.Items.Required, optional) { + t.Fatalf("review schema incorrectly requires optional finding property %q", optional) + } + } } func TestStrictFindingsRejectsMissingRequiredNullableProperties(t *testing.T) { diff --git a/internal/agent/reviewcontract.go b/internal/agent/reviewcontract.go index 1c00503..3c28f77 100644 --- a/internal/agent/reviewcontract.go +++ b/internal/agent/reviewcontract.go @@ -111,6 +111,78 @@ func NewReviewTask(input ReviewInput) (ReviewTask, error) { return ReviewTask{Contract: contract, Text: text}, nil } +// NewManagedReviewTask builds a review task for managed-validation mode. +// Managed mode has stricter requirements than standalone review: +// - Every finding must include a stable, finding-specific code (e.g., "sql_injection", not "security") +// - Every finding must include a class from the finding taxonomy +// - Every finding must include repository-relative paths (normalized, no ".." or ".") +// - Multi-finding files must include symbol or locus to disambiguate +// +// This ensures Decisions can be reliably reapplied across runs and paraphrases. +func NewManagedReviewTask(input ReviewInput) (ReviewTask, error) { + baseBranch := strings.TrimSpace(input.TrustedBaseBranch) + if baseBranch == "" { + return ReviewTask{}, fmt.Errorf("agent: trusted base branch is required") + } + if strings.ContainsAny(baseBranch, "\r\n") { + return ReviewTask{}, fmt.Errorf("agent: trusted base branch contains a newline") + } + if err := validateReviewSHA("trusted base SHA", input.TrustedBaseSHA); err != nil { + return ReviewTask{}, err + } + if err := validateReviewSHA("candidate input SHA", input.CandidateInputSHA); err != nil { + return ReviewTask{}, err + } + if input.CandidateOutputSHA != "" { + if err := validateReviewSHA("candidate output SHA", input.CandidateOutputSHA); err != nil { + return ReviewTask{}, err + } + } + + contract := ReviewContract{ + PromptVersion: ReviewPromptVersion, + OutputSchemaVersion: ReviewOutputSchemaVersion, + TrustedBaseBranch: baseBranch, + TrustedBaseSHA: input.TrustedBaseSHA, + CandidateInputSHA: input.CandidateInputSHA, + CandidateOutputSHA: input.CandidateOutputSHA, + DiffCommand: fmt.Sprintf("git diff --no-ext-diff --unified=80 %s..%s --", input.TrustedBaseSHA, input.CandidateInputSHA), + Scope: "Review only defects introduced by the candidate change relative to the trusted base, including directly affected behavior and public interfaces.", + FindingTaxonomy: append([]string(nil), reviewFindingTaxonomy...), + FindingKinds: append([]string(nil), reviewFindingKinds...), + FindingPolicy: []string{ + "auto-fixable: a fully specified mechanical patch for an introduced defect with exact tracked affected paths; Made applies it only after controlled path, index, and validation checks.", + "ask-user: a finding that requires human judgment or a policy choice; preserve it for explicit approval and never apply a patch automatically.", + "blocking: an introduced defect that makes delivery unsafe; halt the stage and never apply a patch automatically.", + }, + Exclusions: []string{ + "unrelated legacy defects", + "general style advice", + "broad refactoring suggestions", + }, + } + contractJSON, err := json.Marshal(contract) + if err != nil { + return ReviewTask{}, fmt.Errorf("agent: encode managed review contract: %w", err) + } + text := "Managed-validation mode: Every finding MUST include stable structural identity. " + + "Inspect the exact candidate diff before deciding that findings are empty. " + + "Return only the structured object matching the supplied output schema.\n" + + "MADE_REVIEW_CONTRACT=" + string(contractJSON) + "\n" + + "MANAGED MODE REQUIREMENTS:\n" + + "- code: A stable, finding-specific identifier (e.g., 'sql_injection', not 'security'). Must uniquely identify this class of defect within the file. The same code describes the same defect across paraphrases and reruns.\n" + + "- class: One of the finding_taxonomy values, indicating the category of this defect.\n" + + "- paths: Nonempty array of repository-relative file paths affected by this finding (e.g., ['src/auth.go', 'src/token.go']). Paths must be normalized (no '..' or '.' components).\n" + + "- symbol (when multiple findings affect the same file): Stable locus or function name to disambiguate (e.g., 'validateToken', 'line 42').\n" + + "Review every taxonomy category that applies, report exact affected paths for every patch, " + + "include complete structural identity for every finding, " + + "and do not report excluded material.\n" + if len([]byte(text)) > maxReviewTaskBytes { + return ReviewTask{}, fmt.Errorf("agent: managed review task exceeds %d bytes", maxReviewTaskBytes) + } + return ReviewTask{Contract: contract, Text: text}, nil +} + func validateReviewSHA(label, value string) error { if len(value) < 40 || len(value) > 64 { return fmt.Errorf("agent: %s must be a full Git commit SHA", label) diff --git a/internal/agent/reviewcontract_test.go b/internal/agent/reviewcontract_test.go index 95433c9..e83daf0 100644 --- a/internal/agent/reviewcontract_test.go +++ b/internal/agent/reviewcontract_test.go @@ -65,3 +65,62 @@ func TestReviewTask_RejectsMissingTrustedBaseIdentity(t *testing.T) { t.Fatalf("NewReviewTask error = %v, want actionable trusted-base error", err) } } + +func TestManagedReviewTask_DefinedForStrictStructuralIdentity(t *testing.T) { + input := agent.ReviewInput{ + TrustedBaseBranch: "main", + TrustedBaseSHA: strings.Repeat("a", 40), + CandidateInputSHA: strings.Repeat("b", 40), + CandidateOutputSHA: strings.Repeat("c", 40), + } + + task, err := agent.NewManagedReviewTask(input) + if err != nil { + t.Fatalf("NewManagedReviewTask: %v", err) + } + + // Managed task must have same versions and identity as standard task + if task.Contract.PromptVersion != agent.ReviewPromptVersion { + t.Fatalf("prompt version = %q, want %q", task.Contract.PromptVersion, agent.ReviewPromptVersion) + } + if task.Contract.OutputSchemaVersion != agent.ReviewOutputSchemaVersion { + t.Fatalf("output schema version = %q, want %q", task.Contract.OutputSchemaVersion, agent.ReviewOutputSchemaVersion) + } + if task.Contract.TrustedBaseSHA != input.TrustedBaseSHA || task.Contract.CandidateInputSHA != input.CandidateInputSHA { + t.Fatalf("task identity mismatch") + } + + // Managed task must emphasize finding-specific code requirement + if !strings.Contains(task.Text, "Managed-validation mode") { + t.Fatalf("managed prompt missing mode marker") + } + if !strings.Contains(task.Text, "finding-specific") { + t.Fatalf("managed prompt missing finding-specific requirement") + } + if !strings.Contains(task.Text, "code") || !strings.Contains(task.Text, "class") { + t.Fatalf("managed prompt missing code or class requirements") + } + + // Managed task must include repository-relative path requirements + if !strings.Contains(task.Text, "repository-relative") { + t.Fatalf("managed prompt missing repository-relative path requirement") + } + + // Verify contract marker is present and valid + const marker = "MADE_REVIEW_CONTRACT=" + lineStart := strings.Index(task.Text, marker) + if lineStart < 0 { + t.Fatalf("managed review task omitted contract marker: %q", task.Text) + } + line := task.Text[lineStart+len(marker):] + if newline := strings.IndexByte(line, '\n'); newline >= 0 { + line = line[:newline] + } + var embedded agent.ReviewContract + if err := json.Unmarshal([]byte(line), &embedded); err != nil { + t.Fatalf("decode managed embedded review contract: %v", err) + } + if !reflect.DeepEqual(embedded, task.Contract) { + t.Fatalf("managed embedded contract mismatch") + } +} diff --git a/internal/agent/spawn.go b/internal/agent/spawn.go index 9cdf571..da9a9aa 100644 --- a/internal/agent/spawn.go +++ b/internal/agent/spawn.go @@ -227,4 +227,4 @@ func strictFindings(data []byte) (Findings, error) { return findings, nil } -const reviewSchema = `{"type":"object","additionalProperties":false,"required":["findings"],"properties":{"findings":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["kind","description","patch","paths"],"properties":{"kind":{"type":"string","enum":["auto-fixable","ask-user","blocking"]},"description":{"type":"string"},"patch":{"type":["string","null"]},"paths":{"type":["array","null"],"items":{"type":"string"}}}}}}}` +const reviewSchema = `{"type":"object","additionalProperties":false,"required":["findings"],"properties":{"findings":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["kind","description","patch","paths"],"properties":{"kind":{"type":"string","enum":["auto-fixable","ask-user","blocking"]},"description":{"type":"string"},"patch":{"type":["string","null"]},"paths":{"type":["array","null"],"items":{"type":"string"}},"code":{"type":"string"},"class":{"type":"string"},"symbol":{"type":"string"}}}}}}` diff --git a/internal/config/file.go b/internal/config/file.go index 75f1f16..e948412 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -163,3 +163,53 @@ func (c Config) hasConfiguredValue() bool { len(c.Commands.Lint) > 0 || len(c.Agent) > 0 || len(c.Agents) > 0 || c.AllowRepoCommands || len(c.Stages) > 0 } + +// ParseBytes parses a Config from an already-read byte slice. +// This is used by managed-validation mode, which reads and hash-verifies the +// config bytes exactly once before parsing, to avoid TOCTOU issues. +func ParseBytes(data []byte) (Config, error) { + cfg, _, err := parseConfigBytes(data, "") + if err != nil { + return Config{}, err + } + if err := cfg.Validate(); err != nil { + return Config{}, fmt.Errorf("config: validate: %w", err) + } + return cfg, nil +} + +// parseConfigBytes decodes a Config from raw YAML bytes. +func parseConfigBytes(data []byte, label string) (Config, bool, error) { + if len(data) > maxConfigBytes { + return Config{}, false, fmt.Errorf("config: %s exceeds %d bytes", label, maxConfigBytes) + } + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + var cfg Config + if err := decoder.Decode(&cfg); err != nil { + return Config{}, false, err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return Config{}, false, fmt.Errorf("configuration must contain one YAML document") + } + return Config{}, false, err + } + if cfg.Version != 1 { + return Config{}, false, fmt.Errorf("versioned .made.yml requires version: 1, got %d", cfg.Version) + } + for name := range cfg.Stages { + if _, ok := validStageNames[name]; !ok { + return Config{}, false, fmt.Errorf("versioned .made.yml has unknown stage %q", name) + } + stage := cfg.Stages[name] + if stage.TimeoutSeconds != nil && (*stage.TimeoutSeconds <= 0 || *stage.TimeoutSeconds > maxStageTimeoutSeconds) { + return Config{}, false, fmt.Errorf("versioned .made.yml stage %q timeout_seconds must be between 1 and %d", name, maxStageTimeoutSeconds) + } + } + if retention := cfg.Test.Evidence.RetentionBytes; retention != nil && (*retention <= 0 || *retention > maxEvidenceRetention) { + return Config{}, false, fmt.Errorf("versioned .made.yml test.evidence.retention_bytes must be between 1 and %d", maxEvidenceRetention) + } + return cfg, true, nil +} diff --git a/internal/evidence/testhelpers_test.go b/internal/evidence/testhelpers_test.go index 0259061..3527f89 100644 --- a/internal/evidence/testhelpers_test.go +++ b/internal/evidence/testhelpers_test.go @@ -25,6 +25,11 @@ func commitEnv() []string { "GIT_AUTHOR_EMAIL=evidence-test@example.com", "GIT_COMMITTER_NAME=evidence-test", "GIT_COMMITTER_EMAIL=evidence-test@example.com", + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", } } @@ -37,9 +42,21 @@ func runEnv(t *testing.T, dir string, extraEnv []string, name string, args ...st t.Helper() cmd := exec.Command(name, args...) cmd.Dir = dir + env := os.Environ() if extraEnv != nil { - cmd.Env = append(os.Environ(), extraEnv...) + env = append(env, extraEnv...) } + // Ensure git config is set for all git commands in tests + if name == "git" { + env = append(env, + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", + ) + } + cmd.Env = env out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("%s %v in %s failed: %v: %s", name, args, dir, err, out) @@ -50,6 +67,13 @@ func runEnv(t *testing.T, dir string, extraEnv []string, name string, args ...st func runNoFatal(dir string, name string, args ...string) (string, error) { cmd := exec.Command(name, args...) cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", + ) out, err := cmd.CombinedOutput() return string(out), err } diff --git a/internal/gitgate/bare.go b/internal/gitgate/bare.go index 088ab08..78d4678 100644 --- a/internal/gitgate/bare.go +++ b/internal/gitgate/bare.go @@ -16,6 +16,12 @@ func InitBare(path string) error { return fmt.Errorf("gitgate: create parent dir for %s: %w", path, err) } cmd := exec.Command("git", "init", "--bare", path) + // Override safe.bareRepository for this bare repo's initialization to permit access. + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=safe.bareRepository", + "GIT_CONFIG_VALUE_0=all", + ) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("gitgate: git init --bare %s: %w: %s", path, err, strings.TrimSpace(string(out))) diff --git a/internal/gitgate/bare_test.go b/internal/gitgate/bare_test.go index 9dcb059..90848d0 100644 --- a/internal/gitgate/bare_test.go +++ b/internal/gitgate/bare_test.go @@ -1,6 +1,7 @@ package gitgate_test import ( + "os" "os/exec" "path/filepath" "strings" @@ -16,7 +17,13 @@ func TestInitBareCreatesBareRepository(t *testing.T) { t.Fatalf("InitBare(%q) returned error: %v", repoPath, err) } - out, err := exec.Command("git", "-C", repoPath, "rev-parse", "--is-bare-repository").Output() + cmd := exec.Command("git", "-C", repoPath, "rev-parse", "--is-bare-repository") + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=safe.bareRepository", + "GIT_CONFIG_VALUE_0=all", + ) + out, err := cmd.Output() if err != nil { t.Fatalf("git rev-parse --is-bare-repository failed: %v", err) } diff --git a/internal/gitgate/testhelpers_test.go b/internal/gitgate/testhelpers_test.go index d63b7c8..8ab4737 100644 --- a/internal/gitgate/testhelpers_test.go +++ b/internal/gitgate/testhelpers_test.go @@ -33,9 +33,21 @@ func run(t *testing.T, dir string, extraEnv []string, name string, args ...strin t.Helper() cmd := exec.Command(name, args...) cmd.Dir = dir + env := os.Environ() if extraEnv != nil { - cmd.Env = append(os.Environ(), extraEnv...) + env = append(env, extraEnv...) } + // Ensure safe.bareRepository and gpgsign are configured for git operations in tests. + if name == "git" { + env = append(env, + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", + ) + } + cmd.Env = env out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("%s %v in %s failed: %v: %s", name, args, dir, err, out) @@ -46,6 +58,11 @@ func run(t *testing.T, dir string, extraEnv []string, name string, args ...strin func pushRef(dir, remote string) (string, error) { cmd := exec.Command("git", "push", remote, "HEAD:refs/heads/main") cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=safe.bareRepository", + "GIT_CONFIG_VALUE_0=all", + ) out, err := cmd.CombinedOutput() return string(out), err } diff --git a/internal/gitgate/worktree.go b/internal/gitgate/worktree.go index a0b0c52..8ccf580 100644 --- a/internal/gitgate/worktree.go +++ b/internal/gitgate/worktree.go @@ -7,6 +7,17 @@ import ( "strings" ) +// bareRepoEnv returns environment variables for running git commands on bare repositories. +// It sets safe.bareRepository=all to allow operations on bare repos when the system +// has safe.bareRepository=explicit configured. +func bareRepoEnv() []string { + return []string{ + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=safe.bareRepository", + "GIT_CONFIG_VALUE_0=all", + } +} + type Worktree struct { Path string barePath string @@ -26,6 +37,7 @@ func AddWorktree(barePath, worktreesDir, ref string) (*Worktree, error) { cmd := exec.Command("git", "worktree", "add", slot, ref) cmd.Dir = barePath + cmd.Env = append(os.Environ(), bareRepoEnv()...) if out, err := cmd.CombinedOutput(); err != nil { return nil, fmt.Errorf("gitgate: git worktree add %s %s: %w: %s", slot, ref, err, strings.TrimSpace(string(out))) } @@ -35,6 +47,7 @@ func AddWorktree(barePath, worktreesDir, ref string) (*Worktree, error) { func (w *Worktree) Remove() error { cmd := exec.Command("git", "worktree", "remove", "--force", w.Path) cmd.Dir = w.barePath + cmd.Env = append(os.Environ(), bareRepoEnv()...) out, err := cmd.CombinedOutput() if err == nil { return nil @@ -45,6 +58,7 @@ func (w *Worktree) Remove() error { } pruneCmd := exec.Command("git", "worktree", "prune") pruneCmd.Dir = w.barePath + pruneCmd.Env = append(os.Environ(), bareRepoEnv()...) _ = pruneCmd.Run() return nil } diff --git a/internal/managed/contract.go b/internal/managed/contract.go new file mode 100644 index 0000000..4388a4a --- /dev/null +++ b/internal/managed/contract.go @@ -0,0 +1,134 @@ +package managed + +import ( + "time" +) + +// SchemaVersion is the event envelope schema version. +const SchemaVersion = 1 + +// ProtocolVersion is the managed-validation protocol version. +const ProtocolVersion = 1 + +// Outcome represents the terminal validation result. +type Outcome string + +const ( + OutcomePassed Outcome = "passed" + OutcomeNeedsDecision Outcome = "needs_decision" + OutcomeFailedRetryable Outcome = "failed_retryable" + OutcomeFailedTerminal Outcome = "failed_terminal" + OutcomeInfrastructureError Outcome = "infrastructure_error" + OutcomeCanceled Outcome = "canceled" +) + +// ExitCode returns the process exit code for a given outcome. +func (o Outcome) ExitCode() int { + switch o { + case OutcomePassed: + return 0 + case OutcomeInfrastructureError: + return 1 + case OutcomeNeedsDecision: + return 3 + case OutcomeFailedRetryable: + return 4 + case OutcomeFailedTerminal: + return 5 + case OutcomeCanceled: + return 130 + default: + return 1 + } +} + +// Options holds the validated, parsed parameters for a managed-validation run. +type Options struct { + RunID string + MissionID string + Workspace string + BaseSHA string + InputSHA string + TrustedConfig string + PolicyHash string + EvidenceDir string + DecisionsPath string // optional + + // InvocationID uniquely identifies a single Run invocation. It is generated + // internally by Run and used to isolate evidence paths across reruns. + InvocationID string + + // ReviewAgentBinaryPath overrides the agent binary path for testing. + // Leave empty in production. + ReviewAgentBinaryPath string + // ReviewAgentExtraEnv provides additional env vars for the agent process (testing). + ReviewAgentExtraEnv []string +} + +// Event is one line in the JSON-Lines event stream. +type Event struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + Sequence int `json:"sequence"` + RunID string `json:"run_id"` + InvocationID string `json:"invocation_id"` + MissionID string `json:"mission_id"` + InputSHA string `json:"input_sha"` + BaseSHA string `json:"base_sha"` + PolicyHash string `json:"policy_hash"` + EventType string `json:"event"` + Timestamp time.Time `json:"timestamp"` + Payload any `json:"payload"` +} + +// RunStartedPayload is the payload for run.started. +type RunStartedPayload struct{} + +// StageStartedPayload is the payload for stage.started. +type StageStartedPayload struct { + Stage string `json:"stage"` +} + +// StageCompletedPayload is the payload for stage.completed. +type StageCompletedPayload struct { + Stage string `json:"stage"` + Outcome Outcome `json:"outcome"` + Message string `json:"message,omitempty"` +} + +// FindingReportedPayload is the payload for finding.reported. +type FindingReportedPayload struct { + Fingerprint string `json:"fingerprint"` + Stage string `json:"stage"` + Kind string `json:"kind"` + Code string `json:"code,omitempty"` + Class string `json:"class,omitempty"` + Description string `json:"description"` + Paths []string `json:"paths,omitempty"` + Symbol string `json:"symbol,omitempty"` + Patch string `json:"patch,omitempty"` +} + +// EvidenceCreatedPayload is the payload for evidence.created. +type EvidenceCreatedPayload struct { + Stage string `json:"stage"` + Path string `json:"path"` +} + +// RunCompletedPayload is the payload for run.completed. +type RunCompletedPayload struct { + Outcome Outcome `json:"outcome"` + Stage string `json:"stage"` + Message string `json:"message"` + InvocationID string `json:"invocation_id,omitempty"` + Findings []FindingReportedPayload `json:"findings"` + EvidenceRefs []string `json:"evidence_refs"` +} + +// StageResult records the outcome of a single stage. +type StageResult struct { + Stage string `json:"stage"` + Outcome Outcome `json:"outcome"` + Message string `json:"message,omitempty"` + Findings []FindingReportedPayload `json:"findings"` +} diff --git a/internal/managed/decisions.go b/internal/managed/decisions.go new file mode 100644 index 0000000..5a06b11 --- /dev/null +++ b/internal/managed/decisions.go @@ -0,0 +1,169 @@ +package managed + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "regexp" +) + +var fingerprintRegexp = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// DecisionOutcome represents the outcome of a single Decision. +type DecisionOutcome string + +const ( + DecisionApproved DecisionOutcome = "approved" + DecisionRejected DecisionOutcome = "rejected" +) + +// DecisionScope captures the scope metadata for a Decision. +type DecisionScope string + +const ( + ScopeOneShot DecisionScope = "one_shot" + ScopeSHABound DecisionScope = "sha_bound" + ScopeMissionFindingWaiver DecisionScope = "mission_finding_waiver" +) + +// DecisionRecord is one entry in the Decisions file. +type DecisionRecord struct { + DecisionID string `json:"decision_id"` + FindingFingerprint string `json:"finding_fingerprint"` + Outcome DecisionOutcome `json:"outcome"` + Scope DecisionScope `json:"scope"` + Rationale string `json:"rationale,omitempty"` +} + +// DecisionsFile is the parsed top-level Decisions JSON structure. +type DecisionsFile struct { + SchemaVersion int `json:"schema_version"` + RunID string `json:"run_id"` + InvocationID string `json:"invocation_id"` + MissionID string `json:"mission_id"` + InputSHA string `json:"input_sha"` + BaseSHA string `json:"base_sha"` + PolicyHash string `json:"policy_hash"` + Decisions []DecisionRecord `json:"decisions"` +} + +// Decisions holds the parsed and validated Decision records indexed by fingerprint. +type Decisions struct { + byFingerprint map[string]DecisionRecord + all []DecisionRecord +} + +// LoadDecisions parses and validates the Decisions file at path. +// opts is used to bind-check that the file matches the current run. +func LoadDecisions(path string, opts *Options) (*Decisions, error) { + if path == "" { + return &Decisions{byFingerprint: make(map[string]DecisionRecord)}, nil + } + + data, err := func() ([]byte, error) { + fi, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("decisions: stat %q: %w", path, err) + } + if !fi.Mode().IsRegular() { + return nil, fmt.Errorf("decisions: %q is not a regular file", path) + } + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("decisions: open %q: %w", path, err) + } + defer func() { _ = f.Close() }() + b, err := io.ReadAll(io.LimitReader(f, 1<<20+1)) + if err != nil { + return nil, fmt.Errorf("decisions: read %q: %w", path, err) + } + if len(b) > 1<<20 { + return nil, fmt.Errorf("decisions: file too large (>1 MiB): %q", path) + } + return b, nil + }() + if err != nil { + return nil, err + } + + var df DecisionsFile + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&df); err != nil { + return nil, fmt.Errorf("decisions: parse %q: %w", path, err) + } + + if df.SchemaVersion != 1 { + return nil, fmt.Errorf("decisions: unsupported schema_version %d (supported: 1)", df.SchemaVersion) + } + if df.RunID != opts.RunID { + return nil, fmt.Errorf("decisions: run_id mismatch: file has %q, expected %q", df.RunID, opts.RunID) + } + if df.MissionID != opts.MissionID { + return nil, fmt.Errorf("decisions: mission_id mismatch: file has %q, expected %q", df.MissionID, opts.MissionID) + } + if df.InputSHA != opts.InputSHA { + return nil, fmt.Errorf("decisions: input_sha mismatch: file has %q, expected %q", df.InputSHA, opts.InputSHA) + } + if df.BaseSHA != opts.BaseSHA { + return nil, fmt.Errorf("decisions: base_sha mismatch: file has %q, expected %q", df.BaseSHA, opts.BaseSHA) + } + if df.PolicyHash != opts.PolicyHash { + return nil, fmt.Errorf("decisions: policy_hash mismatch: file has %q, expected %q", df.PolicyHash, opts.PolicyHash) + } + + byFingerprint := make(map[string]DecisionRecord, len(df.Decisions)) + seenIDs := make(map[string]struct{}, len(df.Decisions)) + + for _, d := range df.Decisions { + if d.DecisionID == "" { + return nil, fmt.Errorf("decisions: empty decision_id") + } + if _, dup := seenIDs[d.DecisionID]; dup { + return nil, fmt.Errorf("decisions: duplicate decision_id %q", d.DecisionID) + } + seenIDs[d.DecisionID] = struct{}{} + + if !fingerprintRegexp.MatchString(d.FindingFingerprint) { + return nil, fmt.Errorf("decisions: decision %q has malformed fingerprint %q", d.DecisionID, d.FindingFingerprint) + } + if d.Outcome != DecisionApproved && d.Outcome != DecisionRejected { + return nil, fmt.Errorf("decisions: decision %q has invalid outcome %q", d.DecisionID, d.Outcome) + } + switch d.Scope { + case ScopeOneShot, ScopeSHABound, ScopeMissionFindingWaiver: + case "": + return nil, fmt.Errorf("decisions: decision %q has empty scope", d.DecisionID) + default: + return nil, fmt.Errorf("decisions: decision %q has unknown scope %q", d.DecisionID, d.Scope) + } + + if existing, exists := byFingerprint[d.FindingFingerprint]; exists { + if existing.Outcome != d.Outcome { + return nil, fmt.Errorf("decisions: conflicting outcomes for fingerprint %q: %q vs %q", d.FindingFingerprint, existing.Outcome, d.Outcome) + } + } + byFingerprint[d.FindingFingerprint] = d + } + + return &Decisions{byFingerprint: byFingerprint, all: df.Decisions}, nil +} + +// Lookup returns the Decision for a fingerprint, or (zero, false) if none. +func (d *Decisions) Lookup(fingerprint string) (DecisionRecord, bool) { + rec, ok := d.byFingerprint[fingerprint] + return rec, ok +} + +// All returns all Decision records (for reporting unused decisions). +func (d *Decisions) All() []DecisionRecord { + return append([]DecisionRecord(nil), d.all...) +} + +// MarkUsed records that a fingerprint was matched against a finding. +func (d *Decisions) MarkUsed(fingerprint string) { + // Used decisions are tracked by callers via separate set for now; + // this is a hook for future per-Decision usage tracking. +} diff --git a/internal/managed/doc.go b/internal/managed/doc.go new file mode 100644 index 0000000..1dd679d --- /dev/null +++ b/internal/managed/doc.go @@ -0,0 +1,8 @@ +// Package managed implements the Made managed-validation mode. +// +// Managed mode is a short-lived, daemonless execution shape invoked by +// Consigliere to validate an immutable input commit SHA. It runs validation +// stages (review, test, document, lint), emits a versioned JSON event stream +// to stdout, and returns one terminal outcome. It never waits for human input, +// never applies patches, and never mutates the workspace. +package managed diff --git a/internal/managed/events.go b/internal/managed/events.go new file mode 100644 index 0000000..717302b --- /dev/null +++ b/internal/managed/events.go @@ -0,0 +1,78 @@ +package managed + +import ( + "encoding/json" + "fmt" + "io" + "sync" + "time" +) + +// EventWriter writes JSON-Lines events to stdout with a monotonic sequence. +// All human-readable diagnostics must go to stderr; this writer owns stdout. +type EventWriter struct { + mu sync.Mutex + w io.Writer + seq int + opts *Options + closed bool +} + +// NewEventWriter creates an EventWriter bound to the given options. +func NewEventWriter(w io.Writer, opts *Options) *EventWriter { + return &EventWriter{w: w, opts: opts} +} + +// Emit writes one event to the output stream. +// It is safe to call from multiple goroutines, but only one goroutine should +// drive the managed runner at a time. +func (ew *EventWriter) Emit(eventType string, payload any) error { + ew.mu.Lock() + defer ew.mu.Unlock() + if ew.closed { + return fmt.Errorf("events: writer closed; cannot emit %q after terminal event", eventType) + } + ew.seq++ + ev := Event{ + SchemaVersion: SchemaVersion, + ProtocolVersion: ProtocolVersion, + Sequence: ew.seq, + RunID: ew.opts.RunID, + InvocationID: ew.opts.InvocationID, + MissionID: ew.opts.MissionID, + InputSHA: ew.opts.InputSHA, + BaseSHA: ew.opts.BaseSHA, + PolicyHash: ew.opts.PolicyHash, + EventType: eventType, + Timestamp: time.Now().UTC(), + Payload: payload, + } + data, err := json.Marshal(ev) + if err != nil { + return fmt.Errorf("events: marshal %q: %w", eventType, err) + } + data = append(data, '\n') + if _, err := ew.w.Write(data); err != nil { + return fmt.Errorf("events: write %q: %w", eventType, err) + } + if isTerminalEvent(eventType) { + ew.closed = true + } + return nil +} + +// EmitTerminal emits the run.completed terminal event and seals the writer. +func (ew *EventWriter) EmitTerminal(payload RunCompletedPayload) error { + return ew.Emit("run.completed", payload) +} + +// Sequence returns the current sequence counter (number of events emitted so far). +func (ew *EventWriter) Sequence() int { + ew.mu.Lock() + defer ew.mu.Unlock() + return ew.seq +} + +func isTerminalEvent(eventType string) bool { + return eventType == "run.completed" +} diff --git a/internal/managed/evidence.go b/internal/managed/evidence.go new file mode 100644 index 0000000..d527362 --- /dev/null +++ b/internal/managed/evidence.go @@ -0,0 +1,109 @@ +package managed + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// ManagedEvidenceStore writes evidence under ///. +// The safeRunID is derived from the run_id via SHA-256 to prevent path traversal. +// Each invocation gets its own subdirectory, preserving evidence from prior runs. +type ManagedEvidenceStore struct { + EvidenceDir string + RunID string + InvocationID string + safeRunID string +} + +// NewManagedEvidenceStore constructs a store bound to a specific invocation. +func NewManagedEvidenceStore(evidenceDir, runID, invocationID string) *ManagedEvidenceStore { + sum := sha256.Sum256([]byte(runID)) + return &ManagedEvidenceStore{ + EvidenceDir: evidenceDir, + RunID: runID, + InvocationID: invocationID, + safeRunID: hex.EncodeToString(sum[:]), + } +} + +// InvocationDir returns the directory for this specific invocation's evidence. +func (s *ManagedEvidenceStore) InvocationDir() string { + return filepath.Join(s.EvidenceDir, s.safeRunID, s.InvocationID) +} + +// StageDir returns the stage-specific evidence directory. +func (s *ManagedEvidenceStore) StageDir(stage string) string { + return filepath.Join(s.InvocationDir(), stage) +} + +// WriteStageFiles writes evidence files for a stage. +// Returns a list of paths relative to the evidence directory. +// The returned paths can be resolved as: / +func (s *ManagedEvidenceStore) WriteStageFiles(stage string, files map[string][]byte) ([]string, error) { + stageDir := s.StageDir(stage) + if err := os.MkdirAll(stageDir, 0o750); err != nil { + return nil, fmt.Errorf("evidence: create stage dir %q: %w", stageDir, err) + } + var refs []string + for name, data := range files { + destPath := filepath.Join(stageDir, name) + // Use a unique tmp name to avoid races with concurrent invocations. + tmpPath := destPath + "." + s.InvocationID + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o600); err != nil { + return nil, fmt.Errorf("evidence: write %q: %w", destPath, err) + } + if err := os.Rename(tmpPath, destPath); err != nil { + _ = os.Remove(tmpPath) + return nil, fmt.Errorf("evidence: rename %q: %w", destPath, err) + } + // Return path relative to evidence-dir: safeRunID/invocationID/stage/file + refs = append(refs, filepath.ToSlash(filepath.Join(s.safeRunID, s.InvocationID, stage, name))) + } + return refs, nil +} + +// WriteTerminal writes terminal.json for this invocation. +// This is the single authoritative summary file for the run outcome. +func (s *ManagedEvidenceStore) WriteTerminal(terminal any) error { + return s.writeJSON(filepath.Join(s.InvocationDir(), "terminal.json"), terminal) +} + +func (s *ManagedEvidenceStore) writeJSON(path string, v any) error { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return fmt.Errorf("evidence: create dir for %q: %w", path, err) + } + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("evidence: marshal %q: %w", path, err) + } + tmp := path + "." + s.InvocationID + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("evidence: write %q: %w", path, err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("evidence: rename %q: %w", path, err) + } + return nil +} + +// TerminalManifest is written to terminal.json at run completion. +type TerminalManifest struct { + RunID string `json:"run_id"` + MissionID string `json:"mission_id"` + InvocationID string `json:"invocation_id"` + BaseSHA string `json:"base_sha"` + InputSHA string `json:"input_sha"` + PolicyHash string `json:"policy_hash"` + StageResults []StageResult `json:"stage_results"` + Findings []FindingReportedPayload `json:"findings"` + DecisionsApplied []string `json:"decisions_applied"` + Outcome Outcome `json:"outcome"` + EventCount int `json:"event_count"` + EvidenceRefs []string `json:"evidence_refs"` + MadeVersion string `json:"made_version"` +} diff --git a/internal/managed/fingerprint.go b/internal/managed/fingerprint.go new file mode 100644 index 0000000..c935cdf --- /dev/null +++ b/internal/managed/fingerprint.go @@ -0,0 +1,145 @@ +package managed + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "path/filepath" + "sort" + "strings" +) + +// fingerprintVersion is the fingerprint protocol version prefix. +// Changing this value invalidates all existing fingerprints. +const fingerprintVersion = "fpv1" + +// FingerprintInput holds the normalized components used to compute a fingerprint. +type FingerprintInput struct { + Stage string + Kind string + Code string + Class string + Symbol string + Paths []string + Description string + // WorkspacePrefix is stripped from paths and description to avoid + // leaking absolute workspace paths into fingerprints. + WorkspacePrefix string +} + +// Fingerprint computes a deterministic, stable SHA-256 fingerprint for a finding. +// +// For managed mode, the fingerprint is based on structural identity: +// stage, kind, code, class, normalized paths, and symbol. The description +// is intentionally excluded to provide stability across paraphrasing, but +// this requires structural fields to be present. Managed findings without +// stable structural identity will be rejected at preflight. +// +// The format is: sha256:<64-lowercase-hex> +func Fingerprint(in FingerprintInput) string { + // Managed mode always uses structural fingerprinting. + // Description is never used (even if all structural fields are empty). + parts := []string{ + fingerprintVersion, + in.Stage, + in.Kind, + in.Code, + in.Class, + normalizePaths(in.Paths, in.WorkspacePrefix), + in.Symbol, + } + + joined := strings.Join(parts, "\x00") + sum := sha256.Sum256([]byte(joined)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// normalizePaths returns a normalized, sorted, deduplicated string of paths. +// Absolute workspace prefixes are stripped. Path separators are normalized to /. +func normalizePaths(paths []string, workspacePrefix string) string { + if len(paths) == 0 { + return "" + } + seen := make(map[string]struct{}, len(paths)) + for _, p := range paths { + clean := stripWorkspacePrefix(filepath.ToSlash(filepath.Clean(p)), workspacePrefix) + if clean != "" { + seen[clean] = struct{}{} + } + } + normalized := make([]string, 0, len(seen)) + for p := range seen { + normalized = append(normalized, p) + } + sort.Strings(normalized) + return strings.Join(normalized, "|") +} + +func stripWorkspacePrefix(path, workspacePrefix string) string { + if workspacePrefix == "" { + return path + } + prefix := filepath.ToSlash(filepath.Clean(workspacePrefix)) + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + if strings.HasPrefix(path, prefix) { + return path[len(prefix):] + } + return path +} + +// ValidateStableFindingIdentity checks that a finding has sufficient structural +// identity for managed validation. Managed findings must include stable fields +// to enable safe Decision binding across reruns with paraphrased descriptions. +// +// Requirements: +// - code: must be a non-empty, finding-specific identifier (e.g. "review.security_issue", +// not a generic category like "review.issue"). This ensures unique findings on +// the same file can be distinguished. +// - class: must be a non-empty category (e.g. "security", "style", "architecture") +// - paths: must contain at least one repository-relative path. Paths are validated to be: +// - repository-relative (not absolute) +// - clean (no redundant separators, no ".") +// - free of path-escape sequences ("../") +// - symbol/locus: strongly recommended when applicable (e.g., "function name") +// +// A finding without these fields cannot be safely bound to a Decision and will +// be rejected to prevent ambiguous decision application or unintended path escapes. +func ValidateStableFindingIdentity(in FingerprintInput) error { + if in.Code == "" { + return fmt.Errorf("finding missing required 'code' field (stable, finding-specific identifier like 'review.sql_injection', not generic 'review.issue')") + } + if in.Class == "" { + return fmt.Errorf("finding missing required 'class' field (stable category like 'security', 'style', 'architecture')") + } + if len(in.Paths) == 0 { + return fmt.Errorf("finding missing required 'paths' field (one or more repository-relative paths)") + } + + // Validate paths: must be relative, clean, and free of escape sequences. + for i, p := range in.Paths { + if p == "" { + return fmt.Errorf("finding path[%d] is empty", i) + } + if filepath.IsAbs(p) { + return fmt.Errorf("finding path[%d] is absolute: %q (must be repository-relative)", i, p) + } + + // Clean the path and check for modifications that indicate issues. + clean := filepath.Clean(p) + if clean != p { + return fmt.Errorf("finding path[%d] is not clean: %q → %q", i, p, clean) + } + + // Reject paths with ".." or "." components. + if strings.Contains(p, "..") { + return fmt.Errorf("finding path[%d] contains path-escape sequence '..': %q", i, p) + } + if p == "." || strings.HasPrefix(p, "./") || strings.HasSuffix(p, "/.") || strings.Contains(p, "/./") { + return fmt.Errorf("finding path[%d] contains invalid '.' component: %q", i, p) + } + } + + return nil +} diff --git a/internal/managed/fingerprint_test.go b/internal/managed/fingerprint_test.go new file mode 100644 index 0000000..13f177d --- /dev/null +++ b/internal/managed/fingerprint_test.go @@ -0,0 +1,147 @@ +package managed_test + +import ( + "testing" + + "github.com/douglasjarquin/made/internal/managed" +) + +func TestFingerprint_DeterministicForSameInput(t *testing.T) { + in := managed.FingerprintInput{ + Stage: "review", + Kind: "ask-user", + Code: "review.arch", + Class: "project-judgment", + Symbol: "MyFunc", + Paths: []string{"internal/foo.go", "internal/bar.go"}, + Description: "Missing ADR for new dependency", + } + fp1 := managed.Fingerprint(in) + fp2 := managed.Fingerprint(in) + if fp1 != fp2 { + t.Errorf("fingerprint not deterministic: %q vs %q", fp1, fp2) + } +} + +func TestFingerprint_PathOrderingDoesNotMatter(t *testing.T) { + base := managed.FingerprintInput{ + Stage: "review", Kind: "ask-user", Code: "rc", + Paths: []string{"a.go", "b.go"}, + } + reversed := managed.FingerprintInput{ + Stage: "review", Kind: "ask-user", Code: "rc", + Paths: []string{"b.go", "a.go"}, + } + if managed.Fingerprint(base) != managed.Fingerprint(reversed) { + t.Error("path ordering should not affect fingerprint") + } +} + +func TestFingerprint_DuplicatePathsDoNotMatter(t *testing.T) { + base := managed.FingerprintInput{ + Stage: "review", Kind: "ask-user", Code: "rc", + Paths: []string{"a.go"}, + } + dup := managed.FingerprintInput{ + Stage: "review", Kind: "ask-user", Code: "rc", + Paths: []string{"a.go", "a.go"}, + } + if managed.Fingerprint(base) != managed.Fingerprint(dup) { + t.Error("duplicate paths should not affect fingerprint") + } +} + +func TestFingerprint_AbsoluteWorkspacePrefixNotLeaked(t *testing.T) { + withPrefix := managed.FingerprintInput{ + Stage: "review", + Kind: "ask-user", + Code: "rc", + Paths: []string{"/workspace/repo/a.go"}, + WorkspacePrefix: "/workspace/repo", + } + withoutPrefix := managed.FingerprintInput{ + Stage: "review", + Kind: "ask-user", + Code: "rc", + Paths: []string{"a.go"}, + } + if managed.Fingerprint(withPrefix) != managed.Fingerprint(withoutPrefix) { + t.Error("absolute workspace prefix should be stripped before fingerprinting") + } +} + +func TestFingerprint_DifferentCodeProducesDifferentFingerprint(t *testing.T) { + a := managed.FingerprintInput{Stage: "review", Kind: "ask-user", Code: "code-a"} + b := managed.FingerprintInput{Stage: "review", Kind: "ask-user", Code: "code-b"} + if managed.Fingerprint(a) == managed.Fingerprint(b) { + t.Error("different code should produce different fingerprint") + } +} + +func TestFingerprint_DifferentSymbolProducesDifferentFingerprint(t *testing.T) { + a := managed.FingerprintInput{Stage: "review", Kind: "ask-user", Code: "c", Symbol: "FuncA"} + b := managed.FingerprintInput{Stage: "review", Kind: "ask-user", Code: "c", Symbol: "FuncB"} + if managed.Fingerprint(a) == managed.Fingerprint(b) { + t.Error("different symbol should produce different fingerprint") + } +} + +func TestFingerprint_DifferentStageProducesDifferentFingerprint(t *testing.T) { + a := managed.FingerprintInput{Stage: "review", Kind: "ask-user", Code: "c"} + b := managed.FingerprintInput{Stage: "document", Kind: "ask-user", Code: "c"} + if managed.Fingerprint(a) == managed.Fingerprint(b) { + t.Error("different stage should produce different fingerprint") + } +} + +func TestFingerprint_StartsWithSHA256Prefix(t *testing.T) { + fp := managed.Fingerprint(managed.FingerprintInput{ + Stage: "review", Kind: "ask-user", Code: "c", + }) + if len(fp) < 7 || fp[:7] != "sha256:" { + t.Errorf("fingerprint %q should start with sha256:", fp) + } + if len(fp) != 7+64 { + t.Errorf("fingerprint %q should be sha256: + 64 hex chars, got len %d", fp, len(fp)) + } +} + +func TestFingerprint_FallbackNormalizationIsDeterministic(t *testing.T) { + // Same description with extra whitespace should produce same fingerprint. + a := managed.FingerprintInput{Stage: "review", Kind: "ask-user", Description: "foo bar"} + b := managed.FingerprintInput{Stage: "review", Kind: "ask-user", Description: "foo bar"} + if managed.Fingerprint(a) != managed.Fingerprint(b) { + t.Error("whitespace normalization should produce same fingerprint") + } +} + +func TestFingerprint_StructuralPrimaryIgnoresDescription(t *testing.T) { + base := managed.FingerprintInput{ + Stage: "review", + Kind: "ask_user", + Code: "auth.token-rotation", + Class: "security", + Paths: []string{"internal/auth/token.go"}, + } + varied := base + varied.Description = "This cache-policy change could allow stale reads (completely different text)" + if managed.Fingerprint(base) != managed.Fingerprint(varied) { + t.Errorf("structural fingerprints should match regardless of description") + } +} + +func TestFingerprint_DescriptionFallback_StripLineRefs(t *testing.T) { + a := managed.FingerprintInput{ + Stage: "review", + Kind: "ask_user", + Description: "Check the cache policy at line 42", + } + b := managed.FingerprintInput{ + Stage: "review", + Kind: "ask_user", + Description: "Check the cache policy at line 99", + } + if managed.Fingerprint(a) != managed.Fingerprint(b) { + t.Errorf("description fallback fingerprints should match after stripping line refs") + } +} diff --git a/internal/managed/fixtures_test.go b/internal/managed/fixtures_test.go new file mode 100644 index 0000000..103b8c9 --- /dev/null +++ b/internal/managed/fixtures_test.go @@ -0,0 +1,288 @@ +package managed + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "regexp" + "testing" +) + +// TestFixturesAreValid ensures all checked-in JSONL and Decision fixtures are +// faithful to the protocol contract. This prevents documentation drift. +func TestFixturesAreValid(t *testing.T) { + testdataDir := "testdata" + + // Validate all JSONL fixtures + jsonlFiles := []string{ + "passed.jsonl", + "needs-decision.jsonl", + "failed-retryable.jsonl", + "failed-terminal.jsonl", + "infrastructure-error.jsonl", + } + + for _, fixture := range jsonlFiles { + t.Run("jsonl/"+fixture, func(t *testing.T) { + validateJSONLFixture(t, filepath.Join(testdataDir, fixture)) + }) + } + + // Validate all Decision fixtures + decisionFiles := []string{ + "decisions-approved.json", + "decisions-rejected.json", + } + + for _, fixture := range decisionFiles { + t.Run("decision/"+fixture, func(t *testing.T) { + validateDecisionFixture(t, filepath.Join(testdataDir, fixture)) + }) + } +} + +// validateJSONLFixture checks a JSONL event stream fixture for protocol validity. +func validateJSONLFixture(t *testing.T, path string) { + file, err := os.Open(path) + if err != nil { + t.Fatalf("failed to open fixture: %v", err) + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + var ( + lastSequence int + runID string + missionID string + invocationID string + inputSHA string + baseSHA string + policyHash string + terminalCount int + seenRunStarted bool + ) + + // Regex for validating hash formats + sha1Regex := regexp.MustCompile(`^[0-9a-f]{40}$`) + sha256Regex := regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + + lineNum := 0 + for scanner.Scan() { + lineNum++ + var envelope map[string]interface{} + if err := json.Unmarshal(scanner.Bytes(), &envelope); err != nil { + t.Errorf("line %d: malformed JSON: %v", lineNum, err) + continue + } + + // Validate required envelope fields + schemaVersion, ok := envelope["schema_version"].(float64) + if !ok || int(schemaVersion) != 1 { + t.Errorf("line %d: missing or incorrect schema_version", lineNum) + } + + protocolVersion, ok := envelope["protocol_version"].(float64) + if !ok || int(protocolVersion) != 1 { + t.Errorf("line %d: missing or incorrect protocol_version", lineNum) + } + + sequence, ok := envelope["sequence"].(float64) + if !ok { + t.Errorf("line %d: missing sequence", lineNum) + continue + } + + eventType, ok := envelope["event"].(string) + if !ok { + t.Errorf("line %d: missing event type", lineNum) + continue + } + + // Check sequence continuity + expectedSeq := lastSequence + 1 + if int(sequence) != expectedSeq { + t.Errorf("line %d: sequence %d, expected %d", lineNum, int(sequence), expectedSeq) + } + lastSequence = int(sequence) + + // Validate and check constancy of run/invocation identifiers + rid, ok := envelope["run_id"].(string) + if !ok || rid == "" { + t.Errorf("line %d: missing or empty run_id", lineNum) + continue + } + if runID == "" { + runID = rid + } else if rid != runID { + t.Errorf("line %d: run_id mismatch: %s vs %s", lineNum, rid, runID) + } + + mid, ok := envelope["mission_id"].(string) + if !ok || mid == "" { + t.Errorf("line %d: missing or empty mission_id", lineNum) + continue + } + if missionID == "" { + missionID = mid + } else if mid != missionID { + t.Errorf("line %d: mission_id mismatch: %s vs %s", lineNum, mid, missionID) + } + + iid, ok := envelope["invocation_id"].(string) + if !ok || iid == "" { + t.Errorf("line %d: missing or empty invocation_id", lineNum) + continue + } + if invocationID == "" { + invocationID = iid + } else if iid != invocationID { + t.Errorf("line %d: invocation_id mismatch: %s vs %s", lineNum, iid, invocationID) + } + + isha, ok := envelope["input_sha"].(string) + if !ok || !sha1Regex.MatchString(isha) { + t.Errorf("line %d: invalid input_sha: %q (expected 40-hex)", lineNum, isha) + continue + } + if inputSHA == "" { + inputSHA = isha + } else if isha != inputSHA { + t.Errorf("line %d: input_sha mismatch: %s vs %s", lineNum, isha, inputSHA) + } + + bsha, ok := envelope["base_sha"].(string) + if !ok || !sha1Regex.MatchString(bsha) { + t.Errorf("line %d: invalid base_sha: %q (expected 40-hex)", lineNum, bsha) + continue + } + if baseSHA == "" { + baseSHA = bsha + } else if bsha != baseSHA { + t.Errorf("line %d: base_sha mismatch: %s vs %s", lineNum, bsha, baseSHA) + } + + ph, ok := envelope["policy_hash"].(string) + if !ok || !sha256Regex.MatchString(ph) { + t.Errorf("line %d: invalid policy_hash: %q (expected sha256:<64-hex>)", lineNum, ph) + continue + } + if policyHash == "" { + policyHash = ph + } else if ph != policyHash { + t.Errorf("line %d: policy_hash mismatch: %s vs %s", lineNum, ph, policyHash) + } + + // Validate timestamp format (basic check) + _, ok = envelope["timestamp"].(string) + if !ok { + t.Errorf("line %d: missing or non-string timestamp", lineNum) + } + + // Track terminal events + if eventType == "run.completed" { + terminalCount++ + } + + // First event must be run.started + if lineNum == 1 && eventType != "run.started" { + t.Errorf("line %d: first event must be run.started, got %s", lineNum, eventType) + } + + // Validate run.started timing + if eventType == "run.started" { + if lineNum != 1 { + t.Errorf("line %d: run.started must be first event, found at line %d", 1, lineNum) + } + seenRunStarted = true + } + } + + if err := scanner.Err(); err != nil { + t.Fatalf("scanner error: %v", err) + } + + if !seenRunStarted { + t.Error("fixture missing run.started event") + } + + if terminalCount != 1 { + t.Errorf("expected exactly 1 terminal event (run.completed), got %d", terminalCount) + } + + if lastSequence < 1 { + t.Error("fixture contains no events") + } +} + +// validateDecisionFixture checks a Decision JSON fixture for validity. +func validateDecisionFixture(t *testing.T, path string) { + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + // Parse DecisionsFile directly to validate JSON structure + var df DecisionsFile + if err := json.Unmarshal(data, &df); err != nil { + t.Fatalf("failed to parse decisions JSON: %v", err) + } + + // Check that all required fields exist and have expected formats + sha1Regex := regexp.MustCompile(`^[0-9a-f]{40}$`) + sha256Regex := regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + + if df.SchemaVersion != 1 { + t.Errorf("incorrect schema_version: %d (expected 1)", df.SchemaVersion) + } + if df.RunID == "" { + t.Error("missing run_id") + } + if df.MissionID == "" { + t.Error("missing mission_id") + } + if !sha1Regex.MatchString(df.InputSHA) { + t.Errorf("invalid input_sha: %q (expected 40-hex)", df.InputSHA) + } + if !sha1Regex.MatchString(df.BaseSHA) { + t.Errorf("invalid base_sha: %q (expected 40-hex)", df.BaseSHA) + } + if !sha256Regex.MatchString(df.PolicyHash) { + t.Errorf("invalid policy_hash: %q (expected sha256:<64-hex>)", df.PolicyHash) + } + if df.InvocationID == "" { + t.Error("missing invocation_id") + } + + // Check for duplicate decision IDs + seenIDs := make(map[string]bool) + for _, d := range df.Decisions { + if d.DecisionID == "" { + t.Error("decision missing decision_id") + } + if seenIDs[d.DecisionID] { + t.Errorf("duplicate decision_id: %s", d.DecisionID) + } + seenIDs[d.DecisionID] = true + + // Validate fingerprint format + if !sha256Regex.MatchString(d.FindingFingerprint) { + t.Errorf("invalid fingerprint: %q (expected sha256:<64-hex>)", d.FindingFingerprint) + } + + // Validate outcome + if d.Outcome != DecisionApproved && d.Outcome != DecisionRejected { + t.Errorf("invalid outcome: %q (expected approved or rejected)", d.Outcome) + } + + // Validate scope + validScopes := map[DecisionScope]bool{ + ScopeOneShot: true, + ScopeSHABound: true, + ScopeMissionFindingWaiver: true, + } + if !validScopes[d.Scope] { + t.Errorf("invalid scope: %q", d.Scope) + } + } +} diff --git a/internal/managed/managed_e2e_test.go b/internal/managed/managed_e2e_test.go new file mode 100644 index 0000000..557a063 --- /dev/null +++ b/internal/managed/managed_e2e_test.go @@ -0,0 +1,602 @@ +package managed_test + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/douglasjarquin/made/internal/agent" + "github.com/douglasjarquin/made/internal/agent/agenttest" + "github.com/douglasjarquin/made/internal/managed" +) + +// agentConfig is a .made.yml that enables the codex agent (overridden in tests +// with a fake agent binary via Options.ReviewAgentBinaryPath). +const agentConfig = `version: 1 +commands: + test: "true" + lint: "true" +agent: codex +` + +type e2eResult struct { + exitCode int + events []map[string]any + stdout []byte + stderr []byte +} + +// runManaged invokes managed.Run directly using OS pipes for stdout/stderr and +// returns the parsed JSON-Lines event stream. +func runManaged(t *testing.T, ctx context.Context, opts *managed.Options) e2eResult { + t.Helper() + + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + stderrR, stderrW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + // Drain both pipes concurrently to avoid deadlock if output exceeds the + // pipe buffer. + var stdoutBuf, stderrBuf bytes.Buffer + done := make(chan struct{}, 2) + go func() { _, _ = io.Copy(&stdoutBuf, stdoutR); done <- struct{}{} }() + go func() { _, _ = io.Copy(&stderrBuf, stderrR); done <- struct{}{} }() + + exitCode := managed.Run(ctx, opts, stdoutW, stderrW) + _ = stdoutW.Close() + _ = stderrW.Close() + <-done + <-done + _ = stdoutR.Close() + _ = stderrR.Close() + + var events []map[string]any + scanner := bufio.NewScanner(bytes.NewReader(stdoutBuf.Bytes())) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var ev map[string]any + if err := json.Unmarshal([]byte(line), &ev); err != nil { + t.Fatalf("unmarshal event %q: %v", line, err) + } + events = append(events, ev) + } + + return e2eResult{ + exitCode: exitCode, + events: events, + stdout: stdoutBuf.Bytes(), + stderr: stderrBuf.Bytes(), + } +} + +// e2eOptions builds a fully-populated Options for a passing/failing e2e run, +// wiring the fake agent binary and scenario into the review stage. +func e2eOptions(t *testing.T, runID, missionID string, findings agent.Findings) *managed.Options { + t.Helper() + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, agentConfig) + evidenceDir := makeEvidenceDir(t) + scenario := writeScenario(t, findings) + bin := agenttest.Build(t) + + return &managed.Options{ + RunID: runID, + MissionID: missionID, + Workspace: workspace, + BaseSHA: baseSHA, + InputSHA: inputSHA, + TrustedConfig: configPath, + PolicyHash: policyHash, + EvidenceDir: evidenceDir, + ReviewAgentBinaryPath: bin, + ReviewAgentExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenario}, + } +} + +// writeScenario writes a fakeagent scenario file containing the given findings. +func writeScenario(t *testing.T, findings agent.Findings) string { + t.Helper() + data, err := json.Marshal(findings) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "scenario.json") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func finding(kind agent.FindingKind, code, class, desc string, paths ...string) agent.Finding { + return agent.Finding{ + Kind: kind, + Code: code, + Class: class, + Description: desc, + Paths: paths, + } +} + +// autoFixFinding builds an auto-fixable finding with a placeholder patch. In +// managed (ReportOnly) mode the patch is never applied, but the agent contract +// requires auto-fixable findings to carry a non-empty patch. +func autoFixFinding(code, class, desc string, paths ...string) agent.Finding { + f := finding(agent.FindingAutoFixable, code, class, desc, paths...) + f.Patch = "--- a/feature.go\n+++ b/feature.go\n@@ -1 +1 @@\n-package main\n+package main\n" + return f +} + +func TestManaged_Passed(t *testing.T) { + opts := e2eOptions(t, "G-passed", "M-passed", agent.Findings{Findings: []agent.Finding{}}) + + res := runManaged(t, context.Background(), opts) + if res.exitCode != 0 { + t.Fatalf("expected exit 0, got %d (stderr: %s)", res.exitCode, res.stderr) + } + if got := terminalOutcome(t, res.events); got != "passed" { + t.Errorf("expected outcome passed, got %q (stderr: %s)", got, res.stderr) + } + if res.events[0]["event"] != "run.started" { + t.Errorf("first event is %q, want run.started", res.events[0]["event"]) + } + // base_sha must be present on every event. + for i, ev := range res.events { + if ev["base_sha"] != opts.BaseSHA { + t.Errorf("event[%d]: base_sha=%q, want %q", i, ev["base_sha"], opts.BaseSHA) + } + } +} + +func TestManaged_NeedsDecision(t *testing.T) { + opts := e2eOptions(t, "G-nd", "M-nd", agent.Findings{Findings: []agent.Finding{ + finding(agent.FindingAskUser, "sec.api-key", "security", "API key in source code", "feature.go"), + }}) + + res := runManaged(t, context.Background(), opts) + if res.exitCode != 3 { + t.Fatalf("expected exit 3, got %d (stderr: %s)", res.exitCode, res.stderr) + } + if got := terminalOutcome(t, res.events); got != "needs_decision" { + t.Errorf("expected outcome needs_decision, got %q", got) + } + if fp := findingFingerprint(t, res.events); fp == "" { + t.Error("expected a finding.reported event with a fingerprint") + } +} + +func TestManaged_FailedRetryable(t *testing.T) { + opts := e2eOptions(t, "G-fr", "M-fr", agent.Findings{Findings: []agent.Finding{ + autoFixFinding("style.gofmt", "style", "needs gofmt", "feature.go"), + }}) + + res := runManaged(t, context.Background(), opts) + if res.exitCode != 4 { + t.Fatalf("expected exit 4, got %d (stderr: %s)", res.exitCode, res.stderr) + } + if got := terminalOutcome(t, res.events); got != "failed_retryable" { + t.Errorf("expected outcome failed_retryable, got %q", got) + } +} + +func TestManaged_FailedTerminal(t *testing.T) { + opts := e2eOptions(t, "G-ft", "M-ft", agent.Findings{Findings: []agent.Finding{ + finding(agent.FindingBlocking, "sec.sqli", "security", "SQL injection", "feature.go"), + }}) + + res := runManaged(t, context.Background(), opts) + if res.exitCode != 5 { + t.Fatalf("expected exit 5, got %d (stderr: %s)", res.exitCode, res.stderr) + } + if got := terminalOutcome(t, res.events); got != "failed_terminal" { + t.Errorf("expected outcome failed_terminal, got %q", got) + } +} + +func TestManaged_RerunWithApprovedDecision(t *testing.T) { + // First run: ask-user finding with no decision → needs_decision. + opts := e2eOptions(t, "G-rerun", "M-rerun", agent.Findings{Findings: []agent.Finding{ + finding(agent.FindingAskUser, "arch.new-dep", "project-judgment", "new dependency added", "feature.go"), + }}) + + res1 := runManaged(t, context.Background(), opts) + if res1.exitCode != 3 { + t.Fatalf("run1: expected exit 3, got %d (stderr: %s)", res1.exitCode, res1.stderr) + } + fp := findingFingerprint(t, res1.events) + if fp == "" { + t.Fatal("run1: no fingerprint captured") + } + + // Second run: same inputs, but supply an approving decision. + // Note: The Decisions file includes invocation_id from run1 for reference, + // but it doesn't bind the decision - decisions bind only to (run_id, mission_id, input_sha, base_sha, policy_hash). + decPath := filepath.Join(t.TempDir(), "decisions.json") + invID := getInvocationID(t, res1.events) + if invID == "" { + t.Fatal("run1: no invocation_id captured") + } + decContent := map[string]any{ + "schema_version": 1, + "run_id": opts.RunID, + "invocation_id": invID, // From first run, informational only + "mission_id": opts.MissionID, + "input_sha": opts.InputSHA, + "base_sha": opts.BaseSHA, + "policy_hash": opts.PolicyHash, + "decisions": []map[string]any{ + { + "decision_id": "D-1", + "finding_fingerprint": fp, + "outcome": "approved", + "scope": "sha_bound", + "rationale": "reviewed and accepted", + }, + }, + } + decData, _ := json.MarshalIndent(decContent, "", " ") + if err := os.WriteFile(decPath, decData, 0o644); err != nil { + t.Fatal(err) + } + opts.DecisionsPath = decPath + + res2 := runManaged(t, context.Background(), opts) + if res2.exitCode != 0 { + t.Fatalf("run2: expected exit 0 after approval, got %d (stderr: %s)", res2.exitCode, res2.stderr) + } + if got := terminalOutcome(t, res2.events); got != "passed" { + t.Errorf("run2: expected outcome passed, got %q", got) + } +} + +func TestManaged_EvidenceInvocationUniqueness(t *testing.T) { + opts := e2eOptions(t, "G-ev-uniq", "M-ev-uniq", agent.Findings{Findings: []agent.Finding{}}) + + res1 := runManaged(t, context.Background(), opts) + if res1.exitCode != 0 { + t.Fatalf("run1 exit %d (stderr: %s)", res1.exitCode, res1.stderr) + } + res2 := runManaged(t, context.Background(), opts) + if res2.exitCode != 0 { + t.Fatalf("run2 exit %d (stderr: %s)", res2.exitCode, res2.stderr) + } + + // Both runs share the same hashed run-id directory but must have distinct + // invocation subdirectories, each containing a terminal.json. + var runDir string + entries, err := os.ReadDir(opts.EvidenceDir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("expected exactly one hashed run dir, got %d", len(entries)) + } + runDir = filepath.Join(opts.EvidenceDir, entries[0].Name()) + + invEntries, err := os.ReadDir(runDir) + if err != nil { + t.Fatal(err) + } + if len(invEntries) != 2 { + t.Fatalf("expected 2 invocation dirs, got %d", len(invEntries)) + } + for _, e := range invEntries { + terminalPath := filepath.Join(runDir, e.Name(), "terminal.json") + if _, err := os.Stat(terminalPath); err != nil { + t.Errorf("missing terminal.json for invocation %s: %v", e.Name(), err) + } + } +} + +func TestManaged_ReportOnly_NoWorkspaceMutation(t *testing.T) { + opts := e2eOptions(t, "G-ro", "M-ro", agent.Findings{Findings: []agent.Finding{ + autoFixFinding("style.gofmt", "style", "needs gofmt", "feature.go"), + }}) + + headBefore := gitRevParse(t, opts.Workspace, "HEAD") + statusBefore := gitStatus(t, opts.Workspace) + + res := runManaged(t, context.Background(), opts) + if res.exitCode != 4 { + t.Fatalf("expected exit 4, got %d (stderr: %s)", res.exitCode, res.stderr) + } + + headAfter := gitRevParse(t, opts.Workspace, "HEAD") + statusAfter := gitStatus(t, opts.Workspace) + if headBefore != headAfter { + t.Errorf("workspace HEAD changed: before %s after %s", headBefore, headAfter) + } + if statusBefore != statusAfter { + t.Errorf("workspace status changed: before %q after %q", statusBefore, statusAfter) + } +} + +func TestManaged_DuplicateFingerprintDetection(t *testing.T) { + // Create two findings with the same code, class, paths, and description. + // They must produce identical fingerprints and trigger infrastructure_error. + dup := finding(agent.FindingAskUser, "review.style", "style", "duplicate finding", "main.go") + opts := e2eOptions(t, "G-dup", "M-dup", agent.Findings{Findings: []agent.Finding{ + dup, + dup, // same finding repeated + }}) + + res := runManaged(t, context.Background(), opts) + if res.exitCode != 1 { + t.Fatalf("expected exit 1 (infrastructure_error), got %d (stderr: %s)", res.exitCode, res.stderr) + } + if got := terminalOutcome(t, res.events); got != "infrastructure_error" { + t.Errorf("expected outcome infrastructure_error, got %q", got) + } + // Verify that the error message mentions duplicate fingerprints. + terminalMsg := "" + for _, ev := range res.events { + if ev["event"] != "run.completed" { + continue + } + payload, ok := ev["payload"].(map[string]any) + if !ok { + continue + } + if msg, ok := payload["message"].(string); ok { + terminalMsg = msg + } + } + if !strings.Contains(terminalMsg, "share") || !strings.Contains(terminalMsg, "fingerprint") { + t.Errorf("error message should mention duplicate fingerprints, got: %q", terminalMsg) + } +} + +func TestManaged_ParaphraseStability(t *testing.T) { + // When a finding's description is paraphrased on rerun, the fingerprint + // (based on code/class/paths/symbol) must remain stable and the approved + // Decision from run 1 must still apply in run 2. + opts := e2eOptions(t, "G-para", "M-para", agent.Findings{Findings: []agent.Finding{ + finding(agent.FindingAskUser, "arch.style", "style", "old description", "main.go"), + }}) + + // Run 1: capture fingerprint and get needs_decision. + res1 := runManaged(t, context.Background(), opts) + if res1.exitCode != 3 { + t.Fatalf("run1: expected exit 3, got %d", res1.exitCode) + } + fp1 := findingFingerprint(t, res1.events) + if fp1 == "" { + t.Fatal("run1: no fingerprint") + } + + // Run 2: same finding but paraphrased description. + opts2 := e2eOptions(t, "G-para", "M-para", agent.Findings{Findings: []agent.Finding{ + finding(agent.FindingAskUser, "arch.style", "style", "completely different wording", "main.go"), + }}) + + res2 := runManaged(t, context.Background(), opts2) + if res2.exitCode != 3 { + t.Fatalf("run2: expected exit 3 (still needs decision), got %d", res2.exitCode) + } + fp2 := findingFingerprint(t, res2.events) + if fp2 == "" { + t.Fatal("run2: no fingerprint") + } + + // Fingerprints must be identical despite paraphrasing. + if fp1 != fp2 { + t.Errorf("fingerprints should be stable across paraphrasing: run1=%q run2=%q", fp1, fp2) + } + + // Now supply an approving Decision based on fp1 for run 3. + decPath := filepath.Join(t.TempDir(), "decisions.json") + decContent := map[string]any{ + "schema_version": 1, + "run_id": opts.RunID, + "mission_id": opts.MissionID, + "input_sha": opts.InputSHA, + "base_sha": opts.BaseSHA, + "policy_hash": opts.PolicyHash, + "decisions": []map[string]any{ + { + "decision_id": "D-1", + "finding_fingerprint": fp1, + "outcome": "approved", + "scope": "sha_bound", + "rationale": "approved once", + }, + }, + } + decData, _ := json.MarshalIndent(decContent, "", " ") + if err := os.WriteFile(decPath, decData, 0o644); err != nil { + t.Fatal(err) + } + opts.DecisionsPath = decPath + + // Run 3: same finding, same approval Decision → should pass. + res3 := runManaged(t, context.Background(), opts) + if res3.exitCode != 0 { + t.Fatalf("run3: expected exit 0 (decision applies), got %d (stderr: %s)", res3.exitCode, res3.stderr) + } + if got := terminalOutcome(t, res3.events); got != "passed" { + t.Errorf("run3: expected outcome passed, got %q", got) + } +} + +func TestManaged_SameFileDifferentFinding(t *testing.T) { + // Two different ask-user findings on the same file with the same code/class + // must have different fingerprints (because they have different descriptions + // as structural identity) and not collide. + // + // This test verifies the strict finding identity requirements: without stable + // structural identity in descriptions (which are now omitted from fingerprints), + // we rely on code, class, paths, and symbol being sufficient to disambiguate. + // + // If two different findings on the same file need different structural markers + // (e.g., different symbols or line ranges), they must provide them. + + // For now, we test that two findings with identical code/class/paths but + // different (paraphrased) descriptions get caught by duplicate detection if + // they would collide. + f1 := finding(agent.FindingAskUser, "style.naming", "style", "should be PascalCase", "main.go") + f2 := finding(agent.FindingAskUser, "style.naming", "style", "should be snake_case", "main.go") + + opts := e2eOptions(t, "G-diff", "M-diff", agent.Findings{Findings: []agent.Finding{f1, f2}}) + + res := runManaged(t, context.Background(), opts) + // These are identical findings structurally (same code/class/paths), so they collide. + if res.exitCode != 1 { + t.Fatalf("expected exit 1 (infrastructure_error for collision), got %d", res.exitCode) + } + if got := terminalOutcome(t, res.events); got != "infrastructure_error" { + t.Errorf("expected infrastructure_error, got %q", got) + } +} + +func TestManaged_ValidateOptions_UsageError(t *testing.T) { + // Missing RunID → exit 2, no events emitted. + opts := &managed.Options{ + MissionID: "M-1", + Workspace: "/tmp/ws", + BaseSHA: strings.Repeat("a", 40), + InputSHA: strings.Repeat("a", 40), + TrustedConfig: "/tmp/tc", + PolicyHash: "sha256:" + strings.Repeat("a", 64), + EvidenceDir: "/tmp/ev", + } + res := runManaged(t, context.Background(), opts) + if res.exitCode != 2 { + t.Errorf("expected exit 2, got %d", res.exitCode) + } + if len(res.events) != 0 { + t.Errorf("expected no events for usage error, got %d", len(res.events)) + } +} + +// findingFingerprint returns the fingerprint of the first finding.reported event. +func findingFingerprint(t *testing.T, events []map[string]any) string { + t.Helper() + for _, ev := range events { + if ev["event"] != "finding.reported" { + continue + } + payload, ok := ev["payload"].(map[string]any) + if !ok { + continue + } + if fp, ok := payload["fingerprint"].(string); ok { + return fp + } + } + return "" +} + +func getInvocationID(t *testing.T, events []map[string]any) string { + t.Helper() + if len(events) == 0 { + return "" + } + // Get invocation_id from first event (all events in a run have the same invocation_id) + if id, ok := events[0]["invocation_id"].(string); ok { + return id + } + return "" +} + +func gitStatus(t *testing.T, dir string) string { + t.Helper() + out, err := exec.Command("git", "-C", dir, "status", "--porcelain", "--untracked-files=all").Output() + if err != nil { + t.Fatalf("git status: %v", err) + } + return strings.TrimSpace(string(out)) +} + +// TestValidateFindingPathsRejectAbsolute ensures managed validation rejects +// absolute paths which could expose workspace structure or escape containment. +func TestValidateFindingPathsRejectAbsolute(t *testing.T) { + input := managed.FingerprintInput{ + Stage: "review", + Kind: "ask-user", + Code: "review.example", + Class: "example", + Paths: []string{"/absolute/path"}, + Description: "test", + } + err := managed.ValidateStableFindingIdentity(input) + if err == nil { + t.Error("expected error for absolute path, got nil") + } + if !strings.Contains(err.Error(), "absolute") { + t.Errorf("expected 'absolute' in error, got: %v", err) + } +} + +// TestValidateFindingPathsRejectEscape ensures paths cannot use ".." to escape. +func TestValidateFindingPathsRejectEscape(t *testing.T) { + input := managed.FingerprintInput{ + Stage: "review", + Kind: "ask-user", + Code: "review.example", + Class: "example", + Paths: []string{"subdir/../../../etc/passwd"}, + Description: "test", + } + err := managed.ValidateStableFindingIdentity(input) + if err == nil { + t.Error("expected error for path with '..', got nil") + } + if !strings.Contains(err.Error(), "..") { + t.Errorf("expected '..' in error, got: %v", err) + } +} + +// TestValidateFindingPathsRejectUnclean ensures paths must be clean (no redundant +// separators or "." components). +func TestValidateFindingPathsRejectUnclean(t *testing.T) { + input := managed.FingerprintInput{ + Stage: "review", + Kind: "ask-user", + Code: "review.example", + Class: "example", + Paths: []string{"path/./to/file"}, + Description: "test", + } + err := managed.ValidateStableFindingIdentity(input) + if err == nil { + t.Error("expected error for unclean path, got nil") + } + if !strings.Contains(err.Error(), "clean") { + t.Errorf("expected 'clean' in error, got: %v", err) + } +} + +// TestValidateFindingCodeMustBeSpecific ensures that generic codes are rejected +// with clear guidance that code must be finding-specific, not category-generic. +func TestValidateFindingCodeMustBeSpecific(t *testing.T) { + input := managed.FingerprintInput{ + Stage: "review", + Kind: "ask-user", + Code: "", // Empty code + Class: "security", + Paths: []string{"internal/auth.go"}, + Description: "test", + } + err := managed.ValidateStableFindingIdentity(input) + if err == nil { + t.Error("expected error for empty code, got nil") + } + if !strings.Contains(err.Error(), "finding-specific") { + t.Errorf("expected 'finding-specific' guidance in error, got: %v", err) + } +} diff --git a/internal/managed/managed_test.go b/internal/managed/managed_test.go new file mode 100644 index 0000000..9b04b4d --- /dev/null +++ b/internal/managed/managed_test.go @@ -0,0 +1,669 @@ +package managed_test + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// makeTestRepo creates a minimal Git repo at dir with a commit on main +// and returns (repoDir, baseSHA, inputSHA). +func makeTestRepo(t *testing.T) (dir, baseSHA, inputSHA string) { + t.Helper() + dir = t.TempDir() + + gitCmds := [][]string{ + {"init", "-b", "main"}, + {"config", "user.email", "test@test.local"}, + {"config", "user.name", "test"}, + {"config", "commit.gpgsign", "false"}, + } + for _, args := range gitCmds { + c := exec.Command("git", append([]string{"-C", dir}, args...)...) + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + + // Base commit. + writeFile(t, filepath.Join(dir, "README.md"), "# test\n") + for _, args := range [][]string{ + {"add", "."}, + {"commit", "-m", "initial"}, + } { + c := exec.Command("git", append([]string{"-C", dir, "-c", "commit.gpgsign=false"}, args...)...) + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + baseSHA = gitRevParse(t, dir, "HEAD") + + // Input commit. + writeFile(t, filepath.Join(dir, "hello.go"), "package main\n") + for _, args := range [][]string{ + {"add", "."}, + {"commit", "-m", "add hello.go"}, + } { + c := exec.Command("git", append([]string{"-C", dir, "-c", "commit.gpgsign=false"}, args...)...) + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + inputSHA = gitRevParse(t, dir, "HEAD") + return +} + +func gitRevParse(t *testing.T, dir, ref string) string { + t.Helper() + out, err := exec.Command("git", "-C", dir, "rev-parse", "--verify", ref).Output() + if err != nil { + t.Fatalf("git rev-parse %s: %v", ref, err) + } + return strings.TrimSpace(string(out)) +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func makeTrustedConfig(t *testing.T, content string) (path, hash string) { + t.Helper() + dir := t.TempDir() + path = filepath.Join(dir, ".made.yml") + writeFile(t, path, content) + data, _ := os.ReadFile(path) + sum := sha256.Sum256(data) + hash = "sha256:" + hex.EncodeToString(sum[:]) + return +} + +func makeEvidenceDir(t *testing.T) string { + t.Helper() + return t.TempDir() +} + +// minimalConfig is a valid .made.yml for tests that don't need commands. +const minimalConfig = `version: 1 +commands: + test: "true" + lint: "true" +` + +// parseEvents parses JSON-Lines from a buffer and returns the events as maps. +func parseEvents(t *testing.T, data []byte) []map[string]any { + t.Helper() + var events []map[string]any + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var ev map[string]any + if err := json.Unmarshal([]byte(line), &ev); err != nil { + t.Fatalf("parse event line %q: %v", line, err) + } + events = append(events, ev) + } + return events +} + +// findTerminalEvent returns the run.completed event or fails. +func findTerminalEvent(t *testing.T, events []map[string]any) map[string]any { + t.Helper() + for _, ev := range events { + if ev["event"] == "run.completed" { + return ev + } + } + t.Fatal("no run.completed event found") + return nil +} + +// assertProtocolInvariants checks the protocol contract on a stream of events. +func assertProtocolInvariants(t *testing.T, events []map[string]any, opts struct { + RunID string + MissionID string + InputSHA string + PolicyHash string +}) { + t.Helper() + + if len(events) == 0 { + t.Fatal("no events emitted") + } + + // sequence starts at 1 and is contiguous + for i, ev := range events { + seq := int(ev["sequence"].(float64)) + if seq != i+1 { + t.Errorf("event[%d]: expected sequence %d, got %d", i, i+1, seq) + } + } + + // schema_version and protocol_version are constant + for i, ev := range events { + if sv := int(ev["schema_version"].(float64)); sv != 1 { + t.Errorf("event[%d]: schema_version=%d, want 1", i, sv) + } + if pv := int(ev["protocol_version"].(float64)); pv != 1 { + t.Errorf("event[%d]: protocol_version=%d, want 1", i, pv) + } + } + + // run identity is constant + for i, ev := range events { + if ev["run_id"] != opts.RunID { + t.Errorf("event[%d]: run_id=%q, want %q", i, ev["run_id"], opts.RunID) + } + if ev["mission_id"] != opts.MissionID { + t.Errorf("event[%d]: mission_id=%q, want %q", i, ev["mission_id"], opts.MissionID) + } + if ev["input_sha"] != opts.InputSHA { + t.Errorf("event[%d]: input_sha=%q, want %q", i, ev["input_sha"], opts.InputSHA) + } + if ev["policy_hash"] != opts.PolicyHash { + t.Errorf("event[%d]: policy_hash=%q, want %q", i, ev["policy_hash"], opts.PolicyHash) + } + } + + // exactly one terminal event + termCount := 0 + lastTermIdx := -1 + for i, ev := range events { + if ev["event"] == "run.completed" { + termCount++ + lastTermIdx = i + } + } + if termCount != 1 { + t.Errorf("expected exactly 1 run.completed event, got %d", termCount) + } + if lastTermIdx != len(events)-1 { + t.Errorf("run.completed must be the last event, but it is at index %d of %d", lastTermIdx, len(events)-1) + } + + // timestamps are parseable + for i, ev := range events { + ts, ok := ev["timestamp"].(string) + if !ok || ts == "" { + t.Errorf("event[%d]: missing or non-string timestamp", i) + continue + } + if _, err := time.Parse(time.RFC3339Nano, ts); err != nil { + t.Errorf("event[%d]: timestamp %q not RFC3339Nano: %v", i, ts, err) + } + } +} + +// terminalOutcome extracts the outcome string from the run.completed payload. +func terminalOutcome(t *testing.T, events []map[string]any) string { + t.Helper() + term := findTerminalEvent(t, events) + payload, ok := term["payload"].(map[string]any) + if !ok { + t.Fatal("run.completed payload is not an object") + } + outcome, ok := payload["outcome"].(string) + if !ok { + t.Fatal("run.completed payload.outcome is not a string") + } + return outcome +} + +// runManagedValidate executes `made validate --managed` with the given args +// and returns (stdout bytes, stderr bytes, exit code). +func runManagedValidate(t *testing.T, extraArgs ...string) ([]byte, []byte, int) { + t.Helper() + // Build the binary first. + binPath := filepath.Join(t.TempDir(), "made") + build := exec.Command("go", "build", "-o", binPath, "github.com/douglasjarquin/made/cmd/made") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build made: %v: %s", err, out) + } + + var stdout, stderr bytes.Buffer + cmd := exec.Command(binPath, extraArgs...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + code := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + code = exitErr.ExitCode() + } else { + t.Fatalf("run made: %v", err) + } + } + return stdout.Bytes(), stderr.Bytes(), code +} + +// fullValidateArgs constructs a complete set of args for made validate --managed. +func fullValidateArgs(runID, missionID, workspace, baseSHA, inputSHA, configPath, policyHash, evidenceDir string, extra ...string) []string { + args := []string{ + "validate", "--managed", "--json-events", + "--run-id", runID, + "--mission-id", missionID, + "--workspace", workspace, + "--base-sha", baseSHA, + "--input-sha", inputSHA, + "--trusted-config", configPath, + "--policy-hash", policyHash, + "--evidence-dir", evidenceDir, + } + return append(args, extra...) +} + +// TestProtocol_SequenceStartsAt1AndIsContiguous verifies basic protocol invariants +// on a passing run. +func TestProtocol_SequenceStartsAt1AndIsContiguous(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + stdout, _, code := runManagedValidate(t, fullValidateArgs( + "G-1", "M-1", workspace, baseSHA, inputSHA, configPath, policyHash, evidenceDir, + )...) + + events := parseEvents(t, stdout) + assertProtocolInvariants(t, events, struct { + RunID, MissionID, InputSHA, PolicyHash string + }{"G-1", "M-1", inputSHA, policyHash}) + + outcome := terminalOutcome(t, events) + // With no review agent, review stage will fail as infrastructure_error + // (no agent configured), but protocol invariants must still hold. + t.Logf("outcome=%s exit=%d", outcome, code) +} + +// TestProtocol_StdoutContainsOnlyValidJSONLines verifies that every line is valid JSON. +func TestProtocol_StdoutContainsOnlyValidJSONLines(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + stdout, _, _ := runManagedValidate(t, fullValidateArgs( + "G-2", "M-2", workspace, baseSHA, inputSHA, configPath, policyHash, evidenceDir, + )...) + + scanner := bufio.NewScanner(bytes.NewReader(stdout)) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var v any + if err := json.Unmarshal([]byte(line), &v); err != nil { + t.Errorf("line %d is not valid JSON: %v\nline: %s", lineNo, err, line) + } + } + if lineNo == 0 { + t.Error("no output lines at all") + } +} + +// TestProtocol_ExactlyOneTerminalEvent ensures exactly one run.completed event. +func TestProtocol_ExactlyOneTerminalEvent(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + stdout, _, _ := runManagedValidate(t, fullValidateArgs( + "G-3", "M-3", workspace, baseSHA, inputSHA, configPath, policyHash, evidenceDir, + )...) + + events := parseEvents(t, stdout) + count := 0 + for _, ev := range events { + if ev["event"] == "run.completed" { + count++ + } + } + if count != 1 { + t.Errorf("expected exactly 1 run.completed, got %d", count) + } +} + +// TestProtocol_RunStartedIsFirst verifies the first event is run.started. +func TestProtocol_RunStartedIsFirst(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + stdout, _, _ := runManagedValidate(t, fullValidateArgs( + "G-4", "M-4", workspace, baseSHA, inputSHA, configPath, policyHash, evidenceDir, + )...) + + events := parseEvents(t, stdout) + if len(events) == 0 { + t.Fatal("no events") + } + if events[0]["event"] != "run.started" { + t.Errorf("first event is %q, want %q", events[0]["event"], "run.started") + } +} + +// TestProtocol_ExitCodeMatchesOutcome verifies exit code contract. +func TestProtocol_ExitCodeMatchesOutcome(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + // Use a config with no agent to force infrastructure_error on review stage. + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + stdout, _, code := runManagedValidate(t, fullValidateArgs( + "G-5", "M-5", workspace, baseSHA, inputSHA, configPath, policyHash, evidenceDir, + )...) + + events := parseEvents(t, stdout) + outcome := terminalOutcome(t, events) + + expectedCode := map[string]int{ + "passed": 0, + "infrastructure_error": 1, + "needs_decision": 3, + "failed_retryable": 4, + "failed_terminal": 5, + "canceled": 130, + }[outcome] + + if code != expectedCode { + t.Errorf("outcome %q: expected exit code %d, got %d", outcome, expectedCode, code) + } +} + +// TestPreflight_RelativeWorkspaceRejected verifies preflight rejects relative paths. +func TestPreflight_RelativeWorkspaceRejected(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + _, stderr, code := runManagedValidate(t, + "validate", "--managed", "--json-events", + "--run-id", "G-10", + "--mission-id", "M-10", + "--workspace", "relative/path", // relative — should fail + "--base-sha", baseSHA, + "--input-sha", inputSHA, + "--trusted-config", configPath, + "--policy-hash", policyHash, + "--evidence-dir", evidenceDir, + ) + + if code == 0 { + t.Error("expected non-zero exit for relative workspace") + } + if !strings.Contains(string(stderr), "absolute path") && + !strings.Contains(string(stderr), "preflight") { + t.Errorf("expected absolute-path error in stderr, got: %s", stderr) + } + _ = workspace // only needed to build valid config +} + +// TestPreflight_WrongHeadRejected verifies that a workspace with a different HEAD is rejected. +func TestPreflight_WrongHeadRejected(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + _ = inputSHA + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + wrongSHA := strings.Repeat("a", 40) + _, stderr, code := runManagedValidate(t, fullValidateArgs( + "G-11", "M-11", workspace, baseSHA, wrongSHA, configPath, policyHash, evidenceDir, + )...) + + if code == 0 { + t.Error("expected non-zero exit for wrong input_sha") + } + if !strings.Contains(string(stderr), "preflight") { + t.Logf("stderr: %s", stderr) + } +} + +// TestPreflight_AbbreviatedSHARejected verifies abbreviated SHAs are rejected. +func TestPreflight_AbbreviatedSHARejected(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + _, _, code := runManagedValidate(t, fullValidateArgs( + "G-12", "M-12", workspace, baseSHA, inputSHA[:8], configPath, policyHash, evidenceDir, + )...) + if code == 0 { + t.Error("expected non-zero exit for abbreviated input_sha") + } +} + +// TestPreflight_ConfigHashMismatchRejected verifies hash mismatch is rejected. +func TestPreflight_ConfigHashMismatchRejected(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, _ := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + wrongHash := "sha256:" + strings.Repeat("0", 64) + _, stderr, code := runManagedValidate(t, fullValidateArgs( + "G-13", "M-13", workspace, baseSHA, inputSHA, configPath, wrongHash, evidenceDir, + )...) + if code == 0 { + t.Error("expected non-zero exit for hash mismatch") + } + if !strings.Contains(string(stderr), "hash mismatch") && !strings.Contains(string(stderr), "preflight") { + t.Logf("stderr: %s", stderr) + } +} + +// TestPreflight_EvidenceDirInsideWorkspaceRejected verifies evidence dir placement. +func TestPreflight_EvidenceDirInsideWorkspaceRejected(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + + _, _, code := runManagedValidate(t, fullValidateArgs( + "G-14", "M-14", workspace, baseSHA, inputSHA, configPath, policyHash, + filepath.Join(workspace, "evidence"), // inside workspace — should fail + )...) + if code == 0 { + t.Error("expected non-zero exit for evidence dir inside workspace") + } +} + +// TestPreflight_NonAncestorBaseRejected verifies that base must be an ancestor. +func TestPreflight_NonAncestorBaseRejected(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + _ = baseSHA + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + // Use inputSHA as base and baseSHA as input — reversed, so base is a descendant. + // We need a different non-ancestor SHA. Use inputSHA as both (it's an ancestor of itself, + // so we need to create a sibling commit). + // Create a branch commit that is not an ancestor of HEAD. + c := exec.Command("git", "-C", workspace, "-c", "commit.gpgsign=false", + "commit-tree", "HEAD^{tree}", "-p", inputSHA, "-m", "sibling") + out, err := c.Output() + if err != nil { + t.Skipf("cannot create sibling commit: %v", err) + } + sibSHA := strings.TrimSpace(string(out)) + + _, _, code := runManagedValidate(t, fullValidateArgs( + "G-15", "M-15", workspace, sibSHA, inputSHA, configPath, policyHash, evidenceDir, + )...) + if code == 0 { + t.Error("expected non-zero exit for non-ancestor base SHA") + } +} + +// TestPreflight_DirtyWorkspaceRejected verifies that uncommitted changes are rejected. +func TestPreflight_DirtyWorkspaceRejected(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + // Make the workspace dirty. + writeFile(t, filepath.Join(workspace, "dirty.txt"), "dirty\n") + + _, _, code := runManagedValidate(t, fullValidateArgs( + "G-16", "M-16", workspace, baseSHA, inputSHA, configPath, policyHash, evidenceDir, + )...) + if code == 0 { + t.Error("expected non-zero exit for dirty workspace") + } +} + +// TestPreflight_MissingRequiredFlagReturns2 verifies exit 2 for usage errors. +func TestPreflight_MissingRequiredFlagReturns2(t *testing.T) { + _, _, code := runManagedValidate(t, "validate", "--managed", "--json-events", + "--run-id", "G-20", + // missing --mission-id and others + ) + if code != 2 { + t.Errorf("expected exit 2 for missing flags, got %d", code) + } +} + +// TestCapabilities_IncludesManagedV1 verifies capabilities advertises validate.managed.v1. +func TestCapabilities_IncludesManagedV1(t *testing.T) { + binPath := filepath.Join(t.TempDir(), "made") + build := exec.Command("go", "build", "-o", binPath, "github.com/douglasjarquin/made/cmd/made") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build made: %v: %s", err, out) + } + + out, err := exec.Command(binPath, "capabilities", "--json").Output() + if err != nil { + t.Fatalf("capabilities: %v", err) + } + var report struct { + Commands []string `json:"commands"` + } + if err := json.Unmarshal(out, &report); err != nil { + t.Fatalf("parse capabilities: %v", err) + } + found := false + for _, cmd := range report.Commands { + if cmd == "validate.managed.v1" { + found = true + } + } + if !found { + t.Errorf("validate.managed.v1 not in capabilities commands: %v", report.Commands) + } +} + +// TestProtocol_RunIDIsEchoedExactly verifies opaque run_id passthrough. +func TestProtocol_RunIDIsEchoedExactly(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + opaqueRunID := "G-run-9999-opaque-string-!@#" + stdout, _, _ := runManagedValidate(t, fullValidateArgs( + opaqueRunID, "M-opaque", workspace, baseSHA, inputSHA, configPath, policyHash, evidenceDir, + )...) + + events := parseEvents(t, stdout) + for i, ev := range events { + if ev["run_id"] != opaqueRunID { + t.Errorf("event[%d]: run_id=%q, want %q", i, ev["run_id"], opaqueRunID) + } + } +} + +// TestDecisions_WrongRunIDRejectedAtPreflight verifies Decisions file binding. +func TestDecisions_WrongRunIDRejectedAtPreflight(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + // Write a decisions file with a mismatched run_id. + decPath := filepath.Join(t.TempDir(), "decisions.json") + decContent := fmt.Sprintf(`{ + "schema_version": 1, + "run_id": "WRONG-RUN-ID", + "mission_id": "M-dec", + "input_sha": %q, + "policy_hash": %q, + "decisions": [] + }`, inputSHA, policyHash) + writeFile(t, decPath, decContent) + + _, _, code := runManagedValidate(t, append( + fullValidateArgs("G-dec", "M-dec", workspace, baseSHA, inputSHA, configPath, policyHash, evidenceDir), + "--decisions", decPath, + )...) + if code == 0 { + t.Error("expected non-zero exit for mismatched run_id in decisions file") + } +} + +// TestDecisions_WrongInputSHARejected verifies stale Decisions are rejected. +func TestDecisions_WrongInputSHARejected(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + decPath := filepath.Join(t.TempDir(), "decisions.json") + wrongSHA := strings.Repeat("f", 40) + decContent := fmt.Sprintf(`{ + "schema_version": 1, + "run_id": "G-dec2", + "mission_id": "M-dec2", + "input_sha": %q, + "policy_hash": %q, + "decisions": [] + }`, wrongSHA, policyHash) + writeFile(t, decPath, decContent) + + _, _, code := runManagedValidate(t, append( + fullValidateArgs("G-dec2", "M-dec2", workspace, baseSHA, inputSHA, configPath, policyHash, evidenceDir), + "--decisions", decPath, + )...) + if code == 0 { + t.Error("expected non-zero exit for mismatched input_sha in decisions file") + } +} + +// TestEvidenceDir_WrittenOutsideWorkspace verifies evidence appears in evidence dir. +func TestEvidenceDir_WrittenOutsideWorkspace(t *testing.T) { + workspace, baseSHA, inputSHA := makeTestRepo(t) + configPath, policyHash := makeTrustedConfig(t, minimalConfig) + evidenceDir := makeEvidenceDir(t) + + runManagedValidate(t, fullValidateArgs( + "G-ev", "M-ev", workspace, baseSHA, inputSHA, configPath, policyHash, evidenceDir, + )...) + + // Evidence directory must have the run-id subdirectory. The run_id is + // hashed (SHA-256) into a path-safe directory name to prevent traversal. + sum := sha256.Sum256([]byte("G-ev")) + safeRunID := hex.EncodeToString(sum[:]) + runDir := filepath.Join(evidenceDir, safeRunID) + info, err := os.Stat(runDir) + if err != nil { + t.Fatalf("evidence run dir %q not created: %v", runDir, err) + } + if !info.IsDir() { + t.Errorf("evidence run dir %q is not a directory", runDir) + } + + // No evidence should be inside the workspace. + entries, _ := os.ReadDir(workspace) + for _, e := range entries { + if e.Name() != ".git" && e.Name() != "README.md" && e.Name() != "hello.go" { + t.Errorf("unexpected file in workspace: %s", e.Name()) + } + } +} diff --git a/internal/managed/preflight.go b/internal/managed/preflight.go new file mode 100644 index 0000000..3e670c7 --- /dev/null +++ b/internal/managed/preflight.go @@ -0,0 +1,251 @@ +package managed + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/douglasjarquin/made/internal/safegit" +) + +var fullSHARegexp = regexp.MustCompile(`^[0-9a-f]{40}$`) +var policyHashRegexp = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// PreflightResult holds the verified inputs produced by preflight. +type PreflightResult struct { + // ConfigBytes are the exact bytes read from the trusted config file. + // These must be used for parsing; the file must not be re-read. + ConfigBytes []byte +} + +// ValidateOptions performs pure format/argument validation. +// Returns a non-nil error for usage errors that should produce exit 2 (no events). +func ValidateOptions(opts *Options) error { + if opts.RunID == "" { + return fmt.Errorf("--run-id is required") + } + if opts.MissionID == "" { + return fmt.Errorf("--mission-id is required") + } + if !filepath.IsAbs(opts.Workspace) { + return fmt.Errorf("--workspace %q must be an absolute path", opts.Workspace) + } + if !filepath.IsAbs(opts.TrustedConfig) { + return fmt.Errorf("--trusted-config %q must be an absolute path", opts.TrustedConfig) + } + if !filepath.IsAbs(opts.EvidenceDir) { + return fmt.Errorf("--evidence-dir %q must be an absolute path", opts.EvidenceDir) + } + if !fullSHARegexp.MatchString(opts.InputSHA) { + return fmt.Errorf("--input-sha %q must be a full 40-hex commit SHA", opts.InputSHA) + } + if !fullSHARegexp.MatchString(opts.BaseSHA) { + return fmt.Errorf("--base-sha %q must be a full 40-hex commit SHA", opts.BaseSHA) + } + if !policyHashRegexp.MatchString(opts.PolicyHash) { + return fmt.Errorf("--policy-hash %q must match sha256:<64-lowercase-hex>", opts.PolicyHash) + } + if opts.DecisionsPath != "" && !filepath.IsAbs(opts.DecisionsPath) { + return fmt.Errorf("--decisions %q must be an absolute path", opts.DecisionsPath) + } + return nil +} + +// RunPreflight verifies all preconditions before any validation stage begins. +// On success it returns a PreflightResult containing the verified config bytes. +// Format/argument validation is handled separately by ValidateOptions; this +// function performs OS and Git checks only. +func RunPreflight(ctx context.Context, opts *Options) (PreflightResult, error) { + // workspace exists and is a Git working tree + wsInfo, err := os.Stat(opts.Workspace) + if err != nil { + return PreflightResult{}, fmt.Errorf("preflight: workspace %q: %w", opts.Workspace, err) + } + if !wsInfo.IsDir() { + return PreflightResult{}, fmt.Errorf("preflight: workspace %q is not a directory", opts.Workspace) + } + if _, gitErr := safegit.Output(ctx, safegit.Command{ + WorktreePath: opts.Workspace, + Args: []string{"rev-parse", "--git-dir"}, + }); gitErr != nil { + return PreflightResult{}, fmt.Errorf("preflight: workspace %q is not a Git working tree: %w", opts.Workspace, gitErr) + } + + // HEAD^{commit} exactly equals input_sha + headSHA, err := safegit.Output(ctx, safegit.Command{ + WorktreePath: opts.Workspace, + Args: []string{"rev-parse", "--verify", "HEAD^{commit}"}, + }) + if err != nil { + return PreflightResult{}, fmt.Errorf("preflight: resolve HEAD: %w", err) + } + if headSHA != opts.InputSHA { + return PreflightResult{}, fmt.Errorf("preflight: workspace HEAD is %q but input_sha is %q", headSHA, opts.InputSHA) + } + + // both commits exist locally + if _, err := safegit.Output(ctx, safegit.Command{ + WorktreePath: opts.Workspace, + Args: []string{"cat-file", "-e", opts.InputSHA + "^{commit}"}, + }); err != nil { + return PreflightResult{}, fmt.Errorf("preflight: input_sha %q does not exist locally: %w", opts.InputSHA, err) + } + if _, err := safegit.Output(ctx, safegit.Command{ + WorktreePath: opts.Workspace, + Args: []string{"cat-file", "-e", opts.BaseSHA + "^{commit}"}, + }); err != nil { + return PreflightResult{}, fmt.Errorf("preflight: base_sha %q does not exist locally: %w", opts.BaseSHA, err) + } + + // base_sha is an ancestor of input_sha + mergeBase, err := safegit.Output(ctx, safegit.Command{ + WorktreePath: opts.Workspace, + Args: []string{"merge-base", opts.BaseSHA, opts.InputSHA}, + }) + if err != nil { + return PreflightResult{}, fmt.Errorf("preflight: compute merge-base: %w", err) + } + if mergeBase != opts.BaseSHA { + return PreflightResult{}, fmt.Errorf("preflight: base_sha %q is not an ancestor of input_sha %q", opts.BaseSHA, opts.InputSHA) + } + + // worktree is clean (no tracked or non-ignored untracked changes) + status, err := safegit.Output(ctx, safegit.Command{ + WorktreePath: opts.Workspace, + Args: []string{"status", "--porcelain", "--untracked-files=all"}, + }) + if err != nil { + return PreflightResult{}, fmt.Errorf("preflight: inspect worktree status: %w", err) + } + if status != "" { + return PreflightResult{}, fmt.Errorf("preflight: workspace has uncommitted changes:\n%s", status) + } + + // trusted-config: read exactly once, verify it is a regular file even after open + // (closes the symlink-swap race between Lstat and Open). + configInfo, err := os.Lstat(opts.TrustedConfig) + if err != nil { + return PreflightResult{}, fmt.Errorf("preflight: trusted-config stat: %w", err) + } + if !configInfo.Mode().IsRegular() { + return PreflightResult{}, fmt.Errorf("preflight: trusted-config %q is not a regular file", opts.TrustedConfig) + } + var configBytes []byte + if err := func() error { + f, err := os.Open(opts.TrustedConfig) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer func() { _ = f.Close() }() + // Verify fd points to a regular file (prevents symlink-swap race between Lstat and Open). + fi, err := f.Stat() + if err != nil { + return fmt.Errorf("fstat: %w", err) + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("not a regular file after open") + } + configBytes, err = io.ReadAll(f) + return err + }(); err != nil { + return PreflightResult{}, fmt.Errorf("preflight: read trusted-config: %w", err) + } + + // verify SHA-256 of config bytes matches policy_hash + sum := sha256.Sum256(configBytes) + computedHash := "sha256:" + hex.EncodeToString(sum[:]) + if computedHash != opts.PolicyHash { + return PreflightResult{}, fmt.Errorf("preflight: trusted-config hash mismatch: computed %s, expected %s", computedHash, opts.PolicyHash) + } + + // evidence-dir must be outside the workspace, using canonical path resolution. + // Canonicalize both paths to prevent symlink-based escapes. + canonicalWS, err := filepath.EvalSymlinks(opts.Workspace) + if err != nil { + return PreflightResult{}, fmt.Errorf("preflight: resolve workspace canonical path: %w", err) + } + + // Canonicalize evidence directory. If it doesn't exist, resolve its nearest + // existing parent to catch symlinks that point inside the workspace. + var canonicalEV string + evPath := opts.EvidenceDir + for { + canonical, err := filepath.EvalSymlinks(evPath) + if err == nil { + canonicalEV = canonical + break + } + parent := filepath.Dir(evPath) + if parent == evPath { + // Reached root; use the path as-is + canonicalEV = evPath + break + } + evPath = parent + } + + // Verify evidence directory is outside workspace + evWithSep := canonicalEV + string(filepath.Separator) + wsWithSep := canonicalWS + string(filepath.Separator) + if evWithSep == wsWithSep || canonicalEV == canonicalWS || strings.HasPrefix(evWithSep, wsWithSep) || strings.HasPrefix(wsWithSep, evWithSep) { + return PreflightResult{}, fmt.Errorf("preflight: evidence-dir %q (canonical: %q) must be outside and non-overlapping with workspace %q", opts.EvidenceDir, canonicalEV, canonicalWS) + } + + return PreflightResult{ConfigBytes: configBytes}, nil +} + +// CaptureWorktreeState captures HEAD and porcelain status for nonmutation verification. +func CaptureWorktreeState(ctx context.Context, workspace string) (head, status string, err error) { + head, err = safegit.Output(ctx, safegit.Command{ + WorktreePath: workspace, + Args: []string{"rev-parse", "HEAD"}, + }) + if err != nil { + return "", "", fmt.Errorf("capture worktree state: HEAD: %w", err) + } + status, err = safegit.Output(ctx, safegit.Command{ + WorktreePath: workspace, + Args: []string{"status", "--porcelain", "--untracked-files=all"}, + }) + if err != nil { + return "", "", fmt.Errorf("capture worktree state: status: %w", err) + } + return head, status, nil +} + +// VerifyWorktreeUnchanged checks that HEAD and status are identical to the captured values. +func VerifyWorktreeUnchanged(ctx context.Context, workspace, beforeHead, beforeStatus string) error { + afterHead, afterStatus, err := CaptureWorktreeState(ctx, workspace) + if err != nil { + return err + } + if afterHead != beforeHead { + return fmt.Errorf("workspace HEAD changed during stage: before=%s after=%s", beforeHead, afterHead) + } + if afterStatus != beforeStatus { + return fmt.Errorf("workspace status changed during stage: before=%q after=%q", beforeStatus, afterStatus) + } + return nil +} + +// VerifyExactInputSHA checks that HEAD == inputSHA and workspace is clean. +// This guard prevents undetected mutations or concurrent workspace changes. +func VerifyExactInputSHA(ctx context.Context, workspace, inputSHA string) error { + head, status, err := CaptureWorktreeState(ctx, workspace) + if err != nil { + return err + } + if head != inputSHA { + return fmt.Errorf("workspace HEAD %s does not match input_sha %s", head, inputSHA) + } + if status != "" { + return fmt.Errorf("workspace not clean (dirty files detected)") + } + return nil +} diff --git a/internal/managed/run.go b/internal/managed/run.go new file mode 100644 index 0000000..321cdb8 --- /dev/null +++ b/internal/managed/run.go @@ -0,0 +1,161 @@ +package managed + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "os" +) + +// MadeVersion is embedded in terminal evidence. Override with ldflags during release builds. +var MadeVersion = "dev" + +// Run executes the full managed-validation lifecycle. +// stdout is dedicated to the JSON-Lines event stream; diagnostics go to stderr. +// +// Exit codes: +// - 0 passed +// - 1 infrastructure_error +// - 2 usage/contract error (no JSON stream emitted) +// - 3 needs_decision +// - 4 failed_retryable +// - 5 failed_terminal +// - 130 canceled +func Run(ctx context.Context, opts *Options, stdout, stderr *os.File) int { + // Phase 0: Validate argument formats (usage errors → exit 2, no events). + if err := ValidateOptions(opts); err != nil { + _, _ = fmt.Fprintln(stderr, "made validate:", err) + return 2 + } + + // Generate a per-invocation ID to prevent evidence path collisions across reruns. + invID, err := generateInvocationID() + if err != nil { + _, _ = fmt.Fprintln(stderr, "made validate: generate invocation ID:", err) + return 2 + } + opts.InvocationID = invID + + ew := NewEventWriter(stdout, opts) + + // Emit run.started before any infra work. + // All subsequent failures must emit run.completed before returning. + if err := ew.Emit("run.started", RunStartedPayload{}); err != nil { + _, _ = fmt.Fprintln(stderr, "made validate: emit run.started:", err) + return 1 + } + + // emitInfraError emits a terminal infrastructure_error event, logs to stderr, + // and returns the appropriate exit code (1). + emitInfraError := func(msg string) int { + _, _ = fmt.Fprintln(stderr, "made validate:", msg) + _ = ew.EmitTerminal(RunCompletedPayload{ + Outcome: OutcomeInfrastructureError, + Message: msg, + InvocationID: invID, + }) + return OutcomeInfrastructureError.ExitCode() + } + + // Phase 1: Infrastructure preflight. + preflightResult, err := RunPreflight(ctx, opts) + if err != nil { + if ctx.Err() != nil { + _ = ew.EmitTerminal(RunCompletedPayload{ + Outcome: OutcomeCanceled, + Message: "canceled during preflight: " + ctx.Err().Error(), + InvocationID: invID, + }) + return OutcomeCanceled.ExitCode() + } + return emitInfraError("preflight: " + err.Error()) + } + + // Phase 2: Parse config from verified bytes (never re-read the file). + cfg, err := loadConfig(preflightResult.ConfigBytes) + if err != nil { + return emitInfraError("parse trusted config: " + err.Error()) + } + + // Phase 3: Load decisions (optional). + decs, err := readDecisions(opts.DecisionsPath, opts) + if err != nil { + return emitInfraError("load decisions: " + err.Error()) + } + + // Phase 4: Create evidence store. + ev := NewManagedEvidenceStore(opts.EvidenceDir, opts.RunID, invID) + if mkErr := os.MkdirAll(ev.InvocationDir(), 0o750); mkErr != nil { + return emitInfraError("create evidence directory: " + mkErr.Error()) + } + + // Phase 5: Run validation stages. + runner := NewRunner(opts, cfg, ew, ev, decs) + outcome, msg, stoppedAt := runner.Run(ctx) + + if ctx.Err() != nil { + outcome = OutcomeCanceled + msg = "canceled: " + ctx.Err().Error() + stoppedAt = "canceled" + } + + // Phase 6: Write terminal evidence. + // This is the single authoritative summary of the run outcome. + // Any failure here overrides the outcome to infrastructure_error. + manifest := buildTerminalManifest(opts, runner, invID, outcome, ew.Sequence()+1) + if writeErr := ev.WriteTerminal(manifest); writeErr != nil { + outcome = OutcomeInfrastructureError + msg = "terminal evidence write failed: " + writeErr.Error() + _, _ = fmt.Fprintln(stderr, "made validate:", msg) + } + + // Report unused decisions. + for _, unused := range runner.UnusedDecisions() { + _, _ = fmt.Fprintf(stderr, "made validate: unused decision %s (fingerprint %s)\n", + unused.DecisionID, unused.FindingFingerprint) + } + + // Phase 7: Emit terminal run.completed event. + if err := ew.EmitTerminal(RunCompletedPayload{ + Outcome: outcome, + Stage: stoppedAt, + Message: msg, + InvocationID: invID, + Findings: runner.AllFindings(), + EvidenceRefs: runner.EvidenceRefs(), + }); err != nil { + _, _ = fmt.Fprintln(stderr, "made validate: emit run.completed:", err) + return 1 + } + + return outcome.ExitCode() +} + +// generateInvocationID returns a random 16-character lowercase hex string. +func generateInvocationID() (string, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +// buildTerminalManifest constructs the run terminal evidence summary. +func buildTerminalManifest(opts *Options, runner *Runner, invID string, outcome Outcome, eventCount int) *TerminalManifest { + return &TerminalManifest{ + RunID: opts.RunID, + MissionID: opts.MissionID, + InvocationID: invID, + BaseSHA: opts.BaseSHA, + InputSHA: opts.InputSHA, + PolicyHash: opts.PolicyHash, + StageResults: runner.StageResults(), + Findings: runner.AllFindings(), + DecisionsApplied: runner.DecisionsApplied(), + Outcome: outcome, + EventCount: eventCount, + EvidenceRefs: runner.EvidenceRefs(), + MadeVersion: MadeVersion, + } +} diff --git a/internal/managed/runner.go b/internal/managed/runner.go new file mode 100644 index 0000000..8f3a31d --- /dev/null +++ b/internal/managed/runner.go @@ -0,0 +1,510 @@ +package managed + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/douglasjarquin/made/internal/agent" + "github.com/douglasjarquin/made/internal/config" + "github.com/douglasjarquin/made/internal/evidence" + "github.com/douglasjarquin/made/internal/pipeline/document" + "github.com/douglasjarquin/made/internal/pipeline/lint" + "github.com/douglasjarquin/made/internal/pipeline/review" + "github.com/douglasjarquin/made/internal/pipeline/test" +) + +const ( + stageReview = "review" + stageTest = "test" + stageDocument = "document" + stageLint = "lint" +) + +// Runner executes managed validation stages. +type Runner struct { + opts *Options + cfg config.Config + ew *EventWriter + evidence *ManagedEvidenceStore + decisions *Decisions + + allFindings []FindingReportedPayload + stageResults []StageResult + evidenceRefs []string + decisionsUsed map[string]struct{} +} + +// NewRunner constructs a Runner from validated options, parsed config, and loaded decisions. +func NewRunner(opts *Options, cfg config.Config, ew *EventWriter, ev *ManagedEvidenceStore, decisions *Decisions) *Runner { + return &Runner{ + opts: opts, + cfg: cfg, + ew: ew, + evidence: ev, + decisions: decisions, + decisionsUsed: make(map[string]struct{}), + } +} + +// Run executes all managed validation stages in order. +// It returns the terminal outcome. The caller is responsible for emitting the +// terminal event via ew.EmitTerminal. +func (r *Runner) Run(ctx context.Context) (Outcome, string, string) { + stages := []string{stageReview, stageTest, stageDocument, stageLint} + for _, stage := range stages { + outcome, msg, stoppedAt := r.runStage(ctx, stage) + if outcome != OutcomePassed { + return outcome, msg, stoppedAt + } + } + return OutcomePassed, "all managed validation stages passed", stageLint +} + +func (r *Runner) runStage(ctx context.Context, stage string) (Outcome, string, string) { + if err := r.ew.Emit("stage.started", StageStartedPayload{Stage: stage}); err != nil { + return OutcomeInfrastructureError, fmt.Sprintf("emit stage.started: %s", err), stage + } + + // Verify HEAD == input_sha and workspace clean before stage. + if verifyErr := VerifyExactInputSHA(ctx, r.opts.Workspace, r.opts.InputSHA); verifyErr != nil { + return OutcomeInfrastructureError, fmt.Sprintf("pre-stage validation failed: %s", verifyErr), stage + } + + // Capture pre-stage worktree state. + beforeHead, beforeStatus, err := CaptureWorktreeState(ctx, r.opts.Workspace) + if err != nil { + return OutcomeInfrastructureError, fmt.Sprintf("capture pre-stage state for %s: %s", stage, err), stage + } + + outcome, msg, findings, refs := r.executeStage(ctx, stage) + + // Verify workspace unchanged regardless of outcome. + if mutErr := VerifyWorktreeUnchanged(ctx, r.opts.Workspace, beforeHead, beforeStatus); mutErr != nil { + _ = r.ew.Emit("stage.completed", StageCompletedPayload{ + Stage: stage, + Outcome: OutcomeInfrastructureError, + Message: "stage mutated workspace: " + mutErr.Error(), + }) + r.stageResults = append(r.stageResults, StageResult{Stage: stage, Outcome: OutcomeInfrastructureError, Message: mutErr.Error()}) + return OutcomeInfrastructureError, "stage " + stage + " mutated workspace: " + mutErr.Error(), stage + } + + // Verify HEAD == input_sha and workspace clean after stage. + if verifyErr := VerifyExactInputSHA(ctx, r.opts.Workspace, r.opts.InputSHA); verifyErr != nil { + return OutcomeInfrastructureError, fmt.Sprintf("post-stage validation failed: %s", verifyErr), stage + } + + r.allFindings = append(r.allFindings, findings...) + r.stageResults = append(r.stageResults, StageResult{Stage: stage, Outcome: outcome, Message: msg, Findings: findings}) + + for _, ref := range refs { + r.evidenceRefs = append(r.evidenceRefs, ref) + if emitErr := r.ew.Emit("evidence.created", EvidenceCreatedPayload{Stage: stage, Path: ref}); emitErr != nil { + return OutcomeInfrastructureError, fmt.Sprintf("emit evidence.created: %s", emitErr), stage + } + } + + if emitErr := r.ew.Emit("stage.completed", StageCompletedPayload{Stage: stage, Outcome: outcome, Message: msg}); emitErr != nil { + return OutcomeInfrastructureError, fmt.Sprintf("emit stage.completed: %s", emitErr), stage + } + + return outcome, msg, stage +} + +func (r *Runner) executeStage(ctx context.Context, stage string) (Outcome, string, []FindingReportedPayload, []string) { + switch stage { + case stageReview: + return r.reviewStage(ctx) + case stageTest: + return r.testStage(ctx) + case stageDocument: + return r.documentStage(ctx) + case stageLint: + return r.lintStage(ctx) + default: + return OutcomeInfrastructureError, "unknown stage: " + stage, nil, nil + } +} + +func (r *Runner) reviewStage(ctx context.Context) (Outcome, string, []FindingReportedPayload, []string) { + agentKind, err := r.cfg.AgentKind() + if err != nil { + return OutcomeInfrastructureError, fmt.Sprintf("resolve agent kind: %s", err), nil, nil + } + + timeout := r.cfg.StageTimeout(stageReview) + stageCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + reviewOpts := review.Options{ + BaseBranch: r.opts.BaseSHA, // exact SHA used as base ref + ReportOnly: true, // managed mode: never apply auto-fixes + EvidenceRunID: r.opts.RunID, + } + if r.opts.ReviewAgentBinaryPath != "" { + reviewOpts.BinaryPath = r.opts.ReviewAgentBinaryPath + reviewOpts.ExtraEnv = r.opts.ReviewAgentExtraEnv + } + + result, err := review.Run(stageCtx, r.opts.Workspace, agentKind, reviewOpts) + if err != nil { + return OutcomeInfrastructureError, fmt.Sprintf("review: %s", err), nil, nil + } + + // Write evidence. + evidenceData, marshalErr := json.Marshal(result.Findings) + if marshalErr != nil { + return OutcomeInfrastructureError, fmt.Sprintf("review: marshal findings: %s", marshalErr), nil, nil + } + refs, evErr := r.evidence.WriteStageFiles(stageReview, map[string][]byte{ + "findings.json": evidenceData, + }) + if evErr != nil { + return OutcomeInfrastructureError, fmt.Sprintf("review: write evidence: %s", evErr), nil, nil + } + + // Classify findings. + var findings []FindingReportedPayload + outcome := OutcomePassed + var msg string + + for _, f := range result.Findings { + // Validate managed findings have sufficient structural identity. + if validationErr := ValidateStableFindingIdentity(FingerprintInput{ + Stage: stageReview, + Kind: string(f.Kind), + Code: f.Code, + Class: f.Class, + Symbol: f.Symbol, + Paths: f.Paths, + Description: f.Description, + WorkspacePrefix: r.opts.Workspace, + }); validationErr != nil { + return OutcomeInfrastructureError, fmt.Sprintf("review: invalid finding identity: %s", validationErr), nil, nil + } + + fp := Fingerprint(FingerprintInput{ + Stage: stageReview, + Kind: string(f.Kind), + Code: f.Code, + Class: f.Class, + Symbol: f.Symbol, + Paths: f.Paths, + Description: f.Description, + WorkspacePrefix: r.opts.Workspace, + }) + payload := FindingReportedPayload{ + Fingerprint: fp, + Stage: stageReview, + Kind: string(f.Kind), + Code: f.Code, + Class: f.Class, + Description: evidence.RedactString(f.Description), + Paths: f.Paths, + Symbol: f.Symbol, + Patch: f.Patch, + } + findings = append(findings, payload) + if emitErr := r.ew.Emit("finding.reported", payload); emitErr != nil { + return OutcomeInfrastructureError, fmt.Sprintf("emit finding.reported: %s", emitErr), nil, nil + } + + switch f.Kind { + case agent.FindingBlocking: + outcome = mergeOutcome(outcome, OutcomeFailedTerminal) + msg = fmt.Sprintf("blocking finding: %s", evidence.RedactString(f.Description)) + case agent.FindingAutoFixable: + outcome = mergeOutcome(outcome, OutcomeFailedRetryable) + if msg == "" { + msg = "auto-fixable finding(s) require repair" + } + case agent.FindingAskUser: + dec, hasDec := r.decisions.Lookup(fp) + if !hasDec { + outcome = mergeOutcome(outcome, OutcomeNeedsDecision) + if msg == "" { + msg = "ask-user finding(s) require a Decision" + } + } else { + r.decisionsUsed[fp] = struct{}{} + if dec.Outcome == DecisionRejected { + outcome = mergeOutcome(outcome, OutcomeFailedTerminal) + msg = fmt.Sprintf("ask-user finding rejected by decision %s: %s", dec.DecisionID, evidence.RedactString(f.Description)) + } + // approved: continue (outcome stays passed or existing failure) + } + } + } + + if !result.OK { + outcome = mergeOutcome(outcome, OutcomeFailedTerminal) + if msg == "" { + msg = result.Message + } + } + if msg == "" { + msg = result.Message + } + + // Check for duplicate fingerprints in this stage (blocker 1: prevent ambiguous decisions). + if dupErr := r.checkDuplicateFingerprints(stageReview, findings); dupErr != nil { + return OutcomeInfrastructureError, dupErr.Error(), nil, refs + } + + return outcome, msg, findings, refs +} + +func (r *Runner) testStage(ctx context.Context) (Outcome, string, []FindingReportedPayload, []string) { + cmd := r.cfg.TestCommand() + if len(cmd) == 0 { + return OutcomeInfrastructureError, "no test command configured", nil, nil + } + + timeout := r.cfg.StageTimeout(stageTest) + stageCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Use a simple evidence store shim. + ev := &stageEvidenceShim{store: r.evidence, stage: stageTest} + result, err := test.Run(stageCtx, r.opts.Workspace, r.opts.RunID, cmd, ev) + if err != nil { + return OutcomeInfrastructureError, fmt.Sprintf("test: %s", err), nil, nil + } + + refs := ev.refs + if !result.OK { + return OutcomeFailedRetryable, result.Message, nil, refs + } + return OutcomePassed, result.Message, nil, refs +} + +func (r *Runner) documentStage(ctx context.Context) (Outcome, string, []FindingReportedPayload, []string) { + rules := deriveDocumentRules(r.cfg) + + timeout := r.cfg.StageTimeout(stageDocument) + stageCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Use exact SHA range through safegit. + result, err := document.RunContextWithRange(stageCtx, r.opts.Workspace, r.opts.BaseSHA, r.opts.InputSHA, rules) + if err != nil { + return OutcomeInfrastructureError, fmt.Sprintf("document: %s", err), nil, nil + } + + var findings []FindingReportedPayload + outcome := OutcomePassed + msg := result.Message + + for _, f := range result.Findings { + // Validate managed findings have sufficient structural identity. + if validationErr := ValidateStableFindingIdentity(FingerprintInput{ + Stage: stageDocument, + Kind: string(f.Kind), + Code: f.Code, + Class: f.Class, + Symbol: f.Symbol, + Paths: f.Paths, + Description: f.Description, + WorkspacePrefix: r.opts.Workspace, + }); validationErr != nil { + return OutcomeInfrastructureError, fmt.Sprintf("document: invalid finding identity: %s", validationErr), nil, nil + } + + fp := Fingerprint(FingerprintInput{ + Stage: stageDocument, + Kind: string(f.Kind), + Code: f.Code, + Class: f.Class, + Symbol: f.Symbol, + Paths: f.Paths, + Description: f.Description, + WorkspacePrefix: r.opts.Workspace, + }) + payload := FindingReportedPayload{ + Fingerprint: fp, + Stage: stageDocument, + Kind: string(f.Kind), + Code: f.Code, + Class: f.Class, + Description: evidence.RedactString(f.Description), + Paths: f.Paths, + Symbol: f.Symbol, + } + findings = append(findings, payload) + if emitErr := r.ew.Emit("finding.reported", payload); emitErr != nil { + return OutcomeInfrastructureError, fmt.Sprintf("emit finding.reported: %s", emitErr), nil, nil + } + + dec, hasDec := r.decisions.Lookup(fp) + if !hasDec { + outcome = mergeOutcome(outcome, OutcomeNeedsDecision) + if msg == "" || outcome == OutcomeNeedsDecision { + msg = "document finding(s) require a Decision" + } + } else { + r.decisionsUsed[fp] = struct{}{} + if dec.Outcome == DecisionRejected { + outcome = mergeOutcome(outcome, OutcomeFailedTerminal) + msg = fmt.Sprintf("document finding rejected by decision %s: %s", dec.DecisionID, evidence.RedactString(f.Description)) + } + } + } + + // Write evidence. + evidenceData, marshalErr := json.Marshal(result.Findings) + if marshalErr != nil { + return OutcomeInfrastructureError, fmt.Sprintf("document: marshal findings: %s", marshalErr), findings, nil + } + refs, evErr := r.evidence.WriteStageFiles(stageDocument, map[string][]byte{ + "findings.json": evidenceData, + }) + if evErr != nil { + return OutcomeInfrastructureError, fmt.Sprintf("document: write evidence: %s", evErr), findings, nil + } + + // Check for duplicate fingerprints in this stage (blocker 1: prevent ambiguous decisions). + if dupErr := r.checkDuplicateFingerprints(stageDocument, findings); dupErr != nil { + return OutcomeInfrastructureError, dupErr.Error(), nil, refs + } + + return outcome, msg, findings, refs +} + +func (r *Runner) lintStage(ctx context.Context) (Outcome, string, []FindingReportedPayload, []string) { + cmd := r.cfg.LintCommand() + + timeout := r.cfg.StageTimeout(stageLint) + stageCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + ev := &stageEvidenceShim{store: r.evidence, stage: stageLint} + result, err := lint.Run(stageCtx, r.opts.Workspace, r.opts.RunID, cmd, ev) + if err != nil { + return OutcomeInfrastructureError, fmt.Sprintf("lint: %s", err), nil, nil + } + + refs := ev.refs + if !result.OK { + return OutcomeFailedRetryable, result.Message, nil, refs + } + return OutcomePassed, result.Message, nil, refs +} + +func (r *Runner) AllFindings() []FindingReportedPayload { + return r.allFindings +} + +func (r *Runner) EvidenceRefs() []string { + return r.evidenceRefs +} + +func (r *Runner) StageResults() []StageResult { + return r.stageResults +} + +func (r *Runner) UnusedDecisions() []DecisionRecord { + var unused []DecisionRecord + for _, d := range r.decisions.All() { + if _, used := r.decisionsUsed[d.FindingFingerprint]; !used { + unused = append(unused, d) + } + } + return unused +} + +// checkDuplicateFingerprints verifies that no two findings in a stage have the same fingerprint. +// This prevents ambiguous decision application where a Decision might approve or reject the +// wrong finding. Returns an error if duplicates are found. +func (r *Runner) checkDuplicateFingerprints(stage string, findings []FindingReportedPayload) error { + seen := make(map[string]int) // fingerprint -> count + for _, f := range findings { + seen[f.Fingerprint]++ + } + for fp, count := range seen { + if count > 1 { + return fmt.Errorf("stage %s: %d findings share fingerprint %s; cannot apply decisions unambiguously", stage, count, fp) + } + } + return nil +} + +// DecisionsApplied returns the fingerprints of decisions that matched a finding. +func (r *Runner) DecisionsApplied() []string { + applied := make([]string, 0, len(r.decisionsUsed)) + for fp := range r.decisionsUsed { + applied = append(applied, fp) + } + return applied +} + +// outcomeRank ranks outcomes so the most severe outcome wins when merging. +func outcomeRank(o Outcome) int { + switch o { + case OutcomePassed: + return 0 + case OutcomeNeedsDecision: + return 1 + case OutcomeFailedRetryable: + return 2 + case OutcomeFailedTerminal, OutcomeInfrastructureError: + return 3 + default: + return 1 + } +} + +// mergeOutcome returns the more severe of two outcomes. +func mergeOutcome(current, next Outcome) Outcome { + if outcomeRank(next) > outcomeRank(current) { + return next + } + return current +} + +func deriveDocumentRules(cfg config.Config) []document.Rule { + rules := make([]document.Rule, 0, len(cfg.Document.Rules)) + for _, r := range cfg.Document.Rules { + rules = append(rules, document.Rule{SourcePattern: r.PathPattern, DocPattern: r.RequiredDocPattern}) + } + return rules +} + +// stageEvidenceShim adapts ManagedEvidenceStore to the evidence.Store interface +// used by test and lint stages. +type stageEvidenceShim struct { + store *ManagedEvidenceStore + stage string + refs []string +} + +func (s *stageEvidenceShim) WriteEvidence(runID string, files map[string][]byte) error { + refs, err := s.store.WriteStageFiles(s.stage, files) + if err != nil { + return err + } + s.refs = append(s.refs, refs...) + return nil +} + +func (s *stageEvidenceShim) WriteEvidenceContext(ctx context.Context, runID string, files map[string][]byte) error { + return s.WriteEvidence(runID, files) +} + +// loadConfig parses verified config bytes into a config.Config. +// The bytes must already have been hash-verified by preflight. +func loadConfig(configBytes []byte) (config.Config, error) { + cfg, err := config.ParseBytes(configBytes) + if err != nil { + return config.Config{}, fmt.Errorf("managed: parse trusted config: %w", err) + } + return cfg, nil +} + +// readDecisions loads and validates the optional Decisions file. +func readDecisions(path string, opts *Options) (*Decisions, error) { + if path == "" { + return &Decisions{byFingerprint: make(map[string]DecisionRecord)}, nil + } + return LoadDecisions(path, opts) +} diff --git a/internal/managed/testdata/decisions-approved.json b/internal/managed/testdata/decisions-approved.json new file mode 100644 index 0000000..1cb8363 --- /dev/null +++ b/internal/managed/testdata/decisions-approved.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "run_id": "G-229", + "mission_id": "M-402", + "input_sha": "2222222222222222222222222222222222222222", + "base_sha": "1111111111111111111111111111111111111111", + "invocation_id": "0987654321fedcba", + "policy_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "decisions": [ + { + "decision_id": "D-184", + "finding_fingerprint": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "outcome": "approved", + "scope": "sha_bound", + "rationale": "Accepted: the dependency was reviewed in Slack thread #arch-2026-08-18" + } + ] +} diff --git a/internal/managed/testdata/decisions-rejected.json b/internal/managed/testdata/decisions-rejected.json new file mode 100644 index 0000000..048de23 --- /dev/null +++ b/internal/managed/testdata/decisions-rejected.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "run_id": "G-229", + "mission_id": "M-402", + "input_sha": "2222222222222222222222222222222222222222", + "base_sha": "1111111111111111111111111111111111111111", + "invocation_id": "0987654321fedcba", + "policy_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "decisions": [ + { + "decision_id": "D-185", + "finding_fingerprint": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "outcome": "rejected", + "scope": "sha_bound", + "rationale": "Rejected: new dependency violates internal security policy SC-42" + } + ] +} diff --git a/internal/managed/testdata/failed-retryable.jsonl b/internal/managed/testdata/failed-retryable.jsonl new file mode 100644 index 0000000..f3ce0df --- /dev/null +++ b/internal/managed/testdata/failed-retryable.jsonl @@ -0,0 +1,4 @@ +{"schema_version":1,"protocol_version":1,"sequence":1,"run_id":"G-229","invocation_id":"abcdefedcbabcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"run.started","timestamp":"2026-08-18T21:00:00.000000000Z","payload":{}} +{"schema_version":1,"protocol_version":1,"sequence":2,"run_id":"G-229","invocation_id":"abcdefedcbabcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"stage.started","timestamp":"2026-08-18T21:00:00.100000000Z","payload":{"stage":"review"}} +{"schema_version":1,"protocol_version":1,"sequence":3,"run_id":"G-229","invocation_id":"abcdefedcbabcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"finding.reported","timestamp":"2026-08-18T21:00:04.000000000Z","payload":{"fingerprint":"sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890","stage":"review","kind":"auto-fixable","code":"review.formatting","description":"gofmt needed","paths":["internal/foo.go"],"patch":"--- a/internal/foo.go\n+++ b/internal/foo.go\n@@ -1 +1 @@\n-package main\n+package main\n"}} +{"schema_version":1,"protocol_version":1,"sequence":4,"run_id":"G-229","invocation_id":"abcdefedcbabcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"run.completed","timestamp":"2026-08-18T21:00:04.100000000Z","payload":{"outcome":"failed_retryable","stage":"review","message":"auto-fixable finding(s) require repair","invocation_id":"abcdefedcbabcdef","findings":[{"fingerprint":"sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890","stage":"review","kind":"auto-fixable","code":"review.formatting","description":"gofmt needed","paths":["internal/foo.go"],"patch":"--- a/internal/foo.go\n+++ b/internal/foo.go\n@@ -1 +1 @@\n-package main\n+package main\n"}],"evidence_refs":[]}} diff --git a/internal/managed/testdata/failed-terminal.jsonl b/internal/managed/testdata/failed-terminal.jsonl new file mode 100644 index 0000000..5fe9de6 --- /dev/null +++ b/internal/managed/testdata/failed-terminal.jsonl @@ -0,0 +1,4 @@ +{"schema_version":1,"protocol_version":1,"sequence":1,"run_id":"G-229","invocation_id":"deadbeefdeadbeef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"run.started","timestamp":"2026-08-18T21:00:00.000000000Z","payload":{}} +{"schema_version":1,"protocol_version":1,"sequence":2,"run_id":"G-229","invocation_id":"deadbeefdeadbeef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"stage.started","timestamp":"2026-08-18T21:00:00.100000000Z","payload":{"stage":"review"}} +{"schema_version":1,"protocol_version":1,"sequence":3,"run_id":"G-229","invocation_id":"deadbeefdeadbeef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"finding.reported","timestamp":"2026-08-18T21:00:04.000000000Z","payload":{"fingerprint":"sha256:deadbeef1234567890deadbeef1234567890deadbeef1234567890deadbeef12","stage":"review","kind":"blocking","code":"security.sql_injection","description":"Security vulnerability: direct SQL string interpolation","paths":["internal/db.go"]}} +{"schema_version":1,"protocol_version":1,"sequence":4,"run_id":"G-229","invocation_id":"deadbeefdeadbeef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"run.completed","timestamp":"2026-08-18T21:00:04.100000000Z","payload":{"outcome":"failed_terminal","stage":"review","message":"blocking finding: Security vulnerability: direct SQL string interpolation","invocation_id":"deadbeefdeadbeef","findings":[{"fingerprint":"sha256:deadbeef1234567890deadbeef1234567890deadbeef1234567890deadbeef12","stage":"review","kind":"blocking","code":"security.sql_injection","description":"Security vulnerability: direct SQL string interpolation","paths":["internal/db.go"]}],"evidence_refs":[]}} diff --git a/internal/managed/testdata/infrastructure-error.jsonl b/internal/managed/testdata/infrastructure-error.jsonl new file mode 100644 index 0000000..f6bc103 --- /dev/null +++ b/internal/managed/testdata/infrastructure-error.jsonl @@ -0,0 +1,2 @@ +{"schema_version":1,"protocol_version":1,"sequence":1,"run_id":"G-229","invocation_id":"feedcafebabefeed","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"run.started","timestamp":"2026-08-18T21:00:00.000000000Z","payload":{}} +{"schema_version":1,"protocol_version":1,"sequence":2,"run_id":"G-229","invocation_id":"feedcafebabefeed","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"run.completed","timestamp":"2026-08-18T21:00:00.500000000Z","payload":{"outcome":"infrastructure_error","stage":"","message":"preflight: trusted-config hash mismatch: computed sha256:0000...0000, expected sha256:aabb...ccdd","invocation_id":"feedcafebabefeed","findings":[],"evidence_refs":[]}} diff --git a/internal/managed/testdata/needs-decision.jsonl b/internal/managed/testdata/needs-decision.jsonl new file mode 100644 index 0000000..ea4996c --- /dev/null +++ b/internal/managed/testdata/needs-decision.jsonl @@ -0,0 +1,4 @@ +{"schema_version":1,"protocol_version":1,"sequence":1,"run_id":"G-229","invocation_id":"0987654321fedcba","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"run.started","timestamp":"2026-08-18T21:00:00.000000000Z","payload":{}} +{"schema_version":1,"protocol_version":1,"sequence":2,"run_id":"G-229","invocation_id":"0987654321fedcba","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"stage.started","timestamp":"2026-08-18T21:00:00.100000000Z","payload":{"stage":"review"}} +{"schema_version":1,"protocol_version":1,"sequence":3,"run_id":"G-229","invocation_id":"0987654321fedcba","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"finding.reported","timestamp":"2026-08-18T21:00:04.000000000Z","payload":{"fingerprint":"sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","stage":"review","kind":"ask-user","code":"review.architecture_choice","description":"New dependency added without ADR","paths":["go.mod"]}} +{"schema_version":1,"protocol_version":1,"sequence":4,"run_id":"G-229","invocation_id":"0987654321fedcba","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"run.completed","timestamp":"2026-08-18T21:00:04.100000000Z","payload":{"outcome":"needs_decision","stage":"review","message":"1 ask-user finding(s) require a Decision","invocation_id":"0987654321fedcba","findings":[{"fingerprint":"sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","stage":"review","kind":"ask-user","code":"review.architecture_choice","description":"New dependency added without ADR","paths":["go.mod"]}],"evidence_refs":[]}} diff --git a/internal/managed/testdata/passed.jsonl b/internal/managed/testdata/passed.jsonl new file mode 100644 index 0000000..5b747af --- /dev/null +++ b/internal/managed/testdata/passed.jsonl @@ -0,0 +1,13 @@ +{"schema_version":1,"protocol_version":1,"sequence":1,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"run.started","timestamp":"2026-08-18T21:00:00.000000000Z","payload":{}} +{"schema_version":1,"protocol_version":1,"sequence":2,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"stage.started","timestamp":"2026-08-18T21:00:00.100000000Z","payload":{"stage":"review"}} +{"schema_version":1,"protocol_version":1,"sequence":3,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"evidence.created","timestamp":"2026-08-18T21:00:05.000000000Z","payload":{"stage":"review","path":"64aec94d8e1fade3975101ba87f44076e4487016c87c6cf8d24857aad2e28d27/1234567890abcdef/review/findings.json"}} +{"schema_version":1,"protocol_version":1,"sequence":4,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"stage.completed","timestamp":"2026-08-18T21:00:05.100000000Z","payload":{"stage":"review","outcome":"passed","message":"review passed: 0 auto-fix(es) applied, 0 finding(s) await human approval"}} +{"schema_version":1,"protocol_version":1,"sequence":5,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"stage.started","timestamp":"2026-08-18T21:00:05.200000000Z","payload":{"stage":"test"}} +{"schema_version":1,"protocol_version":1,"sequence":6,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"evidence.created","timestamp":"2026-08-18T21:00:10.000000000Z","payload":{"stage":"test","path":"64aec94d8e1fade3975101ba87f44076e4487016c87c6cf8d24857aad2e28d27/1234567890abcdef/test/stdout.log"}} +{"schema_version":1,"protocol_version":1,"sequence":7,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"stage.completed","timestamp":"2026-08-18T21:00:10.100000000Z","payload":{"stage":"test","outcome":"passed","message":"test command \"true\" passed"}} +{"schema_version":1,"protocol_version":1,"sequence":8,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"stage.started","timestamp":"2026-08-18T21:00:10.200000000Z","payload":{"stage":"document"}} +{"schema_version":1,"protocol_version":1,"sequence":9,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"stage.completed","timestamp":"2026-08-18T21:00:10.300000000Z","payload":{"stage":"document","outcome":"passed","message":"document: no documentation policy violations found"}} +{"schema_version":1,"protocol_version":1,"sequence":10,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"stage.started","timestamp":"2026-08-18T21:00:10.400000000Z","payload":{"stage":"lint"}} +{"schema_version":1,"protocol_version":1,"sequence":11,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"evidence.created","timestamp":"2026-08-18T21:00:11.000000000Z","payload":{"stage":"lint","path":"64aec94d8e1fade3975101ba87f44076e4487016c87c6cf8d24857aad2e28d27/1234567890abcdef/lint/stdout.log"}} +{"schema_version":1,"protocol_version":1,"sequence":12,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"stage.completed","timestamp":"2026-08-18T21:00:11.100000000Z","payload":{"stage":"lint","outcome":"passed","message":"lint command \"true\" passed"}} +{"schema_version":1,"protocol_version":1,"sequence":13,"run_id":"G-229","invocation_id":"1234567890abcdef","mission_id":"M-402","input_sha":"2222222222222222222222222222222222222222","base_sha":"1111111111111111111111111111111111111111","policy_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","event":"run.completed","timestamp":"2026-08-18T21:00:11.200000000Z","payload":{"outcome":"passed","stage":"lint","message":"all managed validation stages passed","invocation_id":"1234567890abcdef","findings":[],"evidence_refs":[]}} diff --git a/internal/orchestrator/scaffold.go b/internal/orchestrator/scaffold.go index 1eb0b7d..9e65499 100644 --- a/internal/orchestrator/scaffold.go +++ b/internal/orchestrator/scaffold.go @@ -20,6 +20,16 @@ const pushedConfigFileName = ".made.yml" const githubCallTimeout = 30 * time.Second +func gitEnv() []string { + return append(os.Environ(), + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", + ) +} + type RunContext struct { Config config.Config Worktree *gitgate.Worktree @@ -140,7 +150,7 @@ func resolveConfig(ctx context.Context, gatePath, defaultBranch, worktreePath st } func refreshDefaultBranch(ctx context.Context, gatePath, defaultBranch string) error { - remote, err := execpkg.Run(ctx, execpkg.Command{Name: "git", Args: []string{"remote", "get-url", "origin"}, Dir: gatePath}) + remote, err := execpkg.Run(ctx, execpkg.Command{Name: "git", Args: []string{"remote", "get-url", "origin"}, Dir: gatePath, Env: gitEnv()}) if err != nil { return fmt.Errorf("orchestrator: inspect origin remote: %w", err) } @@ -148,7 +158,7 @@ func refreshDefaultBranch(ctx context.Context, gatePath, defaultBranch string) e return fmt.Errorf("orchestrator: origin remote is unavailable: %s", string(remote.Stderr)) } refspec := fmt.Sprintf("%s:refs/heads/%s", defaultBranch, defaultBranch) - fetch, err := execpkg.Run(ctx, execpkg.Command{Name: "git", Args: []string{"fetch", "origin", refspec}, Dir: gatePath}) + fetch, err := execpkg.Run(ctx, execpkg.Command{Name: "git", Args: []string{"fetch", "origin", refspec}, Dir: gatePath, Env: gitEnv()}) if err != nil { return fmt.Errorf("orchestrator: refresh default branch %s: %w", defaultBranch, err) } @@ -158,6 +168,7 @@ func refreshDefaultBranch(ctx context.Context, gatePath, defaultBranch string) e Name: "git", Args: []string{"update-ref", "-d", "refs/heads/" + defaultBranch}, Dir: gatePath, + Env: gitEnv(), }) if clearErr != nil { return fmt.Errorf("orchestrator: clear deleted default branch %s: %w", defaultBranch, clearErr) @@ -177,6 +188,7 @@ func extractTrustedConfig(ctx context.Context, gatePath, defaultBranch string) ( Name: "git", Args: []string{"show", fmt.Sprintf("refs/heads/%s:%s", defaultBranch, pushedConfigFileName)}, Dir: gatePath, + Env: gitEnv(), }) if err != nil { return "", nil, fmt.Errorf("orchestrator: run git show for trusted config: %w", err) diff --git a/internal/orchestrator/scaffold_test.go b/internal/orchestrator/scaffold_test.go index 917de89..fa604fb 100644 --- a/internal/orchestrator/scaffold_test.go +++ b/internal/orchestrator/scaffold_test.go @@ -260,6 +260,11 @@ func commit(t *testing.T, dir, message string) { "GIT_AUTHOR_EMAIL=orchestrator-test@example.com", "GIT_COMMITTER_NAME=orchestrator-test", "GIT_COMMITTER_EMAIL=orchestrator-test@example.com", + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", ) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git commit in %s failed: %v: %s", dir, err, out) @@ -270,6 +275,13 @@ func pushBranch(t *testing.T, srcDir, barePath, branch string) string { t.Helper() cmd := exec.Command("git", "push", barePath, "HEAD:refs/heads/"+branch) cmd.Dir = srcDir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", + ) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git push %s in %s failed: %v: %s", branch, srcDir, err, out) } @@ -280,6 +292,13 @@ func revParse(t *testing.T, dir, ref string) string { t.Helper() cmd := exec.Command("git", "rev-parse", ref) cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", + ) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git rev-parse %s in %s failed: %v: %s", ref, dir, err, out) @@ -291,6 +310,13 @@ func runGit(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", + ) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v in %s failed: %v: %s", args, dir, err, out) } diff --git a/internal/orchestrator/workfunc_test.go b/internal/orchestrator/workfunc_test.go index fd49ca9..3b5a91a 100644 --- a/internal/orchestrator/workfunc_test.go +++ b/internal/orchestrator/workfunc_test.go @@ -102,7 +102,15 @@ func (f *wfFixture) worktree(t *testing.T, sha string) *gitgate.Worktree { func (f *wfFixture) branchOnRealRemote(t *testing.T, branch string) bool { t.Helper() - out, err := exec.Command("git", "ls-remote", f.realRemote, "refs/heads/"+branch).CombinedOutput() + cmd := exec.Command("git", "ls-remote", f.realRemote, "refs/heads/"+branch) + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", + ) + out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("ls-remote: %v: %s", err, out) } @@ -119,6 +127,11 @@ func commitWithIntent(t *testing.T, dir, title, intentSummary string) { "GIT_AUTHOR_EMAIL=orchestrator-test@example.com", "GIT_COMMITTER_NAME=orchestrator-test", "GIT_COMMITTER_EMAIL=orchestrator-test@example.com", + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", ) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git commit (with intent) in %s failed: %v: %s", dir, err, out) @@ -268,7 +281,15 @@ func TestNewWorkFunc_FullPassEndsRunningWithAwaitingMergeMessage(t *testing.T) { if len(contract.CandidateOutputSHA) != 40 { t.Fatalf("review contract evidence omitted a full candidate output SHA: %s", metadata) } - if err := exec.Command("git", "-C", wt.Path, "cat-file", "-e", contract.CandidateOutputSHA+"^{commit}").Run(); err != nil { + cmd := exec.Command("git", "-C", wt.Path, "cat-file", "-e", contract.CandidateOutputSHA+"^{commit}") + cmd.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", + ) + if err := cmd.Run(); err != nil { t.Fatalf("review contract candidate output SHA %q is not a commit in the prepared worktree: %v", contract.CandidateOutputSHA, err) } assertAllStagesPassed(t, snap.Stages) diff --git a/internal/pipeline/document/document.go b/internal/pipeline/document/document.go index bbc5b35..059ddff 100644 --- a/internal/pipeline/document/document.go +++ b/internal/pipeline/document/document.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/douglasjarquin/made/internal/agent" + "github.com/douglasjarquin/made/internal/safegit" ) type Rule struct { @@ -40,7 +41,33 @@ func RunContext(ctx context.Context, worktreePath, baseBranch string, rules []Ru if err != nil { return Result{}, fmt.Errorf("document: %w", err) } + return computeFindings(changed, rules) +} + +// RunContextWithBaseSHA is like RunContext but uses an exact commit SHA for the +// diff range instead of a mutable branch name. This is required by managed-validation +// mode, which must use the exact base_sha from the preflight-verified CLI arguments. +func RunContextWithBaseSHA(ctx context.Context, worktreePath, baseSHA string, rules []Rule) (Result, error) { + changed, err := changedFilesWithRange(ctx, worktreePath, baseSHA+"..HEAD") + if err != nil { + return Result{}, fmt.Errorf("document: %w", err) + } + return computeFindings(changed, rules) +} +// RunContextWithRange is like RunContextWithBaseSHA but uses safegit for the +// git diff invocation and accepts an exact input SHA (instead of relying on HEAD). +// This is the correct function for managed-validation mode. +func RunContextWithRange(ctx context.Context, worktreePath, baseSHA, inputSHA string, rules []Rule) (Result, error) { + changed, err := changedFilesWithSHAs(ctx, worktreePath, baseSHA, inputSHA) + if err != nil { + return Result{}, fmt.Errorf("document: %w", err) + } + return computeFindings(changed, rules) +} + +// computeFindings applies document rules to the list of changed files. +func computeFindings(changed []string, rules []Rule) (Result, error) { var findings []agent.Finding for _, rule := range rules { sourceMatches, err := matchAny(rule.SourcePattern, changed) @@ -50,7 +77,6 @@ func RunContext(ctx context.Context, worktreePath, baseBranch string, rules []Ru if len(sourceMatches) == 0 { continue } - docMatches, err := matchAny(rule.DocPattern, changed) if err != nil { return Result{}, fmt.Errorf("document: %w", err) @@ -58,16 +84,17 @@ func RunContext(ctx context.Context, worktreePath, baseBranch string, rules []Ru if len(docMatches) > 0 { continue } - findings = append(findings, agent.Finding{ - Kind: agent.FindingAskUser, + Kind: agent.FindingAskUser, + Code: "document.policy_violation", + Class: "documentation-policy", Description: fmt.Sprintf( "documentation policy violation: %s (matches %q) requires a change matching %q, but none was found in this diff", strings.Join(sourceMatches, ", "), rule.SourcePattern, rule.DocPattern, ), + Paths: sourceMatches, }) } - if len(findings) > 0 { return Result{ OK: true, @@ -75,13 +102,45 @@ func RunContext(ctx context.Context, worktreePath, baseBranch string, rules []Ru Findings: findings, }, nil } - return Result{ OK: true, Message: "document: no documentation policy violations found", }, nil } +func changedFilesWithSHAs(ctx context.Context, worktreePath, baseSHA, inputSHA string) ([]string, error) { + out, err := safegit.Output(ctx, safegit.Command{ + WorktreePath: worktreePath, + Args: []string{"diff", "--name-only", "--no-ext-diff", baseSHA, inputSHA, "--"}, + }) + if err != nil { + return nil, fmt.Errorf("git diff --name-only: %w", err) + } + var files []string + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if line != "" { + files = append(files, line) + } + } + return files, nil +} + +func changedFilesWithRange(ctx context.Context, worktreePath, gitRange string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "diff", "--name-only", gitRange) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("git diff --name-only %s: %w: %s", gitRange, err, strings.TrimSpace(string(out))) + } + + var files []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + files = append(files, line) + } + } + return files, nil +} + func changedFiles(ctx context.Context, worktreePath, baseBranch string) ([]string, error) { cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "diff", "--name-only", baseBranch+"...HEAD") out, err := cmd.CombinedOutput() diff --git a/internal/pipeline/push/testhelpers_test.go b/internal/pipeline/push/testhelpers_test.go index f755af8..cc9eed8 100644 --- a/internal/pipeline/push/testhelpers_test.go +++ b/internal/pipeline/push/testhelpers_test.go @@ -15,6 +15,11 @@ func commitEnv() []string { "GIT_AUTHOR_EMAIL=push-test@example.com", "GIT_COMMITTER_NAME=push-test", "GIT_COMMITTER_EMAIL=push-test@example.com", + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=safe.bareRepository", + "GIT_CONFIG_VALUE_1=all", } } diff --git a/internal/pipeline/review/contract.go b/internal/pipeline/review/contract.go index 6abcbb1..96dfb02 100644 --- a/internal/pipeline/review/contract.go +++ b/internal/pipeline/review/contract.go @@ -27,10 +27,15 @@ func resolveReviewTask(ctx context.Context, worktreePath string, opts Options) ( return agent.ReviewTask{}, fmt.Errorf("resolve trusted base %q: %w", baseBranch, err) } } - return agent.NewReviewTask(agent.ReviewInput{ + reviewInput := agent.ReviewInput{ TrustedBaseBranch: baseBranch, TrustedBaseSHA: baseSHA, CandidateInputSHA: candidateSHA, CandidateOutputSHA: opts.CandidateOutputSHA, - }) + } + // Managed-validation mode requires strict structural identity for findings. + if opts.ReportOnly { + return agent.NewManagedReviewTask(reviewInput) + } + return agent.NewReviewTask(reviewInput) } diff --git a/internal/pipeline/review/review.go b/internal/pipeline/review/review.go index aa39c51..4f8db9f 100644 --- a/internal/pipeline/review/review.go +++ b/internal/pipeline/review/review.go @@ -24,6 +24,10 @@ type Options struct { CandidateOutputSHA string Evidence evidence.Store EvidenceRunID string + // ReportOnly disables auto-fix application. When true, auto-fixable findings + // are reported as-is but no patches are applied and no commits are created. + // Existing callers that do not set this field retain their current behavior. + ReportOnly bool } type Result struct { @@ -77,13 +81,18 @@ func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Op for _, finding := range spawned.Findings.Findings { switch finding.Kind { case agent.FindingAutoFixable: - preSHA, postSHA, applyErr := applyAutoFix(ctx, worktreePath, finding) - if applyErr != nil { - return Result{}, fmt.Errorf("review: apply auto-fix %q: %w", finding.Description, applyErr) + if opts.ReportOnly { + // Managed mode: report the finding but do not apply the patch. + pending = append(pending, finding) + } else { + preSHA, postSHA, applyErr := applyAutoFix(ctx, worktreePath, finding) + if applyErr != nil { + return Result{}, fmt.Errorf("review: apply auto-fix %q: %w", finding.Description, applyErr) + } + autoFixed = append(autoFixed, postSHA) + preFixSHAs = append(preFixSHAs, preSHA) + postFixSHAs = append(postFixSHAs, postSHA) } - autoFixed = append(autoFixed, postSHA) - preFixSHAs = append(preFixSHAs, preSHA) - postFixSHAs = append(postFixSHAs, postSHA) case agent.FindingBlocking: blockingMessages = append(blockingMessages, finding.Description) pending = append(pending, finding) diff --git a/internal/safegit/git.go b/internal/safegit/git.go new file mode 100644 index 0000000..fd5eb0b --- /dev/null +++ b/internal/safegit/git.go @@ -0,0 +1,185 @@ +// Package safegit provides a safe Git execution primitive for use in +// managed-validation mode. +// +// All invocations strip hostile environment variables, neutralize hooks and +// credential helpers, and use explicit argv with no shell interpolation. +// No network Git operation is performed. +package safegit + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/douglasjarquin/made/internal/exec" +) + +const ( + DefaultTimeout = 30 * time.Second + DefaultOutputLimit = 1 << 20 +) + +// Command describes a safe Git invocation. +type Command struct { + // WorktreePath is the -C argument; must be an absolute path. + WorktreePath string + // Args are the Git subcommand and its arguments. + Args []string + // Timeout overrides DefaultTimeout when non-zero. + Timeout time.Duration + // OutputLimit overrides DefaultOutputLimit when non-zero. + OutputLimit int +} + +// Output runs git and returns trimmed stdout, or an error on non-zero exit. +func Output(ctx context.Context, cmd Command) (string, error) { + result, err := Run(ctx, cmd) + if err != nil { + return "", err + } + return strings.TrimSpace(string(result.Stdout)), nil +} + +// Run runs git and returns the raw result. +func Run(ctx context.Context, cmd Command) (*exec.Result, error) { + timeout := cmd.Timeout + if timeout == 0 { + timeout = DefaultTimeout + } + limit := cmd.OutputLimit + if limit == 0 { + limit = DefaultOutputLimit + } + + filterArgs, err := repositoryFilterOverrides(ctx, cmd.WorktreePath, timeout, limit) + if err != nil { + return nil, err + } + + commandArgs := []string{ + "-C", cmd.WorktreePath, + "-c", "core.hooksPath=/dev/null", + "-c", "core.fsmonitor=false", + "-c", "diff.external=", + "-c", "credential.helper=", + } + commandArgs = append(commandArgs, filterArgs...) + commandArgs = append(commandArgs, cmd.Args...) + + result, err := exec.Run(ctx, exec.Command{ + Name: "git", + Args: commandArgs, + Env: ControlledEnvironment(), + Timeout: timeout, + OutputLimit: limit, + }) + if err != nil { + return nil, err + } + if result.ExitCode != 0 { + return result, fmt.Errorf("git exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) + } + return result, nil +} + +// ControlledEnvironment returns a sanitized environment for Git invocations. +// It strips all GIT_* overrides and other variables that could redirect or +// intercept Git's behavior when running inside an Agent-controlled repository. +func ControlledEnvironment() []string { + // Variables that must be scrubbed even if prefixed with something other + // than GIT_ or that are not GIT_ but still affect Git behavior. + additionalScrub := map[string]struct{}{ + "SSH_AUTH_SOCK": {}, + "SSH_ASKPASS": {}, + "GIT_SSH_COMMAND": {}, + "GIT_ASKPASS": {}, + } + + env := make([]string, 0, len(os.Environ())+4) + for _, entry := range os.Environ() { + name, _, ok := strings.Cut(entry, "=") + if !ok { + continue + } + if strings.HasPrefix(name, "GIT_") { + continue + } + if _, scrub := additionalScrub[name]; scrub { + continue + } + env = append(env, entry) + } + env = append(env, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_TERMINAL_PROMPT=0", + "GIT_ASKPASS=", + ) + return env +} + +// repositoryFilterOverrides detects any configured filter drivers in the +// repository and overrides them to /bin/cat so they cannot execute arbitrary +// commands. This mirrors the logic in internal/pipeline/review/git.go. +func repositoryFilterOverrides(ctx context.Context, worktreePath string, timeout time.Duration, limit int) ([]string, error) { + result, err := exec.Run(ctx, exec.Command{ + Name: "git", + Args: []string{ + "-C", worktreePath, + "config", "--local", "--name-only", "--get-regexp", + `^filter\..+\.(clean|process|smudge)$`, + }, + Env: ControlledEnvironment(), + Timeout: timeout, + OutputLimit: limit, + }) + if err != nil { + return nil, err + } + if result.ExitCode == 1 { + // git config exits 1 when no matching keys exist. + return nil, nil + } + if result.ExitCode != 0 { + return nil, fmt.Errorf("safegit: inspect filter config: git exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) + } + if strings.Contains(string(result.Stdout), "[output truncated]") { + return nil, fmt.Errorf("safegit: inspect filter config: output exceeded %d bytes", limit) + } + + drivers := make(map[string]struct{}) + for key := range strings.FieldsSeq(string(result.Stdout)) { + prefix := strings.TrimPrefix(key, "filter.") + dot := strings.LastIndexByte(prefix, '.') + if dot <= 0 { + return nil, fmt.Errorf("safegit: invalid filter key %q", key) + } + switch prefix[dot+1:] { + case "clean", "process", "smudge": + drivers[prefix[:dot]] = struct{}{} + default: + return nil, fmt.Errorf("safegit: invalid filter key %q", key) + } + } + + names := make([]string, 0, len(drivers)) + for name := range drivers { + names = append(names, name) + } + sort.Strings(names) + + overrides := make([]string, 0, len(names)*8) + for _, name := range names { + p := "filter." + name + "." + overrides = append(overrides, + "-c", p+"clean=/bin/cat", + "-c", p+"smudge=/bin/cat", + "-c", p+"process=", + "-c", p+"required=false", + ) + } + return overrides, nil +}