From f92baaf345dea88a907e29e8727aa6d937902df9 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:04:38 -0400 Subject: [PATCH 01/53] feat: deliver versioned durable remediation contract --- .github/workflows/ci.yml | 6 +- AGENTS.md | 16 ++ CLAUDE.md | 1 + README.md | 18 ++ cmd/made/daemon.go | 103 +++++++-- cmd/made/daemon_test.go | 10 +- cmd/made/doctor.go | 52 +++++ cmd/made/gate_notify_push_test.go | 4 +- cmd/made/main.go | 14 +- cmd/made/pr.go | 48 ---- cmd/made/remediation_contract_test.go | 210 +++++++++++++++++ cmd/made/remediation_process_contract_test.go | 184 +++++++++++++++ cmd/made/review.go | 57 +++++ cmd/made/review_test.go | 1 + cmd/made/runcommands.go | 204 +++++++++++++++++ cmd/made/runhandlers.go | 146 ++++++++++++ cmd/made/status.go | 166 ++++++-------- cmd/made/status_test.go | 8 +- internal/agent/findings.go | 1 + internal/agent/remediation_contract_test.go | 64 ++++++ internal/agent/spawn.go | 107 ++++++++- internal/api/remediation_contract_test.go | 154 +++++++++++++ internal/api/server.go | 30 ++- internal/config/config.go | 57 ++++- internal/config/remediation_contract_test.go | 30 +++ internal/daemon/contract.go | 76 +++++++ internal/daemon/durable_contract_test.go | 75 ++++++ internal/daemon/lifecycle.go | 59 ++--- internal/daemon/lifecycle_test.go | 8 +- internal/daemon/remediation_contract_test.go | 213 ++++++++++++++++++ internal/daemon/reviewdecisions.go | 15 ++ internal/daemon/runmanager.go | 170 ++++++++++---- internal/daemon/runmanager_test.go | 6 +- internal/daemon/runstate.go | 28 ++- internal/daemon/spool.go | 130 +++++++++++ internal/daemon/store.go | 179 +++++++++++++++ internal/evidence/inrepo.go | 12 +- internal/evidence/orphan.go | 6 +- internal/evidence/redact.go | 20 ++ .../evidence/remediation_contract_test.go | 41 ++++ internal/evidence/store.go | 31 ++- internal/exec/exec.go | 4 + internal/github/client.go | 106 ++++++++- internal/github/remediation_contract_test.go | 19 ++ internal/github/testdata/fakegh/main.go | 21 ++ internal/orchestrator/scaffold.go | 24 +- internal/orchestrator/scaffold_test.go | 2 +- internal/orchestrator/workfunc.go | 73 +++++- internal/orchestrator/workfunc_test.go | 20 +- internal/pipeline/ci/ci.go | 68 ++++-- .../pipeline/ci/remediation_contract_test.go | 21 ++ .../pipeline/pr/remediation_contract_test.go | 46 ++++ internal/pipeline/rebase/rebase.go | 6 + .../rebase/remediation_contract_test.go | 31 +++ .../review/remediation_contract_test.go | 34 +++ internal/pipeline/review/review.go | 148 +++++++++++- internal/skill/skill.go | 38 ++-- skills/made/SKILL.md | 38 ++-- 58 files changed, 3098 insertions(+), 361 deletions(-) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md delete mode 100644 cmd/made/pr.go create mode 100644 cmd/made/remediation_contract_test.go create mode 100644 cmd/made/remediation_process_contract_test.go create mode 100644 cmd/made/runcommands.go create mode 100644 cmd/made/runhandlers.go create mode 100644 internal/agent/remediation_contract_test.go create mode 100644 internal/api/remediation_contract_test.go create mode 100644 internal/config/remediation_contract_test.go create mode 100644 internal/daemon/contract.go create mode 100644 internal/daemon/durable_contract_test.go create mode 100644 internal/daemon/remediation_contract_test.go create mode 100644 internal/daemon/spool.go create mode 100644 internal/daemon/store.go create mode 100644 internal/evidence/redact.go create mode 100644 internal/evidence/remediation_contract_test.go create mode 100644 internal/github/remediation_contract_test.go create mode 100644 internal/pipeline/ci/remediation_contract_test.go create mode 100644 internal/pipeline/pr/remediation_contract_test.go create mode 100644 internal/pipeline/rebase/remediation_contract_test.go create mode 100644 internal/pipeline/review/remediation_contract_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbea5fb..3436a7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,11 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version: "1.26.5" - run: go build ./... - run: go test ./... + - run: go test -race -shuffle=on -count=1 ./... + - run: go vet ./... - uses: golangci/golangci-lint-action@v6 with: - version: latest + version: v2.11.2 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a039d75 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# Project agent memory + +This file is the project's committed home for project-intrinsic agent knowledge: build, test, release, architecture, and sharp-edge notes that should travel with the code. + +- Add durable project-specific notes here as they are discovered through real work. + +- The versioned runtime contract is implemented at `cmd/made/runcommands.go` and `internal/daemon`; validate it through `made capabilities --json` and the `made run ... --json` commands. +- `.made.yml` is decoded strictly with `version: 1`; the authoritative loader is `internal/config/config.go`. +- Local git fixtures can inherit SSH commit signing from the host; use process-local `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false` when running the Go test suite. + +## Maintaining this file + +Keep this file for knowledge useful to almost every future agent session in this project. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 092bedb..3f4b353 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,21 @@ A personal Go rewrite of [no-mistakes](https://github.com/kunchenguid/no-mistake made is an independent synthesis, not a dependency bundle or a one-to-one copy of any source. See `plans/made-rewrite.md` for the full design and build plan. + +## Versioned daemon contract + +`made capabilities --json` reports the public protocol and command schema. + +Use `made run submit --json --repo --branch --input-sha ` to create a run. + +Use `made run status --json ` for one run, `made run list --json --active` for the active batch, and `made run cancel --json ` for idempotent cancellation. + +Use `made review decide --json --stage --decision ` for an exact review decision. + +Use `made doctor --json` for the fixed health schema. + +Run states are `queued`, `running`, `awaiting_review`, `awaiting_merge`, `succeeded`, `failed`, `canceled`, and `superseded`. + +Run state is persisted in a fsync-backed local WAL, and gate submissions use an idempotent fsync-backed spool keyed by gate, ref, and input SHA. + +The daemon acquires its singleton before touching the Unix socket path, removes only stale sockets, and refuses regular files, symlinks, and directories. diff --git a/cmd/made/daemon.go b/cmd/made/daemon.go index b300f48..7e46a60 100644 --- a/cmd/made/daemon.go +++ b/cmd/made/daemon.go @@ -39,7 +39,7 @@ func runDaemonCommand(args []string, stdout, stderr *os.File) int { case "start": return daemonStart(args[1:], home, lockPath, stdout, stderr) case "stop": - return daemonStop(lockPath, stdout, stderr) + return daemonStop(api.SocketPath(home), stdout, stderr) case "status": return daemonStatus(lockPath, stdout, stderr) default: @@ -92,14 +92,45 @@ func daemonStart(args []string, home, lockPath string, stdout, stderr *os.File) // The returned channel receives daemon.Run's final error exactly once, after // the socket server has also been shut down. func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, onReady func(pid int)) (*daemon.RunManager, <-chan error) { - rm := daemon.NewRunManager() + if err := os.MkdirAll(home, 0o700); err != nil { + done := make(chan error, 1) + done <- fmt.Errorf("create made home: %w", err) + return daemon.NewRunManager(), done + } + spool, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if err != nil { + done := make(chan error, 1) + done <- err + return daemon.NewRunManager(), done + } + rm, err := daemon.NewPersistentRunManager(filepath.Join(home, "runs.wal")) + if err != nil { + done := make(chan error, 1) + done <- err + return daemon.NewRunManager(), done + } + ownedLock, err := daemon.AcquireLock(lockPath) + if err != nil { + done := make(chan error, 1) + done <- err + return rm, done + } + socketPath := api.SocketPath(home) + if err := api.PrepareSocket(socketPath); err != nil { + _ = ownedLock.Release() + done := make(chan error, 1) + done <- err + return rm, done + } reviewStore := daemon.NewReviewDecisions() - srv := api.NewServer(api.SocketPath(home)) - registerDaemonHandlers(srv, rm, reviewStore) + runCtx, cancelRun := context.WithCancel(ctx) + srv := api.NewServer(socketPath) + registerDaemonHandlers(srv, rm, reviewStore, spool, cancelRun) done := make(chan error, 1) if err := srv.Listen(); err != nil { + _ = ownedLock.Release() done <- fmt.Errorf("listen on socket: %w", err) return rm, done } @@ -109,11 +140,13 @@ func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, go func() { serveErr <- srv.Serve(serveCtx) }() go func() { - runErr := daemon.Run(ctx, daemon.Options{ + runErr := daemon.Run(runCtx, daemon.Options{ LockPath: lockPath, + Lock: ownedLock, IdleTimeout: idle, OnReady: onReady, ActivityCh: rm.ActivitySignal(), + ActiveFunc: rm.HasActive, }) cancelInFlightRuns(rm, shutdownCancelTimeout) cancelServe() @@ -155,17 +188,22 @@ func cancelInFlightRuns(rm *daemon.RunManager, timeout time.Duration) { } func isTerminalRunStatus(s daemon.RunStatus) bool { - return s == daemon.RunCompleted || s == daemon.RunFailed + return s == daemon.RunSucceeded || s == daemon.RunFailed || s == daemon.RunCanceled || s == daemon.RunSuperseded } const debugHandlersEnv = "MADE_DEBUG_HANDLERS" -func registerDaemonHandlers(srv *api.Server, rm *daemon.RunManager, store *daemon.ReviewDecisions) { +func registerDaemonHandlers(srv *api.Server, rm *daemon.RunManager, store *daemon.ReviewDecisions, spool *daemon.GateSpool, cancel context.CancelFunc) { srv.Handle("status", statusHandler(rm)) - srv.Handle("review.decide", reviewDecideHandler(store)) + srv.Handle("run.status", runStatusHandler(rm)) + srv.Handle("run.submit", runSubmitHandler(rm)) + srv.Handle("run.list", runListHandler(rm)) + srv.Handle("run.cancel", runCancelHandler(rm)) + srv.Handle("review.decide", reviewDecideRunHandler(rm, store)) srv.Handle("review.decision", reviewDecisionHandler(store)) + srv.Handle("daemon.shutdown", daemonShutdownHandler(rm, spool, cancel)) srv.Handle("gate.admitPush", gateAdmitPushHandler()) - srv.Handle("gate.notifyPush", gateNotifyPushHandler(rm, store)) + srv.Handle("gate.notifyPush", gateNotifyPushHandler(rm, store, spool)) if os.Getenv(debugHandlersEnv) == "1" { srv.Handle("debug.submitCancellableRun", debugSubmitCancellableRunHandler(rm)) } @@ -251,7 +289,7 @@ type gateNotifyPushResult struct { // still-queued run for the same branch before submitting this push's own // run, so a rapid second push always wins over a first one that hasn't // started yet - never over one already running. -func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions) api.HandlerFunc { +func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool) api.HandlerFunc { return func(ctx context.Context, params json.RawMessage) (any, error) { var p gateNotifyPushParams if err := json.Unmarshal(params, &p); err != nil { @@ -267,6 +305,10 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review if err != nil { return nil, fmt.Errorf("gate.notifyPush: resolve default branch: %w", err) } + refspec := fmt.Sprintf("%s:refs/heads/%s", defaultBranch, defaultBranch) + if err := runGit(branchCtx, p.GatePath, "fetch", "origin", refspec); err != nil { + return nil, fmt.Errorf("gate.notifyPush: refresh default branch %s: %w", defaultBranch, err) + } decision := gitgate.ClassifyRef(p.Ref, defaultBranch, p.OldSHA, p.NewSHA) if !decision.Accept { @@ -278,21 +320,34 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review branch := strings.TrimPrefix(p.Ref, "refs/heads/") repo := gateRepoIdentifier(p.GatePath) - rm.SupersedeQueued(repo, branch) gatePath := p.GatePath worktreesDir := gitgate.WorktreesDir(gatePath) newSHA := p.NewSHA runID := rm.NewRunID() + submission, created, err := spool.Enqueue(daemon.GateSubmission{Gate: p.GatePath, Ref: p.Ref, SHA: p.NewSHA, RunID: runID}) + if err != nil { + return nil, fmt.Errorf("gate.notifyPush: enqueue submission: %w", err) + } + if !created { + return gateNotifyPushResult{RunID: submission.RunID}, nil + } + rm.SupersedeQueued(repo, branch) work := func(workCtx context.Context, emit func(daemon.Event)) error { return orchestrator.Run(workCtx, gatePath, defaultBranch, worktreesDir, runID, newSHA, orchestrator.NewWorkFunc(rm, reviewDecisions, emit, runID, defaultBranch, branch, orchestrator.Options{})) } - if _, err := rm.Submit(runID, repo, branch, work); err != nil { + if _, err := rm.SubmitWithMetadata(runID, repo, branch, p.NewSHA, "", work); err != nil { return nil, fmt.Errorf("gate.notifyPush: submit run: %w", err) } + if err := rm.AppendSubmissionEvent(runID, daemon.SubmissionEvent{Gate: p.GatePath, Ref: p.Ref, InputSHA: p.NewSHA, Kind: "push"}); err != nil { + return nil, fmt.Errorf("gate.notifyPush: persist submission event: %w", err) + } + if err := spool.Drain(submission); err != nil { + return nil, fmt.Errorf("gate.notifyPush: drain submission: %w", err) + } return gateNotifyPushResult{RunID: runID}, nil } @@ -326,13 +381,29 @@ func debugSubmitCancellableRunHandler(rm *daemon.RunManager) api.HandlerFunc { } } -func daemonStop(lockPath string, stdout, stderr *os.File) int { - if err := daemon.Stop(lockPath, 10*time.Second); err != nil { +func daemonStop(socketPath string, stdout, stderr *os.File) int { + client, err := api.Dial(socketPath) + if err != nil { + _, _ = fmt.Fprintln(stderr, "made daemon: dial shutdown socket:", err) + return 1 + } + defer func() { _ = client.Close() }() + if _, err := client.Call("daemon.shutdown", nil); err != nil { _, _ = fmt.Fprintln(stderr, "made daemon:", err) return 1 } - _, _ = fmt.Fprintln(stdout, "made daemon: stopped") - return 0 + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + probe, probeErr := api.Dial(socketPath) + if probeErr != nil { + _, _ = fmt.Fprintln(stdout, "made daemon: stopped") + return 0 + } + _ = probe.Close() + time.Sleep(20 * time.Millisecond) + } + _, _ = fmt.Fprintln(stderr, "made daemon: shutdown timed out") + return 1 } func daemonStatus(lockPath string, stdout, stderr *os.File) int { diff --git a/cmd/made/daemon_test.go b/cmd/made/daemon_test.go index 41b08ac..90a1de5 100644 --- a/cmd/made/daemon_test.go +++ b/cmd/made/daemon_test.go @@ -109,8 +109,16 @@ func TestDaemonStop_CancelsInFlightRunBeforeProcessExits(t *testing.T) { stopCmd := exec.Command(binPath, "daemon", "stop") stopCmd.Env = env + if out, err := stopCmd.CombinedOutput(); err == nil || !strings.Contains(string(out), "active or awaiting") { + t.Fatalf("daemon stop should refuse active work: %v\n%s", err, out) + } + if err := client.CallInto("run.cancel", map[string]string{"run_id": runID}, nil); err != nil { + t.Fatalf("cancel exact run: %v", err) + } + stopCmd = exec.Command(binPath, "daemon", "stop") + stopCmd.Env = env if out, err := stopCmd.CombinedOutput(); err != nil { - t.Fatalf("daemon stop failed: %v\n%s", err, out) + t.Fatalf("daemon stop after cancellation failed: %v\n%s", err, out) } waitErr := make(chan error, 1) diff --git a/cmd/made/doctor.go b/cmd/made/doctor.go index 2bf7100..16922f5 100644 --- a/cmd/made/doctor.go +++ b/cmd/made/doctor.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "os" @@ -19,6 +20,7 @@ const doctorCheckTimeout = 5 * time.Second func runDoctorCommand(args []string, stdout, stderr *os.File) int { fs := flag.NewFlagSet("made doctor", flag.ContinueOnError) fs.SetOutput(stderr) + asJSON := fs.Bool("json", false, "output structured JSON") if err := fs.Parse(args); err != nil { return 2 } @@ -30,6 +32,9 @@ func runDoctorCommand(args []string, stdout, stderr *os.File) int { if fs.NArg() == 1 { targetPath = fs.Arg(0) } + if *asJSON { + return runDoctorJSON(targetPath, stdout, stderr) + } home, err := madeHome() if err != nil { @@ -72,6 +77,53 @@ func runDoctorCommand(args []string, stdout, stderr *os.File) int { return 0 } +type doctorReport struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + Healthy bool `json:"healthy"` + Checks map[string]string `json:"checks"` +} + +func runDoctorJSON(targetPath string, stdout, stderr *os.File) int { + home, err := madeHome() + if err != nil { + _, _ = fmt.Fprintln(stderr, "made doctor:", err) + return 1 + } + ctx, cancel := context.WithTimeout(context.Background(), doctorCheckTimeout) + defer cancel() + checks := make(map[string]string) + healthy := true + if err := checkDaemon(api.SocketPath(home)); err != nil { + checks["daemon"] = "unreachable" + healthy = false + } else { + checks["daemon"] = "reachable" + } + ghClient := &github.Client{Timeout: doctorCheckTimeout} + if err := ghClient.AuthStatus(ctx); err != nil { + checks["github"] = "unavailable" + healthy = false + } else { + checks["github"] = "authenticated" + } + checks["herdr"] = herdrclient.Connect(ctx).State.String() + if gatePath, gateErr := resolveGatePath(home, targetPath); gateErr == nil && gateInitialized(gatePath) { + checks["gate"] = "initialized" + } else { + checks["gate"] = "not_initialized" + } + encoder := json.NewEncoder(stdout) + if err := encoder.Encode(doctorReport{SchemaVersion: 1, ProtocolVersion: api.Version, Healthy: healthy, Checks: checks}); err != nil { + _, _ = fmt.Fprintln(stderr, "made doctor:", err) + return 1 + } + if !healthy { + return 1 + } + return 0 +} + // resolveGatePath mirrors gateInit's target-path resolution (gate.go) so // doctor's gate check resolves to the exact same bare-repo path a prior // `made gate init` for the same directory would have created. diff --git a/cmd/made/gate_notify_push_test.go b/cmd/made/gate_notify_push_test.go index f7bd5d9..f3a6b42 100644 --- a/cmd/made/gate_notify_push_test.go +++ b/cmd/made/gate_notify_push_test.go @@ -260,8 +260,8 @@ waitLoop: if !ok { t.Fatal("expected the first (superseded) run to remain tracked") } - if final1.Status != daemon.RunFailed || !errors.Is(final1.Err, daemon.ErrRunSuperseded) { - t.Fatalf("expected first run superseded (Failed/ErrRunSuperseded), got status=%v err=%v", final1.Status, final1.Err) + if final1.Status != daemon.RunSuperseded || !errors.Is(final1.Err, daemon.ErrRunSuperseded) { + t.Fatalf("expected first run superseded, got status=%v err=%v", final1.Status, final1.Err) } if !final1.StartedAt.IsZero() { t.Fatal("superseded run must never have started") diff --git a/cmd/made/main.go b/cmd/made/main.go index fe29fde..0f12324 100644 --- a/cmd/made/main.go +++ b/cmd/made/main.go @@ -16,14 +16,18 @@ func run(args []string, stdout, stderr *os.File) int { } switch args[0] { + case "capabilities": + return runCapabilitiesCommand(args[1:], stdout, stderr) + case "run": + return runRunCommand(args[1:], stdout, stderr) case "daemon": return runDaemonCommand(args[1:], stdout, stderr) - case "status": - return runStatusCommand(args[1:], stdout, stderr) case "review": - return runReviewCommand(args[1:], os.Stdin, stdout, stderr) - case "pr": - return runPRCommand(args[1:], stdout, stderr) + if len(args) > 1 && args[1] == "decide" { + return runReviewDecideCommand(args[2:], stdout, stderr) + } + _, _ = fmt.Fprintln(stderr, "made review: use the versioned decide subcommand") + return 2 case "doctor": return runDoctorCommand(args[1:], stdout, stderr) case "gate": diff --git a/cmd/made/pr.go b/cmd/made/pr.go deleted file mode 100644 index dfb5c8b..0000000 --- a/cmd/made/pr.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "os" - - "github.com/douglasjarquin/made/internal/github" - "github.com/douglasjarquin/made/internal/pipeline/pr" -) - -// runPRCommand is a stopgap: it wires internal/github.Client and -// internal/pipeline/pr.Run directly, in-process, because no orchestrator -// exists yet (Task 9's run manager is not tied to a real pipeline run) to -// submit a PR-stage run through the daemon. Once that orchestration lands, -// `made pr` should submit a run via the socket API like a real pipeline -// stage instead of calling the stage function from the CLI process. -func runPRCommand(args []string, stdout, stderr *os.File) int { - fs := flag.NewFlagSet("made pr", flag.ContinueOnError) - fs.SetOutput(stderr) - title := fs.String("title", "", "pull request title (required)") - base := fs.String("base", "", "base branch (required)") - head := fs.String("head", "", "head branch (required)") - evidenceRef := fs.String("evidence", "", "evidence reference embedded in the PR body (required)") - dir := fs.String("dir", "", "git repository directory (default: current directory)") - if err := fs.Parse(args); err != nil { - return 2 - } - - ghClient := &github.Client{Dir: *dir} - result, err := pr.Run(context.Background(), ghClient, pr.Options{ - Title: *title, - Base: *base, - Head: *head, - EvidenceRef: *evidenceRef, - }) - if err != nil { - _, _ = fmt.Fprintln(stderr, "made pr:", err) - return 1 - } - if !result.OK { - _, _ = fmt.Fprintln(stderr, "made pr:", result.Message) - return 1 - } - _, _ = fmt.Fprintln(stdout, result.Message) - return 0 -} diff --git a/cmd/made/remediation_contract_test.go b/cmd/made/remediation_contract_test.go new file mode 100644 index 0000000..567e3c4 --- /dev/null +++ b/cmd/made/remediation_contract_test.go @@ -0,0 +1,210 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/douglasjarquin/made/internal/daemon" +) + +func TestRun_CapabilitiesJSONIsVersionedAndListsStructuredCommands(t *testing.T) { + code, stdout, stderr := captureRun(t, "capabilities", "--json") + if code != 0 { + t.Fatalf("capabilities exit=%d stderr=%q", code, stderr) + } + var payload struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + Commands []string `json:"commands"` + } + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("decode capabilities JSON: %v; stdout=%q", err, stdout) + } + if payload.SchemaVersion != 1 || payload.ProtocolVersion != 1 { + t.Fatalf("capability versions = schema %d protocol %d, want 1/1", payload.SchemaVersion, payload.ProtocolVersion) + } + for _, want := range []string{"run.submit", "run.status", "run.list", "run.cancel", "review.decide", "doctor"} { + if !containsString(payload.Commands, want) { + t.Fatalf("capabilities missing structured command %q: %v", want, payload.Commands) + } + } +} + +func TestRun_SubmitJSONReturnsExactRunIDAndImmutableInputHead(t *testing.T) { + home := shortTempDir(t) + t.Setenv("MADE_HOME", home) + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan int, 1) + _, done := startDaemon(ctx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { ready <- pid }) + t.Cleanup(func() { + cancel() + select { + case err := <-done: + if err != nil { + t.Errorf("daemon cleanup: %v", err) + } + case <-time.After(2 * time.Second): + t.Error("daemon did not stop during cleanup") + } + }) + select { + case <-ready: + case err := <-done: + t.Fatalf("daemon stopped before submit: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("daemon did not become ready") + } + + inputSHA := strings.Repeat("a", 40) + code, stdout, stderr := captureRun(t, "run", "submit", "--json", "--repo", "/repo/example", "--branch", "feature", "--input-sha", inputSHA) + if code != 0 { + t.Fatalf("run submit exit=%d stderr=%q", code, stderr) + } + var payload struct { + RunID string `json:"run_id"` + State string `json:"state"` + InputSHA string `json:"input_sha"` + } + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("decode submit JSON: %v; stdout=%q", err, stdout) + } + if payload.RunID == "" || payload.State != "queued" || payload.InputSHA != inputSHA { + t.Fatalf("submit payload = %+v, want exact queued run identity", payload) + } +} + +func TestStatusHandler_RequiresExactRunID(t *testing.T) { + rm := daemon.NewRunManager() + started := make(chan struct{}) + id := rm.NewRunID() + if _, err := rm.Submit(id, "repo", "branch", func(ctx context.Context, _ func(daemon.Event)) error { + close(started) + <-ctx.Done() + return ctx.Err() + }); err != nil { + t.Fatalf("Submit: %v", err) + } + <-started + t.Cleanup(func() { _ = rm.Cancel(id) }) + + _, err := statusHandler(rm)(context.Background(), nil) + if err == nil { + t.Fatal("status handler resolved a global latest run without an exact run ID") + } +} + +func TestStatusReport_JSONHasFixedDurableRunSchema(t *testing.T) { + raw, err := json.Marshal(newStatusReport(daemon.RunSnapshot{ + ID: "123e4567-e89b-12d3-a456-426614174000", + Repo: "/repo", + Branch: "feature", + Status: daemon.RunStatus("awaiting_merge"), + })) + if err != nil { + t.Fatalf("marshal status report: %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatalf("decode status report: %v", err) + } + for _, field := range []string{ + "schema_version", "protocol_version", "run_id", "repo", "branch", "state", + "input_sha", "output_sha", "execution_finished", "findings", "decisions", + "pr_url", "errors", "superseded_by", "cancel_requested", "submission_events", + } { + if _, ok := fields[field]; !ok { + t.Fatalf("status schema missing durable field %q: %s", field, raw) + } + } +} + +func TestReviewDecide_RejectsUnknownExactRunID(t *testing.T) { + store := newReviewDecisions() + _, err := reviewDecideHandler(store)(context.Background(), []byte(`{"run_id":"missing","stage":"review","decision":"approved"}`)) + if err == nil { + t.Fatal("review.decide accepted a decision for an unknown exact run ID") + } +} + +func TestRun_ListJSONExposesBatchActiveRunQuery(t *testing.T) { + home := shortTempDir(t) + t.Setenv("MADE_HOME", home) + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan int, 1) + _, done := startDaemon(ctx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { ready <- pid }) + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("daemon did not stop during cleanup") + } + }) + select { + case <-ready: + case err := <-done: + t.Fatalf("daemon stopped before list: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("daemon did not become ready") + } + + code, stdout, stderr := captureRun(t, "run", "list", "--json", "--active") + if code != 0 { + t.Fatalf("run list exit=%d stderr=%q", code, stderr) + } + var payload struct { + SchemaVersion int `json:"schema_version"` + Runs []struct { + RunID string `json:"run_id"` + } `json:"runs"` + } + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("decode run list JSON: %v; stdout=%q", err, stdout) + } + if payload.SchemaVersion != 1 || payload.Runs == nil { + t.Fatalf("run list payload = %+v, want versioned active batch", payload) + } +} + +func captureRun(t *testing.T, args ...string) (int, string, string) { + t.Helper() + outFile, err := os.CreateTemp(t.TempDir(), "stdout-") + if err != nil { + t.Fatalf("create stdout fixture: %v", err) + } + errFile, err := os.CreateTemp(t.TempDir(), "stderr-") + if err != nil { + t.Fatalf("create stderr fixture: %v", err) + } + code := run(args, outFile, errFile) + if _, err := outFile.Seek(0, io.SeekStart); err != nil { + t.Fatalf("seek stdout fixture: %v", err) + } + stdout, err := io.ReadAll(outFile) + if err != nil { + t.Fatalf("read stdout fixture: %v", err) + } + if _, err := errFile.Seek(0, io.SeekStart); err != nil { + t.Fatalf("seek stderr fixture: %v", err) + } + stderr, err := io.ReadAll(errFile) + if err != nil { + t.Fatalf("read stderr fixture: %v", err) + } + return code, string(stdout), string(stderr) +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/cmd/made/remediation_process_contract_test.go b/cmd/made/remediation_process_contract_test.go new file mode 100644 index 0000000..34d35e3 --- /dev/null +++ b/cmd/made/remediation_process_contract_test.go @@ -0,0 +1,184 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/douglasjarquin/made/internal/api" + "github.com/douglasjarquin/made/internal/daemon" +) + +func TestRunStateSurvivesDaemonRestart(t *testing.T) { + t.Setenv(debugHandlersEnv, "1") + home := shortTempDir(t) + t.Setenv("MADE_HOME", home) + firstCtx, firstCancel := context.WithCancel(context.Background()) + firstReady := make(chan int, 1) + _, firstDone := startDaemon(firstCtx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { firstReady <- pid }) + select { + case <-firstReady: + case err := <-firstDone: + t.Fatalf("first daemon stopped: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("first daemon did not become ready") + } + client, err := api.Dial(api.SocketPath(home)) + if err != nil { + t.Fatalf("dial first daemon: %v", err) + } + defer func() { _ = client.Close() }() + runID := "123e4567-e89b-12d3-a456-426614174001" + var submitted daemon.RunSnapshot + if err := client.CallInto("debug.submitCancellableRun", map[string]string{ + "id": runID, "repo": "/repo", "branch": "feature", + }, &submitted); err != nil { + t.Fatalf("submit debug run: %v", err) + } + firstCancel() + select { + case <-firstDone: + case <-time.After(2 * time.Second): + t.Fatal("first daemon did not stop") + } + + secondCtx, secondCancel := context.WithCancel(context.Background()) + secondReady := make(chan int, 1) + _, secondDone := startDaemon(secondCtx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { secondReady <- pid }) + t.Cleanup(func() { + secondCancel() + select { + case <-secondDone: + case <-time.After(2 * time.Second): + t.Error("second daemon did not stop during cleanup") + } + }) + select { + case <-secondReady: + case err := <-secondDone: + t.Fatalf("second daemon stopped: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("second daemon did not become ready") + } + client2, err := api.Dial(api.SocketPath(home)) + if err != nil { + t.Fatalf("dial second daemon: %v", err) + } + defer func() { _ = client2.Close() }() + var status StatusReport + if err := client2.CallInto("status", statusParams{RunID: runID}, &status); err != nil { + t.Fatalf("status after restart: %v", err) + } + if status.RunID != runID { + t.Fatalf("status after restart run ID = %q, want %q", status.RunID, runID) + } +} + +func TestStartDaemon_DuplicatePreservesOriginalSocketOwner(t *testing.T) { + home := shortTempDir(t) + firstCtx, firstCancel := context.WithCancel(context.Background()) + firstReady := make(chan int, 1) + _, firstDone := startDaemon(firstCtx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { firstReady <- pid }) + select { + case <-firstReady: + case err := <-firstDone: + t.Fatalf("first daemon stopped: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("first daemon did not become ready") + } + firstClient, err := api.Dial(api.SocketPath(home)) + if err != nil { + t.Fatalf("dial first daemon: %v", err) + } + t.Cleanup(func() { + _ = firstClient.Close() + firstCancel() + select { + case <-firstDone: + case <-time.After(2 * time.Second): + t.Error("first daemon did not stop during cleanup") + } + }) + if _, err := firstClient.Call("ping", nil); err != nil { + t.Fatalf("first daemon ping: %v", err) + } + + secondCtx, secondCancel := context.WithCancel(context.Background()) + secondReady := make(chan int, 1) + _, secondDone := startDaemon(secondCtx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { secondReady <- pid }) + secondStopped := false + t.Cleanup(func() { + secondCancel() + if secondStopped { + return + } + select { + case <-secondDone: + case <-time.After(2 * time.Second): + t.Error("duplicate daemon did not stop during cleanup") + } + }) + select { + case err := <-secondDone: + secondStopped = true + if !errors.Is(err, daemon.ErrAlreadyRunning) { + t.Fatalf("duplicate daemon error = %v, want ErrAlreadyRunning", err) + } + case <-secondReady: + secondStopped = true + t.Fatal("duplicate daemon reported ready and could damage the original owner") + case <-time.After(2 * time.Second): + t.Fatal("duplicate daemon did not fail promptly") + } + if _, err := firstClient.Call("ping", nil); err != nil { + t.Fatalf("original daemon became unreachable after duplicate start: %v", err) + } +} + +func TestHermeticCompatibility_RealMadeBinaryThroughConsigliereScript(t *testing.T) { + root := repoRoot(t) + consigliereRoot := "/Users/douglasjarquin/github/consigliere" + script := filepath.Join(consigliereRoot, "bin", "cs-made-lib.sh") + if _, err := os.Stat(script); err != nil { + t.Fatalf("real Consigliere script unavailable: %v", err) + } + + binDir := t.TempDir() + madePath := filepath.Join(binDir, "made") + build := exec.Command("go", "build", "-o", madePath, "./cmd/made") + build.Dir = root + build.Env = append(os.Environ(), "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=commit.gpgsign", "GIT_CONFIG_VALUE_0=false") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build Made binary: %v\n%s", err, output) + } + fakeGH := filepath.Join(binDir, "gh") + if err := os.WriteFile(fakeGH, []byte("#!/bin/sh\nset -eu\n[ \"$1\" = auth ] && [ \"$2\" = status ]\nprintf '%s\\n' 'Logged in to github.com account fake'\n"), 0o700); err != nil { + t.Fatalf("write strict fake gh: %v", err) + } + + cmd := exec.Command("bash", "-c", `. "$1"; cs_made doctor --json`, "compat", script) + cmd.Env = append(os.Environ(), + "MADE_HOME="+t.TempDir(), + "PATH="+binDir+":"+os.Getenv("PATH"), + ) + output, err := cmd.CombinedOutput() + if !json.Valid([]byte(strings.TrimSpace(string(output)))) { + t.Fatalf("real Consigliere script did not receive a JSON doctor contract: err=%v output=%q", err, output) + } +} + +func repoRoot(t *testing.T) string { + t.Helper() + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Clean(filepath.Join(filepath.Dir(filename), "../..")) +} diff --git a/cmd/made/review.go b/cmd/made/review.go index 726724e..055903c 100644 --- a/cmd/made/review.go +++ b/cmd/made/review.go @@ -58,6 +58,9 @@ func reviewDecideHandler(store *reviewDecisions) api.HandlerFunc { if p.RunID == "" || p.Stage == "" { return nil, fmt.Errorf("review.decide: run_id and stage are required") } + if !store.HasRun(p.RunID) { + return nil, fmt.Errorf("review.decide: exact run_id %q was not found", p.RunID) + } if p.Decision != ReviewApproved && p.Decision != ReviewRejected { return nil, fmt.Errorf("review.decide: decision must be %q or %q", ReviewApproved, ReviewRejected) } @@ -66,6 +69,29 @@ func reviewDecideHandler(store *reviewDecisions) api.HandlerFunc { } } +func reviewDecideRunHandler(rm *daemon.RunManager, store *reviewDecisions) api.HandlerFunc { + return func(ctx context.Context, params json.RawMessage) (any, error) { + var p reviewDecideParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("review.decide: invalid params: %w", err) + } + if p.RunID == "" || p.Stage == "" { + return nil, fmt.Errorf("review.decide: run_id and stage are required") + } + if p.Decision != ReviewApproved && p.Decision != ReviewRejected { + return nil, fmt.Errorf("review.decide: decision must be %q or %q", ReviewApproved, ReviewRejected) + } + if _, ok := rm.Snapshot(p.RunID); !ok { + return nil, fmt.Errorf("review.decide: exact run_id %q was not found", p.RunID) + } + if err := rm.SetDecision(p.RunID, p.Stage, p.Decision); err != nil { + return nil, err + } + store.Set(p.RunID, p.Stage, p.Decision) + return reviewDecideResult{OK: true}, nil + } +} + func reviewDecisionHandler(store *reviewDecisions) api.HandlerFunc { return func(ctx context.Context, params json.RawMessage) (any, error) { var p reviewDecisionParams @@ -144,6 +170,37 @@ func runReviewCommand(args []string, stdin io.Reader, stdout, stderr *os.File) i return 0 } +func runReviewDecideCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made review decide", flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOutput := fs.Bool("json", false, "output JSON") + stage := fs.String("stage", "", "stage name") + decision := fs.String("decision", "", "approved or rejected") + if err := fs.Parse(args); err != nil { + return 2 + } + if !*jsonOutput || fs.NArg() != 1 || *stage == "" || (*decision != ReviewApproved && *decision != ReviewRejected) { + _, _ = fmt.Fprintln(stderr, "usage: made review decide --json --stage --decision ") + return 2 + } + home, err := madeHome() + if err != nil { + _, _ = fmt.Fprintln(stderr, "made review decide:", err) + return 1 + } + client, err := api.Dial(api.SocketPath(home)) + if err != nil { + _, _ = fmt.Fprintln(stderr, "made review decide: daemon not reachable:", err) + return 1 + } + defer func() { _ = client.Close() }() + if err := client.CallInto("review.decide", reviewDecideParams{RunID: fs.Arg(0), Stage: *stage, Decision: *decision}, nil); err != nil { + _, _ = fmt.Fprintln(stderr, "made review decide:", err) + return 1 + } + return writeJSON(stdout, map[string]any{"schema_version": 1, "protocol_version": api.Version, "run_id": fs.Arg(0), "stage": *stage, "decision": *decision}, stderr, "made review decide") +} + func readDecision(scanner *bufio.Scanner) (string, error) { for scanner.Scan() { switch strings.TrimSpace(strings.ToLower(scanner.Text())) { diff --git a/cmd/made/review_test.go b/cmd/made/review_test.go index 910598c..4b128ba 100644 --- a/cmd/made/review_test.go +++ b/cmd/made/review_test.go @@ -26,6 +26,7 @@ func startReviewTestServer(t *testing.T, fixture StatusReport) string { return fixture, nil }) store := newReviewDecisions() + store.RegisterRun(fixture.RunID) srv.Handle("review.decide", reviewDecideHandler(store)) srv.Handle("review.decision", reviewDecisionHandler(store)) diff --git a/cmd/made/runcommands.go b/cmd/made/runcommands.go new file mode 100644 index 0000000..a113b91 --- /dev/null +++ b/cmd/made/runcommands.go @@ -0,0 +1,204 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/douglasjarquin/made/internal/api" +) + +type capabilitiesReport struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + Commands []string `json:"commands"` +} + +func runCapabilitiesCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made capabilities", flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOutput := fs.Bool("json", false, "output JSON") + if err := fs.Parse(args); err != nil { + return 2 + } + if !*jsonOutput { + _, _ = fmt.Fprintln(stderr, "made capabilities: --json is required") + return 2 + } + return writeJSON(stdout, capabilitiesReport{ + SchemaVersion: 1, ProtocolVersion: api.Version, + Commands: []string{"run.submit", "run.status", "run.list", "run.cancel", "review.decide", "doctor"}, + }, stderr, "made capabilities") +} + +type runSubmitParams struct { + RunID string `json:"run_id,omitempty"` + Repo string `json:"repo"` + Branch string `json:"branch"` + InputSHA string `json:"input_sha"` + OutputSHA string `json:"output_sha,omitempty"` +} + +type runCancelParams struct { + RunID string `json:"run_id"` +} + +type runListParams struct { + Active bool `json:"active"` +} + +type runListReport struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + Runs []StatusReport `json:"runs"` +} + +func runRunCommand(args []string, stdout, stderr *os.File) int { + if len(args) == 0 { + _, _ = fmt.Fprintln(stderr, "usage: made run ") + return 2 + } + switch args[0] { + case "submit": + return runSubmitCommand(args[1:], stdout, stderr) + case "status": + return runExactStatusCommand(args[1:], stdout, stderr) + case "list": + return runListCommand(args[1:], stdout, stderr) + case "cancel": + return runCancelCommand(args[1:], stdout, stderr) + default: + _, _ = fmt.Fprintf(stderr, "made run: unknown subcommand %q\n", args[0]) + return 2 + } +} + +func dialMade(home string, stderr *os.File, label string) (*api.Client, bool) { + client, err := api.Dial(api.SocketPath(home)) + if err != nil { + _, _ = fmt.Fprintln(stderr, label+": daemon not reachable:", err) + return nil, false + } + return client, true +} + +func runSubmitCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made run submit", flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOutput := fs.Bool("json", false, "output JSON") + repo := fs.String("repo", "", "repository identity") + branch := fs.String("branch", "", "input branch") + inputSHA := fs.String("input-sha", "", "immutable input commit SHA") + outputSHA := fs.String("output-sha", "", "expected output commit SHA") + if err := fs.Parse(args); err != nil { + return 2 + } + if !*jsonOutput { + _, _ = fmt.Fprintln(stderr, "made run submit: --json is required") + return 2 + } + home, err := madeHome() + if err != nil { + _, _ = fmt.Fprintln(stderr, "made run submit:", err) + return 1 + } + client, ok := dialMade(home, stderr, "made run submit") + if !ok { + return 1 + } + defer func() { _ = client.Close() }() + var result runActionReport + if err := client.CallInto("run.submit", runSubmitParams{Repo: *repo, Branch: *branch, InputSHA: *inputSHA, OutputSHA: *outputSHA}, &result); err != nil { + _, _ = fmt.Fprintln(stderr, "made run submit:", err) + return 1 + } + return writeJSON(stdout, result, stderr, "made run submit") +} + +func runExactStatusCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made run status", flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOutput := fs.Bool("json", false, "output JSON") + if err := fs.Parse(args); err != nil { + return 2 + } + if !*jsonOutput || fs.NArg() != 1 { + _, _ = fmt.Fprintln(stderr, "usage: made run status --json ") + return 2 + } + home, err := madeHome() + if err != nil { + _, _ = fmt.Fprintln(stderr, "made run status:", err) + return 1 + } + client, ok := dialMade(home, stderr, "made run status") + if !ok { + return 1 + } + defer func() { _ = client.Close() }() + var report StatusReport + if err := client.CallInto("run.status", statusParams{RunID: fs.Arg(0)}, &report); err != nil { + _, _ = fmt.Fprintln(stderr, "made run status:", err) + return 1 + } + return writeJSON(stdout, report, stderr, "made run status") +} + +func runListCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made run list", flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOutput := fs.Bool("json", false, "output JSON") + active := fs.Bool("active", false, "only active runs") + if err := fs.Parse(args); err != nil { + return 2 + } + if !*jsonOutput || fs.NArg() != 0 { + _, _ = fmt.Fprintln(stderr, "usage: made run list --json [--active]") + return 2 + } + home, err := madeHome() + if err != nil { + _, _ = fmt.Fprintln(stderr, "made run list:", err) + return 1 + } + client, ok := dialMade(home, stderr, "made run list") + if !ok { + return 1 + } + defer func() { _ = client.Close() }() + var report runListReport + if err := client.CallInto("run.list", runListParams{Active: *active}, &report); err != nil { + _, _ = fmt.Fprintln(stderr, "made run list:", err) + return 1 + } + return writeJSON(stdout, report, stderr, "made run list") +} + +func runCancelCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made run cancel", flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOutput := fs.Bool("json", false, "output JSON") + if err := fs.Parse(args); err != nil { + return 2 + } + if !*jsonOutput || fs.NArg() != 1 { + _, _ = fmt.Fprintln(stderr, "usage: made run cancel --json ") + return 2 + } + home, err := madeHome() + if err != nil { + _, _ = fmt.Fprintln(stderr, "made run cancel:", err) + return 1 + } + client, ok := dialMade(home, stderr, "made run cancel") + if !ok { + return 1 + } + defer func() { _ = client.Close() }() + var report runActionReport + if err := client.CallInto("run.cancel", runCancelParams{RunID: fs.Arg(0)}, &report); err != nil { + _, _ = fmt.Fprintln(stderr, "made run cancel:", err) + return 1 + } + return writeJSON(stdout, report, stderr, "made run cancel") +} diff --git a/cmd/made/runhandlers.go b/cmd/made/runhandlers.go new file mode 100644 index 0000000..dce7f53 --- /dev/null +++ b/cmd/made/runhandlers.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/douglasjarquin/made/internal/api" + "github.com/douglasjarquin/made/internal/daemon" +) + +type runActionReport struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + RunID string `json:"run_id"` + State string `json:"state"` + InputSHA string `json:"input_sha"` + OutputSHA string `json:"output_sha"` +} + +func runStatusHandler(rm *daemon.RunManager) api.HandlerFunc { + return func(ctx context.Context, params json.RawMessage) (any, error) { + return statusHandler(rm)(ctx, params) + } +} + +func runSubmitHandler(rm *daemon.RunManager) api.HandlerFunc { + return func(_ context.Context, params json.RawMessage) (any, error) { + var p runSubmitParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("run.submit: invalid params: %w", err) + } + if strings.TrimSpace(p.Repo) == "" || strings.TrimSpace(p.Branch) == "" || !validSHA(p.InputSHA) { + return nil, fmt.Errorf("run.submit: repo, branch, and a 40-character input_sha are required") + } + if p.RunID == "" { + p.RunID = rm.NewRunID() + } + snapshot, err := rm.SubmitWithMetadata(p.RunID, p.Repo, p.Branch, p.InputSHA, p.OutputSHA, func(ctx context.Context, _ func(daemon.Event)) error { + <-ctx.Done() + return ctx.Err() + }) + if err != nil { + return nil, err + } + return runActionReport{ + SchemaVersion: 1, ProtocolVersion: api.Version, RunID: snapshot.ID, + State: string(snapshot.Status), InputSHA: snapshot.InputSHA, OutputSHA: snapshot.OutputSHA, + }, nil + } +} + +func runListHandler(rm *daemon.RunManager) api.HandlerFunc { + return func(_ context.Context, params json.RawMessage) (any, error) { + var p runListParams + if len(params) > 0 { + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("run.list: invalid params: %w", err) + } + } + runs := rm.List() + report := runListReport{SchemaVersion: 1, ProtocolVersion: api.Version, Runs: make([]StatusReport, 0, len(runs))} + for _, snapshot := range runs { + if p.Active && !activeRunStatus(snapshot.Status) { + continue + } + report.Runs = append(report.Runs, newStatusReport(snapshot)) + } + return report, nil + } +} + +func runCancelHandler(rm *daemon.RunManager) api.HandlerFunc { + return func(ctx context.Context, params json.RawMessage) (any, error) { + var p runCancelParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("run.cancel: invalid params: %w", err) + } + if p.RunID == "" { + return nil, fmt.Errorf("run.cancel: run_id is required") + } + if err := rm.Cancel(p.RunID); err != nil { + return nil, err + } + waitCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + for { + snapshot, ok := rm.Snapshot(p.RunID) + if !ok { + return nil, fmt.Errorf("run.cancel: no run %q", p.RunID) + } + if snapshot.Status == daemon.RunCanceled && snapshot.ExecutionFinished { + return runActionReport{SchemaVersion: 1, ProtocolVersion: api.Version, RunID: p.RunID, State: string(snapshot.Status), InputSHA: snapshot.InputSHA, OutputSHA: snapshot.OutputSHA}, nil + } + select { + case <-waitCtx.Done(): + return nil, fmt.Errorf("run.cancel: cancellation of %q did not finish: %w", p.RunID, waitCtx.Err()) + case <-time.After(5 * time.Millisecond): + } + } + } +} + +func daemonShutdownHandler(rm *daemon.RunManager, spool *daemon.GateSpool, cancel context.CancelFunc) api.HandlerFunc { + return func(_ context.Context, _ json.RawMessage) (any, error) { + if rm.HasActive() || spool.HasPending() { + return nil, fmt.Errorf("daemon.shutdown: active or awaiting runs remain") + } + cancel() + return map[string]any{"ok": true, "schema_version": 1, "protocol_version": api.Version}, nil + } +} + +func activeRunStatus(status daemon.RunStatus) bool { + switch status { + case daemon.RunQueued, daemon.RunRunning, daemon.RunAwaitingReview, daemon.RunAwaitingMerge: + return true + default: + return false + } +} + +func validSHA(value string) bool { + if len(value) != 40 { + return false + } + for _, r := range value { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') { + return false + } + } + return true +} + +func writeJSON(stdout *os.File, value any, stderr *os.File, label string) int { + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { + _, _ = fmt.Fprintln(stderr, label+":", err) + return 1 + } + return 0 +} diff --git a/cmd/made/status.go b/cmd/made/status.go index 7c52a07..3969201 100644 --- a/cmd/made/status.go +++ b/cmd/made/status.go @@ -3,9 +3,7 @@ package main import ( "context" "encoding/json" - "flag" "fmt" - "os" "time" "github.com/douglasjarquin/made/internal/api" @@ -35,17 +33,28 @@ var pipelineStages = []string{ // over the fixed 9-stage order and PendingFindings falls back to empty, so // callers can integrate against the shape before real orchestration lands. type StatusReport struct { - SchemaVersion int `json:"schema_version"` - RunID string `json:"run_id"` - Repo string `json:"repo"` - Branch string `json:"branch"` - State string `json:"state"` - QueuedAt *time.Time `json:"queued_at,omitempty"` - StartedAt *time.Time `json:"started_at,omitempty"` - EndedAt *time.Time `json:"ended_at,omitempty"` - Error string `json:"error,omitempty"` - Stages []StageResult `json:"stages"` - PendingFindings []AskUserFinding `json:"pending_findings"` + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + RunID string `json:"run_id"` + Repo string `json:"repo"` + Branch string `json:"branch"` + State string `json:"state"` + InputSHA string `json:"input_sha"` + OutputSHA string `json:"output_sha"` + ExecutionFinished bool `json:"execution_finished"` + Findings []daemon.RunFinding `json:"findings"` + Decisions map[string]string `json:"decisions"` + PRURL string `json:"pr_url"` + Errors []string `json:"errors"` + SupersededBy string `json:"superseded_by"` + CancelRequested bool `json:"cancel_requested"` + SubmissionEvents []daemon.SubmissionEvent `json:"submission_events"` + QueuedAt *time.Time `json:"queued_at,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` + Error string `json:"error,omitempty"` + Stages []StageResult `json:"stages"` + PendingFindings []AskUserFinding `json:"pending_findings"` } type StageResult = daemon.StageResult @@ -67,10 +76,7 @@ func statusHandler(rm *daemon.RunManager) api.HandlerFunc { snap, ok := resolveRun(rm, p.RunID) if !ok { - if p.RunID != "" { - return nil, fmt.Errorf("status: no run %q", p.RunID) - } - return nil, fmt.Errorf("status: no runs found") + return nil, fmt.Errorf("status: exact run_id %q was not found", p.RunID) } return newStatusReport(snap), nil } @@ -80,17 +86,10 @@ func resolveRun(rm *daemon.RunManager, runID string) (daemon.RunSnapshot, bool) if runID != "" { return rm.Snapshot(runID) } - runs := rm.List() - if len(runs) == 0 { + if runID == "" { return daemon.RunSnapshot{}, false } - latest := runs[0] - for _, r := range runs[1:] { - if r.QueuedAt.After(latest.QueuedAt) { - latest = r - } - } - return latest, true + return rm.Snapshot(runID) } func newStatusReport(snap daemon.RunSnapshot) StatusReport { @@ -113,86 +112,65 @@ func newStatusReport(snap daemon.RunSnapshot) StatusReport { } return StatusReport{ - SchemaVersion: statusSchemaVersion, - RunID: snap.ID, - Repo: snap.Repo, - Branch: snap.Branch, - State: string(snap.Status), - QueuedAt: timePtr(snap.QueuedAt), - StartedAt: timePtr(snap.StartedAt), - EndedAt: timePtr(snap.EndedAt), - Error: errMsg, - Stages: stages, - PendingFindings: pendingFindings, + SchemaVersion: statusSchemaVersion, + ProtocolVersion: api.Version, + RunID: snap.ID, + Repo: snap.Repo, + Branch: snap.Branch, + State: string(snap.Status), + InputSHA: snap.InputSHA, + OutputSHA: snap.OutputSHA, + ExecutionFinished: snap.ExecutionFinished, + Findings: nonNilFindings(snap.Findings), + Decisions: nonNilDecisions(snap.Decisions), + PRURL: snap.PRURL, + Errors: nonNilErrors(snap.Errors, snap.Err), + SupersededBy: snap.SupersededBy, + CancelRequested: snap.CancelRequested, + SubmissionEvents: nonNilSubmissionEvents(snap.SubmissionEvents), + QueuedAt: timePtr(snap.QueuedAt), + StartedAt: timePtr(snap.StartedAt), + EndedAt: timePtr(snap.EndedAt), + Error: errMsg, + Stages: stages, + PendingFindings: pendingFindings, } } -func timePtr(t time.Time) *time.Time { - if t.IsZero() { - return nil +func nonNilFindings(findings []daemon.RunFinding) []daemon.RunFinding { + if findings == nil { + return []daemon.RunFinding{} } - return &t + return findings } -func runStatusCommand(args []string, stdout, stderr *os.File) int { - fs := flag.NewFlagSet("made status", flag.ContinueOnError) - fs.SetOutput(stderr) - asJSON := fs.Bool("json", false, "output structured JSON matching the StatusReport schema") - if err := fs.Parse(args); err != nil { - return 2 - } - runID := "" - if fs.NArg() > 0 { - runID = fs.Arg(0) - } - - home, err := madeHome() - if err != nil { - _, _ = fmt.Fprintln(stderr, "made status:", err) - return 1 +func nonNilDecisions(decisions map[string]string) map[string]string { + if decisions == nil { + return map[string]string{} } + return decisions +} - client, err := api.Dial(api.SocketPath(home)) - if err != nil { - _, _ = fmt.Fprintln(stderr, "made status: daemon not reachable:", err) - return 1 +func nonNilErrors(values []string, runErr error) []string { + if len(values) > 0 { + return values } - defer func() { _ = client.Close() }() - - var report StatusReport - if err := client.CallInto("status", statusParams{RunID: runID}, &report); err != nil { - _, _ = fmt.Fprintln(stderr, "made status:", err) - return 1 + if runErr != nil { + return []string{runErr.Error()} } + return []string{} +} - if *asJSON { - enc := json.NewEncoder(stdout) - enc.SetIndent("", " ") - if err := enc.Encode(report); err != nil { - _, _ = fmt.Fprintln(stderr, "made status:", err) - return 1 - } - return 0 +func nonNilSubmissionEvents(events []daemon.SubmissionEvent) []daemon.SubmissionEvent { + if events == nil { + return []daemon.SubmissionEvent{} } + return events +} - _, _ = fmt.Fprintf(stdout, "run: %s\n", report.RunID) - _, _ = fmt.Fprintf(stdout, "repo: %s\n", report.Repo) - _, _ = fmt.Fprintf(stdout, "branch: %s\n", report.Branch) - _, _ = fmt.Fprintf(stdout, "state: %s\n", report.State) - if report.Error != "" { - _, _ = fmt.Fprintf(stdout, "error: %s\n", report.Error) - } - _, _ = fmt.Fprintln(stdout, "stages:") - for _, s := range report.Stages { - _, _ = fmt.Fprintf(stdout, " %-10s %s\n", s.Name+":", s.Result) - } - if len(report.PendingFindings) == 0 { - _, _ = fmt.Fprintln(stdout, "findings: none pending") - } else { - _, _ = fmt.Fprintln(stdout, "findings:") - for _, f := range report.PendingFindings { - _, _ = fmt.Fprintf(stdout, " [%s] %s\n", f.Stage, f.Message) - } +func timePtr(t time.Time) *time.Time { + if t.IsZero() { + return nil } - return 0 + return &t } diff --git a/cmd/made/status_test.go b/cmd/made/status_test.go index 48163d4..3ea63e1 100644 --- a/cmd/made/status_test.go +++ b/cmd/made/status_test.go @@ -56,7 +56,7 @@ func TestStatusJSON_SchemaValidity(t *testing.T) { } } - out, errOut, code := runCapture(t, []string{"status", "--json"}) + out, errOut, code := runCapture(t, []string{"run", "status", "--json", "run-test-1"}) if code != 0 { t.Fatalf("exit code = %d, want 0; stdout=%s stderr=%s", code, out, errOut) } @@ -79,7 +79,7 @@ func TestStatusJSON_SchemaValidity(t *testing.T) { t.Errorf("Branch = %q, want %q", report.Branch, "feature-x") } switch report.State { - case "queued", "running", "completed", "failed": + case "queued", "running", "awaiting_review", "awaiting_merge", "succeeded", "failed", "canceled", "superseded": default: t.Errorf("State = %q, not one of the documented run states", report.State) } @@ -156,7 +156,7 @@ func TestStatusJSON_ReflectsRealStageUpdate(t *testing.T) { t.Fatalf("UpdatePendingFindings: %v", err) } - out, errOut, code := runCapture(t, []string{"status", "--json", "run-real-stage-1"}) + out, errOut, code := runCapture(t, []string{"run", "status", "--json", "run-real-stage-1"}) if code != 0 { t.Fatalf("exit code = %d, want 0; stdout=%s stderr=%s", code, out, errOut) } @@ -207,7 +207,7 @@ func TestStatus_NoRunsReportsError(t *testing.T) { } }) - _, _, code := runCapture(t, []string{"status", "--json"}) + _, _, code := runCapture(t, []string{"run", "status", "--json", "missing"}) if code == 0 { t.Fatal("expected non-zero exit when no runs have been submitted") } diff --git a/internal/agent/findings.go b/internal/agent/findings.go index 9742692..541609b 100644 --- a/internal/agent/findings.go +++ b/internal/agent/findings.go @@ -12,6 +12,7 @@ type Finding struct { Kind FindingKind `json:"kind"` Description string `json:"description"` Patch string `json:"patch,omitempty"` + Paths []string `json:"paths,omitempty"` } type Findings struct { diff --git a/internal/agent/remediation_contract_test.go b/internal/agent/remediation_contract_test.go new file mode 100644 index 0000000..992eac0 --- /dev/null +++ b/internal/agent/remediation_contract_test.go @@ -0,0 +1,64 @@ +package agent_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/douglasjarquin/made/internal/agent" +) + +func TestSpawn_CodexUsesSupportedExecStructuredContract(t *testing.T) { + worktree := t.TempDir() + logPath := filepath.Join(t.TempDir(), "invocation.log") + script := filepath.Join(t.TempDir(), "strict-codex") + contents := strings.Join([]string{ + "#!/bin/sh", + "set -eu", + "printf '%s\\n' \"$@\" > \"$STRICT_CODEX_LOG\"", + "[ \"$1\" = \"exec\" ]", + "[ \"$2\" = \"--cd\" ]", + "[ \"$3\" = \"$STRICT_CODEX_WORKTREE\" ]", + "shift 3", + "has_json=0", + "has_schema=0", + "while [ \"$#\" -gt 0 ]; do", + " case \"$1\" in", + " --json) has_json=1 ;;", + " --output-schema) has_schema=1; shift; test -f \"$1\" ;;", + " esac", + " shift", + "done", + "[ \"$has_json\" -eq 1 ]", + "[ \"$has_schema\" -eq 1 ]", + "printf '%s\\n' '{\"findings\":[]}'", + "", + }, "\n") + if err := os.WriteFile(script, []byte(contents), 0o700); err != nil { + t.Fatalf("write strict Codex fake: %v", err) + } + + findings, err := agent.Spawn(context.Background(), agent.KindCodex, agent.SpawnParams{ + WorktreePath: worktree, + BinaryPath: script, + ExtraEnv: []string{ + "STRICT_CODEX_LOG=" + logPath, + "STRICT_CODEX_WORKTREE=" + worktree, + }, + }) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + if len(findings.Findings) != 0 { + t.Fatalf("expected empty structured findings, got %+v", findings) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read invocation log: %v", err) + } + if strings.Contains(string(data), "review") { + t.Fatalf("Codex invocation used obsolete review command: %s", data) + } +} diff --git a/internal/agent/spawn.go b/internal/agent/spawn.go index 6c60fa6..366e562 100644 --- a/internal/agent/spawn.go +++ b/internal/agent/spawn.go @@ -1,10 +1,14 @@ package agent import ( + "bufio" + "bytes" "context" "encoding/json" "fmt" "os" + "path/filepath" + "strings" "time" "github.com/douglasjarquin/made/internal/exec" @@ -17,18 +21,30 @@ type SpawnParams struct { Timeout time.Duration } +const defaultSpawnTimeout = 30 * time.Minute + func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) { binary := params.BinaryPath if binary == "" { binary = kind.binaryName() } + args, cleanup, err := invocation(kind, params.WorktreePath) + if err != nil { + return Findings{}, err + } + defer cleanup() + timeout := params.Timeout + if timeout <= 0 { + timeout = defaultSpawnTimeout + } result, err := exec.Run(ctx, exec.Command{ Name: binary, - Args: []string{"review", "--worktree", params.WorktreePath}, + Args: args, Dir: params.WorktreePath, Env: append(os.Environ(), params.ExtraEnv...), - Timeout: params.Timeout, + Stdin: []byte("Return only the Made review JSON object matching the supplied schema.\n"), + Timeout: timeout, }) if err != nil { return Findings{}, fmt.Errorf("agent: spawn %s (%s): %w", kind, binary, err) @@ -37,9 +53,92 @@ func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) return Findings{}, fmt.Errorf("agent: %s (%s) exited %d: %s", kind, binary, result.ExitCode, result.Stderr) } - var findings Findings - if err := json.Unmarshal(result.Stdout, &findings); err != nil { + findings, err := decodeFindings(result.Stdout) + if err != nil { return Findings{}, fmt.Errorf("agent: parse findings from %s: %w: stdout=%s", kind, err, result.Stdout) } return findings, nil } + +func invocation(kind Kind, worktree string) ([]string, func(), error) { + if kind != KindCodex { + return []string{"review", "--worktree", worktree}, func() {}, nil + } + dir, err := os.MkdirTemp("", "made-codex-schema-") + if err != nil { + return nil, nil, fmt.Errorf("agent: create Codex schema directory: %w", err) + } + path := filepath.Join(dir, "output.json") + if err := os.WriteFile(path, []byte(reviewSchema), 0o600); err != nil { + _ = os.RemoveAll(dir) + return nil, nil, fmt.Errorf("agent: write Codex output schema: %w", err) + } + return []string{"exec", "--cd", worktree, "--json", "--output-schema", path, "-"}, func() { _ = os.RemoveAll(dir) }, nil +} + +func decodeFindings(data []byte) (Findings, error) { + var direct Findings + if err := json.Unmarshal(data, &direct); err == nil { + var envelope map[string]json.RawMessage + if json.Unmarshal(data, &envelope) == nil { + if raw, ok := envelope["findings"]; ok { + if string(raw) == "null" { + return Findings{Findings: []Finding{}}, nil + } + if values, err := strictFindings(data); err == nil { + return values, nil + } + } + } + } + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 4096), 4*1024*1024) + var last string + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "{") { + var event struct { + Item struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"item"` + } + if json.Unmarshal([]byte(line), &event) == nil && event.Item.Type == "agent_message" { + last = event.Item.Text + } + } + } + if err := scanner.Err(); err != nil { + return Findings{}, err + } + if last == "" { + return Findings{}, fmt.Errorf("structured findings payload was not found") + } + return strictFindings([]byte(last)) +} + +func strictFindings(data []byte) (Findings, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var findings Findings + if err := decoder.Decode(&findings); err != nil { + return Findings{}, err + } + for _, finding := range findings.Findings { + if finding.Description == "" { + return Findings{}, fmt.Errorf("finding description is required") + } + switch finding.Kind { + case FindingAutoFixable: + if strings.TrimSpace(finding.Patch) == "" { + return Findings{}, fmt.Errorf("auto-fixable finding patch is required") + } + case FindingAskUser, FindingBlocking: + default: + return Findings{}, fmt.Errorf("unknown finding kind %q", finding.Kind) + } + } + return findings, nil +} + +const reviewSchema = `{"type":"object","additionalProperties":false,"required":["findings"],"properties":{"findings":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["kind","description"],"properties":{"kind":{"type":"string","enum":["auto-fixable","ask-user","blocking"]},"description":{"type":"string"},"patch":{"type":"string"},"paths":{"type":"array","items":{"type":"string"}}}}}}}` diff --git a/internal/api/remediation_contract_test.go b/internal/api/remediation_contract_test.go new file mode 100644 index 0000000..99d3ae4 --- /dev/null +++ b/internal/api/remediation_contract_test.go @@ -0,0 +1,154 @@ +package api_test + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/douglasjarquin/made/internal/api" +) + +func TestServer_RefusesExistingNonSocketPaths(t *testing.T) { + cases := []struct { + name string + make func(t *testing.T, path string) + keep func(t *testing.T, path string) + }{ + { + name: "regular file", + make: func(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, []byte("owner data"), 0o600); err != nil { + t.Fatalf("write regular file: %v", err) + } + }, + keep: func(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read preserved regular file: %v", err) + } + if string(data) != "owner data" { + t.Fatalf("regular file contents changed to %q", data) + } + }, + }, + { + name: "symlink", + make: func(t *testing.T, path string) { + t.Helper() + target := filepath.Join(filepath.Dir(path), "socket-target") + if err := os.WriteFile(target, []byte("target"), 0o600); err != nil { + t.Fatalf("write symlink target: %v", err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatalf("create symlink: %v", err) + } + }, + keep: func(t *testing.T, path string) { + t.Helper() + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("lstat preserved symlink: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("path mode %v is no longer a symlink", info.Mode()) + } + }, + }, + { + name: "directory", + make: func(t *testing.T, path string) { + t.Helper() + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("make directory: %v", err) + } + if err := os.WriteFile(filepath.Join(path, "owner-data"), []byte("keep"), 0o600); err != nil { + t.Fatalf("write directory marker: %v", err) + } + }, + keep: func(t *testing.T, path string) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat preserved directory: %v", err) + } + if !info.IsDir() { + t.Fatalf("path mode %v is no longer a directory", info.Mode()) + } + if _, err := os.Stat(filepath.Join(path, "owner-data")); err != nil { + t.Fatalf("directory marker was removed: %v", err) + } + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "daemon.sock") + tc.make(t, path) + + srv := api.NewServer(path) + err := srv.Listen() + if err == nil { + _ = srv.Close() + t.Fatal("Listen accepted an existing non-socket path") + } + tc.keep(t, path) + }) + } +} + +func TestServer_DuplicateListenPreservesOriginalOwner(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + first := api.NewServer(path) + if err := first.Listen(); err != nil { + t.Fatalf("first Listen: %v", err) + } + defer func() { _ = first.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + go func() { _ = first.Serve(ctx) }() + + if err := waitForPing(path); err != nil { + t.Fatalf("first server did not answer ping: %v", err) + } + + second := api.NewServer(path) + if err := second.Listen(); err == nil { + _ = second.Close() + t.Fatal("duplicate Listen unexpectedly acquired the original socket path") + } + if err := waitForPing(path); err != nil { + t.Fatalf("original server became unreachable after duplicate Listen: %v", err) + } +} + +func waitForPing(path string) error { + conn, err := net.DialTimeout("unix", path, 100*time.Millisecond) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + if err := conn.SetDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + return err + } + if err := json.NewEncoder(conn).Encode(api.Request{Protocol: api.Version, ID: "ping", Method: "ping"}); err != nil { + return err + } + var response api.Response + if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&response); err != nil { + return err + } + if response.Error != nil { + return errors.New(response.Error.Error()) + } + return nil +} diff --git a/internal/api/server.go b/internal/api/server.go index 5dcbada..30896ab 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -39,8 +39,10 @@ func (s *Server) Handle(method string, h HandlerFunc) { // is made's entire auth model for this socket, matching herdr's own // filesystem-permission-only model, so there is no separate credential check. func (s *Server) Listen() error { - if err := os.RemoveAll(s.socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("remove stale socket %s: %w", s.socketPath, err) + if info, err := os.Lstat(s.socketPath); err == nil { + return fmt.Errorf("api: socket path %s already exists as %s", s.socketPath, info.Mode()) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("api: inspect socket path %s: %w", s.socketPath, err) } ln, err := net.Listen("unix", s.socketPath) @@ -56,6 +58,26 @@ func (s *Server) Listen() error { return nil } +func PrepareSocket(socketPath string) error { + info, err := os.Lstat(socketPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("api: inspect stale socket %s: %w", socketPath, err) + } + if info.Mode()&os.ModeSymlink != 0 || info.IsDir() { + return fmt.Errorf("api: refusing non-owned socket path %s with mode %s", socketPath, info.Mode()) + } + if info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("api: refusing regular socket path %s", socketPath) + } + if err := os.Remove(socketPath); err != nil { + return fmt.Errorf("api: remove stale owner socket %s: %w", socketPath, err) + } + return nil +} + func (s *Server) Serve(ctx context.Context) error { if s.ln == nil { return errors.New("api: Listen must be called before Serve") @@ -85,7 +107,9 @@ func (s *Server) Close() error { return nil } err := s.ln.Close() - _ = os.Remove(s.socketPath) + if info, statErr := os.Lstat(s.socketPath); statErr == nil && info.Mode()&os.ModeSocket != 0 { + _ = os.Remove(s.socketPath) + } return err } diff --git a/internal/config/config.go b/internal/config/config.go index df6ecb2..4bed73e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,8 +1,11 @@ package config import ( + "bytes" "fmt" + "io" "os" + "path/filepath" "github.com/douglasjarquin/made/internal/agent" "gopkg.in/yaml.v3" @@ -11,16 +14,30 @@ import ( const defaultCIRerunBudget = 2 type Config struct { - Document Document `yaml:"document"` - Review Review `yaml:"review"` - DisableProjectSettings bool `yaml:"disable_project_settings"` - NoCI bool `yaml:"no_ci"` - CI CI `yaml:"ci"` - Test Test `yaml:"test"` - Commands Commands `yaml:"commands"` - Agent string `yaml:"agent"` - Agents []string `yaml:"agents"` - AllowRepoCommands bool `yaml:"allow_repo_commands"` + Version int `yaml:"version"` + Document Document `yaml:"document"` + Review Review `yaml:"review"` + DisableProjectSettings bool `yaml:"disable_project_settings"` + NoCI bool `yaml:"no_ci"` + CI CI `yaml:"ci"` + Test Test `yaml:"test"` + Commands Commands `yaml:"commands"` + Agent string `yaml:"agent"` + Agents []string `yaml:"agents"` + AllowRepoCommands bool `yaml:"allow_repo_commands"` + Stages map[string]Stage `yaml:"stages"` +} + +type Stage struct { + Enabled *bool `yaml:"enabled"` +} + +func (c Config) StageResult(name string) string { + stage, ok := c.Stages[name] + if ok && stage.Enabled != nil && !*stage.Enabled { + return "skipped" + } + return "pending" } type Document struct { @@ -72,12 +89,14 @@ func LoadEffectiveConfig(trustedPath, pushedPath string) (Config, error) { } effective := Config{ + Version: trusted.Version, Document: trusted.Document, Review: trusted.Review, DisableProjectSettings: trusted.DisableProjectSettings, NoCI: trusted.NoCI, CI: trusted.CI, AllowRepoCommands: trusted.AllowRepoCommands, + Stages: trusted.Stages, } effective.Test.Evidence = trusted.Test.Evidence @@ -142,6 +161,24 @@ func loadConfigFile(path string) (cfg Config, exists bool, err error) { return Config{}, false, err } + if filepath.Base(path) == ".made.yml" { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&cfg); err != nil { + return Config{}, true, err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return Config{}, true, fmt.Errorf("versioned .made.yml must contain one document") + } + return Config{}, true, err + } + if cfg.Version != 1 { + return Config{}, true, fmt.Errorf("versioned .made.yml requires version: 1, got %d", cfg.Version) + } + return cfg, true, nil + } if err := yaml.Unmarshal(data, &cfg); err != nil { return Config{}, true, err } diff --git a/internal/config/remediation_contract_test.go b/internal/config/remediation_contract_test.go new file mode 100644 index 0000000..990ce9b --- /dev/null +++ b/internal/config/remediation_contract_test.go @@ -0,0 +1,30 @@ +package config + +import "testing" + +func TestLoadConfig_RejectsUnknownMadeYMLFields(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", "version: 1\nunknown_field: true\n") + + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted an unknown .made.yml field") + } +} + +func TestLoadConfig_RejectsZeroValueMadeYML(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", "") + + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted a zero-value .made.yml configuration") + } +} + +func TestConfig_DisabledStagesAreSkipped(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", "version: 1\nstages:\n review:\n enabled: false\n") + cfg, _, err := loadConfigFile(path) + if err != nil { + t.Fatalf("loadConfigFile: %v", err) + } + if got := cfg.StageResult("review"); got != "skipped" { + t.Fatalf("disabled stage result = %q, want skipped", got) + } +} diff --git a/internal/daemon/contract.go b/internal/daemon/contract.go new file mode 100644 index 0000000..1c51404 --- /dev/null +++ b/internal/daemon/contract.go @@ -0,0 +1,76 @@ +package daemon + +import ( + "fmt" + "time" +) + +func (rm *RunManager) HasActive() bool { + for _, snapshot := range rm.List() { + switch snapshot.Status { + case RunQueued, RunRunning, RunAwaitingReview, RunAwaitingMerge: + return true + } + } + return false +} + +func (rm *RunManager) SetDecision(id, stage, decision string) error { + r, ok := rm.lookupRun(id) + if !ok { + return fmt.Errorf("daemon: no run %q", id) + } + if stage == "" || decision == "" { + return fmt.Errorf("daemon: decision stage and value are required") + } + r.update(func(snapshot *RunSnapshot) { + if snapshot.Decisions == nil { + snapshot.Decisions = make(map[string]string) + } + snapshot.Decisions[stage] = decision + }) + rm.persist(r) + return nil +} + +func (rm *RunManager) SetPRURL(id, prURL string) error { + r, ok := rm.lookupRun(id) + if !ok { + return fmt.Errorf("daemon: no run %q", id) + } + r.update(func(snapshot *RunSnapshot) { snapshot.PRURL = prURL }) + rm.persist(r) + return nil +} + +func (rm *RunManager) AddFindings(id string, findings []RunFinding) error { + r, ok := rm.lookupRun(id) + if !ok { + return fmt.Errorf("daemon: no run %q", id) + } + r.update(func(snapshot *RunSnapshot) { + snapshot.Findings = append(snapshot.Findings, findings...) + }) + rm.persist(r) + return nil +} + +func (rm *RunManager) AppendSubmissionEvent(id string, event SubmissionEvent) error { + r, ok := rm.lookupRun(id) + if !ok { + return fmt.Errorf("daemon: no run %q", id) + } + if event.RecordedAt.IsZero() { + event.RecordedAt = time.Now().UTC() + } + r.update(func(snapshot *RunSnapshot) { + for _, existing := range snapshot.SubmissionEvents { + if existing.Gate == event.Gate && existing.Ref == event.Ref && existing.InputSHA == event.InputSHA { + return + } + } + snapshot.SubmissionEvents = append(snapshot.SubmissionEvents, event) + }) + rm.persist(r) + return nil +} diff --git a/internal/daemon/durable_contract_test.go b/internal/daemon/durable_contract_test.go new file mode 100644 index 0000000..e654462 --- /dev/null +++ b/internal/daemon/durable_contract_test.go @@ -0,0 +1,75 @@ +package daemon + +import ( + "context" + "testing" +) + +func TestPersistentRunStateIncludesSubmissionAndDecisionData(t *testing.T) { + path := t.TempDir() + "/runs.wal" + rm, err := NewPersistentRunManager(path) + if err != nil { + t.Fatalf("NewPersistentRunManager: %v", err) + } + id := "123e4567-e89b-12d3-a456-426614174002" + if _, err := rm.SubmitWithMetadata(id, "repo", "feature", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", func(context.Context, func(Event)) error { + return nil + }); err != nil { + t.Fatalf("SubmitWithMetadata: %v", err) + } + if err := rm.SetDecision(id, "review", ReviewApproved); err != nil { + t.Fatalf("SetDecision: %v", err) + } + if err := rm.SetPRURL(id, "https://github.com/example/repo/pull/42"); err != nil { + t.Fatalf("SetPRURL: %v", err) + } + if err := rm.AppendSubmissionEvent(id, SubmissionEvent{Gate: "gate", Ref: "refs/heads/feature", InputSHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: "push"}); err != nil { + t.Fatalf("AppendSubmissionEvent: %v", err) + } + if err := rm.Finish(id, RunAwaitingMerge, "PR is open"); err != nil { + t.Fatalf("Finish: %v", err) + } + + restarted, err := NewPersistentRunManager(path) + if err != nil { + t.Fatalf("reopen store: %v", err) + } + snapshot, ok := restarted.Snapshot(id) + if !ok { + t.Fatal("run missing after restart") + } + if snapshot.InputSHA != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" || snapshot.OutputSHA != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" { + t.Fatalf("immutable heads lost after restart: %+v", snapshot) + } + if snapshot.Status != RunAwaitingMerge || snapshot.PRURL == "" || snapshot.Decisions["review"] != ReviewApproved || len(snapshot.SubmissionEvents) != 1 { + t.Fatalf("durable state incomplete after restart: %+v", snapshot) + } +} + +func TestGateSpoolIsIdempotentAndDurable(t *testing.T) { + path := t.TempDir() + "/gate.spool" + spool, err := OpenGateSpool(path) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + submission := GateSubmission{Gate: "gate", Ref: "refs/heads/main", SHA: "abc", RunID: "run"} + if _, created, err := spool.Enqueue(submission); err != nil || !created { + t.Fatalf("first Enqueue: created=%v err=%v", created, err) + } + if _, created, err := spool.Enqueue(submission); err != nil || created { + t.Fatalf("duplicate Enqueue: created=%v err=%v", created, err) + } + reopened, err := OpenGateSpool(path) + if err != nil { + t.Fatalf("reopen spool: %v", err) + } + if !reopened.HasPending() { + t.Fatal("pending submission disappeared after restart") + } + if err := reopened.Drain(submission); err != nil { + t.Fatalf("Drain: %v", err) + } + if reopened.HasPending() { + t.Fatal("drained submission remained pending") + } +} diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go index 2279e41..6d74adf 100644 --- a/internal/daemon/lifecycle.go +++ b/internal/daemon/lifecycle.go @@ -3,7 +3,6 @@ package daemon import ( "context" "errors" - "fmt" "os" "os/signal" "syscall" @@ -11,10 +10,13 @@ import ( ) type Options struct { - LockPath string - IdleTimeout time.Duration - OnReady func(pid int) - ActivityCh <-chan struct{} + LockPath string + Lock *Lock + IdleTimeout time.Duration + OnReady func(pid int) + ActivityCh <-chan struct{} + ActiveFunc func() bool + UndrainedFunc func() bool } type StatusInfo struct { @@ -23,9 +25,13 @@ type StatusInfo struct { } func Run(ctx context.Context, opts Options) error { - lock, err := AcquireLock(opts.LockPath) - if err != nil { - return err + lock := opts.Lock + if lock == nil { + var err error + lock, err = AcquireLock(opts.LockPath) + if err != nil { + return err + } } defer func() { _ = lock.Release() }() @@ -55,6 +61,14 @@ func Run(ctx context.Context, opts Options) error { case <-sigCh: return nil case <-idleCh: + if opts.ActiveFunc != nil && opts.ActiveFunc() { + timer.Reset(opts.IdleTimeout) + continue + } + if opts.UndrainedFunc != nil && opts.UndrainedFunc() { + timer.Reset(opts.IdleTimeout) + continue + } return nil case <-ctx.Done(): return nil @@ -86,32 +100,5 @@ func Status(lockPath string) (StatusInfo, error) { } func Stop(lockPath string, timeout time.Duration) error { - pid, err := readLockPID(lockPath) - if err != nil { - return err - } - if pid == 0 { - return errors.New("daemon not running") - } - - proc, err := os.FindProcess(pid) - if err != nil { - return fmt.Errorf("find process %d: %w", pid, err) - } - if err := proc.Signal(syscall.SIGTERM); err != nil { - if errors.Is(err, os.ErrProcessDone) { - return nil - } - return fmt.Errorf("signal process %d: %w", pid, err) - } - - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - st, err := Status(lockPath) - if err == nil && !st.Running { - return nil - } - time.Sleep(20 * time.Millisecond) - } - return fmt.Errorf("daemon did not stop within %s", timeout) + return errors.New("daemon: shutdown requires the owner-only socket") } diff --git a/internal/daemon/lifecycle_test.go b/internal/daemon/lifecycle_test.go index d5d2a45..795c783 100644 --- a/internal/daemon/lifecycle_test.go +++ b/internal/daemon/lifecycle_test.go @@ -13,9 +13,11 @@ func TestRun_GracefulStop(t *testing.T) { ready := make(chan int, 1) done := make(chan error, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() go func() { - done <- Run(context.Background(), Options{ + done <- Run(ctx, Options{ LockPath: lockPath, IdleTimeout: time.Minute, OnReady: func(pid int) { ready <- pid }, @@ -36,9 +38,7 @@ func TestRun_GracefulStop(t *testing.T) { t.Fatal("expected daemon to report running before stop") } - if err := Stop(lockPath, 2*time.Second); err != nil { - t.Fatalf("Stop: %v", err) - } + cancel() select { case runErr := <-done: diff --git a/internal/daemon/remediation_contract_test.go b/internal/daemon/remediation_contract_test.go new file mode 100644 index 0000000..b0baa59 --- /dev/null +++ b/internal/daemon/remediation_contract_test.go @@ -0,0 +1,213 @@ +package daemon + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" + "testing" + "time" +) + +func TestRunManager_SuccessUsesSucceededLifecycle(t *testing.T) { + rm := NewRunManager() + id := rm.NewRunID() + events, unsubscribe := rm.Subscribe(id) + defer unsubscribe() + + if _, err := rm.Submit(id, "repo-success", "feature", func(context.Context, func(Event)) error { + return nil + }); err != nil { + t.Fatalf("Submit: %v", err) + } + + for { + select { + case event := <-events: + if event.Kind == EventRunCompleted { + snapshot, ok := rm.Snapshot(id) + if !ok { + t.Fatal("completed run disappeared from durable query surface") + } + if snapshot.Status != RunStatus("succeeded") { + t.Fatalf("state = %q, want succeeded", snapshot.Status) + } + if snapshot.EndedAt.IsZero() { + t.Fatal("execution-finished timestamp was not recorded separately from lifecycle state") + } + return + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for completed execution event") + } + } +} + +func TestRunManager_CancelUsesCanceledLifecycleAndIsIdempotent(t *testing.T) { + rm := NewRunManager() + id := rm.NewRunID() + started := make(chan struct{}) + if _, err := rm.Submit(id, "repo-cancel", "feature", func(ctx context.Context, _ func(Event)) error { + close(started) + <-ctx.Done() + return ctx.Err() + }); err != nil { + t.Fatalf("Submit: %v", err) + } + <-started + + if err := rm.Cancel(id); err != nil { + t.Fatalf("first Cancel: %v", err) + } + if err := rm.Cancel(id); err != nil { + t.Fatalf("second Cancel must be idempotent: %v", err) + } + + deadline := time.After(2 * time.Second) + for { + snapshot, ok := rm.Snapshot(id) + if ok && snapshot.Status == RunStatus("canceled") { + return + } + select { + case <-deadline: + snapshot, _ := rm.Snapshot(id) + t.Fatalf("state = %q, want canceled", snapshot.Status) + case <-time.After(5 * time.Millisecond): + } + } +} + +func TestRunManager_SupersedeUsesSupersededLifecycle(t *testing.T) { + rm := NewRunManager() + const repo = "repo-supersession" + release := make(chan struct{}) + started := make(chan struct{}) + blocker := rm.NewRunID() + if _, err := rm.Submit(blocker, repo, "blocker", func(context.Context, func(Event)) error { + close(started) + <-release + return nil + }); err != nil { + t.Fatalf("Submit blocker: %v", err) + } + <-started + + first := rm.NewRunID() + if _, err := rm.Submit(first, repo, "feature", func(context.Context, func(Event)) error { return nil }); err != nil { + t.Fatalf("Submit first: %v", err) + } + rm.SupersedeQueued(repo, "feature") + close(release) + + deadline := time.After(2 * time.Second) + for { + snapshot, ok := rm.Snapshot(first) + if ok && snapshot.Status == RunStatus("superseded") { + if !errors.Is(snapshot.Err, ErrRunSuperseded) { + t.Fatalf("superseded error = %v, want ErrRunSuperseded", snapshot.Err) + } + return + } + select { + case <-deadline: + snapshot, _ := rm.Snapshot(first) + t.Fatalf("state = %q, want superseded", snapshot.Status) + case <-time.After(5 * time.Millisecond): + } + } +} + +func TestRunManager_NewRunIDIsRestartSafeUUID(t *testing.T) { + rm := NewRunManager() + first := rm.NewRunID() + second := rm.NewRunID() + if first == "run-1" || second == "run-2" || first == second { + t.Fatalf("run IDs are restart/order-derived: %q and %q", first, second) + } + if len(first) != 36 || len(second) != 36 { + t.Fatalf("run IDs must be UUID-shaped, got %q and %q", first, second) + } +} + +func TestRun_DoesNotIdleStopWithAwaitingMergeRun(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "daemon.lock") + rm := NewRunManager() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + stopped := false + t.Cleanup(func() { + cancel() + if stopped { + return + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("daemon did not stop during cleanup") + } + }) + go func() { + done <- Run(ctx, Options{ + LockPath: lockPath, + IdleTimeout: 100 * time.Millisecond, + ActivityCh: rm.ActivitySignal(), + ActiveFunc: rm.HasActive, + }) + }() + + id := rm.NewRunID() + if _, err := rm.Submit(id, "repo-awaiting-merge", "feature", func(context.Context, func(Event)) error { + if err := rm.Finish(id, RunStatus("awaiting_merge"), "PR is open"); err != nil { + return err + } + return nil + }); err != nil { + t.Fatalf("Submit: %v", err) + } + + deadline := time.After(2 * time.Second) + for { + snapshot, ok := rm.Snapshot(id) + if ok && snapshot.Status == RunStatus("awaiting_merge") { + break + } + select { + case <-deadline: + t.Fatal("run did not reach awaiting_merge") + case <-time.After(5 * time.Millisecond): + } + } + + select { + case err := <-done: + stopped = true + t.Fatalf("daemon idled out while awaiting merge: %v", err) + case <-time.After(250 * time.Millisecond): + } +} + +func TestStop_RefusesUnownedStalePID(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "daemon.lock") + process := exec.Command("sleep", "60") + if err := process.Start(); err != nil { + t.Fatalf("start owner probe: %v", err) + } + t.Cleanup(func() { + _ = process.Process.Kill() + _ = process.Wait() + }) + if err := os.WriteFile(lockPath, []byte(strconv.Itoa(process.Process.Pid)+"\n"), 0o600); err != nil { + t.Fatalf("write stale PID fixture: %v", err) + } + + if err := Stop(lockPath, 100*time.Millisecond); err == nil { + t.Fatal("Stop trusted an unowned stale PID and signaled the unrelated process") + } + if err := process.Process.Signal(syscall.Signal(0)); err != nil { + t.Fatalf("stale PID target was terminated by unowned shutdown: %v", err) + } +} diff --git a/internal/daemon/reviewdecisions.go b/internal/daemon/reviewdecisions.go index 26975e1..2383552 100644 --- a/internal/daemon/reviewdecisions.go +++ b/internal/daemon/reviewdecisions.go @@ -23,15 +23,30 @@ type ReviewDecisions struct { mu sync.Mutex entries map[reviewKey]string waiters map[reviewKey][]chan string + runs map[string]struct{} } func NewReviewDecisions() *ReviewDecisions { return &ReviewDecisions{ entries: make(map[reviewKey]string), waiters: make(map[reviewKey][]chan string), + runs: make(map[string]struct{}), } } +func (d *ReviewDecisions) RegisterRun(runID string) { + d.mu.Lock() + d.runs[runID] = struct{}{} + d.mu.Unlock() +} + +func (d *ReviewDecisions) HasRun(runID string) bool { + d.mu.Lock() + defer d.mu.Unlock() + _, ok := d.runs[runID] + return ok +} + // Set records a decision for (runID, stage) and wakes any goroutine blocked // in Wait on that exact key. func (d *ReviewDecisions) Set(runID, stage, decision string) { diff --git a/internal/daemon/runmanager.go b/internal/daemon/runmanager.go index 358ec35..d872251 100644 --- a/internal/daemon/runmanager.go +++ b/internal/daemon/runmanager.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "crypto/rand" "errors" "fmt" "sync" @@ -12,24 +13,39 @@ import ( type RunStatus string const ( - RunQueued RunStatus = "queued" - RunRunning RunStatus = "running" - RunCompleted RunStatus = "completed" - RunFailed RunStatus = "failed" + RunQueued RunStatus = "queued" + RunRunning RunStatus = "running" + RunAwaitingReview RunStatus = "awaiting_review" + RunAwaitingMerge RunStatus = "awaiting_merge" + RunSucceeded RunStatus = "succeeded" + RunCompleted RunStatus = RunSucceeded + RunFailed RunStatus = "failed" + RunCanceled RunStatus = "canceled" + RunSuperseded RunStatus = "superseded" ) type RunSnapshot struct { - ID string - Repo string - Branch string - Status RunStatus - QueuedAt time.Time - StartedAt time.Time - EndedAt time.Time - Err error - Message string - Stages []StageResult - PendingFindings []AskUserFinding + ID string + Repo string + Branch string + InputSHA string + OutputSHA string + Status RunStatus + QueuedAt time.Time + StartedAt time.Time + EndedAt time.Time + ExecutionFinished bool + Err error + Errors []string + Message string + Findings []RunFinding + Decisions map[string]string + PRURL string + SupersededBy string + CancelRequested bool + SubmissionEvents []SubmissionEvent + Stages []StageResult + PendingFindings []AskUserFinding // finalized is set by Finish and read by execute: it lets a WorkFunc // declare a run's definitive terminal-or-not Status/Message itself, @@ -54,7 +70,7 @@ type run struct { func (r *run) snapshot() RunSnapshot { r.mu.Lock() defer r.mu.Unlock() - return r.snap + return cloneSnapshot(r.snap) } func (r *run) update(fn func(*RunSnapshot)) { @@ -79,21 +95,48 @@ type repoQueue struct { // worktree per bare repo at a time, so a second push against a repo already // running must queue behind it rather than run concurrently or be rejected. type RunManager struct { - mailbox *Mailbox - activity chan struct{} - - mu sync.Mutex - repos map[string]*repoQueue - runs map[string]*run - counter uint64 + mailbox *Mailbox + activity chan struct{} + store *RunStore + persistMu sync.Mutex + + mu sync.Mutex + repos map[string]*repoQueue + runs map[string]*run } func NewRunManager() *RunManager { - return &RunManager{ + return newRunManager(nil, nil) +} + +func NewPersistentRunManager(path string) (*RunManager, error) { + store, snapshots, err := OpenRunStore(path) + if err != nil { + return nil, err + } + return newRunManager(store, snapshots), nil +} + +func newRunManager(store *RunStore, snapshots map[string]RunSnapshot) *RunManager { + rm := &RunManager{ mailbox: NewMailbox(), activity: make(chan struct{}, 1), repos: make(map[string]*repoQueue), runs: make(map[string]*run), + store: store, + } + for id, snapshot := range snapshots { + ctx, cancel := context.WithCancel(context.Background()) + rm.runs[id] = &run{ctx: ctx, cancel: cancel, snap: cloneSnapshot(snapshot)} + } + return rm +} + +func (rm *RunManager) persist(r *run) { + if rm.store != nil { + rm.persistMu.Lock() + defer rm.persistMu.Unlock() + _ = rm.store.Append(r.snapshot()) } } @@ -112,21 +155,41 @@ func (rm *RunManager) signalActivity() { } func (rm *RunManager) NewRunID() string { - n := atomic.AddUint64(&rm.counter, 1) - return fmt.Sprintf("run-%d", n) + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + counter := atomic.AddUint64(&fallbackRunIDCounter, 1) + for i := range id { + id[i] = byte(counter >> (uint(i%8) * 8)) + } + } + id[6] = (id[6] & 0x0f) | 0x40 + id[8] = (id[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", id[0:4], id[4:6], id[6:8], id[8:10], id[10:16]) } +var fallbackRunIDCounter uint64 + func (rm *RunManager) Submit(id, repo, branch string, work WorkFunc) (RunSnapshot, error) { + return rm.SubmitWithMetadata(id, repo, branch, "", "", work) +} + +func (rm *RunManager) SubmitWithMetadata(id, repo, branch, inputSHA, outputSHA string, work WorkFunc) (RunSnapshot, error) { ctx, cancel := context.WithCancel(context.Background()) r := &run{ ctx: ctx, cancel: cancel, snap: RunSnapshot{ - ID: id, - Repo: repo, - Branch: branch, - Status: RunQueued, - QueuedAt: time.Now(), + ID: id, + Repo: repo, + Branch: branch, + InputSHA: inputSHA, + OutputSHA: outputSHA, + Status: RunQueued, + QueuedAt: time.Now(), + Errors: []string{}, + Findings: []RunFinding{}, + Decisions: make(map[string]string), + SubmissionEvents: []SubmissionEvent{}, }, } @@ -142,6 +205,7 @@ func (rm *RunManager) Submit(id, repo, branch string, work WorkFunc) (RunSnapsho rm.repos[repo] = rq } rm.mu.Unlock() + rm.persist(r) rq.mu.Lock() rq.pending = append(rq.pending, &queuedJob{run: r, work: work}) @@ -179,6 +243,7 @@ func (rm *RunManager) execute(r *run, work WorkFunc) { s.Status = RunRunning s.StartedAt = started }) + rm.persist(r) rm.mailbox.Publish(Event{RunID: id, Kind: EventRunStarted, Time: started}) rm.signalActivity() @@ -197,16 +262,24 @@ func (rm *RunManager) execute(r *run, work WorkFunc) { ended := time.Now() r.update(func(s *RunSnapshot) { s.EndedAt = ended + s.ExecutionFinished = true if s.finalized { return } s.Err = err - if err != nil { + if errors.Is(err, context.Canceled) || s.CancelRequested { + s.Status = RunCanceled + if err != nil { + s.Errors = append(s.Errors, err.Error()) + } + } else if err != nil { s.Status = RunFailed + s.Errors = append(s.Errors, err.Error()) } else { - s.Status = RunCompleted + s.Status = RunSucceeded } }) + rm.persist(r) finalKind := EventRunCompleted if err != nil { @@ -233,7 +306,7 @@ func (rm *RunManager) List() []RunSnapshot { snaps := make([]RunSnapshot, len(runs)) for i, r := range runs { - snaps[i] = r.snapshot() + snaps[i] = cloneSnapshot(r.snapshot()) } return snaps } @@ -252,15 +325,32 @@ func (rm *RunManager) Cancel(id string) error { if !ok { return fmt.Errorf("daemon: no run %q", id) } - if isTerminalRunStatus(r.snapshot().Status) { - return fmt.Errorf("daemon: run %q is already %s", id, r.snapshot().Status) + snapshot := r.snapshot() + if snapshot.Status == RunCanceled || snapshot.CancelRequested { + return nil + } + if isTerminalRunStatus(snapshot.Status) { + return fmt.Errorf("daemon: run %q is already %s", id, snapshot.Status) + } + r.update(func(s *RunSnapshot) { s.CancelRequested = true }) + rm.persist(r) + if snapshot.Status == RunAwaitingMerge || snapshot.Status == RunAwaitingReview { + r.update(func(s *RunSnapshot) { + s.Status = RunCanceled + s.ExecutionFinished = true + s.EndedAt = time.Now() + s.Err = context.Canceled + s.Errors = append(s.Errors, context.Canceled.Error()) + }) + rm.persist(r) + return nil } r.cancel() return nil } func isTerminalRunStatus(s RunStatus) bool { - return s == RunCompleted || s == RunFailed + return s == RunSucceeded || s == RunFailed || s == RunCanceled || s == RunSuperseded } // Finish lets a WorkFunc declare a run's definitive Status and a @@ -278,6 +368,7 @@ func (rm *RunManager) Finish(id string, status RunStatus, message string) error s.Message = message s.finalized = true }) + rm.persist(r) return nil } @@ -315,10 +406,13 @@ func (rm *RunManager) SupersedeQueued(repo, branch string) { now := time.Now() for _, j := range dropped { j.run.update(func(s *RunSnapshot) { - s.Status = RunFailed + s.Status = RunSuperseded s.Err = ErrRunSuperseded + s.Errors = append(s.Errors, ErrRunSuperseded.Error()) s.EndedAt = now + s.ExecutionFinished = true }) + rm.persist(j.run) rm.mailbox.Publish(Event{RunID: j.run.snapshot().ID, Kind: EventRunFailed, Time: now, Err: ErrRunSuperseded}) rm.signalActivity() } diff --git a/internal/daemon/runmanager_test.go b/internal/daemon/runmanager_test.go index 20e2931..d61f963 100644 --- a/internal/daemon/runmanager_test.go +++ b/internal/daemon/runmanager_test.go @@ -242,7 +242,7 @@ func TestRunManager_CancelStopsRunningWorkFunc(t *testing.T) { t.Fatal("WorkFunc did not unblock within 1s of Cancel") } - final := waitForStatus(t, rm, id, RunFailed, 2*time.Second) + final := waitForStatus(t, rm, id, RunCanceled, 2*time.Second) if final.Err == nil || !errors.Is(final.Err, context.Canceled) { t.Fatalf("expected final error to wrap context.Canceled, got %v", final.Err) } @@ -306,8 +306,8 @@ func TestRunManager_SupersedeQueuedDropsOnlyStillQueuedJobForBranch(t *testing.T if !ok { t.Fatal("expected superseded run to remain tracked, not deleted") } - if final1.Status != RunFailed { - t.Fatalf("expected superseded run status RunFailed, got %v", final1.Status) + if final1.Status != RunSuperseded { + t.Fatalf("expected superseded run status RunSuperseded, got %v", final1.Status) } if !errors.Is(final1.Err, ErrRunSuperseded) { t.Fatalf("expected superseded run's error to wrap ErrRunSuperseded, got %v", final1.Err) diff --git a/internal/daemon/runstate.go b/internal/daemon/runstate.go index 7f69dd2..8770c21 100644 --- a/internal/daemon/runstate.go +++ b/internal/daemon/runstate.go @@ -2,6 +2,22 @@ package daemon import "fmt" +func cloneSnapshot(snapshot RunSnapshot) RunSnapshot { + snapshot.Errors = append([]string(nil), snapshot.Errors...) + snapshot.Findings = append([]RunFinding(nil), snapshot.Findings...) + snapshot.Stages = append([]StageResult(nil), snapshot.Stages...) + snapshot.PendingFindings = append([]AskUserFinding(nil), snapshot.PendingFindings...) + snapshot.SubmissionEvents = append([]SubmissionEvent(nil), snapshot.SubmissionEvents...) + if snapshot.Decisions != nil { + original := snapshot.Decisions + snapshot.Decisions = make(map[string]string, len(original)) + for key, value := range original { + snapshot.Decisions[key] = value + } + } + return snapshot +} + type StageResult struct { Name string `json:"name"` Result string `json:"result"` @@ -18,8 +34,9 @@ func (rm *RunManager) UpdateStages(id string, stages []StageResult) error { return fmt.Errorf("daemon: no run %q", id) } r.update(func(s *RunSnapshot) { - s.Stages = stages + s.Stages = append([]StageResult(nil), stages...) }) + rm.persist(r) return nil } @@ -29,8 +46,15 @@ func (rm *RunManager) UpdatePendingFindings(id string, findings []AskUserFinding return fmt.Errorf("daemon: no run %q", id) } r.update(func(s *RunSnapshot) { - s.PendingFindings = findings + s.PendingFindings = append([]AskUserFinding(nil), findings...) + if len(findings) > 0 && s.Status == RunRunning { + s.Status = RunAwaitingReview + } + if len(findings) == 0 && s.Status == RunAwaitingReview { + s.Status = RunRunning + } }) + rm.persist(r) return nil } diff --git a/internal/daemon/spool.go b/internal/daemon/spool.go new file mode 100644 index 0000000..5afae3e --- /dev/null +++ b/internal/daemon/spool.go @@ -0,0 +1,130 @@ +package daemon + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" +) + +type GateSubmission struct { + Gate string `json:"gate"` + Ref string `json:"ref"` + SHA string `json:"sha"` + RunID string `json:"run_id"` +} + +type spoolRecord struct { + Kind string `json:"kind"` + Submission GateSubmission `json:"submission"` +} + +type GateSpool struct { + path string + mu sync.Mutex + pending map[string]GateSubmission + seen map[string]GateSubmission +} + +func OpenGateSpool(path string) (*GateSpool, error) { + if path == "" { + return nil, errors.New("daemon: gate spool path is required") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("daemon: create gate spool directory: %w", err) + } + spool := &GateSpool{path: path, pending: make(map[string]GateSubmission), seen: make(map[string]GateSubmission)} + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return spool, nil + } + if err != nil { + return nil, fmt.Errorf("daemon: open gate spool: %w", err) + } + defer func() { _ = file.Close() }() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var record spoolRecord + if err := json.Unmarshal(scanner.Bytes(), &record); err != nil { + return nil, fmt.Errorf("daemon: decode gate spool: %w", err) + } + key := gateSubmissionKey(record.Submission) + switch record.Kind { + case "enqueue": + spool.pending[key] = record.Submission + spool.seen[key] = record.Submission + case "drain": + delete(spool.pending, key) + default: + return nil, fmt.Errorf("daemon: unknown gate spool record %q", record.Kind) + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("daemon: read gate spool: %w", err) + } + return spool, nil +} + +func (s *GateSpool) Enqueue(submission GateSubmission) (GateSubmission, bool, error) { + if submission.Gate == "" || submission.Ref == "" || submission.SHA == "" || submission.RunID == "" { + return GateSubmission{}, false, errors.New("daemon: incomplete gate submission identity") + } + s.mu.Lock() + defer s.mu.Unlock() + key := gateSubmissionKey(submission) + if existing, ok := s.seen[key]; ok { + return existing, false, nil + } + if err := s.appendLocked(spoolRecord{Kind: "enqueue", Submission: submission}); err != nil { + return GateSubmission{}, false, err + } + s.pending[key] = submission + s.seen[key] = submission + return submission, true, nil +} + +func (s *GateSpool) Drain(submission GateSubmission) error { + s.mu.Lock() + defer s.mu.Unlock() + key := gateSubmissionKey(submission) + if _, ok := s.pending[key]; !ok { + return nil + } + if err := s.appendLocked(spoolRecord{Kind: "drain", Submission: submission}); err != nil { + return err + } + delete(s.pending, key) + return nil +} + +func (s *GateSpool) HasPending() bool { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.pending) > 0 +} + +func (s *GateSpool) appendLocked(record spoolRecord) error { + data, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("daemon: encode gate spool record: %w", err) + } + file, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return fmt.Errorf("daemon: open gate spool for append: %w", err) + } + defer func() { _ = file.Close() }() + if _, err := file.Write(append(data, '\n')); err != nil { + return fmt.Errorf("daemon: append gate spool: %w", err) + } + if err := file.Sync(); err != nil { + return fmt.Errorf("daemon: sync gate spool: %w", err) + } + return nil +} + +func gateSubmissionKey(submission GateSubmission) string { + return submission.Gate + "\x00" + submission.Ref + "\x00" + submission.SHA +} diff --git a/internal/daemon/store.go b/internal/daemon/store.go new file mode 100644 index 0000000..de9c766 --- /dev/null +++ b/internal/daemon/store.go @@ -0,0 +1,179 @@ +package daemon + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +const runStoreRecordVersion = 1 + +// RunFinding is the durable Made-owned representation of a review finding. +// It deliberately contains only data needed by the public run contract. +type RunFinding struct { + Stage string `json:"stage"` + Kind string `json:"kind"` + Message string `json:"message"` + Paths []string `json:"paths,omitempty"` + PreFixSHA string `json:"pre_fix_sha,omitempty"` + PostFixSHA string `json:"post_fix_sha,omitempty"` +} + +type SubmissionEvent struct { + Gate string `json:"gate"` + Ref string `json:"ref"` + InputSHA string `json:"input_sha"` + OutputSHA string `json:"output_sha,omitempty"` + Kind string `json:"kind"` + RecordedAt time.Time `json:"recorded_at"` +} + +type persistedSnapshot struct { + ID string `json:"run_id"` + Repo string `json:"repo"` + Branch string `json:"branch"` + InputSHA string `json:"input_sha"` + OutputSHA string `json:"output_sha"` + Status RunStatus `json:"state"` + QueuedAt time.Time `json:"queued_at"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + ExecutionFinished bool `json:"execution_finished"` + Message string `json:"message,omitempty"` + Errors []string `json:"errors"` + Findings []RunFinding `json:"findings"` + Decisions map[string]string `json:"decisions"` + PRURL string `json:"pr_url"` + SupersededBy string `json:"superseded_by"` + CancelRequested bool `json:"cancel_requested"` + SubmissionEvents []SubmissionEvent `json:"submission_events"` + Stages []StageResult `json:"stages"` + PendingFindings []AskUserFinding `json:"pending_findings"` + Finalized bool `json:"finalized"` +} + +type storeRecord struct { + Version int `json:"version"` + Kind string `json:"kind"` + Snapshot persistedSnapshot `json:"snapshot"` +} + +// RunStore is an append-only JSON WAL. Each state transition is a complete +// snapshot, so replay needs no ordering assumptions beyond file order. +// Every append is synced before it is acknowledged to the caller. +type RunStore struct { + path string + mu sync.Mutex +} + +func OpenRunStore(path string) (*RunStore, map[string]RunSnapshot, error) { + if path == "" { + return nil, nil, errors.New("daemon: run store path is required") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, nil, fmt.Errorf("daemon: create run store directory: %w", err) + } + store := &RunStore{path: path} + snapshots := make(map[string]RunSnapshot) + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return store, snapshots, nil + } + if err != nil { + return nil, nil, fmt.Errorf("daemon: open run store: %w", err) + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + for scanner.Scan() { + var record storeRecord + if err := json.Unmarshal(scanner.Bytes(), &record); err != nil { + return nil, nil, fmt.Errorf("daemon: decode run store record: %w", err) + } + if record.Version != runStoreRecordVersion || record.Kind != "snapshot" { + return nil, nil, fmt.Errorf("daemon: unsupported run store record version %d or kind %q", record.Version, record.Kind) + } + snapshots[record.Snapshot.ID] = restoreSnapshot(record.Snapshot) + } + if err := scanner.Err(); err != nil { + return nil, nil, fmt.Errorf("daemon: read run store: %w", err) + } + return store, snapshots, nil +} + +func (s *RunStore) Append(snapshot RunSnapshot) error { + if s == nil { + return errors.New("daemon: nil run store") + } + record := storeRecord{Version: runStoreRecordVersion, Kind: "snapshot", Snapshot: persistSnapshot(snapshot)} + data, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("daemon: encode run store record: %w", err) + } + s.mu.Lock() + defer s.mu.Unlock() + file, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return fmt.Errorf("daemon: open run store for append: %w", err) + } + defer func() { _ = file.Close() }() + if _, err := file.Write(append(data, '\n')); err != nil { + return fmt.Errorf("daemon: append run store: %w", err) + } + if err := file.Sync(); err != nil { + return fmt.Errorf("daemon: sync run store: %w", err) + } + return nil +} + +func persistSnapshot(snapshot RunSnapshot) persistedSnapshot { + errorsList := append([]string(nil), snapshot.Errors...) + if snapshot.Err != nil && len(errorsList) == 0 { + errorsList = []string{snapshot.Err.Error()} + } + decisions := make(map[string]string, len(snapshot.Decisions)) + for key, value := range snapshot.Decisions { + decisions[key] = value + } + return persistedSnapshot{ + ID: snapshot.ID, Repo: snapshot.Repo, Branch: snapshot.Branch, + InputSHA: snapshot.InputSHA, OutputSHA: snapshot.OutputSHA, + Status: snapshot.Status, QueuedAt: snapshot.QueuedAt, StartedAt: snapshot.StartedAt, + EndedAt: snapshot.EndedAt, ExecutionFinished: snapshot.ExecutionFinished, + Message: snapshot.Message, Errors: errorsList, + Findings: append([]RunFinding(nil), snapshot.Findings...), Decisions: decisions, + PRURL: snapshot.PRURL, SupersededBy: snapshot.SupersededBy, + CancelRequested: snapshot.CancelRequested, + SubmissionEvents: append([]SubmissionEvent(nil), snapshot.SubmissionEvents...), + Stages: append([]StageResult(nil), snapshot.Stages...), + PendingFindings: append([]AskUserFinding(nil), snapshot.PendingFindings...), + Finalized: snapshot.finalized, + } +} + +func restoreSnapshot(snapshot persistedSnapshot) RunSnapshot { + var runErr error + if len(snapshot.Errors) > 0 { + runErr = errors.New(snapshot.Errors[len(snapshot.Errors)-1]) + } + return RunSnapshot{ + ID: snapshot.ID, Repo: snapshot.Repo, Branch: snapshot.Branch, + InputSHA: snapshot.InputSHA, OutputSHA: snapshot.OutputSHA, + Status: snapshot.Status, QueuedAt: snapshot.QueuedAt, StartedAt: snapshot.StartedAt, + EndedAt: snapshot.EndedAt, ExecutionFinished: snapshot.ExecutionFinished, + Err: runErr, Errors: append([]string(nil), snapshot.Errors...), + Message: snapshot.Message, Findings: append([]RunFinding(nil), snapshot.Findings...), + Decisions: snapshot.Decisions, PRURL: snapshot.PRURL, + SupersededBy: snapshot.SupersededBy, CancelRequested: snapshot.CancelRequested, + SubmissionEvents: append([]SubmissionEvent(nil), snapshot.SubmissionEvents...), + Stages: append([]StageResult(nil), snapshot.Stages...), + PendingFindings: append([]AskUserFinding(nil), snapshot.PendingFindings...), + finalized: snapshot.Finalized, + } +} diff --git a/internal/evidence/inrepo.go b/internal/evidence/inrepo.go index cbca258..13ce467 100644 --- a/internal/evidence/inrepo.go +++ b/internal/evidence/inrepo.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) type InRepoStore struct { @@ -20,21 +21,24 @@ func (s *InRepoStore) Location(runID string) string { } func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) error { - if runID == "" { - return fmt.Errorf("evidence: runID must not be empty") + if err := validateEvidenceInput(runID, files); err != nil { + return err } dir := s.Dir if dir == "" { dir = DefaultDir } runDir := filepath.Join(s.RepoPath, dir, runID) - for name, data := range files { dest := filepath.Join(runDir, name) + rel, err := filepath.Rel(runDir, dest) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(name) { + return fmt.Errorf("evidence: path %q escapes run evidence directory", name) + } if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return fmt.Errorf("evidence: create evidence dir for %q: %w", name, err) } - if err := os.WriteFile(dest, data, 0o644); err != nil { + if err := os.WriteFile(dest, Redact(data), 0o644); err != nil { return fmt.Errorf("evidence: write evidence file %q: %w", name, err) } } diff --git a/internal/evidence/orphan.go b/internal/evidence/orphan.go index 392e65a..823dca7 100644 --- a/internal/evidence/orphan.go +++ b/internal/evidence/orphan.go @@ -33,8 +33,8 @@ func (s *OrphanBranchStore) Location(runID string) string { // commit-tree is what gives the branch no shared history with the default // branch. func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) error { - if runID == "" { - return fmt.Errorf("evidence: runID must not be empty") + if err := validateEvidenceInput(runID, files); err != nil { + return err } branch := s.Branch if branch == "" { @@ -64,7 +64,7 @@ func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) sort.Strings(names) for _, name := range names { - blobSHA, err := s.runGit(indexEnv, files[name], "hash-object", "-w", "--stdin") + blobSHA, err := s.runGit(indexEnv, Redact(files[name]), "hash-object", "-w", "--stdin") if err != nil { return fmt.Errorf("evidence: hash evidence file %q: %w", name, err) } diff --git a/internal/evidence/redact.go b/internal/evidence/redact.go new file mode 100644 index 0000000..9289469 --- /dev/null +++ b/internal/evidence/redact.go @@ -0,0 +1,20 @@ +package evidence + +import ( + "bytes" + "regexp" +) + +var evidenceSecretPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)(authorization:\s*bearer\s+)[A-Za-z0-9._-]+`), + regexp.MustCompile(`\b(?:ghp_|github_pat_|sk-)[A-Za-z0-9_-]+`), + regexp.MustCompile(`(?i)(token=)[^&\s]+`), +} + +func Redact(data []byte) []byte { + redacted := bytes.Clone(data) + for _, pattern := range evidenceSecretPatterns { + redacted = pattern.ReplaceAll(redacted, []byte("$1[REDACTED]")) + } + return redacted +} diff --git a/internal/evidence/remediation_contract_test.go b/internal/evidence/remediation_contract_test.go new file mode 100644 index 0000000..ff784b2 --- /dev/null +++ b/internal/evidence/remediation_contract_test.go @@ -0,0 +1,41 @@ +package evidence_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/douglasjarquin/made/internal/evidence" +) + +func TestInRepoStore_RejectsPathTraversalAndOversizedEvidence(t *testing.T) { + store := &evidence.InRepoStore{RepoPath: t.TempDir(), Dir: ".made/evidence"} + + if err := store.WriteEvidence("run-1", map[string][]byte{ + "../escaped.txt": []byte("must stay inside the run"), + }); err == nil { + t.Fatal("evidence store accepted a path traversal outside the run evidence directory") + } + + if err := store.WriteEvidence("run-1", map[string][]byte{ + "large.log": []byte(strings.Repeat("x", 2<<20)), + }); err == nil { + t.Fatal("evidence store accepted output beyond the bounded retention limit") + } +} + +func TestInRepoStore_RedactsPublishedSecrets(t *testing.T) { + repo := t.TempDir() + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte("Authorization: Bearer secret-value\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + data, err := os.ReadFile(filepath.Join(repo, ".made/evidence", "run-1", "log.txt")) + if err != nil { + t.Fatalf("read evidence: %v", err) + } + if strings.Contains(string(data), "secret-value") { + t.Fatalf("published evidence retained an authorization secret: %q", data) + } +} diff --git a/internal/evidence/store.go b/internal/evidence/store.go index d64d877..1f3ed79 100644 --- a/internal/evidence/store.go +++ b/internal/evidence/store.go @@ -1,8 +1,16 @@ package evidence +import ( + "fmt" + "path/filepath" + "strings" +) + const ( - DefaultBranch = "made-evidence" - DefaultDir = ".made/evidence" + DefaultBranch = "made-evidence" + DefaultDir = ".made/evidence" + maxEvidenceFileBytes = 1 << 20 + maxEvidenceBytes = 4 << 20 ) type Config struct { @@ -11,6 +19,25 @@ type Config struct { Branch string } +func validateEvidenceInput(runID string, files map[string][]byte) error { + cleanRunID := filepath.Clean(runID) + if runID == "" || cleanRunID != runID || filepath.IsAbs(runID) || cleanRunID == "." || cleanRunID == ".." || strings.HasPrefix(cleanRunID, ".."+string(filepath.Separator)) { + return fmt.Errorf("evidence: invalid runID %q", runID) + } + total := 0 + for name, data := range files { + clean := filepath.Clean(name) + if name == "" || filepath.IsAbs(name) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || clean == ".git" || strings.HasPrefix(clean, ".git"+string(filepath.Separator)) { + return fmt.Errorf("evidence: path %q escapes run evidence directory", name) + } + if len(data) > maxEvidenceFileBytes || total+len(data) > maxEvidenceBytes { + return fmt.Errorf("evidence: retention limit exceeded for %q", name) + } + total += len(data) + } + return nil +} + type Store interface { WriteEvidence(runID string, files map[string][]byte) error } diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 0079079..6c8571e 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -15,6 +15,7 @@ type Command struct { Args []string Dir string Env []string + Stdin []byte Timeout time.Duration } @@ -39,6 +40,9 @@ func Run(ctx context.Context, cmd Command) (*Result, error) { var stdout, stderr bytes.Buffer c.Stdout = &stdout c.Stderr = &stderr + if cmd.Stdin != nil { + c.Stdin = bytes.NewReader(cmd.Stdin) + } if err := c.Start(); err != nil { return nil, fmt.Errorf("start %s: %w", cmd.Name, err) diff --git a/internal/github/client.go b/internal/github/client.go index c4717da..f33d21a 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strconv" "strings" "time" @@ -18,6 +19,8 @@ type Client struct { Timeout time.Duration } +const maxCheckLogBytes = 64 * 1024 + type AuthError struct { Detail string } @@ -33,6 +36,39 @@ type CreatePROptions struct { Head string } +type Check struct { + Name string `json:"name"` + Status string `json:"state"` + Conclusion string `json:"conclusion"` + WorkflowRunID string `json:"workflowRunId"` + DetailsURL string `json:"detailsUrl"` +} + +func (c *Check) UnmarshalJSON(data []byte) error { + var wire struct { + Name string `json:"name"` + Status string `json:"state"` + Conclusion string `json:"conclusion"` + WorkflowRunID json.RawMessage `json:"workflowRunId"` + DetailsURL string `json:"detailsUrl"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + var runID string + if len(wire.WorkflowRunID) > 0 && string(wire.WorkflowRunID) != "null" { + if err := json.Unmarshal(wire.WorkflowRunID, &runID); err != nil { + var numeric json.Number + if err := json.Unmarshal(wire.WorkflowRunID, &numeric); err != nil { + return fmt.Errorf("github: parse workflow run ID: %w", err) + } + runID = numeric.String() + } + } + *c = Check{Name: wire.Name, Status: wire.Status, Conclusion: wire.Conclusion, WorkflowRunID: runID, DetailsURL: wire.DetailsURL} + return nil +} + func (c *Client) AuthStatus(ctx context.Context) error { res, err := c.run(ctx, "auth", "status") if err != nil { @@ -48,6 +84,11 @@ func (c *Client) CreatePR(ctx context.Context, opts CreatePROptions) (string, er if err := c.AuthStatus(ctx); err != nil { return "", err } + if existing, err := c.findOpenPR(ctx, opts); err != nil { + return "", err + } else if existing != "" { + return existing, nil + } args := []string{"pr", "create", "--title", opts.Title, "--body", opts.Body} if opts.Base != "" { @@ -67,6 +108,27 @@ func (c *Client) CreatePR(ctx context.Context, opts CreatePROptions) (string, er return lastLine(res.Stdout), nil } +func (c *Client) findOpenPR(ctx context.Context, opts CreatePROptions) (string, error) { + args := []string{"pr", "list", "--state", "open", "--base", opts.Base, "--head", opts.Head, "--json", "url"} + res, err := c.run(ctx, args...) + if err != nil { + return "", fmt.Errorf("github: run gh pr list: %w", err) + } + if res.ExitCode != 0 { + return "", fmt.Errorf("github: gh pr list failed: %s", strings.TrimSpace(string(res.Stderr))) + } + var entries []struct { + URL string `json:"url"` + } + if err := json.Unmarshal(res.Stdout, &entries); err != nil { + return "", fmt.Errorf("github: parse gh pr list output: %w", err) + } + if len(entries) == 0 { + return "", nil + } + return entries[0].URL, nil +} + func (c *Client) MergeableState(ctx context.Context, prURL string) (string, error) { if err := c.AuthStatus(ctx); err != nil { return "", err @@ -90,6 +152,9 @@ func (c *Client) MergeableState(ctx context.Context, prURL string) (string, erro } func (c *Client) CheckLogs(ctx context.Context, runID string) (string, error) { + if err := validateWorkflowRunID(runID); err != nil { + return "", err + } if err := c.AuthStatus(ctx); err != nil { return "", err } @@ -101,10 +166,17 @@ func (c *Client) CheckLogs(ctx context.Context, runID string) (string, error) { if res.ExitCode != 0 { return "", fmt.Errorf("github: gh run view failed: %s", strings.TrimSpace(string(res.Stderr))) } - return string(res.Stdout), nil + output := res.Stdout + if len(output) > maxCheckLogBytes { + output = append(append([]byte(nil), output[:maxCheckLogBytes]...), []byte("\n[truncated]\n")...) + } + return string(output), nil } func (c *Client) RerunCheck(ctx context.Context, runID string) error { + if err := validateWorkflowRunID(runID); err != nil { + return err + } if err := c.AuthStatus(ctx); err != nil { return err } @@ -119,6 +191,38 @@ func (c *Client) RerunCheck(ctx context.Context, runID string) error { return nil } +func (c *Client) Checks(ctx context.Context, prURL string) ([]Check, error) { + if strings.TrimSpace(prURL) == "" { + return nil, fmt.Errorf("github: pull request URL is required for checks") + } + if err := c.AuthStatus(ctx); err != nil { + return nil, err + } + res, err := c.run(ctx, "pr", "checks", prURL, "--json", "name,state,conclusion,workflowRunId,detailsUrl") + if err != nil { + return nil, fmt.Errorf("github: run gh pr checks: %w", err) + } + if res.ExitCode != 0 { + return nil, fmt.Errorf("github: gh pr checks failed: %s", strings.TrimSpace(string(res.Stderr))) + } + var checks []Check + if err := json.Unmarshal(res.Stdout, &checks); err != nil { + return nil, fmt.Errorf("github: parse gh pr checks output: %w", err) + } + return checks, nil +} + +func validateWorkflowRunID(value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("github: workflow run ID is required") + } + if _, err := strconv.ParseInt(trimmed, 10, 64); err != nil { + return fmt.Errorf("github: workflow run ID must be numeric, got %q", value) + } + return nil +} + func (c *Client) run(ctx context.Context, args ...string) (*exec.Result, error) { binary := c.Binary if binary == "" { diff --git a/internal/github/remediation_contract_test.go b/internal/github/remediation_contract_test.go new file mode 100644 index 0000000..9f209a4 --- /dev/null +++ b/internal/github/remediation_contract_test.go @@ -0,0 +1,19 @@ +package github_test + +import ( + "context" + "strings" + "testing" +) + +func TestCheckLogs_RejectsPRURLWhenWorkflowRunIDIsRequired(t *testing.T) { + c := newClient(t, []string{"FAKE_GH_RUN_LOG=secret log"}, "") + + _, err := c.CheckLogs(context.Background(), "https://github.com/example/repo/pull/42") + if err == nil { + t.Fatal("CheckLogs accepted a PR URL where a workflow run ID is required") + } + if !strings.Contains(err.Error(), "workflow") && !strings.Contains(err.Error(), "run") { + t.Fatalf("error does not explain the workflow-run-ID boundary: %v", err) + } +} diff --git a/internal/github/testdata/fakegh/main.go b/internal/github/testdata/fakegh/main.go index 134b7f3..72c3557 100644 --- a/internal/github/testdata/fakegh/main.go +++ b/internal/github/testdata/fakegh/main.go @@ -36,6 +36,10 @@ func main() { switch { case len(args) >= 2 && args[0] == "pr" && args[1] == "create": fmt.Fprintln(os.Stdout, envOr("FAKE_GH_PR_URL", "https://github.com/example/repo/pull/1")) + case len(args) >= 2 && args[0] == "pr" && args[1] == "list": + fmt.Fprint(os.Stdout, envOr("FAKE_GH_PR_LIST_JSON", "[]")) + case len(args) >= 2 && args[0] == "pr" && args[1] == "checks": + fmt.Fprint(os.Stdout, checksResponse()) case len(args) >= 2 && args[0] == "pr" && args[1] == "view": fmt.Fprint(os.Stdout, prViewResponse()) case len(args) >= 2 && args[0] == "run" && args[1] == "view": @@ -64,6 +68,23 @@ func prViewResponse() string { return fmt.Sprintf(`{"mergeStateStatus":%q}`, strings.TrimSpace(list[idx])) } +func checksResponse() string { + if value := os.Getenv("FAKE_GH_CHECKS_JSON"); value != "" { + return value + } + states := os.Getenv("FAKE_GH_PR_VIEW_STATES") + if states == "" { + return `[{"name":"ci","state":"SUCCESS","conclusion":"SUCCESS","workflowRunId":1,"detailsUrl":"https://github.com/example/repo/actions/runs/1"}]` + } + list := strings.Split(states, ",") + idx := nextSequenceIndex("checks", len(list)) + state := strings.ToUpper(strings.TrimSpace(list[idx])) + if state == "CLEAN" { + return `[{"name":"ci","state":"SUCCESS","conclusion":"SUCCESS","workflowRunId":1,"detailsUrl":"https://github.com/example/repo/actions/runs/1"}]` + } + return `[{"name":"ci","state":"COMPLETED","conclusion":"FAILURE","workflowRunId":1,"detailsUrl":"https://github.com/example/repo/actions/runs/1"}]` +} + // nextSequenceIndex lets one scripted state sequence (e.g. "fails twice then // passes") span multiple fakegh invocations, since gh polling means each // status check is a brand-new process with no memory of the last one. It diff --git a/internal/orchestrator/scaffold.go b/internal/orchestrator/scaffold.go index ce76fa8..c9b883a 100644 --- a/internal/orchestrator/scaffold.go +++ b/internal/orchestrator/scaffold.go @@ -114,6 +114,9 @@ func Run(ctx context.Context, gatePath, defaultBranch, worktreesDir, runID, push } func resolveConfig(ctx context.Context, gatePath, defaultBranch, worktreePath string) (config.Config, error) { + if err := refreshDefaultBranch(ctx, gatePath, defaultBranch); err != nil { + return config.Config{}, err + } trustedPath, cleanup, err := extractTrustedConfig(ctx, gatePath, defaultBranch) if err != nil { return config.Config{}, err @@ -134,6 +137,25 @@ func resolveConfig(ctx context.Context, gatePath, defaultBranch, worktreePath st return cfg, nil } +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}) + if err != nil { + return fmt.Errorf("orchestrator: inspect origin remote: %w", err) + } + if remote.ExitCode != 0 { + return nil + } + 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}) + if err != nil { + return fmt.Errorf("orchestrator: refresh default branch %s: %w", defaultBranch, err) + } + if fetch.ExitCode != 0 { + return fmt.Errorf("orchestrator: refresh default branch %s failed: %s", defaultBranch, string(fetch.Stderr)) + } + return nil +} + func extractTrustedConfig(ctx context.Context, gatePath, defaultBranch string) (path string, cleanup func(), err error) { res, err := execpkg.Run(ctx, execpkg.Command{ Name: "git", @@ -151,7 +173,7 @@ func extractTrustedConfig(ctx context.Context, gatePath, defaultBranch string) ( return "", nil, nil } - f, err := os.CreateTemp("", "made-trusted-config-*.yml") + f, err := os.CreateTemp("", "made-trusted-config-*.made.yml") if err != nil { return "", nil, fmt.Errorf("orchestrator: create temp file for trusted config: %w", err) } diff --git a/internal/orchestrator/scaffold_test.go b/internal/orchestrator/scaffold_test.go index c09a43d..62df86f 100644 --- a/internal/orchestrator/scaffold_test.go +++ b/internal/orchestrator/scaffold_test.go @@ -19,7 +19,7 @@ func TestSetupResolvesTrustedConfigWhenPresentOnDefaultBranch(t *testing.T) { src := filepath.Join(dir, "src") initSourceRepo(t, src) - writeFile(t, src, ".made.yml", "no_ci: true\nallow_repo_commands: true\ncommands:\n test: \"go test ./...\"\n") + writeFile(t, src, ".made.yml", "version: 1\nno_ci: true\nallow_repo_commands: true\ncommands:\n test: \"go test ./...\"\n") commit(t, src, "add made.yml") sha := pushBranch(t, src, barePath, "main") diff --git a/internal/orchestrator/workfunc.go b/internal/orchestrator/workfunc.go index 78f1dea..9f17317 100644 --- a/internal/orchestrator/workfunc.go +++ b/internal/orchestrator/workfunc.go @@ -33,6 +33,7 @@ const ( stageNameCI = "ci" ciStageTimeout = 30 * time.Minute + stageTimeout = 30 * time.Minute ciPollInterval = 10 * time.Second pushRemoteName = "origin" @@ -95,32 +96,38 @@ type chain struct { } func (c *chain) run() error { - if err := c.intentStage(); err != nil { + if err := c.runStage(stageNameIntent, c.intentStage); err != nil { return err } - if err := c.rebaseStage(); err != nil { + if err := c.runStage(stageNameRebase, c.rebaseStage); err != nil { return err } - if err := c.reviewStage(); err != nil { + if err := c.runStage(stageNameReview, c.reviewStage); err != nil { return err } - if err := c.testStage(); err != nil { + if err := c.runStage(stageNameTest, c.testStage); err != nil { return err } - if err := c.documentStage(); err != nil { + if err := c.runStage(stageNameDocument, c.documentStage); err != nil { return err } - if err := c.lintStage(); err != nil { + if err := c.runStage(stageNameLint, c.lintStage); err != nil { return err } - if err := c.pushStage(); err != nil { + if err := c.runStage(stageNamePush, c.pushStage); err != nil { return err } - prResult, err := c.prStage() + var prResult pr.Result + var err error + if c.rc.Config.StageResult(stageNamePR) == "skipped" { + c.finish(stageNamePR, "skipped", "stage disabled") + } else { + prResult, err = c.prStage() + } if err != nil { return err } - if err := c.ciStage(prResult.PRURL); err != nil { + if err := c.runCIStage(prResult.PRURL); err != nil { return err } @@ -129,7 +136,29 @@ func (c *chain) run() error { // final status stays RunRunning rather than RunCompleted, with the PR // URL surfaced in the message instead of a terminal "done" state. message := fmt.Sprintf("all stages passed, PR open, awaiting merge: %s", prResult.PRURL) - return c.rm.Finish(c.runID, daemon.RunRunning, message) + return c.rm.Finish(c.runID, daemon.RunAwaitingMerge, message) +} + +func (c *chain) runStage(name string, stage func() error) error { + if c.rc.Config.StageResult(name) == "skipped" { + c.finish(name, "skipped", "stage disabled") + return nil + } + stageCtx, cancel := context.WithTimeout(c.ctx, stageTimeout) + previous := c.ctx + c.ctx = stageCtx + err := stage() + c.ctx = previous + cancel() + return err +} + +func (c *chain) runCIStage(prURL string) error { + if c.rc.Config.StageResult(stageNameCI) == "skipped" || prURL == "" { + c.finish(stageNameCI, "skipped", "stage disabled") + return nil + } + return c.ciStage(prURL) } func (c *chain) start(stage string) { @@ -200,6 +229,20 @@ func (c *chain) reviewStage() error { if err != nil { return err } + durableFindings := make([]daemon.RunFinding, 0, len(result.Findings)) + autoFixIndex := 0 + for _, finding := range result.Findings { + record := daemon.RunFinding{Stage: stageNameReview, Kind: string(finding.Kind), Message: finding.Description, Paths: append([]string(nil), finding.Paths...)} + if finding.Kind == agent.FindingAutoFixable && autoFixIndex < len(result.PreFixSHAs) { + record.PreFixSHA = result.PreFixSHAs[autoFixIndex] + record.PostFixSHA = result.PostFixSHAs[autoFixIndex] + autoFixIndex++ + } + durableFindings = append(durableFindings, record) + } + if err := c.rm.AddFindings(c.runID, durableFindings); err != nil { + return err + } if !result.OK { c.finish(stageNameReview, stageResultFail, result.Message) return c.stageFailure(stageNameReview, result.Message) @@ -235,6 +278,13 @@ func (c *chain) documentStage() error { if err != nil { return err } + durableFindings := make([]daemon.RunFinding, 0, len(result.Findings)) + for _, finding := range result.Findings { + durableFindings = append(durableFindings, daemon.RunFinding{Stage: stageNameDocument, Kind: string(finding.Kind), Message: finding.Description, Paths: append([]string(nil), finding.Paths...)}) + } + if err := c.rm.AddFindings(c.runID, durableFindings); err != nil { + return err + } if !result.OK { c.finish(stageNameDocument, stageResultFail, result.Message) return c.stageFailure(stageNameDocument, result.Message) @@ -300,6 +350,9 @@ func (c *chain) prStage() (pr.Result, error) { c.finish(stageNamePR, stageResultFail, result.Message) return pr.Result{}, c.stageFailure(stageNamePR, result.Message) } + if err := c.rm.SetPRURL(c.runID, result.PRURL); err != nil { + return pr.Result{}, err + } c.finish(stageNamePR, stageResultPass, result.Message) return result, nil } diff --git a/internal/orchestrator/workfunc_test.go b/internal/orchestrator/workfunc_test.go index 422843b..edd5362 100644 --- a/internal/orchestrator/workfunc_test.go +++ b/internal/orchestrator/workfunc_test.go @@ -219,8 +219,8 @@ func TestNewWorkFunc_FullPassEndsRunningWithAwaitingMergeMessage(t *testing.T) { submitWorkFunc(t, rm, runID, "repo-full-pass", branch, wf, rc) snap := waitForRunEnded(t, rm, runID, 30*time.Second) - if snap.Status != daemon.RunRunning { - t.Fatalf("expected final status RunRunning (awaiting merge), got %v (err=%v)", snap.Status, snap.Err) + if snap.Status != daemon.RunAwaitingMerge { + t.Fatalf("expected final status RunAwaitingMerge, got %v (err=%v)", snap.Status, snap.Err) } if !strings.Contains(snap.Message, "awaiting merge") { t.Fatalf("expected final message to mention awaiting merge, got %q", snap.Message) @@ -258,8 +258,8 @@ func TestNewWorkFunc_FullPassPRTitleMatchesPushedCommitSubject(t *testing.T) { submitWorkFunc(t, rm, runID, "repo-pr-title", branch, wf, rc) snap := waitForRunEnded(t, rm, runID, 30*time.Second) - if snap.Status != daemon.RunRunning { - t.Fatalf("expected final status RunRunning (awaiting merge), got %v (err=%v)", snap.Status, snap.Err) + if snap.Status != daemon.RunAwaitingMerge { + t.Fatalf("expected final status RunAwaitingMerge, got %v (err=%v)", snap.Status, snap.Err) } assertAllStagesPassed(t, snap.Stages) @@ -378,8 +378,8 @@ func TestNewWorkFunc_DocumentFindingParksThenRejectedFailsRun(t *testing.T) { submitWorkFunc(t, rm, runID, "repo-doc-reject", branch, wf, rc) parked := waitForPendingFindings(t, rm, runID, 30*time.Second) - if parked.Status != daemon.RunRunning { - t.Fatalf("expected parked run to stay RunRunning, got %v", parked.Status) + if parked.Status != daemon.RunAwaitingReview { + t.Fatalf("expected parked run to stay RunAwaitingReview, got %v", parked.Status) } if len(parked.PendingFindings) != 1 || parked.PendingFindings[0].Stage != stageNameDocument { t.Fatalf("expected one pending finding on stage %q, got %+v", stageNameDocument, parked.PendingFindings) @@ -424,15 +424,15 @@ func TestNewWorkFunc_DocumentFindingParksThenApprovedResumesToCompletion(t *test submitWorkFunc(t, rm, runID, "repo-doc-approve", branch, wf, rc) parked := waitForPendingFindings(t, rm, runID, 30*time.Second) - if parked.Status != daemon.RunRunning { - t.Fatalf("expected parked run to stay RunRunning, got %v", parked.Status) + if parked.Status != daemon.RunAwaitingReview { + t.Fatalf("expected parked run to stay RunAwaitingReview, got %v", parked.Status) } reviewDecisions.Set(runID, stageNameDocument, daemon.ReviewApproved) snap := waitForRunEnded(t, rm, runID, 30*time.Second) - if snap.Status != daemon.RunRunning { - t.Fatalf("expected final status RunRunning (awaiting merge) after resume, got %v (err=%v)", snap.Status, snap.Err) + if snap.Status != daemon.RunAwaitingMerge { + t.Fatalf("expected final status RunAwaitingMerge after resume, got %v (err=%v)", snap.Status, snap.Err) } if !strings.Contains(snap.Message, "awaiting merge") { t.Fatalf("expected final message to mention awaiting merge, got %q", snap.Message) diff --git a/internal/pipeline/ci/ci.go b/internal/pipeline/ci/ci.go index 3a29c45..724eb3b 100644 --- a/internal/pipeline/ci/ci.go +++ b/internal/pipeline/ci/ci.go @@ -16,13 +16,6 @@ import ( const ( defaultPollInterval = 2 * time.Second - - // passingMergeState is the gh pr view mergeStateStatus value that means - // "all checks passed and the PR is clear to proceed". internal/github's - // Client exposes no separate check-listing endpoint, so this stage - // treats PR mergeability status as its check-status signal; any other - // state is treated as a (possibly transient) check failure. - passingMergeState = "CLEAN" ) type Result struct { @@ -58,40 +51,83 @@ func Run(ctx context.Context, ghClient *github.Client, prURL string, rerunBudget reruns := 0 for { - state, err := ghClient.MergeableState(ctx, prURL) + checks, err := ghClient.Checks(ctx, prURL) if err != nil { - return Result{OK: false, Message: err.Error(), RerunsUsed: reruns}, nil + return Result{}, err + } + if len(checks) == 0 { + return Result{}, fmt.Errorf("ci: GitHub returned no checks for %s", prURL) } - if state == passingMergeState { + allPassed := true + var failing *github.Check + pending := false + for i := range checks { + check := checks[i] + if checkPassed(check) { + continue + } + allPassed = false + if checkPending(check) { + pending = true + continue + } + if failing == nil { + failing = &check + } + } + if allPassed { return Result{ OK: true, Message: fmt.Sprintf("checks passed for %s after %d rerun(s)", prURL, reruns), RerunsUsed: reruns, }, nil } + if pending && failing == nil { + select { + case <-ctx.Done(): + return Result{}, ctx.Err() + case <-time.After(pollInterval): + } + continue + } + if failing == nil { + return Result{}, fmt.Errorf("ci: check state was neither passing, pending, nor failing") + } if reruns >= rerunBudget { - excerpt, logErr := ghClient.CheckLogs(ctx, prURL) + excerpt, logErr := ghClient.CheckLogs(ctx, failing.WorkflowRunID) if logErr != nil { - excerpt = fmt.Sprintf("(failed to fetch check logs: %s)", logErr.Error()) + return Result{}, logErr } return Result{ OK: false, - Message: fmt.Sprintf("checks still failing (%s) for %s after exhausting rerun budget (%d)", state, prURL, rerunBudget), + Message: fmt.Sprintf("check %s still failing for %s after exhausting rerun budget (%d)", failing.Name, prURL, rerunBudget), RerunsUsed: reruns, LogExcerpt: excerpt, }, nil } - if err := ghClient.RerunCheck(ctx, prURL); err != nil { - return Result{OK: false, Message: err.Error(), RerunsUsed: reruns}, nil + if err := ghClient.RerunCheck(ctx, failing.WorkflowRunID); err != nil { + return Result{}, err } reruns++ select { case <-ctx.Done(): - return Result{OK: false, Message: ctx.Err().Error(), RerunsUsed: reruns}, nil + return Result{}, ctx.Err() case <-time.After(pollInterval): } } } + +func checkPassed(check github.Check) bool { + status := strings.ToUpper(strings.TrimSpace(check.Status)) + conclusion := strings.ToUpper(strings.TrimSpace(check.Conclusion)) + return (status == "SUCCESS" || status == "COMPLETED") && (conclusion == "SUCCESS" || conclusion == "SUCCESSFUL" || conclusion == "NEUTRAL") +} + +func checkPending(check github.Check) bool { + status := strings.ToUpper(strings.TrimSpace(check.Status)) + conclusion := strings.TrimSpace(check.Conclusion) + return conclusion == "" || status == "QUEUED" || status == "IN_PROGRESS" || status == "PENDING" +} diff --git a/internal/pipeline/ci/remediation_contract_test.go b/internal/pipeline/ci/remediation_contract_test.go new file mode 100644 index 0000000..8c74f56 --- /dev/null +++ b/internal/pipeline/ci/remediation_contract_test.go @@ -0,0 +1,21 @@ +package ci_test + +import ( + "context" + "testing" + "time" + + "github.com/douglasjarquin/made/internal/pipeline/ci" +) + +func TestRun_AuthenticationFailureIsInfrastructureError(t *testing.T) { + c := newClient(t, []string{ + "FAKE_GH_AUTH_EXIT_CODE=1", + "FAKE_GH_AUTH_STDERR=not authenticated", + }, "") + + result, err := ci.Run(context.Background(), c, "https://github.com/example/repo/pull/42", 0, time.Millisecond) + if err == nil { + t.Fatalf("authentication failure was reported as a failed check: result=%+v", result) + } +} diff --git a/internal/pipeline/pr/remediation_contract_test.go b/internal/pipeline/pr/remediation_contract_test.go new file mode 100644 index 0000000..68bf9fa --- /dev/null +++ b/internal/pipeline/pr/remediation_contract_test.go @@ -0,0 +1,46 @@ +package pr_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/douglasjarquin/made/internal/github" + "github.com/douglasjarquin/made/internal/pipeline/pr" +) + +func TestRun_CreatePRIsIdempotentByRepositoryBaseAndHead(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "gh.log") + statePath := filepath.Join(t.TempDir(), "created") + bin := filepath.Join(t.TempDir(), "strict-gh") + script := strings.Join([]string{ + "#!/bin/sh", + "set -eu", + "printf '%s\\n' \"$*\" >> \"$STRICT_GH_LOG\"", + "if [ \"$1\" = auth ] && [ \"$2\" = status ]; then exit 0; fi", + "if [ \"$1\" = pr ] && [ \"$2\" = list ]; then if [ -f \"$STRICT_GH_STATE\" ]; then printf '%s\\n' '[{\"url\":\"https://github.com/example/repo/pull/42\"}]'; else printf '%s\\n' '[]'; fi; exit 0; fi", + "if [ \"$1\" = pr ] && [ \"$2\" = create ]; then touch \"$STRICT_GH_STATE\"; printf '%s\\n' 'https://github.com/example/repo/pull/42'; exit 0; fi", + "exit 1", + "", + }, "\n") + if err := os.WriteFile(bin, []byte(script), 0o700); err != nil { + t.Fatalf("write strict gh fake: %v", err) + } + c := &github.Client{Binary: bin, Dir: t.TempDir(), ExtraEnv: []string{"STRICT_GH_LOG=" + logPath, "STRICT_GH_STATE=" + statePath}} + opts := pr.Options{Title: "title", Base: "main", Head: "feature", EvidenceRef: "run-1"} + for i := 0; i < 2; i++ { + result, err := pr.Run(context.Background(), c, opts) + if err != nil || !result.OK { + t.Fatalf("Run %d: result=%+v err=%v", i, result, err) + } + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read gh log: %v", err) + } + if count := strings.Count(string(data), "pr create"); count != 1 { + t.Fatalf("expected one idempotent pr create call, got %d\n%s", count, data) + } +} diff --git a/internal/pipeline/rebase/rebase.go b/internal/pipeline/rebase/rebase.go index ce34ec4..7bac992 100644 --- a/internal/pipeline/rebase/rebase.go +++ b/internal/pipeline/rebase/rebase.go @@ -41,6 +41,12 @@ func Run(worktreePath, defaultBranch string) (Result, error) { if err != nil { return Result{}, fmt.Errorf("rebase: list conflicting files after failed rebase onto %s: %w", defaultBranch, err) } + if len(files) == 0 { + if err := abortRebase(worktreePath); err != nil { + return Result{}, fmt.Errorf("rebase: failed without unmerged paths and abort failed: %w", err) + } + return Result{}, fmt.Errorf("rebase: git rebase %s failed without unmerged paths", defaultBranch) + } // A halted stage must never leave the worktree mid-rebase, so whatever // runs next (a retry, another stage) always starts from a clean state. diff --git a/internal/pipeline/rebase/remediation_contract_test.go b/internal/pipeline/rebase/remediation_contract_test.go new file mode 100644 index 0000000..621ae67 --- /dev/null +++ b/internal/pipeline/rebase/remediation_contract_test.go @@ -0,0 +1,31 @@ +package rebase_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/douglasjarquin/made/internal/pipeline/rebase" +) + +func TestRun_DoesNotClassifyRebaseFailureAsConflictWithoutUnmergedPaths(t *testing.T) { + worktree := t.TempDir() + if err := os.Mkdir(filepath.Join(worktree, ".git"), 0o700); err != nil { + t.Fatalf("make fake git dir: %v", err) + } + binDir := t.TempDir() + fakeGit := filepath.Join(binDir, "git") + script := "#!/bin/sh\nset -eu\ncd \"$2\"\nshift 2\ncase \"$*\" in\n *'rev-parse --git-dir'*) printf '.git\\n' ;;\n *'rebase --abort'*) rm -rf .git/rebase-merge ;;\n *'diff --name-only --diff-filter=U'*) : ;;\n *'rebase upstream'*) mkdir -p .git/rebase-merge; exit 1 ;;\n *) exit 1 ;;\nesac\n" + if err := os.WriteFile(fakeGit, []byte(script), 0o700); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("PATH", binDir+":"+os.Getenv("PATH")) + + result, err := rebase.Run(worktree, "upstream") + if err == nil { + if !result.OK && len(result.ConflictingFiles) == 0 { + t.Fatalf("rebase failure was labeled conflict without unmerged paths: %+v", result) + } + t.Fatalf("rebase failure without unmerged paths returned a normal conflict result: %+v", result) + } +} diff --git a/internal/pipeline/review/remediation_contract_test.go b/internal/pipeline/review/remediation_contract_test.go new file mode 100644 index 0000000..00fbc4a --- /dev/null +++ b/internal/pipeline/review/remediation_contract_test.go @@ -0,0 +1,34 @@ +package review_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/douglasjarquin/made/internal/agent" + "github.com/douglasjarquin/made/internal/agent/agenttest" + "github.com/douglasjarquin/made/internal/pipeline/review" +) + +func TestRun_AutoFixRequiresCleanStateBeforeApplyingReturnedPatch(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + + dirtyPath := filepath.Join(wt.Path, "unrelated.txt") + if err := os.WriteFile(dirtyPath, []byte("unrelated user work\n"), 0o600); err != nil { + t.Fatalf("write dirty fixture: %v", err) + } + patch := autoFixPatch(t, wt.Path) + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "clean-state fix", Patch: patch}, + }}) + + if _, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }); err == nil { + t.Fatal("review auto-fix mutated a dirty worktree instead of refusing before apply") + } +} diff --git a/internal/pipeline/review/review.go b/internal/pipeline/review/review.go index 8f34425..8edb850 100644 --- a/internal/pipeline/review/review.go +++ b/internal/pipeline/review/review.go @@ -10,6 +10,7 @@ import ( "context" "fmt" "os/exec" + "path/filepath" "strings" "time" @@ -25,7 +26,10 @@ type Options struct { type Result struct { OK bool Message string + Findings []agent.Finding AutoFixed []string + PreFixSHAs []string + PostFixSHAs []string PendingFindings []agent.Finding } @@ -45,17 +49,21 @@ func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Op } var autoFixed []string + var preFixSHAs []string + var postFixSHAs []string var pending []agent.Finding var blockingMessages []string for _, finding := range findings.Findings { switch finding.Kind { case agent.FindingAutoFixable: - sha, applyErr := applyAutoFix(worktreePath, finding) + preSHA, postSHA, applyErr := applyAutoFix(worktreePath, finding) if applyErr != nil { return Result{}, fmt.Errorf("review: apply auto-fix %q: %w", finding.Description, applyErr) } - autoFixed = append(autoFixed, sha) + autoFixed = append(autoFixed, postSHA) + preFixSHAs = append(preFixSHAs, preSHA) + postFixSHAs = append(postFixSHAs, postSHA) case agent.FindingBlocking: blockingMessages = append(blockingMessages, finding.Description) pending = append(pending, finding) @@ -68,7 +76,10 @@ func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Op return Result{ OK: false, Message: fmt.Sprintf("review halted by blocking finding(s): %s", strings.Join(blockingMessages, "; ")), + Findings: findings.Findings, AutoFixed: autoFixed, + PreFixSHAs: preFixSHAs, + PostFixSHAs: postFixSHAs, PendingFindings: pending, }, nil } @@ -76,25 +87,71 @@ func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Op return Result{ OK: true, Message: fmt.Sprintf("review passed: %d auto-fix(es) applied, %d finding(s) await human approval", len(autoFixed), len(pending)), + Findings: findings.Findings, AutoFixed: autoFixed, + PreFixSHAs: preFixSHAs, + PostFixSHAs: postFixSHAs, PendingFindings: pending, }, nil } -func applyAutoFix(worktreePath string, finding agent.Finding) (string, error) { +func applyAutoFix(worktreePath string, finding agent.Finding) (string, string, error) { if strings.TrimSpace(finding.Patch) == "" { - return "", fmt.Errorf("auto-fixable finding has no patch") + return "", "", fmt.Errorf("auto-fixable finding has no patch") + } + if err := requireCleanWorktree(worktreePath); err != nil { + return "", "", err + } + preSHA, err := gitOutput(worktreePath, "rev-parse", "HEAD") + if err != nil { + return "", "", fmt.Errorf("record pre-fix SHA: %w", err) + } + paths, err := patchPaths(finding.Patch) + if err != nil { + return "", "", err + } + allowed := make(map[string]struct{}, len(finding.Paths)) + for _, path := range finding.Paths { + clean, err := cleanReturnedPath(path) + if err != nil { + return "", "", err + } + allowed[clean] = struct{}{} + } + if len(allowed) == 0 { + for _, path := range paths { + allowed[path] = struct{}{} + } + } + for _, path := range paths { + if _, ok := allowed[path]; !ok { + return "", "", fmt.Errorf("auto-fix patch changes path %q outside returned paths", path) + } } applyCmd := exec.Command("git", "-C", worktreePath, "apply", "--whitespace=fix", "-") applyCmd.Stdin = strings.NewReader(finding.Patch) if out, err := applyCmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("git apply: %w: %s", err, strings.TrimSpace(string(out))) + return "", "", fmt.Errorf("git apply: %w: %s", err, strings.TrimSpace(string(out))) } - addCmd := exec.Command("git", "-C", worktreePath, "add", "-A") + status, err := gitOutput(worktreePath, "status", "--porcelain", "--untracked-files=all") + if err != nil { + return "", "", fmt.Errorf("inspect post-fix paths: %w", err) + } + changed := statusPaths(status) + for _, path := range changed { + if _, ok := allowed[path]; !ok { + return "", "", fmt.Errorf("auto-fix changed forbidden or unreturned path %q", path) + } + } + addArgs := []string{"-C", worktreePath, "add", "--"} + for path := range allowed { + addArgs = append(addArgs, path) + } + addCmd := exec.Command("git", addArgs...) if out, err := addCmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("git add -A: %w: %s", err, strings.TrimSpace(string(out))) + return "", "", fmt.Errorf("git add returned paths: %w: %s", err, strings.TrimSpace(string(out))) } message := finding.Description @@ -104,14 +161,83 @@ func applyAutoFix(worktreePath string, finding agent.Finding) (string, error) { commitCmd := exec.Command("git", "-C", worktreePath, "-c", "user.name=made-review", "-c", "user.email=made-review@local", + "-c", "commit.gpgsign=false", "commit", "-m", message) if out, err := commitCmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("git commit: %w: %s", err, strings.TrimSpace(string(out))) + return "", "", fmt.Errorf("git commit: %w: %s", err, strings.TrimSpace(string(out))) } - shaOut, err := exec.Command("git", "-C", worktreePath, "rev-parse", "HEAD").Output() + shaOut, err := gitOutput(worktreePath, "rev-parse", "HEAD") + if err != nil { + return "", "", fmt.Errorf("git rev-parse HEAD: %w", err) + } + if _, err := gitOutput(worktreePath, "diff", "--check", preSHA, shaOut); err != nil { + return "", "", fmt.Errorf("rerun review validation: %w", err) + } + return preSHA, shaOut, nil +} + +func requireCleanWorktree(worktreePath string) error { + status, err := gitOutput(worktreePath, "status", "--porcelain", "--untracked-files=all") if err != nil { - return "", fmt.Errorf("git rev-parse HEAD: %w", err) + return fmt.Errorf("inspect clean worktree: %w", err) + } + if strings.TrimSpace(status) != "" { + return fmt.Errorf("auto-fix requires a clean worktree") + } + return nil +} + +func gitOutput(worktreePath string, args ...string) (string, error) { + output, err := exec.Command("git", append([]string{"-C", worktreePath}, args...)...).Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} + +func patchPaths(patch string) ([]string, error) { + seen := make(map[string]struct{}) + for _, line := range strings.Split(patch, "\n") { + if !strings.HasPrefix(line, "+++ b/") { + continue + } + path, err := cleanReturnedPath(strings.TrimPrefix(line, "+++ b/")) + if err != nil { + return nil, err + } + seen[path] = struct{}{} + } + if len(seen) == 0 { + return nil, fmt.Errorf("auto-fix patch contains no returned paths") + } + paths := make([]string, 0, len(seen)) + for path := range seen { + paths = append(paths, path) + } + return paths, nil +} + +func cleanReturnedPath(path string) (string, error) { + clean := filepath.Clean(path) + if clean == "." || filepath.IsAbs(path) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || clean == ".git" || strings.HasPrefix(clean, ".git"+string(filepath.Separator)) { + return "", fmt.Errorf("auto-fix returned forbidden path %q", path) + } + return filepath.ToSlash(clean), nil +} + +func statusPaths(status string) []string { + var paths []string + for _, line := range strings.Split(status, "\n") { + line = strings.TrimSpace(line) + if len(line) < 4 { + continue + } + path := strings.TrimSpace(line[2:]) + if strings.Contains(path, " -> ") { + path = strings.TrimSpace(strings.SplitN(path, " -> ", 2)[1]) + } + paths = append(paths, filepath.ToSlash(path)) } - return strings.TrimSpace(string(shaOut)), nil + return paths } diff --git a/internal/skill/skill.go b/internal/skill/skill.go index e9fdc69..0990922 100644 --- a/internal/skill/skill.go +++ b/internal/skill/skill.go @@ -68,21 +68,23 @@ along with the command: - The daemon starts on demand, but ` + "`made doctor`" + ` is the fastest way to confirm the daemon, ` + "`gh`" + ` authentication, and (optionally) a running herdr server are all reachable before you push. + Use ` + "`made doctor --json`" + ` for the versioned health response. ## Running the gate ` + "```sh" + ` made gate init # once per repo: create the bare gate + remote git push made # admits the push and returns immediately -made status --json # poll structured run state, per-stage - # results, and any pending ask-user findings +made capabilities --json # inspect the versioned public command contract +made run list --json --active # list active runs and exact run IDs +made run status --json # poll one exact run ID ` + "```" + ` ` + "`git push made `" + ` only blocks long enough to admit the push; it returns as soon as the push is accepted, before the pipeline has run a single stage. The 9-stage pipeline then runs in the background against a -daemon-managed worktree. Poll ` + "`made status --json`" + ` (it is safe to call -repeatedly) to watch the run progress and to read its eventual outcome - +daemon-managed worktree. Poll ` + "`made run status --json `" + ` (it is safe to call +repeatedly) to watch that run's progress and to read its eventual outcome - the push itself never reports pass or fail. ## Findings and approval @@ -92,10 +94,7 @@ Review and Document can surface findings while the pipeline runs: - Auto-fixable findings are applied automatically as new commits; nothing to do. - **ask-user** findings are queued, never silently applied or dropped. Run - ` + "`made review`" + ` to see them and approve or reject each one from a plain - stdin/stdout prompt - relay the finding to the user verbatim rather than - paraphrasing it, since it challenges something about their intent or the - product behavior of the change. + ` + "`made review decide --json --stage --decision `" + ` after the decision is authorized. A rejected finding halts the pipeline at that stage; an approved one applies and the run resumes. @@ -122,11 +121,10 @@ and the run resumes. ## Outcomes -` + "`made status --json`" + `'s ` + "`state`" + ` field is one of ` + "`queued`" + `, -` + "`running`" + `, ` + "`completed`" + `, or ` + "`failed`" + `: +` + "`made run status --json `" + `'s ` + "`state`" + ` field is one of ` + "`queued`" + `, +` + "`running`" + `, ` + "`awaiting_review`" + `, ` + "`awaiting_merge`" + `, ` + "`succeeded`" + `, ` + "`failed`" + `, ` + "`canceled`" + `, or ` + "`superseded`" + `: -- **All 9 stages pass** - the run's state stays ` + "`running`" + `, not - ` + "`completed`" + `. made opened a real PR and watched its checks go green, +- **All 9 stages pass** - the run's state becomes ` + "`awaiting_merge`" + `. made opened a real PR and watched its checks go green, but merging that PR is a human decision made cannot observe, so it never marks the run done on its own. The run's message names the open PR and says it is awaiting merge - tell the user it is ready for their review, @@ -140,9 +138,19 @@ and the run resumes. real remote, so that message is your signal the branch is already live there even though the run itself failed - check whether it needs manual cleanup before you push a fix. -- An ask-user finding parks the run rather than failing it: state stays - ` + "`running`" + ` and ` + "`pending_findings`" + ` is populated until - ` + "`made review`" + ` resolves it (see above). +- An ask-user finding parks the run rather than failing it: state becomes + ` + "`awaiting_review`" + ` and ` + "`pending_findings`" + ` is populated until + ` + "`made review decide`" + ` resolves it (see above). + +## Durable contract + +The daemon acquires its singleton before inspecting or changing the Unix socket path. + +Only a stale Unix socket can be removed, and regular files, symlinks, and directories are preserved and rejected. + +Run state is stored in a fsync-backed local WAL, and gate submissions use an idempotent fsync-backed spool keyed by gate, ref, and input SHA. + +Shutdown is authorized through the owner-only Unix socket and is refused while active, awaiting, or undrained work remains. ## herdr visibility diff --git a/skills/made/SKILL.md b/skills/made/SKILL.md index c661298..01351b9 100644 --- a/skills/made/SKILL.md +++ b/skills/made/SKILL.md @@ -38,21 +38,23 @@ along with the command: - The daemon starts on demand, but `made doctor` is the fastest way to confirm the daemon, `gh` authentication, and (optionally) a running herdr server are all reachable before you push. + Use `made doctor --json` for the versioned health response. ## Running the gate ```sh made gate init # once per repo: create the bare gate + remote git push made # admits the push and returns immediately -made status --json # poll structured run state, per-stage - # results, and any pending ask-user findings +made capabilities --json # inspect the versioned public command contract +made run list --json --active # list active runs and exact run IDs +made run status --json # poll one exact run ID ``` `git push made ` only blocks long enough to admit the push; it returns as soon as the push is accepted, before the pipeline has run a single stage. The 9-stage pipeline then runs in the background against a -daemon-managed worktree. Poll `made status --json` (it is safe to call -repeatedly) to watch the run progress and to read its eventual outcome - +daemon-managed worktree. Poll `made run status --json ` (it is safe to call +repeatedly) to watch that run's progress and to read its eventual outcome - the push itself never reports pass or fail. ## Findings and approval @@ -62,10 +64,7 @@ Review and Document can surface findings while the pipeline runs: - Auto-fixable findings are applied automatically as new commits; nothing to do. - **ask-user** findings are queued, never silently applied or dropped. Run - `made review` to see them and approve or reject each one from a plain - stdin/stdout prompt - relay the finding to the user verbatim rather than - paraphrasing it, since it challenges something about their intent or the - product behavior of the change. + `made review decide --json --stage --decision ` after the decision is authorized. A rejected finding halts the pipeline at that stage; an approved one applies and the run resumes. @@ -92,11 +91,10 @@ and the run resumes. ## Outcomes -`made status --json`'s `state` field is one of `queued`, -`running`, `completed`, or `failed`: +`made run status --json `'s `state` field is one of `queued`, +`running`, `awaiting_review`, `awaiting_merge`, `succeeded`, `failed`, `canceled`, or `superseded`: -- **All 9 stages pass** - the run's state stays `running`, not - `completed`. made opened a real PR and watched its checks go green, +- **All 9 stages pass** - the run's state becomes `awaiting_merge`. made opened a real PR and watched its checks go green, but merging that PR is a human decision made cannot observe, so it never marks the run done on its own. The run's message names the open PR and says it is awaiting merge - tell the user it is ready for their review, @@ -110,9 +108,19 @@ and the run resumes. real remote, so that message is your signal the branch is already live there even though the run itself failed - check whether it needs manual cleanup before you push a fix. -- An ask-user finding parks the run rather than failing it: state stays - `running` and `pending_findings` is populated until - `made review` resolves it (see above). +- An ask-user finding parks the run rather than failing it: state becomes + `awaiting_review` and `pending_findings` is populated until + `made review decide` resolves it (see above). + +## Durable contract + +The daemon acquires its singleton before inspecting or changing the Unix socket path. + +Only a stale Unix socket can be removed, and regular files, symlinks, and directories are preserved and rejected. + +Run state is stored in a fsync-backed local WAL, and gate submissions use an idempotent fsync-backed spool keyed by gate, ref, and input SHA. + +Shutdown is authorized through the owner-only Unix socket and is refused while active, awaiting, or undrained work remains. ## herdr visibility From deea4ff0a37c7ac2118a2125a487316b65162d8b Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:30:08 -0400 Subject: [PATCH 02/53] fix: close remediation durability review gaps --- cmd/made/daemon.go | 56 ++++-- cmd/made/daemon_test.go | 2 +- cmd/made/remediation_contract_test.go | 12 +- cmd/made/remediation_process_contract_test.go | 87 ++++++++- cmd/made/review.go | 146 +-------------- cmd/made/review_test.go | 172 ------------------ cmd/made/runhandlers.go | 4 +- cmd/made/status.go | 29 +-- docs/remediation/made-remediation-p1p3b.md | 111 +++++++++++ internal/agent/agent_test.go | 2 +- internal/agent/spawn.go | 3 + internal/api/remediation_contract_test.go | 26 +++ internal/api/server.go | 12 +- internal/daemon/contract.go | 16 +- internal/daemon/durable_contract_test.go | 43 +++++ internal/daemon/remediation_contract_test.go | 33 +++- internal/daemon/reviewdecisions.go | 27 +-- internal/daemon/runmanager.go | 109 +++++++++-- internal/daemon/runmanager_test.go | 8 +- internal/daemon/runstate.go | 8 +- internal/daemon/spool.go | 10 + internal/evidence/inrepo.go | 70 ++++++- .../evidence/remediation_contract_test.go | 22 +++ internal/orchestrator/workfunc.go | 94 ++++++---- .../review/remediation_contract_test.go | 2 +- internal/pipeline/review/review.go | 7 +- internal/pipeline/review/review_test.go | 2 +- internal/skill/skill.go | 2 +- internal/skill/skill_test.go | 2 +- skills/made/SKILL.md | 2 +- 30 files changed, 670 insertions(+), 449 deletions(-) delete mode 100644 cmd/made/review_test.go create mode 100644 docs/remediation/made-remediation-p1p3b.md diff --git a/cmd/made/daemon.go b/cmd/made/daemon.go index 7e46a60..58e22d3 100644 --- a/cmd/made/daemon.go +++ b/cmd/made/daemon.go @@ -141,12 +141,13 @@ func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, go func() { runErr := daemon.Run(runCtx, daemon.Options{ - LockPath: lockPath, - Lock: ownedLock, - IdleTimeout: idle, - OnReady: onReady, - ActivityCh: rm.ActivitySignal(), - ActiveFunc: rm.HasActive, + LockPath: lockPath, + Lock: ownedLock, + IdleTimeout: idle, + OnReady: onReady, + ActivityCh: rm.ActivitySignal(), + ActiveFunc: rm.HasActive, + UndrainedFunc: spool.HasPending, }) cancelInFlightRuns(rm, shutdownCancelTimeout) cancelServe() @@ -155,9 +156,29 @@ func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, done <- runErr }() + go replayPendingSubmissions(runCtx, rm, reviewStore, spool) + return rm, done } +func replayPendingSubmissions(ctx context.Context, rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool) { + handler := gateNotifyPushHandler(rm, reviewDecisions, spool) + for _, submission := range spool.Pending() { + params, err := json.Marshal(gateNotifyPushParams{ + GatePath: submission.Gate, + Ref: submission.Ref, + NewSHA: submission.SHA, + RunID: submission.RunID, + }) + if err != nil { + return + } + if _, err := handler(ctx, params); err != nil { + continue + } + } +} + // cancelInFlightRuns runs between daemon.Run returning (SIGTERM, ctx // cancellation, or idle timeout) and the socket server closing, so that // `made daemon stop` never leaves an orphaned pipeline goroutine behind: a @@ -194,13 +215,11 @@ func isTerminalRunStatus(s daemon.RunStatus) bool { const debugHandlersEnv = "MADE_DEBUG_HANDLERS" func registerDaemonHandlers(srv *api.Server, rm *daemon.RunManager, store *daemon.ReviewDecisions, spool *daemon.GateSpool, cancel context.CancelFunc) { - srv.Handle("status", statusHandler(rm)) srv.Handle("run.status", runStatusHandler(rm)) srv.Handle("run.submit", runSubmitHandler(rm)) srv.Handle("run.list", runListHandler(rm)) srv.Handle("run.cancel", runCancelHandler(rm)) srv.Handle("review.decide", reviewDecideRunHandler(rm, store)) - srv.Handle("review.decision", reviewDecisionHandler(store)) srv.Handle("daemon.shutdown", daemonShutdownHandler(rm, spool, cancel)) srv.Handle("gate.admitPush", gateAdmitPushHandler()) srv.Handle("gate.notifyPush", gateNotifyPushHandler(rm, store, spool)) @@ -276,6 +295,7 @@ type gateNotifyPushParams struct { OldSHA string `json:"old_sha"` NewSHA string `json:"new_sha"` Ref string `json:"ref"` + RunID string `json:"run_id,omitempty"` } type gateNotifyPushResult struct { @@ -324,15 +344,29 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review gatePath := p.GatePath worktreesDir := gitgate.WorktreesDir(gatePath) newSHA := p.NewSHA - runID := rm.NewRunID() + runID := p.RunID + if runID == "" { + runID = rm.NewRunID() + } submission, created, err := spool.Enqueue(daemon.GateSubmission{Gate: p.GatePath, Ref: p.Ref, SHA: p.NewSHA, RunID: runID}) if err != nil { return nil, fmt.Errorf("gate.notifyPush: enqueue submission: %w", err) } if !created { - return gateNotifyPushResult{RunID: submission.RunID}, nil + if _, ok := rm.Snapshot(submission.RunID); ok { + if err := rm.AppendSubmissionEvent(submission.RunID, daemon.SubmissionEvent{Gate: p.GatePath, Ref: p.Ref, InputSHA: p.NewSHA, Kind: "push"}); err != nil { + return nil, fmt.Errorf("gate.notifyPush: persist replayed submission event: %w", err) + } + if err := spool.Drain(submission); err != nil { + return nil, fmt.Errorf("gate.notifyPush: drain replayed submission: %w", err) + } + return gateNotifyPushResult{RunID: submission.RunID}, nil + } + runID = submission.RunID + } + if err := rm.SupersedeQueued(repo, branch); err != nil { + return nil, fmt.Errorf("gate.notifyPush: supersede queued runs: %w", err) } - rm.SupersedeQueued(repo, branch) work := func(workCtx context.Context, emit func(daemon.Event)) error { return orchestrator.Run(workCtx, gatePath, defaultBranch, worktreesDir, runID, newSHA, diff --git a/cmd/made/daemon_test.go b/cmd/made/daemon_test.go index 90a1de5..75d5056 100644 --- a/cmd/made/daemon_test.go +++ b/cmd/made/daemon_test.go @@ -95,7 +95,7 @@ func TestDaemonStop_CancelsInFlightRunBeforeProcessExits(t *testing.T) { deadline := time.Now().Add(10 * time.Second) for { var report StatusReport - if err := client.CallInto("status", statusParams{RunID: runID}, &report); err != nil { + if err := client.CallInto("run.status", statusParams{RunID: runID}, &report); err != nil { t.Fatalf("status: %v", err) } if report.State == "running" { diff --git a/cmd/made/remediation_contract_test.go b/cmd/made/remediation_contract_test.go index 567e3c4..65bf133 100644 --- a/cmd/made/remediation_contract_test.go +++ b/cmd/made/remediation_contract_test.go @@ -79,6 +79,14 @@ func TestRun_SubmitJSONReturnsExactRunIDAndImmutableInputHead(t *testing.T) { } } +func TestRunSubmit_RejectsInvalidOutputSHA(t *testing.T) { + rm := daemon.NewRunManager() + _, err := runSubmitHandler(rm)(context.Background(), []byte(`{"repo":"/repo","branch":"feature","input_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","output_sha":"not-a-sha"}`)) + if err == nil { + t.Fatal("run.submit accepted an invalid output_sha") + } +} + func TestStatusHandler_RequiresExactRunID(t *testing.T) { rm := daemon.NewRunManager() started := make(chan struct{}) @@ -125,8 +133,8 @@ func TestStatusReport_JSONHasFixedDurableRunSchema(t *testing.T) { } func TestReviewDecide_RejectsUnknownExactRunID(t *testing.T) { - store := newReviewDecisions() - _, err := reviewDecideHandler(store)(context.Background(), []byte(`{"run_id":"missing","stage":"review","decision":"approved"}`)) + store := daemon.NewReviewDecisions() + _, err := reviewDecideRunHandler(daemon.NewRunManager(), store)(context.Background(), []byte(`{"run_id":"missing","stage":"review","decision":"approved"}`)) if err == nil { t.Fatal("review.decide accepted a decision for an unknown exact run ID") } diff --git a/cmd/made/remediation_process_contract_test.go b/cmd/made/remediation_process_contract_test.go index 34d35e3..861cff2 100644 --- a/cmd/made/remediation_process_contract_test.go +++ b/cmd/made/remediation_process_contract_test.go @@ -73,7 +73,7 @@ func TestRunStateSurvivesDaemonRestart(t *testing.T) { } defer func() { _ = client2.Close() }() var status StatusReport - if err := client2.CallInto("status", statusParams{RunID: runID}, &status); err != nil { + if err := client2.CallInto("run.status", statusParams{RunID: runID}, &status); err != nil { t.Fatalf("status after restart: %v", err) } if status.RunID != runID { @@ -81,6 +81,59 @@ func TestRunStateSurvivesDaemonRestart(t *testing.T) { } } +func TestGateSubmissionSpoolReplaysAfterDaemonRestart(t *testing.T) { + home := shortTempDir(t) + barePath, sourceDir := setupGateFixture(t, home) + testGit(t, sourceDir, "checkout", "-b", "feature-replay") + sha := pushFeatureCommit(t, sourceDir, "feature-replay", "replayed\n", "replayed gate submission") + runID := "123e4567-e89b-12d3-a456-426614174004" + + spoolPath := filepath.Join(home, "gate.spool") + spool, err := daemon.OpenGateSpool(spoolPath) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + if _, created, err := spool.Enqueue(daemon.GateSubmission{ + Gate: barePath, Ref: "refs/heads/feature-replay", SHA: sha, RunID: runID, + }); err != nil || !created { + t.Fatalf("seed pending gate submission: created=%v err=%v", created, err) + } + + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan int, 1) + rm, done := startDaemon(ctx, home, filepath.Join(home, "daemon.lock"), time.Minute, func(pid int) { ready <- pid }) + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("daemon did not stop after replay test") + } + }) + select { + case <-ready: + case err := <-done: + t.Fatalf("daemon stopped before replay: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("daemon did not become ready for replay") + } + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if snapshot, ok := rm.Snapshot(runID); ok && snapshot.Branch == "feature-replay" { + reopened, err := daemon.OpenGateSpool(spoolPath) + if err != nil { + t.Fatalf("reopen gate spool: %v", err) + } + if !reopened.HasPending() { + return + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("pending gate submission was not replayed, runs=%+v", rm.List()) +} + func TestStartDaemon_DuplicatePreservesOriginalSocketOwner(t *testing.T) { home := shortTempDir(t) firstCtx, firstCancel := context.WithCancel(context.Background()) @@ -142,6 +195,38 @@ func TestStartDaemon_DuplicatePreservesOriginalSocketOwner(t *testing.T) { } } +func TestDaemonRejectsObsoleteUnversionedRPCs(t *testing.T) { + home := shortTempDir(t) + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan int, 1) + _, done := startDaemon(ctx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { ready <- pid }) + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("daemon did not stop during cleanup") + } + }) + select { + case <-ready: + case err := <-done: + t.Fatalf("daemon stopped before RPC probe: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("daemon did not become ready") + } + client, err := api.Dial(api.SocketPath(home)) + if err != nil { + t.Fatalf("dial daemon: %v", err) + } + defer func() { _ = client.Close() }() + for _, method := range []string{"status", "review.decision"} { + if _, err := client.Call(method, nil); err == nil { + t.Fatalf("obsolete RPC %q was still registered", method) + } + } +} + func TestHermeticCompatibility_RealMadeBinaryThroughConsigliereScript(t *testing.T) { root := repoRoot(t) consigliereRoot := "/Users/douglasjarquin/github/consigliere" diff --git a/cmd/made/review.go b/cmd/made/review.go index 055903c..345f9f4 100644 --- a/cmd/made/review.go +++ b/cmd/made/review.go @@ -1,14 +1,11 @@ package main import ( - "bufio" "context" "encoding/json" "flag" "fmt" - "io" "os" - "strings" "github.com/douglasjarquin/made/internal/api" "github.com/douglasjarquin/made/internal/daemon" @@ -19,58 +16,14 @@ const ( ReviewRejected = daemon.ReviewRejected ) -// reviewDecisions now lives in internal/daemon (co-located with RunManager, -// since a decision is per-run state) so Task 12's orchestrator can reach the -// same store these RPC handlers use; this alias keeps the RPC-facing code -// below unchanged. -type reviewDecisions = daemon.ReviewDecisions - -func newReviewDecisions() *reviewDecisions { - return daemon.NewReviewDecisions() -} - type reviewDecideParams struct { RunID string `json:"run_id"` Stage string `json:"stage"` Decision string `json:"decision"` } -type reviewDecideResult struct { - OK bool `json:"ok"` -} - -type reviewDecisionParams struct { - RunID string `json:"run_id"` - Stage string `json:"stage"` -} - -type reviewDecisionResult struct { - Decision string `json:"decision"` - Found bool `json:"found"` -} - -func reviewDecideHandler(store *reviewDecisions) api.HandlerFunc { - return func(ctx context.Context, params json.RawMessage) (any, error) { - var p reviewDecideParams - if err := json.Unmarshal(params, &p); err != nil { - return nil, fmt.Errorf("review.decide: invalid params: %w", err) - } - if p.RunID == "" || p.Stage == "" { - return nil, fmt.Errorf("review.decide: run_id and stage are required") - } - if !store.HasRun(p.RunID) { - return nil, fmt.Errorf("review.decide: exact run_id %q was not found", p.RunID) - } - if p.Decision != ReviewApproved && p.Decision != ReviewRejected { - return nil, fmt.Errorf("review.decide: decision must be %q or %q", ReviewApproved, ReviewRejected) - } - store.Set(p.RunID, p.Stage, p.Decision) - return reviewDecideResult{OK: true}, nil - } -} - -func reviewDecideRunHandler(rm *daemon.RunManager, store *reviewDecisions) api.HandlerFunc { - return func(ctx context.Context, params json.RawMessage) (any, error) { +func reviewDecideRunHandler(rm *daemon.RunManager, store *daemon.ReviewDecisions) api.HandlerFunc { + return func(_ context.Context, params json.RawMessage) (any, error) { var p reviewDecideParams if err := json.Unmarshal(params, &p); err != nil { return nil, fmt.Errorf("review.decide: invalid params: %w", err) @@ -88,88 +41,10 @@ func reviewDecideRunHandler(rm *daemon.RunManager, store *reviewDecisions) api.H return nil, err } store.Set(p.RunID, p.Stage, p.Decision) - return reviewDecideResult{OK: true}, nil + return map[string]any{"ok": true}, nil } } -func reviewDecisionHandler(store *reviewDecisions) api.HandlerFunc { - return func(ctx context.Context, params json.RawMessage) (any, error) { - var p reviewDecisionParams - if err := json.Unmarshal(params, &p); err != nil { - return nil, fmt.Errorf("review.decision: invalid params: %w", err) - } - decision, found := store.Get(p.RunID, p.Stage) - return reviewDecisionResult{Decision: decision, Found: found}, nil - } -} - -func runReviewCommand(args []string, stdin io.Reader, stdout, stderr *os.File) int { - fs := flag.NewFlagSet("made review", flag.ContinueOnError) - fs.SetOutput(stderr) - runID := fs.String("run", "", "run ID to review (default: most recent run)") - if err := fs.Parse(args); err != nil { - return 2 - } - - home, err := madeHome() - if err != nil { - _, _ = fmt.Fprintln(stderr, "made review:", err) - return 1 - } - - client, err := api.Dial(api.SocketPath(home)) - if err != nil { - _, _ = fmt.Fprintln(stderr, "made review: daemon not reachable:", err) - return 1 - } - defer func() { _ = client.Close() }() - - var report StatusReport - if err := client.CallInto("status", statusParams{RunID: *runID}, &report); err != nil { - _, _ = fmt.Fprintln(stderr, "made review:", err) - return 1 - } - - if len(report.PendingFindings) == 0 { - _, _ = fmt.Fprintln(stdout, "made review: no pending findings") - return 0 - } - - scanner := bufio.NewScanner(stdin) - anyRejected := false - for _, f := range report.PendingFindings { - _, _ = fmt.Fprintf(stdout, "[%s] %s\n", f.Stage, f.Message) - _, _ = fmt.Fprint(stdout, "approve/reject? [a/r]: ") - - decision, err := readDecision(scanner) - if err != nil { - _, _ = fmt.Fprintln(stderr, "made review:", err) - return 1 - } - - if err := client.CallInto("review.decide", reviewDecideParams{ - RunID: report.RunID, - Stage: f.Stage, - Decision: decision, - }, nil); err != nil { - _, _ = fmt.Fprintln(stderr, "made review:", err) - return 1 - } - - _, _ = fmt.Fprintf(stdout, "%s: %s\n", decision, f.Stage) - if decision == ReviewRejected { - anyRejected = true - } - } - - if anyRejected { - _, _ = fmt.Fprintln(stdout, "made review: one or more findings rejected; pipeline halted") - return 1 - } - _, _ = fmt.Fprintln(stdout, "made review: all findings approved; pipeline resumed") - return 0 -} - func runReviewDecideCommand(args []string, stdout, stderr *os.File) int { fs := flag.NewFlagSet("made review decide", flag.ContinueOnError) fs.SetOutput(stderr) @@ -200,18 +75,3 @@ func runReviewDecideCommand(args []string, stdout, stderr *os.File) int { } return writeJSON(stdout, map[string]any{"schema_version": 1, "protocol_version": api.Version, "run_id": fs.Arg(0), "stage": *stage, "decision": *decision}, stderr, "made review decide") } - -func readDecision(scanner *bufio.Scanner) (string, error) { - for scanner.Scan() { - switch strings.TrimSpace(strings.ToLower(scanner.Text())) { - case "a", "approve": - return ReviewApproved, nil - case "r", "reject": - return ReviewRejected, nil - } - } - if err := scanner.Err(); err != nil { - return "", fmt.Errorf("read decision: %w", err) - } - return "", fmt.Errorf("no approve/reject decision provided") -} diff --git a/cmd/made/review_test.go b/cmd/made/review_test.go deleted file mode 100644 index 4b128ba..0000000 --- a/cmd/made/review_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "io" - "os" - "strings" - "testing" - - "github.com/douglasjarquin/made/internal/api" -) - -// startReviewTestServer fakes only the "status" handler's PendingFindings -// (real runs never populate that field yet, per status.go) while wiring the -// real review.decide/review.decision handlers, so the round trip under test -// is genuine except for the one field no orchestrator produces yet. -func startReviewTestServer(t *testing.T, fixture StatusReport) string { - t.Helper() - - home := shortTempDir(t) - socketPath := api.SocketPath(home) - - srv := api.NewServer(socketPath) - srv.Handle("status", func(ctx context.Context, params json.RawMessage) (any, error) { - return fixture, nil - }) - store := newReviewDecisions() - store.RegisterRun(fixture.RunID) - srv.Handle("review.decide", reviewDecideHandler(store)) - srv.Handle("review.decision", reviewDecisionHandler(store)) - - if err := srv.Listen(); err != nil { - t.Fatalf("Listen: %v", err) - } - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { done <- srv.Serve(ctx) }() - t.Cleanup(func() { - cancel() - <-done - _ = srv.Close() - }) - - t.Setenv("MADE_HOME", home) - return home -} - -func queryDecision(t *testing.T, home, runID, stage string) (string, bool) { - t.Helper() - client, err := api.Dial(api.SocketPath(home)) - if err != nil { - t.Fatalf("Dial: %v", err) - } - defer func() { _ = client.Close() }() - - var result reviewDecisionResult - if err := client.CallInto("review.decision", reviewDecisionParams{RunID: runID, Stage: stage}, &result); err != nil { - t.Fatalf("review.decision: %v", err) - } - return result.Decision, result.Found -} - -func runReviewCapture(t *testing.T, args []string, stdin string) (stdout, stderr []byte, code int) { - t.Helper() - - outR, outW, err := os.Pipe() - if err != nil { - t.Fatalf("pipe: %v", err) - } - errR, errW, err := os.Pipe() - if err != nil { - t.Fatalf("pipe: %v", err) - } - - outCh := make(chan []byte, 1) - errCh := make(chan []byte, 1) - go func() { - b, _ := io.ReadAll(outR) - outCh <- b - }() - go func() { - b, _ := io.ReadAll(errR) - errCh <- b - }() - - code = runReviewCommand(args, strings.NewReader(stdin), outW, errW) - _ = outW.Close() - _ = errW.Close() - - return <-outCh, <-errCh, code -} - -func TestReview_ApproveResumes(t *testing.T) { - fixture := StatusReport{ - SchemaVersion: statusSchemaVersion, - RunID: "run-approve-1", - Repo: "example/repo", - Branch: "feature-x", - State: "running", - Stages: []StageResult{{Name: "review", Result: StageResultPending}}, - PendingFindings: []AskUserFinding{ - {Stage: "review", Message: "Should this helper be exported?"}, - }, - } - home := startReviewTestServer(t, fixture) - - out, errOut, code := runReviewCapture(t, []string{"--run", "run-approve-1"}, "a\n") - if code != 0 { - t.Fatalf("exit code = %d, want 0; stdout=%s stderr=%s", code, out, errOut) - } - if !strings.Contains(string(out), "review") || !strings.Contains(string(out), "Should this helper be exported?") { - t.Errorf("stdout missing finding text: %s", out) - } - - decision, found := queryDecision(t, home, "run-approve-1", "review") - if !found { - t.Fatal("decision not recorded") - } - if decision != ReviewApproved { - t.Errorf("decision = %q, want %q", decision, ReviewApproved) - } -} - -func TestReview_RejectHalts(t *testing.T) { - fixture := StatusReport{ - SchemaVersion: statusSchemaVersion, - RunID: "run-reject-1", - Repo: "example/repo", - Branch: "feature-x", - State: "running", - Stages: []StageResult{{Name: "document", Result: StageResultPending}}, - PendingFindings: []AskUserFinding{ - {Stage: "document", Message: "Does this doc change need a changelog entry?"}, - }, - } - home := startReviewTestServer(t, fixture) - - out, errOut, code := runReviewCapture(t, []string{"--run", "run-reject-1"}, "r\n") - if code == 0 { - t.Fatalf("exit code = %d, want non-zero on rejection; stdout=%s stderr=%s", code, out, errOut) - } - - decision, found := queryDecision(t, home, "run-reject-1", "document") - if !found { - t.Fatal("decision not recorded") - } - if decision != ReviewRejected { - t.Errorf("decision = %q, want %q", decision, ReviewRejected) - } -} - -func TestReview_NoPendingFindings(t *testing.T) { - fixture := StatusReport{ - SchemaVersion: statusSchemaVersion, - RunID: "run-clean-1", - Repo: "example/repo", - Branch: "feature-x", - State: "completed", - Stages: []StageResult{{Name: "review", Result: StageResultPass}}, - PendingFindings: []AskUserFinding{}, - } - startReviewTestServer(t, fixture) - - out, errOut, code := runReviewCapture(t, []string{"--run", "run-clean-1"}, "") - if code != 0 { - t.Fatalf("exit code = %d, want 0; stdout=%s stderr=%s", code, out, errOut) - } - if !strings.Contains(string(out), "no pending findings") { - t.Errorf("stdout = %s, want mention of no pending findings", out) - } -} diff --git a/cmd/made/runhandlers.go b/cmd/made/runhandlers.go index dce7f53..9f352b4 100644 --- a/cmd/made/runhandlers.go +++ b/cmd/made/runhandlers.go @@ -33,8 +33,8 @@ func runSubmitHandler(rm *daemon.RunManager) api.HandlerFunc { if err := json.Unmarshal(params, &p); err != nil { return nil, fmt.Errorf("run.submit: invalid params: %w", err) } - if strings.TrimSpace(p.Repo) == "" || strings.TrimSpace(p.Branch) == "" || !validSHA(p.InputSHA) { - return nil, fmt.Errorf("run.submit: repo, branch, and a 40-character input_sha are required") + if strings.TrimSpace(p.Repo) == "" || strings.TrimSpace(p.Branch) == "" || !validSHA(p.InputSHA) || (p.OutputSHA != "" && !validSHA(p.OutputSHA)) { + return nil, fmt.Errorf("run.submit: repo, branch, input_sha, and optional output_sha must use valid 40-character SHAs") } if p.RunID == "" { p.RunID = rm.NewRunID() diff --git a/cmd/made/status.go b/cmd/made/status.go index 3969201..400bc89 100644 --- a/cmd/made/status.go +++ b/cmd/made/status.go @@ -25,13 +25,11 @@ var pipelineStages = []string{ "intent", "rebase", "review", "test", "document", "lint", "push", "pr", "ci", } -// StatusReport is the schema for `made status --json`, replacing -// no-mistakes' TOON status output for downstream consumers (Task 26). Stages -// and PendingFindings come straight from daemon.RunSnapshot; until an -// orchestrator (this plan's Task 12) actually calls UpdateStages/ -// UpdatePendingFindings on a run, Stages falls back to an all-"pending" list -// over the fixed 9-stage order and PendingFindings falls back to empty, so -// callers can integrate against the shape before real orchestration lands. +// StatusReport is the schema for `made run status --json`. Stages and +// PendingFindings come straight from daemon.RunSnapshot; until an orchestrator +// calls UpdateStages/UpdatePendingFindings on a run, Stages falls back to an +// all-"pending" list over the fixed 9-stage order and PendingFindings falls +// back to empty. type StatusReport struct { SchemaVersion int `json:"schema_version"` ProtocolVersion int `json:"protocol_version"` @@ -74,24 +72,17 @@ func statusHandler(rm *daemon.RunManager) api.HandlerFunc { } } - snap, ok := resolveRun(rm, p.RunID) + if p.RunID == "" { + return nil, fmt.Errorf("run.status: run_id is required") + } + snap, ok := rm.Snapshot(p.RunID) if !ok { - return nil, fmt.Errorf("status: exact run_id %q was not found", p.RunID) + return nil, fmt.Errorf("run.status: exact run_id %q was not found", p.RunID) } return newStatusReport(snap), nil } } -func resolveRun(rm *daemon.RunManager, runID string) (daemon.RunSnapshot, bool) { - if runID != "" { - return rm.Snapshot(runID) - } - if runID == "" { - return daemon.RunSnapshot{}, false - } - return rm.Snapshot(runID) -} - func newStatusReport(snap daemon.RunSnapshot) StatusReport { stages := snap.Stages if len(stages) == 0 { diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md new file mode 100644 index 0000000..6752da0 --- /dev/null +++ b/docs/remediation/made-remediation-p1p3b.md @@ -0,0 +1,111 @@ +# Made remediation phases 1-3 delivery report + +This report records the Made-owned remediation delivery from custody base `3e19ed9d598a68149da5a73949533e8095ca4403` through the direct-PR handoff. + +The work was performed only in `/Users/douglasjarquin/.herdr/worktrees/made/cs-made-remediation-p1p3b` on branch `cs/made-remediation-p1p3b`. + +The failed-launch custody branch remained untouched and the shared Made daemon was never restarted or stopped. + +## Isolation and baseline + +The isolation commands were `pwd -P`, `git rev-parse --show-toplevel`, `git branch --show-current`, and `git rev-parse HEAD`. + +They resolved to the disposable Herdr worktree, branch `cs/made-remediation-p1p3b`, and base SHA `3e19ed9d598a68149da5a73949533e8095ca4403`. + +The baseline toolchain was Go `1.26.6 darwin/arm64` and golangci-lint `2.11.2`. + +The baseline normal, race, vet, and lint commands passed after applying the process-local signing override `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false`. + +The signing override was needed because the inherited global SSH signing configuration requires an unavailable 1Password socket. + +The installed Codex CLI was `/opt/homebrew/bin/codex` version `codex-cli 0.147.0`. + +The supported invocation is `codex exec --cd --json --output-schema -`. + +## Phase 1 RED contract + +The red command was `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test -count=1 ./...`. + +The command exited `1` before production changes and its output was captured in `/tmp/made-remediation-p1p3b-red.log`. + +The red failures covered unknown versioned commands, global-latest status fallback, missing schema fields, unknown decisions, restart loss, missing doctor JSON, obsolete Codex invocation, destructive socket cleanup, unversioned and zero-value configuration, predecessor lifecycle values, restart-unsafe IDs, incorrect idle handling for `awaiting_merge`, stale PID shutdown, evidence traversal and size bounds, PR URLs used where workflow run IDs are required, authentication classified as a failed check, duplicate PR creation, incorrect rebase conflict classification, and dirty auto-fixes. + +Each failure exercised a production boundary with a strict assertion on the required observable behavior, so the failure was a missing implementation contract rather than a permissive fixture mismatch. + +The compatibility fake GitHub boundary rejected PR URLs where workflow run IDs were required and modeled check status, conclusion, workflow run ID, and details URL. + +The fake Codex boundary accepted only the installed `codex exec --cd --json --output-schema` invocation and strict structured output. + +The old mocks were updated or removed so they do not authorize commands that the real Made binary does not implement. + +## Phase 2 implementation and GREEN + +The initial implementation commit was `f92baaf345dea88a907e29e8727aa6d937902df9` with subject `feat: deliver versioned durable remediation contract`. + +The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. + +The daemon persists complete run snapshots in an fsync-backed append-only WAL and persists idempotent gate submissions in an fsync-backed spool keyed by gate, ref, and SHA. + +The public surface is versioned and structured through `made capabilities --json`, `made run submit`, `made run status`, `made run list`, `made run cancel`, `made review decide`, and `made doctor --json`. + +The lifecycle states are `queued`, `running`, `awaiting_review`, `awaiting_merge`, `succeeded`, `failed`, `canceled`, and `superseded`. + +Execution completion is represented separately by `execution_finished`. + +Cancellation requires an exact run ID, is idempotent for an already canceled run, waits for cooperative execution to finish at the CLI boundary, and refuses unknown or unrelated runs. + +Restored queued and running snapshots are reconciled to durable failed state after a daemon restart because no worker can safely resume execution without a durable work specification. + +Pending gate submissions are replayed on daemon startup and remain undrained when their external boundary is unavailable. + +The orchestrator records stages, disabled stages as `skipped`, findings, decisions, PR URL, errors, supersession, cancellation, and submission events. + +## Phase 3 implementation and GREEN + +The `.made.yml` boundary is versioned, strictly decoded, and rejects unknown or zero-value configuration. + +The pipeline refreshes the real remote default branch before trusted policy or rebase decisions. + +The review adapter validates a Made-owned schema and uses the installed structured Codex invocation. + +Auto-fixes require a clean state, require explicitly returned tracked paths, reject forbidden or untracked paths, record pre-fix and post-fix SHAs, and rerun relevant validation. + +Rebase failures are classified as conflicts only when unmerged paths exist. + +Evidence is run- and stage-specific, bounded, redacted, symlink-safe, and published only through accessible paths. + +Pull request creation is idempotent by repository, base, and head. + +CI polling uses actual check status, conclusion, workflow run ID, and details URL, while authentication and API failures are infrastructure failures. + +The Made CI workflow validates the pinned Go version with race, vet, and pinned lint jobs. + +## Validation evidence + +The targeted command `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test ./internal/daemon ./internal/orchestrator ./cmd/made` passed after the durability fixes. + +The final validation set is `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test -count=1 ./...`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test -race -shuffle=on -count=1 ./...`, `GOTOOLCHAIN=local go vet ./...`, and `GOTOOLCHAIN=local golangci-lint run --timeout=5m`. + +The real-process manual QA transcript is `/tmp/made-remediation-p1p3b-manual-final.log` and its final marker was `manual-qa-final=PASS`. + +That scenario used a fresh binary and task-local Made homes to observe capabilities, doctor through the real Consigliere script, exact submission and SHA preservation, exact status and active-list queries, review decision, cancellation, shutdown refusal, WAL restart, duplicate singleton start, stale PID handling, regular-file, symlink, and directory socket rejection, and predecessor command rejection. + +The first manual cancellation run returned `running` before the worker completed, which falsified the CLI response contract. + +The cancellation wait fix returned `canceled` with `execution_finished=true` in the counterfactual rerun. + +The first full validation exposed a WAL replay ordering race where a stale `succeeded` snapshot could be appended after a newer `awaiting_merge` snapshot. + +Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. + +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403`. + +The implementation and contract-test paths in that diff are `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. + +No Consigliere repository file, GitHub issue, default branch, merge, or shared daemon state was changed. + +## Delivery dependency + +The remaining dependency after this report is the direct PR on `cs/made-remediation-p1p3b` against `main`. + +The branch must be committed, pushed only to `origin/cs/made-remediation-p1p3b`, and opened as a direct PR before the Made lane reports done. diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 9f54cab..61e1681 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -29,7 +29,7 @@ func TestSpawn_ParsesFindingsFromFakeAgent(t *testing.T) { bin := agenttest.Build(t) scenarioPath := writeScenario(t, agent.Findings{ Findings: []agent.Finding{ - {Kind: agent.FindingAutoFixable, Description: "fix formatting", Patch: "diff --git a/x b/x\n"}, + {Kind: agent.FindingAutoFixable, Description: "fix formatting", Patch: "diff --git a/x b/x\n", Paths: []string{"x"}}, {Kind: agent.FindingAskUser, Description: "consider renaming Foo"}, }, }) diff --git a/internal/agent/spawn.go b/internal/agent/spawn.go index 366e562..310ebfc 100644 --- a/internal/agent/spawn.go +++ b/internal/agent/spawn.go @@ -133,6 +133,9 @@ func strictFindings(data []byte) (Findings, error) { if strings.TrimSpace(finding.Patch) == "" { return Findings{}, fmt.Errorf("auto-fixable finding patch is required") } + if len(finding.Paths) == 0 { + return Findings{}, fmt.Errorf("auto-fixable finding paths are required") + } case FindingAskUser, FindingBlocking: default: return Findings{}, fmt.Errorf("unknown finding kind %q", finding.Kind) diff --git a/internal/api/remediation_contract_test.go b/internal/api/remediation_contract_test.go index 99d3ae4..690c975 100644 --- a/internal/api/remediation_contract_test.go +++ b/internal/api/remediation_contract_test.go @@ -131,6 +131,32 @@ func TestServer_DuplicateListenPreservesOriginalOwner(t *testing.T) { } } +func TestServer_CloseDoesNotRemoveSuccessorSocket(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + first := api.NewServer(path) + if err := first.Listen(); err != nil { + t.Fatalf("first Listen: %v", err) + } + if err := os.Remove(path); err != nil { + t.Fatalf("remove first socket for handoff fixture: %v", err) + } + second := api.NewServer(path) + if err := second.Listen(); err != nil { + t.Fatalf("successor Listen: %v", err) + } + defer func() { _ = second.Close() }() + secondCtx, secondCancel := context.WithTimeout(context.Background(), time.Second) + defer secondCancel() + go func() { _ = second.Serve(secondCtx) }() + + if err := first.Close(); err != nil { + t.Fatalf("first Close: %v", err) + } + if err := waitForPing(path); err != nil { + t.Fatalf("successor socket was removed by first Close: %v", err) + } +} + func waitForPing(path string) error { conn, err := net.DialTimeout("unix", path, 100*time.Millisecond) if err != nil { diff --git a/internal/api/server.go b/internal/api/server.go index 30896ab..6b9d90c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -15,6 +15,7 @@ type HandlerFunc func(ctx context.Context, params json.RawMessage) (any, error) type Server struct { socketPath string ln net.Listener + socketInfo os.FileInfo mu sync.RWMutex handlers map[string]HandlerFunc @@ -53,8 +54,17 @@ func (s *Server) Listen() error { _ = ln.Close() return fmt.Errorf("chmod %s: %w", s.socketPath, err) } + if unixListener, ok := ln.(*net.UnixListener); ok { + unixListener.SetUnlinkOnClose(false) + } s.ln = ln + info, err := os.Lstat(s.socketPath) + if err != nil { + _ = ln.Close() + return fmt.Errorf("stat listened socket %s: %w", s.socketPath, err) + } + s.socketInfo = info return nil } @@ -107,7 +117,7 @@ func (s *Server) Close() error { return nil } err := s.ln.Close() - if info, statErr := os.Lstat(s.socketPath); statErr == nil && info.Mode()&os.ModeSocket != 0 { + if info, statErr := os.Lstat(s.socketPath); statErr == nil && os.SameFile(s.socketInfo, info) && info.Mode()&os.ModeSocket != 0 { _ = os.Remove(s.socketPath) } return err diff --git a/internal/daemon/contract.go b/internal/daemon/contract.go index 1c51404..6848db0 100644 --- a/internal/daemon/contract.go +++ b/internal/daemon/contract.go @@ -29,7 +29,9 @@ func (rm *RunManager) SetDecision(id, stage, decision string) error { } snapshot.Decisions[stage] = decision }) - rm.persist(r) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist decision for run %q: %w", id, err) + } return nil } @@ -39,7 +41,9 @@ func (rm *RunManager) SetPRURL(id, prURL string) error { return fmt.Errorf("daemon: no run %q", id) } r.update(func(snapshot *RunSnapshot) { snapshot.PRURL = prURL }) - rm.persist(r) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist PR URL for run %q: %w", id, err) + } return nil } @@ -51,7 +55,9 @@ func (rm *RunManager) AddFindings(id string, findings []RunFinding) error { r.update(func(snapshot *RunSnapshot) { snapshot.Findings = append(snapshot.Findings, findings...) }) - rm.persist(r) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist findings for run %q: %w", id, err) + } return nil } @@ -71,6 +77,8 @@ func (rm *RunManager) AppendSubmissionEvent(id string, event SubmissionEvent) er } snapshot.SubmissionEvents = append(snapshot.SubmissionEvents, event) }) - rm.persist(r) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist submission event for run %q: %w", id, err) + } return nil } diff --git a/internal/daemon/durable_contract_test.go b/internal/daemon/durable_contract_test.go index e654462..464ae75 100644 --- a/internal/daemon/durable_contract_test.go +++ b/internal/daemon/durable_contract_test.go @@ -3,6 +3,7 @@ package daemon import ( "context" "testing" + "time" ) func TestPersistentRunStateIncludesSubmissionAndDecisionData(t *testing.T) { @@ -66,6 +67,9 @@ func TestGateSpoolIsIdempotentAndDurable(t *testing.T) { if !reopened.HasPending() { t.Fatal("pending submission disappeared after restart") } + if pending := reopened.Pending(); len(pending) != 1 || pending[0] != submission { + t.Fatalf("Pending returned %+v, want %+v", pending, submission) + } if err := reopened.Drain(submission); err != nil { t.Fatalf("Drain: %v", err) } @@ -73,3 +77,42 @@ func TestGateSpoolIsIdempotentAndDurable(t *testing.T) { t.Fatal("drained submission remained pending") } } + +func TestPersistentRunManagerReconcilesUnfinishedExecutionAfterRestart(t *testing.T) { + path := t.TempDir() + "/runs.wal" + store, _, err := OpenRunStore(path) + if err != nil { + t.Fatalf("OpenRunStore: %v", err) + } + id := "123e4567-e89b-12d3-a456-426614174003" + if err := store.Append(RunSnapshot{ + ID: id, + Repo: "repo", + Branch: "feature", + Status: RunRunning, + QueuedAt: time.Now().Add(-time.Minute), + StartedAt: time.Now().Add(-30 * time.Second), + }); err != nil { + t.Fatalf("seed unfinished snapshot: %v", err) + } + + rm, err := NewPersistentRunManager(path) + if err != nil { + t.Fatalf("NewPersistentRunManager: %v", err) + } + snapshot, ok := rm.Snapshot(id) + if !ok { + t.Fatal("reconciled run missing") + } + if snapshot.Status != RunFailed || !snapshot.ExecutionFinished || snapshot.Err == nil { + t.Fatalf("unfinished run was not reconciled to durable failure: %+v", snapshot) + } + + restarted, err := NewPersistentRunManager(path) + if err != nil { + t.Fatalf("reopen reconciled store: %v", err) + } + if snapshot, _ := restarted.Snapshot(id); snapshot.Status != RunFailed || !snapshot.ExecutionFinished { + t.Fatalf("reconciled failure was not durable: %+v", snapshot) + } +} diff --git a/internal/daemon/remediation_contract_test.go b/internal/daemon/remediation_contract_test.go index b0baa59..c41a53b 100644 --- a/internal/daemon/remediation_contract_test.go +++ b/internal/daemon/remediation_contract_test.go @@ -81,6 +81,35 @@ func TestRunManager_CancelUsesCanceledLifecycleAndIsIdempotent(t *testing.T) { } } +func TestRunManager_CancelAwaitingReviewStopsBlockedWorker(t *testing.T) { + rm := NewRunManager() + id := rm.NewRunID() + workerStopped := make(chan struct{}) + if _, err := rm.Submit(id, "repo-awaiting-review", "feature", func(ctx context.Context, _ func(Event)) error { + if err := rm.UpdatePendingFindings(id, []AskUserFinding{{Stage: "review", Message: "approve"}}); err != nil { + return err + } + <-ctx.Done() + close(workerStopped) + return ctx.Err() + }); err != nil { + t.Fatalf("Submit: %v", err) + } + waitForStatus(t, rm, id, RunAwaitingReview, 2*time.Second) + if err := rm.Cancel(id); err != nil { + t.Fatalf("Cancel awaiting review: %v", err) + } + select { + case <-workerStopped: + case <-time.After(2 * time.Second): + t.Fatal("awaiting-review worker did not observe cancellation") + } + final := waitForStatus(t, rm, id, RunCanceled, 2*time.Second) + if !final.ExecutionFinished { + t.Fatal("canceled awaiting-review run did not record execution_finished") + } +} + func TestRunManager_SupersedeUsesSupersededLifecycle(t *testing.T) { rm := NewRunManager() const repo = "repo-supersession" @@ -100,7 +129,9 @@ func TestRunManager_SupersedeUsesSupersededLifecycle(t *testing.T) { if _, err := rm.Submit(first, repo, "feature", func(context.Context, func(Event)) error { return nil }); err != nil { t.Fatalf("Submit first: %v", err) } - rm.SupersedeQueued(repo, "feature") + if err := rm.SupersedeQueued(repo, "feature"); err != nil { + t.Fatalf("SupersedeQueued: %v", err) + } close(release) deadline := time.After(2 * time.Second) diff --git a/internal/daemon/reviewdecisions.go b/internal/daemon/reviewdecisions.go index 2383552..acdb231 100644 --- a/internal/daemon/reviewdecisions.go +++ b/internal/daemon/reviewdecisions.go @@ -16,37 +16,21 @@ type reviewKey struct { } // ReviewDecisions lives alongside RunManager because a decision only ever -// applies to one (run, stage) pair, making it per-run state that both the -// review.decide/review.decision RPC handlers and the orchestrator's WorkFunc -// need to reach from their separate packages. +// applies to one (run, stage) pair, making it per-run state that the versioned +// review.decide handler and orchestrator's WorkFunc share. type ReviewDecisions struct { mu sync.Mutex entries map[reviewKey]string waiters map[reviewKey][]chan string - runs map[string]struct{} } func NewReviewDecisions() *ReviewDecisions { return &ReviewDecisions{ entries: make(map[reviewKey]string), waiters: make(map[reviewKey][]chan string), - runs: make(map[string]struct{}), } } -func (d *ReviewDecisions) RegisterRun(runID string) { - d.mu.Lock() - d.runs[runID] = struct{}{} - d.mu.Unlock() -} - -func (d *ReviewDecisions) HasRun(runID string) bool { - d.mu.Lock() - defer d.mu.Unlock() - _, ok := d.runs[runID] - return ok -} - // Set records a decision for (runID, stage) and wakes any goroutine blocked // in Wait on that exact key. func (d *ReviewDecisions) Set(runID, stage, decision string) { @@ -63,13 +47,6 @@ func (d *ReviewDecisions) Set(runID, stage, decision string) { } } -func (d *ReviewDecisions) Get(runID, stage string) (string, bool) { - d.mu.Lock() - defer d.mu.Unlock() - decision, ok := d.entries[reviewKey{RunID: runID, Stage: stage}] - return decision, ok -} - // Wait blocks until a decision is recorded for (runID, stage) via Set, or // until ctx is done, whichever comes first. It returns immediately if a // decision is already recorded. diff --git a/internal/daemon/runmanager.go b/internal/daemon/runmanager.go index d872251..a7a8e7f 100644 --- a/internal/daemon/runmanager.go +++ b/internal/daemon/runmanager.go @@ -114,7 +114,11 @@ func NewPersistentRunManager(path string) (*RunManager, error) { if err != nil { return nil, err } - return newRunManager(store, snapshots), nil + rm := newRunManager(store, snapshots) + if err := rm.reconcileRestoredRuns(); err != nil { + return nil, err + } + return rm, nil } func newRunManager(store *RunStore, snapshots map[string]RunSnapshot) *RunManager { @@ -132,12 +136,34 @@ func newRunManager(store *RunStore, snapshots map[string]RunSnapshot) *RunManage return rm } -func (rm *RunManager) persist(r *run) { - if rm.store != nil { - rm.persistMu.Lock() - defer rm.persistMu.Unlock() - _ = rm.store.Append(r.snapshot()) +func (rm *RunManager) persist(r *run) error { + if rm.store == nil { + return nil + } + rm.persistMu.Lock() + defer rm.persistMu.Unlock() + return rm.store.Append(r.snapshot()) +} + +func (rm *RunManager) reconcileRestoredRuns() error { + for _, r := range rm.runs { + snapshot := r.snapshot() + if snapshot.Status != RunQueued && snapshot.Status != RunRunning { + continue + } + restartedErr := errors.New("daemon restarted before execution finished") + r.update(func(s *RunSnapshot) { + s.Status = RunFailed + s.EndedAt = time.Now() + s.ExecutionFinished = true + s.Err = restartedErr + s.Errors = append(s.Errors, restartedErr.Error()) + }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("reconcile restored run %q: %w", snapshot.ID, err) + } } + return nil } func (rm *RunManager) ActivitySignal() <-chan struct{} { @@ -205,7 +231,16 @@ func (rm *RunManager) SubmitWithMetadata(id, repo, branch, inputSHA, outputSHA s rm.repos[repo] = rq } rm.mu.Unlock() - rm.persist(r) + if err := rm.persist(r); err != nil { + rm.mu.Lock() + delete(rm.runs, id) + if current, ok := rm.repos[repo]; ok && current == rq { + delete(rm.repos, repo) + } + rm.mu.Unlock() + cancel() + return RunSnapshot{}, fmt.Errorf("persist submitted run: %w", err) + } rq.mu.Lock() rq.pending = append(rq.pending, &queuedJob{run: r, work: work}) @@ -243,7 +278,15 @@ func (rm *RunManager) execute(r *run, work WorkFunc) { s.Status = RunRunning s.StartedAt = started }) - rm.persist(r) + if err := rm.persist(r); err != nil { + retryErr := rm.recordPersistenceFailure(r, err) + eventErr := err + if retryErr != nil { + eventErr = errors.Join(err, retryErr) + } + rm.mailbox.Publish(Event{RunID: id, Kind: EventRunFailed, Time: time.Now(), Err: eventErr}) + return + } rm.mailbox.Publish(Event{RunID: id, Kind: EventRunStarted, Time: started}) rm.signalActivity() @@ -279,7 +322,14 @@ func (rm *RunManager) execute(r *run, work WorkFunc) { s.Status = RunSucceeded } }) - rm.persist(r) + if persistErr := rm.persist(r); persistErr != nil { + retryErr := rm.recordPersistenceFailure(r, persistErr) + if retryErr != nil { + err = errors.Join(err, persistErr, retryErr) + } else { + err = errors.Join(err, persistErr) + } + } finalKind := EventRunCompleted if err != nil { @@ -288,6 +338,24 @@ func (rm *RunManager) execute(r *run, work WorkFunc) { rm.mailbox.Publish(Event{RunID: id, Kind: finalKind, Time: ended, Err: err}) } +func (rm *RunManager) recordPersistenceFailure(r *run, persistErr error) error { + durabilityErr := fmt.Errorf("daemon: durable run state write failed: %w", persistErr) + r.update(func(s *RunSnapshot) { + s.Status = RunFailed + s.EndedAt = time.Now() + s.ExecutionFinished = true + s.Err = durabilityErr + s.Errors = append(s.Errors, durabilityErr.Error()) + }) + retryErr := rm.persist(r) + if retryErr != nil { + r.update(func(s *RunSnapshot) { + s.Errors = append(s.Errors, fmt.Sprintf("daemon: retry durable run state write failed: %v", retryErr)) + }) + } + return retryErr +} + func (rm *RunManager) Snapshot(id string) (RunSnapshot, bool) { r, ok := rm.lookupRun(id) if !ok { @@ -333,7 +401,9 @@ func (rm *RunManager) Cancel(id string) error { return fmt.Errorf("daemon: run %q is already %s", id, snapshot.Status) } r.update(func(s *RunSnapshot) { s.CancelRequested = true }) - rm.persist(r) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist cancellation request: %w", err) + } if snapshot.Status == RunAwaitingMerge || snapshot.Status == RunAwaitingReview { r.update(func(s *RunSnapshot) { s.Status = RunCanceled @@ -342,7 +412,10 @@ func (rm *RunManager) Cancel(id string) error { s.Err = context.Canceled s.Errors = append(s.Errors, context.Canceled.Error()) }) - rm.persist(r) + r.cancel() + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist canceled run: %w", err) + } return nil } r.cancel() @@ -368,7 +441,9 @@ func (rm *RunManager) Finish(id string, status RunStatus, message string) error s.Message = message s.finalized = true }) - rm.persist(r) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist finished run: %w", err) + } return nil } @@ -382,12 +457,12 @@ var ErrRunSuperseded = errors.New("daemon: run superseded by a newer push to the // inspected, so a job already popped off the queue - running or terminal - // is left completely alone, matching a fresh push's right to replace a // stale intent that hasn't started yet, but never a run already underway. -func (rm *RunManager) SupersedeQueued(repo, branch string) { +func (rm *RunManager) SupersedeQueued(repo, branch string) error { rm.mu.Lock() rq, ok := rm.repos[repo] rm.mu.Unlock() if !ok { - return + return nil } rq.mu.Lock() @@ -404,6 +479,7 @@ func (rm *RunManager) SupersedeQueued(repo, branch string) { rq.mu.Unlock() now := time.Now() + var firstErr error for _, j := range dropped { j.run.update(func(s *RunSnapshot) { s.Status = RunSuperseded @@ -412,8 +488,11 @@ func (rm *RunManager) SupersedeQueued(repo, branch string) { s.EndedAt = now s.ExecutionFinished = true }) - rm.persist(j.run) + if err := rm.persist(j.run); err != nil && firstErr == nil { + firstErr = fmt.Errorf("persist superseded run: %w", err) + } rm.mailbox.Publish(Event{RunID: j.run.snapshot().ID, Kind: EventRunFailed, Time: now, Err: ErrRunSuperseded}) rm.signalActivity() } + return firstErr } diff --git a/internal/daemon/runmanager_test.go b/internal/daemon/runmanager_test.go index d61f963..cf8843e 100644 --- a/internal/daemon/runmanager_test.go +++ b/internal/daemon/runmanager_test.go @@ -291,7 +291,9 @@ func TestRunManager_SupersedeQueuedDropsOnlyStillQueuedJobForBranch(t *testing.T t.Fatalf("expected first run still queued behind the blocker before supersession, got %+v (ok=%v)", snap, ok) } - rm.SupersedeQueued(repo, "feature-x") + if err := rm.SupersedeQueued(repo, "feature-x"); err != nil { + t.Fatalf("SupersedeQueued: %v", err) + } id2 := rm.NewRunID() if _, err := rm.Submit(id2, repo, "feature-x", recordWork("second")); err != nil { @@ -341,7 +343,9 @@ func TestRunManager_SupersedeQueuedLeavesAlreadyStartedRunAlone(t *testing.T) { <-started waitForStatus(t, rm, id, RunRunning, time.Second) - rm.SupersedeQueued(repo, "feature-x") + if err := rm.SupersedeQueued(repo, "feature-x"); err != nil { + t.Fatalf("SupersedeQueued: %v", err) + } if snap, _ := rm.Snapshot(id); snap.Status != RunRunning { t.Fatalf("expected already-started run to stay RunRunning after SupersedeQueued, got %v", snap.Status) diff --git a/internal/daemon/runstate.go b/internal/daemon/runstate.go index 8770c21..77ef0e8 100644 --- a/internal/daemon/runstate.go +++ b/internal/daemon/runstate.go @@ -36,7 +36,9 @@ func (rm *RunManager) UpdateStages(id string, stages []StageResult) error { r.update(func(s *RunSnapshot) { s.Stages = append([]StageResult(nil), stages...) }) - rm.persist(r) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist stages for run %q: %w", id, err) + } return nil } @@ -54,7 +56,9 @@ func (rm *RunManager) UpdatePendingFindings(id string, findings []AskUserFinding s.Status = RunRunning } }) - rm.persist(r) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist pending findings for run %q: %w", id, err) + } return nil } diff --git a/internal/daemon/spool.go b/internal/daemon/spool.go index 5afae3e..ab9b705 100644 --- a/internal/daemon/spool.go +++ b/internal/daemon/spool.go @@ -106,6 +106,16 @@ func (s *GateSpool) HasPending() bool { return len(s.pending) > 0 } +func (s *GateSpool) Pending() []GateSubmission { + s.mu.Lock() + defer s.mu.Unlock() + pending := make([]GateSubmission, 0, len(s.pending)) + for _, submission := range s.pending { + pending = append(pending, submission) + } + return pending +} + func (s *GateSpool) appendLocked(record spoolRecord) error { data, err := json.Marshal(record) if err != nil { diff --git a/internal/evidence/inrepo.go b/internal/evidence/inrepo.go index 13ce467..12d23c1 100644 --- a/internal/evidence/inrepo.go +++ b/internal/evidence/inrepo.go @@ -1,10 +1,13 @@ package evidence import ( + "errors" "fmt" "os" "path/filepath" "strings" + + "golang.org/x/sys/unix" ) type InRepoStore struct { @@ -28,19 +31,80 @@ func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) error if dir == "" { dir = DefaultDir } - runDir := filepath.Join(s.RepoPath, dir, runID) + repoPath, err := filepath.EvalSymlinks(s.RepoPath) + if err != nil { + return fmt.Errorf("evidence: resolve repository path: %w", err) + } + evidenceRoot := filepath.Join(repoPath, dir) + if !isContainedPath(repoPath, evidenceRoot) { + return fmt.Errorf("evidence: configured directory %q escapes repository", dir) + } + runDir := filepath.Join(evidenceRoot, runID) + if !isContainedPath(evidenceRoot, runDir) { + return fmt.Errorf("evidence: run ID %q escapes evidence directory", runID) + } + if err := ensureEvidenceDirectory(repoPath); err != nil { + return err + } + if err := ensureEvidenceDirectory(runDir); err != nil { + return err + } for name, data := range files { dest := filepath.Join(runDir, name) rel, err := filepath.Rel(runDir, dest) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(name) { return fmt.Errorf("evidence: path %q escapes run evidence directory", name) } - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + if err := ensureEvidenceDirectory(filepath.Dir(dest)); err != nil { return fmt.Errorf("evidence: create evidence dir for %q: %w", name, err) } - if err := os.WriteFile(dest, Redact(data), 0o644); err != nil { + file, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC|unix.O_NOFOLLOW, 0o644) + if err != nil { return fmt.Errorf("evidence: write evidence file %q: %w", name, err) } + if _, err := file.Write(Redact(data)); err != nil { + _ = file.Close() + return fmt.Errorf("evidence: write evidence file %q: %w", name, err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("evidence: close evidence file %q: %w", name, err) + } + } + return nil +} + +func isContainedPath(root, target string) bool { + rel, err := filepath.Rel(root, target) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +func ensureEvidenceDirectory(path string) error { + clean := filepath.Clean(path) + volume := filepath.VolumeName(clean) + rest := strings.TrimPrefix(clean, volume) + current := volume + if strings.HasPrefix(rest, string(filepath.Separator)) { + current += string(filepath.Separator) + rest = strings.TrimPrefix(rest, string(filepath.Separator)) + } + for _, component := range strings.Split(rest, string(filepath.Separator)) { + if component == "" { + continue + } + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + if err := os.Mkdir(current, 0o755); err != nil && !errors.Is(err, os.ErrExist) { + return fmt.Errorf("evidence: create directory %q: %w", current, err) + } + info, err = os.Lstat(current) + } + if err != nil { + return fmt.Errorf("evidence: inspect directory %q: %w", current, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("evidence: refusing unsafe directory %q", current) + } } return nil } diff --git a/internal/evidence/remediation_contract_test.go b/internal/evidence/remediation_contract_test.go index ff784b2..318b86c 100644 --- a/internal/evidence/remediation_contract_test.go +++ b/internal/evidence/remediation_contract_test.go @@ -39,3 +39,25 @@ func TestInRepoStore_RedactsPublishedSecrets(t *testing.T) { t.Fatalf("published evidence retained an authorization secret: %q", data) } } + +func TestInRepoStore_RejectsSymlinkedEvidenceDirectory(t *testing.T) { + repo := t.TempDir() + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".made"), 0o755); err != nil { + t.Fatalf("create evidence parent: %v", err) + } + if err := os.Symlink(outside, filepath.Join(repo, ".made", "evidence")); err != nil { + t.Fatalf("create evidence symlink: %v", err) + } + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte("must remain inside")}); err == nil { + t.Fatal("evidence store followed a symlinked evidence directory") + } + entries, err := os.ReadDir(outside) + if err != nil { + t.Fatalf("read outside directory: %v", err) + } + if len(entries) != 0 { + t.Fatalf("evidence escaped through symlink: %+v", entries) + } +} diff --git a/internal/orchestrator/workfunc.go b/internal/orchestrator/workfunc.go index 9f17317..438276e 100644 --- a/internal/orchestrator/workfunc.go +++ b/internal/orchestrator/workfunc.go @@ -120,7 +120,9 @@ func (c *chain) run() error { var prResult pr.Result var err error if c.rc.Config.StageResult(stageNamePR) == "skipped" { - c.finish(stageNamePR, "skipped", "stage disabled") + if err := c.finish(stageNamePR, "skipped", "stage disabled"); err != nil { + return err + } } else { prResult, err = c.prStage() } @@ -141,8 +143,7 @@ func (c *chain) run() error { func (c *chain) runStage(name string, stage func() error) error { if c.rc.Config.StageResult(name) == "skipped" { - c.finish(name, "skipped", "stage disabled") - return nil + return c.finish(name, "skipped", "stage disabled") } stageCtx, cancel := context.WithTimeout(c.ctx, stageTimeout) previous := c.ctx @@ -155,8 +156,7 @@ func (c *chain) runStage(name string, stage func() error) error { func (c *chain) runCIStage(prURL string) error { if c.rc.Config.StageResult(stageNameCI) == "skipped" || prURL == "" { - c.finish(stageNameCI, "skipped", "stage disabled") - return nil + return c.finish(stageNameCI, "skipped", "stage disabled") } return c.ciStage(prURL) } @@ -167,12 +167,15 @@ func (c *chain) start(stage string) { } } -func (c *chain) finish(stage, result, message string) { +func (c *chain) finish(stage, result, message string) error { c.stages = append(c.stages, daemon.StageResult{Name: stage, Result: result}) - _ = c.rm.UpdateStages(c.runID, append([]daemon.StageResult(nil), c.stages...)) + if err := c.rm.UpdateStages(c.runID, append([]daemon.StageResult(nil), c.stages...)); err != nil { + return err + } if c.emit != nil { c.emit(daemon.Event{Kind: daemon.EventStageFinished, Stage: stage, Message: message}) } + return nil } func (c *chain) stageFailure(stage, message string) error { @@ -196,11 +199,12 @@ func (c *chain) intentStage() error { return err } if !result.OK { - c.finish(stageNameIntent, stageResultFail, result.Message) + if err := c.finish(stageNameIntent, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameIntent, result.Message) } - c.finish(stageNameIntent, stageResultPass, result.Message) - return nil + return c.finish(stageNameIntent, stageResultPass, result.Message) } func (c *chain) rebaseStage() error { @@ -210,11 +214,12 @@ func (c *chain) rebaseStage() error { return err } if !result.OK { - c.finish(stageNameRebase, stageResultFail, result.Message) + if err := c.finish(stageNameRebase, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameRebase, result.Message) } - c.finish(stageNameRebase, stageResultPass, result.Message) - return nil + return c.finish(stageNameRebase, stageResultPass, result.Message) } func (c *chain) reviewStage() error { @@ -244,7 +249,9 @@ func (c *chain) reviewStage() error { return err } if !result.OK { - c.finish(stageNameReview, stageResultFail, result.Message) + if err := c.finish(stageNameReview, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameReview, result.Message) } @@ -254,8 +261,7 @@ func (c *chain) reviewStage() error { } } - c.finish(stageNameReview, stageResultPass, result.Message) - return nil + return c.finish(stageNameReview, stageResultPass, result.Message) } func (c *chain) testStage() error { @@ -265,11 +271,12 @@ func (c *chain) testStage() error { return err } if !result.OK { - c.finish(stageNameTest, stageResultFail, result.Message) + if err := c.finish(stageNameTest, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameTest, result.Message) } - c.finish(stageNameTest, stageResultPass, result.Message) - return nil + return c.finish(stageNameTest, stageResultPass, result.Message) } func (c *chain) documentStage() error { @@ -286,7 +293,9 @@ func (c *chain) documentStage() error { return err } if !result.OK { - c.finish(stageNameDocument, stageResultFail, result.Message) + if err := c.finish(stageNameDocument, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameDocument, result.Message) } @@ -296,8 +305,7 @@ func (c *chain) documentStage() error { } } - c.finish(stageNameDocument, stageResultPass, result.Message) - return nil + return c.finish(stageNameDocument, stageResultPass, result.Message) } func (c *chain) lintStage() error { @@ -307,11 +315,12 @@ func (c *chain) lintStage() error { return err } if !result.OK { - c.finish(stageNameLint, stageResultFail, result.Message) + if err := c.finish(stageNameLint, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameLint, result.Message) } - c.finish(stageNameLint, stageResultPass, result.Message) - return nil + return c.finish(stageNameLint, stageResultPass, result.Message) } func (c *chain) pushStage() error { @@ -321,12 +330,13 @@ func (c *chain) pushStage() error { return err } if !result.OK { - c.finish(stageNamePush, stageResultFail, result.Message) + if err := c.finish(stageNamePush, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNamePush, result.Message) } c.pushed = true - c.finish(stageNamePush, stageResultPass, result.Message) - return nil + return c.finish(stageNamePush, stageResultPass, result.Message) } func (c *chain) prStage() (pr.Result, error) { @@ -347,13 +357,17 @@ func (c *chain) prStage() (pr.Result, error) { return pr.Result{}, err } if !result.OK { - c.finish(stageNamePR, stageResultFail, result.Message) + if err := c.finish(stageNamePR, stageResultFail, result.Message); err != nil { + return pr.Result{}, err + } return pr.Result{}, c.stageFailure(stageNamePR, result.Message) } if err := c.rm.SetPRURL(c.runID, result.PRURL); err != nil { return pr.Result{}, err } - c.finish(stageNamePR, stageResultPass, result.Message) + if err := c.finish(stageNamePR, stageResultPass, result.Message); err != nil { + return pr.Result{}, err + } return result, nil } @@ -367,11 +381,12 @@ func (c *chain) ciStage(prURL string) error { return err } if !result.OK { - c.finish(stageNameCI, stageResultFail, result.Message) + if err := c.finish(stageNameCI, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameCI, result.Message) } - c.finish(stageNameCI, stageResultPass, result.Message) - return nil + return c.finish(stageNameCI, stageResultPass, result.Message) } // parkForApproval records findings and blocks on a single decision per @@ -381,14 +396,21 @@ func (c *chain) ciStage(prURL string) error { // report success while still carrying findings a human must weigh in on, so // OK alone is never sufficient to proceed past them. func (c *chain) parkForApproval(stage string, findings []daemon.AskUserFinding) error { - _ = c.rm.UpdatePendingFindings(c.runID, findings) + if err := c.rm.UpdatePendingFindings(c.runID, findings); err != nil { + return err + } decision, err := c.reviewDecisions.Wait(c.ctx, c.runID, stage) - _ = c.rm.UpdatePendingFindings(c.runID, nil) + clearErr := c.rm.UpdatePendingFindings(c.runID, nil) if err != nil { return fmt.Errorf("orchestrator: wait for %s decision: %w", stage, err) } + if clearErr != nil { + return clearErr + } if decision == daemon.ReviewRejected { - c.finish(stage, stageResultFail, "rejected by reviewer") + if err := c.finish(stage, stageResultFail, "rejected by reviewer"); err != nil { + return err + } return c.stageFailure(stage, "rejected by reviewer") } return nil diff --git a/internal/pipeline/review/remediation_contract_test.go b/internal/pipeline/review/remediation_contract_test.go index 00fbc4a..4957934 100644 --- a/internal/pipeline/review/remediation_contract_test.go +++ b/internal/pipeline/review/remediation_contract_test.go @@ -22,7 +22,7 @@ func TestRun_AutoFixRequiresCleanStateBeforeApplyingReturnedPatch(t *testing.T) } patch := autoFixPatch(t, wt.Path) scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ - {Kind: agent.FindingAutoFixable, Description: "clean-state fix", Patch: patch}, + {Kind: agent.FindingAutoFixable, Description: "clean-state fix", Patch: patch, Paths: []string{"reviewed.txt"}}, }}) if _, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ diff --git a/internal/pipeline/review/review.go b/internal/pipeline/review/review.go index 8edb850..6383491 100644 --- a/internal/pipeline/review/review.go +++ b/internal/pipeline/review/review.go @@ -116,12 +116,13 @@ func applyAutoFix(worktreePath string, finding agent.Finding) (string, string, e if err != nil { return "", "", err } + if _, err := gitOutput(worktreePath, "ls-files", "--error-unmatch", "--", clean); err != nil { + return "", "", fmt.Errorf("auto-fix returned untracked or unauthorized path %q", clean) + } allowed[clean] = struct{}{} } if len(allowed) == 0 { - for _, path := range paths { - allowed[path] = struct{}{} - } + return "", "", fmt.Errorf("auto-fixable finding must return paths") } for _, path := range paths { if _, ok := allowed[path]; !ok { diff --git a/internal/pipeline/review/review_test.go b/internal/pipeline/review/review_test.go index d823741..0421dfa 100644 --- a/internal/pipeline/review/review_test.go +++ b/internal/pipeline/review/review_test.go @@ -25,7 +25,7 @@ func TestRun_AutoFixApplied(t *testing.T) { scenarioPath := writeScenario(t, agent.Findings{ Findings: []agent.Finding{ - {Kind: agent.FindingAutoFixable, Description: "append auto-fixed line", Patch: patch}, + {Kind: agent.FindingAutoFixable, Description: "append auto-fixed line", Patch: patch, Paths: []string{"reviewed.txt"}}, }, }) diff --git a/internal/skill/skill.go b/internal/skill/skill.go index 0990922..cc4e35c 100644 --- a/internal/skill/skill.go +++ b/internal/skill/skill.go @@ -129,7 +129,7 @@ and the run resumes. marks the run done on its own. The run's message names the open PR and says it is awaiting merge - tell the user it is ready for their review, and do not wait for the merge yourself. -- ` + "`failed`" + ` - a stage blocked the run. Read ` + "`made status --json`" + ` for +- ` + "`failed`" + ` - a stage blocked the run. Read ` + "`made run status --json `" + ` for the failing stage's result and message, fix it, commit the fix on the same branch, and push to ` + "`made`" + ` again. If PR or CI fails after Push already succeeded, the message says so explicitly - e.g. "push diff --git a/internal/skill/skill_test.go b/internal/skill/skill_test.go index b201ed6..dc9e57d 100644 --- a/internal/skill/skill_test.go +++ b/internal/skill/skill_test.go @@ -54,7 +54,7 @@ func TestCommittedSkillFileMatchesGenerator(t *testing.T) { // The real pipeline is asynchronous (a push is admitted and returns // immediately; the 9-stage pipeline runs in the background and is observed -// via `made status --json`), so the body must never regress to claiming a +// via `made run status --json `), so the body must never regress to claiming a // push blocks until the pipeline finishes. func TestBodyDoesNotClaimPushBlocks(t *testing.T) { if strings.Contains(skill.Markdown(), "blocks until") { diff --git a/skills/made/SKILL.md b/skills/made/SKILL.md index 01351b9..19af6c4 100644 --- a/skills/made/SKILL.md +++ b/skills/made/SKILL.md @@ -99,7 +99,7 @@ and the run resumes. marks the run done on its own. The run's message names the open PR and says it is awaiting merge - tell the user it is ready for their review, and do not wait for the merge yourself. -- `failed` - a stage blocked the run. Read `made status --json` for +- `failed` - a stage blocked the run. Read `made run status --json ` for the failing stage's result and message, fix it, commit the fix on the same branch, and push to `made` again. If PR or CI fails after Push already succeeded, the message says so explicitly - e.g. "push From 3b387b78c9b3a6ec393d29fc866be0ff138a871b Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:31:02 -0400 Subject: [PATCH 03/53] fix: harden remediation review boundaries --- cmd/made/daemon.go | 67 ++++++--- cmd/made/gate_notify_push_test.go | 2 +- cmd/made/status_test.go | 2 +- internal/api/server.go | 5 + internal/daemon/durable_contract_test.go | 12 ++ internal/daemon/runmanager.go | 32 ++-- internal/daemon/runmanager_test.go | 8 +- internal/daemon/runstate_test.go | 2 +- internal/evidence/inrepo.go | 140 ++++++++++++------ internal/evidence/redact.go | 12 +- .../evidence/remediation_contract_test.go | 9 +- internal/orchestrator/workfunc.go | 2 +- 12 files changed, 208 insertions(+), 85 deletions(-) diff --git a/cmd/made/daemon.go b/cmd/made/daemon.go index 58e22d3..6f8954c 100644 --- a/cmd/made/daemon.go +++ b/cmd/made/daemon.go @@ -97,23 +97,25 @@ func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, done <- fmt.Errorf("create made home: %w", err) return daemon.NewRunManager(), done } - spool, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + ownedLock, err := daemon.AcquireLock(lockPath) if err != nil { done := make(chan error, 1) done <- err return daemon.NewRunManager(), done } - rm, err := daemon.NewPersistentRunManager(filepath.Join(home, "runs.wal")) + spool, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) if err != nil { + _ = ownedLock.Release() done := make(chan error, 1) done <- err return daemon.NewRunManager(), done } - ownedLock, err := daemon.AcquireLock(lockPath) + rm, err := daemon.NewPersistentRunManager(filepath.Join(home, "runs.wal")) if err != nil { + _ = ownedLock.Release() done := make(chan error, 1) done <- err - return rm, done + return daemon.NewRunManager(), done } socketPath := api.SocketPath(home) if err := api.PrepareSocket(socketPath); err != nil { @@ -149,7 +151,10 @@ func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, ActiveFunc: rm.HasActive, UndrainedFunc: spool.HasPending, }) - cancelInFlightRuns(rm, shutdownCancelTimeout) + if cancelErr := cancelInFlightRuns(rm, shutdownCancelTimeout); cancelErr != nil { + runErr = errors.Join(runErr, cancelErr) + } + cancelRun() cancelServe() <-serveErr _ = srv.Close() @@ -163,18 +168,39 @@ func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, func replayPendingSubmissions(ctx context.Context, rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool) { handler := gateNotifyPushHandler(rm, reviewDecisions, spool) - for _, submission := range spool.Pending() { - params, err := json.Marshal(gateNotifyPushParams{ - GatePath: submission.Gate, - Ref: submission.Ref, - NewSHA: submission.SHA, - RunID: submission.RunID, - }) - if err != nil { + for { + pending := spool.Pending() + if len(pending) == 0 { + return + } + for _, submission := range pending { + params, err := json.Marshal(gateNotifyPushParams{ + GatePath: submission.Gate, + Ref: submission.Ref, + NewSHA: submission.SHA, + RunID: submission.RunID, + }) + if err != nil { + return + } + if _, err := handler(ctx, params); err != nil { + continue + } + } + if !spool.HasPending() { return } - if _, err := handler(ctx, params); err != nil { - continue + timer := time.NewTimer(5 * time.Second) + select { + case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return + case <-timer.C: } } } @@ -185,10 +211,14 @@ func replayPendingSubmissions(ctx context.Context, rm *daemon.RunManager, review // run's WorkFunc is only cooperative with cancellation, not instantly // killable, so this blocks (up to timeout) for it to actually observe // ctx.Done() and return before shutdown proceeds. -func cancelInFlightRuns(rm *daemon.RunManager, timeout time.Duration) { + +func cancelInFlightRuns(rm *daemon.RunManager, timeout time.Duration) error { + var firstErr error for _, snap := range rm.List() { if !isTerminalRunStatus(snap.Status) { - _ = rm.Cancel(snap.ID) + if err := rm.Cancel(snap.ID); err != nil && firstErr == nil { + firstErr = fmt.Errorf("cancel run %q during shutdown: %w", snap.ID, err) + } } } @@ -202,10 +232,11 @@ func cancelInFlightRuns(rm *daemon.RunManager, timeout time.Duration) { } } if allTerminal { - return + return firstErr } time.Sleep(10 * time.Millisecond) } + return firstErr } func isTerminalRunStatus(s daemon.RunStatus) bool { diff --git a/cmd/made/gate_notify_push_test.go b/cmd/made/gate_notify_push_test.go index f3a6b42..b96aca7 100644 --- a/cmd/made/gate_notify_push_test.go +++ b/cmd/made/gate_notify_push_test.go @@ -62,7 +62,7 @@ func waitForRunTerminal(t *testing.T, rm *daemon.RunManager, id string, timeout deadline := time.After(timeout) for { snap, ok := rm.Snapshot(id) - if ok && (snap.Status == daemon.RunCompleted || snap.Status == daemon.RunFailed) { + if ok && (snap.Status == daemon.RunSucceeded || snap.Status == daemon.RunFailed) { return snap } select { diff --git a/cmd/made/status_test.go b/cmd/made/status_test.go index 3ea63e1..d140ba5 100644 --- a/cmd/made/status_test.go +++ b/cmd/made/status_test.go @@ -46,7 +46,7 @@ func TestStatusJSON_SchemaValidity(t *testing.T) { deadline := time.After(2 * time.Second) for { snap, ok := rm.Snapshot("run-test-1") - if ok && (snap.Status == daemon.RunCompleted || snap.Status == daemon.RunFailed) { + if ok && (snap.Status == daemon.RunSucceeded || snap.Status == daemon.RunFailed) { break } select { diff --git a/internal/api/server.go b/internal/api/server.go index 6b9d90c..6d504fa 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -7,6 +7,7 @@ import ( "fmt" "net" "os" + "syscall" "sync" ) @@ -82,6 +83,10 @@ func PrepareSocket(socketPath string) error { if info.Mode()&os.ModeSocket == 0 { return fmt.Errorf("api: refusing regular socket path %s", socketPath) } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || uint32(stat.Uid) != uint32(os.Getuid()) { + return fmt.Errorf("api: refusing socket path %s owned by another user", socketPath) + } if err := os.Remove(socketPath); err != nil { return fmt.Errorf("api: remove stale owner socket %s: %w", socketPath, err) } diff --git a/internal/daemon/durable_contract_test.go b/internal/daemon/durable_contract_test.go index 464ae75..ad77e08 100644 --- a/internal/daemon/durable_contract_test.go +++ b/internal/daemon/durable_contract_test.go @@ -95,6 +95,14 @@ func TestPersistentRunManagerReconcilesUnfinishedExecutionAfterRestart(t *testin }); err != nil { t.Fatalf("seed unfinished snapshot: %v", err) } + reviewID := "123e4567-e89b-12d3-a456-426614174005" + if err := store.Append(RunSnapshot{ + ID: reviewID, Repo: "repo", Branch: "review", Status: RunAwaitingReview, + QueuedAt: time.Now().Add(-time.Minute), StartedAt: time.Now().Add(-30 * time.Second), + PendingFindings: []AskUserFinding{{Stage: "review", Message: "approve"}}, + }); err != nil { + t.Fatalf("seed awaiting-review snapshot: %v", err) + } rm, err := NewPersistentRunManager(path) if err != nil { @@ -107,6 +115,10 @@ func TestPersistentRunManagerReconcilesUnfinishedExecutionAfterRestart(t *testin if snapshot.Status != RunFailed || !snapshot.ExecutionFinished || snapshot.Err == nil { t.Fatalf("unfinished run was not reconciled to durable failure: %+v", snapshot) } + reviewSnapshot, ok := rm.Snapshot(reviewID) + if !ok || reviewSnapshot.Status != RunFailed || !reviewSnapshot.ExecutionFinished { + t.Fatalf("awaiting-review run was not reconciled to durable failure: %+v", reviewSnapshot) + } restarted, err := NewPersistentRunManager(path) if err != nil { diff --git a/internal/daemon/runmanager.go b/internal/daemon/runmanager.go index a7a8e7f..b50d515 100644 --- a/internal/daemon/runmanager.go +++ b/internal/daemon/runmanager.go @@ -18,7 +18,6 @@ const ( RunAwaitingReview RunStatus = "awaiting_review" RunAwaitingMerge RunStatus = "awaiting_merge" RunSucceeded RunStatus = "succeeded" - RunCompleted RunStatus = RunSucceeded RunFailed RunStatus = "failed" RunCanceled RunStatus = "canceled" RunSuperseded RunStatus = "superseded" @@ -49,10 +48,10 @@ type RunSnapshot struct { // finalized is set by Finish and read by execute: it lets a WorkFunc // declare a run's definitive terminal-or-not Status/Message itself, - // overriding execute's normal "nil error means RunCompleted" inference - + // overriding execute's normal "nil error means RunSucceeded" inference - // needed for the orchestrator's CI-passed-but-awaiting-human-merge case, // where the pipeline finished successfully yet the run must stay - // RunRunning rather than flip to RunCompleted. + // RunAwaitingMerge rather than flip to RunSucceeded. finalized bool } @@ -148,7 +147,7 @@ func (rm *RunManager) persist(r *run) error { func (rm *RunManager) reconcileRestoredRuns() error { for _, r := range rm.runs { snapshot := r.snapshot() - if snapshot.Status != RunQueued && snapshot.Status != RunRunning { + if snapshot.Status != RunQueued && snapshot.Status != RunRunning && snapshot.Status != RunAwaitingReview { continue } restartedErr := errors.New("daemon restarted before execution finished") @@ -383,25 +382,36 @@ func (rm *RunManager) Subscribe(id string) (<-chan Event, func()) { return rm.mailbox.Subscribe(id) } -// Cancel signals the run's WorkFunc via its context; cancellation surfaces as -// the existing RunFailed status with Err wrapping context.Canceled rather -// than a new status value, since a cooperating WorkFunc returning ctx.Err() -// already distinguishes it from an ordinary failure for any caller checking -// errors.Is(snap.Err, context.Canceled). func (rm *RunManager) Cancel(id string) error { r, ok := rm.lookupRun(id) if !ok { return fmt.Errorf("daemon: no run %q", id) } snapshot := r.snapshot() - if snapshot.Status == RunCanceled || snapshot.CancelRequested { + if snapshot.Status == RunCanceled { return nil } if isTerminalRunStatus(snapshot.Status) { return fmt.Errorf("daemon: run %q is already %s", id, snapshot.Status) } + if snapshot.CancelRequested { + r.cancel() + if snapshot.Status == RunAwaitingMerge || snapshot.Status == RunAwaitingReview { + r.update(func(s *RunSnapshot) { + s.Status = RunCanceled + s.ExecutionFinished = true + s.EndedAt = time.Now() + s.Err = context.Canceled + }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist canceled run: %w", err) + } + } + return nil + } r.update(func(s *RunSnapshot) { s.CancelRequested = true }) if err := rm.persist(r); err != nil { + r.cancel() return fmt.Errorf("persist cancellation request: %w", err) } if snapshot.Status == RunAwaitingMerge || snapshot.Status == RunAwaitingReview { @@ -428,7 +438,7 @@ func isTerminalRunStatus(s RunStatus) bool { // Finish lets a WorkFunc declare a run's definitive Status and a // human-readable Message just before it returns, so execute's normal -// nil-error-means-RunCompleted inference does not overwrite it (see +// nil-error-means-RunSucceeded inference does not overwrite it (see // RunSnapshot.finalized). status may be any RunStatus, including RunRunning // for a run that must stay open pending action made cannot itself take. func (rm *RunManager) Finish(id string, status RunStatus, message string) error { diff --git a/internal/daemon/runmanager_test.go b/internal/daemon/runmanager_test.go index cf8843e..f5f4dd1 100644 --- a/internal/daemon/runmanager_test.go +++ b/internal/daemon/runmanager_test.go @@ -46,7 +46,7 @@ func TestRunManager_SequentialQueuing(t *testing.T) { deadline := time.After(2 * time.Second) for { s2, ok := rm.Snapshot(id2) - if ok && (s2.Status == RunCompleted || s2.Status == RunFailed) { + if ok && (s2.Status == RunSucceeded || s2.Status == RunFailed) { break } select { @@ -302,7 +302,7 @@ func TestRunManager_SupersedeQueuedDropsOnlyStillQueuedJobForBranch(t *testing.T close(blockRelease) - waitForStatus(t, rm, id2, RunCompleted, 2*time.Second) + waitForStatus(t, rm, id2, RunSucceeded, 2*time.Second) final1, ok := rm.Snapshot(id1) if !ok { @@ -352,7 +352,7 @@ func TestRunManager_SupersedeQueuedLeavesAlreadyStartedRunAlone(t *testing.T) { } close(release) - final := waitForStatus(t, rm, id, RunCompleted, 2*time.Second) + final := waitForStatus(t, rm, id, RunSucceeded, 2*time.Second) if final.Err != nil { t.Fatalf("expected already-started run to complete normally, got err %v", final.Err) } @@ -366,7 +366,7 @@ func TestRunManager_CancelTerminalRunErrors(t *testing.T) { t.Fatalf("submit: %v", err) } - waitForStatus(t, rm, id, RunCompleted, 2*time.Second) + waitForStatus(t, rm, id, RunSucceeded, 2*time.Second) if err := rm.Cancel(id); err == nil { t.Fatal("expected error cancelling an already-terminal run") diff --git a/internal/daemon/runstate_test.go b/internal/daemon/runstate_test.go index 0c9e320..3257950 100644 --- a/internal/daemon/runstate_test.go +++ b/internal/daemon/runstate_test.go @@ -95,7 +95,7 @@ func TestRunManager_UpdateStagesReflectsListToo(t *testing.T) { deadline := time.After(2 * time.Second) for { snap, ok := rm.Snapshot(id) - if ok && (snap.Status == RunCompleted || snap.Status == RunFailed) { + if ok && (snap.Status == RunSucceeded || snap.Status == RunFailed) { break } select { diff --git a/internal/evidence/inrepo.go b/internal/evidence/inrepo.go index 12d23c1..6b102a1 100644 --- a/internal/evidence/inrepo.go +++ b/internal/evidence/inrepo.go @@ -3,7 +3,6 @@ package evidence import ( "errors" "fmt" - "os" "path/filepath" "strings" @@ -31,7 +30,11 @@ func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) error if dir == "" { dir = DefaultDir } - repoPath, err := filepath.EvalSymlinks(s.RepoPath) + repoPath, err := filepath.Abs(s.RepoPath) + if err != nil { + return fmt.Errorf("evidence: resolve repository path: %w", err) + } + repoPath, err = filepath.EvalSymlinks(repoPath) if err != nil { return fmt.Errorf("evidence: resolve repository path: %w", err) } @@ -43,31 +46,52 @@ func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) error if !isContainedPath(evidenceRoot, runDir) { return fmt.Errorf("evidence: run ID %q escapes evidence directory", runID) } - if err := ensureEvidenceDirectory(repoPath); err != nil { - return err + dirParts, err := safePathComponents(dir) + if err != nil { + return fmt.Errorf("evidence: invalid directory: %w", err) } - if err := ensureEvidenceDirectory(runDir); err != nil { - return err + runParts, err := safePathComponents(runID) + if err != nil { + return fmt.Errorf("evidence: invalid run ID: %w", err) } - for name, data := range files { - dest := filepath.Join(runDir, name) - rel, err := filepath.Rel(runDir, dest) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(name) { - return fmt.Errorf("evidence: path %q escapes run evidence directory", name) + rootFD, err := unix.Open(repoPath, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return fmt.Errorf("evidence: open repository: %w", err) + } + defer unix.Close(rootFD) + runFD, opened, err := openEvidenceDirectory(rootFD, append(dirParts, runParts...)) + if err != nil { + return fmt.Errorf("evidence: open run directory: %w", err) + } + defer closeEvidenceDirectories(opened) + + for name, content := range files { + parts, err := safePathComponents(name) + if err != nil { + return fmt.Errorf("evidence: invalid file path %q: %w", name, err) } - if err := ensureEvidenceDirectory(filepath.Dir(dest)); err != nil { - return fmt.Errorf("evidence: create evidence dir for %q: %w", name, err) + parentFD := runFD + parentOpened := []int(nil) + if len(parts) > 1 { + parentFD, parentOpened, err = openEvidenceDirectory(runFD, parts[:len(parts)-1]) + if err != nil { + return fmt.Errorf("evidence: open parent for %q: %w", name, err) + } } - file, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC|unix.O_NOFOLLOW, 0o644) - if err != nil { - return fmt.Errorf("evidence: write evidence file %q: %w", name, err) + fileFD, openErr := unix.Openat(parentFD, parts[len(parts)-1], unix.O_WRONLY|unix.O_CREAT|unix.O_TRUNC|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o644) + if openErr != nil { + closeEvidenceDirectories(parentOpened) + return fmt.Errorf("evidence: open evidence file %q: %w", name, openErr) } - if _, err := file.Write(Redact(data)); err != nil { - _ = file.Close() - return fmt.Errorf("evidence: write evidence file %q: %w", name, err) + redacted := Redact(content) + writeErr := writeEvidenceFile(fileFD, redacted) + closeErr := unix.Close(fileFD) + closeEvidenceDirectories(parentOpened) + if writeErr != nil { + return fmt.Errorf("evidence: write evidence file %q: %w", name, writeErr) } - if err := file.Close(); err != nil { - return fmt.Errorf("evidence: close evidence file %q: %w", name, err) + if closeErr != nil { + return fmt.Errorf("evidence: close evidence file %q: %w", name, closeErr) } } return nil @@ -78,33 +102,65 @@ func isContainedPath(root, target string) bool { return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) } -func ensureEvidenceDirectory(path string) error { - clean := filepath.Clean(path) - volume := filepath.VolumeName(clean) - rest := strings.TrimPrefix(clean, volume) - current := volume - if strings.HasPrefix(rest, string(filepath.Separator)) { - current += string(filepath.Separator) - rest = strings.TrimPrefix(rest, string(filepath.Separator)) - } - for _, component := range strings.Split(rest, string(filepath.Separator)) { - if component == "" { +func safePathComponents(path string) ([]string, error) { + if path == "" || filepath.IsAbs(path) || filepath.VolumeName(path) != "" { + return nil, errors.New("path must be relative") + } + parts := strings.Split(path, string(filepath.Separator)) + clean := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" { continue } - current = filepath.Join(current, component) - info, err := os.Lstat(current) - if errors.Is(err, os.ErrNotExist) { - if err := os.Mkdir(current, 0o755); err != nil && !errors.Is(err, os.ErrExist) { - return fmt.Errorf("evidence: create directory %q: %w", current, err) + if part == "." || part == ".." { + return nil, errors.New("path traversal is not allowed") + } + clean = append(clean, part) + } + if len(clean) == 0 { + return nil, errors.New("path must not be empty") + } + return clean, nil +} + +func openEvidenceDirectory(rootFD int, parts []string) (int, []int, error) { + current := rootFD + opened := make([]int, 0, len(parts)) + for _, part := range parts { + fd, err := unix.Openat(current, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if errors.Is(err, unix.ENOENT) { + if mkdirErr := unix.Mkdirat(current, part, 0o755); mkdirErr != nil && !errors.Is(mkdirErr, unix.EEXIST) { + closeEvidenceDirectories(opened) + return -1, nil, mkdirErr } - info, err = os.Lstat(current) + fd, err = unix.Openat(current, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) } if err != nil { - return fmt.Errorf("evidence: inspect directory %q: %w", current, err) + closeEvidenceDirectories(opened) + return -1, nil, err } - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return fmt.Errorf("evidence: refusing unsafe directory %q", current) + opened = append(opened, fd) + current = fd + } + return current, opened, nil +} + +func closeEvidenceDirectories(fds []int) { + for i := len(fds) - 1; i >= 0; i-- { + _ = unix.Close(fds[i]) + } +} + +func writeEvidenceFile(fd int, data []byte) error { + for len(data) > 0 { + n, err := unix.Write(fd, data) + if err != nil { + return err + } + if n == 0 { + return errors.New("short write") } + data = data[n:] } - return nil + return unix.Fsync(fd) } diff --git a/internal/evidence/redact.go b/internal/evidence/redact.go index 9289469..b56216e 100644 --- a/internal/evidence/redact.go +++ b/internal/evidence/redact.go @@ -6,9 +6,15 @@ import ( ) var evidenceSecretPatterns = []*regexp.Regexp{ - regexp.MustCompile(`(?i)(authorization:\s*bearer\s+)[A-Za-z0-9._-]+`), - regexp.MustCompile(`\b(?:ghp_|github_pat_|sk-)[A-Za-z0-9_-]+`), - regexp.MustCompile(`(?i)(token=)[^&\s]+`), + regexp.MustCompile(`(?i)(authorization:\s*(?:bearer|basic)\s+)[^\s]+`), + regexp.MustCompile(`(?i)(\b(?:token|api[_-]?key|secret|password|passwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;&}]+)`), + regexp.MustCompile(`(?i)(["']?(?:token|api[_-]?key|secret|password|passwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)["']?\s*:\s*)(?:"[^"]*"|'[^']*'|[^,\s}]+)`), + regexp.MustCompile(`(?i)(x-api-key:\s*)[^\s]+`), + regexp.MustCompile(`(?i)(cookie:\s*)[^\r\n]+`), + regexp.MustCompile(`(?i)(token=|access_token=|refresh_token=|client_secret=)[^&\s]+`), + regexp.MustCompile(`\b(?:ghp_|github_pat_|sk-|xox[baprs]-)[A-Za-z0-9._-]+`), + regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`), + regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`), } func Redact(data []byte) []byte { diff --git a/internal/evidence/remediation_contract_test.go b/internal/evidence/remediation_contract_test.go index 318b86c..dfee028 100644 --- a/internal/evidence/remediation_contract_test.go +++ b/internal/evidence/remediation_contract_test.go @@ -28,15 +28,18 @@ func TestInRepoStore_RejectsPathTraversalAndOversizedEvidence(t *testing.T) { func TestInRepoStore_RedactsPublishedSecrets(t *testing.T) { repo := t.TempDir() store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} - if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte("Authorization: Bearer secret-value\n")}); err != nil { + input := "Authorization: Bearer bearer-secret\napi_key=api-secret\n\"access_token\": \"json-secret\"\nx-api-key: header-secret\ntoken=query-secret&ok=1\nghp_1234567890abcdef\nAKIA1234567890ABCDEF\n-----BEGIN RSA PRIVATE KEY-----\nprivate-secret\n-----END RSA PRIVATE KEY-----\n" + if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte(input)}); err != nil { t.Fatalf("WriteEvidence: %v", err) } data, err := os.ReadFile(filepath.Join(repo, ".made/evidence", "run-1", "log.txt")) if err != nil { t.Fatalf("read evidence: %v", err) } - if strings.Contains(string(data), "secret-value") { - t.Fatalf("published evidence retained an authorization secret: %q", data) + for _, secret := range []string{"bearer-secret", "api-secret", "json-secret", "header-secret", "query-secret", "ghp_1234567890abcdef", "AKIA1234567890ABCDEF", "private-secret"} { + if strings.Contains(string(data), secret) { + t.Fatalf("published evidence retained %q: %q", secret, data) + } } } diff --git a/internal/orchestrator/workfunc.go b/internal/orchestrator/workfunc.go index 438276e..815584e 100644 --- a/internal/orchestrator/workfunc.go +++ b/internal/orchestrator/workfunc.go @@ -135,7 +135,7 @@ func (c *chain) run() error { // A passing CI stage validates the branch and leaves a PR open, but // merging it is a human decision made cannot observe - so the run's - // final status stays RunRunning rather than RunCompleted, with the PR + // final status stays RunAwaitingMerge rather than RunSucceeded, with the PR // URL surfaced in the message instead of a terminal "done" state. message := fmt.Sprintf("all stages passed, PR open, awaiting merge: %s", prResult.PRURL) return c.rm.Finish(c.runID, daemon.RunAwaitingMerge, message) From d866545b1391e25f738930200566ab7dcff5c4e5 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:32:35 -0400 Subject: [PATCH 04/53] fix: close final validation findings --- internal/api/server.go | 2 +- internal/evidence/inrepo.go | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 6d504fa..3f53461 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -7,8 +7,8 @@ import ( "fmt" "net" "os" - "syscall" "sync" + "syscall" ) type HandlerFunc func(ctx context.Context, params json.RawMessage) (any, error) diff --git a/internal/evidence/inrepo.go b/internal/evidence/inrepo.go index 6b102a1..c75c16c 100644 --- a/internal/evidence/inrepo.go +++ b/internal/evidence/inrepo.go @@ -22,7 +22,7 @@ func (s *InRepoStore) Location(runID string) string { return filepath.Join(dir, runID) } -func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) error { +func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) (err error) { if err := validateEvidenceInput(runID, files); err != nil { return err } @@ -58,7 +58,11 @@ func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) error if err != nil { return fmt.Errorf("evidence: open repository: %w", err) } - defer unix.Close(rootFD) + defer func() { + if closeErr := unix.Close(rootFD); closeErr != nil && err == nil { + err = fmt.Errorf("evidence: close repository: %w", closeErr) + } + }() runFD, opened, err := openEvidenceDirectory(rootFD, append(dirParts, runParts...)) if err != nil { return fmt.Errorf("evidence: open run directory: %w", err) From ed9646c2d92e37008d25d90a7863527e1e1c13fa Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:36:54 -0400 Subject: [PATCH 05/53] docs: record remediation delivery evidence --- docs/remediation/made-remediation-p1p3b.md | 26 ++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 6752da0..b792f5f 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -14,6 +14,8 @@ They resolved to the disposable Herdr worktree, branch `cs/made-remediation-p1p3 The baseline toolchain was Go `1.26.6 darwin/arm64` and golangci-lint `2.11.2`. +The committed module and CI pin Go `1.26.5`; the local Go `1.26.6` toolchain was used only for this validation run. + The baseline normal, race, vet, and lint commands passed after applying the process-local signing override `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false`. The signing override was needed because the inherited global SSH signing configuration requires an unavailable 1Password socket. @@ -42,6 +44,12 @@ The old mocks were updated or removed so they do not authorize commands that the The initial implementation commit was `f92baaf345dea88a907e29e8727aa6d937902df9` with subject `feat: deliver versioned durable remediation contract`. +The durability follow-up commit was `deea4ff0a37c7ac2118a2125a487316b65162d8b` with subject `fix: close remediation durability review gaps`. + +The review-boundary follow-up commit was `3b387b78c9b3a6ec393d29fc866be0ff138a871b` with subject `fix: harden remediation review boundaries`. + +The final validation-fix commit is `d866545b1391e25f738930200566ab7dcff5c4e5` with subject `fix: close final validation findings`. + The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. The daemon persists complete run snapshots in an fsync-backed append-only WAL and persists idempotent gate submissions in an fsync-backed spool keyed by gate, ref, and SHA. @@ -54,7 +62,7 @@ Execution completion is represented separately by `execution_finished`. Cancellation requires an exact run ID, is idempotent for an already canceled run, waits for cooperative execution to finish at the CLI boundary, and refuses unknown or unrelated runs. -Restored queued and running snapshots are reconciled to durable failed state after a daemon restart because no worker can safely resume execution without a durable work specification. +Restored queued, running, and awaiting-review snapshots are reconciled to durable failed state after a daemon restart because no worker can safely resume execution without a durable work specification. Pending gate submissions are replayed on daemon startup and remain undrained when their external boundary is unavailable. @@ -82,11 +90,13 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The targeted command `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test ./internal/daemon ./internal/orchestrator ./cmd/made` passed after the durability fixes. +The final source SHA for this validation section is `d866545b1391e25f738930200566ab7dcff5c4e5`. + +The final validation set was `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go build ./...`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test -count=1 ./...`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test -race -shuffle=on -count=1 ./...`, `GOTOOLCHAIN=local go vet ./...`, and `GOTOOLCHAIN=local golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...`. -The final validation set is `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test -count=1 ./...`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test -race -shuffle=on -count=1 ./...`, `GOTOOLCHAIN=local go vet ./...`, and `GOTOOLCHAIN=local golangci-lint run --timeout=5m`. +All final validation commands exited `0`, and golangci-lint reported `0 issues`. -The real-process manual QA transcript is `/tmp/made-remediation-p1p3b-manual-final.log` and its final marker was `manual-qa-final=PASS`. +The fresh exact-HEAD real-process manual QA transcript is `/tmp/made-remediation-p1p3b-manual-d866.log`, and its final marker was `manual-qa-final=PASS` at the same full SHA. That scenario used a fresh binary and task-local Made homes to observe capabilities, doctor through the real Consigliere script, exact submission and SHA preservation, exact status and active-list queries, review decision, cancellation, shutdown refusal, WAL restart, duplicate singleton start, stale PID handling, regular-file, symlink, and directory socket rejection, and predecessor command rejection. @@ -98,14 +108,16 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403`. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..d866545b1391e25f738930200566ab7dcff5c4e5`, which reports 63 paths from the custody base. + +At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. -The implementation and contract-test paths in that diff are `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. +The final validation-fix commit-only diff is `git diff --name-status deea4ff0a37c7ac2118a2125a487316b65162d8b..d866545b1391e25f738930200566ab7dcff5c4e5` and contains only the 12 review-boundary files changed by that follow-up. No Consigliere repository file, GitHub issue, default branch, merge, or shared daemon state was changed. ## Delivery dependency -The remaining dependency after this report is the direct PR on `cs/made-remediation-p1p3b` against `main`. +The remaining dependency after this report is the exact-SHA review pass and direct PR on `cs/made-remediation-p1p3b` against `main`. The branch must be committed, pushed only to `origin/cs/made-remediation-p1p3b`, and opened as a direct PR before the Made lane reports done. From dbc0eb20835a8e0ff5188bfa63364fa88564f7fa Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:39:04 -0400 Subject: [PATCH 06/53] fix: align remediation evidence provenance --- docs/remediation/made-remediation-p1p3b.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index b792f5f..ebb6efa 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -90,13 +90,15 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final source SHA for this validation section is `d866545b1391e25f738930200566ab7dcff5c4e5`. +The final executable source SHA covered by this validation section is `d866545b1391e25f738930200566ab7dcff5c4e5`. + +The evidence-only report commits after that SHA did not change executable source, tests, configuration, or CI. The final validation set was `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go build ./...`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test -count=1 ./...`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test -race -shuffle=on -count=1 ./...`, `GOTOOLCHAIN=local go vet ./...`, and `GOTOOLCHAIN=local golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...`. All final validation commands exited `0`, and golangci-lint reported `0 issues`. -The fresh exact-HEAD real-process manual QA transcript is `/tmp/made-remediation-p1p3b-manual-d866.log`, and its final marker was `manual-qa-final=PASS` at the same full SHA. +The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-d866.log`, and its final marker was `manual-qa-final=PASS` at that full SHA. That scenario used a fresh binary and task-local Made homes to observe capabilities, doctor through the real Consigliere script, exact submission and SHA preservation, exact status and active-list queries, review decision, cancellation, shutdown refusal, WAL restart, duplicate singleton start, stale PID handling, regular-file, symlink, and directory socket rejection, and predecessor command rejection. From d92ea81542ba492eb540ad894c092e9ebeabe91a Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:42:33 -0400 Subject: [PATCH 07/53] docs: clarify remediation plan boundaries --- docs/remediation/made-remediation-p1p3b.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index ebb6efa..8696c3f 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -116,6 +116,10 @@ At directory level, the base-to-final diff is limited to `.github/workflows/ci.y The final validation-fix commit-only diff is `git diff --name-status deea4ff0a37c7ac2118a2125a487316b65162d8b..d866545b1391e25f738930200566ab7dcff5c4e5` and contains only the 12 review-boundary files changed by that follow-up. +The separate Made project plan `plans/made-rewrite.md` retains its broader F3 checkbox because that criterion requires running the full Consigliere `--mode made` soldier flow and changing shared Herdr lifecycle state. + +This task explicitly forbids running `/made`, editing the Consigliere repository, and stopping or restarting the shared Made daemon, so the Made-specific manual QA above does not claim completion of that broader plan item. + No Consigliere repository file, GitHub issue, default branch, merge, or shared daemon state was changed. ## Delivery dependency From 1f8055eeab3fb93b34bf764911f0aec7bfb54767 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:53:38 -0400 Subject: [PATCH 08/53] fix: close remediation boundary review gaps --- cmd/made/daemon.go | 38 ++++++++++++++++--- cmd/made/remediation_contract_test.go | 32 ++++++++++++++++ cmd/made/remediation_process_contract_test.go | 6 ++- internal/config/config.go | 11 ++++++ internal/config/remediation_contract_test.go | 8 ++++ internal/evidence/inrepo.go | 4 +- .../evidence/remediation_contract_test.go | 24 ++++++++++++ internal/orchestrator/workfunc.go | 21 ++++++++++ internal/orchestrator/workfunc_test.go | 15 ++++++++ 9 files changed, 151 insertions(+), 8 deletions(-) diff --git a/cmd/made/daemon.go b/cmd/made/daemon.go index 6f8954c..bc61320 100644 --- a/cmd/made/daemon.go +++ b/cmd/made/daemon.go @@ -16,6 +16,7 @@ import ( "github.com/douglasjarquin/made/internal/exec" "github.com/douglasjarquin/made/internal/gitgate" "github.com/douglasjarquin/made/internal/orchestrator" + "golang.org/x/sys/unix" ) const defaultIdleTimeout = 30 * time.Minute @@ -92,11 +93,13 @@ func daemonStart(args []string, home, lockPath string, stdout, stderr *os.File) // The returned channel receives daemon.Run's final error exactly once, after // the socket server has also been shut down. func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, onReady func(pid int)) (*daemon.RunManager, <-chan error) { - if err := os.MkdirAll(home, 0o700); err != nil { + validatedHome, err := ensureMadeHome(home) + if err != nil { done := make(chan error, 1) - done <- fmt.Errorf("create made home: %w", err) + done <- err return daemon.NewRunManager(), done } + home = validatedHome ownedLock, err := daemon.AcquireLock(lockPath) if err != nil { done := make(chan error, 1) @@ -494,8 +497,33 @@ func madeHome() (string, error) { } dir = filepath.Join(home, ".made") } - if err := os.MkdirAll(dir, 0o700); err != nil { - return "", fmt.Errorf("create made home %s: %w", dir, err) + return ensureMadeHome(dir) +} + +func ensureMadeHome(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolve made home %s: %w", dir, err) + } + if err := os.MkdirAll(abs, 0o700); err != nil { + return "", fmt.Errorf("create made home %s: %w", abs, err) + } + fd, err := unix.Open(abs, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return "", fmt.Errorf("open made home %s: %w", abs, err) + } + defer func() { _ = unix.Close(fd) }() + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return "", fmt.Errorf("inspect made home %s: %w", abs, err) + } + if uint32(stat.Uid) != uint32(os.Getuid()) { + return "", fmt.Errorf("made home %s is not owned by the current user", abs) + } + if stat.Mode&0o077 != 0 { + if err := unix.Fchmod(fd, 0o700); err != nil { + return "", fmt.Errorf("restrict made home %s: %w", abs, err) + } } - return dir, nil + return abs, nil } diff --git a/cmd/made/remediation_contract_test.go b/cmd/made/remediation_contract_test.go index 65bf133..82eec5e 100644 --- a/cmd/made/remediation_contract_test.go +++ b/cmd/made/remediation_contract_test.go @@ -36,6 +36,38 @@ func TestRun_CapabilitiesJSONIsVersionedAndListsStructuredCommands(t *testing.T) } } +func TestEnsureMadeHome_RepairsGroupAndOtherPermissions(t *testing.T) { + home := filepath.Join(t.TempDir(), "made") + if err := os.Mkdir(home, 0o755); err != nil { + t.Fatalf("create made home: %v", err) + } + if _, err := ensureMadeHome(home); err != nil { + t.Fatalf("ensureMadeHome: %v", err) + } + info, err := os.Stat(home) + if err != nil { + t.Fatalf("stat made home: %v", err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Fatalf("made home permissions = %o, want 700", got) + } +} + +func TestEnsureMadeHome_RejectsSymlink(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatalf("create target: %v", err) + } + link := filepath.Join(root, "made") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("create made home symlink: %v", err) + } + if _, err := ensureMadeHome(link); err == nil { + t.Fatal("ensureMadeHome accepted a symlink") + } +} + func TestRun_SubmitJSONReturnsExactRunIDAndImmutableInputHead(t *testing.T) { home := shortTempDir(t) t.Setenv("MADE_HOME", home) diff --git a/cmd/made/remediation_process_contract_test.go b/cmd/made/remediation_process_contract_test.go index 861cff2..8c688a8 100644 --- a/cmd/made/remediation_process_contract_test.go +++ b/cmd/made/remediation_process_contract_test.go @@ -229,7 +229,10 @@ func TestDaemonRejectsObsoleteUnversionedRPCs(t *testing.T) { func TestHermeticCompatibility_RealMadeBinaryThroughConsigliereScript(t *testing.T) { root := repoRoot(t) - consigliereRoot := "/Users/douglasjarquin/github/consigliere" + consigliereRoot := os.Getenv("MADE_CONSIGLIERE_ROOT") + if consigliereRoot == "" { + t.Skip("MADE_CONSIGLIERE_ROOT is required for the real Consigliere script compatibility test") + } script := filepath.Join(consigliereRoot, "bin", "cs-made-lib.sh") if _, err := os.Stat(script); err != nil { t.Fatalf("real Consigliere script unavailable: %v", err) @@ -252,6 +255,7 @@ func TestHermeticCompatibility_RealMadeBinaryThroughConsigliereScript(t *testing cmd.Env = append(os.Environ(), "MADE_HOME="+t.TempDir(), "PATH="+binDir+":"+os.Getenv("PATH"), + "HERDR_SOCKET_PATH="+filepath.Join(t.TempDir(), "herdr.sock"), ) output, err := cmd.CombinedOutput() if !json.Valid([]byte(strings.TrimSpace(string(output)))) { diff --git a/internal/config/config.go b/internal/config/config.go index 4bed73e..f3ed178 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -177,6 +177,9 @@ func loadConfigFile(path string) (cfg Config, exists bool, err error) { if cfg.Version != 1 { return Config{}, true, fmt.Errorf("versioned .made.yml requires version: 1, got %d", cfg.Version) } + if !cfg.hasConfiguredValue() { + return Config{}, true, fmt.Errorf("versioned .made.yml must configure at least one non-version field") + } return cfg, true, nil } if err := yaml.Unmarshal(data, &cfg); err != nil { @@ -185,3 +188,11 @@ func loadConfigFile(path string) (cfg Config, exists bool, err error) { return cfg, true, nil } + +func (c Config) hasConfiguredValue() bool { + return len(c.Document.Rules) > 0 || c.Review.Required || c.DisableProjectSettings || c.NoCI || + c.CI.Required || c.CI.RerunBudget != 0 || len(c.Test.Evidence.Branch) > 0 || + c.Test.Evidence.StoreInRepo || len(c.Test.Evidence.Dir) > 0 || len(c.Commands.Test) > 0 || + len(c.Commands.Lint) > 0 || len(c.Agent) > 0 || len(c.Agents) > 0 || c.AllowRepoCommands || + len(c.Stages) > 0 +} diff --git a/internal/config/remediation_contract_test.go b/internal/config/remediation_contract_test.go index 990ce9b..3e6958a 100644 --- a/internal/config/remediation_contract_test.go +++ b/internal/config/remediation_contract_test.go @@ -18,6 +18,14 @@ func TestLoadConfig_RejectsZeroValueMadeYML(t *testing.T) { } } +func TestLoadConfig_RejectsVersionOnlyMadeYML(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", "version: 1\n") + + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted a version-only .made.yml configuration") + } +} + func TestConfig_DisabledStagesAreSkipped(t *testing.T) { path := writeConfigFile(t, t.TempDir(), ".made.yml", "version: 1\nstages:\n review:\n enabled: false\n") cfg, _, err := loadConfigFile(path) diff --git a/internal/evidence/inrepo.go b/internal/evidence/inrepo.go index c75c16c..8e9997e 100644 --- a/internal/evidence/inrepo.go +++ b/internal/evidence/inrepo.go @@ -82,7 +82,7 @@ func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) (err return fmt.Errorf("evidence: open parent for %q: %w", name, err) } } - fileFD, openErr := unix.Openat(parentFD, parts[len(parts)-1], unix.O_WRONLY|unix.O_CREAT|unix.O_TRUNC|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o644) + fileFD, openErr := unix.Openat(parentFD, parts[len(parts)-1], unix.O_WRONLY|unix.O_CREAT|unix.O_TRUNC|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600) if openErr != nil { closeEvidenceDirectories(parentOpened) return fmt.Errorf("evidence: open evidence file %q: %w", name, openErr) @@ -133,7 +133,7 @@ func openEvidenceDirectory(rootFD int, parts []string) (int, []int, error) { for _, part := range parts { fd, err := unix.Openat(current, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if errors.Is(err, unix.ENOENT) { - if mkdirErr := unix.Mkdirat(current, part, 0o755); mkdirErr != nil && !errors.Is(mkdirErr, unix.EEXIST) { + if mkdirErr := unix.Mkdirat(current, part, 0o700); mkdirErr != nil && !errors.Is(mkdirErr, unix.EEXIST) { closeEvidenceDirectories(opened) return -1, nil, mkdirErr } diff --git a/internal/evidence/remediation_contract_test.go b/internal/evidence/remediation_contract_test.go index dfee028..c6ebea1 100644 --- a/internal/evidence/remediation_contract_test.go +++ b/internal/evidence/remediation_contract_test.go @@ -43,6 +43,30 @@ func TestInRepoStore_RedactsPublishedSecrets(t *testing.T) { } } +func TestInRepoStore_UsesPrivateEvidencePermissions(t *testing.T) { + repo := t.TempDir() + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte("bounded")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + for _, tc := range []struct { + name string + want os.FileMode + }{ + {name: ".made/evidence", want: 0o700}, + {name: ".made/evidence/run-1", want: 0o700}, + {name: ".made/evidence/run-1/log.txt", want: 0o600}, + } { + info, err := os.Stat(filepath.Join(repo, tc.name)) + if err != nil { + t.Fatalf("stat %s: %v", tc.name, err) + } + if got := info.Mode().Perm(); got != tc.want { + t.Errorf("permissions for %s = %o, want %o", tc.name, got, tc.want) + } + } +} + func TestInRepoStore_RejectsSymlinkedEvidenceDirectory(t *testing.T) { repo := t.TempDir() outside := t.TempDir() diff --git a/internal/orchestrator/workfunc.go b/internal/orchestrator/workfunc.go index 815584e..3d7435e 100644 --- a/internal/orchestrator/workfunc.go +++ b/internal/orchestrator/workfunc.go @@ -114,6 +114,9 @@ func (c *chain) run() error { if err := c.runStage(stageNameLint, c.lintStage); err != nil { return err } + if err := c.requireDeliveryStages(); err != nil { + return err + } if err := c.runStage(stageNamePush, c.pushStage); err != nil { return err } @@ -141,6 +144,24 @@ func (c *chain) run() error { return c.rm.Finish(c.runID, daemon.RunAwaitingMerge, message) } +func (c *chain) requireDeliveryStages() error { + for _, name := range []string{ + stageNameIntent, stageNameRebase, stageNameReview, stageNameTest, + stageNameDocument, stageNameLint, stageNamePush, stageNamePR, stageNameCI, + } { + if c.rc.Config.StageResult(name) != "skipped" { + continue + } + if name == stageNamePush || name == stageNamePR || name == stageNameCI { + if err := c.finish(name, "skipped", "stage disabled"); err != nil { + return err + } + } + return fmt.Errorf("orchestrator: refusing delivery because required stage %q is disabled", name) + } + return nil +} + func (c *chain) runStage(name string, stage func() error) error { if c.rc.Config.StageResult(name) == "skipped" { return c.finish(name, "skipped", "stage disabled") diff --git a/internal/orchestrator/workfunc_test.go b/internal/orchestrator/workfunc_test.go index edd5362..c77a10b 100644 --- a/internal/orchestrator/workfunc_test.go +++ b/internal/orchestrator/workfunc_test.go @@ -30,6 +30,21 @@ type wfFixture struct { defaultBranch string } +func TestChain_RefusesDeliveryWhenRequiredStageDisabled(t *testing.T) { + disabled := false + c := &chain{rc: &RunContext{Config: config.Config{ + Stages: map[string]config.Stage{stageNameReview: {Enabled: &disabled}}, + }}} + + err := c.requireDeliveryStages() + if err == nil { + t.Fatal("requireDeliveryStages allowed delivery with a disabled required review stage") + } + if !strings.Contains(err.Error(), `required stage "review" is disabled`) { + t.Fatalf("requireDeliveryStages error = %q, want disabled review stage", err) + } +} + func newWFFixture(t *testing.T) *wfFixture { t.Helper() dir := t.TempDir() From af5010d7bd910bfa829e030c0198cae909188e69 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:58:41 -0400 Subject: [PATCH 09/53] docs: record final remediation QA evidence --- docs/remediation/made-remediation-p1p3b.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 8696c3f..366f96f 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -50,6 +50,10 @@ The review-boundary follow-up commit was `3b387b78c9b3a6ec393d29fc866be0ff138a87 The final validation-fix commit is `d866545b1391e25f738930200566ab7dcff5c4e5` with subject `fix: close final validation findings`. +The final boundary-hardening commit is `1f8055eeab3fb93b34bf764911f0aec7bfb54767` with subject `fix: close remediation boundary review gaps`. + +That follow-up hardens Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled required-stage delivery refusal, and environment-injected real Consigliere compatibility testing. + The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. The daemon persists complete run snapshots in an fsync-backed append-only WAL and persists idempotent gate submissions in an fsync-backed spool keyed by gate, ref, and SHA. @@ -90,18 +94,20 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `d866545b1391e25f738930200566ab7dcff5c4e5`. +The final executable source SHA covered by this validation section is `1f8055eeab3fb93b34bf764911f0aec7bfb54767`. -The evidence-only report commits after that SHA did not change executable source, tests, configuration, or CI. +The evidence-only report commits before that SHA did not change executable source, tests, configuration, or CI. -The final validation set was `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go build ./...`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test -count=1 ./...`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test -race -shuffle=on -count=1 ./...`, `GOTOOLCHAIN=local go vet ./...`, and `GOTOOLCHAIN=local golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...`. +The final validation set was `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local MADE_CONSIGLIERE_ROOT=/Users/douglasjarquin/github/consigliere go build ./...`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local MADE_CONSIGLIERE_ROOT=/Users/douglasjarquin/github/consigliere go test -count=1 ./...`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local MADE_CONSIGLIERE_ROOT=/Users/douglasjarquin/github/consigliere go test -race -shuffle=on -count=1 ./...`, `GOTOOLCHAIN=local MADE_CONSIGLIERE_ROOT=/Users/douglasjarquin/github/consigliere go vet ./...`, and `GOTOOLCHAIN=local MADE_CONSIGLIERE_ROOT=/Users/douglasjarquin/github/consigliere golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...`. All final validation commands exited `0`, and golangci-lint reported `0 issues`. -The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-d866.log`, and its final marker was `manual-qa-final=PASS` at that full SHA. +The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-1f8055e-final.log`, and its final marker was `manual-qa-1f8055e=PASS` at that full SHA. That scenario used a fresh binary and task-local Made homes to observe capabilities, doctor through the real Consigliere script, exact submission and SHA preservation, exact status and active-list queries, review decision, cancellation, shutdown refusal, WAL restart, duplicate singleton start, stale PID handling, regular-file, symlink, and directory socket rejection, and predecessor command rejection. +The compatibility subscenario used the real `bin/cs-made-lib.sh` script, the real Made binary, a strict fake `gh auth status` boundary, a task-local unavailable Herdr socket, and accepted the expected nonzero health exit only after asserting valid versioned JSON and authenticated GitHub state. + The first manual cancellation run returned `running` before the worker completed, which falsified the CLI response contract. The cancellation wait fix returned `canceled` with `execution_finished=true` in the counterfactual rerun. @@ -110,7 +116,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..d866545b1391e25f738930200566ab7dcff5c4e5`, which reports 63 paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..1f8055eeab3fb93b34bf764911f0aec7bfb54767`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. From d45f5c518664db5f73f42d1d4db595216331f24b Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:26:47 -0400 Subject: [PATCH 10/53] fix: close final remediation boundary gaps --- cmd/made/daemon.go | 42 +++++++++--- cmd/made/gate.go | 19 +++++- cmd/made/gate_notify_push_test.go | 36 ++++++++++ cmd/made/remediation_contract_test.go | 37 +++++++++++ cmd/made/review.go | 18 ++++- cmd/made/runhandlers.go | 27 ++++++-- internal/config/config.go | 6 +- internal/config/remediation_contract_test.go | 14 ++++ internal/daemon/remediation_contract_test.go | 12 ++++ internal/daemon/runmanager.go | 66 +++++++++++++++---- internal/evidence/inrepo.go | 21 ++++++ .../evidence/remediation_contract_test.go | 6 ++ internal/github/client.go | 6 +- internal/orchestrator/scaffold.go | 6 +- internal/orchestrator/scaffold_test.go | 6 ++ internal/orchestrator/workfunc.go | 20 +++--- internal/orchestrator/workfunc_test.go | 11 +++- internal/pipeline/document/document.go | 11 +++- internal/pipeline/intent/intent.go | 17 +++-- internal/pipeline/pr/pr.go | 10 +-- internal/pipeline/pr/pr_test.go | 15 ++--- internal/pipeline/rebase/rebase.go | 27 ++++---- .../review/remediation_contract_test.go | 59 +++++++++++++++++ internal/pipeline/review/review.go | 36 +++++++++- internal/pipeline/review/testhelpers_test.go | 11 ++++ 25 files changed, 454 insertions(+), 85 deletions(-) diff --git a/cmd/made/daemon.go b/cmd/made/daemon.go index bc61320..4d2a52c 100644 --- a/cmd/made/daemon.go +++ b/cmd/made/daemon.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/douglasjarquin/made/internal/api" @@ -128,9 +129,10 @@ func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, return rm, done } reviewStore := daemon.NewReviewDecisions() + admission := &sync.Mutex{} runCtx, cancelRun := context.WithCancel(ctx) srv := api.NewServer(socketPath) - registerDaemonHandlers(srv, rm, reviewStore, spool, cancelRun) + registerDaemonHandlers(srv, rm, reviewStore, spool, cancelRun, admission) done := make(chan error, 1) @@ -154,6 +156,9 @@ func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, ActiveFunc: rm.HasActive, UndrainedFunc: spool.HasPending, }) + admission.Lock() + rm.StopAccepting() + admission.Unlock() if cancelErr := cancelInFlightRuns(rm, shutdownCancelTimeout); cancelErr != nil { runErr = errors.Join(runErr, cancelErr) } @@ -164,13 +169,13 @@ func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, done <- runErr }() - go replayPendingSubmissions(runCtx, rm, reviewStore, spool) + go replayPendingSubmissions(runCtx, rm, reviewStore, spool, admission) return rm, done } -func replayPendingSubmissions(ctx context.Context, rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool) { - handler := gateNotifyPushHandler(rm, reviewDecisions, spool) +func replayPendingSubmissions(ctx context.Context, rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool, admission ...*sync.Mutex) { + handler := gateNotifyPushHandler(rm, reviewDecisions, spool, admission...) for { pending := spool.Pending() if len(pending) == 0 { @@ -182,6 +187,7 @@ func replayPendingSubmissions(ctx context.Context, rm *daemon.RunManager, review Ref: submission.Ref, NewSHA: submission.SHA, RunID: submission.RunID, + Replay: true, }) if err != nil { return @@ -239,6 +245,11 @@ func cancelInFlightRuns(rm *daemon.RunManager, timeout time.Duration) error { } time.Sleep(10 * time.Millisecond) } + for _, snap := range rm.List() { + if !isTerminalRunStatus(snap.Status) { + return errors.Join(firstErr, fmt.Errorf("shutdown: timed out waiting for run %q to finish", snap.ID)) + } + } return firstErr } @@ -248,15 +259,15 @@ func isTerminalRunStatus(s daemon.RunStatus) bool { const debugHandlersEnv = "MADE_DEBUG_HANDLERS" -func registerDaemonHandlers(srv *api.Server, rm *daemon.RunManager, store *daemon.ReviewDecisions, spool *daemon.GateSpool, cancel context.CancelFunc) { +func registerDaemonHandlers(srv *api.Server, rm *daemon.RunManager, store *daemon.ReviewDecisions, spool *daemon.GateSpool, cancel context.CancelFunc, admission ...*sync.Mutex) { srv.Handle("run.status", runStatusHandler(rm)) - srv.Handle("run.submit", runSubmitHandler(rm)) + srv.Handle("run.submit", runSubmitHandler(rm, admission...)) srv.Handle("run.list", runListHandler(rm)) srv.Handle("run.cancel", runCancelHandler(rm)) srv.Handle("review.decide", reviewDecideRunHandler(rm, store)) - srv.Handle("daemon.shutdown", daemonShutdownHandler(rm, spool, cancel)) + srv.Handle("daemon.shutdown", daemonShutdownHandler(rm, spool, cancel, admission...)) srv.Handle("gate.admitPush", gateAdmitPushHandler()) - srv.Handle("gate.notifyPush", gateNotifyPushHandler(rm, store, spool)) + srv.Handle("gate.notifyPush", gateNotifyPushHandler(rm, store, spool, admission...)) if os.Getenv(debugHandlersEnv) == "1" { srv.Handle("debug.submitCancellableRun", debugSubmitCancellableRunHandler(rm)) } @@ -330,6 +341,7 @@ type gateNotifyPushParams struct { NewSHA string `json:"new_sha"` Ref string `json:"ref"` RunID string `json:"run_id,omitempty"` + Replay bool `json:"replay,omitempty"` } type gateNotifyPushResult struct { @@ -343,7 +355,7 @@ type gateNotifyPushResult struct { // still-queued run for the same branch before submitting this push's own // run, so a rapid second push always wins over a first one that hasn't // started yet - never over one already running. -func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool) api.HandlerFunc { +func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool, admission ...*sync.Mutex) api.HandlerFunc { return func(ctx context.Context, params json.RawMessage) (any, error) { var p gateNotifyPushParams if err := json.Unmarshal(params, &p); err != nil { @@ -366,12 +378,24 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review decision := gitgate.ClassifyRef(p.Ref, defaultBranch, p.OldSHA, p.NewSHA) if !decision.Accept { + if p.Replay { + if err := spool.Drain(daemon.GateSubmission{Gate: p.GatePath, Ref: p.Ref, SHA: p.NewSHA, RunID: p.RunID}); err != nil { + return nil, fmt.Errorf("gate.notifyPush: drain rejected replay: %w", err) + } + return gateNotifyPushResult{}, nil + } return nil, fmt.Errorf("gate.notifyPush: %s", decision.Message) } if !decision.CreateRun { return gateNotifyPushResult{}, nil } + unlock := lockAdmission(admission) + defer unlock() + if !rm.Accepting() { + return nil, daemon.ErrRunSubmissionClosed + } + branch := strings.TrimPrefix(p.Ref, "refs/heads/") repo := gateRepoIdentifier(p.GatePath) diff --git a/cmd/made/gate.go b/cmd/made/gate.go index 1786d11..53f5dc7 100644 --- a/cmd/made/gate.go +++ b/cmd/made/gate.go @@ -10,12 +10,15 @@ import ( "time" "github.com/douglasjarquin/made/internal/api" + "github.com/douglasjarquin/made/internal/daemon" "github.com/douglasjarquin/made/internal/exec" "github.com/douglasjarquin/made/internal/gitgate" ) const gateCommandTimeout = 30 * time.Second +const gitZeroSHAValue = "0000000000000000000000000000000000000000" + func runGateCommand(args []string, stdout, stderr *os.File) int { if len(args) < 1 { _, _ = fmt.Fprintln(stderr, "usage: made gate init ") @@ -68,7 +71,21 @@ func runGateNotifyPushCommand(args []string, stdout, stderr *os.File) int { client, err := api.Dial(api.SocketPath(home)) if err != nil { - _, _ = fmt.Fprintln(stderr, "gate notify-push: dial daemon:", err) + if *newSHA != gitZeroSHAValue { + spool, spoolErr := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if spoolErr == nil { + _, _, spoolErr = spool.Enqueue(daemon.GateSubmission{ + Gate: *gatePath, Ref: *ref, SHA: *newSHA, RunID: daemon.NewRunID(), + }) + } + if spoolErr != nil { + _, _ = fmt.Fprintln(stderr, "gate notify-push: dial daemon:", err, "; durable queue:", spoolErr) + } else { + _, _ = fmt.Fprintln(stderr, "gate notify-push: daemon unavailable; submission durably queued:", err) + } + } else { + _, _ = fmt.Fprintln(stderr, "gate notify-push: ref deletion does not require a run:", err) + } return 0 } defer func() { _ = client.Close() }() diff --git a/cmd/made/gate_notify_push_test.go b/cmd/made/gate_notify_push_test.go index b96aca7..73c3e6f 100644 --- a/cmd/made/gate_notify_push_test.go +++ b/cmd/made/gate_notify_push_test.go @@ -309,6 +309,42 @@ func TestGateNotifyPushCLI_AlwaysExitsZeroEvenWhenDaemonUnreachable(t *testing.T } } +func TestGateNotifyPushCLI_DurablyQueuesWhenDaemonUnreachable(t *testing.T) { + home := shortTempDir(t) + t.Setenv("MADE_HOME", home) + gatePath := filepath.Join(home, "gates", "repo", "gate.git") + args := []string{ + "gate", "notify-push", + "--gate", gatePath, + "--old", gitZeroSHA, + "--new", strings.Repeat("c", 40), + "--ref", "refs/heads/feature-x", + } + if _, _, code := runCapture(t, args); code != 0 { + t.Fatalf("offline gate notify-push exit code = %d, want 0", code) + } + spool, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + pending := spool.Pending() + if len(pending) != 1 || pending[0].Gate != gatePath || pending[0].Ref != "refs/heads/feature-x" || pending[0].SHA != strings.Repeat("c", 40) || pending[0].RunID == "" { + t.Fatalf("offline submission spool = %+v, want one complete durable identity", pending) + } + firstRunID := pending[0].RunID + if _, _, code := runCapture(t, args); code != 0 { + t.Fatalf("duplicate offline gate notify-push exit code = %d, want 0", code) + } + reopened, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if err != nil { + t.Fatalf("reopen GateSpool: %v", err) + } + pending = reopened.Pending() + if len(pending) != 1 || pending[0].RunID != firstRunID { + t.Fatalf("duplicate offline submission spool = %+v, want original idempotent record", pending) + } +} + func TestGateNotifyPushCLI_NormalPushExitsZero(t *testing.T) { home := shortTempDir(t) t.Setenv("MADE_HOME", home) diff --git a/cmd/made/remediation_contract_test.go b/cmd/made/remediation_contract_test.go index 82eec5e..7e5512b 100644 --- a/cmd/made/remediation_contract_test.go +++ b/cmd/made/remediation_contract_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/douglasjarquin/made/internal/api" "github.com/douglasjarquin/made/internal/daemon" ) @@ -119,6 +120,25 @@ func TestRunSubmit_RejectsInvalidOutputSHA(t *testing.T) { } } +func TestRunSubmit_DoesNotLeaveMissingExecutionWorkActive(t *testing.T) { + rm := daemon.NewRunManager() + result, err := runSubmitHandler(rm)(context.Background(), []byte(`{"repo":"/repo","branch":"feature","input_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`)) + if err != nil { + t.Fatalf("run.submit: %v", err) + } + report := result.(runActionReport) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + snapshot, ok := rm.Snapshot(report.RunID) + if ok && snapshot.Status == daemon.RunFailed && snapshot.ExecutionFinished { + return + } + time.Sleep(5 * time.Millisecond) + } + snapshot, _ := rm.Snapshot(report.RunID) + t.Fatalf("run.submit left an unexecutable run active: %+v", snapshot) +} + func TestStatusHandler_RequiresExactRunID(t *testing.T) { rm := daemon.NewRunManager() started := make(chan struct{}) @@ -172,6 +192,23 @@ func TestReviewDecide_RejectsUnknownExactRunID(t *testing.T) { } } +func TestReviewDecide_ReturnsVersionedStructuredResult(t *testing.T) { + rm := daemon.NewRunManager() + store := daemon.NewReviewDecisions() + id := rm.NewRunID() + if _, err := rm.Submit(id, "repo", "branch", func(context.Context, func(daemon.Event)) error { return nil }); err != nil { + t.Fatalf("Submit: %v", err) + } + result, err := reviewDecideRunHandler(rm, store)(context.Background(), []byte(`{"run_id":"`+id+`","stage":"review","decision":"approved"}`)) + if err != nil { + t.Fatalf("review.decide: %v", err) + } + report, ok := result.(reviewDecisionReport) + if !ok || report.SchemaVersion != 1 || report.ProtocolVersion != api.Version || report.RunID != id || report.Decision != ReviewApproved { + t.Fatalf("review.decide result = %#v, want versioned exact decision", result) + } +} + func TestRun_ListJSONExposesBatchActiveRunQuery(t *testing.T) { home := shortTempDir(t) t.Setenv("MADE_HOME", home) diff --git a/cmd/made/review.go b/cmd/made/review.go index 345f9f4..f6bfb71 100644 --- a/cmd/made/review.go +++ b/cmd/made/review.go @@ -22,6 +22,14 @@ type reviewDecideParams struct { Decision string `json:"decision"` } +type reviewDecisionReport struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + RunID string `json:"run_id"` + Stage string `json:"stage"` + Decision string `json:"decision"` +} + func reviewDecideRunHandler(rm *daemon.RunManager, store *daemon.ReviewDecisions) api.HandlerFunc { return func(_ context.Context, params json.RawMessage) (any, error) { var p reviewDecideParams @@ -41,7 +49,10 @@ func reviewDecideRunHandler(rm *daemon.RunManager, store *daemon.ReviewDecisions return nil, err } store.Set(p.RunID, p.Stage, p.Decision) - return map[string]any{"ok": true}, nil + return reviewDecisionReport{ + SchemaVersion: 1, ProtocolVersion: api.Version, + RunID: p.RunID, Stage: p.Stage, Decision: p.Decision, + }, nil } } @@ -69,9 +80,10 @@ func runReviewDecideCommand(args []string, stdout, stderr *os.File) int { return 1 } defer func() { _ = client.Close() }() - if err := client.CallInto("review.decide", reviewDecideParams{RunID: fs.Arg(0), Stage: *stage, Decision: *decision}, nil); err != nil { + var report reviewDecisionReport + if err := client.CallInto("review.decide", reviewDecideParams{RunID: fs.Arg(0), Stage: *stage, Decision: *decision}, &report); err != nil { _, _ = fmt.Fprintln(stderr, "made review decide:", err) return 1 } - return writeJSON(stdout, map[string]any{"schema_version": 1, "protocol_version": api.Version, "run_id": fs.Arg(0), "stage": *stage, "decision": *decision}, stderr, "made review decide") + return writeJSON(stdout, report, stderr, "made review decide") } diff --git a/cmd/made/runhandlers.go b/cmd/made/runhandlers.go index 9f352b4..841a035 100644 --- a/cmd/made/runhandlers.go +++ b/cmd/made/runhandlers.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strings" + "sync" "time" "github.com/douglasjarquin/made/internal/api" @@ -27,7 +28,7 @@ func runStatusHandler(rm *daemon.RunManager) api.HandlerFunc { } } -func runSubmitHandler(rm *daemon.RunManager) api.HandlerFunc { +func runSubmitHandler(rm *daemon.RunManager, admission ...*sync.Mutex) api.HandlerFunc { return func(_ context.Context, params json.RawMessage) (any, error) { var p runSubmitParams if err := json.Unmarshal(params, &p); err != nil { @@ -36,12 +37,13 @@ func runSubmitHandler(rm *daemon.RunManager) api.HandlerFunc { if strings.TrimSpace(p.Repo) == "" || strings.TrimSpace(p.Branch) == "" || !validSHA(p.InputSHA) || (p.OutputSHA != "" && !validSHA(p.OutputSHA)) { return nil, fmt.Errorf("run.submit: repo, branch, input_sha, and optional output_sha must use valid 40-character SHAs") } + unlock := lockAdmission(admission) + defer unlock() if p.RunID == "" { p.RunID = rm.NewRunID() } - snapshot, err := rm.SubmitWithMetadata(p.RunID, p.Repo, p.Branch, p.InputSHA, p.OutputSHA, func(ctx context.Context, _ func(daemon.Event)) error { - <-ctx.Done() - return ctx.Err() + snapshot, err := rm.SubmitWithMetadata(p.RunID, p.Repo, p.Branch, p.InputSHA, p.OutputSHA, func(context.Context, func(daemon.Event)) error { + return fmt.Errorf("run.submit: no executable gate work was supplied; submit through gate.notify-push") }) if err != nil { return nil, err @@ -104,16 +106,29 @@ func runCancelHandler(rm *daemon.RunManager) api.HandlerFunc { } } -func daemonShutdownHandler(rm *daemon.RunManager, spool *daemon.GateSpool, cancel context.CancelFunc) api.HandlerFunc { +func daemonShutdownHandler(rm *daemon.RunManager, spool *daemon.GateSpool, cancel context.CancelFunc, admission ...*sync.Mutex) api.HandlerFunc { return func(_ context.Context, _ json.RawMessage) (any, error) { - if rm.HasActive() || spool.HasPending() { + unlock := lockAdmission(admission) + defer unlock() + if spool.HasPending() { return nil, fmt.Errorf("daemon.shutdown: active or awaiting runs remain") } + if err := rm.BeginShutdown(); err != nil { + return nil, fmt.Errorf("daemon.shutdown: active or awaiting runs remain: %w", err) + } cancel() return map[string]any{"ok": true, "schema_version": 1, "protocol_version": api.Version}, nil } } +func lockAdmission(admission []*sync.Mutex) func() { + if len(admission) == 0 || admission[0] == nil { + return func() {} + } + admission[0].Lock() + return admission[0].Unlock +} + func activeRunStatus(status daemon.RunStatus) bool { switch status { case daemon.RunQueued, daemon.RunRunning, daemon.RunAwaitingReview, daemon.RunAwaitingMerge: diff --git a/internal/config/config.go b/internal/config/config.go index f3ed178..d36b6a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "strings" "github.com/douglasjarquin/made/internal/agent" "gopkg.in/yaml.v3" @@ -33,6 +34,9 @@ type Stage struct { } func (c Config) StageResult(name string) string { + if name == "ci" && c.NoCI { + return "skipped" + } stage, ok := c.Stages[name] if ok && stage.Enabled != nil && !*stage.Enabled { return "skipped" @@ -161,7 +165,7 @@ func loadConfigFile(path string) (cfg Config, exists bool, err error) { return Config{}, false, err } - if filepath.Base(path) == ".made.yml" { + if filepath.Base(path) == ".made.yml" || strings.HasSuffix(filepath.Base(path), ".made.yml") { decoder := yaml.NewDecoder(bytes.NewReader(data)) decoder.KnownFields(true) if err := decoder.Decode(&cfg); err != nil { diff --git a/internal/config/remediation_contract_test.go b/internal/config/remediation_contract_test.go index 3e6958a..429cb67 100644 --- a/internal/config/remediation_contract_test.go +++ b/internal/config/remediation_contract_test.go @@ -10,6 +10,14 @@ func TestLoadConfig_RejectsUnknownMadeYMLFields(t *testing.T) { } } +func TestLoadConfig_RejectsUnknownFieldsInTrustedMadeYMLCopy(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), "trusted-copy.made.yml", "version: 1\nunknown_field: true\n") + + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted an unknown field in a trusted .made.yml copy") + } +} + func TestLoadConfig_RejectsZeroValueMadeYML(t *testing.T) { path := writeConfigFile(t, t.TempDir(), ".made.yml", "") @@ -36,3 +44,9 @@ func TestConfig_DisabledStagesAreSkipped(t *testing.T) { t.Fatalf("disabled stage result = %q, want skipped", got) } } + +func TestConfig_NoCIIsRepresentedAsSkipped(t *testing.T) { + if got := (Config{NoCI: true}).StageResult("ci"); got != "skipped" { + t.Fatalf("NoCI stage result = %q, want skipped", got) + } +} diff --git a/internal/daemon/remediation_contract_test.go b/internal/daemon/remediation_contract_test.go index c41a53b..754d7e8 100644 --- a/internal/daemon/remediation_contract_test.go +++ b/internal/daemon/remediation_contract_test.go @@ -164,6 +164,18 @@ func TestRunManager_NewRunIDIsRestartSafeUUID(t *testing.T) { } } +func TestRunManager_BeginShutdownClosesSubmissionAdmission(t *testing.T) { + rm := NewRunManager() + if err := rm.BeginShutdown(); err != nil { + t.Fatalf("BeginShutdown: %v", err) + } + if _, err := rm.Submit(rm.NewRunID(), "repo", "branch", func(context.Context, func(Event)) error { + return nil + }); !errors.Is(err, ErrRunSubmissionClosed) { + t.Fatalf("Submit after BeginShutdown error = %v, want ErrRunSubmissionClosed", err) + } +} + func TestRun_DoesNotIdleStopWithAwaitingMergeRun(t *testing.T) { lockPath := filepath.Join(t.TempDir(), "daemon.lock") rm := NewRunManager() diff --git a/internal/daemon/runmanager.go b/internal/daemon/runmanager.go index b50d515..f1fe10a 100644 --- a/internal/daemon/runmanager.go +++ b/internal/daemon/runmanager.go @@ -59,6 +59,8 @@ type WorkFunc func(ctx context.Context, emit func(Event)) error var ErrRunIDExists = errors.New("daemon: run ID already submitted") +var ErrRunSubmissionClosed = errors.New("daemon: run submission is closed") + type run struct { mu sync.Mutex snap RunSnapshot @@ -99,15 +101,29 @@ type RunManager struct { store *RunStore persistMu sync.Mutex - mu sync.Mutex - repos map[string]*repoQueue - runs map[string]*run + mu sync.Mutex + repos map[string]*repoQueue + runs map[string]*run + closing bool } func NewRunManager() *RunManager { return newRunManager(nil, nil) } +func NewRunID() string { + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + counter := atomic.AddUint64(&fallbackRunIDCounter, 1) + for i := range id { + id[i] = byte(counter >> (uint(i%8) * 8)) + } + } + id[6] = (id[6] & 0x0f) | 0x40 + id[8] = (id[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", id[0:4], id[4:6], id[6:8], id[8:10], id[10:16]) +} + func NewPersistentRunManager(path string) (*RunManager, error) { store, snapshots, err := OpenRunStore(path) if err != nil { @@ -180,16 +196,7 @@ func (rm *RunManager) signalActivity() { } func (rm *RunManager) NewRunID() string { - var id [16]byte - if _, err := rand.Read(id[:]); err != nil { - counter := atomic.AddUint64(&fallbackRunIDCounter, 1) - for i := range id { - id[i] = byte(counter >> (uint(i%8) * 8)) - } - } - id[6] = (id[6] & 0x0f) | 0x40 - id[8] = (id[8] & 0x3f) | 0x80 - return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", id[0:4], id[4:6], id[6:8], id[8:10], id[10:16]) + return NewRunID() } var fallbackRunIDCounter uint64 @@ -219,6 +226,11 @@ func (rm *RunManager) SubmitWithMetadata(id, repo, branch, inputSHA, outputSHA s } rm.mu.Lock() + if rm.closing { + rm.mu.Unlock() + cancel() + return RunSnapshot{}, ErrRunSubmissionClosed + } if _, exists := rm.runs[id]; exists { rm.mu.Unlock() return RunSnapshot{}, ErrRunIDExists @@ -254,6 +266,34 @@ func (rm *RunManager) SubmitWithMetadata(id, repo, branch, inputSHA, outputSHA s return r.snapshot(), nil } +func (rm *RunManager) BeginShutdown() error { + rm.mu.Lock() + defer rm.mu.Unlock() + if rm.closing { + return ErrRunSubmissionClosed + } + for _, r := range rm.runs { + snapshot := r.snapshot() + if snapshot.Status == RunQueued || snapshot.Status == RunRunning || snapshot.Status == RunAwaitingReview || snapshot.Status == RunAwaitingMerge { + return fmt.Errorf("daemon: active run %q remains in state %s", snapshot.ID, snapshot.Status) + } + } + rm.closing = true + return nil +} + +func (rm *RunManager) StopAccepting() { + rm.mu.Lock() + rm.closing = true + rm.mu.Unlock() +} + +func (rm *RunManager) Accepting() bool { + rm.mu.Lock() + defer rm.mu.Unlock() + return !rm.closing +} + func (rm *RunManager) drain(rq *repoQueue) { for { rq.mu.Lock() diff --git a/internal/evidence/inrepo.go b/internal/evidence/inrepo.go index 8e9997e..f9b1896 100644 --- a/internal/evidence/inrepo.go +++ b/internal/evidence/inrepo.go @@ -87,6 +87,11 @@ func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) (err closeEvidenceDirectories(parentOpened) return fmt.Errorf("evidence: open evidence file %q: %w", name, openErr) } + if chmodErr := unix.Fchmod(fileFD, 0o600); chmodErr != nil { + _ = unix.Close(fileFD) + closeEvidenceDirectories(parentOpened) + return fmt.Errorf("evidence: restrict evidence file %q: %w", name, chmodErr) + } redacted := Redact(content) writeErr := writeEvidenceFile(fileFD, redacted) closeErr := unix.Close(fileFD) @@ -143,12 +148,28 @@ func openEvidenceDirectory(rootFD int, parts []string) (int, []int, error) { closeEvidenceDirectories(opened) return -1, nil, err } + if err := restrictEvidenceDirectory(fd); err != nil { + _ = unix.Close(fd) + closeEvidenceDirectories(opened) + return -1, nil, err + } opened = append(opened, fd) current = fd } return current, opened, nil } +func restrictEvidenceDirectory(fd int) error { + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return err + } + if stat.Mode&0o077 != 0 { + return unix.Fchmod(fd, 0o700) + } + return nil +} + func closeEvidenceDirectories(fds []int) { for i := len(fds) - 1; i >= 0; i-- { _ = unix.Close(fds[i]) diff --git a/internal/evidence/remediation_contract_test.go b/internal/evidence/remediation_contract_test.go index c6ebea1..b85d558 100644 --- a/internal/evidence/remediation_contract_test.go +++ b/internal/evidence/remediation_contract_test.go @@ -46,6 +46,12 @@ func TestInRepoStore_RedactsPublishedSecrets(t *testing.T) { func TestInRepoStore_UsesPrivateEvidencePermissions(t *testing.T) { repo := t.TempDir() store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := os.MkdirAll(filepath.Join(repo, ".made", "evidence", "run-1"), 0o755); err != nil { + t.Fatalf("create existing evidence directories: %v", err) + } + if err := os.WriteFile(filepath.Join(repo, ".made", "evidence", "run-1", "log.txt"), []byte("old"), 0o644); err != nil { + t.Fatalf("create existing evidence file: %v", err) + } if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte("bounded")}); err != nil { t.Fatalf("WriteEvidence: %v", err) } diff --git a/internal/github/client.go b/internal/github/client.go index f33d21a..8ebd0d7 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -105,7 +105,11 @@ func (c *Client) CreatePR(ctx context.Context, opts CreatePROptions) (string, er if res.ExitCode != 0 { return "", fmt.Errorf("github: gh pr create failed: %s", strings.TrimSpace(string(res.Stderr))) } - return lastLine(res.Stdout), nil + url := lastLine(res.Stdout) + if url == "" { + return "", fmt.Errorf("github: gh pr create returned an empty URL") + } + return url, nil } func (c *Client) findOpenPR(ctx context.Context, opts CreatePROptions) (string, error) { diff --git a/internal/orchestrator/scaffold.go b/internal/orchestrator/scaffold.go index c9b883a..437c47a 100644 --- a/internal/orchestrator/scaffold.go +++ b/internal/orchestrator/scaffold.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/douglasjarquin/made/internal/config" @@ -143,7 +144,7 @@ func refreshDefaultBranch(ctx context.Context, gatePath, defaultBranch string) e return fmt.Errorf("orchestrator: inspect origin remote: %w", err) } if remote.ExitCode != 0 { - return nil + 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}) @@ -151,6 +152,9 @@ func refreshDefaultBranch(ctx context.Context, gatePath, defaultBranch string) e return fmt.Errorf("orchestrator: refresh default branch %s: %w", defaultBranch, err) } if fetch.ExitCode != 0 { + if strings.Contains(string(fetch.Stderr), "couldn't find remote ref") { + return nil + } return fmt.Errorf("orchestrator: refresh default branch %s failed: %s", defaultBranch, string(fetch.Stderr)) } return nil diff --git a/internal/orchestrator/scaffold_test.go b/internal/orchestrator/scaffold_test.go index 62df86f..3061013 100644 --- a/internal/orchestrator/scaffold_test.go +++ b/internal/orchestrator/scaffold_test.go @@ -16,6 +16,7 @@ func TestSetupResolvesTrustedConfigWhenPresentOnDefaultBranch(t *testing.T) { if err := gitgate.InitBare(barePath); err != nil { t.Fatalf("InitBare: %v", err) } + runGit(t, barePath, "remote", "add", "origin", barePath) src := filepath.Join(dir, "src") initSourceRepo(t, src) @@ -47,6 +48,7 @@ func TestSetupResolvesEmptyTrustedConfigWhenMadeYmlMissingFromDefaultBranch(t *t if err := gitgate.InitBare(barePath); err != nil { t.Fatalf("InitBare: %v", err) } + runGit(t, barePath, "remote", "add", "origin", barePath) src := filepath.Join(dir, "src") initSourceRepo(t, src) @@ -70,6 +72,7 @@ func TestSetupResolvesEmptyTrustedConfigWhenDefaultBranchNeverFetched(t *testing if err := gitgate.InitBare(barePath); err != nil { t.Fatalf("InitBare: %v", err) } + runGit(t, barePath, "remote", "add", "origin", barePath) src := filepath.Join(dir, "src") initSourceRepo(t, src) @@ -93,6 +96,7 @@ func TestSetupCutsWorktreeAtExactPushedSHANotBranchTip(t *testing.T) { if err := gitgate.InitBare(barePath); err != nil { t.Fatalf("InitBare: %v", err) } + runGit(t, barePath, "remote", "add", "origin", barePath) src := filepath.Join(dir, "src") initSourceRepo(t, src) @@ -134,6 +138,7 @@ func TestSetupRecoversFromMidSetupPanicAndCleansUp(t *testing.T) { if err := gitgate.InitBare(barePath); err != nil { t.Fatalf("InitBare: %v", err) } + runGit(t, barePath, "remote", "add", "origin", barePath) src := filepath.Join(dir, "src") initSourceRepo(t, src) @@ -168,6 +173,7 @@ func TestRunWrapsSetupWorkAndCleanupWithPanicRecovery(t *testing.T) { if err := gitgate.InitBare(barePath); err != nil { t.Fatalf("InitBare: %v", err) } + runGit(t, barePath, "remote", "add", "origin", barePath) src := filepath.Join(dir, "src") initSourceRepo(t, src) diff --git a/internal/orchestrator/workfunc.go b/internal/orchestrator/workfunc.go index 3d7435e..98737e7 100644 --- a/internal/orchestrator/workfunc.go +++ b/internal/orchestrator/workfunc.go @@ -152,10 +152,11 @@ func (c *chain) requireDeliveryStages() error { if c.rc.Config.StageResult(name) != "skipped" { continue } - if name == stageNamePush || name == stageNamePR || name == stageNameCI { - if err := c.finish(name, "skipped", "stage disabled"); err != nil { - return err - } + if name == stageNameCI && c.rc.Config.NoCI { + continue + } + if err := c.finish(name, "skipped", "stage disabled"); err != nil { + return err } return fmt.Errorf("orchestrator: refusing delivery because required stage %q is disabled", name) } @@ -215,7 +216,7 @@ func (c *chain) stageFailure(stage, message string) error { func (c *chain) intentStage() error { c.start(stageNameIntent) - result, err := intent.Check(c.rc.Worktree.Path) + result, err := intent.CheckContext(c.ctx, c.rc.Worktree.Path) if err != nil { return err } @@ -230,7 +231,7 @@ func (c *chain) intentStage() error { func (c *chain) rebaseStage() error { c.start(stageNameRebase) - result, err := rebase.Run(c.rc.Worktree.Path, c.defaultBranch) + result, err := rebase.RunContext(c.ctx, c.rc.Worktree.Path, c.defaultBranch) if err != nil { return err } @@ -302,7 +303,7 @@ func (c *chain) testStage() error { func (c *chain) documentStage() error { c.start(stageNameDocument) - result, err := document.Run(c.rc.Worktree.Path, c.defaultBranch, deriveDocumentRules(c.rc.Config)) + result, err := document.RunContext(c.ctx, c.rc.Worktree.Path, c.defaultBranch, deriveDocumentRules(c.rc.Config)) if err != nil { return err } @@ -375,7 +376,10 @@ func (c *chain) prStage() (pr.Result, error) { EvidenceRef: deriveEvidenceRef(c.rc.Evidence, c.runID), }) if err != nil { - return pr.Result{}, err + if finishErr := c.finish(stageNamePR, stageResultFail, err.Error()); finishErr != nil { + return pr.Result{}, finishErr + } + return pr.Result{}, c.stageFailure(stageNamePR, err.Error()) } if !result.OK { if err := c.finish(stageNamePR, stageResultFail, result.Message); err != nil { diff --git a/internal/orchestrator/workfunc_test.go b/internal/orchestrator/workfunc_test.go index c77a10b..5cc7298 100644 --- a/internal/orchestrator/workfunc_test.go +++ b/internal/orchestrator/workfunc_test.go @@ -32,9 +32,14 @@ type wfFixture struct { func TestChain_RefusesDeliveryWhenRequiredStageDisabled(t *testing.T) { disabled := false + rm := daemon.NewRunManager() + runID := rm.NewRunID() + if _, err := rm.Submit(runID, "repo", "branch", func(context.Context, func(daemon.Event)) error { return nil }); err != nil { + t.Fatalf("Submit: %v", err) + } c := &chain{rc: &RunContext{Config: config.Config{ Stages: map[string]config.Stage{stageNameReview: {Enabled: &disabled}}, - }}} + }}, rm: rm, runID: runID} err := c.requireDeliveryStages() if err == nil { @@ -43,6 +48,10 @@ func TestChain_RefusesDeliveryWhenRequiredStageDisabled(t *testing.T) { if !strings.Contains(err.Error(), `required stage "review" is disabled`) { t.Fatalf("requireDeliveryStages error = %q, want disabled review stage", err) } + snapshot, ok := rm.Snapshot(runID) + if !ok || len(snapshot.Stages) != 1 || snapshot.Stages[0] != (daemon.StageResult{Name: stageNameReview, Result: "skipped"}) { + t.Fatalf("disabled stage snapshot = %+v, want review/skipped", snapshot.Stages) + } } func newWFFixture(t *testing.T) *wfFixture { diff --git a/internal/pipeline/document/document.go b/internal/pipeline/document/document.go index 596893e..bbc5b35 100644 --- a/internal/pipeline/document/document.go +++ b/internal/pipeline/document/document.go @@ -8,6 +8,7 @@ package document import ( + "context" "fmt" "os/exec" "path/filepath" @@ -31,7 +32,11 @@ type Result struct { // failing, worktreePath unreadable, etc); a policy violation is a normal // outcome reported via Result.Findings, not an error. func Run(worktreePath, baseBranch string, rules []Rule) (Result, error) { - changed, err := changedFiles(worktreePath, baseBranch) + return RunContext(context.Background(), worktreePath, baseBranch, rules) +} + +func RunContext(ctx context.Context, worktreePath, baseBranch string, rules []Rule) (Result, error) { + changed, err := changedFiles(ctx, worktreePath, baseBranch) if err != nil { return Result{}, fmt.Errorf("document: %w", err) } @@ -77,8 +82,8 @@ func Run(worktreePath, baseBranch string, rules []Rule) (Result, error) { }, nil } -func changedFiles(worktreePath, baseBranch string) ([]string, error) { - cmd := exec.Command("git", "-C", worktreePath, "diff", "--name-only", baseBranch+"...HEAD") +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() if err != nil { return nil, fmt.Errorf("git diff --name-only %s...HEAD: %w: %s", baseBranch, err, strings.TrimSpace(string(out))) diff --git a/internal/pipeline/intent/intent.go b/internal/pipeline/intent/intent.go index 42fde2d..2d91bc3 100644 --- a/internal/pipeline/intent/intent.go +++ b/internal/pipeline/intent/intent.go @@ -4,6 +4,7 @@ package intent import ( + "context" "fmt" "os/exec" "strings" @@ -18,12 +19,16 @@ type Result struct { // unreadable, git missing, etc); a missing/empty Intent trailer is a normal // outcome reported via Result.OK, not an error. func Check(repoPath string) (Result, error) { - message, err := commitMessage(repoPath) + return CheckContext(context.Background(), repoPath) +} + +func CheckContext(ctx context.Context, repoPath string) (Result, error) { + message, err := commitMessage(ctx, repoPath) if err != nil { return Result{}, err } - value, err := intentTrailerValue(repoPath, message) + value, err := intentTrailerValue(ctx, repoPath, message) if err != nil { return Result{}, err } @@ -41,8 +46,8 @@ func Check(repoPath string) (Result, error) { }, nil } -func commitMessage(repoPath string) (string, error) { - cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--format=%B", "HEAD") +func commitMessage(ctx context.Context, repoPath string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "log", "-1", "--format=%B", "HEAD") out, err := cmd.Output() if err != nil { return "", fmt.Errorf("intent: read tip commit message: %w", err) @@ -50,8 +55,8 @@ func commitMessage(repoPath string) (string, error) { return string(out), nil } -func intentTrailerValue(repoPath, message string) (string, error) { - cmd := exec.Command("git", "-C", repoPath, "interpret-trailers", "--parse") +func intentTrailerValue(ctx context.Context, repoPath, message string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "interpret-trailers", "--parse") cmd.Stdin = strings.NewReader(message) out, err := cmd.Output() if err != nil { diff --git a/internal/pipeline/pr/pr.go b/internal/pipeline/pr/pr.go index 1f19628..c493146 100644 --- a/internal/pipeline/pr/pr.go +++ b/internal/pipeline/pr/pr.go @@ -36,11 +36,6 @@ type Result struct { PRURL string } -// Run's error return is reserved for infrastructure/configuration failures -// (a nil client, missing required options); a PR request rejected by GitHub -// itself - auth failure, branch protection, network error - is a normal -// outcome reported via Result.OK, not an error, following the push stage's -// convention (internal/pipeline/push). func Run(ctx context.Context, ghClient *github.Client, opts Options) (Result, error) { if ghClient == nil { return Result{}, fmt.Errorf("pr: ghClient must not be nil") @@ -65,10 +60,7 @@ func Run(ctx context.Context, ghClient *github.Client, opts Options) (Result, er Head: opts.Head, }) if err != nil { - return Result{ - OK: false, - Message: err.Error(), - }, nil + return Result{}, fmt.Errorf("pr: GitHub API failure: %w", err) } return Result{ diff --git a/internal/pipeline/pr/pr_test.go b/internal/pipeline/pr/pr_test.go index 88d29c4..801d24b 100644 --- a/internal/pipeline/pr/pr_test.go +++ b/internal/pipeline/pr/pr_test.go @@ -94,22 +94,19 @@ func TestRun_RejectsEmptyEvidenceRef(t *testing.T) { } } -func TestRun_ReportsGHFailureAsResultNotError(t *testing.T) { +func TestRun_ReportsGHFailureAsInfrastructureError(t *testing.T) { c := newClient(t, []string{"FAKE_GH_EXIT_CODE=1", "FAKE_GH_STDERR=pr create failed: branch protection"}, "") - result, err := pr.Run(context.Background(), c, pr.Options{ + _, err := pr.Run(context.Background(), c, pr.Options{ Title: "made: automated change", Base: "main", Head: "made/run-999", EvidenceRef: "evidence/run-999/summary.txt", }) - if err != nil { - t.Fatalf("Run: expected a reported failure, not an error, got: %v", err) - } - if result.OK { - t.Fatalf("expected OK=false, got %+v", result) + if err == nil { + t.Fatal("expected GitHub API failure to be returned as infrastructure error") } - if result.Message == "" { - t.Fatal("expected a non-empty failure message") + if !strings.Contains(err.Error(), "GitHub API failure") { + t.Fatalf("expected infrastructure classification, got %v", err) } } diff --git a/internal/pipeline/rebase/rebase.go b/internal/pipeline/rebase/rebase.go index 7bac992..571d720 100644 --- a/internal/pipeline/rebase/rebase.go +++ b/internal/pipeline/rebase/rebase.go @@ -6,6 +6,7 @@ package rebase import ( + "context" "fmt" "os" "os/exec" @@ -24,7 +25,11 @@ type Result struct { // etc); a rebase conflict is a normal outcome reported via Result.OK, not an // error. func Run(worktreePath, defaultBranch string) (Result, error) { - cmd := exec.Command("git", "-C", worktreePath, "rebase", defaultBranch) + return RunContext(context.Background(), worktreePath, defaultBranch) +} + +func RunContext(ctx context.Context, worktreePath, defaultBranch string) (Result, error) { + cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rebase", defaultBranch) out, rebaseErr := cmd.CombinedOutput() if rebaseErr == nil { return Result{ @@ -33,16 +38,16 @@ func Run(worktreePath, defaultBranch string) (Result, error) { }, nil } - if !rebaseInProgress(worktreePath) { + if !rebaseInProgress(ctx, worktreePath) { return Result{}, fmt.Errorf("rebase: git rebase %s: %w: %s", defaultBranch, rebaseErr, strings.TrimSpace(string(out))) } - files, err := conflictingFiles(worktreePath) + files, err := conflictingFiles(ctx, worktreePath) if err != nil { return Result{}, fmt.Errorf("rebase: list conflicting files after failed rebase onto %s: %w", defaultBranch, err) } if len(files) == 0 { - if err := abortRebase(worktreePath); err != nil { + if err := abortRebase(ctx, worktreePath); err != nil { return Result{}, fmt.Errorf("rebase: failed without unmerged paths and abort failed: %w", err) } return Result{}, fmt.Errorf("rebase: git rebase %s failed without unmerged paths", defaultBranch) @@ -50,7 +55,7 @@ func Run(worktreePath, defaultBranch string) (Result, error) { // A halted stage must never leave the worktree mid-rebase, so whatever // runs next (a retry, another stage) always starts from a clean state. - if err := abortRebase(worktreePath); err != nil { + if err := abortRebase(ctx, worktreePath); err != nil { return Result{}, fmt.Errorf("rebase: abort after conflict onto %s: %w", defaultBranch, err) } @@ -61,8 +66,8 @@ func Run(worktreePath, defaultBranch string) (Result, error) { }, nil } -func conflictingFiles(worktreePath string) ([]string, error) { - cmd := exec.Command("git", "-C", worktreePath, "diff", "--name-only", "--diff-filter=U") +func conflictingFiles(ctx context.Context, worktreePath string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "diff", "--name-only", "--diff-filter=U") out, err := cmd.Output() if err != nil { return nil, err @@ -77,8 +82,8 @@ func conflictingFiles(worktreePath string) ([]string, error) { return files, nil } -func abortRebase(worktreePath string) error { - cmd := exec.Command("git", "-C", worktreePath, "rebase", "--abort") +func abortRebase(ctx context.Context, worktreePath string) error { + cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rebase", "--abort") out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("git rebase --abort: %w: %s", err, strings.TrimSpace(string(out))) @@ -86,8 +91,8 @@ func abortRebase(worktreePath string) error { return nil } -func rebaseInProgress(worktreePath string) bool { - out, err := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-dir").Output() +func rebaseInProgress(ctx context.Context, worktreePath string) bool { + out, err := exec.CommandContext(ctx, "git", "-C", worktreePath, "rev-parse", "--git-dir").Output() if err != nil { return false } diff --git a/internal/pipeline/review/remediation_contract_test.go b/internal/pipeline/review/remediation_contract_test.go index 4957934..2ba4b44 100644 --- a/internal/pipeline/review/remediation_contract_test.go +++ b/internal/pipeline/review/remediation_contract_test.go @@ -3,6 +3,7 @@ package review_test import ( "os" "path/filepath" + "strings" "testing" "github.com/douglasjarquin/made/internal/agent" @@ -32,3 +33,61 @@ func TestRun_AutoFixRequiresCleanStateBeforeApplyingReturnedPatch(t *testing.T) t.Fatal("review auto-fix mutated a dirty worktree instead of refusing before apply") } } + +func TestRun_AutoFixRejectsUnauthorizedDeletionBeforeApplyingPatch(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + + writeFile(t, wt.Path, "unauthorized.txt", "must survive\n") + run(t, wt.Path, "add", "unauthorized.txt") + run(t, wt.Path, "commit", "-q", "-m", "seed unauthorized path") + patch := deletionAndAllowedPatch(t, wt.Path) + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "reject unauthorized deletion", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + + if _, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }); err == nil { + t.Fatal("review auto-fix accepted a patch deleting an unreturned tracked path") + } + if _, err := os.Stat(filepath.Join(wt.Path, "unauthorized.txt")); err != nil { + t.Fatalf("unauthorized tracked file was removed before rejection: %v", err) + } + if got := run(t, wt.Path, "status", "--porcelain"); got != "" { + t.Fatalf("rejected auto-fix left worktree dirty: %q", got) + } +} + +func TestRun_AutoFixRejectsForbiddenPatchHeaderBeforeApplyingPatch(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + + patch := strings.Join([]string{ + "diff --git a/reviewed.txt b/../../unauthorized.txt", + "--- a/reviewed.txt", + "+++ b/../../unauthorized.txt", + "@@ -1 +1 @@", + "-line one", + "+changed", + "", + }, "\n") + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "reject forbidden header", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + + if _, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }); err == nil { + t.Fatal("review auto-fix accepted a forbidden patch header") + } + if got := run(t, wt.Path, "status", "--porcelain"); got != "" { + t.Fatalf("forbidden auto-fix left worktree dirty: %q", got) + } +} diff --git a/internal/pipeline/review/review.go b/internal/pipeline/review/review.go index 6383491..7fa1176 100644 --- a/internal/pipeline/review/review.go +++ b/internal/pipeline/review/review.go @@ -199,15 +199,30 @@ func gitOutput(worktreePath string, args ...string) (string, error) { func patchPaths(patch string) ([]string, error) { seen := make(map[string]struct{}) + var oldPath string for _, line := range strings.Split(patch, "\n") { - if !strings.HasPrefix(line, "+++ b/") { + if strings.HasPrefix(line, "--- ") { + var err error + oldPath, err = patchHeaderPath(strings.TrimPrefix(line, "--- ")) + if err != nil { + return nil, err + } + continue + } + if !strings.HasPrefix(line, "+++ ") { continue } - path, err := cleanReturnedPath(strings.TrimPrefix(line, "+++ b/")) + newPath, err := patchHeaderPath(strings.TrimPrefix(line, "+++ ")) if err != nil { return nil, err } - seen[path] = struct{}{} + if oldPath != "" { + seen[oldPath] = struct{}{} + } + if newPath != "" { + seen[newPath] = struct{}{} + } + oldPath = "" } if len(seen) == 0 { return nil, fmt.Errorf("auto-fix patch contains no returned paths") @@ -219,6 +234,21 @@ func patchPaths(patch string) ([]string, error) { return paths, nil } +func patchHeaderPath(path string) (string, error) { + fields := strings.Fields(path) + if len(fields) == 0 { + return "", fmt.Errorf("auto-fix patch contains an empty file header") + } + path = fields[0] + if path == "/dev/null" { + return "", nil + } + if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") { + path = path[2:] + } + return cleanReturnedPath(path) +} + func cleanReturnedPath(path string) (string, error) { clean := filepath.Clean(path) if clean == "." || filepath.IsAbs(path) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || clean == ".git" || strings.HasPrefix(clean, ".git"+string(filepath.Separator)) { diff --git a/internal/pipeline/review/testhelpers_test.go b/internal/pipeline/review/testhelpers_test.go index 12d4507..564fe32 100644 --- a/internal/pipeline/review/testhelpers_test.go +++ b/internal/pipeline/review/testhelpers_test.go @@ -111,3 +111,14 @@ func autoFixPatch(t *testing.T, worktreePath string) string { writeFile(t, scratch, "reviewed.txt", "line one\nauto-fixed line\n") return run(t, scratch, "diff") } + +func deletionAndAllowedPatch(t *testing.T, worktreePath string) string { + t.Helper() + scratch := t.TempDir() + run(t, scratch, "clone", "-q", worktreePath, ".") + writeFile(t, scratch, "reviewed.txt", "line one\nauto-fixed line\n") + if err := os.Remove(filepath.Join(scratch, "unauthorized.txt")); err != nil { + t.Fatalf("remove unauthorized patch fixture: %v", err) + } + return run(t, scratch, "diff") +} From 1bd7ae2be49765638da54ddcb081bdea76ea318b Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:29:34 -0400 Subject: [PATCH 11/53] docs: record final remediation evidence --- docs/remediation/made-remediation-p1p3b.md | 30 ++++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 366f96f..b83a2b0 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -52,12 +52,20 @@ The final validation-fix commit is `d866545b1391e25f738930200566ab7dcff5c4e5` wi The final boundary-hardening commit is `1f8055eeab3fb93b34bf764911f0aec7bfb54767` with subject `fix: close remediation boundary review gaps`. -That follow-up hardens Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled required-stage delivery refusal, and environment-injected real Consigliere compatibility testing. +The evidence-only QA commit is `af5010d7bd910bfa829e030c0198cae909188e69` with subject `docs: record final remediation QA evidence`. + +The final executable boundary-fix commit is `d45f5c518664db5f73f42d1d4db595216331f24b` with subject `fix: close final remediation boundary gaps`. + +Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, and final review/API boundaries. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. The daemon persists complete run snapshots in an fsync-backed append-only WAL and persists idempotent gate submissions in an fsync-backed spool keyed by gate, ref, and SHA. +Run snapshots remain retained in the local WAL until explicit operator archival outside this task, while evidence is bounded to 1 MiB per file and 4 MiB per run. + +Submission admission is closed under the same mutex as the shutdown check, so a concurrent run or gate submission cannot arrive after a successful shutdown decision. + The public surface is versioned and structured through `made capabilities --json`, `made run submit`, `made run status`, `made run list`, `made run cancel`, `made review decide`, and `made doctor --json`. The lifecycle states are `queued`, `running`, `awaiting_review`, `awaiting_merge`, `succeeded`, `failed`, `canceled`, and `superseded`. @@ -72,15 +80,17 @@ Pending gate submissions are replayed on daemon startup and remain undrained whe The orchestrator records stages, disabled stages as `skipped`, findings, decisions, PR URL, errors, supersession, cancellation, and submission events. +The direct `run.submit` record API now fails its execution promptly when no gate work specification is supplied, rather than leaving a cancellation-only worker active indefinitely; real pipeline execution remains owned by `gate.notify-push`. + ## Phase 3 implementation and GREEN The `.made.yml` boundary is versioned, strictly decoded, and rejects unknown or zero-value configuration. -The pipeline refreshes the real remote default branch before trusted policy or rebase decisions. +The pipeline refreshes the real remote default branch before trusted policy or rebase decisions and fails closed for unavailable remotes, while treating an absent default ref as an empty trusted-policy case. The review adapter validates a Made-owned schema and uses the installed structured Codex invocation. -Auto-fixes require a clean state, require explicitly returned tracked paths, reject forbidden or untracked paths, record pre-fix and post-fix SHAs, and rerun relevant validation. +Auto-fixes require a clean state, require explicitly returned tracked paths, reject forbidden or untracked paths and forbidden patch headers before apply, record pre-fix and post-fix SHAs, and rerun relevant validation. Rebase failures are classified as conflicts only when unmerged paths exist. @@ -90,11 +100,13 @@ Pull request creation is idempotent by repository, base, and head. CI polling uses actual check status, conclusion, workflow run ID, and details URL, while authentication and API failures are infrastructure failures. +Pull-request GitHub authentication and API failures now remain infrastructure errors instead of being represented as failed checks. + The Made CI workflow validates the pinned Go version with race, vet, and pinned lint jobs. ## Validation evidence -The final executable source SHA covered by this validation section is `1f8055eeab3fb93b34bf764911f0aec7bfb54767`. +The final executable source SHA covered by this validation section is `d45f5c518664db5f73f42d1d4db595216331f24b`. The evidence-only report commits before that SHA did not change executable source, tests, configuration, or CI. @@ -102,9 +114,11 @@ The final validation set was `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign All final validation commands exited `0`, and golangci-lint reported `0 issues`. -The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-1f8055e-final.log`, and its final marker was `manual-qa-1f8055e=PASS` at that full SHA. +The full validation transcript is `/tmp/made-remediation-p1p3b-full-precommit.log`. + +The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-d45f5c5-final.log`, and its final marker was `manual-qa-d45f5c5=PASS` at that full SHA. -That scenario used a fresh binary and task-local Made homes to observe capabilities, doctor through the real Consigliere script, exact submission and SHA preservation, exact status and active-list queries, review decision, cancellation, shutdown refusal, WAL restart, duplicate singleton start, stale PID handling, regular-file, symlink, and directory socket rejection, and predecessor command rejection. +That scenario used a fresh binary and task-local Made homes to observe capabilities, doctor through the real Consigliere script, durable offline gate spooling, exact submission and SHA preservation, exact status and active-list queries, versioned review decision output, WAL restart, duplicate singleton start, stale PID handling, regular-file, symlink, and directory socket rejection, and predecessor command rejection. The compatibility subscenario used the real `bin/cs-made-lib.sh` script, the real Made binary, a strict fake `gh auth status` boundary, a task-local unavailable Herdr socket, and accepted the expected nonzero health exit only after asserting valid versioned JSON and authenticated GitHub state. @@ -116,11 +130,11 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..1f8055eeab3fb93b34bf764911f0aec7bfb54767`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..d45f5c518664db5f73f42d1d4db595216331f24b`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. -The final validation-fix commit-only diff is `git diff --name-status deea4ff0a37c7ac2118a2125a487316b65162d8b..d866545b1391e25f738930200566ab7dcff5c4e5` and contains only the 12 review-boundary files changed by that follow-up. +The final boundary-fix commit-only diff is `git diff --name-status af5010d7bd910bfa829e030c0198cae909188e69..d45f5c518664db5f73f42d1d4db595216331f24b` and contains only Made implementation and test files. The separate Made project plan `plans/made-rewrite.md` retains its broader F3 checkbox because that criterion requires running the full Consigliere `--mode made` soldier flow and changing shared Herdr lifecycle state. From da8f5653bc3e13877480728bc3dd2daf296e7dd2 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:15:42 -0400 Subject: [PATCH 12/53] fix: harden final remediation boundaries --- cmd/made/daemon.go | 143 +++++++++++++++--- cmd/made/gate.go | 42 +++-- cmd/made/gate_notify_push_test.go | 67 +++++++- cmd/made/remediation_contract_test.go | 55 +++++-- cmd/made/runcommands.go | 15 +- cmd/made/runhandlers.go | 54 +++++-- internal/api/remediation_contract_test.go | 21 +++ internal/api/server.go | 20 +++ internal/config/config.go | 21 +++ internal/config/remediation_contract_test.go | 27 ++++ internal/daemon/contract.go | 15 ++ internal/daemon/spool.go | 13 +- internal/evidence/inrepo.go | 61 ++++++++ internal/evidence/redact.go | 2 + .../evidence/remediation_contract_test.go | 39 ++++- internal/evidence/store.go | 4 + internal/orchestrator/params.go | 15 ++ internal/orchestrator/scaffold.go | 11 ++ internal/orchestrator/scaffold_test.go | 31 ++++ internal/orchestrator/workfunc.go | 22 ++- internal/orchestrator/workfunc_test.go | 4 + 21 files changed, 605 insertions(+), 77 deletions(-) diff --git a/cmd/made/daemon.go b/cmd/made/daemon.go index 4d2a52c..1cb7fe1 100644 --- a/cmd/made/daemon.go +++ b/cmd/made/daemon.go @@ -183,11 +183,12 @@ func replayPendingSubmissions(ctx context.Context, rm *daemon.RunManager, review } for _, submission := range pending { params, err := json.Marshal(gateNotifyPushParams{ - GatePath: submission.Gate, - Ref: submission.Ref, - NewSHA: submission.SHA, - RunID: submission.RunID, - Replay: true, + GatePath: submission.Gate, + Ref: submission.Ref, + NewSHA: submission.SHA, + RunID: submission.RunID, + OutputSHA: submission.OutputSHA, + Replay: true, }) if err != nil { return @@ -261,7 +262,7 @@ const debugHandlersEnv = "MADE_DEBUG_HANDLERS" func registerDaemonHandlers(srv *api.Server, rm *daemon.RunManager, store *daemon.ReviewDecisions, spool *daemon.GateSpool, cancel context.CancelFunc, admission ...*sync.Mutex) { srv.Handle("run.status", runStatusHandler(rm)) - srv.Handle("run.submit", runSubmitHandler(rm, admission...)) + srv.Handle("run.submit", runSubmitHandler(rm, store, spool, admission...)) srv.Handle("run.list", runListHandler(rm)) srv.Handle("run.cancel", runCancelHandler(rm)) srv.Handle("review.decide", reviewDecideRunHandler(rm, store)) @@ -336,12 +337,13 @@ func validateBareGateRepo(path string) error { const gateNotifyPushDefaultBranchTimeout = 10 * time.Second type gateNotifyPushParams struct { - GatePath string `json:"gate_path"` - OldSHA string `json:"old_sha"` - NewSHA string `json:"new_sha"` - Ref string `json:"ref"` - RunID string `json:"run_id,omitempty"` - Replay bool `json:"replay,omitempty"` + GatePath string `json:"gate_path"` + OldSHA string `json:"old_sha"` + NewSHA string `json:"new_sha"` + Ref string `json:"ref"` + RunID string `json:"run_id,omitempty"` + OutputSHA string `json:"output_sha,omitempty"` + Replay bool `json:"replay,omitempty"` } type gateNotifyPushResult struct { @@ -364,6 +366,30 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review if p.GatePath == "" || p.Ref == "" || p.NewSHA == "" { return nil, fmt.Errorf("gate.notifyPush: gate_path, ref, and new_sha are required") } + if p.OldSHA != "" && !validSHA(p.OldSHA) { + return nil, fmt.Errorf("gate.notifyPush: old_sha must be a valid 40-character SHA") + } + if p.OutputSHA != "" && !validSHA(p.OutputSHA) { + return nil, fmt.Errorf("gate.notifyPush: output_sha must be a valid 40-character SHA") + } + if err := validateGateSubmission(ctx, spool.Path(), p.GatePath, p.Ref, p.NewSHA); err != nil { + return nil, fmt.Errorf("gate.notifyPush: %w", err) + } + + var submission daemon.GateSubmission + var created bool + if p.NewSHA != gitZeroSHAValue { + if p.RunID == "" { + p.RunID = rm.NewRunID() + } + var err error + submission, created, err = spool.Enqueue(daemon.GateSubmission{ + Gate: p.GatePath, Ref: p.Ref, SHA: p.NewSHA, RunID: p.RunID, OutputSHA: p.OutputSHA, + }) + if err != nil { + return nil, fmt.Errorf("gate.notifyPush: enqueue submission: %w", err) + } + } branchCtx, cancel := context.WithTimeout(ctx, gateNotifyPushDefaultBranchTimeout) defer cancel() @@ -378,10 +404,12 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review decision := gitgate.ClassifyRef(p.Ref, defaultBranch, p.OldSHA, p.NewSHA) if !decision.Accept { - if p.Replay { - if err := spool.Drain(daemon.GateSubmission{Gate: p.GatePath, Ref: p.Ref, SHA: p.NewSHA, RunID: p.RunID}); err != nil { + if submission.RunID != "" { + if err := spool.Drain(submission); err != nil { return nil, fmt.Errorf("gate.notifyPush: drain rejected replay: %w", err) } + } + if p.Replay { return gateNotifyPushResult{}, nil } return nil, fmt.Errorf("gate.notifyPush: %s", decision.Message) @@ -402,14 +430,7 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review gatePath := p.GatePath worktreesDir := gitgate.WorktreesDir(gatePath) newSHA := p.NewSHA - runID := p.RunID - if runID == "" { - runID = rm.NewRunID() - } - submission, created, err := spool.Enqueue(daemon.GateSubmission{Gate: p.GatePath, Ref: p.Ref, SHA: p.NewSHA, RunID: runID}) - if err != nil { - return nil, fmt.Errorf("gate.notifyPush: enqueue submission: %w", err) - } + runID := submission.RunID if !created { if _, ok := rm.Snapshot(submission.RunID); ok { if err := rm.AppendSubmissionEvent(submission.RunID, daemon.SubmissionEvent{Gate: p.GatePath, Ref: p.Ref, InputSHA: p.NewSHA, Kind: "push"}); err != nil { @@ -431,7 +452,7 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review orchestrator.NewWorkFunc(rm, reviewDecisions, emit, runID, defaultBranch, branch, orchestrator.Options{})) } - if _, err := rm.SubmitWithMetadata(runID, repo, branch, p.NewSHA, "", work); err != nil { + if _, err := rm.SubmitWithMetadata(runID, repo, branch, p.NewSHA, p.OutputSHA, work); err != nil { return nil, fmt.Errorf("gate.notifyPush: submit run: %w", err) } if err := rm.AppendSubmissionEvent(runID, daemon.SubmissionEvent{Gate: p.GatePath, Ref: p.Ref, InputSHA: p.NewSHA, Kind: "push"}); err != nil { @@ -445,6 +466,82 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review } } +func validateGateSubmission(ctx context.Context, spoolPath, gatePath, ref, newSHA string) error { + if !validSHA(newSHA) { + return fmt.Errorf("new_sha must be a valid 40-character SHA") + } + if !strings.HasPrefix(ref, "refs/heads/") { + return fmt.Errorf("ref %q is not a branch ref", ref) + } + home, err := filepath.Abs(filepath.Dir(spoolPath)) + if err != nil { + return fmt.Errorf("resolve Made home: %w", err) + } + absGate, err := filepath.Abs(gatePath) + if err != nil { + return fmt.Errorf("resolve gate path: %w", err) + } + rel, err := filepath.Rel(filepath.Join(home, "gates"), absGate) + if err != nil { + return fmt.Errorf("inspect gate layout: %w", err) + } + parts := strings.Split(filepath.Clean(rel), string(filepath.Separator)) + if len(parts) != 2 || parts[1] != "gate.git" || len(parts[0]) != 64 || !hexString(parts[0]) { + return fmt.Errorf("gate path %q is outside the Made-owned gate layout", gatePath) + } + info, err := os.Lstat(absGate) + if err != nil { + return fmt.Errorf("inspect gate path: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("gate path %q is not an owned directory", gatePath) + } + realHome, err := filepath.EvalSymlinks(home) + if err != nil { + return fmt.Errorf("resolve Made home: %w", err) + } + realGate, err := filepath.EvalSymlinks(absGate) + if err != nil { + return fmt.Errorf("resolve gate path: %w", err) + } + realRel, err := filepath.Rel(filepath.Join(realHome, "gates"), realGate) + if err != nil { + return fmt.Errorf("inspect resolved gate layout: %w", err) + } + if filepath.Clean(realRel) != filepath.Clean(rel) { + return fmt.Errorf("gate path %q resolves outside its Made-owned layout", gatePath) + } + if err := validateBareGateRepo(absGate); err != nil { + return err + } + if newSHA == gitZeroSHAValue { + return nil + } + res, err := exec.Run(ctx, exec.Command{ + Name: "git", + Args: []string{"-C", absGate, "rev-parse", "--verify", ref + "^{commit}"}, + }) + if err != nil { + return fmt.Errorf("inspect pushed head: %w", err) + } + if res.ExitCode != 0 { + return fmt.Errorf("inspect pushed head failed: %s", strings.TrimSpace(string(res.Stderr))) + } + if strings.TrimSpace(string(res.Stdout)) != newSHA { + return fmt.Errorf("input SHA %s does not match gate head %s", newSHA, strings.TrimSpace(string(res.Stdout))) + } + return nil +} + +func hexString(value string) bool { + for _, r := range value { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + return false + } + } + return true +} + type debugSubmitCancellableRunParams struct { ID string `json:"id"` Repo string `json:"repo"` diff --git a/cmd/made/gate.go b/cmd/made/gate.go index 53f5dc7..8900eef 100644 --- a/cmd/made/gate.go +++ b/cmd/made/gate.go @@ -71,20 +71,13 @@ func runGateNotifyPushCommand(args []string, stdout, stderr *os.File) int { client, err := api.Dial(api.SocketPath(home)) if err != nil { - if *newSHA != gitZeroSHAValue { - spool, spoolErr := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) - if spoolErr == nil { - _, _, spoolErr = spool.Enqueue(daemon.GateSubmission{ - Gate: *gatePath, Ref: *ref, SHA: *newSHA, RunID: daemon.NewRunID(), - }) - } - if spoolErr != nil { - _, _ = fmt.Fprintln(stderr, "gate notify-push: dial daemon:", err, "; durable queue:", spoolErr) - } else { - _, _ = fmt.Fprintln(stderr, "gate notify-push: daemon unavailable; submission durably queued:", err) - } - } else { + queueErr := queueOfflineGateSubmission(home, *gatePath, *ref, *newSHA) + if queueErr != nil { + _, _ = fmt.Fprintln(stderr, "gate notify-push: dial daemon:", err, "; durable queue:", queueErr) + } else if *newSHA == gitZeroSHAValue { _, _ = fmt.Fprintln(stderr, "gate notify-push: ref deletion does not require a run:", err) + } else { + _, _ = fmt.Fprintln(stderr, "gate notify-push: daemon unavailable; submission durably queued:", err) } return 0 } @@ -97,7 +90,16 @@ func runGateNotifyPushCommand(args []string, stdout, stderr *os.File) int { NewSHA: *newSHA, Ref: *ref, }, &result); err != nil { - _, _ = fmt.Fprintln(stderr, "gate notify-push:", err) + if *newSHA == gitZeroSHAValue { + _, _ = fmt.Fprintln(stderr, "gate notify-push:", err, "; ref deletion does not require a run") + return 0 + } + queueErr := queueOfflineGateSubmission(home, *gatePath, *ref, *newSHA) + if queueErr != nil { + _, _ = fmt.Fprintln(stderr, "gate notify-push:", err, "; durable queue:", queueErr) + } else { + _, _ = fmt.Fprintln(stderr, "gate notify-push:", err, "; submission durably queued") + } return 0 } @@ -109,6 +111,18 @@ func runGateNotifyPushCommand(args []string, stdout, stderr *os.File) int { return 0 } +func queueOfflineGateSubmission(home, gatePath, ref, newSHA string) error { + if newSHA == gitZeroSHAValue { + return nil + } + spool, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if err != nil { + return err + } + _, _, err = spool.Enqueue(daemon.GateSubmission{Gate: gatePath, Ref: ref, SHA: newSHA, RunID: daemon.NewRunID()}) + return err +} + func runGateAdmitPushCommand(args []string, stdout, stderr *os.File) int { fs := flag.NewFlagSet("made gate admit-push", flag.ContinueOnError) fs.SetOutput(stderr) diff --git a/cmd/made/gate_notify_push_test.go b/cmd/made/gate_notify_push_test.go index 73c3e6f..69edbd4 100644 --- a/cmd/made/gate_notify_push_test.go +++ b/cmd/made/gate_notify_push_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "os" "path/filepath" @@ -143,6 +144,63 @@ func TestGateNotifyPushRPC_RejectedRefCreatesNoRun(t *testing.T) { } } +func TestGateNotifyPushRPC_RejectsInputSHAThatIsNotGateHead(t *testing.T) { + home := shortTempDir(t) + rm, client := startTestDaemon(t, home) + barePath, sourceDir := setupGateFixture(t, home) + testGit(t, sourceDir, "checkout", "-b", "feature-head") + sha := pushFeatureCommit(t, sourceDir, "feature-head", "v1\n", "feature head") + wrongSHA := strings.Repeat("b", 40) + if wrongSHA == sha { + t.Fatal("test setup bug: wrong SHA unexpectedly equals pushed SHA") + } + + _, err := client.Call("gate.notifyPush", gateNotifyPushParams{ + GatePath: barePath, + OldSHA: gitZeroSHA, + NewSHA: wrongSHA, + Ref: "refs/heads/feature-head", + }) + if err == nil || !strings.Contains(err.Error(), "does not match gate head") { + t.Fatalf("gate.notifyPush accepted an input SHA that is not the gate head: %v", err) + } + if len(rm.List()) != 0 { + t.Fatalf("unauthorized input SHA created a run: %+v", rm.List()) + } +} + +func TestGateNotifyPushRPC_RetainsSubmissionWhenRemoteRefreshFails(t *testing.T) { + home := shortTempDir(t) + rm, _ := startTestDaemon(t, home) + barePath, sourceDir := setupGateFixture(t, home) + testGit(t, sourceDir, "checkout", "-b", "feature-offline") + sha := pushFeatureCommit(t, sourceDir, "feature-offline", "v1\n", "feature offline") + missingRemote := filepath.Join(home, "missing-remote.git") + testGit(t, barePath, "remote", "set-url", "origin", missingRemote) + spool, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + handler := gateNotifyPushHandler(rm, daemon.NewReviewDecisions(), spool) + params, marshalErr := json.Marshal(gateNotifyPushParams{ + GatePath: barePath, + OldSHA: gitZeroSHA, + NewSHA: sha, + Ref: "refs/heads/feature-offline", + }) + if marshalErr != nil { + t.Fatalf("marshal gate.notifyPush params: %v", marshalErr) + } + _, err = handler(context.Background(), params) + if err == nil { + t.Fatal("gate.notifyPush succeeded despite an unavailable remote") + } + pending := spool.Pending() + if len(pending) != 1 || pending[0].Gate != barePath || pending[0].SHA != sha || pending[0].RunID == "" { + t.Fatalf("accepted submission was not retained in the durable spool: %+v", pending) + } +} + func TestGateNotifyPushRPC_RefDeletionCreatesNoRun(t *testing.T) { home := shortTempDir(t) rm, client := startTestDaemon(t, home) @@ -173,10 +231,6 @@ func TestGateNotifyPushRPC_SupersededPushValidatesNewestSHA(t *testing.T) { testGit(t, sourceDir, "checkout", "-b", "feature-x") sha1 := pushFeatureCommit(t, sourceDir, "feature-x", "v1\n", "feature commit 1") - sha2 := pushFeatureCommit(t, sourceDir, "feature-x", "v2\n", "feature commit 2") - if sha1 == sha2 { - t.Fatal("test setup bug: expected two distinct commits") - } repo := gateRepoIdentifier(barePath) @@ -209,6 +263,11 @@ func TestGateNotifyPushRPC_SupersededPushValidatesNewestSHA(t *testing.T) { t.Fatalf("expected first run still queued behind the blocker, got %+v (ok=%v)", snap, ok) } + sha2 := pushFeatureCommit(t, sourceDir, "feature-x", "v2\n", "feature commit 2") + if sha1 == sha2 { + t.Fatal("test setup bug: expected two distinct commits") + } + var result2 gateNotifyPushResult if err := client.CallInto("gate.notifyPush", gateNotifyPushParams{ GatePath: barePath, diff --git a/cmd/made/remediation_contract_test.go b/cmd/made/remediation_contract_test.go index 7e5512b..ef5f022 100644 --- a/cmd/made/remediation_contract_test.go +++ b/cmd/made/remediation_contract_test.go @@ -94,8 +94,10 @@ func TestRun_SubmitJSONReturnsExactRunIDAndImmutableInputHead(t *testing.T) { t.Fatal("daemon did not become ready") } - inputSHA := strings.Repeat("a", 40) - code, stdout, stderr := captureRun(t, "run", "submit", "--json", "--repo", "/repo/example", "--branch", "feature", "--input-sha", inputSHA) + barePath, sourceDir := setupGateFixture(t, home) + testGit(t, sourceDir, "checkout", "-b", "feature-submit-cli") + inputSHA := pushFeatureCommit(t, sourceDir, "feature-submit-cli", "v1\n", "run submit cli") + code, stdout, stderr := captureRun(t, "run", "submit", "--json", "--gate", barePath, "--ref", "refs/heads/feature-submit-cli", "--old-sha", gitZeroSHA, "--input-sha", inputSHA) if code != 0 { t.Fatalf("run submit exit=%d stderr=%q", code, stderr) } @@ -107,36 +109,59 @@ func TestRun_SubmitJSONReturnsExactRunIDAndImmutableInputHead(t *testing.T) { if err := json.Unmarshal([]byte(stdout), &payload); err != nil { t.Fatalf("decode submit JSON: %v; stdout=%q", err, stdout) } - if payload.RunID == "" || payload.State != "queued" || payload.InputSHA != inputSHA { + if payload.RunID == "" || (payload.State != "queued" && payload.State != "running") || payload.InputSHA != inputSHA { t.Fatalf("submit payload = %+v, want exact queued run identity", payload) } } func TestRunSubmit_RejectsInvalidOutputSHA(t *testing.T) { rm := daemon.NewRunManager() - _, err := runSubmitHandler(rm)(context.Background(), []byte(`{"repo":"/repo","branch":"feature","input_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","output_sha":"not-a-sha"}`)) + _, err := runSubmitHandler(rm, nil, nil)(context.Background(), []byte(`{"repo":"/repo","branch":"feature","input_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","output_sha":"not-a-sha"}`)) if err == nil { t.Fatal("run.submit accepted an invalid output_sha") } } -func TestRunSubmit_DoesNotLeaveMissingExecutionWorkActive(t *testing.T) { +func TestRunSubmit_RequiresExecutableGateDescriptor(t *testing.T) { rm := daemon.NewRunManager() - result, err := runSubmitHandler(rm)(context.Background(), []byte(`{"repo":"/repo","branch":"feature","input_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`)) + _, err := runSubmitHandler(rm, nil, nil)(context.Background(), []byte(`{"repo":"/repo","branch":"feature","input_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`)) + if err == nil || !strings.Contains(err.Error(), "gate_path") { + t.Fatalf("run.submit accepted a submission without executable gate metadata: %v", err) + } +} + +func TestRunSubmit_ExecutesGatePipeline(t *testing.T) { + home := shortTempDir(t) + barePath, sourceDir := setupGateFixture(t, home) + testGit(t, sourceDir, "checkout", "-b", "feature-submit") + inputSHA := pushFeatureCommit(t, sourceDir, "feature-submit", "v1\n", "run submit") + + spool, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + rm := daemon.NewRunManager() + params, err := json.Marshal(runSubmitParams{ + GatePath: barePath, + Ref: "refs/heads/feature-submit", + OldSHA: gitZeroSHA, + InputSHA: inputSHA, + }) + if err != nil { + t.Fatalf("marshal run.submit params: %v", err) + } + result, err := runSubmitHandler(rm, daemon.NewReviewDecisions(), spool)(context.Background(), params) if err != nil { t.Fatalf("run.submit: %v", err) } report := result.(runActionReport) - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - snapshot, ok := rm.Snapshot(report.RunID) - if ok && snapshot.Status == daemon.RunFailed && snapshot.ExecutionFinished { - return - } - time.Sleep(5 * time.Millisecond) + if report.RunID == "" || report.InputSHA != inputSHA || (report.State != string(daemon.RunQueued) && report.State != string(daemon.RunRunning)) { + t.Fatalf("run.submit report = %+v, want exact queued immutable identity", report) + } + snapshot := waitForRunTerminal(t, rm, report.RunID, 10*time.Second) + if snapshot.StartedAt.IsZero() || len(snapshot.Stages) == 0 { + t.Fatalf("run.submit did not execute the gate pipeline: %+v", snapshot) } - snapshot, _ := rm.Snapshot(report.RunID) - t.Fatalf("run.submit left an unexecutable run active: %+v", snapshot) } func TestStatusHandler_RequiresExactRunID(t *testing.T) { diff --git a/cmd/made/runcommands.go b/cmd/made/runcommands.go index a113b91..cfe5d95 100644 --- a/cmd/made/runcommands.go +++ b/cmd/made/runcommands.go @@ -33,8 +33,11 @@ func runCapabilitiesCommand(args []string, stdout, stderr *os.File) int { type runSubmitParams struct { RunID string `json:"run_id,omitempty"` - Repo string `json:"repo"` - Branch string `json:"branch"` + GatePath string `json:"gate_path"` + Ref string `json:"ref"` + OldSHA string `json:"old_sha,omitempty"` + Repo string `json:"repo,omitempty"` + Branch string `json:"branch,omitempty"` InputSHA string `json:"input_sha"` OutputSHA string `json:"output_sha,omitempty"` } @@ -86,6 +89,9 @@ func runSubmitCommand(args []string, stdout, stderr *os.File) int { fs := flag.NewFlagSet("made run submit", flag.ContinueOnError) fs.SetOutput(stderr) jsonOutput := fs.Bool("json", false, "output JSON") + gatePath := fs.String("gate", "", "bare Made gate repository path") + ref := fs.String("ref", "", "immutable branch ref") + oldSHA := fs.String("old-sha", "", "previous branch head SHA") repo := fs.String("repo", "", "repository identity") branch := fs.String("branch", "", "input branch") inputSHA := fs.String("input-sha", "", "immutable input commit SHA") @@ -108,7 +114,10 @@ func runSubmitCommand(args []string, stdout, stderr *os.File) int { } defer func() { _ = client.Close() }() var result runActionReport - if err := client.CallInto("run.submit", runSubmitParams{Repo: *repo, Branch: *branch, InputSHA: *inputSHA, OutputSHA: *outputSHA}, &result); err != nil { + if err := client.CallInto("run.submit", runSubmitParams{ + GatePath: *gatePath, Ref: *ref, OldSHA: *oldSHA, + Repo: *repo, Branch: *branch, InputSHA: *inputSHA, OutputSHA: *outputSHA, + }, &result); err != nil { _, _ = fmt.Fprintln(stderr, "made run submit:", err) return 1 } diff --git a/cmd/made/runhandlers.go b/cmd/made/runhandlers.go index 841a035..3ce147d 100644 --- a/cmd/made/runhandlers.go +++ b/cmd/made/runhandlers.go @@ -28,26 +28,60 @@ func runStatusHandler(rm *daemon.RunManager) api.HandlerFunc { } } -func runSubmitHandler(rm *daemon.RunManager, admission ...*sync.Mutex) api.HandlerFunc { - return func(_ context.Context, params json.RawMessage) (any, error) { +func runSubmitHandler(rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool, admission ...*sync.Mutex) api.HandlerFunc { + return func(ctx context.Context, params json.RawMessage) (any, error) { var p runSubmitParams if err := json.Unmarshal(params, &p); err != nil { return nil, fmt.Errorf("run.submit: invalid params: %w", err) } - if strings.TrimSpace(p.Repo) == "" || strings.TrimSpace(p.Branch) == "" || !validSHA(p.InputSHA) || (p.OutputSHA != "" && !validSHA(p.OutputSHA)) { - return nil, fmt.Errorf("run.submit: repo, branch, input_sha, and optional output_sha must use valid 40-character SHAs") + if strings.TrimSpace(p.GatePath) == "" || strings.TrimSpace(p.Ref) == "" { + return nil, fmt.Errorf("run.submit: gate_path and ref are required to execute a gate pipeline") } - unlock := lockAdmission(admission) - defer unlock() - if p.RunID == "" { - p.RunID = rm.NewRunID() + branch, ok := strings.CutPrefix(p.Ref, "refs/heads/") + if !ok || branch == "" { + return nil, fmt.Errorf("run.submit: ref must be a non-empty refs/heads branch") + } + if p.Branch != "" && p.Branch != branch { + return nil, fmt.Errorf("run.submit: branch %q does not match ref %q", p.Branch, p.Ref) + } + if p.Repo != "" && p.Repo != gateRepoIdentifier(p.GatePath) { + return nil, fmt.Errorf("run.submit: repo %q does not match the gate identity", p.Repo) + } + if !validSHA(p.InputSHA) || (p.OutputSHA != "" && !validSHA(p.OutputSHA)) || (p.OldSHA != "" && !validSHA(p.OldSHA)) { + return nil, fmt.Errorf("run.submit: input_sha, old_sha, and optional output_sha must use valid 40-character SHAs") } - snapshot, err := rm.SubmitWithMetadata(p.RunID, p.Repo, p.Branch, p.InputSHA, p.OutputSHA, func(context.Context, func(daemon.Event)) error { - return fmt.Errorf("run.submit: no executable gate work was supplied; submit through gate.notify-push") + if p.OldSHA == "" { + p.OldSHA = gitZeroSHAValue + } + if reviewDecisions == nil || spool == nil { + return nil, fmt.Errorf("run.submit: executable gate dependencies are unavailable") + } + request, err := json.Marshal(gateNotifyPushParams{ + GatePath: p.GatePath, + OldSHA: p.OldSHA, + NewSHA: p.InputSHA, + Ref: p.Ref, + RunID: p.RunID, + OutputSHA: p.OutputSHA, }) + if err != nil { + return nil, fmt.Errorf("run.submit: encode gate request: %w", err) + } + result, err := gateNotifyPushHandler(rm, reviewDecisions, spool, admission...)(ctx, request) if err != nil { return nil, err } + gateResult, ok := result.(gateNotifyPushResult) + if !ok { + return nil, fmt.Errorf("run.submit: unexpected gate submission response %T", result) + } + if gateResult.RunID == "" { + return nil, fmt.Errorf("run.submit: gate submission did not create a run") + } + snapshot, ok := rm.Snapshot(gateResult.RunID) + if !ok { + return nil, fmt.Errorf("run.submit: submitted run %q was not persisted", gateResult.RunID) + } return runActionReport{ SchemaVersion: 1, ProtocolVersion: api.Version, RunID: snapshot.ID, State: string(snapshot.Status), InputSHA: snapshot.InputSHA, OutputSHA: snapshot.OutputSHA, diff --git a/internal/api/remediation_contract_test.go b/internal/api/remediation_contract_test.go index 690c975..ef73e33 100644 --- a/internal/api/remediation_contract_test.go +++ b/internal/api/remediation_contract_test.go @@ -131,6 +131,27 @@ func TestServer_DuplicateListenPreservesOriginalOwner(t *testing.T) { } } +func TestPrepareSocket_PreservesLiveOwnerSocket(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatalf("listen live owner socket: %v", err) + } + defer func() { _ = listener.Close() }() + + if err := api.PrepareSocket(path); err == nil { + t.Fatal("PrepareSocket replaced a live owner socket") + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("live owner socket was not preserved: %v", err) + } + conn, err := net.DialTimeout("unix", path, 100*time.Millisecond) + if err != nil { + t.Fatalf("preserved live owner socket is not reachable: %v", err) + } + _ = conn.Close() +} + func TestServer_CloseDoesNotRemoveSuccessorSocket(t *testing.T) { path := filepath.Join(tempSocketDir(t), "daemon.sock") first := api.NewServer(path) diff --git a/internal/api/server.go b/internal/api/server.go index 3f53461..2c3f0f0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -9,6 +9,7 @@ import ( "os" "sync" "syscall" + "time" ) type HandlerFunc func(ctx context.Context, params json.RawMessage) (any, error) @@ -87,12 +88,31 @@ func PrepareSocket(socketPath string) error { if !ok || uint32(stat.Uid) != uint32(os.Getuid()) { return fmt.Errorf("api: refusing socket path %s owned by another user", socketPath) } + live, err := socketIsLive(socketPath) + if err != nil { + return fmt.Errorf("api: cannot prove owner socket %s is stale: %w", socketPath, err) + } + if live { + return fmt.Errorf("api: refusing to replace live owner socket %s", socketPath) + } if err := os.Remove(socketPath); err != nil { return fmt.Errorf("api: remove stale owner socket %s: %w", socketPath, err) } return nil } +func socketIsLive(socketPath string) (bool, error) { + conn, err := net.DialTimeout("unix", socketPath, 100*time.Millisecond) + if err == nil { + _ = conn.Close() + return true, nil + } + if errors.Is(err, syscall.ECONNREFUSED) { + return false, nil + } + return false, err +} + func (s *Server) Serve(ctx context.Context) error { if s.ln == nil { return errors.New("api: Listen must be called before Serve") diff --git a/internal/config/config.go b/internal/config/config.go index d36b6a5..7ab4e1f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,11 @@ import ( const defaultCIRerunBudget = 2 +var validStageNames = map[string]struct{}{ + "intent": {}, "rebase": {}, "review": {}, "test": {}, "document": {}, + "lint": {}, "push": {}, "pr": {}, "ci": {}, +} + type Config struct { Version int `yaml:"version"` Document Document `yaml:"document"` @@ -44,6 +49,17 @@ func (c Config) StageResult(name string) string { return "pending" } +func (c Config) StageRequired(name string) bool { + switch name { + case "review": + return c.Review.Required + case "ci": + return c.CI.Required && !c.NoCI + default: + return true + } +} + type Document struct { Rules []DocumentRule `yaml:"rules"` } @@ -181,6 +197,11 @@ func loadConfigFile(path string) (cfg Config, exists bool, err error) { if cfg.Version != 1 { return Config{}, true, fmt.Errorf("versioned .made.yml requires version: 1, got %d", cfg.Version) } + for name := range cfg.Stages { + if _, ok := validStageNames[name]; !ok { + return Config{}, true, fmt.Errorf("versioned .made.yml has unknown stage %q", name) + } + } if !cfg.hasConfiguredValue() { return Config{}, true, fmt.Errorf("versioned .made.yml must configure at least one non-version field") } diff --git a/internal/config/remediation_contract_test.go b/internal/config/remediation_contract_test.go index 429cb67..2531e94 100644 --- a/internal/config/remediation_contract_test.go +++ b/internal/config/remediation_contract_test.go @@ -50,3 +50,30 @@ func TestConfig_NoCIIsRepresentedAsSkipped(t *testing.T) { t.Fatalf("NoCI stage result = %q, want skipped", got) } } + +func TestLoadConfig_RejectsUnknownStageKeys(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", "version: 1\nstages:\n reviw:\n enabled: false\n") + + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted an unknown stage key") + } +} + +func TestConfig_RequiredSettingsControlDisabledReviewAndCI(t *testing.T) { + disabled := false + cfg := Config{ + Stages: map[string]Stage{"review": {Enabled: &disabled}, "ci": {Enabled: &disabled}}, + } + if cfg.StageRequired("review") || cfg.StageRequired("ci") { + t.Fatal("review or CI was required without the trusted required setting") + } + cfg.Review.Required = true + cfg.CI.Required = true + if !cfg.StageRequired("review") || !cfg.StageRequired("ci") { + t.Fatal("trusted required settings did not make disabled review and CI stages required") + } + cfg.NoCI = true + if cfg.StageRequired("ci") { + t.Fatal("NoCI did not disable the CI requirement") + } +} diff --git a/internal/daemon/contract.go b/internal/daemon/contract.go index 6848db0..15139ba 100644 --- a/internal/daemon/contract.go +++ b/internal/daemon/contract.go @@ -47,6 +47,21 @@ func (rm *RunManager) SetPRURL(id, prURL string) error { return nil } +func (rm *RunManager) SetOutputSHA(id, outputSHA string) error { + r, ok := rm.lookupRun(id) + if !ok { + return fmt.Errorf("daemon: no run %q", id) + } + if outputSHA == "" { + return fmt.Errorf("daemon: output SHA is required") + } + r.update(func(snapshot *RunSnapshot) { snapshot.OutputSHA = outputSHA }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist output SHA for run %q: %w", id, err) + } + return nil +} + func (rm *RunManager) AddFindings(id string, findings []RunFinding) error { r, ok := rm.lookupRun(id) if !ok { diff --git a/internal/daemon/spool.go b/internal/daemon/spool.go index ab9b705..8bf257c 100644 --- a/internal/daemon/spool.go +++ b/internal/daemon/spool.go @@ -11,10 +11,11 @@ import ( ) type GateSubmission struct { - Gate string `json:"gate"` - Ref string `json:"ref"` - SHA string `json:"sha"` - RunID string `json:"run_id"` + Gate string `json:"gate"` + Ref string `json:"ref"` + SHA string `json:"sha"` + RunID string `json:"run_id"` + OutputSHA string `json:"output_sha,omitempty"` } type spoolRecord struct { @@ -29,6 +30,10 @@ type GateSpool struct { seen map[string]GateSubmission } +func (s *GateSpool) Path() string { + return s.path +} + func OpenGateSpool(path string) (*GateSpool, error) { if path == "" { return nil, errors.New("daemon: gate spool path is required") diff --git a/internal/evidence/inrepo.go b/internal/evidence/inrepo.go index f9b1896..9d4f864 100644 --- a/internal/evidence/inrepo.go +++ b/internal/evidence/inrepo.go @@ -3,6 +3,8 @@ package evidence import ( "errors" "fmt" + "os" + "os/exec" "path/filepath" "strings" @@ -106,6 +108,65 @@ func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) (err return nil } +func (s *InRepoStore) PublishEvidence(runID string) error { + if err := validateEvidenceInput(runID, nil); err != nil { + return err + } + dir := s.Dir + if dir == "" { + dir = DefaultDir + } + repoPath, err := filepath.Abs(s.RepoPath) + if err != nil { + return fmt.Errorf("evidence: resolve repository path: %w", err) + } + if _, err := os.Stat(filepath.Join(repoPath, dir, runID)); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return fmt.Errorf("evidence: inspect run directory: %w", err) + } + relPath := filepath.Join(dir, runID) + if err := runEvidenceGit(repoPath, "add", "--", relPath); err != nil { + return fmt.Errorf("evidence: stage in-repo evidence: %w", err) + } + diff := exec.Command("git", "diff", "--cached", "--quiet", "--", relPath) + diff.Dir = repoPath + if err := diff.Run(); err == nil { + return nil + } else if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 1 { + return fmt.Errorf("evidence: inspect staged evidence: %w", err) + } + titleCmd := exec.Command("git", "log", "-1", "--format=%s") + titleCmd.Dir = repoPath + titleOutput, err := titleCmd.Output() + if err != nil { + return fmt.Errorf("evidence: derive commit subject: %w", err) + } + title := strings.TrimSpace(string(titleOutput)) + if title == "" { + title = "made: publish evidence" + } + if err := runEvidenceGit(repoPath, "-c", "commit.gpgsign=false", "commit", "--only", "-m", title, "--", relPath); err != nil { + return fmt.Errorf("evidence: commit in-repo evidence: %w", err) + } + return nil +} + +func runEvidenceGit(repoPath string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = repoPath + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=made-evidence", + "GIT_AUTHOR_EMAIL=made-evidence@localhost", + "GIT_COMMITTER_NAME=made-evidence", + "GIT_COMMITTER_EMAIL=made-evidence@localhost", + ) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + return nil +} + func isContainedPath(root, target string) bool { rel, err := filepath.Rel(root, target) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) diff --git a/internal/evidence/redact.go b/internal/evidence/redact.go index b56216e..96afa5e 100644 --- a/internal/evidence/redact.go +++ b/internal/evidence/redact.go @@ -12,6 +12,8 @@ var evidenceSecretPatterns = []*regexp.Regexp{ regexp.MustCompile(`(?i)(x-api-key:\s*)[^\s]+`), regexp.MustCompile(`(?i)(cookie:\s*)[^\r\n]+`), regexp.MustCompile(`(?i)(token=|access_token=|refresh_token=|client_secret=)[^&\s]+`), + regexp.MustCompile(`(?i)(\b(?:database[_-]?url|redis[_-]?url)\s*=\s*)(?:"[^"]*"|'[^']*'|[^\r\n\s]+)`), + regexp.MustCompile(`(?i)(\b[A-Z][A-Z0-9_]*(?:token|api[_-]?key|secret|password|passwd|database[_-]?url|redis[_-]?url)[A-Z0-9_]*\s*=\s*)(?:"[^"]*"|'[^']*'|[^\r\n\s]+)`), regexp.MustCompile(`\b(?:ghp_|github_pat_|sk-|xox[baprs]-)[A-Za-z0-9._-]+`), regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`), regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`), diff --git a/internal/evidence/remediation_contract_test.go b/internal/evidence/remediation_contract_test.go index b85d558..75f752e 100644 --- a/internal/evidence/remediation_contract_test.go +++ b/internal/evidence/remediation_contract_test.go @@ -2,6 +2,7 @@ package evidence_test import ( "os" + "os/exec" "path/filepath" "strings" "testing" @@ -28,7 +29,7 @@ func TestInRepoStore_RejectsPathTraversalAndOversizedEvidence(t *testing.T) { func TestInRepoStore_RedactsPublishedSecrets(t *testing.T) { repo := t.TempDir() store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} - input := "Authorization: Bearer bearer-secret\napi_key=api-secret\n\"access_token\": \"json-secret\"\nx-api-key: header-secret\ntoken=query-secret&ok=1\nghp_1234567890abcdef\nAKIA1234567890ABCDEF\n-----BEGIN RSA PRIVATE KEY-----\nprivate-secret\n-----END RSA PRIVATE KEY-----\n" + input := "Authorization: Bearer bearer-secret\napi_key=api-secret\n\"access_token\": \"json-secret\"\nx-api-key: header-secret\ntoken=query-secret&ok=1\nAWS_SECRET_ACCESS_KEY=aws-secret\nOPENAI_API_KEY=openai-secret\nDATABASE_URL=postgres://user:password@db.example/app\nghp_1234567890abcdef\nAKIA1234567890ABCDEF\n-----BEGIN RSA PRIVATE KEY-----\nprivate-secret\n-----END RSA PRIVATE KEY-----\n" if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte(input)}); err != nil { t.Fatalf("WriteEvidence: %v", err) } @@ -36,13 +37,47 @@ func TestInRepoStore_RedactsPublishedSecrets(t *testing.T) { if err != nil { t.Fatalf("read evidence: %v", err) } - for _, secret := range []string{"bearer-secret", "api-secret", "json-secret", "header-secret", "query-secret", "ghp_1234567890abcdef", "AKIA1234567890ABCDEF", "private-secret"} { + for _, secret := range []string{"bearer-secret", "api-secret", "json-secret", "header-secret", "query-secret", "aws-secret", "openai-secret", "postgres://user:password@db.example/app", "ghp_1234567890abcdef", "AKIA1234567890ABCDEF", "private-secret"} { if strings.Contains(string(data), secret) { t.Fatalf("published evidence retained %q: %q", secret, data) } } } +func TestInRepoStore_PublishesEvidenceInAccessibleCommit(t *testing.T) { + repo := t.TempDir() + runEvidenceGit(t, repo, "init", "-q", "-b", "main") + runEvidenceGit(t, repo, "config", "user.name", "evidence-test") + runEvidenceGit(t, repo, "config", "user.email", "evidence-test@example.com") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("fixture\n"), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + runEvidenceGit(t, repo, "add", "README.md") + runEvidenceGit(t, repo, "commit", "-q", "-m", "fixture") + + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte("visible evidence\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + if err := store.PublishEvidence("run-1"); err != nil { + t.Fatalf("PublishEvidence: %v", err) + } + if got := strings.TrimSpace(string(runEvidenceGit(t, repo, "show", "--format=", "--name-only", "HEAD"))); !strings.Contains(got, ".made/evidence/run-1/log.txt") { + t.Fatalf("published commit does not contain evidence path: %q", got) + } +} + +func runEvidenceGit(t *testing.T, dir string, args ...string) []byte { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + return out +} + func TestInRepoStore_UsesPrivateEvidencePermissions(t *testing.T) { repo := t.TempDir() store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} diff --git a/internal/evidence/store.go b/internal/evidence/store.go index 1f3ed79..b776bbb 100644 --- a/internal/evidence/store.go +++ b/internal/evidence/store.go @@ -42,6 +42,10 @@ type Store interface { WriteEvidence(runID string, files map[string][]byte) error } +type Publisher interface { + PublishEvidence(runID string) error +} + func NewStore(repoPath string, cfg Config) Store { if cfg.StoreInRepo { return &InRepoStore{RepoPath: repoPath, Dir: cfg.Dir} diff --git a/internal/orchestrator/params.go b/internal/orchestrator/params.go index 5b96d04..4a3a0a7 100644 --- a/internal/orchestrator/params.go +++ b/internal/orchestrator/params.go @@ -26,6 +26,21 @@ func derivePRTitle(worktreePath string) (string, error) { return strings.TrimSpace(string(res.Stdout)), nil } +func deriveOutputSHA(worktreePath string) (string, error) { + res, err := execpkg.Run(context.Background(), execpkg.Command{ + Name: "git", + Args: []string{"rev-parse", "HEAD"}, + Dir: worktreePath, + }) + if err != nil { + return "", fmt.Errorf("orchestrator: run git rev-parse HEAD: %w", err) + } + if res.ExitCode != 0 { + return "", fmt.Errorf("orchestrator: git rev-parse HEAD failed: %s", strings.TrimSpace(string(res.Stderr))) + } + return strings.TrimSpace(string(res.Stdout)), nil +} + func deriveEvidenceRef(store evidence.Store, runID string) string { switch s := store.(type) { case *evidence.OrphanBranchStore: diff --git a/internal/orchestrator/scaffold.go b/internal/orchestrator/scaffold.go index 437c47a..d493944 100644 --- a/internal/orchestrator/scaffold.go +++ b/internal/orchestrator/scaffold.go @@ -153,6 +153,17 @@ func refreshDefaultBranch(ctx context.Context, gatePath, defaultBranch string) e } if fetch.ExitCode != 0 { if strings.Contains(string(fetch.Stderr), "couldn't find remote ref") { + clear, clearErr := execpkg.Run(ctx, execpkg.Command{ + Name: "git", + Args: []string{"update-ref", "-d", "refs/heads/" + defaultBranch}, + Dir: gatePath, + }) + if clearErr != nil { + return fmt.Errorf("orchestrator: clear deleted default branch %s: %w", defaultBranch, clearErr) + } + if clear.ExitCode != 0 { + return fmt.Errorf("orchestrator: clear deleted default branch %s failed: %s", defaultBranch, string(clear.Stderr)) + } return nil } return fmt.Errorf("orchestrator: refresh default branch %s failed: %s", defaultBranch, string(fetch.Stderr)) diff --git a/internal/orchestrator/scaffold_test.go b/internal/orchestrator/scaffold_test.go index 3061013..917de89 100644 --- a/internal/orchestrator/scaffold_test.go +++ b/internal/orchestrator/scaffold_test.go @@ -90,6 +90,37 @@ func TestSetupResolvesEmptyTrustedConfigWhenDefaultBranchNeverFetched(t *testing } } +func TestRefreshDefaultBranchClearsDeletedRemotePolicyRef(t *testing.T) { + dir := t.TempDir() + gatePath := filepath.Join(dir, "gate.git") + remotePath := filepath.Join(dir, "remote.git") + runGit(t, "", "init", "--bare", "-q", "-b", "main", remotePath) + if err := gitgate.InitBare(gatePath); err != nil { + t.Fatalf("InitBare gate: %v", err) + } + runGit(t, gatePath, "remote", "add", "origin", remotePath) + + src := filepath.Join(dir, "src") + initSourceRepo(t, src) + sha := pushBranch(t, src, remotePath, "main") + if err := refreshDefaultBranch(context.Background(), gatePath, "main"); err != nil { + t.Fatalf("initial refreshDefaultBranch: %v", err) + } + if got := revParse(t, gatePath, "refs/heads/main"); got != sha { + t.Fatalf("fetched trusted ref = %s, want %s", got, sha) + } + runGit(t, remotePath, "update-ref", "-d", "refs/heads/main") + + if err := refreshDefaultBranch(context.Background(), gatePath, "main"); err != nil { + t.Fatalf("refreshDefaultBranch after remote deletion: %v", err) + } + cmd := exec.Command("git", "rev-parse", "--verify", "refs/heads/main") + cmd.Dir = gatePath + if err := cmd.Run(); err == nil { + t.Fatal("refreshDefaultBranch retained a deleted remote trusted ref") + } +} + func TestSetupCutsWorktreeAtExactPushedSHANotBranchTip(t *testing.T) { dir := t.TempDir() barePath := filepath.Join(dir, "gate.git") diff --git a/internal/orchestrator/workfunc.go b/internal/orchestrator/workfunc.go index 98737e7..6137149 100644 --- a/internal/orchestrator/workfunc.go +++ b/internal/orchestrator/workfunc.go @@ -7,6 +7,7 @@ import ( "github.com/douglasjarquin/made/internal/agent" "github.com/douglasjarquin/made/internal/daemon" + "github.com/douglasjarquin/made/internal/evidence" "github.com/douglasjarquin/made/internal/pipeline/ci" "github.com/douglasjarquin/made/internal/pipeline/document" "github.com/douglasjarquin/made/internal/pipeline/intent" @@ -152,7 +153,7 @@ func (c *chain) requireDeliveryStages() error { if c.rc.Config.StageResult(name) != "skipped" { continue } - if name == stageNameCI && c.rc.Config.NoCI { + if !c.rc.Config.StageRequired(name) { continue } if err := c.finish(name, "skipped", "stage disabled"); err != nil { @@ -347,6 +348,24 @@ func (c *chain) lintStage() error { func (c *chain) pushStage() error { c.start(stageNamePush) + if publisher, ok := c.rc.Evidence.(evidence.Publisher); ok { + if err := publisher.PublishEvidence(c.runID); err != nil { + if finishErr := c.finish(stageNamePush, stageResultFail, err.Error()); finishErr != nil { + return finishErr + } + return c.stageFailure(stageNamePush, err.Error()) + } + } + outputSHA, err := deriveOutputSHA(c.rc.Worktree.Path) + if err != nil { + if finishErr := c.finish(stageNamePush, stageResultFail, err.Error()); finishErr != nil { + return finishErr + } + return c.stageFailure(stageNamePush, err.Error()) + } + if err := c.rm.SetOutputSHA(c.runID, outputSHA); err != nil { + return err + } result, err := push.Run(c.ctx, c.rc.Worktree.Path, pushRemoteName, c.branch) if err != nil { return err @@ -363,7 +382,6 @@ func (c *chain) pushStage() error { func (c *chain) prStage() (pr.Result, error) { c.start(stageNamePR) - title, err := derivePRTitle(c.rc.Worktree.Path) if err != nil { return pr.Result{}, fmt.Errorf("orchestrator: derive PR title: %w", err) diff --git a/internal/orchestrator/workfunc_test.go b/internal/orchestrator/workfunc_test.go index 5cc7298..4f462b7 100644 --- a/internal/orchestrator/workfunc_test.go +++ b/internal/orchestrator/workfunc_test.go @@ -38,6 +38,7 @@ func TestChain_RefusesDeliveryWhenRequiredStageDisabled(t *testing.T) { t.Fatalf("Submit: %v", err) } c := &chain{rc: &RunContext{Config: config.Config{ + Review: config.Review{Required: true}, Stages: map[string]config.Stage{stageNameReview: {Enabled: &disabled}}, }}, rm: rm, runID: runID} @@ -249,6 +250,9 @@ func TestNewWorkFunc_FullPassEndsRunningWithAwaitingMergeMessage(t *testing.T) { if !strings.Contains(snap.Message, "awaiting merge") { t.Fatalf("expected final message to mention awaiting merge, got %q", snap.Message) } + if len(snap.OutputSHA) != 40 { + t.Fatalf("expected durable output SHA after push preparation, got %q", snap.OutputSHA) + } assertAllStagesPassed(t, snap.Stages) if !f.branchOnRealRemote(t, branch) { From 6398fbd8a493d93447d1c889e8fb12a81ffda5f8 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:22:17 -0400 Subject: [PATCH 13/53] docs: record final remediation evidence --- docs/remediation/made-remediation-p1p3b.md | 30 ++++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index b83a2b0..25cc668 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -56,6 +56,8 @@ The evidence-only QA commit is `af5010d7bd910bfa829e030c0198cae909188e69` with s The final executable boundary-fix commit is `d45f5c518664db5f73f42d1d4db595216331f24b` with subject `fix: close final remediation boundary gaps`. +The final boundary-completion commit is `da8f5653bc3e13877480728bc3dd2daf296e7dd2` with subject `fix: harden final remediation boundaries`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, and final review/API boundaries. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -80,7 +82,13 @@ Pending gate submissions are replayed on daemon startup and remain undrained whe The orchestrator records stages, disabled stages as `skipped`, findings, decisions, PR URL, errors, supersession, cancellation, and submission events. -The direct `run.submit` record API now fails its execution promptly when no gate work specification is supplied, rather than leaving a cancellation-only worker active indefinitely; real pipeline execution remains owned by `gate.notify-push`. +The direct `run.submit` API requires a Made-owned gate path, branch ref, and immutable input head, then executes the same real gate pipeline as `gate.notify-push`. + +Gate submissions are fsync-enqueued before default-branch refresh, so a reachable daemon cannot lose an accepted update when the external remote is unavailable. + +Gate RPCs validate the Made-owned gate layout, bare-repository identity, and exact pushed ref head before creating a run. + +The run manager persists the actual prepared output SHA before pushing, while the in-repository evidence mode commits bounded redacted evidence into the pushed branch for later access. ## Phase 3 implementation and GREEN @@ -94,7 +102,13 @@ Auto-fixes require a clean state, require explicitly returned tracked paths, rej Rebase failures are classified as conflicts only when unmerged paths exist. -Evidence is run- and stage-specific, bounded, redacted, symlink-safe, and published only through accessible paths. +Evidence is run- and stage-specific, bounded, redacted for common credential assignments and URLs, symlink-safe, and published only through accessible paths. + +In-repository evidence is committed into the pushed branch before the push stage completes, while orphan evidence remains on its dedicated evidence branch. + +When a remote default ref disappears, Made deletes the cached trusted ref before resolving policy, preventing stale trusted configuration from surviving refresh. + +Trusted review and CI required settings control whether disabled review and CI stages block delivery, and `no_ci` explicitly disables the CI requirement. Pull request creation is idempotent by repository, base, and head. @@ -106,7 +120,7 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `d45f5c518664db5f73f42d1d4db595216331f24b`. +The final executable source SHA covered by this validation section is `da8f5653bc3e13877480728bc3dd2daf296e7dd2`. The evidence-only report commits before that SHA did not change executable source, tests, configuration, or CI. @@ -114,11 +128,11 @@ The final validation set was `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign All final validation commands exited `0`, and golangci-lint reported `0 issues`. -The full validation transcript is `/tmp/made-remediation-p1p3b-full-precommit.log`. +The full validation transcript is `/tmp/made-remediation-p1p3b-full-final-precommit.log`. -The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-d45f5c5-final.log`, and its final marker was `manual-qa-d45f5c5=PASS` at that full SHA. +The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-da8f5653-final.log`, and its final marker was `manual-qa-da8f5653=PASS` at that full SHA. -That scenario used a fresh binary and task-local Made homes to observe capabilities, doctor through the real Consigliere script, durable offline gate spooling, exact submission and SHA preservation, exact status and active-list queries, versioned review decision output, WAL restart, duplicate singleton start, stale PID handling, regular-file, symlink, and directory socket rejection, and predecessor command rejection. +That scenario used a fresh final binary and task-local Made homes to observe capabilities, doctor through the real Consigliere script, real gate initialization and hook execution, native `run.submit` pipeline execution, durable offline gate spooling and replay, exact submission and SHA preservation, exact status and active-list queries, versioned review decision output, WAL restart, duplicate singleton start, and predecessor command rejection. The compatibility subscenario used the real `bin/cs-made-lib.sh` script, the real Made binary, a strict fake `gh auth status` boundary, a task-local unavailable Herdr socket, and accepted the expected nonzero health exit only after asserting valid versioned JSON and authenticated GitHub state. @@ -130,11 +144,11 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..d45f5c518664db5f73f42d1d4db595216331f24b`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..da8f5653bc3e13877480728bc3dd2daf296e7dd2`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. -The final boundary-fix commit-only diff is `git diff --name-status af5010d7bd910bfa829e030c0198cae909188e69..d45f5c518664db5f73f42d1d4db595216331f24b` and contains only Made implementation and test files. +The final boundary-fix commit-only diff is `git diff --name-status af5010d7bd910bfa829e030c0198cae909188e69..da8f5653bc3e13877480728bc3dd2daf296e7dd2` and contains only Made implementation and test files. The separate Made project plan `plans/made-rewrite.md` retains its broader F3 checkbox because that criterion requires running the full Consigliere `--mode made` soldier flow and changing shared Herdr lifecycle state. From a724f6c857e903ce52d62d803c540b27a221d6f3 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:57:47 -0400 Subject: [PATCH 14/53] fix: close security and boundary review gaps --- .github/workflows/ci.yml | 6 +- README.md | 2 +- cmd/made/status.go | 32 +++++---- cmd/made/status_test.go | 17 +++++ internal/agent/spawn.go | 5 +- internal/api/server.go | 27 +++++++- internal/config/config.go | 43 ++++++++++-- internal/config/remediation_contract_test.go | 46 ++++++++++++- internal/daemon/durable_contract_test.go | 26 ++++++++ internal/daemon/lock.go | 2 +- internal/daemon/lock_test.go | 47 ++++++++++++++ internal/daemon/spool.go | 6 +- internal/daemon/store.go | 65 +++++++++++++++---- internal/evidence/inrepo.go | 9 +-- internal/evidence/orphan.go | 22 ++++++- internal/evidence/orphan_test.go | 19 ++++++ internal/evidence/redact.go | 4 ++ .../evidence/remediation_contract_test.go | 13 ++++ internal/evidence/store.go | 18 +++-- internal/exec/exec.go | 57 ++++++++++++++-- internal/exec/exec_test.go | 18 +++++ internal/orchestrator/scaffold.go | 7 +- internal/orchestrator/workfunc.go | 6 +- internal/pipeline/push/push.go | 19 +++--- internal/pipeline/push/push_test.go | 11 ++-- 25 files changed, 444 insertions(+), 83 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3436a7b..fc98870 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,14 +9,14 @@ jobs: build-test-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: "1.26.5" - run: go build ./... - run: go test ./... - run: go test -race -shuffle=on -count=1 ./... - run: go vet ./... - - uses: golangci/golangci-lint-action@v6 + - uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6.5.2 with: version: v2.11.2 diff --git a/README.md b/README.md index 3f4b353..dfb2f0f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ See `plans/made-rewrite.md` for the full design and build plan. `made capabilities --json` reports the public protocol and command schema. -Use `made run submit --json --repo --branch --input-sha ` to create a run. +Use `made run submit --json --gate --ref refs/heads/ --old-sha --input-sha ` to create a run. Use `made run status --json ` for one run, `made run list --json --active` for the active batch, and `made run cancel --json ` for idempotent cancellation. diff --git a/cmd/made/status.go b/cmd/made/status.go index 400bc89..bb65a6d 100644 --- a/cmd/made/status.go +++ b/cmd/made/status.go @@ -8,6 +8,7 @@ import ( "github.com/douglasjarquin/made/internal/api" "github.com/douglasjarquin/made/internal/daemon" + "github.com/douglasjarquin/made/internal/evidence" ) const statusSchemaVersion = 1 @@ -99,7 +100,7 @@ func newStatusReport(snap daemon.RunSnapshot) StatusReport { errMsg := "" if snap.Err != nil { - errMsg = snap.Err.Error() + errMsg = evidence.RedactString(snap.Err.Error()) } return StatusReport{ @@ -112,10 +113,10 @@ func newStatusReport(snap daemon.RunSnapshot) StatusReport { InputSHA: snap.InputSHA, OutputSHA: snap.OutputSHA, ExecutionFinished: snap.ExecutionFinished, - Findings: nonNilFindings(snap.Findings), + Findings: redactedFindings(snap.Findings), Decisions: nonNilDecisions(snap.Decisions), PRURL: snap.PRURL, - Errors: nonNilErrors(snap.Errors, snap.Err), + Errors: redactedErrors(snap.Errors, snap.Err), SupersededBy: snap.SupersededBy, CancelRequested: snap.CancelRequested, SubmissionEvents: nonNilSubmissionEvents(snap.SubmissionEvents), @@ -128,11 +129,16 @@ func newStatusReport(snap daemon.RunSnapshot) StatusReport { } } -func nonNilFindings(findings []daemon.RunFinding) []daemon.RunFinding { +func redactedFindings(findings []daemon.RunFinding) []daemon.RunFinding { if findings == nil { return []daemon.RunFinding{} } - return findings + redacted := make([]daemon.RunFinding, len(findings)) + copy(redacted, findings) + for i := range redacted { + redacted[i].Message = evidence.RedactString(redacted[i].Message) + } + return redacted } func nonNilDecisions(decisions map[string]string) map[string]string { @@ -142,14 +148,18 @@ func nonNilDecisions(decisions map[string]string) map[string]string { return decisions } -func nonNilErrors(values []string, runErr error) []string { - if len(values) > 0 { - return values +func redactedErrors(values []string, runErr error) []string { + if len(values) == 0 { + if runErr == nil { + return []string{} + } + return []string{evidence.RedactString(runErr.Error())} } - if runErr != nil { - return []string{runErr.Error()} + redacted := make([]string, len(values)) + for i, value := range values { + redacted[i] = evidence.RedactString(value) } - return []string{} + return redacted } func nonNilSubmissionEvents(events []daemon.SubmissionEvent) []daemon.SubmissionEvent { diff --git a/cmd/made/status_test.go b/cmd/made/status_test.go index d140ba5..be96d5c 100644 --- a/cmd/made/status_test.go +++ b/cmd/made/status_test.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" "time" @@ -106,6 +107,22 @@ func TestStatusJSON_SchemaValidity(t *testing.T) { } } +func TestNewStatusReportRedactsSensitiveRunText(t *testing.T) { + secret := "token=status-secret" + report := newStatusReport(daemon.RunSnapshot{ + ID: "run-sensitive", + Errors: []string{secret}, + Findings: []daemon.RunFinding{{Message: secret}}, + }) + encoded, err := json.Marshal(report) + if err != nil { + t.Fatalf("marshal report: %v", err) + } + if strings.Contains(string(encoded), "status-secret") { + t.Fatalf("status report retained sensitive text: %s", encoded) + } +} + func TestStatusJSON_ReflectsRealStageUpdate(t *testing.T) { home := shortTempDir(t) t.Setenv("MADE_HOME", home) diff --git a/internal/agent/spawn.go b/internal/agent/spawn.go index 310ebfc..f333969 100644 --- a/internal/agent/spawn.go +++ b/internal/agent/spawn.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/douglasjarquin/made/internal/evidence" "github.com/douglasjarquin/made/internal/exec" ) @@ -50,12 +51,12 @@ func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) return Findings{}, fmt.Errorf("agent: spawn %s (%s): %w", kind, binary, err) } if result.ExitCode != 0 { - return Findings{}, fmt.Errorf("agent: %s (%s) exited %d: %s", kind, binary, result.ExitCode, result.Stderr) + return Findings{}, fmt.Errorf("agent: %s (%s) exited %d: %s", kind, binary, result.ExitCode, evidence.RedactString(string(result.Stderr))) } findings, err := decodeFindings(result.Stdout) if err != nil { - return Findings{}, fmt.Errorf("agent: parse findings from %s: %w: stdout=%s", kind, err, result.Stdout) + return Findings{}, fmt.Errorf("agent: parse findings from %s: %w: stdout=%s", kind, err, evidence.RedactString(string(result.Stdout))) } return findings, nil } diff --git a/internal/api/server.go b/internal/api/server.go index 2c3f0f0..2491b9e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -95,8 +95,31 @@ func PrepareSocket(socketPath string) error { if live { return fmt.Errorf("api: refusing to replace live owner socket %s", socketPath) } - if err := os.Remove(socketPath); err != nil { - return fmt.Errorf("api: remove stale owner socket %s: %w", socketPath, err) + current, err := os.Lstat(socketPath) + if err != nil { + return fmt.Errorf("api: recheck stale socket %s: %w", socketPath, err) + } + if !os.SameFile(info, current) { + return fmt.Errorf("api: refusing to remove replaced socket path %s", socketPath) + } + quarantine := fmt.Sprintf("%s.stale-%d", socketPath, time.Now().UnixNano()) + if _, err := os.Lstat(quarantine); err == nil { + return fmt.Errorf("api: refusing occupied stale-socket quarantine path %s", quarantine) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("api: inspect stale-socket quarantine path %s: %w", quarantine, err) + } + if err := os.Rename(socketPath, quarantine); err != nil { + return fmt.Errorf("api: quarantine stale owner socket %s: %w", socketPath, err) + } + quarantined, err := os.Lstat(quarantine) + if err != nil { + return fmt.Errorf("api: inspect quarantined socket %s: %w", quarantine, err) + } + if !os.SameFile(info, quarantined) { + return fmt.Errorf("api: refusing to remove replaced socket path %s", socketPath) + } + if err := os.Remove(quarantine); err != nil { + return fmt.Errorf("api: remove quarantined stale owner socket %s: %w", quarantine, err) } return nil } diff --git a/internal/config/config.go b/internal/config/config.go index 7ab4e1f..44e08b7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,12 +7,19 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/douglasjarquin/made/internal/agent" "gopkg.in/yaml.v3" ) -const defaultCIRerunBudget = 2 +const ( + defaultCIRerunBudget = 2 + defaultStageTimeout = 30 * time.Minute + maxStageTimeoutSeconds = 2 * 60 * 60 + defaultEvidenceRetention = 4 << 20 + maxEvidenceRetention = 64 << 20 +) var validStageNames = map[string]struct{}{ "intent": {}, "rebase": {}, "review": {}, "test": {}, "document": {}, @@ -35,7 +42,23 @@ type Config struct { } type Stage struct { - Enabled *bool `yaml:"enabled"` + Enabled *bool `yaml:"enabled"` + TimeoutSeconds *int `yaml:"timeout_seconds"` +} + +func (c Config) StageTimeout(name string) time.Duration { + stage := c.Stages[name] + if stage.TimeoutSeconds == nil { + return defaultStageTimeout + } + return time.Duration(*stage.TimeoutSeconds) * time.Second +} + +func (c Config) EvidenceRetentionBytes() int { + if c.Test.Evidence.RetentionBytes == nil { + return defaultEvidenceRetention + } + return *c.Test.Evidence.RetentionBytes } func (c Config) StageResult(name string) string { @@ -83,9 +106,10 @@ type Test struct { } type Evidence struct { - Branch string `yaml:"branch"` - StoreInRepo bool `yaml:"store_in_repo"` - Dir string `yaml:"dir"` + Branch string `yaml:"branch"` + StoreInRepo bool `yaml:"store_in_repo"` + Dir string `yaml:"dir"` + RetentionBytes *int `yaml:"retention_bytes"` } type Commands struct { @@ -201,6 +225,13 @@ func loadConfigFile(path string) (cfg Config, exists bool, err error) { if _, ok := validStageNames[name]; !ok { return Config{}, true, 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{}, true, 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{}, true, fmt.Errorf("versioned .made.yml test.evidence.retention_bytes must be between 1 and %d", maxEvidenceRetention) } if !cfg.hasConfiguredValue() { return Config{}, true, fmt.Errorf("versioned .made.yml must configure at least one non-version field") @@ -216,7 +247,7 @@ func loadConfigFile(path string) (cfg Config, exists bool, err error) { func (c Config) hasConfiguredValue() bool { return len(c.Document.Rules) > 0 || c.Review.Required || c.DisableProjectSettings || c.NoCI || - c.CI.Required || c.CI.RerunBudget != 0 || len(c.Test.Evidence.Branch) > 0 || + c.CI.Required || c.CI.RerunBudget != 0 || len(c.Test.Evidence.Branch) > 0 || c.Test.Evidence.RetentionBytes != nil || c.Test.Evidence.StoreInRepo || len(c.Test.Evidence.Dir) > 0 || len(c.Commands.Test) > 0 || len(c.Commands.Lint) > 0 || len(c.Agent) > 0 || len(c.Agents) > 0 || c.AllowRepoCommands || len(c.Stages) > 0 diff --git a/internal/config/remediation_contract_test.go b/internal/config/remediation_contract_test.go index 2531e94..bb7aa98 100644 --- a/internal/config/remediation_contract_test.go +++ b/internal/config/remediation_contract_test.go @@ -1,6 +1,9 @@ package config -import "testing" +import ( + "testing" + "time" +) func TestLoadConfig_RejectsUnknownMadeYMLFields(t *testing.T) { path := writeConfigFile(t, t.TempDir(), ".made.yml", "version: 1\nunknown_field: true\n") @@ -77,3 +80,44 @@ func TestConfig_RequiredSettingsControlDisabledReviewAndCI(t *testing.T) { t.Fatal("NoCI did not disable the CI requirement") } } + +func TestLoadConfig_StageTimeoutAndEvidenceRetentionAreBounded(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", `version: 1 +stages: + review: + timeout_seconds: 45 +test: + evidence: + retention_bytes: 8192 +`) + cfg, _, err := loadConfigFile(path) + if err != nil { + t.Fatalf("loadConfigFile: %v", err) + } + if got := cfg.StageTimeout("review"); got != 45*time.Second { + t.Fatalf("review timeout = %s, want 45s", got) + } + if got := cfg.EvidenceRetentionBytes(); got != 8192 { + t.Fatalf("evidence retention = %d, want 8192", got) + } +} + +func TestLoadConfig_RejectsZeroOrUnboundedTimeoutAndRetention(t *testing.T) { + tests := []struct { + name string + body string + }{ + {name: "zero timeout", body: "version: 1\nstages:\n review:\n timeout_seconds: 0\n"}, + {name: "unbounded timeout", body: "version: 1\nstages:\n review:\n timeout_seconds: 7201\n"}, + {name: "zero retention", body: "version: 1\ntest:\n evidence:\n retention_bytes: 0\n"}, + {name: "unbounded retention", body: "version: 1\ntest:\n evidence:\n retention_bytes: 67108865\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", tt.body) + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted an invalid bounded setting") + } + }) + } +} diff --git a/internal/daemon/durable_contract_test.go b/internal/daemon/durable_contract_test.go index ad77e08..4018e06 100644 --- a/internal/daemon/durable_contract_test.go +++ b/internal/daemon/durable_contract_test.go @@ -2,6 +2,8 @@ package daemon import ( "context" + "os" + "strings" "testing" "time" ) @@ -47,6 +49,30 @@ func TestPersistentRunStateIncludesSubmissionAndDecisionData(t *testing.T) { } } +func TestRunStoreRedactsDurableFindingAndErrorText(t *testing.T) { + path := t.TempDir() + "/runs.wal" + store, _, err := OpenRunStore(path) + if err != nil { + t.Fatalf("OpenRunStore: %v", err) + } + secret := "token=durable-secret" + if err := store.Append(RunSnapshot{ + ID: "123e4567-e89b-12d3-a456-426614174006", + Message: secret, + Errors: []string{secret}, + Findings: []RunFinding{{Message: secret}}, + }); err != nil { + t.Fatalf("Append: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read WAL: %v", err) + } + if strings.Contains(string(data), "durable-secret") { + t.Fatalf("WAL retained sensitive text: %s", data) + } +} + func TestGateSpoolIsIdempotentAndDurable(t *testing.T) { path := t.TempDir() + "/gate.spool" spool, err := OpenGateSpool(path) diff --git a/internal/daemon/lock.go b/internal/daemon/lock.go index b2e33ac..a227c6d 100644 --- a/internal/daemon/lock.go +++ b/internal/daemon/lock.go @@ -20,7 +20,7 @@ type Lock struct { // including on a crash - unlike a PID file, whose mere existence proves // nothing about whether its writer is still alive. func AcquireLock(path string) (*Lock, error) { - f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|unix.O_NOFOLLOW, 0o600) if err != nil { return nil, fmt.Errorf("open lock file %s: %w", path, err) } diff --git a/internal/daemon/lock_test.go b/internal/daemon/lock_test.go index 791dce0..92e7aa3 100644 --- a/internal/daemon/lock_test.go +++ b/internal/daemon/lock_test.go @@ -2,6 +2,7 @@ package daemon import ( "errors" + "os" "path/filepath" "testing" ) @@ -21,6 +22,52 @@ func TestAcquireLock_DoubleStartRejected(t *testing.T) { } } +func TestStateFilesRejectSymlinkChildren(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("keep"), 0o600); err != nil { + t.Fatalf("write target: %v", err) + } + cases := []struct { + name string + open func(string) error + }{ + {name: "lock", open: func(path string) error { + lock, err := AcquireLock(path) + if lock != nil { + _ = lock.Release() + } + return err + }}, + {name: "wal", open: func(path string) error { + _, _, err := OpenRunStore(path) + return err + }}, + {name: "spool", open: func(path string) error { + _, err := OpenGateSpool(path) + return err + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + link := filepath.Join(dir, tc.name) + if err := os.Symlink(target, link); err != nil { + t.Fatalf("create symlink: %v", err) + } + if err := tc.open(link); err == nil { + t.Fatal("state opener followed a symlink child") + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read target: %v", err) + } + if string(data) != "keep" { + t.Fatalf("symlink target was modified: %q", data) + } + }) + } +} + func TestAcquireLock_ReacquireAfterRelease(t *testing.T) { lockPath := filepath.Join(t.TempDir(), "daemon.lock") diff --git a/internal/daemon/spool.go b/internal/daemon/spool.go index 8bf257c..063d8c7 100644 --- a/internal/daemon/spool.go +++ b/internal/daemon/spool.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "sync" + + "golang.org/x/sys/unix" ) type GateSubmission struct { @@ -42,7 +44,7 @@ func OpenGateSpool(path string) (*GateSpool, error) { return nil, fmt.Errorf("daemon: create gate spool directory: %w", err) } spool := &GateSpool{path: path, pending: make(map[string]GateSubmission), seen: make(map[string]GateSubmission)} - file, err := os.Open(path) + file, err := os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW, 0) if errors.Is(err, os.ErrNotExist) { return spool, nil } @@ -126,7 +128,7 @@ func (s *GateSpool) appendLocked(record spoolRecord) error { if err != nil { return fmt.Errorf("daemon: encode gate spool record: %w", err) } - file, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + file, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|unix.O_NOFOLLOW, 0o600) if err != nil { return fmt.Errorf("daemon: open gate spool for append: %w", err) } diff --git a/internal/daemon/store.go b/internal/daemon/store.go index de9c766..10df286 100644 --- a/internal/daemon/store.go +++ b/internal/daemon/store.go @@ -9,6 +9,9 @@ import ( "path/filepath" "sync" "time" + + "github.com/douglasjarquin/made/internal/evidence" + "golang.org/x/sys/unix" ) const runStoreRecordVersion = 1 @@ -80,7 +83,7 @@ func OpenRunStore(path string) (*RunStore, map[string]RunSnapshot, error) { } store := &RunStore{path: path} snapshots := make(map[string]RunSnapshot) - file, err := os.Open(path) + file, err := os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW, 0) if errors.Is(err, os.ErrNotExist) { return store, snapshots, nil } @@ -118,7 +121,7 @@ func (s *RunStore) Append(snapshot RunSnapshot) error { } s.mu.Lock() defer s.mu.Unlock() - file, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + file, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|unix.O_NOFOLLOW, 0o600) if err != nil { return fmt.Errorf("daemon: open run store for append: %w", err) } @@ -137,22 +140,27 @@ func persistSnapshot(snapshot RunSnapshot) persistedSnapshot { if snapshot.Err != nil && len(errorsList) == 0 { errorsList = []string{snapshot.Err.Error()} } + for i := range errorsList { + errorsList[i] = evidence.RedactString(errorsList[i]) + } decisions := make(map[string]string, len(snapshot.Decisions)) for key, value := range snapshot.Decisions { - decisions[key] = value + decisions[evidence.RedactString(key)] = evidence.RedactString(value) } + findings := redactFindings(snapshot.Findings) + pendingFindings := redactPendingFindings(snapshot.PendingFindings) return persistedSnapshot{ ID: snapshot.ID, Repo: snapshot.Repo, Branch: snapshot.Branch, InputSHA: snapshot.InputSHA, OutputSHA: snapshot.OutputSHA, Status: snapshot.Status, QueuedAt: snapshot.QueuedAt, StartedAt: snapshot.StartedAt, EndedAt: snapshot.EndedAt, ExecutionFinished: snapshot.ExecutionFinished, - Message: snapshot.Message, Errors: errorsList, - Findings: append([]RunFinding(nil), snapshot.Findings...), Decisions: decisions, - PRURL: snapshot.PRURL, SupersededBy: snapshot.SupersededBy, + Message: evidence.RedactString(snapshot.Message), Errors: errorsList, + Findings: findings, Decisions: decisions, + PRURL: evidence.RedactString(snapshot.PRURL), SupersededBy: evidence.RedactString(snapshot.SupersededBy), CancelRequested: snapshot.CancelRequested, SubmissionEvents: append([]SubmissionEvent(nil), snapshot.SubmissionEvents...), Stages: append([]StageResult(nil), snapshot.Stages...), - PendingFindings: append([]AskUserFinding(nil), snapshot.PendingFindings...), + PendingFindings: pendingFindings, Finalized: snapshot.finalized, } } @@ -160,20 +168,55 @@ func persistSnapshot(snapshot RunSnapshot) persistedSnapshot { func restoreSnapshot(snapshot persistedSnapshot) RunSnapshot { var runErr error if len(snapshot.Errors) > 0 { - runErr = errors.New(snapshot.Errors[len(snapshot.Errors)-1]) + runErr = errors.New(evidence.RedactString(snapshot.Errors[len(snapshot.Errors)-1])) } return RunSnapshot{ ID: snapshot.ID, Repo: snapshot.Repo, Branch: snapshot.Branch, InputSHA: snapshot.InputSHA, OutputSHA: snapshot.OutputSHA, Status: snapshot.Status, QueuedAt: snapshot.QueuedAt, StartedAt: snapshot.StartedAt, EndedAt: snapshot.EndedAt, ExecutionFinished: snapshot.ExecutionFinished, - Err: runErr, Errors: append([]string(nil), snapshot.Errors...), - Message: snapshot.Message, Findings: append([]RunFinding(nil), snapshot.Findings...), + Err: runErr, Errors: redactStrings(snapshot.Errors), + Message: evidence.RedactString(snapshot.Message), Findings: redactFindings(snapshot.Findings), Decisions: snapshot.Decisions, PRURL: snapshot.PRURL, SupersededBy: snapshot.SupersededBy, CancelRequested: snapshot.CancelRequested, SubmissionEvents: append([]SubmissionEvent(nil), snapshot.SubmissionEvents...), Stages: append([]StageResult(nil), snapshot.Stages...), - PendingFindings: append([]AskUserFinding(nil), snapshot.PendingFindings...), + PendingFindings: redactPendingFindings(snapshot.PendingFindings), finalized: snapshot.Finalized, } } + +func redactStrings(values []string) []string { + if values == nil { + return nil + } + redacted := make([]string, len(values)) + for i, value := range values { + redacted[i] = evidence.RedactString(value) + } + return redacted +} + +func redactFindings(values []RunFinding) []RunFinding { + if values == nil { + return nil + } + redacted := make([]RunFinding, len(values)) + for i, value := range values { + value.Message = evidence.RedactString(value.Message) + redacted[i] = value + } + return redacted +} + +func redactPendingFindings(values []AskUserFinding) []AskUserFinding { + if values == nil { + return nil + } + redacted := make([]AskUserFinding, len(values)) + for i, value := range values { + value.Message = evidence.RedactString(value.Message) + redacted[i] = value + } + return redacted +} diff --git a/internal/evidence/inrepo.go b/internal/evidence/inrepo.go index 9d4f864..35925f9 100644 --- a/internal/evidence/inrepo.go +++ b/internal/evidence/inrepo.go @@ -12,8 +12,9 @@ import ( ) type InRepoStore struct { - RepoPath string - Dir string + RepoPath string + Dir string + RetentionBytes int } func (s *InRepoStore) Location(runID string) string { @@ -25,7 +26,7 @@ func (s *InRepoStore) Location(runID string) string { } func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) (err error) { - if err := validateEvidenceInput(runID, files); err != nil { + if err := validateEvidenceInput(runID, files, s.RetentionBytes); err != nil { return err } dir := s.Dir @@ -109,7 +110,7 @@ func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) (err } func (s *InRepoStore) PublishEvidence(runID string) error { - if err := validateEvidenceInput(runID, nil); err != nil { + if err := validateEvidenceInput(runID, nil, s.RetentionBytes); err != nil { return err } dir := s.Dir diff --git a/internal/evidence/orphan.go b/internal/evidence/orphan.go index 823dca7..98515f9 100644 --- a/internal/evidence/orphan.go +++ b/internal/evidence/orphan.go @@ -11,8 +11,24 @@ import ( ) type OrphanBranchStore struct { - RepoPath string - Branch string + RepoPath string + Branch string + RetentionBytes int +} + +func (s *OrphanBranchStore) PublishEvidence(runID string) error { + if err := validateEvidenceInput(runID, nil, s.RetentionBytes); err != nil { + return err + } + branch := s.Branch + if branch == "" { + branch = DefaultBranch + } + ref := "refs/heads/" + branch + if _, err := s.runGit(nil, nil, "push", "origin", ref+":"+ref); err != nil { + return fmt.Errorf("evidence: publish branch %s: %w", branch, err) + } + return nil } // Location names where a run's evidence commit lives on the orphan branch, @@ -33,7 +49,7 @@ func (s *OrphanBranchStore) Location(runID string) string { // commit-tree is what gives the branch no shared history with the default // branch. func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) error { - if err := validateEvidenceInput(runID, files); err != nil { + if err := validateEvidenceInput(runID, files, s.RetentionBytes); err != nil { return err } branch := s.Branch diff --git a/internal/evidence/orphan_test.go b/internal/evidence/orphan_test.go index 8f7fa8e..4eaba77 100644 --- a/internal/evidence/orphan_test.go +++ b/internal/evidence/orphan_test.go @@ -1,6 +1,7 @@ package evidence_test import ( + "path/filepath" "strings" "testing" @@ -74,3 +75,21 @@ func TestOrphanBranchStoreDefaultBranchName(t *testing.T) { t.Fatalf("expected default evidence branch %q to exist: %v", evidence.DefaultBranch, err) } } + +func TestOrphanBranchStorePublishesEvidenceToOrigin(t *testing.T) { + repo := initTargetRepo(t) + remote := filepath.Join(t.TempDir(), "remote.git") + run(t, t.TempDir(), "git", "init", "--bare", "-q", remote) + run(t, repo, "git", "remote", "add", "origin", remote) + store := &evidence.OrphanBranchStore{RepoPath: repo, Branch: "made-evidence"} + if err := store.WriteEvidence("run-remote", map[string][]byte{"summary.txt": []byte("published\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + if err := store.PublishEvidence("run-remote"); err != nil { + t.Fatalf("PublishEvidence: %v", err) + } + tree := run(t, remote, "git", "ls-tree", "-r", "--name-only", "refs/heads/made-evidence") + if !strings.Contains(tree, "run-remote/summary.txt") { + t.Fatalf("remote evidence branch lacks published file: %s", tree) + } +} diff --git a/internal/evidence/redact.go b/internal/evidence/redact.go index 96afa5e..7bbd6a4 100644 --- a/internal/evidence/redact.go +++ b/internal/evidence/redact.go @@ -26,3 +26,7 @@ func Redact(data []byte) []byte { } return redacted } + +func RedactString(value string) string { + return string(Redact([]byte(value))) +} diff --git a/internal/evidence/remediation_contract_test.go b/internal/evidence/remediation_contract_test.go index 75f752e..f147efb 100644 --- a/internal/evidence/remediation_contract_test.go +++ b/internal/evidence/remediation_contract_test.go @@ -26,6 +26,19 @@ func TestInRepoStore_RejectsPathTraversalAndOversizedEvidence(t *testing.T) { } } +func TestNewStoreAppliesConfiguredRetentionBound(t *testing.T) { + store, ok := evidence.NewStore(t.TempDir(), evidence.Config{StoreInRepo: true, RetentionBytes: 8}).(*evidence.InRepoStore) + if !ok { + t.Fatal("NewStore returned the wrong store type") + } + if store.RetentionBytes != 8 { + t.Fatalf("retention bytes = %d, want 8", store.RetentionBytes) + } + if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte("123456789")}); err == nil { + t.Fatal("configured evidence retention bound was not enforced") + } +} + func TestInRepoStore_RedactsPublishedSecrets(t *testing.T) { repo := t.TempDir() store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} diff --git a/internal/evidence/store.go b/internal/evidence/store.go index b776bbb..3c6447b 100644 --- a/internal/evidence/store.go +++ b/internal/evidence/store.go @@ -14,23 +14,27 @@ const ( ) type Config struct { - StoreInRepo bool - Dir string - Branch string + StoreInRepo bool + Dir string + Branch string + RetentionBytes int } -func validateEvidenceInput(runID string, files map[string][]byte) error { +func validateEvidenceInput(runID string, files map[string][]byte, retentionBytes int) error { cleanRunID := filepath.Clean(runID) if runID == "" || cleanRunID != runID || filepath.IsAbs(runID) || cleanRunID == "." || cleanRunID == ".." || strings.HasPrefix(cleanRunID, ".."+string(filepath.Separator)) { return fmt.Errorf("evidence: invalid runID %q", runID) } total := 0 + if retentionBytes <= 0 { + retentionBytes = maxEvidenceBytes + } for name, data := range files { clean := filepath.Clean(name) if name == "" || filepath.IsAbs(name) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || clean == ".git" || strings.HasPrefix(clean, ".git"+string(filepath.Separator)) { return fmt.Errorf("evidence: path %q escapes run evidence directory", name) } - if len(data) > maxEvidenceFileBytes || total+len(data) > maxEvidenceBytes { + if len(data) > maxEvidenceFileBytes || total+len(data) > retentionBytes { return fmt.Errorf("evidence: retention limit exceeded for %q", name) } total += len(data) @@ -48,7 +52,7 @@ type Publisher interface { func NewStore(repoPath string, cfg Config) Store { if cfg.StoreInRepo { - return &InRepoStore{RepoPath: repoPath, Dir: cfg.Dir} + return &InRepoStore{RepoPath: repoPath, Dir: cfg.Dir, RetentionBytes: cfg.RetentionBytes} } - return &OrphanBranchStore{RepoPath: repoPath, Branch: cfg.Branch} + return &OrphanBranchStore{RepoPath: repoPath, Branch: cfg.Branch, RetentionBytes: cfg.RetentionBytes} } diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 6c8571e..a63db0f 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -11,12 +11,13 @@ import ( ) type Command struct { - Name string - Args []string - Dir string - Env []string - Stdin []byte - Timeout time.Duration + Name string + Args []string + Dir string + Env []string + Stdin []byte + Timeout time.Duration + OutputLimit int } type Result struct { @@ -37,7 +38,13 @@ func Run(ctx context.Context, cmd Command) (*Result, error) { c.Env = cmd.Env c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - var stdout, stderr bytes.Buffer + limit := cmd.OutputLimit + if limit <= 0 { + limit = defaultOutputLimit + } + var stdout, stderr boundedBuffer + stdout.limit = limit + stderr.limit = limit c.Stdout = &stdout c.Stderr = &stderr if cmd.Stdin != nil { @@ -69,6 +76,42 @@ func Run(ctx context.Context, cmd Command) (*Result, error) { } } +const defaultOutputLimit = 4 << 20 + +type boundedBuffer struct { + data []byte + limit int + truncated bool +} + +func (b *boundedBuffer) Write(data []byte) (int, error) { + remaining := b.limit - len(b.data) + if remaining > 0 { + if len(data) > remaining { + b.data = append(b.data, data[:remaining]...) + b.truncated = true + } else { + b.data = append(b.data, data...) + } + } else if len(data) > 0 { + b.truncated = true + } + return len(data), nil +} + +func (b *boundedBuffer) Bytes() []byte { + data := append([]byte(nil), b.data...) + if !b.truncated { + return data + } + marker := []byte("\n[output truncated]\n") + if len(marker) >= b.limit { + return append([]byte(nil), marker[:b.limit]...) + } + data = data[:b.limit-len(marker)] + return append(data, marker...) +} + // killGroup signals the process's entire group, not just the direct child: // a negative pid tells the kernel to deliver the signal to every process // sharing that group ID, which is how a backgrounded grandchild gets reaped diff --git a/internal/exec/exec_test.go b/internal/exec/exec_test.go index 2f6b142..fd8c70f 100644 --- a/internal/exec/exec_test.go +++ b/internal/exec/exec_test.go @@ -41,6 +41,24 @@ func TestRunCapturesStderr(t *testing.T) { } } +func TestRunBoundsStdoutAndStderrWhileProcessRuns(t *testing.T) { + const limit = 64 + res, err := exec.Run(context.Background(), exec.Command{ + Name: "sh", + Args: []string{"-c", "printf '%*s' 10000 x; printf '%*s' 10000 y >&2"}, + OutputLimit: limit, + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if len(res.Stdout) > limit || len(res.Stderr) > limit { + t.Fatalf("output exceeded limit: stdout=%d stderr=%d limit=%d", len(res.Stdout), len(res.Stderr), limit) + } + if !strings.Contains(string(res.Stdout), "output truncated") || !strings.Contains(string(res.Stderr), "output truncated") { + t.Fatalf("bounded output omitted truncation markers: stdout=%q stderr=%q", res.Stdout, res.Stderr) + } +} + func TestRunCancellationReapsGrandchildren(t *testing.T) { if _, err := osexec.LookPath("pgrep"); err != nil { t.Skip("pgrep not available on this system") diff --git a/internal/orchestrator/scaffold.go b/internal/orchestrator/scaffold.go index d493944..1eb0b7d 100644 --- a/internal/orchestrator/scaffold.go +++ b/internal/orchestrator/scaffold.go @@ -72,9 +72,10 @@ func Setup(ctx context.Context, gatePath, defaultBranch, worktreesDir, runID, pu setupTestHook() store := evidence.NewStore(wt.Path, evidence.Config{ - StoreInRepo: cfg.Test.Evidence.StoreInRepo, - Dir: cfg.Test.Evidence.Dir, - Branch: cfg.Test.Evidence.Branch, + StoreInRepo: cfg.Test.Evidence.StoreInRepo, + Dir: cfg.Test.Evidence.Dir, + Branch: cfg.Test.Evidence.Branch, + RetentionBytes: cfg.EvidenceRetentionBytes(), }) return &RunContext{ diff --git a/internal/orchestrator/workfunc.go b/internal/orchestrator/workfunc.go index 6137149..c5475f2 100644 --- a/internal/orchestrator/workfunc.go +++ b/internal/orchestrator/workfunc.go @@ -33,8 +33,6 @@ const ( stageNamePR = "pr" stageNameCI = "ci" - ciStageTimeout = 30 * time.Minute - stageTimeout = 30 * time.Minute ciPollInterval = 10 * time.Second pushRemoteName = "origin" @@ -168,7 +166,7 @@ func (c *chain) runStage(name string, stage func() error) error { if c.rc.Config.StageResult(name) == "skipped" { return c.finish(name, "skipped", "stage disabled") } - stageCtx, cancel := context.WithTimeout(c.ctx, stageTimeout) + stageCtx, cancel := context.WithTimeout(c.ctx, c.rc.Config.StageTimeout(name)) previous := c.ctx c.ctx = stageCtx err := stage() @@ -416,7 +414,7 @@ func (c *chain) prStage() (pr.Result, error) { func (c *chain) ciStage(prURL string) error { c.start(stageNameCI) - ciCtx, cancel := context.WithTimeout(c.ctx, ciStageTimeout) + ciCtx, cancel := context.WithTimeout(c.ctx, c.rc.Config.StageTimeout(stageNameCI)) defer cancel() result, err := ci.Run(ciCtx, c.rc.GitHub, prURL, c.rc.Config.CI.RerunBudget, ciPollInterval) diff --git a/internal/pipeline/push/push.go b/internal/pipeline/push/push.go index 492885b..fa85757 100644 --- a/internal/pipeline/push/push.go +++ b/internal/pipeline/push/push.go @@ -19,6 +19,14 @@ type Result struct { Message string } +type InfrastructureError struct { + Detail string +} + +func (e *InfrastructureError) Error() string { + return "push: infrastructure failure: " + e.Detail +} + // credentialInURL matches the userinfo component of a URL (scheme://user:pass@host) // so it can be stripped from anything made surfaces in a Result or error - git // itself prints the real remote URL verbatim into its own error output when a @@ -26,10 +34,8 @@ type Result struct { // and that string must never end up in made's own logs, DB, or evidence. var credentialInURL = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://)[^\s/@]+@`) -// Run's error return is reserved for infrastructure failures (empty -// arguments, git failing to start); a push rejected by the remote - auth -// failure, unreachable host, non-fast-forward - is a normal outcome reported -// via Result.OK, not an error, following the lint/test stage convention. +// Run returns infrastructure errors for a remote that cannot accept the push, +// including authentication, transport, and policy-hook failures. // // Run never reads or constructs the remote's URL itself: it shells out to // `git push ` inside the worktree, so git resolves the @@ -65,10 +71,7 @@ func Run(ctx context.Context, worktreePath, remoteName, branch string) (Result, if res.ExitCode != 0 { output := redact(strings.TrimSpace(string(res.Stdout) + "\n" + string(res.Stderr))) - return Result{ - OK: false, - Message: fmt.Sprintf("git push %s %s failed with exit code %d: %s", remoteName, refspec, res.ExitCode, output), - }, nil + return Result{}, &InfrastructureError{Detail: fmt.Sprintf("git push %s %s failed with exit code %d: %s", remoteName, refspec, res.ExitCode, output)} } return Result{ diff --git a/internal/pipeline/push/push_test.go b/internal/pipeline/push/push_test.go index dd87a14..90f68c9 100644 --- a/internal/pipeline/push/push_test.go +++ b/internal/pipeline/push/push_test.go @@ -61,14 +61,11 @@ func TestRun_UnreachableRemoteFailsClean(t *testing.T) { beforeHead := strings.TrimSpace(run(t, wt.Path, "rev-parse", "HEAD")) result, err := push.Run(context.Background(), wt.Path, "origin", f.branch) - if err != nil { - t.Fatalf("Run: expected a reported failure, not an error, got: %v", err) - } - if result.OK { - t.Fatalf("expected OK=false for a push to an unreachable remote, got %+v", result) + if err == nil { + t.Fatalf("Run: expected unreachable push to be classified as infrastructure error, result=%+v", result) } - if result.Message == "" { - t.Fatalf("expected a non-empty failure message") + if !strings.Contains(err.Error(), "infrastructure") { + t.Fatalf("expected infrastructure classification, got %v", err) } status := run(t, wt.Path, "status", "--porcelain") From 8d196c4af539c6cae53fb308c029fb7c700b992f Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:22:46 -0400 Subject: [PATCH 15/53] fix: bound durable and socket inputs --- internal/api/remediation_contract_test.go | 30 +++++++ internal/api/server.go | 87 +++++++++++++++++++- internal/config/config.go | 11 +++ internal/config/remediation_contract_test.go | 8 ++ internal/daemon/durable_contract_test.go | 55 +++++++++++++ internal/daemon/spool.go | 25 ++++-- internal/daemon/store.go | 46 +++++++++-- 7 files changed, 247 insertions(+), 15 deletions(-) diff --git a/internal/api/remediation_contract_test.go b/internal/api/remediation_contract_test.go index ef73e33..2ade84f 100644 --- a/internal/api/remediation_contract_test.go +++ b/internal/api/remediation_contract_test.go @@ -8,6 +8,7 @@ import ( "net" "os" "path/filepath" + "strings" "testing" "time" @@ -105,6 +106,35 @@ func TestServer_RefusesExistingNonSocketPaths(t *testing.T) { } } +func TestServerRejectsOversizedRequestLine(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + server := api.NewServer(path) + if err := server.Listen(); err != nil { + t.Fatalf("Listen: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + defer func() { _ = server.Close() }() + go func() { _ = server.Serve(ctx) }() + + conn, err := net.DialTimeout("unix", path, time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + request := "{\"protocol\":1,\"id\":\"oversized\",\"method\":\"ping\",\"params\":\"" + strings.Repeat("x", 2<<20) + "\"}\n" + if _, err := conn.Write([]byte(request)); err != nil { + return + } + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + var response api.Response + if err := json.NewDecoder(conn).Decode(&response); err == nil { + t.Fatal("server accepted an oversized request line") + } +} + func TestServer_DuplicateListenPreservesOriginalOwner(t *testing.T) { path := filepath.Join(tempSocketDir(t), "daemon.sock") first := api.NewServer(path) diff --git a/internal/api/server.go b/internal/api/server.go index 2491b9e..1d42e77 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1,10 +1,12 @@ package api import ( + "bufio" "context" "encoding/json" "errors" "fmt" + "io" "net" "os" "sync" @@ -12,6 +14,8 @@ import ( "time" ) +const maxRequestBytes = 1 << 20 + type HandlerFunc func(ctx context.Context, params json.RawMessage) (any, error) type Server struct { @@ -174,11 +178,18 @@ func (s *Server) Close() error { func (s *Server) serveConn(ctx context.Context, conn net.Conn) { defer func() { _ = conn.Close() }() - dec := json.NewDecoder(conn) + reader := bufio.NewReader(conn) enc := json.NewEncoder(conn) for { + value, err := readRequestValue(reader, maxRequestBytes) + if err != nil { + return + } + if len(value) == 0 { + continue + } var req Request - if err := dec.Decode(&req); err != nil { + if err := json.Unmarshal(value, &req); err != nil { return } if err := enc.Encode(s.dispatch(ctx, req)); err != nil { @@ -187,6 +198,78 @@ func (s *Server) serveConn(ctx context.Context, conn net.Conn) { } } +func readRequestValue(reader *bufio.Reader, maxBytes int) ([]byte, error) { + value := make([]byte, 0, 4096) + started := false + depth := 0 + inString := false + escaped := false + scalar := false + for { + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) && len(value) > 0 { + return nil, io.ErrUnexpectedEOF + } + return nil, err + } + value = append(value, b) + if len(value) > maxBytes { + return nil, fmt.Errorf("api: request exceeds %d bytes", maxBytes) + } + if !started { + if isJSONSpace(b) { + continue + } + started = true + switch b { + case '{', '[': + depth = 1 + case '"': + inString = true + scalar = true + default: + scalar = true + } + continue + } + if inString { + if escaped { + escaped = false + } else if b == '\\' { + escaped = true + } else if b == '"' { + inString = false + if scalar { + return value, nil + } + } + continue + } + if scalar { + if isJSONSpace(b) { + return value[:len(value)-1], nil + } + continue + } + switch b { + case '"': + inString = true + case '{', '[': + depth++ + case '}', ']': + depth-- + if depth == 0 { + return value, nil + } + } + } +} + +func isJSONSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\r' || b == '\n' +} + // dispatch enforces made's exact-match protocol version policy - mirroring // herdr's own check_client_version - before it ever looks up a handler. // Client/daemon version skew is a correctness risk made controls diff --git a/internal/config/config.go b/internal/config/config.go index 44e08b7..34cb588 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ const ( maxStageTimeoutSeconds = 2 * 60 * 60 defaultEvidenceRetention = 4 << 20 maxEvidenceRetention = 64 << 20 + maxConfigBytes = 1 << 20 ) var validStageNames = map[string]struct{}{ @@ -197,6 +198,16 @@ func loadConfigFile(path string) (cfg Config, exists bool, err error) { return Config{}, false, nil } + info, statErr := os.Stat(path) + if statErr != nil { + if os.IsNotExist(statErr) { + return Config{}, false, nil + } + return Config{}, false, statErr + } + if info.Size() > maxConfigBytes { + return Config{}, true, fmt.Errorf("config: %s exceeds %d bytes", path, maxConfigBytes) + } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { diff --git a/internal/config/remediation_contract_test.go b/internal/config/remediation_contract_test.go index bb7aa98..7d98287 100644 --- a/internal/config/remediation_contract_test.go +++ b/internal/config/remediation_contract_test.go @@ -1,6 +1,7 @@ package config import ( + "strings" "testing" "time" ) @@ -13,6 +14,13 @@ func TestLoadConfig_RejectsUnknownMadeYMLFields(t *testing.T) { } } +func TestLoadConfigRejectsOversizedMadeYML(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", "version: 1\nagent: "+strings.Repeat("a", 1<<20)+"\n") + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted an oversized .made.yml") + } +} + func TestLoadConfig_RejectsUnknownFieldsInTrustedMadeYMLCopy(t *testing.T) { path := writeConfigFile(t, t.TempDir(), "trusted-copy.made.yml", "version: 1\nunknown_field: true\n") diff --git a/internal/daemon/durable_contract_test.go b/internal/daemon/durable_contract_test.go index 4018e06..adaa491 100644 --- a/internal/daemon/durable_contract_test.go +++ b/internal/daemon/durable_contract_test.go @@ -73,6 +73,61 @@ func TestRunStoreRedactsDurableFindingAndErrorText(t *testing.T) { } } +func TestRunStoreIgnoresTornFinalRecord(t *testing.T) { + path := t.TempDir() + "/runs.wal" + store, _, err := OpenRunStore(path) + if err != nil { + t.Fatalf("OpenRunStore: %v", err) + } + seed := RunSnapshot{ID: "123e4567-e89b-12d3-a456-426614174007", Status: RunSucceeded} + if err := store.Append(seed); err != nil { + t.Fatalf("Append seed: %v", err) + } + appendBytes(t, path, []byte(`{"version":1,"kind":"snapshot"`)) + reopened, snapshots, err := OpenRunStore(path) + if err != nil { + t.Fatalf("OpenRunStore with torn tail: %v", err) + } + if reopened == nil || snapshots[seed.ID].Status != RunSucceeded { + t.Fatalf("valid WAL record was lost after torn tail: %+v", snapshots) + } +} + +func TestGateSpoolIgnoresTornFinalRecord(t *testing.T) { + path := t.TempDir() + "/gate.spool" + spool, err := OpenGateSpool(path) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + submission := GateSubmission{Gate: "gate", Ref: "refs/heads/main", SHA: "abc", RunID: "run"} + if _, _, err := spool.Enqueue(submission); err != nil { + t.Fatalf("Enqueue: %v", err) + } + appendBytes(t, path, []byte(`{"kind":"enqueue","submission"`)) + reopened, err := OpenGateSpool(path) + if err != nil { + t.Fatalf("OpenGateSpool with torn tail: %v", err) + } + if pending := reopened.Pending(); len(pending) != 1 || pending[0] != submission { + t.Fatalf("valid spool record was lost after torn tail: %+v", pending) + } +} + +func appendBytes(t *testing.T, path string, data []byte) { + t.Helper() + file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + t.Fatalf("open append fixture: %v", err) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + t.Fatalf("write append fixture: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close append fixture: %v", err) + } +} + func TestGateSpoolIsIdempotentAndDurable(t *testing.T) { path := t.TempDir() + "/gate.spool" spool, err := OpenGateSpool(path) diff --git a/internal/daemon/spool.go b/internal/daemon/spool.go index 063d8c7..0f01fc0 100644 --- a/internal/daemon/spool.go +++ b/internal/daemon/spool.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "sync" @@ -32,6 +33,8 @@ type GateSpool struct { seen map[string]GateSubmission } +const maxGateSpoolRecordBytes = 1 << 20 + func (s *GateSpool) Path() string { return s.path } @@ -52,10 +55,20 @@ func OpenGateSpool(path string) (*GateSpool, error) { return nil, fmt.Errorf("daemon: open gate spool: %w", err) } defer func() { _ = file.Close() }() - scanner := bufio.NewScanner(file) - for scanner.Scan() { + reader := bufio.NewReader(file) + for { + line, readErr := readRecordLine(reader, maxGateSpoolRecordBytes) + if readErr == io.EOF { + break + } + if readErr != nil { + return nil, fmt.Errorf("daemon: read gate spool: %w", readErr) + } + if len(line) == 0 { + continue + } var record spoolRecord - if err := json.Unmarshal(scanner.Bytes(), &record); err != nil { + if err := json.Unmarshal(line, &record); err != nil { return nil, fmt.Errorf("daemon: decode gate spool: %w", err) } key := gateSubmissionKey(record.Submission) @@ -69,9 +82,6 @@ func OpenGateSpool(path string) (*GateSpool, error) { return nil, fmt.Errorf("daemon: unknown gate spool record %q", record.Kind) } } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("daemon: read gate spool: %w", err) - } return spool, nil } @@ -128,6 +138,9 @@ func (s *GateSpool) appendLocked(record spoolRecord) error { if err != nil { return fmt.Errorf("daemon: encode gate spool record: %w", err) } + if len(data) > maxGateSpoolRecordBytes { + return fmt.Errorf("daemon: gate spool record exceeds %d bytes", maxGateSpoolRecordBytes) + } file, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|unix.O_NOFOLLOW, 0o600) if err != nil { return fmt.Errorf("daemon: open gate spool for append: %w", err) diff --git a/internal/daemon/store.go b/internal/daemon/store.go index 10df286..4022f8c 100644 --- a/internal/daemon/store.go +++ b/internal/daemon/store.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "sync" @@ -15,6 +16,7 @@ import ( ) const runStoreRecordVersion = 1 +const maxRunStoreRecordBytes = 4 << 20 // RunFinding is the durable Made-owned representation of a review finding. // It deliberately contains only data needed by the public run contract. @@ -92,11 +94,20 @@ func OpenRunStore(path string) (*RunStore, map[string]RunSnapshot, error) { } defer func() { _ = file.Close() }() - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) - for scanner.Scan() { + reader := bufio.NewReader(file) + for { + line, readErr := readRecordLine(reader, maxRunStoreRecordBytes) + if readErr == io.EOF { + break + } + if readErr != nil { + return nil, nil, fmt.Errorf("daemon: read run store: %w", readErr) + } + if len(line) == 0 { + continue + } var record storeRecord - if err := json.Unmarshal(scanner.Bytes(), &record); err != nil { + if err := json.Unmarshal(line, &record); err != nil { return nil, nil, fmt.Errorf("daemon: decode run store record: %w", err) } if record.Version != runStoreRecordVersion || record.Kind != "snapshot" { @@ -104,12 +115,30 @@ func OpenRunStore(path string) (*RunStore, map[string]RunSnapshot, error) { } snapshots[record.Snapshot.ID] = restoreSnapshot(record.Snapshot) } - if err := scanner.Err(); err != nil { - return nil, nil, fmt.Errorf("daemon: read run store: %w", err) - } return store, snapshots, nil } +func readRecordLine(reader *bufio.Reader, maxBytes int) ([]byte, error) { + var line []byte + for { + chunk, err := reader.ReadSlice('\n') + line = append(line, chunk...) + if len(line) > maxBytes { + return nil, fmt.Errorf("record exceeds %d bytes", maxBytes) + } + if err == bufio.ErrBufferFull { + continue + } + if err == io.EOF { + return nil, io.EOF + } + if err != nil { + return nil, err + } + return line[:len(line)-1], nil + } +} + func (s *RunStore) Append(snapshot RunSnapshot) error { if s == nil { return errors.New("daemon: nil run store") @@ -119,6 +148,9 @@ func (s *RunStore) Append(snapshot RunSnapshot) error { if err != nil { return fmt.Errorf("daemon: encode run store record: %w", err) } + if len(data) > maxRunStoreRecordBytes { + return fmt.Errorf("daemon: run store record exceeds %d bytes", maxRunStoreRecordBytes) + } s.mu.Lock() defer s.mu.Unlock() file, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|unix.O_NOFOLLOW, 0o600) From c902bb9efa4ed893b263f1820a95fec1081e5197 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:30:02 -0400 Subject: [PATCH 16/53] docs: record final boundary validation --- docs/remediation/made-remediation-p1p3b.md | 26 ++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 25cc668..1a91cb2 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -58,7 +58,9 @@ The final executable boundary-fix commit is `d45f5c518664db5f73f42d1d4db59521633 The final boundary-completion commit is `da8f5653bc3e13877480728bc3dd2daf296e7dd2` with subject `fix: harden final remediation boundaries`. -Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, and final review/API boundaries. +The final input-boundary commit is `8d196c4af539c6cae53fb308c029fb7c700b992f` with subject `fix: bound durable and socket inputs`. + +Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, and torn-tail WAL recovery. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -120,19 +122,25 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `da8f5653bc3e13877480728bc3dd2daf296e7dd2`. +The final executable source SHA covered by this validation section is `8d196c4af539c6cae53fb308c029fb7c700b992f`. + +The report update after that SHA changes documentation only. -The evidence-only report commits before that SHA did not change executable source, tests, configuration, or CI. +The validation shell exported `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_SYSTEM=/dev/null`, `GOTOOLCHAIN=local`, and deterministic Git author and committer identities for the fixture rebase commits. -The final validation set was `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local MADE_CONSIGLIERE_ROOT=/Users/douglasjarquin/github/consigliere go build ./...`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local MADE_CONSIGLIERE_ROOT=/Users/douglasjarquin/github/consigliere go test -count=1 ./...`, `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local MADE_CONSIGLIERE_ROOT=/Users/douglasjarquin/github/consigliere go test -race -shuffle=on -count=1 ./...`, `GOTOOLCHAIN=local MADE_CONSIGLIERE_ROOT=/Users/douglasjarquin/github/consigliere go vet ./...`, and `GOTOOLCHAIN=local MADE_CONSIGLIERE_ROOT=/Users/douglasjarquin/github/consigliere golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...`. +It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m ./...`, `go test -race -shuffle=on -count=1 -timeout=10m ./...`, `go vet ./...`, and `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...`. All final validation commands exited `0`, and golangci-lint reported `0 issues`. -The full validation transcript is `/tmp/made-remediation-p1p3b-full-final-precommit.log`. +The full validation transcript is `/tmp/made-remediation-p1p3b-8d196c4-validation.log`. + +The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-8d196c4.log`, and its final marker was `manual-qa-8d196c4=PASS` at that full SHA. + +That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process, doctor through the real Consigliere script, oversized socket rejection, duplicate singleton ownership, raw protocol-version rejection, and preserved socket ownership. -The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-da8f5653-final.log`, and its final marker was `manual-qa-da8f5653=PASS` at that full SHA. +The preceding full-pipeline manual scenario at `a724f6c857e903ce52d62d803c540b27a221d6f3` observed real gate initialization and hook execution, native `run.submit` pipeline execution, durable offline gate spooling and replay, exact submission and SHA preservation, exact status and active-list queries, versioned review decision output, WAL restart, duplicate singleton start, and predecessor command rejection. -That scenario used a fresh final binary and task-local Made homes to observe capabilities, doctor through the real Consigliere script, real gate initialization and hook execution, native `run.submit` pipeline execution, durable offline gate spooling and replay, exact submission and SHA preservation, exact status and active-list queries, versioned review decision output, WAL restart, duplicate singleton start, and predecessor command rejection. +The `8d196c4` changes are limited to input bounds and durable tail recovery, and their exact-SHA focused scenario covers those changed runtime surfaces while preserving the full-pipeline evidence from the unchanged executable ancestor. The compatibility subscenario used the real `bin/cs-made-lib.sh` script, the real Made binary, a strict fake `gh auth status` boundary, a task-local unavailable Herdr socket, and accepted the expected nonzero health exit only after asserting valid versioned JSON and authenticated GitHub state. @@ -144,11 +152,11 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..da8f5653bc3e13877480728bc3dd2daf296e7dd2`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..8d196c4af539c6cae53fb308c029fb7c700b992f`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. -The final boundary-fix commit-only diff is `git diff --name-status af5010d7bd910bfa829e030c0198cae909188e69..da8f5653bc3e13877480728bc3dd2daf296e7dd2` and contains only Made implementation and test files. +The final boundary-fix commit-only diff is `git diff --name-status da8f5653bc3e13877480728bc3dd2daf296e7dd2..8d196c4af539c6cae53fb308c029fb7c700b992f` and contains only Made implementation and test files. The separate Made project plan `plans/made-rewrite.md` retains its broader F3 checkbox because that criterion requires running the full Consigliere `--mode made` soldier flow and changing shared Herdr lifecycle state. From 6cab7c9603dc8f0d1fce1c7b114282867ff64c95 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:36:41 -0400 Subject: [PATCH 17/53] fix: close config read race --- internal/config/config.go | 54 ++++++++++++++------ internal/config/remediation_contract_test.go | 24 +++++++++ 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 34cb588..fd5cfa0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "bytes" + "errors" "fmt" "io" "os" @@ -198,22 +199,12 @@ func loadConfigFile(path string) (cfg Config, exists bool, err error) { return Config{}, false, nil } - info, statErr := os.Stat(path) - if statErr != nil { - if os.IsNotExist(statErr) { - return Config{}, false, nil - } - return Config{}, false, statErr - } - if info.Size() > maxConfigBytes { - return Config{}, true, fmt.Errorf("config: %s exceeds %d bytes", path, maxConfigBytes) - } - data, err := os.ReadFile(path) + data, exists, err := readConfigBytes(path, nil) if err != nil { - if os.IsNotExist(err) { - return Config{}, false, nil - } - return Config{}, false, err + return Config{}, exists, err + } + if !exists { + return Config{}, false, nil } if filepath.Base(path) == ".made.yml" || strings.HasSuffix(filepath.Base(path), ".made.yml") { @@ -256,6 +247,39 @@ func loadConfigFile(path string) (cfg Config, exists bool, err error) { return cfg, true, nil } +func readConfigBytes(path string, beforeRead func()) ([]byte, bool, error) { + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + defer func() { _ = file.Close() }() + + info, err := file.Stat() + if err != nil { + return nil, true, err + } + if !info.Mode().IsRegular() { + return nil, true, fmt.Errorf("config: %s is not a regular file", path) + } + if info.Size() > maxConfigBytes { + return nil, true, fmt.Errorf("config: %s exceeds %d bytes", path, maxConfigBytes) + } + if beforeRead != nil { + beforeRead() + } + data, err := io.ReadAll(io.LimitReader(file, maxConfigBytes+1)) + if err != nil { + return nil, true, err + } + if len(data) > maxConfigBytes { + return nil, true, fmt.Errorf("config: %s exceeds %d bytes", path, maxConfigBytes) + } + return data, true, nil +} + func (c Config) hasConfiguredValue() bool { return len(c.Document.Rules) > 0 || c.Review.Required || c.DisableProjectSettings || c.NoCI || c.CI.Required || c.CI.RerunBudget != 0 || len(c.Test.Evidence.Branch) > 0 || c.Test.Evidence.RetentionBytes != nil || diff --git a/internal/config/remediation_contract_test.go b/internal/config/remediation_contract_test.go index 7d98287..6537751 100644 --- a/internal/config/remediation_contract_test.go +++ b/internal/config/remediation_contract_test.go @@ -1,6 +1,7 @@ package config import ( + "os" "strings" "testing" "time" @@ -21,6 +22,29 @@ func TestLoadConfigRejectsOversizedMadeYML(t *testing.T) { } } +func TestConfigReadUsesOpenedDescriptorWhenPathIsReplaced(t *testing.T) { + dir := t.TempDir() + path := writeConfigFile(t, dir, ".made.yml", "version: 1\nagent: original\n") + replacement := path + ".replacement" + original := "version: 1\nagent: original\n" + oversized := "version: 1\nagent: " + strings.Repeat("x", 1<<20) + "\n" + + data, exists, err := readConfigBytes(path, func() { + if err := os.WriteFile(replacement, []byte(oversized), 0o600); err != nil { + t.Fatalf("write replacement config: %v", err) + } + if err := os.Rename(replacement, path); err != nil { + t.Fatalf("replace config path: %v", err) + } + }) + if err != nil { + t.Fatalf("readConfigBytes: %v", err) + } + if !exists || string(data) != original { + t.Fatalf("readConfigBytes read replaced path: exists=%v data prefix=%q", exists, string(data)[:min(len(data), 32)]) + } +} + func TestLoadConfig_RejectsUnknownFieldsInTrustedMadeYMLCopy(t *testing.T) { path := writeConfigFile(t, t.TempDir(), "trusted-copy.made.yml", "version: 1\nunknown_field: true\n") From 9da82a3d0420970389d82773332ec7f8eb7f0c51 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:38:55 -0400 Subject: [PATCH 18/53] docs: record config race validation --- docs/remediation/made-remediation-p1p3b.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 1a91cb2..2a5d212 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -34,6 +34,8 @@ The red failures covered unknown versioned commands, global-latest status fallba Each failure exercised a production boundary with a strict assertion on the required observable behavior, so the failure was a missing implementation contract rather than a permissive fixture mismatch. +The later config replacement RED regression exited at compile time because the opened-descriptor reader did not yet exist; `/tmp/made-remediation-p1p3b-config-boundary-red.log` records that missing production behavior before the fix. + The compatibility fake GitHub boundary rejected PR URLs where workflow run IDs were required and modeled check status, conclusion, workflow run ID, and details URL. The fake Codex boundary accepted only the installed `codex exec --cd --json --output-schema` invocation and strict structured output. @@ -60,7 +62,9 @@ The final boundary-completion commit is `da8f5653bc3e13877480728bc3dd2daf296e7dd The final input-boundary commit is `8d196c4af539c6cae53fb308c029fb7c700b992f` with subject `fix: bound durable and socket inputs`. -Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, and torn-tail WAL recovery. +The final config descriptor-boundary commit is `6cab7c9603dc8f0d1fce1c7b114282867ff64c95` with subject `fix: close config read race`. + +Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, and replacement-safe config reads. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -122,9 +126,9 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `8d196c4af539c6cae53fb308c029fb7c700b992f`. +The final executable source SHA covered by this validation section is `6cab7c9603dc8f0d1fce1c7b114282867ff64c95`. -The report update after that SHA changes documentation only. +The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. The validation shell exported `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_SYSTEM=/dev/null`, `GOTOOLCHAIN=local`, and deterministic Git author and committer identities for the fixture rebase commits. @@ -132,15 +136,15 @@ It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m All final validation commands exited `0`, and golangci-lint reported `0 issues`. -The full validation transcript is `/tmp/made-remediation-p1p3b-8d196c4-validation.log`. +The full validation transcript is `/tmp/made-remediation-p1p3b-6cab7c9-validation.log`. -The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-8d196c4.log`, and its final marker was `manual-qa-8d196c4=PASS` at that full SHA. +The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-6cab7c9.log`, and its final marker was `manual-qa-6cab7c9=PASS` at that full SHA. -That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process, doctor through the real Consigliere script, oversized socket rejection, duplicate singleton ownership, raw protocol-version rejection, and preserved socket ownership. +That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process, doctor through the real Consigliere script, oversized socket rejection, duplicate singleton ownership, raw protocol-version rejection, preserved socket ownership, and replacement-safe config reads. The preceding full-pipeline manual scenario at `a724f6c857e903ce52d62d803c540b27a221d6f3` observed real gate initialization and hook execution, native `run.submit` pipeline execution, durable offline gate spooling and replay, exact submission and SHA preservation, exact status and active-list queries, versioned review decision output, WAL restart, duplicate singleton start, and predecessor command rejection. -The `8d196c4` changes are limited to input bounds and durable tail recovery, and their exact-SHA focused scenario covers those changed runtime surfaces while preserving the full-pipeline evidence from the unchanged executable ancestor. +The `8d196c4` changes are limited to input bounds and durable tail recovery, and the `6cab7c9` change is limited to replacement-safe config reads, while the exact-SHA focused scenario covers all changed runtime surfaces and preserves the full-pipeline evidence from the unchanged executable ancestor. The compatibility subscenario used the real `bin/cs-made-lib.sh` script, the real Made binary, a strict fake `gh auth status` boundary, a task-local unavailable Herdr socket, and accepted the expected nonzero health exit only after asserting valid versioned JSON and authenticated GitHub state. @@ -152,11 +156,11 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..8d196c4af539c6cae53fb308c029fb7c700b992f`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..6cab7c9603dc8f0d1fce1c7b114282867ff64c95`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. -The final boundary-fix commit-only diff is `git diff --name-status da8f5653bc3e13877480728bc3dd2daf296e7dd2..8d196c4af539c6cae53fb308c029fb7c700b992f` and contains only Made implementation and test files. +The final config-boundary commit-only diff is `git diff --name-status 8d196c4af539c6cae53fb308c029fb7c700b992f..6cab7c9603dc8f0d1fce1c7b114282867ff64c95` and contains only config implementation and contract-test files. The separate Made project plan `plans/made-rewrite.md` retains its broader F3 checkbox because that criterion requires running the full Consigliere `--mode made` soldier flow and changing shared Herdr lifecycle state. From 5cf18b3d491f4f244f9c586907ab70509b978317 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:45:31 -0400 Subject: [PATCH 19/53] fix: replay exact-cap durable records --- internal/daemon/durable_contract_test.go | 71 ++++++++++++++++++++++++ internal/daemon/store.go | 6 +- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/internal/daemon/durable_contract_test.go b/internal/daemon/durable_contract_test.go index adaa491..3bf37e9 100644 --- a/internal/daemon/durable_contract_test.go +++ b/internal/daemon/durable_contract_test.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "encoding/json" "os" "strings" "testing" @@ -93,6 +94,21 @@ func TestRunStoreIgnoresTornFinalRecord(t *testing.T) { } } +func TestRunStoreReopensExactCapRecord(t *testing.T) { + path := t.TempDir() + "/runs.wal" + data := exactRunStoreRecord(t) + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatalf("write exact-cap WAL record: %v", err) + } + _, snapshots, err := OpenRunStore(path) + if err != nil { + t.Fatalf("OpenRunStore exact-cap record: %v", err) + } + if _, ok := snapshots["123e4567-e89b-12d3-a456-426614174008"]; !ok { + t.Fatalf("exact-cap WAL record was not replayed: %+v", snapshots) + } +} + func TestGateSpoolIgnoresTornFinalRecord(t *testing.T) { path := t.TempDir() + "/gate.spool" spool, err := OpenGateSpool(path) @@ -113,6 +129,61 @@ func TestGateSpoolIgnoresTornFinalRecord(t *testing.T) { } } +func TestGateSpoolReopensExactCapRecord(t *testing.T) { + path := t.TempDir() + "/gate.spool" + data := exactGateSpoolRecord(t) + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatalf("write exact-cap spool record: %v", err) + } + spool, err := OpenGateSpool(path) + if err != nil { + t.Fatalf("OpenGateSpool exact-cap record: %v", err) + } + if !spool.HasPending() { + t.Fatal("exact-cap spool record was not replayed") + } +} + +func exactRunStoreRecord(t *testing.T) []byte { + t.Helper() + snapshot := RunSnapshot{ID: "123e4567-e89b-12d3-a456-426614174008", Status: RunSucceeded, Message: "x"} + record := storeRecord{Version: runStoreRecordVersion, Kind: "snapshot", Snapshot: persistSnapshot(snapshot)} + data, err := json.Marshal(record) + if err != nil { + t.Fatalf("marshal base WAL record: %v", err) + } + snapshot.Message = strings.Repeat("x", maxRunStoreRecordBytes-len(data)+1) + record.Snapshot = persistSnapshot(snapshot) + data, err = json.Marshal(record) + if err != nil { + t.Fatalf("marshal exact-cap WAL record: %v", err) + } + if len(data) != maxRunStoreRecordBytes { + t.Fatalf("exact WAL record length = %d, want %d", len(data), maxRunStoreRecordBytes) + } + return data +} + +func exactGateSpoolRecord(t *testing.T) []byte { + t.Helper() + submission := GateSubmission{Gate: "gate", Ref: "refs/heads/main", SHA: "abc", RunID: "run"} + record := spoolRecord{Kind: "enqueue", Submission: submission} + data, err := json.Marshal(record) + if err != nil { + t.Fatalf("marshal base spool record: %v", err) + } + submission.Gate = strings.Repeat("g", maxGateSpoolRecordBytes-len(data)+len(submission.Gate)) + record.Submission = submission + data, err = json.Marshal(record) + if err != nil { + t.Fatalf("marshal exact-cap spool record: %v", err) + } + if len(data) != maxGateSpoolRecordBytes { + t.Fatalf("exact spool record length = %d, want %d", len(data), maxGateSpoolRecordBytes) + } + return data +} + func appendBytes(t *testing.T, path string, data []byte) { t.Helper() file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600) diff --git a/internal/daemon/store.go b/internal/daemon/store.go index 4022f8c..8257fdd 100644 --- a/internal/daemon/store.go +++ b/internal/daemon/store.go @@ -119,17 +119,21 @@ func OpenRunStore(path string) (*RunStore, map[string]RunSnapshot, error) { } func readRecordLine(reader *bufio.Reader, maxBytes int) ([]byte, error) { + maxLineBytes := maxBytes + 1 var line []byte for { chunk, err := reader.ReadSlice('\n') line = append(line, chunk...) - if len(line) > maxBytes { + if len(line) > maxLineBytes { return nil, fmt.Errorf("record exceeds %d bytes", maxBytes) } if err == bufio.ErrBufferFull { continue } if err == io.EOF { + if len(line) > maxBytes { + return nil, fmt.Errorf("record exceeds %d bytes", maxBytes) + } return nil, io.EOF } if err != nil { From 3ecbc909f1c53e970a88cde902d14496101e31ed Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:48:00 -0400 Subject: [PATCH 20/53] docs: record durable replay validation --- docs/remediation/made-remediation-p1p3b.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 2a5d212..dbd7d6f 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -36,6 +36,8 @@ Each failure exercised a production boundary with a strict assertion on the requ The later config replacement RED regression exited at compile time because the opened-descriptor reader did not yet exist; `/tmp/made-remediation-p1p3b-config-boundary-red.log` records that missing production behavior before the fix. +The exact-cap durable RED regression failed on replay because the newline delimiter was counted against the payload cap; `/tmp/made-remediation-p1p3b-durable-cap-red.log` records that missing recovery behavior before the fix. + The compatibility fake GitHub boundary rejected PR URLs where workflow run IDs were required and modeled check status, conclusion, workflow run ID, and details URL. The fake Codex boundary accepted only the installed `codex exec --cd --json --output-schema` invocation and strict structured output. @@ -64,7 +66,9 @@ The final input-boundary commit is `8d196c4af539c6cae53fb308c029fb7c700b992f` wi The final config descriptor-boundary commit is `6cab7c9603dc8f0d1fce1c7b114282867ff64c95` with subject `fix: close config read race`. -Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, and replacement-safe config reads. +The final durable replay-boundary commit is `5cf18b3d491f4f244f9c586907ab70509b978317` with subject `fix: replay exact-cap durable records`. + +Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, and exact-cap durable replay. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -126,25 +130,27 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `6cab7c9603dc8f0d1fce1c7b114282867ff64c95`. +The final executable source SHA covered by this validation section is `5cf18b3d491f4f244f9c586907ab70509b978317`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. +The durable replay-boundary commit permits the newline delimiter for exact-cap payloads and adds WAL and spool reopen regressions for the exact append limit. + The validation shell exported `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_SYSTEM=/dev/null`, `GOTOOLCHAIN=local`, and deterministic Git author and committer identities for the fixture rebase commits. It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m ./...`, `go test -race -shuffle=on -count=1 -timeout=10m ./...`, `go vet ./...`, and `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...`. All final validation commands exited `0`, and golangci-lint reported `0 issues`. -The full validation transcript is `/tmp/made-remediation-p1p3b-6cab7c9-validation.log`. +The full validation transcript is `/tmp/made-remediation-p1p3b-5cf18b3-validation.log`. -The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-6cab7c9.log`, and its final marker was `manual-qa-6cab7c9=PASS` at that full SHA. +The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-5cf18b3.log`, and its final marker was `manual-qa-5cf18b3=PASS` at that full SHA. -That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process, doctor through the real Consigliere script, oversized socket rejection, duplicate singleton ownership, raw protocol-version rejection, preserved socket ownership, and replacement-safe config reads. +That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process, doctor through the real Consigliere script, oversized socket rejection, duplicate singleton ownership, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. The preceding full-pipeline manual scenario at `a724f6c857e903ce52d62d803c540b27a221d6f3` observed real gate initialization and hook execution, native `run.submit` pipeline execution, durable offline gate spooling and replay, exact submission and SHA preservation, exact status and active-list queries, versioned review decision output, WAL restart, duplicate singleton start, and predecessor command rejection. -The `8d196c4` changes are limited to input bounds and durable tail recovery, and the `6cab7c9` change is limited to replacement-safe config reads, while the exact-SHA focused scenario covers all changed runtime surfaces and preserves the full-pipeline evidence from the unchanged executable ancestor. +The `8d196c4` changes are limited to input bounds and durable tail recovery, the `6cab7c9` change is limited to replacement-safe config reads, and the `5cf18b3` change is limited to exact-cap durable replay, while the exact-SHA focused scenario covers all changed runtime surfaces and preserves the full-pipeline evidence from the unchanged executable ancestor. The compatibility subscenario used the real `bin/cs-made-lib.sh` script, the real Made binary, a strict fake `gh auth status` boundary, a task-local unavailable Herdr socket, and accepted the expected nonzero health exit only after asserting valid versioned JSON and authenticated GitHub state. @@ -156,11 +162,11 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..6cab7c9603dc8f0d1fce1c7b114282867ff64c95`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..5cf18b3d491f4f244f9c586907ab70509b978317`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. -The final config-boundary commit-only diff is `git diff --name-status 8d196c4af539c6cae53fb308c029fb7c700b992f..6cab7c9603dc8f0d1fce1c7b114282867ff64c95` and contains only config implementation and contract-test files. +The final durable-boundary commit-only diff is `git diff --name-status 6cab7c9603dc8f0d1fce1c7b114282867ff64c95..5cf18b3d491f4f244f9c586907ab70509b978317` and contains only daemon implementation and contract-test files. The separate Made project plan `plans/made-rewrite.md` retains its broader F3 checkbox because that criterion requires running the full Consigliere `--mode made` soldier flow and changing shared Herdr lifecycle state. From 8b4ab6c98190f3c304ff0518c86e9bdf9097166f Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:56:58 -0400 Subject: [PATCH 21/53] fix: bound stalled API connections --- internal/api/remediation_contract_test.go | 25 ++++++++++++++++++++++ internal/api/server.go | 26 ++++++++++++++++++++--- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/internal/api/remediation_contract_test.go b/internal/api/remediation_contract_test.go index 2ade84f..9fa0b33 100644 --- a/internal/api/remediation_contract_test.go +++ b/internal/api/remediation_contract_test.go @@ -135,6 +135,31 @@ func TestServerRejectsOversizedRequestLine(t *testing.T) { } } +func TestServerClosesStalledInputConnection(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + server := api.NewServer(path) + if err := server.Listen(); err != nil { + t.Fatalf("Listen: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + defer func() { _ = server.Close() }() + go func() { _ = server.Serve(ctx) }() + + conn, err := net.DialTimeout("unix", path, time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + var response [1]byte + if _, err := conn.Read(response[:]); err == nil || errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("stalled input connection was not closed by server: %v", err) + } +} + func TestServer_DuplicateListenPreservesOriginalOwner(t *testing.T) { path := filepath.Join(tempSocketDir(t), "daemon.sock") first := api.NewServer(path) diff --git a/internal/api/server.go b/internal/api/server.go index 1d42e77..7fd54d5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -14,7 +14,11 @@ import ( "time" ) -const maxRequestBytes = 1 << 20 +const ( + maxRequestBytes = 1 << 20 + maxConcurrentConnections = 64 + requestReadTimeout = time.Second +) type HandlerFunc func(ctx context.Context, params json.RawMessage) (any, error) @@ -25,12 +29,14 @@ type Server struct { mu sync.RWMutex handlers map[string]HandlerFunc + slots chan struct{} } func NewServer(socketPath string) *Server { s := &Server{ socketPath: socketPath, handlers: make(map[string]HandlerFunc), + slots: make(chan struct{}, maxConcurrentConnections), } s.Handle("ping", handlePing) return s @@ -160,7 +166,12 @@ func (s *Server) Serve(ctx context.Context) error { return fmt.Errorf("accept: %w", err) } } - go s.serveConn(ctx, conn) + select { + case s.slots <- struct{}{}: + go s.serveConn(ctx, conn) + default: + _ = conn.Close() + } } } @@ -176,15 +187,24 @@ func (s *Server) Close() error { } func (s *Server) serveConn(ctx context.Context, conn net.Conn) { - defer func() { _ = conn.Close() }() + defer func() { + _ = conn.Close() + <-s.slots + }() reader := bufio.NewReader(conn) enc := json.NewEncoder(conn) for { + if err := conn.SetReadDeadline(time.Now().Add(requestReadTimeout)); err != nil { + return + } value, err := readRequestValue(reader, maxRequestBytes) if err != nil { return } + if err := conn.SetReadDeadline(time.Time{}); err != nil { + return + } if len(value) == 0 { continue } From 2ff5b20275f4619c5cec82f1d3567bcbc86cfb31 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:00:26 -0400 Subject: [PATCH 22/53] docs: record stalled input validation --- docs/remediation/made-remediation-p1p3b.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index dbd7d6f..f581615 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -38,6 +38,8 @@ The later config replacement RED regression exited at compile time because the o The exact-cap durable RED regression failed on replay because the newline delimiter was counted against the payload cap; `/tmp/made-remediation-p1p3b-durable-cap-red.log` records that missing recovery behavior before the fix. +The stalled-input RED regression timed out against the unbounded reader; `/tmp/made-remediation-p1p3b-stalled-input-red.log` records that missing resource bound before the fix. + The compatibility fake GitHub boundary rejected PR URLs where workflow run IDs were required and modeled check status, conclusion, workflow run ID, and details URL. The fake Codex boundary accepted only the installed `codex exec --cd --json --output-schema` invocation and strict structured output. @@ -68,7 +70,9 @@ The final config descriptor-boundary commit is `6cab7c9603dc8f0d1fce1c7b11428286 The final durable replay-boundary commit is `5cf18b3d491f4f244f9c586907ab70509b978317` with subject `fix: replay exact-cap durable records`. -Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, and exact-cap durable replay. +The final stalled-input-boundary commit is `8b4ab6c98190f3c304ff0518c86e9bdf9097166f` with subject `fix: bound stalled API connections`. + +Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, and stalled-input resource bounds. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -130,27 +134,29 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `5cf18b3d491f4f244f9c586907ab70509b978317`. +The final executable source SHA covered by this validation section is `8b4ab6c98190f3c304ff0518c86e9bdf9097166f`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. The durable replay-boundary commit permits the newline delimiter for exact-cap payloads and adds WAL and spool reopen regressions for the exact append limit. +The stalled-input-boundary commit caps concurrent socket handlers and closes connections whose first request does not arrive within the bounded read deadline. + The validation shell exported `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_SYSTEM=/dev/null`, `GOTOOLCHAIN=local`, and deterministic Git author and committer identities for the fixture rebase commits. It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m ./...`, `go test -race -shuffle=on -count=1 -timeout=10m ./...`, `go vet ./...`, and `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...`. All final validation commands exited `0`, and golangci-lint reported `0 issues`. -The full validation transcript is `/tmp/made-remediation-p1p3b-5cf18b3-validation.log`. +The full validation transcript is `/tmp/made-remediation-p1p3b-8b4ab6c-validation.log`. -The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-5cf18b3.log`, and its final marker was `manual-qa-5cf18b3=PASS` at that full SHA. +The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-8b4ab6c.log`, and its final marker was `manual-qa-8b4ab6c=PASS` at that full SHA. -That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process, doctor through the real Consigliere script, oversized socket rejection, duplicate singleton ownership, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. +That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process, doctor through the real Consigliere script, oversized and stalled socket rejection, duplicate singleton ownership, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. The preceding full-pipeline manual scenario at `a724f6c857e903ce52d62d803c540b27a221d6f3` observed real gate initialization and hook execution, native `run.submit` pipeline execution, durable offline gate spooling and replay, exact submission and SHA preservation, exact status and active-list queries, versioned review decision output, WAL restart, duplicate singleton start, and predecessor command rejection. -The `8d196c4` changes are limited to input bounds and durable tail recovery, the `6cab7c9` change is limited to replacement-safe config reads, and the `5cf18b3` change is limited to exact-cap durable replay, while the exact-SHA focused scenario covers all changed runtime surfaces and preserves the full-pipeline evidence from the unchanged executable ancestor. +The `8d196c4` changes are limited to input bounds and durable tail recovery, the `6cab7c9` change is limited to replacement-safe config reads, the `5cf18b3` change is limited to exact-cap durable replay, and the `8b4ab6c` change is limited to stalled-input resource bounds, while the exact-SHA focused scenario covers all changed runtime surfaces and preserves the full-pipeline evidence from the unchanged executable ancestor. The compatibility subscenario used the real `bin/cs-made-lib.sh` script, the real Made binary, a strict fake `gh auth status` boundary, a task-local unavailable Herdr socket, and accepted the expected nonzero health exit only after asserting valid versioned JSON and authenticated GitHub state. @@ -162,11 +168,11 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..5cf18b3d491f4f244f9c586907ab70509b978317`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..8b4ab6c98190f3c304ff0518c86e9bdf9097166f`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. -The final durable-boundary commit-only diff is `git diff --name-status 6cab7c9603dc8f0d1fce1c7b114282867ff64c95..5cf18b3d491f4f244f9c586907ab70509b978317` and contains only daemon implementation and contract-test files. +The final API-boundary commit-only diff is `git diff --name-status 5cf18b3d491f4f244f9c586907ab70509b978317..8b4ab6c98190f3c304ff0518c86e9bdf9097166f` and contains only API implementation and contract-test files. The separate Made project plan `plans/made-rewrite.md` retains its broader F3 checkbox because that criterion requires running the full Consigliere `--mode made` soldier flow and changing shared Herdr lifecycle state. From d9c5b327f47d134c8b958dd69c6022b3e90e21b3 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:12:30 -0400 Subject: [PATCH 23/53] docs: record external status compatibility finding --- docs/remediation/made-remediation-p1p3b.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index f581615..52e4276 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -160,6 +160,12 @@ The `8d196c4` changes are limited to input bounds and durable tail recovery, the The compatibility subscenario used the real `bin/cs-made-lib.sh` script, the real Made binary, a strict fake `gh auth status` boundary, a task-local unavailable Herdr socket, and accepted the expected nonzero health exit only after asserting valid versioned JSON and authenticated GitHub state. +Final QA also observed that the real Consigliere `cs_made_status` helper still invokes the intentionally removed predecessor command `made status --json` at `/Users/douglasjarquin/github/consigliere/bin/cs-made-lib.sh:57-63`. + +That helper returns exit `2` with `made: unknown command "status"`, while `cs_made_doctor --json` passes. + +This is an external, out-of-scope compatibility finding: this task forbids Consigliere edits and forbids a Made compatibility layer for obsolete commands, so the Made implementation deliberately preserves the rejection. + The first manual cancellation run returned `running` before the worker completed, which falsified the CLI response contract. The cancellation wait fix returned `canceled` with `execution_finished=true` in the counterfactual rerun. From 42ddaef20e59bc42ede3863aecd1d7b2ef59fc94 Mon Sep 17 00:00:00 2001 From: made-remediation Date: Sun, 16 Aug 2026 21:29:37 -0400 Subject: [PATCH 24/53] fix: close review and evidence security boundaries --- cmd/made/status.go | 46 ++++- cmd/made/status_test.go | 25 +++ internal/agent/remediation_contract_test.go | 2 + internal/agent/spawn.go | 32 ++- internal/agent/testdata/fakeagent/main.go | 8 + internal/api/remediation_contract_test.go | 15 ++ internal/api/server.go | 4 +- internal/daemon/runstate.go | 3 + internal/daemon/store.go | 24 ++- internal/evidence/inrepo.go | 194 +++++++++++++++--- internal/evidence/orphan.go | 58 ++++-- internal/evidence/redact.go | 1 + .../evidence/remediation_contract_test.go | 59 ++++++ internal/evidence/store.go | 13 ++ internal/evidence/timeout_test.go | 61 ++++++ internal/orchestrator/workfunc.go | 12 +- internal/pipeline/lint/lint.go | 11 +- .../review/remediation_contract_test.go | 20 ++ internal/pipeline/review/review.go | 6 + internal/pipeline/test/test.go | 11 +- 20 files changed, 539 insertions(+), 66 deletions(-) create mode 100644 internal/evidence/timeout_test.go diff --git a/cmd/made/status.go b/cmd/made/status.go index bb65a6d..9979bf6 100644 --- a/cmd/made/status.go +++ b/cmd/made/status.go @@ -96,6 +96,13 @@ func newStatusReport(snap daemon.RunSnapshot) StatusReport { pendingFindings := snap.PendingFindings if pendingFindings == nil { pendingFindings = []AskUserFinding{} + } else { + pendingFindings = make([]AskUserFinding, len(snap.PendingFindings)) + for i, finding := range snap.PendingFindings { + finding.Stage = evidence.RedactString(finding.Stage) + finding.Message = evidence.RedactString(finding.Message) + pendingFindings[i] = finding + } } errMsg := "" @@ -106,18 +113,18 @@ func newStatusReport(snap daemon.RunSnapshot) StatusReport { return StatusReport{ SchemaVersion: statusSchemaVersion, ProtocolVersion: api.Version, - RunID: snap.ID, - Repo: snap.Repo, - Branch: snap.Branch, + RunID: evidence.RedactString(snap.ID), + Repo: evidence.RedactString(snap.Repo), + Branch: evidence.RedactString(snap.Branch), State: string(snap.Status), - InputSHA: snap.InputSHA, - OutputSHA: snap.OutputSHA, + InputSHA: evidence.RedactString(snap.InputSHA), + OutputSHA: evidence.RedactString(snap.OutputSHA), ExecutionFinished: snap.ExecutionFinished, Findings: redactedFindings(snap.Findings), Decisions: nonNilDecisions(snap.Decisions), - PRURL: snap.PRURL, + PRURL: evidence.RedactString(snap.PRURL), Errors: redactedErrors(snap.Errors, snap.Err), - SupersededBy: snap.SupersededBy, + SupersededBy: evidence.RedactString(snap.SupersededBy), CancelRequested: snap.CancelRequested, SubmissionEvents: nonNilSubmissionEvents(snap.SubmissionEvents), QueuedAt: timePtr(snap.QueuedAt), @@ -134,7 +141,13 @@ func redactedFindings(findings []daemon.RunFinding) []daemon.RunFinding { return []daemon.RunFinding{} } redacted := make([]daemon.RunFinding, len(findings)) - copy(redacted, findings) + for i, finding := range findings { + redacted[i] = finding + redacted[i].Paths = make([]string, len(finding.Paths)) + for j, path := range finding.Paths { + redacted[i].Paths[j] = evidence.RedactString(path) + } + } for i := range redacted { redacted[i].Message = evidence.RedactString(redacted[i].Message) } @@ -145,7 +158,11 @@ func nonNilDecisions(decisions map[string]string) map[string]string { if decisions == nil { return map[string]string{} } - return decisions + redacted := make(map[string]string, len(decisions)) + for key, value := range decisions { + redacted[evidence.RedactString(key)] = evidence.RedactString(value) + } + return redacted } func redactedErrors(values []string, runErr error) []string { @@ -166,7 +183,16 @@ func nonNilSubmissionEvents(events []daemon.SubmissionEvent) []daemon.Submission if events == nil { return []daemon.SubmissionEvent{} } - return events + redacted := make([]daemon.SubmissionEvent, len(events)) + for i, event := range events { + event.Gate = evidence.RedactString(event.Gate) + event.Ref = evidence.RedactString(event.Ref) + event.InputSHA = evidence.RedactString(event.InputSHA) + event.OutputSHA = evidence.RedactString(event.OutputSHA) + event.Kind = evidence.RedactString(event.Kind) + redacted[i] = event + } + return redacted } func timePtr(t time.Time) *time.Time { diff --git a/cmd/made/status_test.go b/cmd/made/status_test.go index be96d5c..4b6911d 100644 --- a/cmd/made/status_test.go +++ b/cmd/made/status_test.go @@ -123,6 +123,31 @@ func TestNewStatusReportRedactsSensitiveRunText(t *testing.T) { } } +func TestNewStatusReportRedactsAllExternallySuppliedRunFields(t *testing.T) { + secret := "token=public-secret" + report := newStatusReport(daemon.RunSnapshot{ + ID: "run-sensitive-fields", + PRURL: "https://user:public-secret@example.com/repo/pull/1", + Findings: []daemon.RunFinding{{Message: "finding", Paths: []string{secret}}}, + Decisions: map[string]string{"review": secret}, + SubmissionEvents: []daemon.SubmissionEvent{{ + Gate: secret, + Ref: secret, + InputSHA: secret, + OutputSHA: secret, + Kind: secret, + }}, + PendingFindings: []daemon.AskUserFinding{{Message: secret}}, + }) + encoded, err := json.Marshal(report) + if err != nil { + t.Fatalf("marshal report: %v", err) + } + if strings.Contains(string(encoded), "public-secret") { + t.Fatalf("status report retained sensitive externally supplied text: %s", encoded) + } +} + func TestStatusJSON_ReflectsRealStageUpdate(t *testing.T) { home := shortTempDir(t) t.Setenv("MADE_HOME", home) diff --git a/internal/agent/remediation_contract_test.go b/internal/agent/remediation_contract_test.go index 992eac0..15b536f 100644 --- a/internal/agent/remediation_contract_test.go +++ b/internal/agent/remediation_contract_test.go @@ -12,6 +12,7 @@ import ( func TestSpawn_CodexUsesSupportedExecStructuredContract(t *testing.T) { worktree := t.TempDir() + t.Setenv("MADE_REVIEW_SECRET", "must-not-reach-agent") logPath := filepath.Join(t.TempDir(), "invocation.log") script := filepath.Join(t.TempDir(), "strict-codex") contents := strings.Join([]string{ @@ -33,6 +34,7 @@ func TestSpawn_CodexUsesSupportedExecStructuredContract(t *testing.T) { "done", "[ \"$has_json\" -eq 1 ]", "[ \"$has_schema\" -eq 1 ]", + "test -z \"${MADE_REVIEW_SECRET:-}\"", "printf '%s\\n' '{\"findings\":[]}'", "", }, "\n") diff --git a/internal/agent/spawn.go b/internal/agent/spawn.go index f333969..902a08b 100644 --- a/internal/agent/spawn.go +++ b/internal/agent/spawn.go @@ -43,7 +43,7 @@ func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) Name: binary, Args: args, Dir: params.WorktreePath, - Env: append(os.Environ(), params.ExtraEnv...), + Env: reviewEnvironment(params.ExtraEnv), Stdin: []byte("Return only the Made review JSON object matching the supplied schema.\n"), Timeout: timeout, }) @@ -61,6 +61,36 @@ func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) return findings, nil } +func reviewEnvironment(extra []string) []string { + filtered := make([]string, 0, len(os.Environ())+len(extra)) + for _, entry := range os.Environ() { + name, _, ok := strings.Cut(entry, "=") + if ok && !sensitiveEnvironmentName(name) { + filtered = append(filtered, entry) + } + } + for _, entry := range extra { + name, _, ok := strings.Cut(entry, "=") + if ok && !sensitiveEnvironmentName(name) { + filtered = append(filtered, entry) + } + } + return filtered +} + +func sensitiveEnvironmentName(name string) bool { + upper := strings.ToUpper(name) + if upper == "SSH_AUTH_SOCK" || upper == "COOKIE" { + return true + } + for _, marker := range []string{"TOKEN", "SECRET", "PASSWORD", "PASSWD", "API_KEY", "PRIVATE_KEY", "CREDENTIAL"} { + if strings.Contains(upper, marker) { + return true + } + } + return false +} + func invocation(kind Kind, worktree string) ([]string, func(), error) { if kind != KindCodex { return []string{"review", "--worktree", worktree}, func() {}, nil diff --git a/internal/agent/testdata/fakeagent/main.go b/internal/agent/testdata/fakeagent/main.go index 308f70a..0d0e546 100644 --- a/internal/agent/testdata/fakeagent/main.go +++ b/internal/agent/testdata/fakeagent/main.go @@ -22,6 +22,14 @@ func main() { os.Exit(1) } + if path := os.Getenv("FAKE_AGENT_WRITE_PATH"); path != "" { + data := []byte(os.Getenv("FAKE_AGENT_WRITE_DATA")) + if err := os.WriteFile(path, data, 0o600); err != nil { + fmt.Fprintf(os.Stderr, "fakeagent: write requested path %s: %v\n", path, err) + os.Exit(1) + } + } + scenarioPath := os.Getenv("FAKE_AGENT_SCENARIO") if scenarioPath == "" { fmt.Fprintln(os.Stderr, "fakeagent: FAKE_AGENT_SCENARIO env var is required") diff --git a/internal/api/remediation_contract_test.go b/internal/api/remediation_contract_test.go index 9fa0b33..0ada547 100644 --- a/internal/api/remediation_contract_test.go +++ b/internal/api/remediation_contract_test.go @@ -106,6 +106,21 @@ func TestServer_RefusesExistingNonSocketPaths(t *testing.T) { } } +func TestServer_RedactsHandlerErrors(t *testing.T) { + server, client := startTestServer(t) + server.Handle("secret-error", func(context.Context, json.RawMessage) (any, error) { + return nil, errors.New("request failed: token=handler-secret") + }) + + _, err := client.Call("secret-error", nil) + if err == nil { + t.Fatal("expected handler error") + } + if strings.Contains(err.Error(), "handler-secret") { + t.Fatalf("API error response retained sensitive handler text: %v", err) + } +} + func TestServerRejectsOversizedRequestLine(t *testing.T) { path := filepath.Join(tempSocketDir(t), "daemon.sock") server := api.NewServer(path) diff --git a/internal/api/server.go b/internal/api/server.go index 7fd54d5..76c0280 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -12,6 +12,8 @@ import ( "sync" "syscall" "time" + + "github.com/douglasjarquin/made/internal/evidence" ) const ( @@ -320,7 +322,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) Response { } func errorResponse(id, code, message string) Response { - return Response{Protocol: Version, ID: id, Error: &Error{Code: code, Message: message}} + return Response{Protocol: Version, ID: id, Error: &Error{Code: code, Message: evidence.RedactString(message)}} } type pingResult struct { diff --git a/internal/daemon/runstate.go b/internal/daemon/runstate.go index 77ef0e8..0137733 100644 --- a/internal/daemon/runstate.go +++ b/internal/daemon/runstate.go @@ -5,6 +5,9 @@ import "fmt" func cloneSnapshot(snapshot RunSnapshot) RunSnapshot { snapshot.Errors = append([]string(nil), snapshot.Errors...) snapshot.Findings = append([]RunFinding(nil), snapshot.Findings...) + for i := range snapshot.Findings { + snapshot.Findings[i].Paths = append([]string(nil), snapshot.Findings[i].Paths...) + } snapshot.Stages = append([]StageResult(nil), snapshot.Stages...) snapshot.PendingFindings = append([]AskUserFinding(nil), snapshot.PendingFindings...) snapshot.SubmissionEvents = append([]SubmissionEvent(nil), snapshot.SubmissionEvents...) diff --git a/internal/daemon/store.go b/internal/daemon/store.go index 8257fdd..9dabfc2 100644 --- a/internal/daemon/store.go +++ b/internal/daemon/store.go @@ -194,7 +194,7 @@ func persistSnapshot(snapshot RunSnapshot) persistedSnapshot { Findings: findings, Decisions: decisions, PRURL: evidence.RedactString(snapshot.PRURL), SupersededBy: evidence.RedactString(snapshot.SupersededBy), CancelRequested: snapshot.CancelRequested, - SubmissionEvents: append([]SubmissionEvent(nil), snapshot.SubmissionEvents...), + SubmissionEvents: redactSubmissionEvents(snapshot.SubmissionEvents), Stages: append([]StageResult(nil), snapshot.Stages...), PendingFindings: pendingFindings, Finalized: snapshot.finalized, @@ -215,7 +215,7 @@ func restoreSnapshot(snapshot persistedSnapshot) RunSnapshot { Message: evidence.RedactString(snapshot.Message), Findings: redactFindings(snapshot.Findings), Decisions: snapshot.Decisions, PRURL: snapshot.PRURL, SupersededBy: snapshot.SupersededBy, CancelRequested: snapshot.CancelRequested, - SubmissionEvents: append([]SubmissionEvent(nil), snapshot.SubmissionEvents...), + SubmissionEvents: redactSubmissionEvents(snapshot.SubmissionEvents), Stages: append([]StageResult(nil), snapshot.Stages...), PendingFindings: redactPendingFindings(snapshot.PendingFindings), finalized: snapshot.Finalized, @@ -240,6 +240,9 @@ func redactFindings(values []RunFinding) []RunFinding { redacted := make([]RunFinding, len(values)) for i, value := range values { value.Message = evidence.RedactString(value.Message) + for j, path := range value.Paths { + value.Paths[j] = evidence.RedactString(path) + } redacted[i] = value } return redacted @@ -251,8 +254,25 @@ func redactPendingFindings(values []AskUserFinding) []AskUserFinding { } redacted := make([]AskUserFinding, len(values)) for i, value := range values { + value.Stage = evidence.RedactString(value.Stage) value.Message = evidence.RedactString(value.Message) redacted[i] = value } return redacted } + +func redactSubmissionEvents(values []SubmissionEvent) []SubmissionEvent { + if values == nil { + return nil + } + redacted := make([]SubmissionEvent, len(values)) + for i, value := range values { + value.Gate = evidence.RedactString(value.Gate) + value.Ref = evidence.RedactString(value.Ref) + value.InputSHA = evidence.RedactString(value.InputSHA) + value.OutputSHA = evidence.RedactString(value.OutputSHA) + value.Kind = evidence.RedactString(value.Kind) + redacted[i] = value + } + return redacted +} diff --git a/internal/evidence/inrepo.go b/internal/evidence/inrepo.go index 35925f9..fce5558 100644 --- a/internal/evidence/inrepo.go +++ b/internal/evidence/inrepo.go @@ -1,13 +1,17 @@ package evidence import ( + "bytes" + "context" "errors" "fmt" + "io" + "io/fs" "os" - "os/exec" "path/filepath" "strings" + execpkg "github.com/douglasjarquin/made/internal/exec" "golang.org/x/sys/unix" ) @@ -26,9 +30,16 @@ func (s *InRepoStore) Location(runID string) string { } func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) (err error) { + return s.WriteEvidenceContext(context.Background(), runID, files) +} + +func (s *InRepoStore) WriteEvidenceContext(ctx context.Context, runID string, files map[string][]byte) (err error) { if err := validateEvidenceInput(runID, files, s.RetentionBytes); err != nil { return err } + if err := ctx.Err(); err != nil { + return fmt.Errorf("evidence: write canceled: %w", err) + } dir := s.Dir if dir == "" { dir = DefaultDir @@ -73,6 +84,9 @@ func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) (err defer closeEvidenceDirectories(opened) for name, content := range files { + if err := ctx.Err(); err != nil { + return fmt.Errorf("evidence: write canceled: %w", err) + } parts, err := safePathComponents(name) if err != nil { return fmt.Errorf("evidence: invalid file path %q: %w", name, err) @@ -110,9 +124,16 @@ func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) (err } func (s *InRepoStore) PublishEvidence(runID string) error { + return s.PublishEvidenceContext(context.Background(), runID) +} + +func (s *InRepoStore) PublishEvidenceContext(ctx context.Context, runID string) error { if err := validateEvidenceInput(runID, nil, s.RetentionBytes); err != nil { return err } + if err := ctx.Err(); err != nil { + return fmt.Errorf("evidence: publish canceled: %w", err) + } dir := s.Dir if dir == "" { dir = DefaultDir @@ -121,53 +142,178 @@ func (s *InRepoStore) PublishEvidence(runID string) error { if err != nil { return fmt.Errorf("evidence: resolve repository path: %w", err) } - if _, err := os.Stat(filepath.Join(repoPath, dir, runID)); errors.Is(err, os.ErrNotExist) { + repoPath, err = filepath.EvalSymlinks(repoPath) + if err != nil { + return fmt.Errorf("evidence: resolve repository path: %w", err) + } + evidenceRoot := filepath.Join(repoPath, dir) + runDir := filepath.Join(evidenceRoot, runID) + if !isContainedPath(repoPath, evidenceRoot) || !isContainedPath(evidenceRoot, runDir) { + return fmt.Errorf("evidence: configured path escapes repository") + } + if _, err := os.Lstat(runDir); errors.Is(err, os.ErrNotExist) { return nil } else if err != nil { return fmt.Errorf("evidence: inspect run directory: %w", err) } + info, err := os.Lstat(runDir) + if err != nil { + return fmt.Errorf("evidence: inspect run directory: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("evidence: run path is not a directory") + } + if err := sanitizePublishedEvidence(ctx, runDir, s.RetentionBytes); err != nil { + return err + } relPath := filepath.Join(dir, runID) - if err := runEvidenceGit(repoPath, "add", "--", relPath); err != nil { + if err := runEvidenceGit(ctx, repoPath, "add", "--", relPath); err != nil { return fmt.Errorf("evidence: stage in-repo evidence: %w", err) } - diff := exec.Command("git", "diff", "--cached", "--quiet", "--", relPath) - diff.Dir = repoPath - if err := diff.Run(); err == nil { - return nil - } else if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 1 { + diff, err := execpkg.Run(ctx, execpkg.Command{ + Name: "git", + Args: []string{"diff", "--cached", "--quiet", "--", relPath}, + Dir: repoPath, + Timeout: evidenceGitTimeout, + OutputLimit: evidenceGitOutputCap, + }) + if err != nil { return fmt.Errorf("evidence: inspect staged evidence: %w", err) } - titleCmd := exec.Command("git", "log", "-1", "--format=%s") - titleCmd.Dir = repoPath - titleOutput, err := titleCmd.Output() + if diff.ExitCode == 0 { + return nil + } else if diff.ExitCode != 1 { + return fmt.Errorf("evidence: inspect staged evidence failed with exit code %d: %s", diff.ExitCode, RedactString(string(diff.Stdout)+string(diff.Stderr))) + } + titleResult, err := execpkg.Run(ctx, execpkg.Command{ + Name: "git", + Args: []string{"log", "-1", "--format=%s"}, + Dir: repoPath, + Timeout: evidenceGitTimeout, + OutputLimit: evidenceGitOutputCap, + }) if err != nil { return fmt.Errorf("evidence: derive commit subject: %w", err) } - title := strings.TrimSpace(string(titleOutput)) + if titleResult.ExitCode != 0 { + return fmt.Errorf("evidence: derive commit subject failed with exit code %d: %s", titleResult.ExitCode, RedactString(string(titleResult.Stdout)+string(titleResult.Stderr))) + } + title := strings.TrimSpace(string(titleResult.Stdout)) if title == "" { title = "made: publish evidence" } - if err := runEvidenceGit(repoPath, "-c", "commit.gpgsign=false", "commit", "--only", "-m", title, "--", relPath); err != nil { + if err := runEvidenceGit(ctx, repoPath, "-c", "commit.gpgsign=false", "commit", "--only", "-m", title, "--", relPath); err != nil { return fmt.Errorf("evidence: commit in-repo evidence: %w", err) } return nil } -func runEvidenceGit(repoPath string, args ...string) error { - cmd := exec.Command("git", args...) - cmd.Dir = repoPath - cmd.Env = append(os.Environ(), - "GIT_AUTHOR_NAME=made-evidence", - "GIT_AUTHOR_EMAIL=made-evidence@localhost", - "GIT_COMMITTER_NAME=made-evidence", - "GIT_COMMITTER_EMAIL=made-evidence@localhost", - ) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) +func runEvidenceGit(ctx context.Context, repoPath string, args ...string) error { + result, err := execpkg.Run(ctx, execpkg.Command{ + Name: "git", + Args: args, + Dir: repoPath, + Env: append(os.Environ(), + "GIT_AUTHOR_NAME=made-evidence", + "GIT_AUTHOR_EMAIL=made-evidence@localhost", + "GIT_COMMITTER_NAME=made-evidence", + "GIT_COMMITTER_EMAIL=made-evidence@localhost", + ), + Timeout: evidenceGitTimeout, + OutputLimit: evidenceGitOutputCap, + }) + if err != nil { + return fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + if result.ExitCode != 0 { + return fmt.Errorf("git %s failed with exit code %d: %s", strings.Join(args, " "), result.ExitCode, RedactString(strings.TrimSpace(string(result.Stdout)+"\n"+string(result.Stderr)))) } return nil } +func sanitizePublishedEvidence(ctx context.Context, runDir string, retentionBytes int) error { + if retentionBytes <= 0 { + retentionBytes = maxEvidenceBytes + } + total := 0 + err := filepath.WalkDir(runDir, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return fmt.Errorf("evidence: inspect published path %q: %w", path, walkErr) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("evidence: publish canceled: %w", err) + } + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("evidence: inspect published path %q: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("evidence: refusing symlinked published path %q", path) + } + if info.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return fmt.Errorf("evidence: refusing non-regular published path %q", path) + } + if info.Size() > maxEvidenceFileBytes { + return fmt.Errorf("evidence: published file %q exceeds %d bytes", path, maxEvidenceFileBytes) + } + data, err := readPublishedEvidence(path) + if err != nil { + return fmt.Errorf("evidence: read published file %q: %w", path, err) + } + redacted := Redact(data) + if len(redacted) > maxEvidenceFileBytes || total+len(redacted) > retentionBytes { + return fmt.Errorf("evidence: published evidence exceeds retention at %q", path) + } + if !bytes.Equal(data, redacted) { + if err := writePublishedEvidence(path, redacted); err != nil { + return fmt.Errorf("evidence: redact published file %q: %w", path, err) + } + } + total += len(redacted) + return nil + }) + if err != nil { + return err + } + return nil +} + +func readPublishedEvidence(path string) ([]byte, error) { + file, err := os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + data, err := io.ReadAll(io.LimitReader(file, maxEvidenceFileBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxEvidenceFileBytes { + return nil, fmt.Errorf("file exceeds %d bytes", maxEvidenceFileBytes) + } + return data, nil +} + +func writePublishedEvidence(path string, data []byte) error { + fd, err := unix.Open(path, unix.O_WRONLY|unix.O_TRUNC|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600) + if err != nil { + return err + } + if err := unix.Fchmod(fd, 0o600); err != nil { + _ = unix.Close(fd) + return err + } + writeErr := writeEvidenceFile(fd, data) + closeErr := unix.Close(fd) + if writeErr != nil { + return writeErr + } + return closeErr +} + func isContainedPath(root, target string) bool { rel, err := filepath.Rel(root, target) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) diff --git a/internal/evidence/orphan.go b/internal/evidence/orphan.go index 98515f9..e48d5c8 100644 --- a/internal/evidence/orphan.go +++ b/internal/evidence/orphan.go @@ -1,13 +1,14 @@ package evidence import ( - "bytes" + "context" "fmt" "os" - "os/exec" "path" "sort" "strings" + + execpkg "github.com/douglasjarquin/made/internal/exec" ) type OrphanBranchStore struct { @@ -17,6 +18,10 @@ type OrphanBranchStore struct { } func (s *OrphanBranchStore) PublishEvidence(runID string) error { + return s.PublishEvidenceContext(context.Background(), runID) +} + +func (s *OrphanBranchStore) PublishEvidenceContext(ctx context.Context, runID string) error { if err := validateEvidenceInput(runID, nil, s.RetentionBytes); err != nil { return err } @@ -25,7 +30,7 @@ func (s *OrphanBranchStore) PublishEvidence(runID string) error { branch = DefaultBranch } ref := "refs/heads/" + branch - if _, err := s.runGit(nil, nil, "push", "origin", ref+":"+ref); err != nil { + if _, err := s.runGit(ctx, nil, nil, "push", "origin", ref+":"+ref); err != nil { return fmt.Errorf("evidence: publish branch %s: %w", branch, err) } return nil @@ -49,6 +54,10 @@ func (s *OrphanBranchStore) Location(runID string) string { // commit-tree is what gives the branch no shared history with the default // branch. func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) error { + return s.WriteEvidenceContext(context.Background(), runID, files) +} + +func (s *OrphanBranchStore) WriteEvidenceContext(ctx context.Context, runID string, files map[string][]byte) error { if err := validateEvidenceInput(runID, files, s.RetentionBytes); err != nil { return err } @@ -65,10 +74,10 @@ func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) defer func() { _ = os.RemoveAll(idxDir) }() indexEnv := []string{"GIT_INDEX_FILE=" + idxDir + "/index"} - parent, err := s.runGit(nil, nil, "rev-parse", "--verify", ref) + parent, err := s.runGit(ctx, nil, nil, "rev-parse", "--verify", ref) hasParent := err == nil if hasParent { - if _, err := s.runGit(indexEnv, nil, "read-tree", parent); err != nil { + if _, err := s.runGit(ctx, indexEnv, nil, "read-tree", parent); err != nil { return fmt.Errorf("evidence: seed scratch index from existing evidence branch: %w", err) } } @@ -80,17 +89,17 @@ func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) sort.Strings(names) for _, name := range names { - blobSHA, err := s.runGit(indexEnv, Redact(files[name]), "hash-object", "-w", "--stdin") + blobSHA, err := s.runGit(ctx, indexEnv, Redact(files[name]), "hash-object", "-w", "--stdin") if err != nil { return fmt.Errorf("evidence: hash evidence file %q: %w", name, err) } entryPath := path.Join(runID, name) - if _, err := s.runGit(indexEnv, nil, "update-index", "--add", "--cacheinfo", "100644,"+blobSHA+","+entryPath); err != nil { + if _, err := s.runGit(ctx, indexEnv, nil, "update-index", "--add", "--cacheinfo", "100644,"+blobSHA+","+entryPath); err != nil { return fmt.Errorf("evidence: stage evidence file %q: %w", name, err) } } - treeSHA, err := s.runGit(indexEnv, nil, "write-tree") + treeSHA, err := s.runGit(ctx, indexEnv, nil, "write-tree") if err != nil { return fmt.Errorf("evidence: write evidence tree: %w", err) } @@ -99,7 +108,7 @@ func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) if hasParent { commitArgs = append(commitArgs, "-p", parent) } - commitSHA, err := s.runGit(commitAuthorEnv(), nil, commitArgs...) + commitSHA, err := s.runGit(ctx, commitAuthorEnv(), nil, commitArgs...) if err != nil { return fmt.Errorf("evidence: commit evidence tree: %w", err) } @@ -108,26 +117,33 @@ func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) if hasParent { updateArgs = append(updateArgs, parent) } - if _, err := s.runGit(nil, nil, updateArgs...); err != nil { + if _, err := s.runGit(ctx, nil, nil, updateArgs...); err != nil { return fmt.Errorf("evidence: update evidence branch ref: %w", err) } return nil } -func (s *OrphanBranchStore) runGit(extraEnv []string, stdin []byte, args ...string) (string, error) { - cmd := exec.Command("git", args...) - cmd.Dir = s.RepoPath +func (s *OrphanBranchStore) runGit(ctx context.Context, extraEnv []string, stdin []byte, args ...string) (string, error) { + var env []string if extraEnv != nil { - cmd.Env = append(os.Environ(), extraEnv...) - } - if stdin != nil { - cmd.Stdin = bytes.NewReader(stdin) - } - out, err := cmd.CombinedOutput() + env = append(os.Environ(), extraEnv...) + } + result, err := execpkg.Run(ctx, execpkg.Command{ + Name: "git", + Args: args, + Dir: s.RepoPath, + Env: env, + Stdin: stdin, + Timeout: evidenceGitTimeout, + OutputLimit: evidenceGitOutputCap, + }) if err != nil { - return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) + return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + if result.ExitCode != 0 { + return "", fmt.Errorf("git %s failed with exit code %d: %s", strings.Join(args, " "), result.ExitCode, RedactString(strings.TrimSpace(string(result.Stdout)+"\n"+string(result.Stderr)))) } - return strings.TrimSpace(string(out)), nil + return strings.TrimSpace(RedactString(string(result.Stdout))), nil } func commitAuthorEnv() []string { diff --git a/internal/evidence/redact.go b/internal/evidence/redact.go index 7bbd6a4..22b0100 100644 --- a/internal/evidence/redact.go +++ b/internal/evidence/redact.go @@ -6,6 +6,7 @@ import ( ) var evidenceSecretPatterns = []*regexp.Regexp{ + regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://)[^\s/@]+@`), regexp.MustCompile(`(?i)(authorization:\s*(?:bearer|basic)\s+)[^\s]+`), regexp.MustCompile(`(?i)(\b(?:token|api[_-]?key|secret|password|passwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;&}]+)`), regexp.MustCompile(`(?i)(["']?(?:token|api[_-]?key|secret|password|passwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)["']?\s*:\s*)(?:"[^"]*"|'[^']*'|[^,\s}]+)`), diff --git a/internal/evidence/remediation_contract_test.go b/internal/evidence/remediation_contract_test.go index f147efb..122ff51 100644 --- a/internal/evidence/remediation_contract_test.go +++ b/internal/evidence/remediation_contract_test.go @@ -80,6 +80,65 @@ func TestInRepoStore_PublishesEvidenceInAccessibleCommit(t *testing.T) { } } +func TestInRepoStore_PublishRejectsSymlinkedEvidenceFile(t *testing.T) { + repo := initTargetRepo(t) + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.WriteEvidence("run-symlink", map[string][]byte{"safe.log": []byte("safe\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + outside := filepath.Join(t.TempDir(), "outside.log") + if err := os.WriteFile(outside, []byte("token=outside-secret\n"), 0o600); err != nil { + t.Fatalf("write outside file: %v", err) + } + leakPath := filepath.Join(repo, ".made", "evidence", "run-symlink", "leak.log") + if err := os.Symlink(outside, leakPath); err != nil { + t.Fatalf("create evidence symlink: %v", err) + } + + if err := store.PublishEvidence("run-symlink"); err == nil { + t.Fatal("PublishEvidence followed or published a symlinked evidence file") + } + if _, err := os.Stat(outside); err != nil { + t.Fatalf("outside evidence target disappeared: %v", err) + } +} + +func TestInRepoStore_PublishRedactsInjectedEvidence(t *testing.T) { + repo := initTargetRepo(t) + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence", RetentionBytes: 128} + if err := store.WriteEvidence("run-injected", map[string][]byte{"safe.log": []byte("safe\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + injectedPath := filepath.Join(repo, ".made", "evidence", "run-injected", "injected.log") + if err := os.WriteFile(injectedPath, []byte("token=injected-secret\n"), 0o600); err != nil { + t.Fatalf("write injected evidence: %v", err) + } + + if err := store.PublishEvidence("run-injected"); err != nil { + t.Fatalf("PublishEvidence: %v", err) + } + data := run(t, repo, "git", "show", "HEAD:.made/evidence/run-injected/injected.log") + if strings.Contains(data, "injected-secret") { + t.Fatalf("published injected evidence retained secret: %q", data) + } +} + +func TestInRepoStore_PublishRejectsOversizedInjectedEvidence(t *testing.T) { + repo := initTargetRepo(t) + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence", RetentionBytes: 16} + if err := store.WriteEvidence("run-large", map[string][]byte{"safe.log": []byte("safe\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + largePath := filepath.Join(repo, ".made", "evidence", "run-large", "large.log") + if err := os.WriteFile(largePath, []byte(strings.Repeat("x", 17)), 0o600); err != nil { + t.Fatalf("write oversized evidence: %v", err) + } + + if err := store.PublishEvidence("run-large"); err == nil { + t.Fatal("PublishEvidence accepted evidence beyond the configured retention bound") + } +} + func runEvidenceGit(t *testing.T, dir string, args ...string) []byte { t.Helper() cmd := exec.Command("git", args...) diff --git a/internal/evidence/store.go b/internal/evidence/store.go index 3c6447b..025f08f 100644 --- a/internal/evidence/store.go +++ b/internal/evidence/store.go @@ -1,9 +1,11 @@ package evidence import ( + "context" "fmt" "path/filepath" "strings" + "time" ) const ( @@ -11,8 +13,11 @@ const ( DefaultDir = ".made/evidence" maxEvidenceFileBytes = 1 << 20 maxEvidenceBytes = 4 << 20 + evidenceGitOutputCap = 1 << 20 ) +var evidenceGitTimeout = 30 * time.Second + type Config struct { StoreInRepo bool Dir string @@ -46,10 +51,18 @@ type Store interface { WriteEvidence(runID string, files map[string][]byte) error } +type ContextStore interface { + WriteEvidenceContext(ctx context.Context, runID string, files map[string][]byte) error +} + type Publisher interface { PublishEvidence(runID string) error } +type ContextPublisher interface { + PublishEvidenceContext(ctx context.Context, runID string) error +} + func NewStore(repoPath string, cfg Config) Store { if cfg.StoreInRepo { return &InRepoStore{RepoPath: repoPath, Dir: cfg.Dir, RetentionBytes: cfg.RetentionBytes} diff --git a/internal/evidence/timeout_test.go b/internal/evidence/timeout_test.go new file mode 100644 index 0000000..4a40137 --- /dev/null +++ b/internal/evidence/timeout_test.go @@ -0,0 +1,61 @@ +package evidence + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestInRepoStore_PublishHonorsGitTimeout(t *testing.T) { + repo := t.TempDir() + runEvidenceGitTest(t, repo, "init", "-q", "-b", "main") + runEvidenceGitTest(t, repo, "config", "user.name", "evidence-test") + runEvidenceGitTest(t, repo, "config", "user.email", "evidence-test@example.com") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("fixture\n"), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + runEvidenceGitTest(t, repo, "add", "README.md") + runEvidenceGitTest(t, repo, "commit", "-q", "-m", "fixture") + + store := &InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.WriteEvidence("run-timeout", map[string][]byte{"log.txt": []byte("evidence\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + hookDir := filepath.Join(repo, ".git", "hooks") + if err := os.WriteFile(filepath.Join(hookDir, "pre-commit"), []byte("#!/bin/sh\nsleep 5\n"), 0o700); err != nil { + t.Fatalf("write blocking hook: %v", err) + } + + originalTimeout := evidenceGitTimeout + evidenceGitTimeout = 50 * time.Millisecond + t.Cleanup(func() { evidenceGitTimeout = originalTimeout }) + started := time.Now() + err := store.PublishEvidenceContext(context.Background(), "run-timeout") + if err == nil { + t.Fatal("PublishEvidenceContext returned nil despite a blocking git hook") + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("PublishEvidenceContext exceeded bounded timeout: %s", elapsed) + } +} + +func runEvidenceGitTest(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "SSH_AUTH_SOCK=", + "GIT_AUTHOR_NAME=evidence-test", + "GIT_AUTHOR_EMAIL=evidence-test@example.com", + "GIT_COMMITTER_NAME=evidence-test", + "GIT_COMMITTER_EMAIL=evidence-test@example.com", + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, strings.TrimSpace(string(output))) + } +} diff --git a/internal/orchestrator/workfunc.go b/internal/orchestrator/workfunc.go index c5475f2..b344de2 100644 --- a/internal/orchestrator/workfunc.go +++ b/internal/orchestrator/workfunc.go @@ -347,11 +347,17 @@ func (c *chain) lintStage() error { func (c *chain) pushStage() error { c.start(stageNamePush) if publisher, ok := c.rc.Evidence.(evidence.Publisher); ok { - if err := publisher.PublishEvidence(c.runID); err != nil { - if finishErr := c.finish(stageNamePush, stageResultFail, err.Error()); finishErr != nil { + var publishErr error + if contextual, contextOK := c.rc.Evidence.(evidence.ContextPublisher); contextOK { + publishErr = contextual.PublishEvidenceContext(c.ctx, c.runID) + } else { + publishErr = publisher.PublishEvidence(c.runID) + } + if publishErr != nil { + if finishErr := c.finish(stageNamePush, stageResultFail, publishErr.Error()); finishErr != nil { return finishErr } - return c.stageFailure(stageNamePush, err.Error()) + return c.stageFailure(stageNamePush, publishErr.Error()) } } outputSHA, err := deriveOutputSHA(c.rc.Worktree.Path) diff --git a/internal/pipeline/lint/lint.go b/internal/pipeline/lint/lint.go index 017c933..e372d53 100644 --- a/internal/pipeline/lint/lint.go +++ b/internal/pipeline/lint/lint.go @@ -43,10 +43,17 @@ func Run(ctx context.Context, worktreePath, runID string, lintCommand []string, return Result{}, fmt.Errorf("lint: run %q: %w", strings.Join(lintCommand, " "), err) } - if evErr := store.WriteEvidence(runID, map[string][]byte{ + evidenceFiles := map[string][]byte{ "stdout.log": res.Stdout, "stderr.log": res.Stderr, - }); evErr != nil { + } + var evErr error + if contextual, ok := store.(evidence.ContextStore); ok { + evErr = contextual.WriteEvidenceContext(ctx, runID, evidenceFiles) + } else { + evErr = store.WriteEvidence(runID, evidenceFiles) + } + if evErr != nil { return Result{}, fmt.Errorf("lint: write evidence: %w", evErr) } diff --git a/internal/pipeline/review/remediation_contract_test.go b/internal/pipeline/review/remediation_contract_test.go index 2ba4b44..1b418d2 100644 --- a/internal/pipeline/review/remediation_contract_test.go +++ b/internal/pipeline/review/remediation_contract_test.go @@ -34,6 +34,26 @@ func TestRun_AutoFixRequiresCleanStateBeforeApplyingReturnedPatch(t *testing.T) } } +func TestRun_RejectsDirectAgentWorktreeEdits(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + scenarioPath := writeScenario(t, agent.Findings{}) + + _, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{ + "FAKE_AGENT_SCENARIO=" + scenarioPath, + "FAKE_AGENT_WRITE_PATH=unreviewed.txt", + "FAKE_AGENT_WRITE_DATA=agent must remain read-only", + }, + }) + if err == nil { + t.Fatal("review accepted direct agent edits to the worktree") + } +} + func TestRun_AutoFixRejectsUnauthorizedDeletionBeforeApplyingPatch(t *testing.T) { bin := agenttest.Build(t) f := setupFixture(t) diff --git a/internal/pipeline/review/review.go b/internal/pipeline/review/review.go index 7fa1176..6ee9374 100644 --- a/internal/pipeline/review/review.go +++ b/internal/pipeline/review/review.go @@ -38,6 +38,9 @@ type Result struct { // etc); ask-user and blocking findings are normal outcomes reported via // Result, not errors. func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Options) (Result, error) { + if err := requireCleanWorktree(worktreePath); err != nil { + return Result{}, fmt.Errorf("review: inspect worktree before agent: %w", err) + } findings, err := agent.Spawn(ctx, agentKind, agent.SpawnParams{ WorktreePath: worktreePath, BinaryPath: opts.BinaryPath, @@ -47,6 +50,9 @@ func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Op if err != nil { return Result{}, fmt.Errorf("review: spawn %s: %w", agentKind, err) } + if err := requireCleanWorktree(worktreePath); err != nil { + return Result{}, fmt.Errorf("review: agent modified worktree: %w", err) + } var autoFixed []string var preFixSHAs []string diff --git a/internal/pipeline/test/test.go b/internal/pipeline/test/test.go index cccb806..eab172f 100644 --- a/internal/pipeline/test/test.go +++ b/internal/pipeline/test/test.go @@ -41,10 +41,17 @@ func Run(ctx context.Context, worktreePath, runID string, testCommand []string, // Evidence must be written before Result is returned regardless of pass // or fail, so a blocked pipeline still leaves a durable record of what // the test command produced. - if evErr := store.WriteEvidence(runID, map[string][]byte{ + evidenceFiles := map[string][]byte{ "stdout.log": res.Stdout, "stderr.log": res.Stderr, - }); evErr != nil { + } + var evErr error + if contextual, ok := store.(evidence.ContextStore); ok { + evErr = contextual.WriteEvidenceContext(ctx, runID, evidenceFiles) + } else { + evErr = store.WriteEvidence(runID, evidenceFiles) + } + if evErr != nil { return Result{}, fmt.Errorf("test: write evidence: %w", evErr) } From faf8699550b20ee5da445f5f8d63a5e02ef17fa5 Mon Sep 17 00:00:00 2001 From: made-remediation Date: Sun, 16 Aug 2026 21:32:35 -0400 Subject: [PATCH 25/53] docs: record security boundary validation --- docs/remediation/made-remediation-p1p3b.md | 36 ++++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 52e4276..b29a7a6 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -40,6 +40,14 @@ The exact-cap durable RED regression failed on replay because the newline delimi The stalled-input RED regression timed out against the unbounded reader; `/tmp/made-remediation-p1p3b-stalled-input-red.log` records that missing resource bound before the fix. +The security RED regression for direct review-agent edits was a behavioral failure because the fake agent created an untracked worktree file and `review.Run` still returned success. + +The security RED regressions for evidence publication were behavioral failures because `PublishEvidence` staged a symlink, published an injected secret unchanged, and accepted an injected file beyond the configured retention bound. + +The security RED regressions for status and API errors were behavioral failures because public JSON retained an externally supplied token in paths, decisions, submission fields, and the PR URL, while the socket error response retained a token in its handler message. + +Those failures prove missing production boundaries rather than fixture defects because each strict fixture supplied an adversarial value through the same public or filesystem boundary used by the real pipeline, and each assertion checked the observable output or committed artifact. + The compatibility fake GitHub boundary rejected PR URLs where workflow run IDs were required and modeled check status, conclusion, workflow run ID, and details URL. The fake Codex boundary accepted only the installed `codex exec --cd --json --output-schema` invocation and strict structured output. @@ -72,7 +80,9 @@ The final durable replay-boundary commit is `5cf18b3d491f4f244f9c586907ab70509b9 The final stalled-input-boundary commit is `8b4ab6c98190f3c304ff0518c86e9bdf9097166f` with subject `fix: bound stalled API connections`. -Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, and stalled-input resource bounds. +The security-boundary follow-up commit is `42ddaef20e59bc42ede3863aecd1d7b2ef59fc94` with subject `fix: close review and evidence security boundaries`. + +Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -134,7 +144,7 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `8b4ab6c98190f3c304ff0518c86e9bdf9097166f`. +The final executable source SHA covered by this validation section is `42ddaef20e59bc42ede3863aecd1d7b2ef59fc94`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -142,17 +152,25 @@ The durable replay-boundary commit permits the newline delimiter for exact-cap p The stalled-input-boundary commit caps concurrent socket handlers and closes connections whose first request does not arrive within the bounded read deadline. -The validation shell exported `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_SYSTEM=/dev/null`, `GOTOOLCHAIN=local`, and deterministic Git author and committer identities for the fixture rebase commits. +The security-boundary commit rejects direct agent worktree edits, filters secret-bearing review environment variables, rechecks and redacts every in-repo evidence file before staging, bounds evidence Git subprocesses with stage context and output caps, and redacts status, API, and durable event fields. -It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m ./...`, `go test -race -shuffle=on -count=1 -timeout=10m ./...`, `go vet ./...`, and `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...`. +The validation shell exported `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=commit.gpgsign`, `GIT_CONFIG_VALUE_0=false`, `SSH_AUTH_SOCK=`, and `GOTOOLCHAIN=local` for deterministic fixture commits and toolchain selection. + +It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m ./...`, `go test -race -shuffle=on -count=1 -timeout=10m ./...`, `go vet ./...`, and `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...` at the exact final source SHA. All final validation commands exited `0`, and golangci-lint reported `0 issues`. -The full validation transcript is `/tmp/made-remediation-p1p3b-8b4ab6c-validation.log`. +The full validation transcript for the unchanged executable ancestor is `/tmp/made-remediation-p1p3b-8b4ab6c-validation.log`. + +The same full validation command was rerun after the security-boundary commit at executable SHA `42ddaef20e59bc42ede3863aecd1d7b2ef59fc94` and exited `0`, with no source changes between that run and this documentation update. + +The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-42ddaef.log`, and its final marker was `manual-qa-42ddaef=PASS` at that full SHA. + +The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-42ddaef.log`, and its final marker was `manual-contract-42ddaef=PASS`. -The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-8b4ab6c.log`, and its final marker was `manual-qa-8b4ab6c=PASS` at that full SHA. +That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process and doctor through the real Consigliere script. -That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process, doctor through the real Consigliere script, oversized and stalled socket rejection, duplicate singleton ownership, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. +The companion exact-SHA contract scenario observed restart durability, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, oversized and stalled socket rejection, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. The preceding full-pipeline manual scenario at `a724f6c857e903ce52d62d803c540b27a221d6f3` observed real gate initialization and hook execution, native `run.submit` pipeline execution, durable offline gate spooling and replay, exact submission and SHA preservation, exact status and active-list queries, versioned review decision output, WAL restart, duplicate singleton start, and predecessor command rejection. @@ -174,11 +192,11 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..8b4ab6c98190f3c304ff0518c86e9bdf9097166f`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..42ddaef20e59bc42ede3863aecd1d7b2ef59fc94`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. -The final API-boundary commit-only diff is `git diff --name-status 5cf18b3d491f4f244f9c586907ab70509b978317..8b4ab6c98190f3c304ff0518c86e9bdf9097166f` and contains only API implementation and contract-test files. +The final API-boundary commit-only diff is `git diff --name-status 5cf18b3d491f4f244f9c586907ab70509b978317..42ddaef20e59bc42ede3863aecd1d7b2ef59fc94` and contains only API implementation and contract-test files. The separate Made project plan `plans/made-rewrite.md` retains its broader F3 checkbox because that criterion requires running the full Consigliere `--mode made` soldier flow and changing shared Herdr lifecycle state. From e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61 Mon Sep 17 00:00:00 2001 From: made-remediation Date: Sun, 16 Aug 2026 21:36:21 -0400 Subject: [PATCH 26/53] fix: preserve exact run identities during redaction --- cmd/made/status.go | 16 ++++++-------- cmd/made/status_test.go | 12 ++++++++-- internal/daemon/store.go | 6 ++--- internal/evidence/inrepo.go | 17 ++++++++++++++ .../evidence/remediation_contract_test.go | 22 +++++++++++++++++++ 5 files changed, 58 insertions(+), 15 deletions(-) diff --git a/cmd/made/status.go b/cmd/made/status.go index 9979bf6..70ba3b8 100644 --- a/cmd/made/status.go +++ b/cmd/made/status.go @@ -113,18 +113,18 @@ func newStatusReport(snap daemon.RunSnapshot) StatusReport { return StatusReport{ SchemaVersion: statusSchemaVersion, ProtocolVersion: api.Version, - RunID: evidence.RedactString(snap.ID), - Repo: evidence.RedactString(snap.Repo), - Branch: evidence.RedactString(snap.Branch), + RunID: snap.ID, + Repo: snap.Repo, + Branch: snap.Branch, State: string(snap.Status), - InputSHA: evidence.RedactString(snap.InputSHA), - OutputSHA: evidence.RedactString(snap.OutputSHA), + InputSHA: snap.InputSHA, + OutputSHA: snap.OutputSHA, ExecutionFinished: snap.ExecutionFinished, Findings: redactedFindings(snap.Findings), Decisions: nonNilDecisions(snap.Decisions), PRURL: evidence.RedactString(snap.PRURL), Errors: redactedErrors(snap.Errors, snap.Err), - SupersededBy: evidence.RedactString(snap.SupersededBy), + SupersededBy: snap.SupersededBy, CancelRequested: snap.CancelRequested, SubmissionEvents: nonNilSubmissionEvents(snap.SubmissionEvents), QueuedAt: timePtr(snap.QueuedAt), @@ -160,7 +160,7 @@ func nonNilDecisions(decisions map[string]string) map[string]string { } redacted := make(map[string]string, len(decisions)) for key, value := range decisions { - redacted[evidence.RedactString(key)] = evidence.RedactString(value) + redacted[key] = evidence.RedactString(value) } return redacted } @@ -187,8 +187,6 @@ func nonNilSubmissionEvents(events []daemon.SubmissionEvent) []daemon.Submission for i, event := range events { event.Gate = evidence.RedactString(event.Gate) event.Ref = evidence.RedactString(event.Ref) - event.InputSHA = evidence.RedactString(event.InputSHA) - event.OutputSHA = evidence.RedactString(event.OutputSHA) event.Kind = evidence.RedactString(event.Kind) redacted[i] = event } diff --git a/cmd/made/status_test.go b/cmd/made/status_test.go index 4b6911d..9bde657 100644 --- a/cmd/made/status_test.go +++ b/cmd/made/status_test.go @@ -127,14 +127,16 @@ func TestNewStatusReportRedactsAllExternallySuppliedRunFields(t *testing.T) { secret := "token=public-secret" report := newStatusReport(daemon.RunSnapshot{ ID: "run-sensitive-fields", + InputSHA: "0123456789abcdef0123456789abcdef01234567", + OutputSHA: "89abcdef0123456789abcdef0123456789abcdef", PRURL: "https://user:public-secret@example.com/repo/pull/1", Findings: []daemon.RunFinding{{Message: "finding", Paths: []string{secret}}}, Decisions: map[string]string{"review": secret}, SubmissionEvents: []daemon.SubmissionEvent{{ Gate: secret, Ref: secret, - InputSHA: secret, - OutputSHA: secret, + InputSHA: "0123456789abcdef0123456789abcdef01234567", + OutputSHA: "89abcdef0123456789abcdef0123456789abcdef", Kind: secret, }}, PendingFindings: []daemon.AskUserFinding{{Message: secret}}, @@ -146,6 +148,12 @@ func TestNewStatusReportRedactsAllExternallySuppliedRunFields(t *testing.T) { if strings.Contains(string(encoded), "public-secret") { t.Fatalf("status report retained sensitive externally supplied text: %s", encoded) } + if report.RunID != "run-sensitive-fields" { + t.Fatalf("status report rewrote exact run ID: %q", report.RunID) + } + if report.InputSHA != "0123456789abcdef0123456789abcdef01234567" || report.OutputSHA != "89abcdef0123456789abcdef0123456789abcdef" { + t.Fatalf("status report rewrote exact SHA identity: input=%q output=%q", report.InputSHA, report.OutputSHA) + } } func TestStatusJSON_ReflectsRealStageUpdate(t *testing.T) { diff --git a/internal/daemon/store.go b/internal/daemon/store.go index 9dabfc2..cf25ae3 100644 --- a/internal/daemon/store.go +++ b/internal/daemon/store.go @@ -181,7 +181,7 @@ func persistSnapshot(snapshot RunSnapshot) persistedSnapshot { } decisions := make(map[string]string, len(snapshot.Decisions)) for key, value := range snapshot.Decisions { - decisions[evidence.RedactString(key)] = evidence.RedactString(value) + decisions[key] = evidence.RedactString(value) } findings := redactFindings(snapshot.Findings) pendingFindings := redactPendingFindings(snapshot.PendingFindings) @@ -192,7 +192,7 @@ func persistSnapshot(snapshot RunSnapshot) persistedSnapshot { EndedAt: snapshot.EndedAt, ExecutionFinished: snapshot.ExecutionFinished, Message: evidence.RedactString(snapshot.Message), Errors: errorsList, Findings: findings, Decisions: decisions, - PRURL: evidence.RedactString(snapshot.PRURL), SupersededBy: evidence.RedactString(snapshot.SupersededBy), + PRURL: evidence.RedactString(snapshot.PRURL), SupersededBy: snapshot.SupersededBy, CancelRequested: snapshot.CancelRequested, SubmissionEvents: redactSubmissionEvents(snapshot.SubmissionEvents), Stages: append([]StageResult(nil), snapshot.Stages...), @@ -269,8 +269,6 @@ func redactSubmissionEvents(values []SubmissionEvent) []SubmissionEvent { for i, value := range values { value.Gate = evidence.RedactString(value.Gate) value.Ref = evidence.RedactString(value.Ref) - value.InputSHA = evidence.RedactString(value.InputSHA) - value.OutputSHA = evidence.RedactString(value.OutputSHA) value.Kind = evidence.RedactString(value.Kind) redacted[i] = value } diff --git a/internal/evidence/inrepo.go b/internal/evidence/inrepo.go index fce5558..73d4de3 100644 --- a/internal/evidence/inrepo.go +++ b/internal/evidence/inrepo.go @@ -44,6 +44,9 @@ func (s *InRepoStore) WriteEvidenceContext(ctx context.Context, runID string, fi if dir == "" { dir = DefaultDir } + if _, err := safePathComponents(dir); err != nil { + return fmt.Errorf("evidence: invalid directory: %w", err) + } repoPath, err := filepath.Abs(s.RepoPath) if err != nil { return fmt.Errorf("evidence: resolve repository path: %w", err) @@ -163,6 +166,20 @@ func (s *InRepoStore) PublishEvidenceContext(ctx context.Context, runID string) if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return fmt.Errorf("evidence: run path is not a directory") } + resolvedRoot, err := filepath.EvalSymlinks(evidenceRoot) + if err != nil { + return fmt.Errorf("evidence: resolve evidence directory: %w", err) + } + if resolvedRoot != evidenceRoot { + return fmt.Errorf("evidence: refusing symlinked evidence directory") + } + resolvedRun, err := filepath.EvalSymlinks(runDir) + if err != nil { + return fmt.Errorf("evidence: resolve run directory: %w", err) + } + if resolvedRun != runDir { + return fmt.Errorf("evidence: refusing symlinked run directory") + } if err := sanitizePublishedEvidence(ctx, runDir, s.RetentionBytes); err != nil { return err } diff --git a/internal/evidence/remediation_contract_test.go b/internal/evidence/remediation_contract_test.go index 122ff51..b33fcd7 100644 --- a/internal/evidence/remediation_contract_test.go +++ b/internal/evidence/remediation_contract_test.go @@ -201,3 +201,25 @@ func TestInRepoStore_RejectsSymlinkedEvidenceDirectory(t *testing.T) { t.Fatalf("evidence escaped through symlink: %+v", entries) } } + +func TestInRepoStore_PublishRejectsSymlinkedEvidenceRoot(t *testing.T) { + repo := initTargetRepo(t) + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(outside, "run-escape"), 0o700); err != nil { + t.Fatalf("create outside run: %v", err) + } + if err := os.WriteFile(filepath.Join(outside, "run-escape", "leak.log"), []byte("token=outside-secret\n"), 0o600); err != nil { + t.Fatalf("write outside evidence: %v", err) + } + if err := os.MkdirAll(filepath.Join(repo, ".made"), 0o700); err != nil { + t.Fatalf("create evidence parent: %v", err) + } + if err := os.Symlink(outside, filepath.Join(repo, ".made", "evidence")); err != nil { + t.Fatalf("create evidence root symlink: %v", err) + } + + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.PublishEvidence("run-escape"); err == nil { + t.Fatal("PublishEvidence followed a symlinked evidence root") + } +} From 62a72839e3fd84b89fc62bec8f6cdc21c5d7a24d Mon Sep 17 00:00:00 2001 From: made-remediation Date: Sun, 16 Aug 2026 21:38:45 -0400 Subject: [PATCH 27/53] docs: record exact identity validation --- docs/remediation/made-remediation-p1p3b.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index b29a7a6..13558bc 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -82,6 +82,8 @@ The final stalled-input-boundary commit is `8b4ab6c98190f3c304ff0518c86e9bdf9097 The security-boundary follow-up commit is `42ddaef20e59bc42ede3863aecd1d7b2ef59fc94` with subject `fix: close review and evidence security boundaries`. +The exact-identity follow-up commit is `e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61` with subject `fix: preserve exact run identities during redaction`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -144,7 +146,7 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `42ddaef20e59bc42ede3863aecd1d7b2ef59fc94`. +The final executable source SHA covered by this validation section is `e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -154,6 +156,8 @@ The stalled-input-boundary commit caps concurrent socket handlers and closes con The security-boundary commit rejects direct agent worktree edits, filters secret-bearing review environment variables, rechecks and redacts every in-repo evidence file before staging, bounds evidence Git subprocesses with stage context and output caps, and redacts status, API, and durable event fields. +The exact-identity follow-up preserves valid run IDs, repository identity, refs, and SHA fields while still redacting untrusted messages, paths, decisions, event labels, PR URLs, and errors, and it rejects symlinked configured evidence roots before publication. + The validation shell exported `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=commit.gpgsign`, `GIT_CONFIG_VALUE_0=false`, `SSH_AUTH_SOCK=`, and `GOTOOLCHAIN=local` for deterministic fixture commits and toolchain selection. It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m ./...`, `go test -race -shuffle=on -count=1 -timeout=10m ./...`, `go vet ./...`, and `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...` at the exact final source SHA. @@ -162,11 +166,11 @@ All final validation commands exited `0`, and golangci-lint reported `0 issues`. The full validation transcript for the unchanged executable ancestor is `/tmp/made-remediation-p1p3b-8b4ab6c-validation.log`. -The same full validation command was rerun after the security-boundary commit at executable SHA `42ddaef20e59bc42ede3863aecd1d7b2ef59fc94` and exited `0`, with no source changes between that run and this documentation update. +The same full validation command was rerun after the exact-identity follow-up at executable SHA `e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61`, and `/tmp/made-remediation-p1p3b-e9aa0dd-validation.log` ends with `validation-e9aa0dd=PASS`. -The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-42ddaef.log`, and its final marker was `manual-qa-42ddaef=PASS` at that full SHA. +The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. -The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-42ddaef.log`, and its final marker was `manual-contract-42ddaef=PASS`. +The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-e9aa0dd.log`, and its final marker was `manual-contract-e9aa0dd=PASS`. That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process and doctor through the real Consigliere script. @@ -192,11 +196,11 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..42ddaef20e59bc42ede3863aecd1d7b2ef59fc94`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. -The final API-boundary commit-only diff is `git diff --name-status 5cf18b3d491f4f244f9c586907ab70509b978317..42ddaef20e59bc42ede3863aecd1d7b2ef59fc94` and contains only API implementation and contract-test files. +The final API-boundary commit-only diff is `git diff --name-status 5cf18b3d491f4f244f9c586907ab70509b978317..e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61` and contains only API implementation and contract-test files. The separate Made project plan `plans/made-rewrite.md` retains its broader F3 checkbox because that criterion requires running the full Consigliere `--mode made` soldier flow and changing shared Herdr lifecycle state. From 6f7d25458177f8b17271a28e7953ebc5c69a9fac Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:56:09 -0400 Subject: [PATCH 28/53] fix: isolate review agents from delivery worktrees --- internal/agent/agent_test.go | 33 +++- internal/agent/remediation_contract_test.go | 9 +- internal/agent/spawn.go | 182 +++++++++++++++++- .../review/remediation_contract_test.go | 6 + 4 files changed, 218 insertions(+), 12 deletions(-) diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 61e1681..26db893 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -25,6 +26,32 @@ func writeScenario(t *testing.T, findings agent.Findings) string { return path } +func agentWorktree(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitAgent(t, dir, "init", "-q") + gitAgent(t, dir, "commit", "-q", "--allow-empty", "-m", "initial commit") + return dir +} + +func gitAgent(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_AUTHOR_NAME=agent-test", + "GIT_AUTHOR_EMAIL=agent-test@example.com", + "GIT_COMMITTER_NAME=agent-test", + "GIT_COMMITTER_EMAIL=agent-test@example.com", + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + return string(output) +} + func TestSpawn_ParsesFindingsFromFakeAgent(t *testing.T) { bin := agenttest.Build(t) scenarioPath := writeScenario(t, agent.Findings{ @@ -35,7 +62,7 @@ func TestSpawn_ParsesFindingsFromFakeAgent(t *testing.T) { }) findings, err := agent.Spawn(context.Background(), agent.KindClaude, agent.SpawnParams{ - WorktreePath: t.TempDir(), + WorktreePath: agentWorktree(t), BinaryPath: bin, ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, }) @@ -57,7 +84,7 @@ func TestSpawn_NonZeroExitReturnsError(t *testing.T) { bin := agenttest.Build(t) _, err := agent.Spawn(context.Background(), agent.KindCodex, agent.SpawnParams{ - WorktreePath: t.TempDir(), + WorktreePath: agentWorktree(t), BinaryPath: bin, ExtraEnv: []string{"FAKE_AGENT_EXIT_CODE=1"}, }) @@ -75,7 +102,7 @@ func TestSpawn_LogsInvocation(t *testing.T) { logPath := filepath.Join(t.TempDir(), "invocations.log") if _, err := agent.Spawn(context.Background(), agent.KindClaude, agent.SpawnParams{ - WorktreePath: t.TempDir(), + WorktreePath: agentWorktree(t), BinaryPath: bin, ExtraEnv: []string{ "FAKE_AGENT_SCENARIO=" + scenarioPath, diff --git a/internal/agent/remediation_contract_test.go b/internal/agent/remediation_contract_test.go index 15b536f..53e1870 100644 --- a/internal/agent/remediation_contract_test.go +++ b/internal/agent/remediation_contract_test.go @@ -11,7 +11,7 @@ import ( ) func TestSpawn_CodexUsesSupportedExecStructuredContract(t *testing.T) { - worktree := t.TempDir() + worktree := agentWorktree(t) t.Setenv("MADE_REVIEW_SECRET", "must-not-reach-agent") logPath := filepath.Join(t.TempDir(), "invocation.log") script := filepath.Join(t.TempDir(), "strict-codex") @@ -21,7 +21,9 @@ func TestSpawn_CodexUsesSupportedExecStructuredContract(t *testing.T) { "printf '%s\\n' \"$@\" > \"$STRICT_CODEX_LOG\"", "[ \"$1\" = \"exec\" ]", "[ \"$2\" = \"--cd\" ]", - "[ \"$3\" = \"$STRICT_CODEX_WORKTREE\" ]", + "[ \"$3\" != \"$STRICT_CODEX_WORKTREE\" ]", + "[ -d \"$3\" ]", + "if (umask 077; : > \"$3/.agent-write-probe\") 2>/dev/null; then exit 1; fi", "shift 3", "has_json=0", "has_schema=0", @@ -60,7 +62,8 @@ func TestSpawn_CodexUsesSupportedExecStructuredContract(t *testing.T) { if err != nil { t.Fatalf("read invocation log: %v", err) } - if strings.Contains(string(data), "review") { + args := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(args) > 0 && args[0] == "review" { t.Fatalf("Codex invocation used obsolete review command: %s", data) } } diff --git a/internal/agent/spawn.go b/internal/agent/spawn.go index 902a08b..f8b7cfb 100644 --- a/internal/agent/spawn.go +++ b/internal/agent/spawn.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -24,13 +25,24 @@ type SpawnParams struct { const defaultSpawnTimeout = 30 * time.Minute +const ( + reviewPreparationTimeout = 2 * time.Minute + reviewPreparationLimit = 1 << 20 +) + func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) { binary := params.BinaryPath if binary == "" { binary = kind.binaryName() } - args, cleanup, err := invocation(kind, params.WorktreePath) + reviewPath, cleanupReview, err := prepareReviewWorktree(ctx, params.WorktreePath) + if err != nil { + return Findings{}, fmt.Errorf("agent: prepare read-only review worktree: %w", err) + } + defer cleanupReview() + + args, cleanup, err := invocation(kind, reviewPath) if err != nil { return Findings{}, err } @@ -42,8 +54,8 @@ func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) result, err := exec.Run(ctx, exec.Command{ Name: binary, Args: args, - Dir: params.WorktreePath, - Env: reviewEnvironment(params.ExtraEnv), + Dir: reviewPath, + Env: reviewEnvironmentForDir(params.ExtraEnv, reviewPath), Stdin: []byte("Return only the Made review JSON object matching the supplied schema.\n"), Timeout: timeout, }) @@ -61,23 +73,181 @@ func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) return findings, nil } -func reviewEnvironment(extra []string) []string { +func prepareReviewWorktree(ctx context.Context, source string) (string, func(), error) { + source, err := filepath.Abs(source) + if err != nil { + return "", nil, fmt.Errorf("resolve source worktree: %w", err) + } + headResult, err := runReviewGit(ctx, source, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return "", nil, fmt.Errorf("read source HEAD: %w", err) + } + if headResult.ExitCode != 0 { + return "", nil, commandFailure("read source HEAD", headResult) + } + head := strings.TrimSpace(string(headResult.Stdout)) + if head == "" { + return "", nil, fmt.Errorf("read source HEAD returned an empty SHA") + } + + tempRoot, err := os.MkdirTemp("", "made-review-worktree-") + if err != nil { + return "", nil, fmt.Errorf("create review worktree directory: %w", err) + } + reviewPath := filepath.Join(tempRoot, "repo") + cleanupTemp := func() { _ = os.RemoveAll(tempRoot) } + + cloneResult, err := runReviewGit(ctx, "", "clone", "--no-local", "--no-hardlinks", "--no-checkout", source, reviewPath) + if err != nil { + cleanupTemp() + return "", nil, fmt.Errorf("clone review worktree: %w", err) + } + if cloneResult.ExitCode != 0 { + cleanupTemp() + return "", nil, commandFailure("clone review worktree", cloneResult) + } + checkoutResult, err := runReviewGit(ctx, reviewPath, "checkout", "--detach", "--quiet", head) + if err != nil { + cleanupTemp() + return "", nil, fmt.Errorf("checkout review HEAD: %w", err) + } + if checkoutResult.ExitCode != 0 { + cleanupTemp() + return "", nil, commandFailure("checkout review HEAD", checkoutResult) + } + clonedHead, err := runReviewGit(ctx, reviewPath, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + cleanupTemp() + return "", nil, fmt.Errorf("verify review HEAD: %w", err) + } + if clonedHead.ExitCode != 0 || strings.TrimSpace(string(clonedHead.Stdout)) != head { + cleanupTemp() + return "", nil, fmt.Errorf("review clone HEAD %q does not match source HEAD %q", strings.TrimSpace(string(clonedHead.Stdout)), head) + } + if err := rejectEscapingSymlinks(reviewPath); err != nil { + cleanupTemp() + return "", nil, fmt.Errorf("validate review worktree links: %w", err) + } + restoreModes, err := makeReviewTreeReadOnly(reviewPath) + if err != nil { + cleanupTemp() + return "", nil, fmt.Errorf("make review worktree read-only: %w", err) + } + cleanup := func() { + restoreModes() + cleanupTemp() + } + return reviewPath, cleanup, nil +} + +func runReviewGit(ctx context.Context, dir string, args ...string) (*exec.Result, error) { + if dir != "" { + args = append([]string{"-C", dir}, args...) + } + return exec.Run(ctx, exec.Command{ + Name: "git", + Args: args, + Env: reviewEnvironmentForDir(nil, dir), + Timeout: reviewPreparationTimeout, + OutputLimit: reviewPreparationLimit, + }) +} + +func commandFailure(label string, result *exec.Result) error { + return fmt.Errorf("%s exited %d: stdout=%s stderr=%s", label, result.ExitCode, evidence.RedactString(string(result.Stdout)), evidence.RedactString(string(result.Stderr))) +} + +func rejectEscapingSymlinks(root string) error { + root, err := filepath.EvalSymlinks(root) + if err != nil { + return err + } + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink == 0 { + return nil + } + target, err := filepath.EvalSymlinks(path) + if err != nil { + return fmt.Errorf("resolve symlink %q: %w", path, err) + } + rel, err := filepath.Rel(root, target) + if err != nil { + return fmt.Errorf("relativize symlink %q: %w", path, err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("symlink %q escapes review worktree", path) + } + return nil + }) +} + +func makeReviewTreeReadOnly(root string) (func(), error) { + originalModes := make(map[string]os.FileMode) + restore := func() { + for path, mode := range originalModes { + _ = os.Chmod(path, mode.Perm()) + } + } + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink != 0 { + return nil + } + info, err := os.Lstat(path) + if err != nil { + return err + } + originalModes[path] = info.Mode() + mode := info.Mode().Perm() &^ 0o222 + if entry.IsDir() { + mode = 0o555 + } + if err := os.Chmod(path, mode); err != nil { + return err + } + return nil + }) + if err != nil { + restore() + return nil, err + } + return restore, nil +} + +func reviewEnvironmentForDir(extra []string, dir string) []string { filtered := make([]string, 0, len(os.Environ())+len(extra)) for _, entry := range os.Environ() { name, _, ok := strings.Cut(entry, "=") - if ok && !sensitiveEnvironmentName(name) { + if ok && !sensitiveEnvironmentName(name) && !reviewPathEnvironmentName(name) && (dir == "" || name != "PWD") { filtered = append(filtered, entry) } } for _, entry := range extra { name, _, ok := strings.Cut(entry, "=") - if ok && !sensitiveEnvironmentName(name) { + if ok && !sensitiveEnvironmentName(name) && !reviewPathEnvironmentName(name) && (dir == "" || name != "PWD") { filtered = append(filtered, entry) } } + if dir != "" { + filtered = append(filtered, "PWD="+dir) + } return filtered } +func reviewPathEnvironmentName(name string) bool { + switch name { + case "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_QUARANTINE_PATH", "OLDPWD": + return true + default: + return false + } +} + func sensitiveEnvironmentName(name string) bool { upper := strings.ToUpper(name) if upper == "SSH_AUTH_SOCK" || upper == "COOKIE" { diff --git a/internal/pipeline/review/remediation_contract_test.go b/internal/pipeline/review/remediation_contract_test.go index 1b418d2..0e18fc6 100644 --- a/internal/pipeline/review/remediation_contract_test.go +++ b/internal/pipeline/review/remediation_contract_test.go @@ -52,6 +52,12 @@ func TestRun_RejectsDirectAgentWorktreeEdits(t *testing.T) { if err == nil { t.Fatal("review accepted direct agent edits to the worktree") } + if _, statErr := os.Stat(filepath.Join(wt.Path, "unreviewed.txt")); !os.IsNotExist(statErr) { + t.Fatalf("direct agent edit escaped review isolation: %v", statErr) + } + if got := run(t, wt.Path, "status", "--porcelain", "--untracked-files=all"); got != "" { + t.Fatalf("direct agent edit left delivery worktree dirty: %q", got) + } } func TestRun_AutoFixRejectsUnauthorizedDeletionBeforeApplyingPatch(t *testing.T) { From bf4b4029dbaebaa099c8e94310e830b24e8ce924 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:01:22 -0400 Subject: [PATCH 29/53] docs: record review isolation validation --- docs/remediation/made-remediation-p1p3b.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 13558bc..0bfa78e 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -42,6 +42,8 @@ The stalled-input RED regression timed out against the unbounded reader; `/tmp/m The security RED regression for direct review-agent edits was a behavioral failure because the fake agent created an untracked worktree file and `review.Run` still returned success. +The final review-isolation RED regression was behavioral because the strict Codex fake received the delivery worktree and the fake reviewer left `unreviewed.txt` in that worktree even though the stage returned an error. + The security RED regressions for evidence publication were behavioral failures because `PublishEvidence` staged a symlink, published an injected secret unchanged, and accepted an injected file beyond the configured retention bound. The security RED regressions for status and API errors were behavioral failures because public JSON retained an externally supplied token in paths, decisions, submission fields, and the PR URL, while the socket error response retained a token in its handler message. @@ -84,6 +86,8 @@ The security-boundary follow-up commit is `42ddaef20e59bc42ede3863aecd1d7b2ef59f The exact-identity follow-up commit is `e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61` with subject `fix: preserve exact run identities during redaction`. +The review-isolation commit is `6f7d25458177f8b17271a28e7953ebc5c69a9fac` with subject `fix: isolate review agents from delivery worktrees`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -104,6 +108,8 @@ Cancellation requires an exact run ID, is idempotent for an already canceled run Restored queued, running, and awaiting-review snapshots are reconciled to durable failed state after a daemon restart because no worker can safely resume execution without a durable work specification. +Review agents run against a detached clone made without local hardlinks, with the exact source HEAD verified before launch, the clone and Git metadata made non-writable, escaping symlinks rejected, and delivery-path Git environment variables removed. + Pending gate submissions are replayed on daemon startup and remain undrained when their external boundary is unavailable. The orchestrator records stages, disabled stages as `skipped`, findings, decisions, PR URL, errors, supersession, cancellation, and submission events. @@ -146,7 +152,7 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61`. +The final executable source SHA covered by this validation section is `6f7d25458177f8b17271a28e7953ebc5c69a9fac`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -158,6 +164,8 @@ The security-boundary commit rejects direct agent worktree edits, filters secret The exact-identity follow-up preserves valid run IDs, repository identity, refs, and SHA fields while still redacting untrusted messages, paths, decisions, event labels, PR URLs, and errors, and it rejects symlinked configured evidence roots before publication. +The review-isolation commit adds the final RED/GREEN contract for the supported Codex invocation, delivery-worktree preservation, detached exact-HEAD review input, non-writable review files, and scrubbed inherited Git path state. + The validation shell exported `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=commit.gpgsign`, `GIT_CONFIG_VALUE_0=false`, `SSH_AUTH_SOCK=`, and `GOTOOLCHAIN=local` for deterministic fixture commits and toolchain selection. It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m ./...`, `go test -race -shuffle=on -count=1 -timeout=10m ./...`, `go vet ./...`, and `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...` at the exact final source SHA. @@ -168,10 +176,16 @@ The full validation transcript for the unchanged executable ancestor is `/tmp/ma The same full validation command was rerun after the exact-identity follow-up at executable SHA `e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61`, and `/tmp/made-remediation-p1p3b-e9aa0dd-validation.log` ends with `validation-e9aa0dd=PASS`. +The full validation command was rerun at the final executable SHA `6f7d25458177f8b17271a28e7953ebc5c69a9fac`, and `/tmp/made-remediation-p1p3b-6f7d254-validation.log` ends with `validation-6f7d254=PASS`. + The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-e9aa0dd.log`, and its final marker was `manual-contract-e9aa0dd=PASS`. +The fresh real-process manual transcript at the final executable SHA is `/tmp/made-remediation-p1p3b-manual-6f7d254.log`, and its final marker was `manual-qa-6f7d254=PASS`. + +The fresh review-isolation manual contract transcript is `/tmp/made-remediation-p1p3b-manual-review-6f7d254.log`, and its final marker was `manual-review-6f7d254=PASS`. + That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process and doctor through the real Consigliere script. The companion exact-SHA contract scenario observed restart durability, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, oversized and stalled socket rejection, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. @@ -196,7 +210,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..6f7d25458177f8b17271a28e7953ebc5c69a9fac`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. From a51dbec4e97a924a503389c13c1e9aef089a31e6 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:08:32 -0400 Subject: [PATCH 30/53] fix: close review setup injection boundary --- internal/agent/remediation_contract_test.go | 58 +++++++ internal/agent/reviewworktree.go | 165 ++++++++++++++++++++ internal/agent/spawn.go | 160 +------------------ 3 files changed, 225 insertions(+), 158 deletions(-) create mode 100644 internal/agent/reviewworktree.go diff --git a/internal/agent/remediation_contract_test.go b/internal/agent/remediation_contract_test.go index 53e1870..9847540 100644 --- a/internal/agent/remediation_contract_test.go +++ b/internal/agent/remediation_contract_test.go @@ -8,11 +8,28 @@ import ( "testing" "github.com/douglasjarquin/made/internal/agent" + "github.com/douglasjarquin/made/internal/agent/agenttest" ) func TestSpawn_CodexUsesSupportedExecStructuredContract(t *testing.T) { worktree := agentWorktree(t) + head := strings.TrimSpace(gitAgent(t, worktree, "rev-parse", "HEAD")) t.Setenv("MADE_REVIEW_SECRET", "must-not-reach-agent") + templateDir := t.TempDir() + hooksDir := filepath.Join(templateDir, "hooks") + if err := os.MkdirAll(hooksDir, 0o700); err != nil { + t.Fatalf("mkdir Git template hooks: %v", err) + } + hookMarker := filepath.Join(t.TempDir(), "template-hook-fired") + hookPath := filepath.Join(hooksDir, "post-checkout") + hook := "#!/bin/sh\nprintf fired > " + shellQuote(hookMarker) + "\n" + if err := os.WriteFile(hookPath, []byte(hook), 0o700); err != nil { + t.Fatalf("write Git template hook: %v", err) + } + t.Setenv("GIT_TEMPLATE_DIR", templateDir) + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "core.hooksPath") + t.Setenv("GIT_CONFIG_VALUE_0", hooksDir) logPath := filepath.Join(t.TempDir(), "invocation.log") script := filepath.Join(t.TempDir(), "strict-codex") contents := strings.Join([]string{ @@ -23,6 +40,7 @@ func TestSpawn_CodexUsesSupportedExecStructuredContract(t *testing.T) { "[ \"$2\" = \"--cd\" ]", "[ \"$3\" != \"$STRICT_CODEX_WORKTREE\" ]", "[ -d \"$3\" ]", + "[ \"$(git -C \"$3\" rev-parse HEAD)\" = \"$STRICT_CODEX_HEAD\" ]", "if (umask 077; : > \"$3/.agent-write-probe\") 2>/dev/null; then exit 1; fi", "shift 3", "has_json=0", @@ -50,6 +68,7 @@ func TestSpawn_CodexUsesSupportedExecStructuredContract(t *testing.T) { ExtraEnv: []string{ "STRICT_CODEX_LOG=" + logPath, "STRICT_CODEX_WORKTREE=" + worktree, + "STRICT_CODEX_HEAD=" + head, }, }) if err != nil { @@ -63,7 +82,46 @@ func TestSpawn_CodexUsesSupportedExecStructuredContract(t *testing.T) { t.Fatalf("read invocation log: %v", err) } args := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(args) < 3 { + t.Fatalf("expected Codex invocation arguments, got %q", data) + } if len(args) > 0 && args[0] == "review" { t.Fatalf("Codex invocation used obsolete review command: %s", data) } + if _, err := os.Stat(args[2]); !os.IsNotExist(err) { + t.Fatalf("review clone was not cleaned up: %v", err) + } + if _, err := os.Stat(hookMarker); !os.IsNotExist(err) { + t.Fatalf("Git template or injected config hook ran during review setup: %v", err) + } +} + +func TestSpawn_RejectsReviewSymlinkThatEscapesClone(t *testing.T) { + worktree := agentWorktree(t) + outside := filepath.Join(t.TempDir(), "outside.txt") + if err := os.WriteFile(outside, []byte("outside\n"), 0o600); err != nil { + t.Fatalf("write outside target: %v", err) + } + if err := os.Symlink(outside, filepath.Join(worktree, "escape.txt")); err != nil { + t.Fatalf("create escaping symlink: %v", err) + } + gitAgent(t, worktree, "add", "escape.txt") + gitAgent(t, worktree, "commit", "-q", "-m", "add escaping symlink") + scenarioPath := filepath.Join(t.TempDir(), "scenario.json") + if err := os.WriteFile(scenarioPath, []byte(`{"findings":[]}`), 0o600); err != nil { + t.Fatalf("write scenario: %v", err) + } + + _, err := agent.Spawn(context.Background(), agent.KindClaude, agent.SpawnParams{ + WorktreePath: worktree, + BinaryPath: agenttest.Build(t), + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }) + if err == nil || !strings.Contains(err.Error(), "escapes review worktree") { + t.Fatalf("expected escaping review symlink rejection, got %v", err) + } +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } diff --git a/internal/agent/reviewworktree.go b/internal/agent/reviewworktree.go new file mode 100644 index 0000000..23b6e9c --- /dev/null +++ b/internal/agent/reviewworktree.go @@ -0,0 +1,165 @@ +package agent + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/douglasjarquin/made/internal/evidence" + "github.com/douglasjarquin/made/internal/exec" +) + +const ( + reviewPreparationTimeout = 2 * time.Minute + reviewPreparationLimit = 1 << 20 +) + +func prepareReviewWorktree(ctx context.Context, source string) (string, func(), error) { + source, err := filepath.Abs(source) + if err != nil { + return "", nil, fmt.Errorf("resolve source worktree: %w", err) + } + headResult, err := runReviewGit(ctx, source, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return "", nil, fmt.Errorf("read source HEAD: %w", err) + } + if headResult.ExitCode != 0 { + return "", nil, commandFailure("read source HEAD", headResult) + } + head := strings.TrimSpace(string(headResult.Stdout)) + if head == "" { + return "", nil, fmt.Errorf("read source HEAD returned an empty SHA") + } + + tempRoot, err := os.MkdirTemp("", "made-review-worktree-") + if err != nil { + return "", nil, fmt.Errorf("create review worktree directory: %w", err) + } + reviewPath := filepath.Join(tempRoot, "repo") + cleanupTemp := func() { _ = os.RemoveAll(tempRoot) } + + cloneResult, err := runReviewGit(ctx, "", "clone", "--no-local", "--no-hardlinks", "--no-checkout", source, reviewPath) + if err != nil { + cleanupTemp() + return "", nil, fmt.Errorf("clone review worktree: %w", err) + } + if cloneResult.ExitCode != 0 { + cleanupTemp() + return "", nil, commandFailure("clone review worktree", cloneResult) + } + checkoutResult, err := runReviewGit(ctx, reviewPath, "checkout", "--detach", "--quiet", head) + if err != nil { + cleanupTemp() + return "", nil, fmt.Errorf("checkout review HEAD: %w", err) + } + if checkoutResult.ExitCode != 0 { + cleanupTemp() + return "", nil, commandFailure("checkout review HEAD", checkoutResult) + } + clonedHead, err := runReviewGit(ctx, reviewPath, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + cleanupTemp() + return "", nil, fmt.Errorf("verify review HEAD: %w", err) + } + if clonedHead.ExitCode != 0 || strings.TrimSpace(string(clonedHead.Stdout)) != head { + cleanupTemp() + return "", nil, fmt.Errorf("review clone HEAD %q does not match source HEAD %q", strings.TrimSpace(string(clonedHead.Stdout)), head) + } + if err := rejectEscapingSymlinks(reviewPath); err != nil { + cleanupTemp() + return "", nil, fmt.Errorf("validate review worktree links: %w", err) + } + restoreModes, err := makeReviewTreeReadOnly(reviewPath) + if err != nil { + cleanupTemp() + return "", nil, fmt.Errorf("make review worktree read-only: %w", err) + } + cleanup := func() { + restoreModes() + cleanupTemp() + } + return reviewPath, cleanup, nil +} + +func runReviewGit(ctx context.Context, dir string, args ...string) (*exec.Result, error) { + if dir != "" { + args = append([]string{"-C", dir}, args...) + } + return exec.Run(ctx, exec.Command{ + Name: "git", + Args: args, + Env: reviewEnvironmentForDir(nil, dir), + Timeout: reviewPreparationTimeout, + OutputLimit: reviewPreparationLimit, + }) +} + +func commandFailure(label string, result *exec.Result) error { + return fmt.Errorf("%s exited %d: stdout=%s stderr=%s", label, result.ExitCode, evidence.RedactString(string(result.Stdout)), evidence.RedactString(string(result.Stderr))) +} + +func rejectEscapingSymlinks(root string) error { + root, err := filepath.EvalSymlinks(root) + if err != nil { + return err + } + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink == 0 { + return nil + } + target, err := filepath.EvalSymlinks(path) + if err != nil { + return fmt.Errorf("resolve symlink %q: %w", path, err) + } + rel, err := filepath.Rel(root, target) + if err != nil { + return fmt.Errorf("relativize symlink %q: %w", path, err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("symlink %q escapes review worktree", path) + } + return nil + }) +} + +func makeReviewTreeReadOnly(root string) (func(), error) { + originalModes := make(map[string]os.FileMode) + restore := func() { + for path, mode := range originalModes { + _ = os.Chmod(path, mode.Perm()) + } + } + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink != 0 { + return nil + } + info, err := os.Lstat(path) + if err != nil { + return err + } + originalModes[path] = info.Mode() + mode := info.Mode().Perm() &^ 0o222 + if entry.IsDir() { + mode = 0o555 + } + if err := os.Chmod(path, mode); err != nil { + return err + } + return nil + }) + if err != nil { + restore() + return nil, err + } + return restore, nil +} diff --git a/internal/agent/spawn.go b/internal/agent/spawn.go index f8b7cfb..78c11b6 100644 --- a/internal/agent/spawn.go +++ b/internal/agent/spawn.go @@ -6,7 +6,6 @@ import ( "context" "encoding/json" "fmt" - "io/fs" "os" "path/filepath" "strings" @@ -25,11 +24,6 @@ type SpawnParams struct { const defaultSpawnTimeout = 30 * time.Minute -const ( - reviewPreparationTimeout = 2 * time.Minute - reviewPreparationLimit = 1 << 20 -) - func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) { binary := params.BinaryPath if binary == "" { @@ -73,152 +67,6 @@ func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) return findings, nil } -func prepareReviewWorktree(ctx context.Context, source string) (string, func(), error) { - source, err := filepath.Abs(source) - if err != nil { - return "", nil, fmt.Errorf("resolve source worktree: %w", err) - } - headResult, err := runReviewGit(ctx, source, "rev-parse", "--verify", "HEAD^{commit}") - if err != nil { - return "", nil, fmt.Errorf("read source HEAD: %w", err) - } - if headResult.ExitCode != 0 { - return "", nil, commandFailure("read source HEAD", headResult) - } - head := strings.TrimSpace(string(headResult.Stdout)) - if head == "" { - return "", nil, fmt.Errorf("read source HEAD returned an empty SHA") - } - - tempRoot, err := os.MkdirTemp("", "made-review-worktree-") - if err != nil { - return "", nil, fmt.Errorf("create review worktree directory: %w", err) - } - reviewPath := filepath.Join(tempRoot, "repo") - cleanupTemp := func() { _ = os.RemoveAll(tempRoot) } - - cloneResult, err := runReviewGit(ctx, "", "clone", "--no-local", "--no-hardlinks", "--no-checkout", source, reviewPath) - if err != nil { - cleanupTemp() - return "", nil, fmt.Errorf("clone review worktree: %w", err) - } - if cloneResult.ExitCode != 0 { - cleanupTemp() - return "", nil, commandFailure("clone review worktree", cloneResult) - } - checkoutResult, err := runReviewGit(ctx, reviewPath, "checkout", "--detach", "--quiet", head) - if err != nil { - cleanupTemp() - return "", nil, fmt.Errorf("checkout review HEAD: %w", err) - } - if checkoutResult.ExitCode != 0 { - cleanupTemp() - return "", nil, commandFailure("checkout review HEAD", checkoutResult) - } - clonedHead, err := runReviewGit(ctx, reviewPath, "rev-parse", "--verify", "HEAD^{commit}") - if err != nil { - cleanupTemp() - return "", nil, fmt.Errorf("verify review HEAD: %w", err) - } - if clonedHead.ExitCode != 0 || strings.TrimSpace(string(clonedHead.Stdout)) != head { - cleanupTemp() - return "", nil, fmt.Errorf("review clone HEAD %q does not match source HEAD %q", strings.TrimSpace(string(clonedHead.Stdout)), head) - } - if err := rejectEscapingSymlinks(reviewPath); err != nil { - cleanupTemp() - return "", nil, fmt.Errorf("validate review worktree links: %w", err) - } - restoreModes, err := makeReviewTreeReadOnly(reviewPath) - if err != nil { - cleanupTemp() - return "", nil, fmt.Errorf("make review worktree read-only: %w", err) - } - cleanup := func() { - restoreModes() - cleanupTemp() - } - return reviewPath, cleanup, nil -} - -func runReviewGit(ctx context.Context, dir string, args ...string) (*exec.Result, error) { - if dir != "" { - args = append([]string{"-C", dir}, args...) - } - return exec.Run(ctx, exec.Command{ - Name: "git", - Args: args, - Env: reviewEnvironmentForDir(nil, dir), - Timeout: reviewPreparationTimeout, - OutputLimit: reviewPreparationLimit, - }) -} - -func commandFailure(label string, result *exec.Result) error { - return fmt.Errorf("%s exited %d: stdout=%s stderr=%s", label, result.ExitCode, evidence.RedactString(string(result.Stdout)), evidence.RedactString(string(result.Stderr))) -} - -func rejectEscapingSymlinks(root string) error { - root, err := filepath.EvalSymlinks(root) - if err != nil { - return err - } - return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.Type()&os.ModeSymlink == 0 { - return nil - } - target, err := filepath.EvalSymlinks(path) - if err != nil { - return fmt.Errorf("resolve symlink %q: %w", path, err) - } - rel, err := filepath.Rel(root, target) - if err != nil { - return fmt.Errorf("relativize symlink %q: %w", path, err) - } - if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return fmt.Errorf("symlink %q escapes review worktree", path) - } - return nil - }) -} - -func makeReviewTreeReadOnly(root string) (func(), error) { - originalModes := make(map[string]os.FileMode) - restore := func() { - for path, mode := range originalModes { - _ = os.Chmod(path, mode.Perm()) - } - } - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.Type()&os.ModeSymlink != 0 { - return nil - } - info, err := os.Lstat(path) - if err != nil { - return err - } - originalModes[path] = info.Mode() - mode := info.Mode().Perm() &^ 0o222 - if entry.IsDir() { - mode = 0o555 - } - if err := os.Chmod(path, mode); err != nil { - return err - } - return nil - }) - if err != nil { - restore() - return nil, err - } - return restore, nil -} - func reviewEnvironmentForDir(extra []string, dir string) []string { filtered := make([]string, 0, len(os.Environ())+len(extra)) for _, entry := range os.Environ() { @@ -236,16 +84,12 @@ func reviewEnvironmentForDir(extra []string, dir string) []string { if dir != "" { filtered = append(filtered, "PWD="+dir) } + filtered = append(filtered, "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") return filtered } func reviewPathEnvironmentName(name string) bool { - switch name { - case "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_QUARANTINE_PATH", "OLDPWD": - return true - default: - return false - } + return name == "OLDPWD" || strings.HasPrefix(name, "GIT_") } func sensitiveEnvironmentName(name string) bool { From eaf51a8685011a703dfcbb01549c33fe03da1da0 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:11:04 -0400 Subject: [PATCH 31/53] docs: record review setup hardening --- docs/remediation/made-remediation-p1p3b.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 0bfa78e..1cee3be 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -44,6 +44,8 @@ The security RED regression for direct review-agent edits was a behavioral failu The final review-isolation RED regression was behavioral because the strict Codex fake received the delivery worktree and the fake reviewer left `unreviewed.txt` in that worktree even though the stage returned an error. +The review-setup injection RED regression was behavioral because an inherited `GIT_TEMPLATE_DIR` and `GIT_CONFIG_*` hook configuration executed a `post-checkout` hook during temporary clone setup. + The security RED regressions for evidence publication were behavioral failures because `PublishEvidence` staged a symlink, published an injected secret unchanged, and accepted an injected file beyond the configured retention bound. The security RED regressions for status and API errors were behavioral failures because public JSON retained an externally supplied token in paths, decisions, submission fields, and the PR URL, while the socket error response retained a token in its handler message. @@ -88,6 +90,8 @@ The exact-identity follow-up commit is `e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61 The review-isolation commit is `6f7d25458177f8b17271a28e7953ebc5c69a9fac` with subject `fix: isolate review agents from delivery worktrees`. +The review-setup injection follow-up is `a51dbec4e97a924a503389c13c1e9aef089a31e6` with subject `fix: close review setup injection boundary`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -110,6 +114,8 @@ Restored queued, running, and awaiting-review snapshots are reconciled to durabl Review agents run against a detached clone made without local hardlinks, with the exact source HEAD verified before launch, the clone and Git metadata made non-writable, escaping symlinks rejected, and delivery-path Git environment variables removed. +Review setup removes all inherited `GIT_*` injection variables, disables global and system Git configuration for clone and checkout, and tests template hooks, injected config, exact HEAD, cleanup, and escaping symlinks. + Pending gate submissions are replayed on daemon startup and remain undrained when their external boundary is unavailable. The orchestrator records stages, disabled stages as `skipped`, findings, decisions, PR URL, errors, supersession, cancellation, and submission events. @@ -152,7 +158,7 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `6f7d25458177f8b17271a28e7953ebc5c69a9fac`. +The final executable source SHA covered by this validation section is `a51dbec4e97a924a503389c13c1e9aef089a31e6`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -166,6 +172,8 @@ The exact-identity follow-up preserves valid run IDs, repository identity, refs, The review-isolation commit adds the final RED/GREEN contract for the supported Codex invocation, delivery-worktree preservation, detached exact-HEAD review input, non-writable review files, and scrubbed inherited Git path state. +The review-setup injection follow-up adds the RED/GREEN contract for Git template and config hook suppression, review-clone cleanup, exact-HEAD verification, and escaping-symlink rejection, while splitting the isolation implementation into its own module. + The validation shell exported `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=commit.gpgsign`, `GIT_CONFIG_VALUE_0=false`, `SSH_AUTH_SOCK=`, and `GOTOOLCHAIN=local` for deterministic fixture commits and toolchain selection. It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m ./...`, `go test -race -shuffle=on -count=1 -timeout=10m ./...`, `go vet ./...`, and `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...` at the exact final source SHA. @@ -178,6 +186,8 @@ The same full validation command was rerun after the exact-identity follow-up at The full validation command was rerun at the final executable SHA `6f7d25458177f8b17271a28e7953ebc5c69a9fac`, and `/tmp/made-remediation-p1p3b-6f7d254-validation.log` ends with `validation-6f7d254=PASS`. +The full validation command was rerun at the final executable SHA `a51dbec4e97a924a503389c13c1e9aef089a31e6`, and `/tmp/made-remediation-p1p3b-a51dbec-validation.log` ends with `validation-a51dbec=PASS`. + The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-e9aa0dd.log`, and its final marker was `manual-contract-e9aa0dd=PASS`. @@ -186,6 +196,10 @@ The fresh real-process manual transcript at the final executable SHA is `/tmp/ma The fresh review-isolation manual contract transcript is `/tmp/made-remediation-p1p3b-manual-review-6f7d254.log`, and its final marker was `manual-review-6f7d254=PASS`. +The fresh real-process manual transcript at the final executable SHA is `/tmp/made-remediation-p1p3b-manual-a51dbec.log`, and its final marker was `manual-qa-a51dbec=PASS`. + +The fresh review-setup isolation transcript is `/tmp/made-remediation-p1p3b-manual-review-a51dbec.log`, and its final marker was `manual-review-a51dbec=PASS`. + That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process and doctor through the real Consigliere script. The companion exact-SHA contract scenario observed restart durability, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, oversized and stalled socket rejection, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. @@ -210,7 +224,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..6f7d25458177f8b17271a28e7953ebc5c69a9fac`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..a51dbec4e97a924a503389c13c1e9aef089a31e6`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. From 5e263785b7313523fdeec648ea3475ac9d446543 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:18:44 -0400 Subject: [PATCH 32/53] fix: sanitize controlled review Git commands --- internal/pipeline/review/git.go | 56 ++++++++++++++ .../review/remediation_contract_test.go | 76 +++++++++++++++++++ internal/pipeline/review/review.go | 52 +++++-------- 3 files changed, 152 insertions(+), 32 deletions(-) create mode 100644 internal/pipeline/review/git.go diff --git a/internal/pipeline/review/git.go b/internal/pipeline/review/git.go new file mode 100644 index 0000000..ec3b3b0 --- /dev/null +++ b/internal/pipeline/review/git.go @@ -0,0 +1,56 @@ +package review + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/douglasjarquin/made/internal/exec" +) + +const ( + reviewGitTimeout = 30 * time.Second + reviewGitLimit = 1 << 20 +) + +func gitOutput(ctx context.Context, worktreePath string, args ...string) (string, error) { + result, err := runGit(ctx, worktreePath, args, nil) + if err != nil { + return "", err + } + return strings.TrimSpace(string(result.Stdout)), nil +} + +func runGit(ctx context.Context, worktreePath string, args []string, stdin []byte) (*exec.Result, error) { + args = append([]string{"-C", worktreePath}, args...) + result, err := exec.Run(ctx, exec.Command{ + Name: "git", + Args: args, + Env: controlledGitEnvironment(), + Stdin: stdin, + Timeout: reviewGitTimeout, + OutputLimit: reviewGitLimit, + }) + 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 +} + +func controlledGitEnvironment() []string { + env := make([]string, 0, len(os.Environ())+3) + for _, entry := range os.Environ() { + name, _, ok := strings.Cut(entry, "=") + if !ok || strings.HasPrefix(name, "GIT_") || name == "SSH_AUTH_SOCK" { + continue + } + env = append(env, entry) + } + env = append(env, "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_TERMINAL_PROMPT=0") + return env +} diff --git a/internal/pipeline/review/remediation_contract_test.go b/internal/pipeline/review/remediation_contract_test.go index 0e18fc6..5b7963a 100644 --- a/internal/pipeline/review/remediation_contract_test.go +++ b/internal/pipeline/review/remediation_contract_test.go @@ -34,6 +34,82 @@ func TestRun_AutoFixRequiresCleanStateBeforeApplyingReturnedPatch(t *testing.T) } } +func TestRun_AutoFixIgnoresAmbientGitRoutingAndHooks(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + patch := autoFixPatch(t, wt.Path) + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "ignore ambient Git state", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + + alternateDir := t.TempDir() + run(t, alternateDir, "init", "--bare", "-q") + hooksDir := t.TempDir() + hookMarker := filepath.Join(t.TempDir(), "hook-fired") + if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte("#!/bin/sh\nprintf fired > '"+hookMarker+"'\n"), 0o700); err != nil { + t.Fatalf("write auto-fix hook: %v", err) + } + t.Setenv("GIT_DIR", alternateDir) + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "core.hooksPath") + t.Setenv("GIT_CONFIG_VALUE_0", hooksDir) + + result, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }) + if err != nil { + t.Fatalf("review auto-fix inherited ambient Git routing/configuration: %v", err) + } + if !result.OK || len(result.AutoFixed) != 1 { + t.Fatalf("expected one controlled auto-fix, got %+v", result) + } + for _, name := range []string{"GIT_DIR", "GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"} { + _ = os.Unsetenv(name) + } + if got := run(t, wt.Path, "status", "--porcelain"); got != "" { + t.Fatalf("controlled auto-fix left worktree dirty: %q", got) + } + if _, err := os.Stat(hookMarker); !os.IsNotExist(err) { + t.Fatalf("ambient auto-fix hook ran: %v", err) + } +} + +func TestRun_AutoFixSuppressesAmbientGitHook(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + patch := autoFixPatch(t, wt.Path) + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "suppress ambient hook", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + hooksDir := t.TempDir() + hookMarker := filepath.Join(t.TempDir(), "hook-fired") + if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte("#!/bin/sh\nprintf fired > '"+hookMarker+"'\n"), 0o700); err != nil { + t.Fatalf("write auto-fix hook: %v", err) + } + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "core.hooksPath") + t.Setenv("GIT_CONFIG_VALUE_0", hooksDir) + + result, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }) + if err != nil { + t.Fatalf("review auto-fix inherited ambient Git hook configuration: %v", err) + } + if !result.OK || len(result.AutoFixed) != 1 { + t.Fatalf("expected one controlled auto-fix, got %+v", result) + } + if _, err := os.Stat(hookMarker); !os.IsNotExist(err) { + t.Fatalf("ambient auto-fix hook ran: %v", err) + } +} + func TestRun_RejectsDirectAgentWorktreeEdits(t *testing.T) { bin := agenttest.Build(t) f := setupFixture(t) diff --git a/internal/pipeline/review/review.go b/internal/pipeline/review/review.go index 6ee9374..af25dbc 100644 --- a/internal/pipeline/review/review.go +++ b/internal/pipeline/review/review.go @@ -9,7 +9,6 @@ package review import ( "context" "fmt" - "os/exec" "path/filepath" "strings" "time" @@ -38,7 +37,7 @@ type Result struct { // etc); ask-user and blocking findings are normal outcomes reported via // Result, not errors. func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Options) (Result, error) { - if err := requireCleanWorktree(worktreePath); err != nil { + if err := requireCleanWorktree(ctx, worktreePath); err != nil { return Result{}, fmt.Errorf("review: inspect worktree before agent: %w", err) } findings, err := agent.Spawn(ctx, agentKind, agent.SpawnParams{ @@ -50,7 +49,7 @@ func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Op if err != nil { return Result{}, fmt.Errorf("review: spawn %s: %w", agentKind, err) } - if err := requireCleanWorktree(worktreePath); err != nil { + if err := requireCleanWorktree(ctx, worktreePath); err != nil { return Result{}, fmt.Errorf("review: agent modified worktree: %w", err) } @@ -63,7 +62,7 @@ func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Op for _, finding := range findings.Findings { switch finding.Kind { case agent.FindingAutoFixable: - preSHA, postSHA, applyErr := applyAutoFix(worktreePath, finding) + preSHA, postSHA, applyErr := applyAutoFix(ctx, worktreePath, finding) if applyErr != nil { return Result{}, fmt.Errorf("review: apply auto-fix %q: %w", finding.Description, applyErr) } @@ -101,14 +100,14 @@ func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Op }, nil } -func applyAutoFix(worktreePath string, finding agent.Finding) (string, string, error) { +func applyAutoFix(ctx context.Context, worktreePath string, finding agent.Finding) (string, string, error) { if strings.TrimSpace(finding.Patch) == "" { return "", "", fmt.Errorf("auto-fixable finding has no patch") } - if err := requireCleanWorktree(worktreePath); err != nil { + if err := requireCleanWorktree(ctx, worktreePath); err != nil { return "", "", err } - preSHA, err := gitOutput(worktreePath, "rev-parse", "HEAD") + preSHA, err := gitOutput(ctx, worktreePath, "rev-parse", "HEAD") if err != nil { return "", "", fmt.Errorf("record pre-fix SHA: %w", err) } @@ -122,7 +121,7 @@ func applyAutoFix(worktreePath string, finding agent.Finding) (string, string, e if err != nil { return "", "", err } - if _, err := gitOutput(worktreePath, "ls-files", "--error-unmatch", "--", clean); err != nil { + if _, err := gitOutput(ctx, worktreePath, "ls-files", "--error-unmatch", "--", clean); err != nil { return "", "", fmt.Errorf("auto-fix returned untracked or unauthorized path %q", clean) } allowed[clean] = struct{}{} @@ -136,13 +135,11 @@ func applyAutoFix(worktreePath string, finding agent.Finding) (string, string, e } } - applyCmd := exec.Command("git", "-C", worktreePath, "apply", "--whitespace=fix", "-") - applyCmd.Stdin = strings.NewReader(finding.Patch) - if out, err := applyCmd.CombinedOutput(); err != nil { - return "", "", fmt.Errorf("git apply: %w: %s", err, strings.TrimSpace(string(out))) + if _, err := runGit(ctx, worktreePath, []string{"apply", "--whitespace=fix", "-"}, []byte(finding.Patch)); err != nil { + return "", "", fmt.Errorf("git apply: %w", err) } - status, err := gitOutput(worktreePath, "status", "--porcelain", "--untracked-files=all") + status, err := gitOutput(ctx, worktreePath, "status", "--porcelain", "--untracked-files=all") if err != nil { return "", "", fmt.Errorf("inspect post-fix paths: %w", err) } @@ -156,36 +153,35 @@ func applyAutoFix(worktreePath string, finding agent.Finding) (string, string, e for path := range allowed { addArgs = append(addArgs, path) } - addCmd := exec.Command("git", addArgs...) - if out, err := addCmd.CombinedOutput(); err != nil { - return "", "", fmt.Errorf("git add returned paths: %w: %s", err, strings.TrimSpace(string(out))) + if _, err := runGit(ctx, worktreePath, addArgs[2:], nil); err != nil { + return "", "", fmt.Errorf("git add returned paths: %w", err) } message := finding.Description if message == "" { message = "made review: auto-fix" } - commitCmd := exec.Command("git", "-C", worktreePath, + if _, err := runGit(ctx, worktreePath, []string{ "-c", "user.name=made-review", "-c", "user.email=made-review@local", "-c", "commit.gpgsign=false", - "commit", "-m", message) - if out, err := commitCmd.CombinedOutput(); err != nil { - return "", "", fmt.Errorf("git commit: %w: %s", err, strings.TrimSpace(string(out))) + "commit", "-m", message, + }, nil); err != nil { + return "", "", fmt.Errorf("git commit: %w", err) } - shaOut, err := gitOutput(worktreePath, "rev-parse", "HEAD") + shaOut, err := gitOutput(ctx, worktreePath, "rev-parse", "HEAD") if err != nil { return "", "", fmt.Errorf("git rev-parse HEAD: %w", err) } - if _, err := gitOutput(worktreePath, "diff", "--check", preSHA, shaOut); err != nil { + if _, err := gitOutput(ctx, worktreePath, "diff", "--check", preSHA, shaOut); err != nil { return "", "", fmt.Errorf("rerun review validation: %w", err) } return preSHA, shaOut, nil } -func requireCleanWorktree(worktreePath string) error { - status, err := gitOutput(worktreePath, "status", "--porcelain", "--untracked-files=all") +func requireCleanWorktree(ctx context.Context, worktreePath string) error { + status, err := gitOutput(ctx, worktreePath, "status", "--porcelain", "--untracked-files=all") if err != nil { return fmt.Errorf("inspect clean worktree: %w", err) } @@ -195,14 +191,6 @@ func requireCleanWorktree(worktreePath string) error { return nil } -func gitOutput(worktreePath string, args ...string) (string, error) { - output, err := exec.Command("git", append([]string{"-C", worktreePath}, args...)...).Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(output)), nil -} - func patchPaths(patch string) ([]string, error) { seen := make(map[string]struct{}) var oldPath string From ab3b2b3c1cc4192c021a118bd1283e0abf117330 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:21:07 -0400 Subject: [PATCH 33/53] docs: record controlled Git validation --- docs/remediation/made-remediation-p1p3b.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 1cee3be..c988f00 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -92,6 +92,8 @@ The review-isolation commit is `6f7d25458177f8b17271a28e7953ebc5c69a9fac` with s The review-setup injection follow-up is `a51dbec4e97a924a503389c13c1e9aef089a31e6` with subject `fix: close review setup injection boundary`. +The controlled auto-fix Git follow-up is `5e263785b7313523fdeec648ea3475ac9d446543` with subject `fix: sanitize controlled review Git commands`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -116,6 +118,8 @@ Review agents run against a detached clone made without local hardlinks, with th Review setup removes all inherited `GIT_*` injection variables, disables global and system Git configuration for clone and checkout, and tests template hooks, injected config, exact HEAD, cleanup, and escaping symlinks. +Controlled auto-fix Git commands use the same bounded execution contract with all ambient `GIT_*` routing and hook configuration removed before status, apply, add, commit, and validation operations. + Pending gate submissions are replayed on daemon startup and remain undrained when their external boundary is unavailable. The orchestrator records stages, disabled stages as `skipped`, findings, decisions, PR URL, errors, supersession, cancellation, and submission events. @@ -158,7 +162,7 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `a51dbec4e97a924a503389c13c1e9aef089a31e6`. +The final executable source SHA covered by this validation section is `5e263785b7313523fdeec648ea3475ac9d446543`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -174,6 +178,8 @@ The review-isolation commit adds the final RED/GREEN contract for the supported The review-setup injection follow-up adds the RED/GREEN contract for Git template and config hook suppression, review-clone cleanup, exact-HEAD verification, and escaping-symlink rejection, while splitting the isolation implementation into its own module. +The controlled auto-fix Git follow-up adds the RED/GREEN contract for ambient `GIT_DIR` routing and pre-commit hook suppression, and bounds all auto-fix Git subprocesses. + The validation shell exported `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=commit.gpgsign`, `GIT_CONFIG_VALUE_0=false`, `SSH_AUTH_SOCK=`, and `GOTOOLCHAIN=local` for deterministic fixture commits and toolchain selection. It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m ./...`, `go test -race -shuffle=on -count=1 -timeout=10m ./...`, `go vet ./...`, and `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...` at the exact final source SHA. @@ -188,6 +194,8 @@ The full validation command was rerun at the final executable SHA `6f7d25458177f The full validation command was rerun at the final executable SHA `a51dbec4e97a924a503389c13c1e9aef089a31e6`, and `/tmp/made-remediation-p1p3b-a51dbec-validation.log` ends with `validation-a51dbec=PASS`. +The full validation command was rerun at the final executable SHA `5e263785b7313523fdeec648ea3475ac9d446543`, and `/tmp/made-remediation-p1p3b-5e26378-validation.log` ends with `validation-5e26378=PASS`. + The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-e9aa0dd.log`, and its final marker was `manual-contract-e9aa0dd=PASS`. @@ -200,6 +208,10 @@ The fresh real-process manual transcript at the final executable SHA is `/tmp/ma The fresh review-setup isolation transcript is `/tmp/made-remediation-p1p3b-manual-review-a51dbec.log`, and its final marker was `manual-review-a51dbec=PASS`. +The fresh real-process manual transcript at the final executable SHA is `/tmp/made-remediation-p1p3b-manual-5e26378.log`, and its final marker was `manual-qa-5e26378=PASS`. + +The fresh controlled auto-fix transcript is `/tmp/made-remediation-p1p3b-manual-review-5e26378.log`, and its final marker was `manual-review-5e26378=PASS`. + That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process and doctor through the real Consigliere script. The companion exact-SHA contract scenario observed restart durability, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, oversized and stalled socket rejection, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. @@ -224,7 +236,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..a51dbec4e97a924a503389c13c1e9aef089a31e6`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..5e263785b7313523fdeec648ea3475ac9d446543`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. From a81179eae674b45e4086e8d758e8aac36bd4f92c Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:26:36 -0400 Subject: [PATCH 34/53] fix: disable repository hooks for auto-fixes --- .../review/remediation_contract_test.go | 31 +++++++++++++++++++ internal/pipeline/review/review.go | 1 + 2 files changed, 32 insertions(+) diff --git a/internal/pipeline/review/remediation_contract_test.go b/internal/pipeline/review/remediation_contract_test.go index 5b7963a..3e7ddf6 100644 --- a/internal/pipeline/review/remediation_contract_test.go +++ b/internal/pipeline/review/remediation_contract_test.go @@ -110,6 +110,37 @@ func TestRun_AutoFixSuppressesAmbientGitHook(t *testing.T) { } } +func TestRun_AutoFixSuppressesRepositoryLocalHook(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + patch := autoFixPatch(t, wt.Path) + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "suppress repository hook", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + hooksDir := t.TempDir() + hookMarker := filepath.Join(t.TempDir(), "hook-fired") + if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte("#!/bin/sh\nprintf fired > '"+hookMarker+"'\n"), 0o700); err != nil { + t.Fatalf("write repository hook: %v", err) + } + run(t, wt.Path, "config", "core.hooksPath", hooksDir) + + result, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }) + if err != nil { + t.Fatalf("review auto-fix inherited repository-local hook configuration: %v", err) + } + if !result.OK || len(result.AutoFixed) != 1 { + t.Fatalf("expected one controlled auto-fix, got %+v", result) + } + if _, err := os.Stat(hookMarker); !os.IsNotExist(err) { + t.Fatalf("repository-local auto-fix hook ran: %v", err) + } +} + func TestRun_RejectsDirectAgentWorktreeEdits(t *testing.T) { bin := agenttest.Build(t) f := setupFixture(t) diff --git a/internal/pipeline/review/review.go b/internal/pipeline/review/review.go index af25dbc..c6c4a14 100644 --- a/internal/pipeline/review/review.go +++ b/internal/pipeline/review/review.go @@ -165,6 +165,7 @@ func applyAutoFix(ctx context.Context, worktreePath string, finding agent.Findin "-c", "user.name=made-review", "-c", "user.email=made-review@local", "-c", "commit.gpgsign=false", + "-c", "core.hooksPath=/dev/null", "commit", "-m", message, }, nil); err != nil { return "", "", fmt.Errorf("git commit: %w", err) From 651139d72a7adcaf8c696e51d7949d2d589e13cf Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:29:10 -0400 Subject: [PATCH 35/53] docs: record repository hook validation --- docs/remediation/made-remediation-p1p3b.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index c988f00..da0de96 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -94,6 +94,8 @@ The review-setup injection follow-up is `a51dbec4e97a924a503389c13c1e9aef089a31e The controlled auto-fix Git follow-up is `5e263785b7313523fdeec648ea3475ac9d446543` with subject `fix: sanitize controlled review Git commands`. +The repository-hook follow-up is `a81179eae674b45e4086e8d758e8aac36bd4f92c` with subject `fix: disable repository hooks for auto-fixes`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -120,6 +122,8 @@ Review setup removes all inherited `GIT_*` injection variables, disables global Controlled auto-fix Git commands use the same bounded execution contract with all ambient `GIT_*` routing and hook configuration removed before status, apply, add, commit, and validation operations. +Auto-fix commits explicitly set `core.hooksPath=/dev/null`, so repository-local hooks cannot execute during controlled mutations. + Pending gate submissions are replayed on daemon startup and remain undrained when their external boundary is unavailable. The orchestrator records stages, disabled stages as `skipped`, findings, decisions, PR URL, errors, supersession, cancellation, and submission events. @@ -162,7 +166,7 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `5e263785b7313523fdeec648ea3475ac9d446543`. +The final executable source SHA covered by this validation section is `a81179eae674b45e4086e8d758e8aac36bd4f92c`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -180,6 +184,8 @@ The review-setup injection follow-up adds the RED/GREEN contract for Git templat The controlled auto-fix Git follow-up adds the RED/GREEN contract for ambient `GIT_DIR` routing and pre-commit hook suppression, and bounds all auto-fix Git subprocesses. +The repository-hook follow-up adds the RED/GREEN contract for local `core.hooksPath` suppression during auto-fix commits. + The validation shell exported `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=commit.gpgsign`, `GIT_CONFIG_VALUE_0=false`, `SSH_AUTH_SOCK=`, and `GOTOOLCHAIN=local` for deterministic fixture commits and toolchain selection. It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m ./...`, `go test -race -shuffle=on -count=1 -timeout=10m ./...`, `go vet ./...`, and `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...` at the exact final source SHA. @@ -196,6 +202,8 @@ The full validation command was rerun at the final executable SHA `a51dbec4e97a9 The full validation command was rerun at the final executable SHA `5e263785b7313523fdeec648ea3475ac9d446543`, and `/tmp/made-remediation-p1p3b-5e26378-validation.log` ends with `validation-5e26378=PASS`. +The full validation command was rerun at the final executable SHA `a81179eae674b45e4086e8d758e8aac36bd4f92c`, and `/tmp/made-remediation-p1p3b-a81179e-validation.log` ends with `validation-a81179e=PASS`. + The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-e9aa0dd.log`, and its final marker was `manual-contract-e9aa0dd=PASS`. @@ -212,6 +220,10 @@ The fresh real-process manual transcript at the final executable SHA is `/tmp/ma The fresh controlled auto-fix transcript is `/tmp/made-remediation-p1p3b-manual-review-5e26378.log`, and its final marker was `manual-review-5e26378=PASS`. +The fresh real-process manual transcript at the final executable SHA is `/tmp/made-remediation-p1p3b-manual-a81179e.log`, and its final marker was `manual-qa-a81179e=PASS`. + +The fresh controlled auto-fix transcript is `/tmp/made-remediation-p1p3b-manual-review-a81179e.log`, and its final marker was `manual-review-a81179e=PASS`. + That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process and doctor through the real Consigliere script. The companion exact-SHA contract scenario observed restart durability, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, oversized and stalled socket rejection, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. @@ -236,7 +248,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..5e263785b7313523fdeec648ea3475ac9d446543`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..a81179eae674b45e4086e8d758e8aac36bd4f92c`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. From 738cc55b2d4b4dbdaadc05eb351f612be0eafcd5 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:38:08 -0400 Subject: [PATCH 36/53] fix: neutralize repository clean filters --- internal/pipeline/review/git.go | 72 ++++++++++++++++++- .../review/remediation_contract_test.go | 38 ++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/internal/pipeline/review/git.go b/internal/pipeline/review/git.go index ec3b3b0..e9adbca 100644 --- a/internal/pipeline/review/git.go +++ b/internal/pipeline/review/git.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "sort" "strings" "time" @@ -24,10 +25,21 @@ func gitOutput(ctx context.Context, worktreePath string, args ...string) (string } func runGit(ctx context.Context, worktreePath string, args []string, stdin []byte) (*exec.Result, error) { - args = append([]string{"-C", worktreePath}, args...) + filterArgs, err := repositoryFilterOverrides(ctx, worktreePath) + if err != nil { + return nil, err + } + commandArgs := []string{ + "-C", worktreePath, + "-c", "core.hooksPath=/dev/null", + "-c", "core.fsmonitor=false", + "-c", "diff.external=", + } + commandArgs = append(commandArgs, filterArgs...) + commandArgs = append(commandArgs, args...) result, err := exec.Run(ctx, exec.Command{ Name: "git", - Args: args, + Args: commandArgs, Env: controlledGitEnvironment(), Stdin: stdin, Timeout: reviewGitTimeout, @@ -42,6 +54,62 @@ func runGit(ctx context.Context, worktreePath string, args []string, stdin []byt return result, nil } +func repositoryFilterOverrides(ctx context.Context, worktreePath string) ([]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: controlledGitEnvironment(), + Timeout: reviewGitTimeout, + OutputLimit: reviewGitLimit, + }) + if err != nil { + return nil, err + } + if result.ExitCode == 1 { + return nil, nil + } + if result.ExitCode != 0 { + return nil, fmt.Errorf("inspect repository Git filters: git exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) + } + if strings.Contains(string(result.Stdout), "[output truncated]") { + return nil, fmt.Errorf("inspect repository Git filters: output exceeded %d bytes", reviewGitLimit) + } + drivers := make(map[string]struct{}) + for _, key := range strings.Fields(string(result.Stdout)) { + prefix := strings.TrimPrefix(key, "filter.") + dot := strings.LastIndexByte(prefix, '.') + if dot <= 0 { + return nil, fmt.Errorf("inspect repository Git filters: invalid key %q", key) + } + switch prefix[dot+1:] { + case "clean", "process", "smudge": + drivers[prefix[:dot]] = struct{}{} + default: + return nil, fmt.Errorf("inspect repository Git filters: invalid 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 { + prefix := "filter." + name + "." + overrides = append(overrides, + "-c", prefix+"clean=/bin/cat", + "-c", prefix+"smudge=/bin/cat", + "-c", prefix+"process=", + "-c", prefix+"required=false", + ) + } + return overrides, nil +} + func controlledGitEnvironment() []string { env := make([]string, 0, len(os.Environ())+3) for _, entry := range os.Environ() { diff --git a/internal/pipeline/review/remediation_contract_test.go b/internal/pipeline/review/remediation_contract_test.go index 3e7ddf6..d7d2c0d 100644 --- a/internal/pipeline/review/remediation_contract_test.go +++ b/internal/pipeline/review/remediation_contract_test.go @@ -141,6 +141,44 @@ func TestRun_AutoFixSuppressesRepositoryLocalHook(t *testing.T) { } } +func TestRun_AutoFixSuppressesRepositoryLocalCleanFilter(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + writeFile(t, wt.Path, ".gitattributes", "reviewed.txt filter=malicious\n") + run(t, wt.Path, "add", ".gitattributes") + run(t, wt.Path, "commit", "-q", "-m", "add filter attributes") + filterDir := t.TempDir() + filterPath := filepath.Join(filterDir, "clean-filter") + filterMarker := filepath.Join(t.TempDir(), "filter-fired") + filter := "#!/bin/sh\nprintf fired > '" + filterMarker + "'\ncat\n" + if err := os.WriteFile(filterPath, []byte(filter), 0o700); err != nil { + t.Fatalf("write clean filter: %v", err) + } + run(t, wt.Path, "config", "filter.malicious.clean", filterPath) + run(t, wt.Path, "config", "filter.malicious.smudge", "cat") + run(t, wt.Path, "config", "filter.malicious.required", "true") + patch := autoFixPatch(t, wt.Path) + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "suppress repository filter", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + + result, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }) + if err != nil { + t.Fatalf("review auto-fix inherited repository-local clean filter: %v", err) + } + if !result.OK || len(result.AutoFixed) != 1 { + t.Fatalf("expected one controlled auto-fix, got %+v", result) + } + if _, err := os.Stat(filterMarker); !os.IsNotExist(err) { + t.Fatalf("repository-local clean filter ran: %v", err) + } +} + func TestRun_RejectsDirectAgentWorktreeEdits(t *testing.T) { bin := agenttest.Build(t) f := setupFixture(t) From 11961309feffed7a39a4962136711193b25ea833 Mon Sep 17 00:00:00 2001 From: Douglas Jarquin <8209+douglasjarquin@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:41:02 -0400 Subject: [PATCH 37/53] docs: record clean filter validation --- docs/remediation/made-remediation-p1p3b.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index da0de96..fd143ea 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -96,6 +96,8 @@ The controlled auto-fix Git follow-up is `5e263785b7313523fdeec648ea3475ac9d4465 The repository-hook follow-up is `a81179eae674b45e4086e8d758e8aac36bd4f92c` with subject `fix: disable repository hooks for auto-fixes`. +The clean-filter follow-up is `738cc55b2d4b4dbdaadc05eb351f612be0eafcd5` with subject `fix: neutralize repository clean filters`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -124,6 +126,8 @@ Controlled auto-fix Git commands use the same bounded execution contract with al Auto-fix commits explicitly set `core.hooksPath=/dev/null`, so repository-local hooks cannot execute during controlled mutations. +Controlled Git also neutralizes repository-local clean, smudge, and process filters before any auto-fix status, apply, add, commit, or validation command. + Pending gate submissions are replayed on daemon startup and remain undrained when their external boundary is unavailable. The orchestrator records stages, disabled stages as `skipped`, findings, decisions, PR URL, errors, supersession, cancellation, and submission events. @@ -166,7 +170,7 @@ The Made CI workflow validates the pinned Go version with race, vet, and pinned ## Validation evidence -The final executable source SHA covered by this validation section is `a81179eae674b45e4086e8d758e8aac36bd4f92c`. +The final executable source SHA covered by this validation section is `738cc55b2d4b4dbdaadc05eb351f612be0eafcd5`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -186,6 +190,8 @@ The controlled auto-fix Git follow-up adds the RED/GREEN contract for ambient `G The repository-hook follow-up adds the RED/GREEN contract for local `core.hooksPath` suppression during auto-fix commits. +The clean-filter follow-up adds the RED/GREEN contract for repository-local executable `filter.*.clean` suppression during staging. + The validation shell exported `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=commit.gpgsign`, `GIT_CONFIG_VALUE_0=false`, `SSH_AUTH_SOCK=`, and `GOTOOLCHAIN=local` for deterministic fixture commits and toolchain selection. It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m ./...`, `go test -race -shuffle=on -count=1 -timeout=10m ./...`, `go vet ./...`, and `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...` at the exact final source SHA. @@ -204,6 +210,8 @@ The full validation command was rerun at the final executable SHA `5e263785b7313 The full validation command was rerun at the final executable SHA `a81179eae674b45e4086e8d758e8aac36bd4f92c`, and `/tmp/made-remediation-p1p3b-a81179e-validation.log` ends with `validation-a81179e=PASS`. +The full validation command was rerun at the final executable SHA `738cc55b2d4b4dbdaadc05eb351f612be0eafcd5`, and `/tmp/made-remediation-p1p3b-738cc55-validation.log` ends with `validation-738cc55=PASS`. + The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-e9aa0dd.log`, and its final marker was `manual-contract-e9aa0dd=PASS`. @@ -224,6 +232,10 @@ The fresh real-process manual transcript at the final executable SHA is `/tmp/ma The fresh controlled auto-fix transcript is `/tmp/made-remediation-p1p3b-manual-review-a81179e.log`, and its final marker was `manual-review-a81179e=PASS`. +The fresh real-process manual transcript at the final executable SHA is `/tmp/made-remediation-p1p3b-manual-738cc55.log`, and its final marker was `manual-qa-738cc55=PASS`. + +The fresh controlled auto-fix transcript is `/tmp/made-remediation-p1p3b-manual-review-738cc55.log`, and its final marker was `manual-review-738cc55=PASS`. + That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process and doctor through the real Consigliere script. The companion exact-SHA contract scenario observed restart durability, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, oversized and stalled socket rejection, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. @@ -248,7 +260,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..a81179eae674b45e4086e8d758e8aac36bd4f92c`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..738cc55b2d4b4dbdaadc05eb351f612be0eafcd5`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. From 0c3af42fd75d6f02412cde82b33c8341305243e3 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 16 Aug 2026 23:17:47 -0400 Subject: [PATCH 38/53] fix: harden portable rebase validation --- internal/api/scenario_demo_test.go | 13 ++++++------- internal/pipeline/rebase/rebase.go | 10 ++++++++-- internal/pipeline/rebase/rebase_test.go | 3 +++ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/api/scenario_demo_test.go b/internal/api/scenario_demo_test.go index dccd87f..0d82c07 100644 --- a/internal/api/scenario_demo_test.go +++ b/internal/api/scenario_demo_test.go @@ -4,9 +4,8 @@ import ( "encoding/json" "fmt" "net" - "os/exec" + "os" "path/filepath" - "strings" "testing" "github.com/douglasjarquin/made/internal/api" @@ -22,14 +21,14 @@ func TestScenarioDemo_SocketPermissions(t *testing.T) { } t.Cleanup(func() { _ = srv.Close() }) - out, err := exec.Command("stat", "-f", "%Sp %Su", socketPath).CombinedOutput() + info, err := os.Stat(socketPath) if err != nil { - t.Fatalf("stat: %v: %s", err, out) + t.Fatalf("stat: %v", err) } - fmt.Printf("$ stat -f \"%%Sp %%Su\" %s\n%s", socketPath, out) + fmt.Printf("$ inspect Unix socket %s\nmode=%#o type=%s\n", socketPath, info.Mode().Perm(), info.Mode().Type()) - if !strings.HasPrefix(strings.TrimSpace(string(out)), "srw-------") { - t.Fatalf("expected owner-only socket permissions srw-------, got: %s", out) + if info.Mode()&os.ModeSocket == 0 || info.Mode().Perm() != 0o600 { + t.Fatalf("expected owner-only Unix socket mode 0600, got mode=%#o type=%s", info.Mode().Perm(), info.Mode().Type()) } fmt.Println("=== RESULT ===") fmt.Println("PASS: socket created with mode 0600 (srw-------), owner-only") diff --git a/internal/pipeline/rebase/rebase.go b/internal/pipeline/rebase/rebase.go index 571d720..e54fbe4 100644 --- a/internal/pipeline/rebase/rebase.go +++ b/internal/pipeline/rebase/rebase.go @@ -29,7 +29,13 @@ func Run(worktreePath, defaultBranch string) (Result, error) { } func RunContext(ctx context.Context, worktreePath, defaultBranch string) (Result, error) { - cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rebase", defaultBranch) + cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, + "-c", "user.name=made-rebase", + "-c", "user.email=made-rebase@local", + "-c", "commit.gpgsign=false", + "-c", "core.hooksPath=/dev/null", + "rebase", defaultBranch, + ) out, rebaseErr := cmd.CombinedOutput() if rebaseErr == nil { return Result{ @@ -50,7 +56,7 @@ func RunContext(ctx context.Context, worktreePath, defaultBranch string) (Result if err := abortRebase(ctx, worktreePath); err != nil { return Result{}, fmt.Errorf("rebase: failed without unmerged paths and abort failed: %w", err) } - return Result{}, fmt.Errorf("rebase: git rebase %s failed without unmerged paths", defaultBranch) + return Result{}, fmt.Errorf("rebase: git rebase %s failed without unmerged paths: %s", defaultBranch, strings.TrimSpace(string(out))) } // A halted stage must never leave the worktree mid-rebase, so whatever diff --git a/internal/pipeline/rebase/rebase_test.go b/internal/pipeline/rebase/rebase_test.go index 183b2e4..27612f2 100644 --- a/internal/pipeline/rebase/rebase_test.go +++ b/internal/pipeline/rebase/rebase_test.go @@ -10,6 +10,9 @@ import ( ) func TestRun_CleanRebaseProceeds(t *testing.T) { + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + t.Setenv("GIT_CONFIG_SYSTEM", "/dev/null") + t.Setenv("SSH_AUTH_SOCK", "") f := setupFixture(t, "", "", "") wt := f.addWorktree(t) defer func() { From c3b002e1faa4fccb20fc4f9f63600a425b5c5e52 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 16 Aug 2026 23:36:23 -0400 Subject: [PATCH 39/53] fix: harden evidence and API boundaries --- cmd/made/daemon.go | 6 +- cmd/made/remediation_contract_test.go | 7 ++ cmd/made/review.go | 2 +- cmd/made/runhandlers.go | 6 +- cmd/made/status.go | 2 +- cmd/made/strictjson.go | 31 ++++++ docs/remediation/made-remediation-p1p3b.md | 14 ++- internal/api/envelope.go | 1 + internal/api/remediation_contract_test.go | 31 ++++++ internal/api/server.go | 24 ++++- internal/evidence/git.go | 100 ++++++++++++++++++ internal/evidence/inrepo.go | 22 +++- internal/evidence/orphan.go | 10 +- .../evidence/remediation_contract_test.go | 25 +++++ internal/evidence/timeout_test.go | 18 +++- 15 files changed, 272 insertions(+), 27 deletions(-) create mode 100644 cmd/made/strictjson.go create mode 100644 internal/evidence/git.go diff --git a/cmd/made/daemon.go b/cmd/made/daemon.go index 1cb7fe1..0ac98a8 100644 --- a/cmd/made/daemon.go +++ b/cmd/made/daemon.go @@ -293,7 +293,7 @@ type gateAdmitPushResult struct { func gateAdmitPushHandler() api.HandlerFunc { return func(_ context.Context, params json.RawMessage) (any, error) { var p gateAdmitPushParams - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("gate.admitPush: invalid params: %w", err) } if p.GatePath == "" { @@ -360,7 +360,7 @@ type gateNotifyPushResult struct { func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool, admission ...*sync.Mutex) api.HandlerFunc { return func(ctx context.Context, params json.RawMessage) (any, error) { var p gateNotifyPushParams - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("gate.notifyPush: invalid params: %w", err) } if p.GatePath == "" || p.Ref == "" || p.NewSHA == "" { @@ -556,7 +556,7 @@ type debugSubmitCancellableRunParams struct { func debugSubmitCancellableRunHandler(rm *daemon.RunManager) api.HandlerFunc { return func(_ context.Context, params json.RawMessage) (any, error) { var p debugSubmitCancellableRunParams - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("decode params: %w", err) } work := func(workCtx context.Context, _ func(daemon.Event)) error { diff --git a/cmd/made/remediation_contract_test.go b/cmd/made/remediation_contract_test.go index ef5f022..9b220c5 100644 --- a/cmd/made/remediation_contract_test.go +++ b/cmd/made/remediation_contract_test.go @@ -274,6 +274,13 @@ func TestRun_ListJSONExposesBatchActiveRunQuery(t *testing.T) { } } +func TestRunListHandlerRejectsUnknownParams(t *testing.T) { + _, err := runListHandler(daemon.NewRunManager())(context.Background(), []byte(`{"active":true,"unexpected":true}`)) + if err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("run.list accepted unknown parameter field: %v", err) + } +} + func captureRun(t *testing.T, args ...string) (int, string, string) { t.Helper() outFile, err := os.CreateTemp(t.TempDir(), "stdout-") diff --git a/cmd/made/review.go b/cmd/made/review.go index f6bfb71..efdd994 100644 --- a/cmd/made/review.go +++ b/cmd/made/review.go @@ -33,7 +33,7 @@ type reviewDecisionReport struct { func reviewDecideRunHandler(rm *daemon.RunManager, store *daemon.ReviewDecisions) api.HandlerFunc { return func(_ context.Context, params json.RawMessage) (any, error) { var p reviewDecideParams - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("review.decide: invalid params: %w", err) } if p.RunID == "" || p.Stage == "" { diff --git a/cmd/made/runhandlers.go b/cmd/made/runhandlers.go index 3ce147d..ee7e68c 100644 --- a/cmd/made/runhandlers.go +++ b/cmd/made/runhandlers.go @@ -31,7 +31,7 @@ func runStatusHandler(rm *daemon.RunManager) api.HandlerFunc { func runSubmitHandler(rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool, admission ...*sync.Mutex) api.HandlerFunc { return func(ctx context.Context, params json.RawMessage) (any, error) { var p runSubmitParams - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("run.submit: invalid params: %w", err) } if strings.TrimSpace(p.GatePath) == "" || strings.TrimSpace(p.Ref) == "" { @@ -93,7 +93,7 @@ func runListHandler(rm *daemon.RunManager) api.HandlerFunc { return func(_ context.Context, params json.RawMessage) (any, error) { var p runListParams if len(params) > 0 { - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("run.list: invalid params: %w", err) } } @@ -112,7 +112,7 @@ func runListHandler(rm *daemon.RunManager) api.HandlerFunc { func runCancelHandler(rm *daemon.RunManager) api.HandlerFunc { return func(ctx context.Context, params json.RawMessage) (any, error) { var p runCancelParams - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("run.cancel: invalid params: %w", err) } if p.RunID == "" { diff --git a/cmd/made/status.go b/cmd/made/status.go index 70ba3b8..15ac67a 100644 --- a/cmd/made/status.go +++ b/cmd/made/status.go @@ -68,7 +68,7 @@ func statusHandler(rm *daemon.RunManager) api.HandlerFunc { return func(ctx context.Context, params json.RawMessage) (any, error) { var p statusParams if len(params) > 0 { - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("status: invalid params: %w", err) } } diff --git a/cmd/made/strictjson.go b/cmd/made/strictjson.go new file mode 100644 index 0000000..f60a7b0 --- /dev/null +++ b/cmd/made/strictjson.go @@ -0,0 +1,31 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" +) + +func decodeStrictParams(params json.RawMessage, out any) error { + trimmed := bytes.TrimSpace(params) + if len(trimmed) == 0 { + return nil + } + if trimmed[0] != '{' { + return fmt.Errorf("params must be a JSON object") + } + decoder := json.NewDecoder(bytes.NewReader(trimmed)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(out); err != nil { + return err + } + var extra json.RawMessage + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("params contain multiple JSON values") + } + return err + } + return nil +} diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index fd143ea..1c48d59 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -98,6 +98,8 @@ The repository-hook follow-up is `a81179eae674b45e4086e8d758e8aac36bd4f92c` with The clean-filter follow-up is `738cc55b2d4b4dbdaadc05eb351f612be0eafcd5` with subject `fix: neutralize repository clean filters`. +The portability follow-up is `0c3af42fd75d6f02412cde82b33c8341305243e3` with subject `fix: harden portable rebase validation`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -168,9 +170,11 @@ Pull-request GitHub authentication and API failures now remain infrastructure er The Made CI workflow validates the pinned Go version with race, vet, and pinned lint jobs. +The CI portability fix uses Go's platform-independent Unix-socket mode inspection instead of Darwin-only `stat -f` flags, and clean rebase execution supplies deterministic committer identity, disabled signing, and disabled hooks at the Git boundary. + ## Validation evidence -The final executable source SHA covered by this validation section is `738cc55b2d4b4dbdaadc05eb351f612be0eafcd5`. +The final executable source SHA covered by this validation section is `0c3af42fd75d6f02412cde82b33c8341305243e3`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -212,6 +216,8 @@ The full validation command was rerun at the final executable SHA `a81179eae674b The full validation command was rerun at the final executable SHA `738cc55b2d4b4dbdaadc05eb351f612be0eafcd5`, and `/tmp/made-remediation-p1p3b-738cc55-validation.log` ends with `validation-738cc55=PASS`. +The full validation command was rerun at the final executable SHA `0c3af42fd75d6f02412cde82b33c8341305243e3`, and `/tmp/made-remediation-p1p3b-0c3af42-validation-clean.log` ends with the pinned lint result `0 issues.` and exit `0`. + The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-e9aa0dd.log`, and its final marker was `manual-contract-e9aa0dd=PASS`. @@ -236,6 +242,10 @@ The fresh real-process manual transcript at the final executable SHA is `/tmp/ma The fresh controlled auto-fix transcript is `/tmp/made-remediation-p1p3b-manual-review-738cc55.log`, and its final marker was `manual-review-738cc55=PASS`. +The fresh exact-tip real-process transcript is `/tmp/made-remediation-p1p3b-manual-0c3af42.log`, and its final marker is `manual-qa-0c3af42=PASS`. + +That exact-tip scenario observed process-level cancellation, WAL restart persistence, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, and the hermetic real Made binary against the real Consigliere script with strict fake GitHub and unavailable Herdr boundaries. + That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process and doctor through the real Consigliere script. The companion exact-SHA contract scenario observed restart durability, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, oversized and stalled socket rejection, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. @@ -260,7 +270,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..738cc55b2d4b4dbdaadc05eb351f612be0eafcd5`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..0c3af42fd75d6f02412cde82b33c8341305243e3`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. diff --git a/internal/api/envelope.go b/internal/api/envelope.go index 0e7ab46..aaa1f0f 100644 --- a/internal/api/envelope.go +++ b/internal/api/envelope.go @@ -10,6 +10,7 @@ const Version = 1 const ( ErrProtocolMismatch = "protocol_mismatch" + ErrInvalidRequest = "invalid_request" ErrUnknownMethod = "unknown_method" ErrHandlerFailed = "handler_error" ErrInternal = "internal_error" diff --git a/internal/api/remediation_contract_test.go b/internal/api/remediation_contract_test.go index 0ada547..21d90ce 100644 --- a/internal/api/remediation_contract_test.go +++ b/internal/api/remediation_contract_test.go @@ -121,6 +121,37 @@ func TestServer_RedactsHandlerErrors(t *testing.T) { } } +func TestServerRejectsUnknownEnvelopeFields(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + server := api.NewServer(path) + if err := server.Listen(); err != nil { + t.Fatalf("Listen: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + defer func() { _ = server.Close() }() + go func() { _ = server.Serve(ctx) }() + + conn, err := net.DialTimeout("unix", path, time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + if _, err := conn.Write([]byte(`{"made.protocol":1,"id":"strict-1","method":"ping","unexpected":true}`)); err != nil { + t.Fatalf("write request: %v", err) + } + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + var response api.Response + if err := json.NewDecoder(conn).Decode(&response); err != nil { + t.Fatalf("decode strict rejection response: %v", err) + } + if response.Error == nil || response.Error.Code != api.ErrInvalidRequest { + t.Fatalf("unknown envelope field response = %+v, want %q", response, api.ErrInvalidRequest) + } +} + func TestServerRejectsOversizedRequestLine(t *testing.T) { path := filepath.Join(tempSocketDir(t), "daemon.sock") server := api.NewServer(path) diff --git a/internal/api/server.go b/internal/api/server.go index 76c0280..f595df6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2,6 +2,7 @@ package api import ( "bufio" + "bytes" "context" "encoding/json" "errors" @@ -210,9 +211,16 @@ func (s *Server) serveConn(ctx context.Context, conn net.Conn) { if len(value) == 0 { continue } - var req Request - if err := json.Unmarshal(value, &req); err != nil { - return + req, err := decodeRequest(value) + if err != nil { + var envelope struct { + ID string `json:"id"` + } + _ = json.Unmarshal(value, &envelope) + if err := enc.Encode(errorResponse(envelope.ID, ErrInvalidRequest, "invalid request")); err != nil { + return + } + continue } if err := enc.Encode(s.dispatch(ctx, req)); err != nil { return @@ -220,6 +228,16 @@ func (s *Server) serveConn(ctx context.Context, conn net.Conn) { } } +func decodeRequest(value []byte) (Request, error) { + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.DisallowUnknownFields() + var req Request + if err := decoder.Decode(&req); err != nil { + return Request{}, err + } + return req, nil +} + func readRequestValue(reader *bufio.Reader, maxBytes int) ([]byte, error) { value := make([]byte, 0, 4096) started := false diff --git a/internal/evidence/git.go b/internal/evidence/git.go new file mode 100644 index 0000000..9469e6c --- /dev/null +++ b/internal/evidence/git.go @@ -0,0 +1,100 @@ +package evidence + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + execpkg "github.com/douglasjarquin/made/internal/exec" +) + +func evidenceGitArgs(ctx context.Context, repoPath string, args ...string) ([]string, error) { + filterArgs, err := evidenceFilterOverrides(ctx, repoPath) + if err != nil { + return nil, err + } + commandArgs := []string{ + "-C", repoPath, + "-c", "core.hooksPath=/dev/null", + "-c", "core.fsmonitor=false", + "-c", "diff.external=", + } + commandArgs = append(commandArgs, filterArgs...) + return append(commandArgs, args...), nil +} + +func evidenceFilterOverrides(ctx context.Context, repoPath string) ([]string, error) { + result, err := execpkg.Run(ctx, execpkg.Command{ + Name: "git", + Args: []string{ + "-C", repoPath, + "config", "--local", "--name-only", "--get-regexp", + "^filter\\..+\\.(clean|process|smudge)$", + }, + Env: controlledEvidenceGitEnvironment(), + Timeout: evidenceGitTimeout, + OutputLimit: evidenceGitOutputCap, + }) + if err != nil { + return nil, err + } + if result.ExitCode == 1 { + return nil, nil + } + if result.ExitCode != 0 { + return nil, fmt.Errorf("inspect repository Git filters: git exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) + } + if strings.Contains(string(result.Stdout), "[output truncated]") { + return nil, fmt.Errorf("inspect repository Git filters: output exceeded %d bytes", evidenceGitOutputCap) + } + drivers := make(map[string]struct{}) + for _, key := range strings.Fields(string(result.Stdout)) { + prefix := strings.TrimPrefix(key, "filter.") + dot := strings.LastIndexByte(prefix, '.') + if dot <= 0 { + return nil, fmt.Errorf("inspect repository Git filters: invalid key %q", key) + } + switch prefix[dot+1:] { + case "clean", "process", "smudge": + drivers[prefix[:dot]] = struct{}{} + default: + return nil, fmt.Errorf("inspect repository Git filters: invalid 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 { + prefix := "filter." + name + "." + overrides = append(overrides, + "-c", prefix+"clean=/bin/cat", + "-c", prefix+"smudge=/bin/cat", + "-c", prefix+"process=", + "-c", prefix+"required=false", + ) + } + return overrides, nil +} + +func controlledEvidenceGitEnvironment(extraEnv ...string) []string { + env := make([]string, 0, len(os.Environ())+4+len(extraEnv)) + for _, entry := range os.Environ() { + name, _, ok := strings.Cut(entry, "=") + if !ok || strings.HasPrefix(name, "GIT_") || name == "SSH_AUTH_SOCK" { + continue + } + env = append(env, entry) + } + env = append(env, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_TERMINAL_PROMPT=0", + ) + return append(env, extraEnv...) +} diff --git a/internal/evidence/inrepo.go b/internal/evidence/inrepo.go index 73d4de3..c29e1d7 100644 --- a/internal/evidence/inrepo.go +++ b/internal/evidence/inrepo.go @@ -187,10 +187,15 @@ func (s *InRepoStore) PublishEvidenceContext(ctx context.Context, runID string) if err := runEvidenceGit(ctx, repoPath, "add", "--", relPath); err != nil { return fmt.Errorf("evidence: stage in-repo evidence: %w", err) } + diffArgs, err := evidenceGitArgs(ctx, repoPath, "diff", "--cached", "--quiet", "--", relPath) + if err != nil { + return fmt.Errorf("evidence: prepare staged evidence inspection: %w", err) + } diff, err := execpkg.Run(ctx, execpkg.Command{ Name: "git", - Args: []string{"diff", "--cached", "--quiet", "--", relPath}, + Args: diffArgs, Dir: repoPath, + Env: controlledEvidenceGitEnvironment(), Timeout: evidenceGitTimeout, OutputLimit: evidenceGitOutputCap, }) @@ -202,10 +207,15 @@ func (s *InRepoStore) PublishEvidenceContext(ctx context.Context, runID string) } else if diff.ExitCode != 1 { return fmt.Errorf("evidence: inspect staged evidence failed with exit code %d: %s", diff.ExitCode, RedactString(string(diff.Stdout)+string(diff.Stderr))) } + titleArgs, err := evidenceGitArgs(ctx, repoPath, "log", "-1", "--format=%s") + if err != nil { + return fmt.Errorf("evidence: prepare commit subject inspection: %w", err) + } titleResult, err := execpkg.Run(ctx, execpkg.Command{ Name: "git", - Args: []string{"log", "-1", "--format=%s"}, + Args: titleArgs, Dir: repoPath, + Env: controlledEvidenceGitEnvironment(), Timeout: evidenceGitTimeout, OutputLimit: evidenceGitOutputCap, }) @@ -226,11 +236,15 @@ func (s *InRepoStore) PublishEvidenceContext(ctx context.Context, runID string) } func runEvidenceGit(ctx context.Context, repoPath string, args ...string) error { + commandArgs, err := evidenceGitArgs(ctx, repoPath, args...) + if err != nil { + return fmt.Errorf("prepare git %s: %w", strings.Join(args, " "), err) + } result, err := execpkg.Run(ctx, execpkg.Command{ Name: "git", - Args: args, + Args: commandArgs, Dir: repoPath, - Env: append(os.Environ(), + Env: controlledEvidenceGitEnvironment( "GIT_AUTHOR_NAME=made-evidence", "GIT_AUTHOR_EMAIL=made-evidence@localhost", "GIT_COMMITTER_NAME=made-evidence", diff --git a/internal/evidence/orphan.go b/internal/evidence/orphan.go index e48d5c8..c5dacb3 100644 --- a/internal/evidence/orphan.go +++ b/internal/evidence/orphan.go @@ -124,15 +124,15 @@ func (s *OrphanBranchStore) WriteEvidenceContext(ctx context.Context, runID stri } func (s *OrphanBranchStore) runGit(ctx context.Context, extraEnv []string, stdin []byte, args ...string) (string, error) { - var env []string - if extraEnv != nil { - env = append(os.Environ(), extraEnv...) + commandArgs, err := evidenceGitArgs(ctx, s.RepoPath, args...) + if err != nil { + return "", fmt.Errorf("prepare git %s: %w", strings.Join(args, " "), err) } result, err := execpkg.Run(ctx, execpkg.Command{ Name: "git", - Args: args, + Args: commandArgs, Dir: s.RepoPath, - Env: env, + Env: controlledEvidenceGitEnvironment(extraEnv...), Stdin: stdin, Timeout: evidenceGitTimeout, OutputLimit: evidenceGitOutputCap, diff --git a/internal/evidence/remediation_contract_test.go b/internal/evidence/remediation_contract_test.go index b33fcd7..a7f7b49 100644 --- a/internal/evidence/remediation_contract_test.go +++ b/internal/evidence/remediation_contract_test.go @@ -80,6 +80,31 @@ func TestInRepoStore_PublishesEvidenceInAccessibleCommit(t *testing.T) { } } +func TestInRepoStore_PublishSuppressesRepositoryHooksAndAmbientGitConfig(t *testing.T) { + repo := initTargetRepo(t) + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.WriteEvidence("run-hooks", map[string][]byte{"log.txt": []byte("visible evidence\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + hooksDir := t.TempDir() + hookMarker := filepath.Join(t.TempDir(), "hook-fired") + if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte("#!/bin/sh\nprintf fired > '"+hookMarker+"'\n"), 0o700); err != nil { + t.Fatalf("write pre-commit hook: %v", err) + } + t.Setenv("GIT_CONFIG_COUNT", "2") + t.Setenv("GIT_CONFIG_KEY_0", "core.hooksPath") + t.Setenv("GIT_CONFIG_VALUE_0", hooksDir) + t.Setenv("GIT_CONFIG_KEY_1", "commit.gpgsign") + t.Setenv("GIT_CONFIG_VALUE_1", "false") + + if err := store.PublishEvidence("run-hooks"); err != nil { + t.Fatalf("PublishEvidence: %v", err) + } + if _, err := os.Stat(hookMarker); !os.IsNotExist(err) { + t.Fatalf("evidence publication executed an inherited Git hook: %v", err) + } +} + func TestInRepoStore_PublishRejectsSymlinkedEvidenceFile(t *testing.T) { repo := initTargetRepo(t) store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} diff --git a/internal/evidence/timeout_test.go b/internal/evidence/timeout_test.go index 4a40137..73455c1 100644 --- a/internal/evidence/timeout_test.go +++ b/internal/evidence/timeout_test.go @@ -2,6 +2,7 @@ package evidence import ( "context" + "fmt" "os" "os/exec" "path/filepath" @@ -25,18 +26,25 @@ func TestInRepoStore_PublishHonorsGitTimeout(t *testing.T) { if err := store.WriteEvidence("run-timeout", map[string][]byte{"log.txt": []byte("evidence\n")}); err != nil { t.Fatalf("WriteEvidence: %v", err) } - hookDir := filepath.Join(repo, ".git", "hooks") - if err := os.WriteFile(filepath.Join(hookDir, "pre-commit"), []byte("#!/bin/sh\nsleep 5\n"), 0o700); err != nil { - t.Fatalf("write blocking hook: %v", err) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("locate real git: %v", err) } + shimDir := t.TempDir() + shimPath := filepath.Join(shimDir, "git") + shim := fmt.Sprintf("#!/bin/sh\ncase \"$*\" in\n *' add '*) sleep 5 ;;\n *) exec %q \"$@\" ;;\nesac\n", realGit) + if err := os.WriteFile(shimPath, []byte(shim), 0o700); err != nil { + t.Fatalf("write blocking git shim: %v", err) + } + t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH")) originalTimeout := evidenceGitTimeout evidenceGitTimeout = 50 * time.Millisecond t.Cleanup(func() { evidenceGitTimeout = originalTimeout }) started := time.Now() - err := store.PublishEvidenceContext(context.Background(), "run-timeout") + err = store.PublishEvidenceContext(context.Background(), "run-timeout") if err == nil { - t.Fatal("PublishEvidenceContext returned nil despite a blocking git hook") + t.Fatal("PublishEvidenceContext returned nil despite a blocking git command") } if elapsed := time.Since(started); elapsed > time.Second { t.Fatalf("PublishEvidenceContext exceeded bounded timeout: %s", elapsed) From 3fc98f031613e7be77abf0152cb1eb5b3d1baeaf Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 16 Aug 2026 23:48:44 -0400 Subject: [PATCH 40/53] fix: isolate rebase and no-param API --- cmd/made/remediation_contract_test.go | 11 ++ cmd/made/runhandlers.go | 6 +- internal/api/remediation_contract_test.go | 28 ++++ internal/api/server.go | 32 ++++- internal/pipeline/rebase/rebase.go | 154 +++++++++++++++++++--- internal/pipeline/rebase/rebase_test.go | 22 ++++ 6 files changed, 231 insertions(+), 22 deletions(-) diff --git a/cmd/made/remediation_contract_test.go b/cmd/made/remediation_contract_test.go index 9b220c5..f4194f7 100644 --- a/cmd/made/remediation_contract_test.go +++ b/cmd/made/remediation_contract_test.go @@ -281,6 +281,17 @@ func TestRunListHandlerRejectsUnknownParams(t *testing.T) { } } +func TestDaemonShutdownHandlerRejectsUnknownParams(t *testing.T) { + spool, err := daemon.OpenGateSpool(filepath.Join(t.TempDir(), "gate.spool")) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + _, err = daemonShutdownHandler(daemon.NewRunManager(), spool, func() {})(context.Background(), []byte(`{"unexpected":true}`)) + if err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("daemon.shutdown accepted unknown parameter field: %v", err) + } +} + func captureRun(t *testing.T, args ...string) (int, string, string) { t.Helper() outFile, err := os.CreateTemp(t.TempDir(), "stdout-") diff --git a/cmd/made/runhandlers.go b/cmd/made/runhandlers.go index ee7e68c..5efb7c3 100644 --- a/cmd/made/runhandlers.go +++ b/cmd/made/runhandlers.go @@ -141,7 +141,11 @@ func runCancelHandler(rm *daemon.RunManager) api.HandlerFunc { } func daemonShutdownHandler(rm *daemon.RunManager, spool *daemon.GateSpool, cancel context.CancelFunc, admission ...*sync.Mutex) api.HandlerFunc { - return func(_ context.Context, _ json.RawMessage) (any, error) { + return func(_ context.Context, params json.RawMessage) (any, error) { + var noParams struct{} + if err := decodeStrictParams(params, &noParams); err != nil { + return nil, fmt.Errorf("daemon.shutdown: invalid params: %w", err) + } unlock := lockAdmission(admission) defer unlock() if spool.HasPending() { diff --git a/internal/api/remediation_contract_test.go b/internal/api/remediation_contract_test.go index 21d90ce..a5fd710 100644 --- a/internal/api/remediation_contract_test.go +++ b/internal/api/remediation_contract_test.go @@ -152,6 +152,34 @@ func TestServerRejectsUnknownEnvelopeFields(t *testing.T) { } } +func TestServerRejectsUnknownPingParams(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + server := api.NewServer(path) + if err := server.Listen(); err != nil { + t.Fatalf("Listen: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + defer func() { _ = server.Close() }() + go func() { _ = server.Serve(ctx) }() + + conn, err := net.DialTimeout("unix", path, time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + if _, err := conn.Write([]byte(`{"made.protocol":1,"id":"ping-strict","method":"ping","params":{"unexpected":true}}`)); err != nil { + t.Fatalf("write request: %v", err) + } + var response api.Response + if err := json.NewDecoder(conn).Decode(&response); err != nil { + t.Fatalf("decode strict rejection response: %v", err) + } + if response.Error == nil { + t.Fatalf("ping accepted unknown params: %+v", response) + } +} + func TestServerRejectsOversizedRequestLine(t *testing.T) { path := filepath.Join(tempSocketDir(t), "daemon.sock") server := api.NewServer(path) diff --git a/internal/api/server.go b/internal/api/server.go index f595df6..2239831 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -306,6 +306,33 @@ func readRequestValue(reader *bufio.Reader, maxBytes int) ([]byte, error) { } } +func validateEmptyParams(params json.RawMessage) error { + trimmed := bytes.TrimSpace(params) + if len(trimmed) == 0 { + return nil + } + if trimmed[0] != '{' { + return fmt.Errorf("params must be a JSON object") + } + decoder := json.NewDecoder(bytes.NewReader(trimmed)) + decoder.DisallowUnknownFields() + var fields map[string]json.RawMessage + if err := decoder.Decode(&fields); err != nil { + return err + } + var extra json.RawMessage + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("params contain multiple JSON values") + } + return err + } + if len(fields) != 0 { + return fmt.Errorf("params contain unknown fields") + } + return nil +} + func isJSONSpace(b byte) bool { return b == ' ' || b == '\t' || b == '\r' || b == '\n' } @@ -347,6 +374,9 @@ type pingResult struct { Message string `json:"message"` } -func handlePing(context.Context, json.RawMessage) (any, error) { +func handlePing(_ context.Context, params json.RawMessage) (any, error) { + if err := validateEmptyParams(params); err != nil { + return nil, fmt.Errorf("ping: invalid params: %w", err) + } return pingResult{Message: "pong"}, nil } diff --git a/internal/pipeline/rebase/rebase.go b/internal/pipeline/rebase/rebase.go index e54fbe4..ee06d33 100644 --- a/internal/pipeline/rebase/rebase.go +++ b/internal/pipeline/rebase/rebase.go @@ -9,9 +9,17 @@ import ( "context" "fmt" "os" - "os/exec" "path/filepath" + "sort" "strings" + "time" + + madeexec "github.com/douglasjarquin/made/internal/exec" +) + +const ( + rebaseGitTimeout = 30 * time.Second + rebaseGitOutputCap = 1 << 20 ) type Result struct { @@ -29,15 +37,12 @@ func Run(worktreePath, defaultBranch string) (Result, error) { } func RunContext(ctx context.Context, worktreePath, defaultBranch string) (Result, error) { - cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, - "-c", "user.name=made-rebase", - "-c", "user.email=made-rebase@local", - "-c", "commit.gpgsign=false", - "-c", "core.hooksPath=/dev/null", - "rebase", defaultBranch, - ) - out, rebaseErr := cmd.CombinedOutput() - if rebaseErr == nil { + result, err := runGit(ctx, worktreePath, "rebase", defaultBranch) + if err != nil { + return Result{}, fmt.Errorf("rebase: git rebase %s: %w", defaultBranch, err) + } + out := append(append([]byte(nil), result.Stdout...), result.Stderr...) + if result.ExitCode == 0 { return Result{ OK: true, Message: fmt.Sprintf("rebased cleanly onto %s", defaultBranch), @@ -45,7 +50,7 @@ func RunContext(ctx context.Context, worktreePath, defaultBranch string) (Result } if !rebaseInProgress(ctx, worktreePath) { - return Result{}, fmt.Errorf("rebase: git rebase %s: %w: %s", defaultBranch, rebaseErr, strings.TrimSpace(string(out))) + return Result{}, fmt.Errorf("rebase: git rebase %s failed without unmerged paths: %s", defaultBranch, strings.TrimSpace(string(out))) } files, err := conflictingFiles(ctx, worktreePath) @@ -73,14 +78,16 @@ func RunContext(ctx context.Context, worktreePath, defaultBranch string) (Result } func conflictingFiles(ctx context.Context, worktreePath string) ([]string, error) { - cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "diff", "--name-only", "--diff-filter=U") - out, err := cmd.Output() + result, err := runGit(ctx, worktreePath, "diff", "--name-only", "--diff-filter=U") if err != nil { return nil, err } + if result.ExitCode != 0 { + return nil, fmt.Errorf("git diff --name-only --diff-filter=U exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) + } var files []string - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + for _, line := range strings.Split(strings.TrimSpace(string(result.Stdout)), "\n") { if line != "" { files = append(files, line) } @@ -89,21 +96,23 @@ func conflictingFiles(ctx context.Context, worktreePath string) ([]string, error } func abortRebase(ctx context.Context, worktreePath string) error { - cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rebase", "--abort") - out, err := cmd.CombinedOutput() + result, err := runGit(ctx, worktreePath, "rebase", "--abort") if err != nil { - return fmt.Errorf("git rebase --abort: %w: %s", err, strings.TrimSpace(string(out))) + return fmt.Errorf("git rebase --abort: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("git rebase --abort exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) } return nil } func rebaseInProgress(ctx context.Context, worktreePath string) bool { - out, err := exec.CommandContext(ctx, "git", "-C", worktreePath, "rev-parse", "--git-dir").Output() - if err != nil { + result, err := runGit(ctx, worktreePath, "rev-parse", "--git-dir") + if err != nil || result.ExitCode != 0 { return false } - gitDir := strings.TrimSpace(string(out)) + gitDir := strings.TrimSpace(string(result.Stdout)) if !filepath.IsAbs(gitDir) { gitDir = filepath.Join(worktreePath, gitDir) } @@ -115,3 +124,108 @@ func rebaseInProgress(ctx context.Context, worktreePath string) bool { } return false } + +func runGit(ctx context.Context, worktreePath string, args ...string) (*madeexec.Result, error) { + commandArgs, err := rebaseGitArgs(ctx, worktreePath, args...) + if err != nil { + return nil, err + } + return madeexec.Run(ctx, madeexec.Command{ + Name: "git", + Args: commandArgs, + Env: controlledRebaseGitEnvironment(), + Timeout: rebaseGitTimeout, + OutputLimit: rebaseGitOutputCap, + }) +} + +func rebaseGitArgs(ctx context.Context, worktreePath string, args ...string) ([]string, error) { + filterArgs, err := repositoryFilterOverrides(ctx, worktreePath) + if err != nil { + return nil, err + } + commandArgs := []string{ + "-C", worktreePath, + "-c", "user.name=made-rebase", + "-c", "user.email=made-rebase@local", + "-c", "commit.gpgsign=false", + "-c", "core.hooksPath=/dev/null", + "-c", "core.fsmonitor=false", + "-c", "diff.external=", + } + commandArgs = append(commandArgs, filterArgs...) + return append(commandArgs, args...), nil +} + +func repositoryFilterOverrides(ctx context.Context, worktreePath string) ([]string, error) { + result, err := madeexec.Run(ctx, madeexec.Command{ + Name: "git", + Args: []string{ + "-C", worktreePath, + "config", "--local", "--name-only", "--get-regexp", + "^filter\\..+\\.(clean|process|smudge)$", + }, + Env: controlledRebaseGitEnvironment(), + Timeout: rebaseGitTimeout, + OutputLimit: rebaseGitOutputCap, + }) + if err != nil { + return nil, err + } + if result.ExitCode == 1 { + return nil, nil + } + if result.ExitCode != 0 { + return nil, fmt.Errorf("inspect repository Git filters: git exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) + } + if strings.Contains(string(result.Stdout), "[output truncated]") { + return nil, fmt.Errorf("inspect repository Git filters: output exceeded %d bytes", rebaseGitOutputCap) + } + drivers := make(map[string]struct{}) + for _, key := range strings.Fields(string(result.Stdout)) { + prefix := strings.TrimPrefix(key, "filter.") + dot := strings.LastIndexByte(prefix, '.') + if dot <= 0 { + return nil, fmt.Errorf("inspect repository Git filters: invalid key %q", key) + } + switch prefix[dot+1:] { + case "clean", "process", "smudge": + drivers[prefix[:dot]] = struct{}{} + default: + return nil, fmt.Errorf("inspect repository Git filters: invalid 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 { + prefix := "filter." + name + "." + overrides = append(overrides, + "-c", prefix+"clean=/bin/cat", + "-c", prefix+"smudge=/bin/cat", + "-c", prefix+"process=", + "-c", prefix+"required=false", + ) + } + return overrides, nil +} + +func controlledRebaseGitEnvironment() []string { + env := make([]string, 0, len(os.Environ())+4) + for _, entry := range os.Environ() { + name, _, ok := strings.Cut(entry, "=") + if !ok || strings.HasPrefix(name, "GIT_") || name == "SSH_AUTH_SOCK" { + continue + } + env = append(env, entry) + } + return append(env, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_TERMINAL_PROMPT=0", + ) +} diff --git a/internal/pipeline/rebase/rebase_test.go b/internal/pipeline/rebase/rebase_test.go index 27612f2..9bc20f5 100644 --- a/internal/pipeline/rebase/rebase_test.go +++ b/internal/pipeline/rebase/rebase_test.go @@ -45,6 +45,28 @@ func TestRun_CleanRebaseProceeds(t *testing.T) { } } +func TestRun_CleanRebaseIgnoresAmbientGitRouting(t *testing.T) { + f := setupFixture(t, "", "", "") + wt := f.addWorktree(t) + defer func() { + if err := wt.Remove(); err != nil { + t.Errorf("Remove: %v", err) + } + }() + + ambientGitDir := t.TempDir() + run(t, ambientGitDir, "init", "--bare", "-q") + t.Setenv("GIT_DIR", ambientGitDir) + + result, err := rebase.Run(wt.Path, f.defaultBranch) + if err != nil { + t.Fatalf("Run with ambient GIT_DIR: %v", err) + } + if !result.OK { + t.Fatalf("expected OK=true despite ambient GIT_DIR, got %+v", result) + } +} + func TestRun_ConflictingRebaseHalts(t *testing.T) { f := setupFixture(t, "shared.txt", "main version\n", "feature version\n") wt := f.addWorktree(t) From 2f36e77a1857e97c58a7f19178627827726d5975 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 16 Aug 2026 23:54:14 -0400 Subject: [PATCH 41/53] docs: record final boundary validation --- docs/remediation/made-remediation-p1p3b.md | 30 +++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 1c48d59..41791d6 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -100,6 +100,10 @@ The clean-filter follow-up is `738cc55b2d4b4dbdaadc05eb351f612be0eafcd5` with su The portability follow-up is `0c3af42fd75d6f02412cde82b33c8341305243e3` with subject `fix: harden portable rebase validation`. +The final review-boundary follow-up is `c3b002e1faa4fccb20fc4f9f63600a425b5c5e52` with subject `fix: harden evidence and API boundaries`. + +The final strict-boundary follow-up is `3fc98f031613e7be77abf0152cb1eb5b3d1baeaf` with subject `fix: isolate rebase and no-param API`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -112,6 +116,8 @@ Submission admission is closed under the same mutex as the shutdown check, so a The public surface is versioned and structured through `made capabilities --json`, `made run submit`, `made run status`, `made run list`, `made run cancel`, `made review decide`, and `made doctor --json`. +The Unix-socket envelope and every public daemon parameter object now reject unknown fields and non-object parameter values instead of silently accepting an unversioned shape. + The lifecycle states are `queued`, `running`, `awaiting_review`, `awaiting_merge`, `succeeded`, `failed`, `canceled`, and `superseded`. Execution completion is represented separately by `execution_finished`. @@ -156,6 +162,10 @@ Rebase failures are classified as conflicts only when unmerged paths exist. Evidence is run- and stage-specific, bounded, redacted for common credential assignments and URLs, symlink-safe, and published only through accessible paths. +Every evidence Git command runs with inherited `GIT_*` and SSH-agent routing removed, global and system configuration disabled, hooks and fsmonitor disabled, external diff disabled, and repository-local clean/process/smudge filters overridden. + +Rebase Git commands use the same bounded, sanitized environment and filter overrides, so ambient `GIT_DIR`, hooks, configuration, and filters cannot redirect or execute during trusted-branch preparation. + In-repository evidence is committed into the pushed branch before the push stage completes, while orphan evidence remains on its dedicated evidence branch. When a remote default ref disappears, Made deletes the cached trusted ref before resolving policy, preventing stale trusted configuration from surviving refresh. @@ -174,7 +184,7 @@ The CI portability fix uses Go's platform-independent Unix-socket mode inspectio ## Validation evidence -The final executable source SHA covered by this validation section is `0c3af42fd75d6f02412cde82b33c8341305243e3`. +The final executable source SHA covered by this validation section is `3fc98f031613e7be77abf0152cb1eb5b3d1baeaf`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -218,6 +228,10 @@ The full validation command was rerun at the final executable SHA `738cc55b2d4b4 The full validation command was rerun at the final executable SHA `0c3af42fd75d6f02412cde82b33c8341305243e3`, and `/tmp/made-remediation-p1p3b-0c3af42-validation-clean.log` ends with the pinned lint result `0 issues.` and exit `0`. +The full validation command was rerun at the final executable SHA `c3b002e1faa4fccb20fc4f9f63600a425b5c5e52`, and `/tmp/made-remediation-p1p3b-c3b002e-validation.log` ends with `validation-c3b002e=PASS` and the pinned lint result `0 issues.`. + +The full validation command was rerun at the final executable SHA `3fc98f031613e7be77abf0152cb1eb5b3d1baeaf`, and `/tmp/made-remediation-p1p3b-3fc98f0-validation.log` ends with `validation-3fc98f0=PASS` and the pinned lint result `0 issues.`. + The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-e9aa0dd.log`, and its final marker was `manual-contract-e9aa0dd=PASS`. @@ -246,6 +260,16 @@ The fresh exact-tip real-process transcript is `/tmp/made-remediation-p1p3b-manu That exact-tip scenario observed process-level cancellation, WAL restart persistence, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, and the hermetic real Made binary against the real Consigliere script with strict fake GitHub and unavailable Herdr boundaries. +The final exact-tip focused evidence and review transcript is `/tmp/made-remediation-p1p3b-manual-review-c3b002e.log`, and its final marker is `manual-review-c3b002e=PASS`. + +The final executable exact-tip transcript is `/tmp/made-remediation-p1p3b-manual-3fc98f0.log`, and its final marker is `manual-qa-3fc98f0=PASS`. + +The final executable exact-tip focused API, evidence, agent, rebase, and review transcript is `/tmp/made-remediation-p1p3b-manual-review-3fc98f0.log`, and its final marker is `manual-review-3fc98f0=PASS`. + +The exact-tip API and evidence RED log is `/tmp/made-remediation-p1p3b-strict-boundary-red.log`, and the green rerun is covered by `/tmp/made-remediation-p1p3b-c3b002e-validation.log` plus the evidence-focused `/tmp/made-remediation-p1p3b-manual-review-c3b002e.log`. + +The no-parameter API and ambient-rebase RED log is `/tmp/made-remediation-p1p3b-strict-no-params-red.log` plus `/tmp/made-remediation-p1p3b-rebase-boundary-red.log`, and the focused green rerun is the exact-tip rebase/API test pass immediately before commit `3fc98f031613e7be77abf0152cb1eb5b3d1baeaf`. + That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process and doctor through the real Consigliere script. The companion exact-SHA contract scenario observed restart durability, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, oversized and stalled socket rejection, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. @@ -270,7 +294,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..0c3af42fd75d6f02412cde82b33c8341305243e3`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..3fc98f031613e7be77abf0152cb1eb5b3d1baeaf`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. @@ -284,6 +308,6 @@ No Consigliere repository file, GitHub issue, default branch, merge, or shared d ## Delivery dependency -The remaining dependency after this report is the exact-SHA review pass and direct PR on `cs/made-remediation-p1p3b` against `main`. +The remaining dependency after this report is the final exact-SHA review pass and direct PR on `cs/made-remediation-p1p3b` against `main`. The branch must be committed, pushed only to `origin/cs/made-remediation-p1p3b`, and opened as a direct PR before the Made lane reports done. From f94306655d32e74c4cdff73c5ebbd349b73ad2c9 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 17 Aug 2026 00:04:27 -0400 Subject: [PATCH 42/53] fix: contain review agents at OS boundary --- .github/workflows/ci.yml | 2 + internal/agent/containment.go | 70 +++++++++++++++++++++ internal/agent/remediation_contract_test.go | 46 ++++++++++++++ internal/agent/reviewworktree.go | 59 ++++++++++++----- internal/agent/spawn.go | 10 ++- 5 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 internal/agent/containment.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc98870..22b6cf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version: "1.26.5" + - name: Install reviewer containment + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y bubblewrap - run: go build ./... - run: go test ./... - run: go test -race -shuffle=on -count=1 ./... diff --git a/internal/agent/containment.go b/internal/agent/containment.go new file mode 100644 index 0000000..e566e3e --- /dev/null +++ b/internal/agent/containment.go @@ -0,0 +1,70 @@ +package agent + +import ( + "fmt" + "os" + stdexec "os/exec" + "runtime" + "sort" + "strconv" + "strings" +) + +func containedInvocation(binary string, args []string, reviewPath string, protectedPaths []string) (string, []string, error) { + switch runtime.GOOS { + case "darwin": + const sandboxExec = "/usr/bin/sandbox-exec" + if _, err := os.Stat(sandboxExec); err != nil { + return "", nil, fmt.Errorf("%s is required for reviewer containment: %w", sandboxExec, err) + } + profile := darwinReviewProfile(protectedPaths) + commandArgs := []string{"-p", profile, binary} + return sandboxExec, append(commandArgs, args...), nil + case "linux": + bwrap, err := stdexec.LookPath("bwrap") + if err != nil { + return "", nil, fmt.Errorf("bubblewrap is required for reviewer containment: %w", err) + } + return bwrap, bubblewrapReviewArgs(binary, args, reviewPath, protectedPaths), nil + default: + return "", nil, fmt.Errorf("reviewer containment is unsupported on %s", runtime.GOOS) + } +} + +func darwinReviewProfile(protectedPaths []string) string { + var profile strings.Builder + profile.WriteString("(version 1)\n(allow default)\n") + for _, path := range protectedPaths { + quoted := strconv.Quote(path) + profile.WriteString("(deny file-read* (subpath ") + profile.WriteString(quoted) + profile.WriteString("))\n") + profile.WriteString("(deny file-write* (subpath ") + profile.WriteString(quoted) + profile.WriteString("))\n") + } + return profile.String() +} + +func bubblewrapReviewArgs(binary string, args []string, reviewPath string, protectedPaths []string) []string { + paths := append([]string(nil), protectedPaths...) + sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) + commandArgs := []string{ + "--die-with-parent", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--unshare-user-try", + "--ro-bind", "/", "/", + "--bind", "/tmp", "/tmp", + "--dev", "/dev", + "--proc", "/proc", + "--ro-bind", reviewPath, reviewPath, + } + for _, path := range paths { + commandArgs = append(commandArgs, "--tmpfs", path) + } + commandArgs = append(commandArgs, "--chdir", reviewPath) + commandArgs = append(commandArgs, "--", binary) + return append(commandArgs, args...) +} diff --git a/internal/agent/remediation_contract_test.go b/internal/agent/remediation_contract_test.go index 9847540..7c77b1e 100644 --- a/internal/agent/remediation_contract_test.go +++ b/internal/agent/remediation_contract_test.go @@ -122,6 +122,52 @@ func TestSpawn_RejectsReviewSymlinkThatEscapesClone(t *testing.T) { } } +func TestSpawn_ContainsReviewerFromSourceWorktree(t *testing.T) { + worktree := agentWorktree(t) + sourceFile := filepath.Join(worktree, "source.txt") + if err := os.WriteFile(sourceFile, []byte("source-only\n"), 0o600); err != nil { + t.Fatalf("write source fixture: %v", err) + } + gitAgent(t, worktree, "add", "source.txt") + gitAgent(t, worktree, "commit", "-q", "-m", "add source fixture") + marker := filepath.Join(t.TempDir(), "source-mutated") + script := filepath.Join(t.TempDir(), "containment-codex") + contents := strings.Join([]string{ + "#!/bin/sh", + "set -eu", + "if cat \"$STRICT_CODEX_SOURCE/source.txt\" >/dev/null 2>&1; then exit 41; fi", + "if chmod -R u+w \"$STRICT_CODEX_SOURCE\" 2>/dev/null; then", + " if : > \"$STRICT_CODEX_SOURCE/source-mutated\" 2>/dev/null; then printf mutated > \"$STRICT_CODEX_MARKER\"; exit 42; fi", + "fi", + "printf '%s\\n' '{\"findings\":[]}'", + "", + }, "\n") + if err := os.WriteFile(script, []byte(contents), 0o700); err != nil { + t.Fatalf("write containment Codex fake: %v", err) + } + + findings, err := agent.Spawn(context.Background(), agent.KindCodex, agent.SpawnParams{ + WorktreePath: worktree, + BinaryPath: script, + ExtraEnv: []string{ + "STRICT_CODEX_SOURCE=" + worktree, + "STRICT_CODEX_MARKER=" + marker, + }, + }) + if err != nil { + t.Fatalf("Spawn should contain reviewer without changing source: %v", err) + } + if len(findings.Findings) != 0 { + t.Fatalf("expected empty findings, got %+v", findings) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("reviewer escaped into source worktree: %v", err) + } + if _, err := os.Stat(filepath.Join(worktree, "source-mutated")); !os.IsNotExist(err) { + t.Fatalf("reviewer modified source worktree: %v", err) + } +} + func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } diff --git a/internal/agent/reviewworktree.go b/internal/agent/reviewworktree.go index 23b6e9c..8d023a1 100644 --- a/internal/agent/reviewworktree.go +++ b/internal/agent/reviewworktree.go @@ -18,26 +18,37 @@ const ( reviewPreparationLimit = 1 << 20 ) -func prepareReviewWorktree(ctx context.Context, source string) (string, func(), error) { +func prepareReviewWorktree(ctx context.Context, source string) (string, []string, func(), error) { source, err := filepath.Abs(source) if err != nil { - return "", nil, fmt.Errorf("resolve source worktree: %w", err) + return "", nil, nil, fmt.Errorf("resolve source worktree: %w", err) } headResult, err := runReviewGit(ctx, source, "rev-parse", "--verify", "HEAD^{commit}") if err != nil { - return "", nil, fmt.Errorf("read source HEAD: %w", err) + return "", nil, nil, fmt.Errorf("read source HEAD: %w", err) } if headResult.ExitCode != 0 { - return "", nil, commandFailure("read source HEAD", headResult) + return "", nil, nil, commandFailure("read source HEAD", headResult) } head := strings.TrimSpace(string(headResult.Stdout)) if head == "" { - return "", nil, fmt.Errorf("read source HEAD returned an empty SHA") + return "", nil, nil, fmt.Errorf("read source HEAD returned an empty SHA") + } + commonResult, err := runReviewGit(ctx, source, "rev-parse", "--git-common-dir") + if err != nil { + return "", nil, nil, fmt.Errorf("read source Git common directory: %w", err) + } + if commonResult.ExitCode != 0 { + return "", nil, nil, commandFailure("read source Git common directory", commonResult) + } + protectedPaths, err := reviewProtectedPaths(source, strings.TrimSpace(string(commonResult.Stdout))) + if err != nil { + return "", nil, nil, fmt.Errorf("resolve review protected paths: %w", err) } tempRoot, err := os.MkdirTemp("", "made-review-worktree-") if err != nil { - return "", nil, fmt.Errorf("create review worktree directory: %w", err) + return "", nil, nil, fmt.Errorf("create review worktree directory: %w", err) } reviewPath := filepath.Join(tempRoot, "repo") cleanupTemp := func() { _ = os.RemoveAll(tempRoot) } @@ -45,44 +56,62 @@ func prepareReviewWorktree(ctx context.Context, source string) (string, func(), cloneResult, err := runReviewGit(ctx, "", "clone", "--no-local", "--no-hardlinks", "--no-checkout", source, reviewPath) if err != nil { cleanupTemp() - return "", nil, fmt.Errorf("clone review worktree: %w", err) + return "", nil, nil, fmt.Errorf("clone review worktree: %w", err) } if cloneResult.ExitCode != 0 { cleanupTemp() - return "", nil, commandFailure("clone review worktree", cloneResult) + return "", nil, nil, commandFailure("clone review worktree", cloneResult) } checkoutResult, err := runReviewGit(ctx, reviewPath, "checkout", "--detach", "--quiet", head) if err != nil { cleanupTemp() - return "", nil, fmt.Errorf("checkout review HEAD: %w", err) + return "", nil, nil, fmt.Errorf("checkout review HEAD: %w", err) } if checkoutResult.ExitCode != 0 { cleanupTemp() - return "", nil, commandFailure("checkout review HEAD", checkoutResult) + return "", nil, nil, commandFailure("checkout review HEAD", checkoutResult) } clonedHead, err := runReviewGit(ctx, reviewPath, "rev-parse", "--verify", "HEAD^{commit}") if err != nil { cleanupTemp() - return "", nil, fmt.Errorf("verify review HEAD: %w", err) + return "", nil, nil, fmt.Errorf("verify review HEAD: %w", err) } if clonedHead.ExitCode != 0 || strings.TrimSpace(string(clonedHead.Stdout)) != head { cleanupTemp() - return "", nil, fmt.Errorf("review clone HEAD %q does not match source HEAD %q", strings.TrimSpace(string(clonedHead.Stdout)), head) + return "", nil, nil, fmt.Errorf("review clone HEAD %q does not match source HEAD %q", strings.TrimSpace(string(clonedHead.Stdout)), head) } if err := rejectEscapingSymlinks(reviewPath); err != nil { cleanupTemp() - return "", nil, fmt.Errorf("validate review worktree links: %w", err) + return "", nil, nil, fmt.Errorf("validate review worktree links: %w", err) } restoreModes, err := makeReviewTreeReadOnly(reviewPath) if err != nil { cleanupTemp() - return "", nil, fmt.Errorf("make review worktree read-only: %w", err) + return "", nil, nil, fmt.Errorf("make review worktree read-only: %w", err) } cleanup := func() { restoreModes() cleanupTemp() } - return reviewPath, cleanup, nil + return reviewPath, protectedPaths, cleanup, nil +} + +func reviewProtectedPaths(source, commonDir string) ([]string, error) { + paths := make([]string, 0, 2) + for _, path := range []string{source, commonDir} { + if path == "" { + return nil, fmt.Errorf("Git common directory is empty") + } + if !filepath.IsAbs(path) { + path = filepath.Join(source, path) + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return nil, err + } + paths = append(paths, resolved) + } + return paths, nil } func runReviewGit(ctx context.Context, dir string, args ...string) (*exec.Result, error) { diff --git a/internal/agent/spawn.go b/internal/agent/spawn.go index 78c11b6..67c7e79 100644 --- a/internal/agent/spawn.go +++ b/internal/agent/spawn.go @@ -30,7 +30,7 @@ func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) binary = kind.binaryName() } - reviewPath, cleanupReview, err := prepareReviewWorktree(ctx, params.WorktreePath) + reviewPath, protectedPaths, cleanupReview, err := prepareReviewWorktree(ctx, params.WorktreePath) if err != nil { return Findings{}, fmt.Errorf("agent: prepare read-only review worktree: %w", err) } @@ -41,13 +41,17 @@ func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) return Findings{}, err } defer cleanup() + commandName, commandArgs, err := containedInvocation(binary, args, reviewPath, protectedPaths) + if err != nil { + return Findings{}, fmt.Errorf("agent: contain review process: %w", err) + } timeout := params.Timeout if timeout <= 0 { timeout = defaultSpawnTimeout } result, err := exec.Run(ctx, exec.Command{ - Name: binary, - Args: args, + Name: commandName, + Args: commandArgs, Dir: reviewPath, Env: reviewEnvironmentForDir(params.ExtraEnv, reviewPath), Stdin: []byte("Return only the Made review JSON object matching the supplied schema.\n"), From ed9009251e9754b137dc0719352b525aad880e1b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 17 Aug 2026 00:06:20 -0400 Subject: [PATCH 43/53] fix: satisfy containment lint --- internal/agent/reviewworktree.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/agent/reviewworktree.go b/internal/agent/reviewworktree.go index 8d023a1..6369922 100644 --- a/internal/agent/reviewworktree.go +++ b/internal/agent/reviewworktree.go @@ -100,7 +100,7 @@ func reviewProtectedPaths(source, commonDir string) ([]string, error) { paths := make([]string, 0, 2) for _, path := range []string{source, commonDir} { if path == "" { - return nil, fmt.Errorf("Git common directory is empty") + return nil, fmt.Errorf("git common directory is empty") } if !filepath.IsAbs(path) { path = filepath.Join(source, path) From 60430bca69f5bdcca39dd8972e4efa38ba3f57d2 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 17 Aug 2026 00:08:58 -0400 Subject: [PATCH 44/53] docs: record containment validation --- docs/remediation/made-remediation-p1p3b.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 41791d6..11aae7a 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -104,6 +104,10 @@ The final review-boundary follow-up is `c3b002e1faa4fccb20fc4f9f63600a425b5c5e52 The final strict-boundary follow-up is `3fc98f031613e7be77abf0152cb1eb5b3d1baeaf` with subject `fix: isolate rebase and no-param API`. +The review-containment follow-up is `f94306655d32e74c4cdff73c5ebbd349b73ad2c9` with subject `fix: contain review agents at OS boundary`. + +The final lint follow-up is `ed9009251e9754b137dc0719352b525aad880e1b` with subject `fix: satisfy containment lint`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -128,6 +132,8 @@ Restored queued, running, and awaiting-review snapshots are reconciled to durabl Review agents run against a detached clone made without local hardlinks, with the exact source HEAD verified before launch, the clone and Git metadata made non-writable, escaping symlinks rejected, and delivery-path Git environment variables removed. +Review processes are additionally contained with macOS `sandbox-exec` or Linux `bubblewrap`, denying reads and writes to the source worktree and common Git directory while binding the detached review tree read-only. + Review setup removes all inherited `GIT_*` injection variables, disables global and system Git configuration for clone and checkout, and tests template hooks, injected config, exact HEAD, cleanup, and escaping symlinks. Controlled auto-fix Git commands use the same bounded execution contract with all ambient `GIT_*` routing and hook configuration removed before status, apply, add, commit, and validation operations. @@ -180,11 +186,13 @@ Pull-request GitHub authentication and API failures now remain infrastructure er The Made CI workflow validates the pinned Go version with race, vet, and pinned lint jobs. +The CI job installs the pinned-environment reviewer containment prerequisite `bubblewrap` before running the Made tests. + The CI portability fix uses Go's platform-independent Unix-socket mode inspection instead of Darwin-only `stat -f` flags, and clean rebase execution supplies deterministic committer identity, disabled signing, and disabled hooks at the Git boundary. ## Validation evidence -The final executable source SHA covered by this validation section is `3fc98f031613e7be77abf0152cb1eb5b3d1baeaf`. +The final executable source SHA covered by this validation section is `ed9009251e9754b137dc0719352b525aad880e1b`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -232,6 +240,8 @@ The full validation command was rerun at the final executable SHA `c3b002e1faa4f The full validation command was rerun at the final executable SHA `3fc98f031613e7be77abf0152cb1eb5b3d1baeaf`, and `/tmp/made-remediation-p1p3b-3fc98f0-validation.log` ends with `validation-3fc98f0=PASS` and the pinned lint result `0 issues.`. +The full validation command was rerun at the final executable SHA `ed9009251e9754b137dc0719352b525aad880e1b`, and `/tmp/made-remediation-p1p3b-ed90092-validation.log` ends with `validation-ed90092=PASS` and the pinned lint result `0 issues.`. + The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-e9aa0dd.log`, and its final marker was `manual-contract-e9aa0dd=PASS`. @@ -266,10 +276,16 @@ The final executable exact-tip transcript is `/tmp/made-remediation-p1p3b-manual The final executable exact-tip focused API, evidence, agent, rebase, and review transcript is `/tmp/made-remediation-p1p3b-manual-review-3fc98f0.log`, and its final marker is `manual-review-3fc98f0=PASS`. +The final executable real-process transcript is `/tmp/made-remediation-p1p3b-manual-ed90092.log`, and its final marker is `manual-qa-ed90092=PASS`. + +The final executable focused API, evidence, containment, agent, rebase, and review transcript is `/tmp/made-remediation-p1p3b-manual-review-ed90092.log`, and its final marker is `manual-review-ed90092=PASS`. + The exact-tip API and evidence RED log is `/tmp/made-remediation-p1p3b-strict-boundary-red.log`, and the green rerun is covered by `/tmp/made-remediation-p1p3b-c3b002e-validation.log` plus the evidence-focused `/tmp/made-remediation-p1p3b-manual-review-c3b002e.log`. The no-parameter API and ambient-rebase RED log is `/tmp/made-remediation-p1p3b-strict-no-params-red.log` plus `/tmp/made-remediation-p1p3b-rebase-boundary-red.log`, and the focused green rerun is the exact-tip rebase/API test pass immediately before commit `3fc98f031613e7be77abf0152cb1eb5b3d1baeaf`. +The reviewer-containment RED log is `/tmp/made-remediation-p1p3b-review-containment-red.log`, where the fake reviewer escaped through the source worktree before the OS boundary was added; `TestSpawn_ContainsReviewerFromSourceWorktree` passes at the final executable SHA. + That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process and doctor through the real Consigliere script. The companion exact-SHA contract scenario observed restart durability, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, oversized and stalled socket rejection, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. @@ -294,7 +310,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..3fc98f031613e7be77abf0152cb1eb5b3d1baeaf`, which reports the Made-only paths from the custody base. +The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..ed9009251e9754b137dc0719352b525aad880e1b`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. From 7e61074e03da212da04c517ccbb23a5e0899d05f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 17 Aug 2026 00:24:27 -0400 Subject: [PATCH 45/53] fix: make reviewer containment portable in CI --- .github/workflows/ci.yml | 5 ++++- docs/remediation/made-remediation-p1p3b.md | 2 +- internal/agent/containment.go | 1 - internal/agent/containment_test.go | 13 +++++++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 internal/agent/containment_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22b6cf0..074dc50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,10 @@ jobs: with: go-version: "1.26.5" - name: Install reviewer containment - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y bubblewrap + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y bubblewrap + sudo chmod 4755 "$(command -v bwrap)" - run: go build ./... - run: go test ./... - run: go test -race -shuffle=on -count=1 ./... diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 11aae7a..d147884 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -186,7 +186,7 @@ Pull-request GitHub authentication and API failures now remain infrastructure er The Made CI workflow validates the pinned Go version with race, vet, and pinned lint jobs. -The CI job installs the pinned-environment reviewer containment prerequisite `bubblewrap` before running the Made tests. +The CI job installs the pinned-environment reviewer containment prerequisite `bubblewrap` and enables its documented setuid execution mode before running the Made tests. The CI portability fix uses Go's platform-independent Unix-socket mode inspection instead of Darwin-only `stat -f` flags, and clean rebase execution supplies deterministic committer identity, disabled signing, and disabled hooks at the Git boundary. diff --git a/internal/agent/containment.go b/internal/agent/containment.go index e566e3e..29a1fb0 100644 --- a/internal/agent/containment.go +++ b/internal/agent/containment.go @@ -54,7 +54,6 @@ func bubblewrapReviewArgs(binary string, args []string, reviewPath string, prote "--unshare-pid", "--unshare-ipc", "--unshare-uts", - "--unshare-user-try", "--ro-bind", "/", "/", "--bind", "/tmp", "/tmp", "--dev", "/dev", diff --git a/internal/agent/containment_test.go b/internal/agent/containment_test.go new file mode 100644 index 0000000..07249f1 --- /dev/null +++ b/internal/agent/containment_test.go @@ -0,0 +1,13 @@ +package agent + +import ( + "slices" + "testing" +) + +func TestBubblewrapReviewArgsAvoidSetuidIncompatibleUserNamespaceFlag(t *testing.T) { + args := bubblewrapReviewArgs("/bin/agent", []string{"--review"}, "/tmp/review", []string{"/tmp/source"}) + if slices.Contains(args, "--unshare-user") || slices.Contains(args, "--unshare-user-try") { + t.Fatalf("bubblewrap arguments request an incompatible user namespace mode: %v", args) + } +} From fe54b0ee6b23c58742950506074f03d2bfa9605f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 17 Aug 2026 00:27:51 -0400 Subject: [PATCH 46/53] docs: record CI containment validation --- docs/remediation/made-remediation-p1p3b.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index d147884..14287af 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -108,6 +108,8 @@ The review-containment follow-up is `f94306655d32e74c4cdff73c5ebbd349b73ad2c9` w The final lint follow-up is `ed9009251e9754b137dc0719352b525aad880e1b` with subject `fix: satisfy containment lint`. +The CI containment portability follow-up is `7e61074e03da212da04c517ccbb23a5e0899d05f` with subject `fix: make reviewer containment portable in CI`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -192,7 +194,7 @@ The CI portability fix uses Go's platform-independent Unix-socket mode inspectio ## Validation evidence -The final executable source SHA covered by this validation section is `ed9009251e9754b137dc0719352b525aad880e1b`. +The final executable source SHA covered by this validation section is `7e61074e03da212da04c517ccbb23a5e0899d05f`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -242,6 +244,8 @@ The full validation command was rerun at the final executable SHA `3fc98f031613e The full validation command was rerun at the final executable SHA `ed9009251e9754b137dc0719352b525aad880e1b`, and `/tmp/made-remediation-p1p3b-ed90092-validation.log` ends with `validation-ed90092=PASS` and the pinned lint result `0 issues.`. +At executable source SHA `7e61074e03da212da04c517ccbb23a5e0899d05f`, the deterministic full suite, race suite, build, vet, and pinned lint all passed; the focused containment, API, evidence, rebase, and review suites passed; and `TestHermeticCompatibility_RealMadeBinaryThroughConsigliereScript` passed with the real Consigliere script and strict external fakes. + The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-e9aa0dd.log`, and its final marker was `manual-contract-e9aa0dd=PASS`. @@ -310,7 +314,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..ed9009251e9754b137dc0719352b525aad880e1b`, which reports the Made-only paths from the custody base. +The final changed-file authority through the implementation SHA is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..7e61074e03da212da04c517ccbb23a5e0899d05f`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. From 0e8f4c41b1104c20bc4e40604ded49c37d65e9fb Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 17 Aug 2026 00:34:40 -0400 Subject: [PATCH 47/53] fix: make reviewer masks read-only --- internal/agent/containment.go | 2 +- internal/agent/containment_test.go | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/agent/containment.go b/internal/agent/containment.go index 29a1fb0..0b30fa0 100644 --- a/internal/agent/containment.go +++ b/internal/agent/containment.go @@ -61,7 +61,7 @@ func bubblewrapReviewArgs(binary string, args []string, reviewPath string, prote "--ro-bind", reviewPath, reviewPath, } for _, path := range paths { - commandArgs = append(commandArgs, "--tmpfs", path) + commandArgs = append(commandArgs, "--perms", "0555", "--tmpfs", path) } commandArgs = append(commandArgs, "--chdir", reviewPath) commandArgs = append(commandArgs, "--", binary) diff --git a/internal/agent/containment_test.go b/internal/agent/containment_test.go index 07249f1..5cf4abf 100644 --- a/internal/agent/containment_test.go +++ b/internal/agent/containment_test.go @@ -10,4 +10,9 @@ func TestBubblewrapReviewArgsAvoidSetuidIncompatibleUserNamespaceFlag(t *testing if slices.Contains(args, "--unshare-user") || slices.Contains(args, "--unshare-user-try") { t.Fatalf("bubblewrap arguments request an incompatible user namespace mode: %v", args) } + for index, arg := range args { + if arg == "--tmpfs" && (index == 0 || args[index-1] != "0555") { + t.Fatalf("bubblewrap protected path is not masked read-only: %v", args) + } + } } From abf67ea656d2f63a99a8051de13f4be43ebf66db Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 17 Aug 2026 00:37:17 -0400 Subject: [PATCH 48/53] docs: record read-only containment validation --- docs/remediation/made-remediation-p1p3b.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 14287af..f1d28b8 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -110,6 +110,8 @@ The final lint follow-up is `ed9009251e9754b137dc0719352b525aad880e1b` with subj The CI containment portability follow-up is `7e61074e03da212da04c517ccbb23a5e0899d05f` with subject `fix: make reviewer containment portable in CI`. +The read-only reviewer-mask follow-up is `0e8f4c41b1104c20bc4e40604ded49c37d65e9fb` with subject `fix: make reviewer masks read-only`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -194,7 +196,7 @@ The CI portability fix uses Go's platform-independent Unix-socket mode inspectio ## Validation evidence -The final executable source SHA covered by this validation section is `7e61074e03da212da04c517ccbb23a5e0899d05f`. +The final executable source SHA covered by this validation section is `0e8f4c41b1104c20bc4e40604ded49c37d65e9fb`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -244,7 +246,9 @@ The full validation command was rerun at the final executable SHA `3fc98f031613e The full validation command was rerun at the final executable SHA `ed9009251e9754b137dc0719352b525aad880e1b`, and `/tmp/made-remediation-p1p3b-ed90092-validation.log` ends with `validation-ed90092=PASS` and the pinned lint result `0 issues.`. -At executable source SHA `7e61074e03da212da04c517ccbb23a5e0899d05f`, the deterministic full suite, race suite, build, vet, and pinned lint all passed; the focused containment, API, evidence, rebase, and review suites passed; and `TestHermeticCompatibility_RealMadeBinaryThroughConsigliereScript` passed with the real Consigliere script and strict external fakes. +At executable source SHA `0e8f4c41b1104c20bc4e40604ded49c37d65e9fb`, the deterministic full suite, race suite, build, vet, and pinned lint all passed; the focused containment, API, evidence, rebase, and review suites passed; and `TestHermeticCompatibility_RealMadeBinaryThroughConsigliereScript` passed with the real Consigliere script and strict external fakes. + +The final Linux containment correction masks protected paths with non-writable `0555` tmpfs mounts, preventing a reviewer from recreating files at a masked source path. The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. @@ -314,7 +318,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority through the implementation SHA is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..7e61074e03da212da04c517ccbb23a5e0899d05f`, which reports the Made-only paths from the custody base. +The final changed-file authority through the implementation SHA is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..0e8f4c41b1104c20bc4e40604ded49c37d65e9fb`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. From f2bac112fd2b91eb3bd1396878da21273961cbe0 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 17 Aug 2026 00:43:17 -0400 Subject: [PATCH 49/53] fix: harden containment and submit identity --- cmd/made/daemon.go | 12 ++++--- cmd/made/runhandlers.go | 10 ++++-- internal/agent/containment.go | 12 ++++--- internal/agent/containment_test.go | 8 ++--- internal/agent/reviewworktree.go | 50 +++++++++++++++++++----------- internal/agent/spawn.go | 4 +-- internal/daemon/runmanager.go | 3 +- 7 files changed, 61 insertions(+), 38 deletions(-) diff --git a/cmd/made/daemon.go b/cmd/made/daemon.go index 0ac98a8..cd55668 100644 --- a/cmd/made/daemon.go +++ b/cmd/made/daemon.go @@ -347,7 +347,8 @@ type gateNotifyPushParams struct { } type gateNotifyPushResult struct { - RunID string `json:"run_id,omitempty"` + RunID string `json:"run_id,omitempty"` + Snapshot daemon.RunSnapshot `json:"-"` } // gateNotifyPushHandler is the post-receive-driven counterpart to @@ -432,14 +433,14 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review newSHA := p.NewSHA runID := submission.RunID if !created { - if _, ok := rm.Snapshot(submission.RunID); ok { + if existingSnapshot, ok := rm.Snapshot(submission.RunID); ok { if err := rm.AppendSubmissionEvent(submission.RunID, daemon.SubmissionEvent{Gate: p.GatePath, Ref: p.Ref, InputSHA: p.NewSHA, Kind: "push"}); err != nil { return nil, fmt.Errorf("gate.notifyPush: persist replayed submission event: %w", err) } if err := spool.Drain(submission); err != nil { return nil, fmt.Errorf("gate.notifyPush: drain replayed submission: %w", err) } - return gateNotifyPushResult{RunID: submission.RunID}, nil + return gateNotifyPushResult{RunID: submission.RunID, Snapshot: existingSnapshot}, nil } runID = submission.RunID } @@ -452,7 +453,8 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review orchestrator.NewWorkFunc(rm, reviewDecisions, emit, runID, defaultBranch, branch, orchestrator.Options{})) } - if _, err := rm.SubmitWithMetadata(runID, repo, branch, p.NewSHA, p.OutputSHA, work); err != nil { + snapshot, err := rm.SubmitWithMetadata(runID, repo, branch, p.NewSHA, p.OutputSHA, work) + if err != nil { return nil, fmt.Errorf("gate.notifyPush: submit run: %w", err) } if err := rm.AppendSubmissionEvent(runID, daemon.SubmissionEvent{Gate: p.GatePath, Ref: p.Ref, InputSHA: p.NewSHA, Kind: "push"}); err != nil { @@ -462,7 +464,7 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review return nil, fmt.Errorf("gate.notifyPush: drain submission: %w", err) } - return gateNotifyPushResult{RunID: runID}, nil + return gateNotifyPushResult{RunID: runID, Snapshot: snapshot}, nil } } diff --git a/cmd/made/runhandlers.go b/cmd/made/runhandlers.go index 5efb7c3..20d157d 100644 --- a/cmd/made/runhandlers.go +++ b/cmd/made/runhandlers.go @@ -78,9 +78,13 @@ func runSubmitHandler(rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecis if gateResult.RunID == "" { return nil, fmt.Errorf("run.submit: gate submission did not create a run") } - snapshot, ok := rm.Snapshot(gateResult.RunID) - if !ok { - return nil, fmt.Errorf("run.submit: submitted run %q was not persisted", gateResult.RunID) + snapshot := gateResult.Snapshot + if snapshot.ID == "" { + var ok bool + snapshot, ok = rm.Snapshot(gateResult.RunID) + if !ok { + return nil, fmt.Errorf("run.submit: submitted run %q was not persisted", gateResult.RunID) + } } return runActionReport{ SchemaVersion: 1, ProtocolVersion: api.Version, RunID: snapshot.ID, diff --git a/internal/agent/containment.go b/internal/agent/containment.go index 0b30fa0..97f0263 100644 --- a/internal/agent/containment.go +++ b/internal/agent/containment.go @@ -10,7 +10,7 @@ import ( "strings" ) -func containedInvocation(binary string, args []string, reviewPath string, protectedPaths []string) (string, []string, error) { +func containedInvocation(binary string, args []string, reviewPath string, protectedPaths, maskPaths []string) (string, []string, error) { switch runtime.GOOS { case "darwin": const sandboxExec = "/usr/bin/sandbox-exec" @@ -25,7 +25,7 @@ func containedInvocation(binary string, args []string, reviewPath string, protec if err != nil { return "", nil, fmt.Errorf("bubblewrap is required for reviewer containment: %w", err) } - return bwrap, bubblewrapReviewArgs(binary, args, reviewPath, protectedPaths), nil + return bwrap, bubblewrapReviewArgs(binary, args, reviewPath, protectedPaths, maskPaths), nil default: return "", nil, fmt.Errorf("reviewer containment is unsupported on %s", runtime.GOOS) } @@ -46,8 +46,12 @@ func darwinReviewProfile(protectedPaths []string) string { return profile.String() } -func bubblewrapReviewArgs(binary string, args []string, reviewPath string, protectedPaths []string) []string { +func bubblewrapReviewArgs(binary string, args []string, reviewPath string, protectedPaths, maskPaths []string) []string { paths := append([]string(nil), protectedPaths...) + maskByPath := make(map[string]string, len(protectedPaths)) + for index, path := range protectedPaths { + maskByPath[path] = maskPaths[index] + } sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) commandArgs := []string{ "--die-with-parent", @@ -61,7 +65,7 @@ func bubblewrapReviewArgs(binary string, args []string, reviewPath string, prote "--ro-bind", reviewPath, reviewPath, } for _, path := range paths { - commandArgs = append(commandArgs, "--perms", "0555", "--tmpfs", path) + commandArgs = append(commandArgs, "--ro-bind", maskByPath[path], path) } commandArgs = append(commandArgs, "--chdir", reviewPath) commandArgs = append(commandArgs, "--", binary) diff --git a/internal/agent/containment_test.go b/internal/agent/containment_test.go index 5cf4abf..31c982c 100644 --- a/internal/agent/containment_test.go +++ b/internal/agent/containment_test.go @@ -6,13 +6,11 @@ import ( ) func TestBubblewrapReviewArgsAvoidSetuidIncompatibleUserNamespaceFlag(t *testing.T) { - args := bubblewrapReviewArgs("/bin/agent", []string{"--review"}, "/tmp/review", []string{"/tmp/source"}) + args := bubblewrapReviewArgs("/bin/agent", []string{"--review"}, "/tmp/review", []string{"/tmp/source"}, []string{"/tmp/mask"}) if slices.Contains(args, "--unshare-user") || slices.Contains(args, "--unshare-user-try") { t.Fatalf("bubblewrap arguments request an incompatible user namespace mode: %v", args) } - for index, arg := range args { - if arg == "--tmpfs" && (index == 0 || args[index-1] != "0555") { - t.Fatalf("bubblewrap protected path is not masked read-only: %v", args) - } + if !slices.Contains(args, "--ro-bind") || !slices.Contains(args, "/tmp/mask") { + t.Fatalf("bubblewrap protected path is not masked with a read-only bind: %v", args) } } diff --git a/internal/agent/reviewworktree.go b/internal/agent/reviewworktree.go index 6369922..263f73b 100644 --- a/internal/agent/reviewworktree.go +++ b/internal/agent/reviewworktree.go @@ -18,37 +18,37 @@ const ( reviewPreparationLimit = 1 << 20 ) -func prepareReviewWorktree(ctx context.Context, source string) (string, []string, func(), error) { +func prepareReviewWorktree(ctx context.Context, source string) (string, []string, []string, func(), error) { source, err := filepath.Abs(source) if err != nil { - return "", nil, nil, fmt.Errorf("resolve source worktree: %w", err) + return "", nil, nil, nil, fmt.Errorf("resolve source worktree: %w", err) } headResult, err := runReviewGit(ctx, source, "rev-parse", "--verify", "HEAD^{commit}") if err != nil { - return "", nil, nil, fmt.Errorf("read source HEAD: %w", err) + return "", nil, nil, nil, fmt.Errorf("read source HEAD: %w", err) } if headResult.ExitCode != 0 { - return "", nil, nil, commandFailure("read source HEAD", headResult) + return "", nil, nil, nil, commandFailure("read source HEAD", headResult) } head := strings.TrimSpace(string(headResult.Stdout)) if head == "" { - return "", nil, nil, fmt.Errorf("read source HEAD returned an empty SHA") + return "", nil, nil, nil, fmt.Errorf("read source HEAD returned an empty SHA") } commonResult, err := runReviewGit(ctx, source, "rev-parse", "--git-common-dir") if err != nil { - return "", nil, nil, fmt.Errorf("read source Git common directory: %w", err) + return "", nil, nil, nil, fmt.Errorf("read source Git common directory: %w", err) } if commonResult.ExitCode != 0 { - return "", nil, nil, commandFailure("read source Git common directory", commonResult) + return "", nil, nil, nil, commandFailure("read source Git common directory", commonResult) } protectedPaths, err := reviewProtectedPaths(source, strings.TrimSpace(string(commonResult.Stdout))) if err != nil { - return "", nil, nil, fmt.Errorf("resolve review protected paths: %w", err) + return "", nil, nil, nil, fmt.Errorf("resolve review protected paths: %w", err) } tempRoot, err := os.MkdirTemp("", "made-review-worktree-") if err != nil { - return "", nil, nil, fmt.Errorf("create review worktree directory: %w", err) + return "", nil, nil, nil, fmt.Errorf("create review worktree directory: %w", err) } reviewPath := filepath.Join(tempRoot, "repo") cleanupTemp := func() { _ = os.RemoveAll(tempRoot) } @@ -56,44 +56,58 @@ func prepareReviewWorktree(ctx context.Context, source string) (string, []string cloneResult, err := runReviewGit(ctx, "", "clone", "--no-local", "--no-hardlinks", "--no-checkout", source, reviewPath) if err != nil { cleanupTemp() - return "", nil, nil, fmt.Errorf("clone review worktree: %w", err) + return "", nil, nil, nil, fmt.Errorf("clone review worktree: %w", err) } if cloneResult.ExitCode != 0 { cleanupTemp() - return "", nil, nil, commandFailure("clone review worktree", cloneResult) + return "", nil, nil, nil, commandFailure("clone review worktree", cloneResult) } checkoutResult, err := runReviewGit(ctx, reviewPath, "checkout", "--detach", "--quiet", head) if err != nil { cleanupTemp() - return "", nil, nil, fmt.Errorf("checkout review HEAD: %w", err) + return "", nil, nil, nil, fmt.Errorf("checkout review HEAD: %w", err) } if checkoutResult.ExitCode != 0 { cleanupTemp() - return "", nil, nil, commandFailure("checkout review HEAD", checkoutResult) + return "", nil, nil, nil, commandFailure("checkout review HEAD", checkoutResult) } clonedHead, err := runReviewGit(ctx, reviewPath, "rev-parse", "--verify", "HEAD^{commit}") if err != nil { cleanupTemp() - return "", nil, nil, fmt.Errorf("verify review HEAD: %w", err) + return "", nil, nil, nil, fmt.Errorf("verify review HEAD: %w", err) } if clonedHead.ExitCode != 0 || strings.TrimSpace(string(clonedHead.Stdout)) != head { cleanupTemp() - return "", nil, nil, fmt.Errorf("review clone HEAD %q does not match source HEAD %q", strings.TrimSpace(string(clonedHead.Stdout)), head) + return "", nil, nil, nil, fmt.Errorf("review clone HEAD %q does not match source HEAD %q", strings.TrimSpace(string(clonedHead.Stdout)), head) } if err := rejectEscapingSymlinks(reviewPath); err != nil { cleanupTemp() - return "", nil, nil, fmt.Errorf("validate review worktree links: %w", err) + return "", nil, nil, nil, fmt.Errorf("validate review worktree links: %w", err) + } + maskRoot, err := os.MkdirTemp(reviewPath, ".made-review-masks-") + if err != nil { + cleanupTemp() + return "", nil, nil, nil, fmt.Errorf("create review mask directory: %w", err) + } + maskPaths := make([]string, 0, len(protectedPaths)) + for index := range protectedPaths { + maskPath := filepath.Join(maskRoot, fmt.Sprintf("%d", index)) + if err := os.Mkdir(maskPath, 0o700); err != nil { + cleanupTemp() + return "", nil, nil, nil, fmt.Errorf("create review mask %d: %w", index, err) + } + maskPaths = append(maskPaths, maskPath) } restoreModes, err := makeReviewTreeReadOnly(reviewPath) if err != nil { cleanupTemp() - return "", nil, nil, fmt.Errorf("make review worktree read-only: %w", err) + return "", nil, nil, nil, fmt.Errorf("make review worktree read-only: %w", err) } cleanup := func() { restoreModes() cleanupTemp() } - return reviewPath, protectedPaths, cleanup, nil + return reviewPath, protectedPaths, maskPaths, cleanup, nil } func reviewProtectedPaths(source, commonDir string) ([]string, error) { diff --git a/internal/agent/spawn.go b/internal/agent/spawn.go index 67c7e79..f542f6c 100644 --- a/internal/agent/spawn.go +++ b/internal/agent/spawn.go @@ -30,7 +30,7 @@ func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) binary = kind.binaryName() } - reviewPath, protectedPaths, cleanupReview, err := prepareReviewWorktree(ctx, params.WorktreePath) + reviewPath, protectedPaths, maskPaths, cleanupReview, err := prepareReviewWorktree(ctx, params.WorktreePath) if err != nil { return Findings{}, fmt.Errorf("agent: prepare read-only review worktree: %w", err) } @@ -41,7 +41,7 @@ func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) return Findings{}, err } defer cleanup() - commandName, commandArgs, err := containedInvocation(binary, args, reviewPath, protectedPaths) + commandName, commandArgs, err := containedInvocation(binary, args, reviewPath, protectedPaths, maskPaths) if err != nil { return Findings{}, fmt.Errorf("agent: contain review process: %w", err) } diff --git a/internal/daemon/runmanager.go b/internal/daemon/runmanager.go index f1fe10a..02a5473 100644 --- a/internal/daemon/runmanager.go +++ b/internal/daemon/runmanager.go @@ -258,12 +258,13 @@ func (rm *RunManager) SubmitWithMetadata(id, repo, branch, inputSHA, outputSHA s startDrain := !rq.active rq.active = true rq.mu.Unlock() + queuedSnapshot := r.snapshot() if startDrain { go rm.drain(rq) } - return r.snapshot(), nil + return queuedSnapshot, nil } func (rm *RunManager) BeginShutdown() error { From d844fb919d50a6a0ed8d0796761492228c4cf8a0 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 17 Aug 2026 00:45:38 -0400 Subject: [PATCH 50/53] docs: record final containment and submission validation --- docs/remediation/made-remediation-p1p3b.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index f1d28b8..30860cf 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -112,6 +112,8 @@ The CI containment portability follow-up is `7e61074e03da212da04c517ccbb23a5e089 The read-only reviewer-mask follow-up is `0e8f4c41b1104c20bc4e40604ded49c37d65e9fb` with subject `fix: make reviewer masks read-only`. +The final containment and submission-identity follow-up is `f2bac112fd2b91eb3bd1396878da21273961cbe0` with subject `fix: harden containment and submit identity`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -196,7 +198,7 @@ The CI portability fix uses Go's platform-independent Unix-socket mode inspectio ## Validation evidence -The final executable source SHA covered by this validation section is `0e8f4c41b1104c20bc4e40604ded49c37d65e9fb`. +The final executable source SHA covered by this validation section is `f2bac112fd2b91eb3bd1396878da21273961cbe0`. The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. @@ -246,9 +248,9 @@ The full validation command was rerun at the final executable SHA `3fc98f031613e The full validation command was rerun at the final executable SHA `ed9009251e9754b137dc0719352b525aad880e1b`, and `/tmp/made-remediation-p1p3b-ed90092-validation.log` ends with `validation-ed90092=PASS` and the pinned lint result `0 issues.`. -At executable source SHA `0e8f4c41b1104c20bc4e40604ded49c37d65e9fb`, the deterministic full suite, race suite, build, vet, and pinned lint all passed; the focused containment, API, evidence, rebase, and review suites passed; and `TestHermeticCompatibility_RealMadeBinaryThroughConsigliereScript` passed with the real Consigliere script and strict external fakes. +At executable source SHA `f2bac112fd2b91eb3bd1396878da21273961cbe0`, the deterministic full suite, race suite, build, vet, and pinned lint all passed; the focused containment, API, evidence, rebase, and review suites passed; `TestRunSubmit_ExecutesGatePipeline` passed with a stable queued identity; and `TestHermeticCompatibility_RealMadeBinaryThroughConsigliereScript` passed with the real Consigliere script and strict external fakes. -The final Linux containment correction masks protected paths with non-writable `0555` tmpfs mounts, preventing a reviewer from recreating files at a masked source path. +The final Linux containment correction uses read-only binds from private empty directories inside the detached review clone, preventing a reviewer from recreating files at a masked source path or reaching a writable mask through `/tmp`. The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. @@ -318,7 +320,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority through the implementation SHA is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..0e8f4c41b1104c20bc4e40604ded49c37d65e9fb`, which reports the Made-only paths from the custody base. +The final changed-file authority through the implementation SHA is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..f2bac112fd2b91eb3bd1396878da21273961cbe0`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. From 463e5805d8ba4eac8d6e72e5315cfc43f2c7782b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 17 Aug 2026 00:50:19 -0400 Subject: [PATCH 51/53] fix: pin golangci action for lint v2 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 074dc50..a47795e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,6 @@ jobs: - run: go test ./... - run: go test -race -shuffle=on -count=1 ./... - run: go vet ./... - - uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6.5.2 + - uses: golangci/golangci-lint-action@9fae48acfc02a90574d7c304a1758ef9895495fa # v7.0.1 with: version: v2.11.2 From 1e5cafb3ae945b50fe5057cbafc2e4b9733244a3 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 17 Aug 2026 00:51:03 -0400 Subject: [PATCH 52/53] docs: record lint action compatibility --- docs/remediation/made-remediation-p1p3b.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index 30860cf..e118775 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -114,6 +114,8 @@ The read-only reviewer-mask follow-up is `0e8f4c41b1104c20bc4e40604ded49c37d65e9 The final containment and submission-identity follow-up is `f2bac112fd2b91eb3bd1396878da21273961cbe0` with subject `fix: harden containment and submit identity`. +The lint-action compatibility follow-up is `463e5805d8ba4eac8d6e72e5315cfc43f2c7782b` with subject `fix: pin golangci action for lint v2`. + Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. @@ -192,7 +194,7 @@ Pull-request GitHub authentication and API failures now remain infrastructure er The Made CI workflow validates the pinned Go version with race, vet, and pinned lint jobs. -The CI job installs the pinned-environment reviewer containment prerequisite `bubblewrap` and enables its documented setuid execution mode before running the Made tests. +The CI job installs the pinned-environment reviewer containment prerequisite `bubblewrap` and enables its documented setuid execution mode before running the Made tests, while the pinned lint action uses v7.0.1 for golangci-lint v2. The CI portability fix uses Go's platform-independent Unix-socket mode inspection instead of Darwin-only `stat -f` flags, and clean rebase execution supplies deterministic committer identity, disabled signing, and disabled hooks at the Git boundary. @@ -250,6 +252,8 @@ The full validation command was rerun at the final executable SHA `ed9009251e975 At executable source SHA `f2bac112fd2b91eb3bd1396878da21273961cbe0`, the deterministic full suite, race suite, build, vet, and pinned lint all passed; the focused containment, API, evidence, rebase, and review suites passed; `TestRunSubmit_ExecutesGatePipeline` passed with a stable queued identity; and `TestHermeticCompatibility_RealMadeBinaryThroughConsigliereScript` passed with the real Consigliere script and strict external fakes. +The final CI configuration pins `golangci/golangci-lint-action` v7.0.1 at `9fae48acfc02a90574d7c304a1758ef9895495fa` so the workflow accepts the required golangci-lint v2.11.2. + The final Linux containment correction uses read-only binds from private empty directories inside the detached review clone, preventing a reviewer from recreating files at a masked source path or reaching a writable mask through `/tmp`. The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. @@ -320,7 +324,7 @@ The first full validation exposed a WAL replay ordering race where a stale `succ Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. -The final changed-file authority through the implementation SHA is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..f2bac112fd2b91eb3bd1396878da21273961cbe0`, which reports the Made-only paths from the custody base. +The final changed-file authority through the delivery implementation and CI SHA is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..463e5805d8ba4eac8d6e72e5315cfc43f2c7782b`, which reports the Made-only paths from the custody base. At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. From 7f9348558d1e4f635afdb50883e5600c980498c1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 17 Aug 2026 00:56:54 -0400 Subject: [PATCH 53/53] docs: record direct PR delivery receipt --- docs/remediation/made-remediation-p1p3b.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md index e118775..461c9e6 100644 --- a/docs/remediation/made-remediation-p1p3b.md +++ b/docs/remediation/made-remediation-p1p3b.md @@ -336,8 +336,12 @@ This task explicitly forbids running `/made`, editing the Consigliere repository No Consigliere repository file, GitHub issue, default branch, merge, or shared daemon state was changed. -## Delivery dependency +## Delivery receipt -The remaining dependency after this report is the final exact-SHA review pass and direct PR on `cs/made-remediation-p1p3b` against `main`. +The application and CI delivery head immediately before this report-only receipt is `1e5cafb3ae945b50fe5057cbafc2e4b9733244a3` on `cs/made-remediation-p1p3b`. -The branch must be committed, pushed only to `origin/cs/made-remediation-p1p3b`, and opened as a direct PR before the Made lane reports done. +Direct PR [#1](https://github.com/douglasjarquin/made/pull/1) is open, non-draft, unmerged, and targets `main` at `3e19ed9d598a68149da5a73949533e8095ca4403`. + +Live CI run `31995872679` passed its required `build-test-lint` job at that exact delivery head. + +No merge, default-branch push, Consigliere edit, GitHub issue, or shared Made-daemon lifecycle action was performed.