diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db0dc412d..e73d889b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: set -euo pipefail mkdir -p dist DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - COMMIT="$(git rev-parse --short=7 HEAD)" + COMMIT="$(git rev-parse HEAD)" CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \ go build -ldflags "-X github.com/kunchenguid/no-mistakes/internal/buildinfo.Version=${TAG} -X github.com/kunchenguid/no-mistakes/internal/buildinfo.Commit=${COMMIT} -X github.com/kunchenguid/no-mistakes/internal/buildinfo.Date=${DATE} -X github.com/kunchenguid/no-mistakes/internal/buildinfo.TelemetryHost=${UMAMI_HOST} -X github.com/kunchenguid/no-mistakes/internal/buildinfo.TelemetryWebsiteID=${UMAMI_WEBSITE_ID}" \ -o "dist/no-mistakes" ./cmd/no-mistakes @@ -258,7 +258,7 @@ jobs: set -euo pipefail mkdir -p dist DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - COMMIT="$(git rev-parse --short=7 HEAD)" + COMMIT="$(git rev-parse HEAD)" BIN="no-mistakes" OUT="dist/${BIN}" if [ "$GOOS" = "windows" ]; then diff --git a/Makefile b/Makefile index 03d018d1a..3fa4de535 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) DEFAULT_UMAMI_HOST := https://a.kunchenguid.com DEFAULT_UMAMI_WEBSITE_ID := f959e889-92f5-4121-8a1f-571b10861198 diff --git a/docs/src/content/docs/concepts/daemon.md b/docs/src/content/docs/concepts/daemon.md index 41d716189..8e1904c91 100644 --- a/docs/src/content/docs/concepts/daemon.md +++ b/docs/src/content/docs/concepts/daemon.md @@ -115,7 +115,9 @@ reason about in one long-lived process than inside independent hook invocations. On startup, the daemon checks for runs that were left in `pending` or `running` status (which means the daemon crashed while they were active): - Completes legacy active rows whose persisted PR state is already `merged` or `closed`, including their CI step, before active-run recovery and parked-run planning -- Resumes only fully recorded parked approval gates whose worktree and step history can be validated; incomplete or ambiguous active runs fail closed +- Fails a protected `pending` row left between durable run protection and executor registration before any provider setup, restoring its sealed repository, branch, and submitted head so it cannot wedge a later run; likewise anchors the exact managed-worktree head before projecting an already-journaled signed cancellation without recreating an executor +- Keeps a public-key-bound protected gate compute-idle until a fresh controller-signed checkpoint verifies the externally retained decision-history head, then resumes only if its worktree and step history also validate +- Fails an unbound legacy parked gate closed while retaining its verified custody head; after restart, a missing local authority row cannot safely distinguish a real legacy run from a protected run whose same-UID workload deleted all protection rows - Before resuming a parked CI gate, re-checks its persisted PR URL through the configured provider; a currently merged or closed PR completes the stale gate, while an open, unknown, or unreachable PR remains parked - Preserves a run that was actively monitoring CI for an already-created PR as `ci_monitor_interrupted` rather than failing it: the PR is still open, so a restart mid-monitor is not a pipeline failure. That run is terminal and never resumed - Before failing any other stale active run, verifies its managed worktree head and pins an unpublished descendant under the run-specific recovery ref so later rerun or guarded custody recovery does not fall back to a stale gate branch diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index d0789fd5d..eb0ef63f1 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -107,6 +107,8 @@ no-mistakes axi run --intent "the user's goal" --yes | `--intent` | `string` | (none) | What the user set out to accomplish; required to start a new run | | `-y`, `--yes` | `bool` | `false` | Auto-resolve every gate until a decision point or outcome | | `--skip` | `string` | (none) | Comma-separated pipeline steps to skip | +| `--owner-decision-public-key` | `path` | (none) | Bind a new protected run to an Ed25519 public key | +| `--expected-owner-decision-head` | `sha256` | key-and-run-bound genesis | Controller-held initial history head for a new protected run | `--intent` is not a description of the diff. It is the user's goal or request, and no-mistakes uses it verbatim instead of transcript inference. @@ -120,6 +122,7 @@ Reattaching to an in-flight run can proceed while the daemon is already running Starting a fresh run also requires a runnable effective pipeline agent. If the configured native agent or ACP runner is unavailable, the run fails before any pipeline step starts instead of reporting command-only validation as a passed gate. With `--yes`, `axi run` treats both `action: auto-fix` and `action: ask-user` findings as standing consent for the pipeline to fix them by selecting every finding, then accepts the resulting fix review. +Protected runs refuse local `--yes`: putting the private key on the workload host would let the workload mint its own decisions. An external controller must export each challenge, sign it offline, and apply the resulting envelope. Gates with no findings or only `action: no-op` findings are approved as-is, and each step is fixed at most once so unresolved findings do not loop forever. Without `--yes`, an agent driving `axi run` should stop when a gate contains `action: ask-user` findings and relay each finding's ID, file, and full description to the user before responding. Review gates include a `note` field reminding agents that `auto_fix.review` defaults to `0`, so blocking and ask-user review findings park for a decision unless configuration explicitly opts back into review auto-fix. @@ -152,6 +155,7 @@ no-mistakes axi respond --action skip | `--instructions` | `string` | (none) | Guidance applied to selected findings | | `--add-finding` | `string` | (none) | JSON finding object to add and fix | | `-y`, `--yes` | `bool` | `false` | Auto-resolve every subsequent gate until a decision point or outcome | +| `--decision-file` | `path` | (none) | Apply one controller-signed protected-run envelope | After the explicit response, `--yes` uses the same auto-resolution behavior as `axi run --yes`: have the pipeline fix `auto-fix` and `ask-user` findings once, approve the fix review, approve gates that only contain non-actionable `no-op` findings, and stop at `outcome: checks-passed` when the CI monitor reports readiness but the PR still needs a human merge. Each `axi respond` blocks until the next gate, CI-ready decision point, or final outcome. @@ -159,6 +163,53 @@ If it returns another `gate:`, answer that gate; do not idle-wait for the run to When the daemon is already running, `axi respond` can continue an active run even if the global config file has become invalid, because it is not starting a fresh run. The same successful-output reporting instructions apply to `axi respond` results. +## no-mistakes axi owner-decision + +Protected runs keep the private key outside the daemon, database, run worktree, and pipeline-agent process. +Create a controller key pair once, then bind a new run with the public key: + +```sh +no-mistakes axi owner-decision keygen --private-key owner.key --public-key owner.pub +no-mistakes axi run --intent "the user's goal" --owner-decision-public-key owner.pub +``` + +At a gate, sign and apply the exact canonical challenge: + +```sh +# Workload host: export, then transfer only challenge.json to the controller. +no-mistakes axi owner-decision challenge --run --purpose respond --out challenge.json + +# Controller host: this command is offline and never connects to the daemon. +no-mistakes axi owner-decision sign --challenge-file challenge.json --private-key owner.key --action approve --out decision.json + +# Workload host: transfer back only decision.json and apply it. +no-mistakes axi respond --decision-file decision.json +``` + +Use `--action fix --findings F1,F2`, `--instructions`, and `--add-finding` on `owner-decision sign` to bind the full fix choice into the signature. +Unsigned `axi respond`, TUI responses, and local `--yes` resolution are rejected for protected runs. +Never copy `owner.key` to the workload host or pass it in a workload command. + +The signed history is appended and its round projection is committed in one SQLite transaction before execution resumes. +For journaled `respond` and `cancel` envelopes, each externally retained history head hashes the prior head and exact signed envelope, so the offline signer reports a `next_head` the controller can derive and retain before submission. A `checkpoint` is admission proof rather than a journal event, so its signer output reports the unchanged `history_head` and never a `next_head`. +The daemon separately requires the materialized round projection to match that envelope and the immutable signed findings digest byte for byte. +The initial head commits to the exact public key, repository ID, branch, and submitted head. Every challenge repeats that sealed identity and separately carries the current gate head, so replacing a local authority/run row or replaying a decision against another current head cannot preserve the controller-held binding. + +After a daemon restart, a protected parked run remains compute-idle until the controller signs its externally retained head: + +```sh +no-mistakes axi owner-decision challenge --run --purpose checkpoint --expected-head --out checkpoint-challenge.json +no-mistakes axi owner-decision sign --challenge-file checkpoint-challenge.json --private-key owner.key --out checkpoint.json +no-mistakes axi owner-decision checkpoint --run --decision-file checkpoint.json +``` + +Offline signing accepts a private key only from a directly opened regular file owned by the current user with exact mode `0600`; it refuses final-path symlinks and oversized key files. Secure private-key loading currently fails closed on Windows, where POSIX mode bits cannot prove an owner-only ACL. + +The checkpoint challenge carries a fresh unpredictable nonce created for that exact daemon recovery. It expires within 15 minutes and cannot be replayed after another restart. Challenge export itself verifies the supplied controller-held head against the complete local history before emitting anything to sign. The controller must also compare the sealed repository, branch, submitted head, and current gate head with its own run record before signing. + +An unbound legacy parked run cannot be distinguished from a protected run whose same-UID workload deleted every local protection row. +It therefore fails closed on restart and retains its verified head for custody recovery instead of auto-resuming. + ## no-mistakes axi status Show a run, preferring the current branch's active or most recent run before falling back to repo-wide active or recent runs. @@ -173,6 +224,7 @@ no-mistakes axi status --run | `--run` | `string` | resolved run | Inspect a specific run ID | When the resolved run is parked at an `awaiting_approval` or `fix_review` gate, its top-level `run:` object includes `awaiting_agent: parked ` immediately after `status`. +Protected run objects also include `owner_decision_protected: true` and the complete `owner_decision_head`; an external controller should compare that value with the head it independently derives and retains, never replace its checkpoint from an untrusted local read after an ambiguous failure. The field disappears after `axi respond`, on cancel, and on terminal outcomes; use it to distinguish a run waiting for the driving agent from one actively running, fixing, or watching CI. When the resolved run has a `running` or `fixing` step, the run object includes `active_steps`. Each row reports how long the step has been active, the latest meaningful log or native-agent lifecycle activity, the native agent PID if one is currently running, and the current round such as `round 1`, `auto-fix 1/3`, or `fix 2`. @@ -271,6 +323,7 @@ no-mistakes axi abort --run ``` `--run` does not need a repo, branch, or worktree, so it works from anywhere. +Protected runs require an exported cancel challenge and an offline signed cancellation envelope, applied with `axi abort --run --decision-file `. Use it to reap an orphaned CI monitor whose worktree was torn down before the PR merged - the run id is shown in `axi run` output and in the `axi` home view. A `--run` id that is not currently active is resolved against the exact run's durable record rather than trusted blindly: a known already-terminal run returns an idempotent success carrying its terminal `run_status` with no fabricated new cancellation, a positively proven unknown id keeps the documented successful no-op with no fabricated state, and a run that is recorded as still nonterminal or cannot be read returns the nonzero terminal-unconfirmed contract. When the daemon is not running, nothing can be cancelled and abort never starts one: the durable record alone decides the same three outcomes, and a recorded nonterminal run reports that cancellation could not be requested. diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 508768bf0..913541c24 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -5,7 +5,7 @@ import "runtime/debug" // Set via ldflags at build time: // // -ldflags "-X github.com/kunchenguid/no-mistakes/internal/buildinfo.Version=v1.0.0 -// -X github.com/kunchenguid/no-mistakes/internal/buildinfo.Commit=abc1234 +// -X github.com/kunchenguid/no-mistakes/internal/buildinfo.Commit=<40-hex-source-revision> // -X github.com/kunchenguid/no-mistakes/internal/buildinfo.Date=2024-01-01 // -X github.com/kunchenguid/no-mistakes/internal/buildinfo.TelemetryHost=https://a.example.com // -X github.com/kunchenguid/no-mistakes/internal/buildinfo.TelemetryWebsiteID=abc123" diff --git a/internal/cli/axi.go b/internal/cli/axi.go index 124e1b2dc..83e44a69c 100644 --- a/internal/cli/axi.go +++ b/internal/cli/axi.go @@ -49,6 +49,7 @@ func newAxiCmd() *cobra.Command { cmd.AddCommand(newAxiSyncCmd()) cmd.AddCommand(newAxiLogsCmd()) cmd.AddCommand(newAxiAbortCmd()) + cmd.AddCommand(newAxiOwnerDecisionCmd()) return cmd } diff --git a/internal/cli/axi_drive.go b/internal/cli/axi_drive.go index 0805b0708..4c6f79fa3 100644 --- a/internal/cli/axi_drive.go +++ b/internal/cli/axi_drive.go @@ -17,6 +17,7 @@ import ( "github.com/kunchenguid/no-mistakes/internal/gate" "github.com/kunchenguid/no-mistakes/internal/git" "github.com/kunchenguid/no-mistakes/internal/ipc" + "github.com/kunchenguid/no-mistakes/internal/ownerdecision" "github.com/kunchenguid/no-mistakes/internal/paths" "github.com/kunchenguid/no-mistakes/internal/telemetry" "github.com/kunchenguid/no-mistakes/internal/types" @@ -58,6 +59,7 @@ func newAxiRunCmd() *cobra.Command { var autoYes bool var skipValue string var intent string + var ownerPublicKey, ownerExpectedHead string cmd := &cobra.Command{ Use: "run", @@ -89,17 +91,23 @@ func newAxiRunCmd() *cobra.Command { return emitError(cmd, 2, err.Error(), "Valid steps: intent, rebase, review, test, document, lint, push, pr, ci") } - return runAxiRun(cmd, autoYes, skipSteps, intent) + return runAxiRunProtected(cmd, autoYes, skipSteps, intent, ownerPublicKey, ownerExpectedHead) }) }, } cmd.Flags().BoolVarP(&autoYes, "yes", "y", false, "auto-resolve every gate (fix findings, then accept) until a decision point or outcome") cmd.Flags().StringVar(&skipValue, "skip", "", "comma-separated pipeline steps to skip") cmd.Flags().StringVar(&intent, "intent", "", "what the user set out to accomplish (not a description of the diff); used instead of inferring from transcripts (required to start a run)") + cmd.Flags().StringVar(&ownerPublicKey, "owner-decision-public-key", "", "Ed25519 public-key file for a new protected run") + cmd.Flags().StringVar(&ownerExpectedHead, "expected-owner-decision-head", "", "controller-held history head (new protected runs require genesis)") return cmd } func runAxiRun(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, intent string) error { + return runAxiRunProtected(cmd, autoYes, skipSteps, intent, "", "") +} + +func runAxiRunProtected(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, intent, publicKeyPath, expectedHead string) error { ctx := cmd.Context() env, err := openAxiRunEnv() if err != nil { @@ -120,6 +128,10 @@ func runAxiRun(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, int if err != nil { return emitError(cmd, 1, fmt.Sprintf("get current HEAD: %v", err)) } + ownerConfig, err := loadOwnerRunProtection(publicKeyPath, expectedHead, env.repo.ID, branch, headSHA) + if err != nil { + return emitError(cmd, 2, err.Error()) + } runID := activeRunID(env, branch, headSHA) if runID == "" { @@ -142,7 +154,7 @@ func runAxiRun(cmd *cobra.Command, autoYes bool, skipSteps []types.StepName, int return guard(cmd) } var err error - runID, err = triggerRun(ctx, env, branch, headSHA, skipSteps, intent) + runID, err = triggerRunWithOwnerDecision(ctx, env, branch, headSHA, skipSteps, intent, ownerConfig) if err != nil { if ownershipErr, ok := err.(*branchOwnershipError); ok { return emitBranchOwnershipError(cmd, ownershipErr) @@ -290,10 +302,19 @@ func freshRunBranchOwnershipState(ctx context.Context, env *axiEnv) *branchsync. // no-op (the gate already had this commit). Callers must check for an existing // active run first (see activeRunID) and apply pre-flight guards. func triggerRun(ctx context.Context, env *axiEnv, branch, headSHA string, skipSteps []types.StepName, intent string) (string, error) { + return triggerRunWithOwnerDecision(ctx, env, branch, headSHA, skipSteps, intent, nil) +} + +func triggerRunWithOwnerDecision(ctx context.Context, env *axiEnv, branch, headSHA string, skipSteps []types.StepName, intent string, ownerConfig *ipc.OwnerDecisionRunConfig) (string, error) { pushOptions := formatSkipPushOptions(skipSteps) if opt := formatIntentPushOption(intent); opt != "" { pushOptions = append(pushOptions, opt) } + if opt, err := formatOwnerDecisionPushOption(ownerConfig); err != nil { + return "", fmt.Errorf("encode owner-decision run binding: %w", err) + } else if opt != "" { + pushOptions = append(pushOptions, opt) + } priorRunIDs, err := runIDsForHead(env.client, env.repo.ID, branch, headSHA) if err != nil { // An active run can still be found below. Without a baseline, however, @@ -323,7 +344,9 @@ func triggerRun(ctx context.Context, env *axiEnv, branch, headSHA string, skipSt // No run appeared: the push was likely up-to-date. Rerun the latest gate // head so `axi run` is still useful when there are no new commits. var rr ipc.RerunResult - if err := env.client.Call(ipc.MethodRerun, rerunParams(env.repo.ID, branch, skipSteps, intent), &rr); err != nil { + rerun := rerunParams(env.repo.ID, branch, skipSteps, intent) + rerun.OwnerDecision = ownerConfig + if err := env.client.Call(ipc.MethodRerun, rerun, &rr); err != nil { return "", fmt.Errorf("no run started for %q: %v", branch, err) } return rr.RunID, nil @@ -467,7 +490,9 @@ func driveRunWithReconciler(ctx context.Context, progress io.Writer, client *ipc if action == types.ActionFix { fixedSteps[gate.Name] = true } - if err := sendRespond(client, runID, types.StepName(gate.Name), action, findingIDs, nil, nil); err != nil { + if run.OwnerDecisionProtected { + return nil, false, fmt.Errorf("auto-resolve %s: protected runs require an externally signed decision; refusing to load a private key beside the workload", gate.Name) + } else if err := sendRespond(client, runID, types.StepName(gate.Name), action, findingIDs, nil, nil); err != nil { return nil, false, fmt.Errorf("auto-resolve %s: %w", gate.Name, err) } pendingGate = gateKey @@ -576,6 +601,17 @@ func sendRespond(client *ipc.Client, runID string, step types.StepName, action t return nil } +func sendSignedRespond(client *ipc.Client, runID string, envelope ownerdecision.Envelope) error { + var result ipc.RespondResult + if err := client.Call(ipc.MethodRespond, &ipc.RespondParams{RunID: runID, Decision: &envelope}, &result); err != nil { + return err + } + if !result.OK { + return fmt.Errorf("daemon rejected the signed response") + } + return nil +} + // renderDriveResult prints the run snapshot plus one of: the active gate (exit // 0, a normal decision point), a checks-passed outcome (exit 0, CI readiness is // established by green checks or the trusted no_ci declaration and the PR is @@ -687,7 +723,7 @@ func successReportHelp(fixes []fixRow) []string { } func newAxiRespondCmd() *cobra.Command { - var action, step, findings, instructions, addFinding string + var action, step, findings, instructions, addFinding, decisionFile string var autoYes bool cmd := &cobra.Command{ @@ -710,6 +746,7 @@ func newAxiRespondCmd() *cobra.Command { findings: findings, instructions: instructions, addFinding: addFinding, + decisionFile: decisionFile, autoYes: autoYes, }) }) @@ -720,6 +757,7 @@ func newAxiRespondCmd() *cobra.Command { cmd.Flags().StringVar(&findings, "findings", "", "comma-separated finding IDs to fix (with --action fix)") cmd.Flags().StringVar(&instructions, "instructions", "", "guidance applied to the selected findings (with --action fix)") cmd.Flags().StringVar(&addFinding, "add-finding", "", "JSON finding object to add and fix (with --action fix)") + cmd.Flags().StringVar(&decisionFile, "decision-file", "", "controller-signed owner-decision envelope (protected runs)") cmd.Flags().BoolVarP(&autoYes, "yes", "y", false, "auto-resolve every subsequent gate until a decision point or outcome") return cmd } @@ -730,6 +768,7 @@ type respondArgs struct { findings string instructions string addFinding string + decisionFile string autoYes bool } @@ -737,14 +776,18 @@ func runAxiRespond(cmd *cobra.Command, ra respondArgs) error { ctx := cmd.Context() act := types.ApprovalAction(strings.TrimSpace(ra.action)) - switch act { - case types.ActionApprove, types.ActionFix, types.ActionSkip: - case "": - return emitError(cmd, 2, "--action is required", - "Run `no-mistakes axi respond --action approve|fix|skip`") - default: - return emitError(cmd, 2, fmt.Sprintf("unknown action %q", ra.action), - "Valid actions: approve, fix, skip") + if ra.decisionFile == "" { + switch act { + case types.ActionApprove, types.ActionFix, types.ActionSkip: + case "": + return emitError(cmd, 2, "--action is required", + "Run `no-mistakes axi respond --action approve|fix|skip`") + default: + return emitError(cmd, 2, fmt.Sprintf("unknown action %q", ra.action), + "Valid actions: approve, fix, skip") + } + } else if ra.action != "" || ra.step != "" || ra.findings != "" || ra.instructions != "" || ra.addFinding != "" || ra.autoYes { + return emitError(cmd, 2, "--decision-file cannot be combined with legacy response fields or --yes") } env, err := openAxiDaemonEnv() @@ -766,6 +809,27 @@ func runAxiRespond(cmd *cobra.Command, ra respondArgs) error { "Run `no-mistakes axi run --intent \"...\"` to start one") } runID := active.Run.ID + if ra.decisionFile != "" { + envelope, err := readOwnerDecisionEnvelope(ra.decisionFile) + if err != nil { + return emitError(cmd, 1, err.Error()) + } + if envelope.Challenge.RunID != runID || envelope.Challenge.Purpose != ownerdecision.PurposeRespond { + return emitError(cmd, 1, "signed decision does not belong to the active response gate") + } + if err := sendSignedRespond(env.client, runID, envelope); err != nil { + return emitError(cmd, 1, fmt.Sprintf("respond with signed decision: %v", err)) + } + view := runViewFromIPC(active.Run) + if err := waitStepLeavesGate(ctx, env.p.Socket(), runID, string(envelope.Challenge.Step), gateStatusFor(view, string(envelope.Challenge.Step))); err != nil { + return emitError(cmd, 1, fmt.Sprintf("wait for %s: %v", envelope.Challenge.Step, err)) + } + final, ciReady, err := driveRun(ctx, cmd.ErrOrStderr(), env.client, env.p.Socket(), runID, false) + if err != nil { + return emitError(cmd, 1, fmt.Sprintf("drive run: %v", err)) + } + return renderDriveResult(cmd, final, ciReady) + } run, err := getRunInfo(env.client, runID) if err != nil || run == nil { @@ -838,7 +902,7 @@ func gateStatusFor(rv runView, step string) string { } func newAxiAbortCmd() *cobra.Command { - var runID string + var runID, decisionFile string cmd := &cobra.Command{ Use: "abort", Short: "Cancel the active pipeline run", @@ -856,16 +920,20 @@ func newAxiAbortCmd() *cobra.Command { SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { return trackAxiSurface("axi-abort", "/axi/abort", nil, func() error { - return runAxiAbort(cmd, strings.TrimSpace(runID)) + return runAxiAbort(cmd, strings.TrimSpace(runID), strings.TrimSpace(decisionFile)) }) }, } cmd.Flags().StringVar(&runID, "run", "", "cancel this run id directly, without resolving the current branch or worktree") + cmd.Flags().StringVar(&decisionFile, "decision-file", "", "controller-signed cancellation envelope (protected runs)") return cmd } -func runAxiAbort(cmd *cobra.Command, runID string) error { +func runAxiAbort(cmd *cobra.Command, runID, decisionFile string) error { if runID != "" { + if decisionFile != "" { + return runAxiSignedAbortByRunID(cmd, runID, decisionFile) + } return runAxiAbortByRunID(cmd, runID) } @@ -900,8 +968,19 @@ func runAxiAbort(cmd *cobra.Command, runID string) error { return nil } + params := &ipc.CancelRunParams{RunID: active.Run.ID} + if decisionFile != "" { + envelope, err := readOwnerDecisionEnvelope(decisionFile) + if err != nil { + return emitError(cmd, 1, err.Error()) + } + if envelope.Challenge.RunID != active.Run.ID || envelope.Challenge.Purpose != ownerdecision.PurposeCancel { + return emitError(cmd, 1, "signed cancellation does not belong to the active run") + } + params.Decision = &envelope + } var result ipc.CancelRunResult - if err := env.client.Call(ipc.MethodCancelRun, &ipc.CancelRunParams{RunID: active.Run.ID}, &result); err != nil { + if err := env.client.Call(ipc.MethodCancelRun, params, &result); err != nil { return emitError(cmd, 1, fmt.Sprintf("abort run: %v", err)) } // Success and the final ownership state may only be reported after the @@ -1036,6 +1115,42 @@ func emitUnconfirmedAbort(cmd *cobra.Command, runID, branch, reason string, last return &exitError{code: 1} } +func runAxiSignedAbortByRunID(cmd *cobra.Command, runID, decisionFile string) error { + p, err := paths.New() + if err != nil { + return emitError(cmd, 1, fmt.Sprintf("resolve paths: %v", err)) + } + if alive, _ := daemon.IsRunning(p); !alive { + return emitError(cmd, 1, "daemon is not running; a signed cancellation was not applied") + } + client, err := ipc.Dial(p.Socket()) + if err != nil { + return emitError(cmd, 1, fmt.Sprintf("connect to daemon: %v", err)) + } + defer client.Close() + envelope, err := readOwnerDecisionEnvelope(decisionFile) + if err != nil { + return emitError(cmd, 1, err.Error()) + } + if envelope.Challenge.RunID != runID || envelope.Challenge.Purpose != ownerdecision.PurposeCancel { + return emitError(cmd, 1, "signed cancellation does not belong to the requested run") + } + var result ipc.CancelRunResult + if err := client.Call(ipc.MethodCancelRun, &ipc.CancelRunParams{RunID: runID, Decision: &envelope}, &result); err != nil { + return emitError(cmd, 1, fmt.Sprintf("abort run: %v", err)) + } + final, confirmed, reason := waitForTerminalRun(cmd.Context(), client, runID, abortStateWaitTimeout) + if !confirmed { + return emitUnconfirmedAbort(cmd, runID, "", reason, runViewPtrFromIPC(final), true) + } + emitDoc(cmd, + toon.Field{Key: "aborted", Value: true}, + toon.Field{Key: "run", Value: runID}, + toon.Field{Key: "run_status", Value: string(final.Status)}, + ) + return nil +} + // runAxiAbortByRunID cancels a run by its id directly via the daemon, without // resolving a repo, branch, or worktree. This is how an orphaned monitor run - // one whose worktree was torn down before the PR merged - gets reaped from diff --git a/internal/cli/axi_drive_test.go b/internal/cli/axi_drive_test.go index 487ba3027..286fd1e1e 100644 --- a/internal/cli/axi_drive_test.go +++ b/internal/cli/axi_drive_test.go @@ -20,6 +20,27 @@ import ( "github.com/kunchenguid/no-mistakes/internal/types" ) +func TestDriveRunProtectedYoloRefusesToLoadPrivateKeyBesideWorkload(t *testing.T) { + events := make(chan ipc.Event, 1) + source := &scriptedRunStateSource{ + subscriptions: []scriptedSubscription{{events: events}}, + runs: []*ipc.RunInfo{ + {ID: "run-protected", RepoID: "repo-1", Branch: "feature/x", HeadSHA: "head-1", Status: types.RunRunning, OwnerDecisionProtected: true, + Steps: []ipc.StepResultInfo{{ID: "step-1", StepName: types.StepReview, Status: types.StepStatusAwaitingApproval, FindingsJSON: ownerTestStringPtr(`{"findings":[]}`)}}, + }, + {ID: "run-protected", RepoID: "repo-1", Branch: "feature/x", HeadSHA: "head-1", Status: types.RunCompleted, OwnerDecisionProtected: true}, + }, + } + reconciler := newRunReconciler(source, "run-protected") + defer reconciler.Close() + _, _, err := driveRunWithReconciler(context.Background(), io.Discard, nil, reconciler, "run-protected", true) + if err == nil || !strings.Contains(err.Error(), "externally signed decision") { + t.Fatalf("protected yolo error = %v", err) + } +} + +func ownerTestStringPtr(value string) *string { return &value } + func ciRunView(ciStatus types.StepStatus) runView { return runView{ ID: "run-1", diff --git a/internal/cli/axi_owner_decision.go b/internal/cli/axi_owner_decision.go new file mode 100644 index 000000000..e0169d58e --- /dev/null +++ b/internal/cli/axi_owner_decision.go @@ -0,0 +1,411 @@ +package cli + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/kunchenguid/no-mistakes/internal/ipc" + "github.com/kunchenguid/no-mistakes/internal/ownerdecision" + "github.com/kunchenguid/no-mistakes/internal/paths" + "github.com/kunchenguid/no-mistakes/internal/types" + "github.com/spf13/cobra" +) + +func newAxiOwnerDecisionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "owner-decision", + Short: "Create and apply controller-signed decisions for protected runs", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + } + cmd.AddCommand(newAxiOwnerDecisionKeygenCmd()) + cmd.AddCommand(newAxiOwnerDecisionChallengeCmd()) + cmd.AddCommand(newAxiOwnerDecisionSignCmd()) + cmd.AddCommand(newAxiOwnerDecisionCheckpointCmd()) + return cmd +} + +func newAxiOwnerDecisionKeygenCmd() *cobra.Command { + var privatePath, publicPath string + cmd := &cobra.Command{ + Use: "keygen", + Short: "Generate an Ed25519 controller key pair without exposing the private key to the daemon", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(privatePath) == "" || strings.TrimSpace(publicPath) == "" { + return emitError(cmd, 2, "--private-key and --public-key are required") + } + if privatePath == publicPath { + return emitError(cmd, 2, "private and public key paths must differ") + } + if fileExists(privatePath) || fileExists(publicPath) { + return emitError(cmd, 1, "refusing to overwrite an existing owner-decision key file") + } + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return emitError(cmd, 1, fmt.Sprintf("generate owner-decision key: %v", err)) + } + privateEncoded, _ := ownerdecision.EncodePrivateKey(privateKey) + publicEncoded, _ := ownerdecision.EncodePublicKey(publicKey) + if err := writeExclusive(privatePath, []byte(privateEncoded+"\n"), 0o600); err != nil { + return emitError(cmd, 1, fmt.Sprintf("write private key: %v", err)) + } + if err := writeExclusive(publicPath, []byte(publicEncoded+"\n"), 0o644); err != nil { + _ = os.Remove(privatePath) + return emitError(cmd, 1, fmt.Sprintf("write public key: %v", err)) + } + fmt.Fprintf(cmd.OutOrStdout(), "private_key: %s\npublic_key: %s\n", privatePath, publicPath) + return nil + }, + } + cmd.Flags().StringVar(&privatePath, "private-key", "", "new private-key file (created mode 0600; never overwritten)") + cmd.Flags().StringVar(&publicPath, "public-key", "", "new public-key file (never overwritten)") + return cmd +} + +func newAxiOwnerDecisionChallengeCmd() *cobra.Command { + var runID, purpose, expectedHead, outputPath string + cmd := &cobra.Command{ + Use: "challenge", + Short: "Export the exact active challenge for signing outside the workload host", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if runID == "" { + return emitError(cmd, 2, "--run is required") + } + client, closeClient, err := openOwnerDecisionClient() + if err != nil { + return emitError(cmd, 1, err.Error()) + } + defer closeClient() + challenge, err := ownerChallengeForExport(client, runID, purpose, expectedHead) + if err != nil { + return emitError(cmd, 1, err.Error()) + } + encoded, err := json.MarshalIndent(challenge, "", " ") + if err != nil { + return emitError(cmd, 1, err.Error()) + } + encoded = append(encoded, '\n') + if outputPath == "" { + _, err = cmd.OutOrStdout().Write(encoded) + return err + } + if err := writeExclusive(outputPath, encoded, 0o644); err != nil { + return emitError(cmd, 1, fmt.Sprintf("write owner-decision challenge: %v", err)) + } + fmt.Fprintf(cmd.OutOrStdout(), "challenge: %s\n", outputPath) + return nil + }, + } + cmd.Flags().StringVar(&runID, "run", "", "protected run id") + cmd.Flags().StringVar(&purpose, "purpose", ownerdecision.PurposeRespond, "respond | cancel | checkpoint") + cmd.Flags().StringVar(&expectedHead, "expected-head", "", "controller-held history head (required for checkpoint)") + cmd.Flags().StringVar(&outputPath, "out", "", "write a new challenge file instead of stdout (never overwrites)") + return cmd +} + +func newAxiOwnerDecisionSignCmd() *cobra.Command { + var challengePath, privatePath, action, findings, instructions, addFinding, outputPath string + cmd := &cobra.Command{ + Use: "sign", + Short: "Sign an exported challenge offline, without connecting to the workload daemon", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if challengePath == "" || privatePath == "" { + return emitError(cmd, 2, "--challenge-file and --private-key are required") + } + challenge, err := readOwnerDecisionChallenge(challengePath) + if err != nil { + return emitError(cmd, 1, err.Error()) + } + if err := validateChallengeForOfflineSigning(challenge, time.Now().UTC()); err != nil { + return emitError(cmd, 1, err.Error()) + } + privateKey, err := readOwnerPrivateKey(privatePath) + if err != nil { + return emitError(cmd, 1, err.Error()) + } + response, err := ownerResponseForSign(challenge.Purpose, action, findings, instructions, addFinding) + if err != nil { + return emitError(cmd, 2, err.Error()) + } + envelope, err := ownerdecision.Sign(privateKey, challenge, response) + if err != nil { + return emitError(cmd, 1, err.Error()) + } + encoded, err := json.MarshalIndent(envelope, "", " ") + if err != nil { + return emitError(cmd, 1, err.Error()) + } + encoded = append(encoded, '\n') + if outputPath == "" { + _, err = cmd.OutOrStdout().Write(encoded) + return err + } + if err := writeExclusive(outputPath, encoded, 0o644); err != nil { + return emitError(cmd, 1, fmt.Sprintf("write decision envelope: %v", err)) + } + if challenge.Purpose == ownerdecision.PurposeCheckpoint { + fmt.Fprintf(cmd.OutOrStdout(), "decision: %s\nhistory_head: %s\n", outputPath, challenge.PreviousHead) + return nil + } + envelopeDigest, err := ownerdecision.EnvelopeDigest(envelope) + if err != nil { + return emitError(cmd, 1, err.Error()) + } + nextHead, err := ownerdecision.NextHead(challenge.PreviousHead, envelopeDigest) + if err != nil { + return emitError(cmd, 1, err.Error()) + } + fmt.Fprintf(cmd.OutOrStdout(), "decision: %s\nnext_head: %s\n", outputPath, nextHead) + return nil + }, + } + cmd.Flags().StringVar(&challengePath, "challenge-file", "", "exported canonical challenge JSON") + cmd.Flags().StringVar(&privatePath, "private-key", "", "controller private-key file") + cmd.Flags().StringVar(&action, "action", "", "approve | fix | skip | abort (respond only)") + cmd.Flags().StringVar(&findings, "findings", "", "comma-separated finding IDs (fix only)") + cmd.Flags().StringVar(&instructions, "instructions", "", "guidance for selected findings (fix only)") + cmd.Flags().StringVar(&addFinding, "add-finding", "", "JSON finding object (fix only)") + cmd.Flags().StringVar(&outputPath, "out", "", "write a new envelope file instead of stdout (never overwrites)") + return cmd +} + +func validateChallengeForOfflineSigning(challenge ownerdecision.Challenge, now time.Time) error { + if err := ownerdecision.ValidateChallenge(challenge); err != nil { + return err + } + if now.Unix() < challenge.IssuedAt { + return errors.New("owner decision: refusing to sign a future-issued challenge") + } + if now.Unix() >= challenge.ExpiresAt { + return errors.New("owner decision: refusing to sign an expired challenge") + } + return nil +} + +func newAxiOwnerDecisionCheckpointCmd() *cobra.Command { + var runID, decisionPath string + cmd := &cobra.Command{ + Use: "checkpoint", + Short: "Resume a protected run after restart using a signed external-head checkpoint", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if runID == "" || decisionPath == "" { + return emitError(cmd, 2, "--run and --decision-file are required") + } + envelope, err := readOwnerDecisionEnvelope(decisionPath) + if err != nil { + return emitError(cmd, 1, err.Error()) + } + client, closeClient, err := openOwnerDecisionClient() + if err != nil { + return emitError(cmd, 1, err.Error()) + } + defer closeClient() + var result ipc.OwnerDecisionCheckpointResult + if err := client.Call(ipc.MethodOwnerDecisionCheckpoint, &ipc.OwnerDecisionCheckpointParams{RunID: runID, Decision: envelope}, &result); err != nil { + return emitError(cmd, 1, err.Error()) + } + fmt.Fprintf(cmd.OutOrStdout(), "checkpointed: %t\nrun: %s\n", result.OK, runID) + return nil + }, + } + cmd.Flags().StringVar(&runID, "run", "", "protected run id") + cmd.Flags().StringVar(&decisionPath, "decision-file", "", "signed checkpoint envelope") + return cmd +} + +func ownerChallengeForExport(client *ipc.Client, runID, purpose, expectedHead string) (ownerdecision.Challenge, error) { + switch purpose { + case ownerdecision.PurposeRespond, ownerdecision.PurposeCancel: + var result ipc.OwnerDecisionChallengeResult + if err := client.Call(ipc.MethodOwnerDecisionChallenge, &ipc.OwnerDecisionChallengeParams{RunID: runID, Purpose: purpose}, &result); err != nil { + return ownerdecision.Challenge{}, err + } + return result.Challenge, nil + case ownerdecision.PurposeCheckpoint: + if expectedHead == "" { + return ownerdecision.Challenge{}, errors.New("--expected-head is required for a checkpoint") + } + var result ipc.OwnerDecisionChallengeResult + if err := client.Call(ipc.MethodOwnerDecisionChallenge, &ipc.OwnerDecisionChallengeParams{RunID: runID, Purpose: purpose, ExpectedHead: expectedHead}, &result); err != nil { + return ownerdecision.Challenge{}, err + } + return result.Challenge, nil + default: + return ownerdecision.Challenge{}, fmt.Errorf("unknown purpose %q", purpose) + } +} + +func ownerResponseForSign(purpose, action, findings, instructions, addFinding string) (ownerdecision.Response, error) { + response := ownerdecision.Response{} + switch purpose { + case ownerdecision.PurposeCancel: + response.Action = types.ActionAbort + return response, nil + case ownerdecision.PurposeCheckpoint: + response.Action = types.ActionApprove + return response, nil + case ownerdecision.PurposeRespond: + default: + return response, fmt.Errorf("unknown purpose %q", purpose) + } + response.Action = types.ApprovalAction(strings.TrimSpace(action)) + switch response.Action { + case types.ActionApprove, types.ActionSkip, types.ActionAbort: + if findings != "" || instructions != "" || addFinding != "" { + return response, errors.New("finding fields require --action fix") + } + return response, nil + case types.ActionFix: + response.FindingIDs = splitCSV(findings) + if note := strings.TrimSpace(instructions); note != "" { + response.Instructions = make(map[string]string, len(response.FindingIDs)) + for _, id := range response.FindingIDs { + response.Instructions[id] = note + } + } + if addFinding != "" { + finding, err := parseAddFinding(addFinding) + if err != nil { + return response, err + } + response.AddedFindings = []types.Finding{finding} + } + if len(response.FindingIDs) == 0 && len(response.AddedFindings) == 0 { + return response, errors.New("fix requires --findings or --add-finding") + } + return response, nil + default: + return response, errors.New("--action approve|fix|skip|abort is required for respond") + } +} + +func readOwnerPrivateKey(path string) (ed25519.PrivateKey, error) { + encoded, err := readOwnerPrivateKeyFile(path) + if err != nil { + return nil, fmt.Errorf("read owner-decision private key: %w", err) + } + return ownerdecision.ParsePrivateKey(encoded) +} + +func loadOwnerRunProtection(publicPath, expectedHead, repoID, branch, initialHeadSHA string) (*ipc.OwnerDecisionRunConfig, error) { + if publicPath == "" { + if expectedHead != "" { + return nil, errors.New("--expected-owner-decision-head requires --owner-decision-public-key for a new run") + } + return nil, nil + } + publicKey, err := readOwnerPublicKey(publicPath) + if err != nil { + return nil, err + } + genesisHead, err := ownerdecision.GenesisHeadForRun(publicKey, repoID, branch, initialHeadSHA) + if err != nil { + return nil, err + } + if expectedHead == "" { + expectedHead = genesisHead + } + if expectedHead != genesisHead { + return nil, errors.New("a new protected run must use the public-key-and-run-bound genesis owner-decision history head") + } + encoded, err := ownerdecision.EncodePublicKey(publicKey) + if err != nil { + return nil, err + } + return &ipc.OwnerDecisionRunConfig{PublicKey: encoded, ExpectedHead: expectedHead}, nil +} + +func readOwnerPublicKey(path string) (ed25519.PublicKey, error) { + encoded, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read owner-decision public key: %w", err) + } + return ownerdecision.ParsePublicKey(string(encoded)) +} + +func readOwnerDecisionEnvelope(path string) (ownerdecision.Envelope, error) { + encoded, err := os.ReadFile(path) + if err != nil { + return ownerdecision.Envelope{}, fmt.Errorf("read owner-decision envelope: %w", err) + } + var envelope ownerdecision.Envelope + decoder := json.NewDecoder(strings.NewReader(string(encoded))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&envelope); err != nil { + return ownerdecision.Envelope{}, fmt.Errorf("decode owner-decision envelope: %w", err) + } + if err := requireJSONEOF(decoder); err != nil { + return ownerdecision.Envelope{}, fmt.Errorf("decode owner-decision envelope: %w", err) + } + return envelope, nil +} + +func readOwnerDecisionChallenge(path string) (ownerdecision.Challenge, error) { + encoded, err := os.ReadFile(path) + if err != nil { + return ownerdecision.Challenge{}, fmt.Errorf("read owner-decision challenge: %w", err) + } + var challenge ownerdecision.Challenge + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&challenge); err != nil { + return ownerdecision.Challenge{}, fmt.Errorf("decode owner-decision challenge: %w", err) + } + if err := requireJSONEOF(decoder); err != nil { + return ownerdecision.Challenge{}, fmt.Errorf("decode owner-decision challenge: %w", err) + } + return challenge, nil +} + +func requireJSONEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return errors.New("multiple JSON values are not allowed") + } + return err + } + return nil +} + +func openOwnerDecisionClient() (*ipc.Client, func(), error) { + p, err := paths.New() + if err != nil { + return nil, func() {}, err + } + client, err := ipc.Dial(p.Socket()) + if err != nil { + return nil, func() {}, fmt.Errorf("connect to daemon: %w", err) + } + return client, func() { _ = client.Close() }, nil +} + +func writeExclusive(path string, value []byte, mode os.FileMode) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + if _, err := file.Write(value); err != nil { + _ = file.Close() + return err + } + return file.Close() +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil || !os.IsNotExist(err) +} diff --git a/internal/cli/axi_owner_decision_test.go b/internal/cli/axi_owner_decision_test.go new file mode 100644 index 000000000..ccba55bf4 --- /dev/null +++ b/internal/cli/axi_owner_decision_test.go @@ -0,0 +1,276 @@ +package cli + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/kunchenguid/no-mistakes/internal/ownerdecision" + "github.com/kunchenguid/no-mistakes/internal/types" +) + +func TestLoadOwnerRunProtectionBindsPublicKeyAndImmutableRunIdentity(t *testing.T) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + publicEncoded, _ := ownerdecision.EncodePublicKey(publicKey) + genesisHead, _ := ownerdecision.GenesisHeadForRun(publicKey, "repo-1", "feature", "abc123") + publicPath := filepath.Join(t.TempDir(), "owner.pub") + if err := os.WriteFile(publicPath, []byte(publicEncoded), 0o644); err != nil { + t.Fatal(err) + } + config, err := loadOwnerRunProtection(publicPath, "", "repo-1", "feature", "abc123") + if err != nil { + t.Fatal(err) + } + if config == nil || config.ExpectedHead != genesisHead || config.PublicKey != publicEncoded { + t.Fatalf("loaded protection = %+v", config) + } + if _, err := loadOwnerRunProtection(publicPath, ownerdecision.DigestBytes([]byte("wrong")), "repo-1", "feature", "abc123"); err == nil { + t.Fatal("non-genesis initial head was accepted") + } +} + +func TestOwnerDecisionSignIsOfflineAndBindsExportedChallenge(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + privateEncoded, _ := ownerdecision.EncodePrivateKey(privateKey) + privatePath := filepath.Join(t.TempDir(), "owner.key") + if err := os.WriteFile(privatePath, []byte(privateEncoded), 0o600); err != nil { + t.Fatal(err) + } + genesisHead, _ := ownerdecision.GenesisHeadForRun(publicKey, "repo-1", "feature", "abc123") + now := time.Now().UTC() + challenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, Purpose: ownerdecision.PurposeRespond, + RunID: "run-1", RepoID: "repo-1", Branch: "feature", HeadSHA: "abc123", GateHeadSHA: "abc123", + Step: types.StepReview, StepResultID: "step-1", RoundID: "round-1", + FindingsDigest: ownerdecision.DigestBytes([]byte(`{"findings":[]}`)), PreviousHead: genesisHead, + Nonce: "respond:round-1:" + genesisHead, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), + } + challengeBytes, _ := json.Marshal(challenge) + challengePath := filepath.Join(t.TempDir(), "challenge.json") + decisionPath := filepath.Join(t.TempDir(), "decision.json") + if err := os.WriteFile(challengePath, challengeBytes, 0o644); err != nil { + t.Fatal(err) + } + cmd := newAxiOwnerDecisionSignCmd() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetArgs([]string{"--challenge-file", challengePath, "--private-key", privatePath, "--action", "approve", "--out", decisionPath}) + if runtime.GOOS == "windows" { + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "not supported on Windows") { + t.Fatalf("Windows signer refusal = %v", err) + } + return + } + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + envelope, err := readOwnerDecisionEnvelope(decisionPath) + if err != nil { + t.Fatal(err) + } + if err := ownerdecision.Verify(publicKey, envelope, challenge, now); err != nil { + t.Fatal(err) + } + digest, _ := ownerdecision.EnvelopeDigest(envelope) + nextHead, _ := ownerdecision.NextHead(challenge.PreviousHead, digest) + if !strings.Contains(output.String(), "next_head: "+nextHead) { + t.Fatalf("offline signer did not publish controller-derivable next head: %s", output.String()) + } +} + +func TestCheckpointSigningKeepsHistoryHeadUnchanged(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + privateEncoded, _ := ownerdecision.EncodePrivateKey(privateKey) + privatePath := filepath.Join(t.TempDir(), "owner.key") + if err := os.WriteFile(privatePath, []byte(privateEncoded), 0o600); err != nil { + t.Fatal(err) + } + historyHead, _ := ownerdecision.GenesisHeadForRun(publicKey, "repo-1", "feature", "abc123") + now := time.Now().UTC() + challenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, Purpose: ownerdecision.PurposeCheckpoint, + RunID: "run-1", RepoID: "repo-1", Branch: "feature", HeadSHA: "abc123", GateHeadSHA: "abc123", + PreviousHead: historyHead, Nonce: "checkpoint:run-1:fresh", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), + } + challengeBytes, _ := json.Marshal(challenge) + challengePath := filepath.Join(t.TempDir(), "checkpoint-challenge.json") + decisionPath := filepath.Join(t.TempDir(), "checkpoint.json") + if err := os.WriteFile(challengePath, challengeBytes, 0o644); err != nil { + t.Fatal(err) + } + cmd := newAxiOwnerDecisionSignCmd() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetArgs([]string{"--challenge-file", challengePath, "--private-key", privatePath, "--out", decisionPath}) + if runtime.GOOS == "windows" { + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "not supported on Windows") { + t.Fatalf("Windows checkpoint signer refusal = %v", err) + } + return + } + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if strings.Contains(output.String(), "next_head:") || !strings.Contains(output.String(), "history_head: "+historyHead) { + t.Fatalf("checkpoint signer implied a journal append: %s", output.String()) + } +} + +func TestOwnerPrivateKeyReadRequiresPinnedOwnerOnlyRegularFile(t *testing.T) { + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + encoded, _ := ownerdecision.EncodePrivateKey(privateKey) + dir := t.TempDir() + good := filepath.Join(dir, "good.key") + if err := os.WriteFile(good, []byte(encoded+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if runtime.GOOS == "windows" { + if _, err := readOwnerPrivateKey(good); err == nil || !strings.Contains(err.Error(), "not supported on Windows") { + t.Fatalf("Windows private-key refusal = %v", err) + } + return + } + if loaded, err := readOwnerPrivateKey(good); err != nil || !bytes.Equal(loaded, privateKey) { + t.Fatalf("secure key read = %v, %v", loaded, err) + } + + t.Run("symlink", func(t *testing.T) { + path := filepath.Join(dir, "linked.key") + if err := os.Symlink(good, path); err != nil { + t.Fatal(err) + } + if _, err := readOwnerPrivateKey(path); err == nil { + t.Fatal("symlinked private key was accepted") + } + }) + + t.Run("non-owner-only mode", func(t *testing.T) { + path := filepath.Join(dir, "shared.key") + if err := os.WriteFile(path, []byte(encoded), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o640); err != nil { + t.Fatal(err) + } + if _, err := readOwnerPrivateKey(path); err == nil { + t.Fatal("group-readable private key was accepted") + } + }) + + t.Run("not regular", func(t *testing.T) { + if _, err := readOwnerPrivateKey(dir); err == nil { + t.Fatal("directory private key was accepted") + } + }) + + t.Run("oversized", func(t *testing.T) { + path := filepath.Join(dir, "oversized.key") + if err := os.WriteFile(path, bytes.Repeat([]byte("x"), maxOwnerPrivateKeyBytes+1), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readOwnerPrivateKey(path); err == nil { + t.Fatal("oversized private key was accepted") + } + }) +} + +func TestOfflineSigningRefusesLongFutureAndExpiredChallenges(t *testing.T) { + publicKey, _, _ := ed25519.GenerateKey(rand.Reader) + genesisHead, _ := ownerdecision.GenesisHeadForRun(publicKey, "repo-1", "feature", "abc123") + now := time.Now().UTC().Truncate(time.Second) + base := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, Purpose: ownerdecision.PurposeCancel, + RunID: "run-1", RepoID: "repo-1", Branch: "feature", HeadSHA: "abc123", GateHeadSHA: "abc123", + PreviousHead: genesisHead, Nonce: "cancel:run-1:" + genesisHead, + IssuedAt: now.Add(-time.Minute).Unix(), ExpiresAt: now.Add(time.Minute).Unix(), + } + tests := map[string]func(*ownerdecision.Challenge){ + "long": func(challenge *ownerdecision.Challenge) { + challenge.IssuedAt = now.Unix() + challenge.ExpiresAt = now.Add(ownerdecision.MaxChallengeLifetime + time.Second).Unix() + }, + "future": func(challenge *ownerdecision.Challenge) { + challenge.IssuedAt = now.Add(time.Minute).Unix() + challenge.ExpiresAt = now.Add(2 * time.Minute).Unix() + }, + "expired": func(challenge *ownerdecision.Challenge) { + challenge.IssuedAt = now.Add(-2 * time.Minute).Unix() + challenge.ExpiresAt = now.Add(-time.Minute).Unix() + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + challenge := base + mutate(&challenge) + if err := validateChallengeForOfflineSigning(challenge, now); err == nil { + t.Fatal("unsafe challenge was accepted for offline signing") + } + }) + } +} + +func TestOwnerDecisionKeygenCreatesPrivate0600AndNeverOverwrites(t *testing.T) { + privatePath := filepath.Join(t.TempDir(), "owner.key") + publicPath := filepath.Join(t.TempDir(), "owner.pub") + cmd := newAxiOwnerDecisionKeygenCmd() + cmd.SetArgs([]string{"--private-key", privatePath, "--public-key", publicPath}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + privateInfo, err := os.Stat(privatePath) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && privateInfo.Mode().Perm() != 0o600 { + t.Fatalf("private key mode = %o", privateInfo.Mode().Perm()) + } + privateBefore, _ := os.ReadFile(privatePath) + again := newAxiOwnerDecisionKeygenCmd() + again.SetArgs([]string{"--private-key", privatePath, "--public-key", publicPath}) + if err := again.Execute(); err == nil { + t.Fatal("keygen overwrote existing key files") + } + privateAfter, _ := os.ReadFile(privatePath) + if !bytes.Equal(privateBefore, privateAfter) { + t.Fatal("failed repeat keygen changed the private key") + } +} + +func TestOwnerResponseForSignKeepsYoloAndManualActionsInsideEnvelope(t *testing.T) { + response, err := ownerResponseForSign(ownerdecision.PurposeRespond, "fix", "review-1", "keep owner behavior", "") + if err != nil { + t.Fatal(err) + } + if response.Action != types.ActionFix || len(response.FindingIDs) != 1 || response.Instructions["review-1"] != "keep owner behavior" { + t.Fatalf("response = %+v", response) + } + cancel, err := ownerResponseForSign(ownerdecision.PurposeCancel, "", "", "", "") + if err != nil || cancel.Action != types.ActionAbort { + t.Fatalf("cancel response = %+v, %v", cancel, err) + } + checkpoint, err := ownerResponseForSign(ownerdecision.PurposeCheckpoint, "", "", "", "") + if err != nil || checkpoint.Action != types.ActionApprove { + t.Fatalf("checkpoint response = %+v, %v", checkpoint, err) + } +} diff --git a/internal/cli/axi_query.go b/internal/cli/axi_query.go index 813a590e8..71218ac76 100644 --- a/internal/cli/axi_query.go +++ b/internal/cli/axi_query.go @@ -122,6 +122,10 @@ func annotateRunView(env *axiEnv, rv *runView) { if env == nil || rv == nil { return } + if head, protected, err := env.d.OwnerDecisionHead(rv.ID); err == nil && protected { + rv.OwnerDecisionProtected = true + rv.OwnerDecisionHead = head + } quietWarning := configQuietWarning(env) for i := range rv.Steps { step := &rv.Steps[i] diff --git a/internal/cli/axi_render.go b/internal/cli/axi_render.go index 1b9a44d1c..25f9d6454 100644 --- a/internal/cli/axi_render.go +++ b/internal/cli/axi_render.go @@ -95,13 +95,15 @@ type stepView struct { // runView is a render-ready view of a pipeline run. type runView struct { - ID string - Branch string - Status string - HeadSHA string - PRURL string - CIReady bool - CIReadyNoCI bool + ID string + Branch string + Status string + HeadSHA string + PRURL string + CIReady bool + CIReadyNoCI bool + OwnerDecisionProtected bool + OwnerDecisionHead string // AwaitingAgentSince is the unix-seconds time the run parked at a gate // awaiting the driving agent, or nil when the run is not parked. It powers // the top-level parked signal in the run object. @@ -111,13 +113,15 @@ type runView struct { func runViewFromIPC(r *ipc.RunInfo) runView { rv := runView{ - ID: r.ID, - Branch: r.Branch, - Status: string(r.Status), - HeadSHA: r.HeadSHA, - CIReady: r.CIReady, - CIReadyNoCI: r.CIReadyNoCI, - AwaitingAgentSince: r.AwaitingAgentSince, + ID: r.ID, + Branch: r.Branch, + Status: string(r.Status), + HeadSHA: r.HeadSHA, + CIReady: r.CIReady, + CIReadyNoCI: r.CIReadyNoCI, + OwnerDecisionProtected: r.OwnerDecisionProtected, + OwnerDecisionHead: r.OwnerDecisionHead, + AwaitingAgentSince: r.AwaitingAgentSince, } if r.PRURL != nil { rv.PRURL = *r.PRURL @@ -421,6 +425,12 @@ func runObjectFieldWithKey(key string, rv runView) toon.Field { fields = append(fields, toon.Field{Key: "awaiting_agent", Value: formatParkedFor(*rv.AwaitingAgentSince)}) } fields = append(fields, toon.Field{Key: "head", Value: shortSHA(rv.HeadSHA)}) + if rv.OwnerDecisionProtected { + fields = append(fields, + toon.Field{Key: "owner_decision_protected", Value: true}, + toon.Field{Key: "owner_decision_head", Value: rv.OwnerDecisionHead}, + ) + } if rv.PRURL != "" { fields = append(fields, toon.Field{Key: "pr", Value: rv.PRURL}) } diff --git a/internal/cli/axi_test.go b/internal/cli/axi_test.go index 5d2b8e2ce..3e61e8990 100644 --- a/internal/cli/axi_test.go +++ b/internal/cli/axi_test.go @@ -84,10 +84,12 @@ func TestTruncateDisclosesTotal(t *testing.T) { func TestWriteRunObjectShape(t *testing.T) { rv := runView{ - ID: "run-1", - Branch: "feature/x", - Status: string(types.RunRunning), - HeadSHA: "abcdef1234567890", + ID: "run-1", + Branch: "feature/x", + Status: string(types.RunRunning), + HeadSHA: "abcdef1234567890", + OwnerDecisionProtected: true, + OwnerDecisionHead: strings.Repeat("a", 64), Steps: []stepView{ {Name: "review", Status: "completed", DurationMS: 1200, FindingsJSON: findingsJSON(t, []types.Finding{{ID: "r1", Action: types.ActionNoOp, Description: "ok"}}, "s")}, {Name: "test", Status: "awaiting_approval"}, @@ -98,6 +100,8 @@ func TestWriteRunObjectShape(t *testing.T) { for _, want := range []string{ "run:\n", " id: run-1\n", + " owner_decision_protected: true\n", + " owner_decision_head: " + strings.Repeat("a", 64) + "\n", " branch: feature/x\n", " status: running\n", " head: abcdef12\n", diff --git a/internal/cli/daemon_cmd.go b/internal/cli/daemon_cmd.go index ae9127733..31737fe36 100644 --- a/internal/cli/daemon_cmd.go +++ b/internal/cli/daemon_cmd.go @@ -1,7 +1,9 @@ package cli import ( + "bytes" "encoding/base64" + "encoding/json" "fmt" "io" "os" @@ -106,6 +108,10 @@ func newDaemonNotifyPushCmd() *cobra.Command { if err != nil { return err } + ownerConfig, err := parseOwnerDecisionPushOptions(pushOptions) + if err != nil { + return err + } gatePath, err := normalizeNotifyGatePath(gate) if err != nil { return err @@ -124,12 +130,13 @@ func newDaemonNotifyPushCmd() *cobra.Command { var result ipc.PushReceivedResult return client.Call(ipc.MethodPushReceived, &ipc.PushReceivedParams{ - Gate: gatePath, - Ref: ref, - Old: oldSHA, - New: newSHA, - SkipSteps: skipSteps, - Intent: intent, + Gate: gatePath, + Ref: ref, + Old: oldSHA, + New: newSHA, + SkipSteps: skipSteps, + Intent: intent, + OwnerDecision: ownerConfig, }, &result) }, } @@ -193,6 +200,7 @@ func parseSkipSteps(value string) ([]types.StepName, error) { // The value is base64-encoded so multi-line or special-character intents // survive the push-option transport (which is line-oriented). const intentPushOptionPrefix = "no-mistakes.intent=" +const ownerDecisionPushOptionPrefix = "no-mistakes.owner-decision=" // formatIntentPushOption encodes intent as a single push option, or returns "" // when there is no intent to carry. @@ -221,6 +229,45 @@ func parseIntentPushOptions(options []string) (string, error) { return intent, nil } +func formatOwnerDecisionPushOption(config *ipc.OwnerDecisionRunConfig) (string, error) { + if config == nil { + return "", nil + } + encoded, err := json.Marshal(config) + if err != nil { + return "", err + } + return ownerDecisionPushOptionPrefix + base64.StdEncoding.EncodeToString(encoded), nil +} + +func parseOwnerDecisionPushOptions(options []string) (*ipc.OwnerDecisionRunConfig, error) { + var config *ipc.OwnerDecisionRunConfig + for _, option := range options { + encoded, ok := strings.CutPrefix(option, ownerDecisionPushOptionPrefix) + if !ok { + continue + } + if config != nil { + return nil, fmt.Errorf("duplicate owner-decision push option") + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("decode owner-decision push option: %w", err) + } + var next ipc.OwnerDecisionRunConfig + decoder := json.NewDecoder(bytes.NewReader(decoded)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&next); err != nil { + return nil, fmt.Errorf("decode owner-decision push option: %w", err) + } + if err := requireJSONEOF(decoder); err != nil { + return nil, fmt.Errorf("decode owner-decision push option: %w", err) + } + config = &next + } + return config, nil +} + func formatSkipPushOptions(steps []types.StepName) []string { if len(steps) == 0 { return nil diff --git a/internal/cli/daemon_cmd_test.go b/internal/cli/daemon_cmd_test.go index 51e31c601..86aa75dd6 100644 --- a/internal/cli/daemon_cmd_test.go +++ b/internal/cli/daemon_cmd_test.go @@ -6,6 +6,8 @@ import ( "reflect" "testing" + "github.com/kunchenguid/no-mistakes/internal/ipc" + "github.com/kunchenguid/no-mistakes/internal/ownerdecision" "github.com/kunchenguid/no-mistakes/internal/types" ) @@ -109,3 +111,21 @@ func TestParseIntentPushOptionsNone(t *testing.T) { t.Fatalf("parseIntentPushOptions(no intent) = %q, want empty", got) } } + +func TestOwnerDecisionPushOptionRoundTrip(t *testing.T) { + want := &ipc.OwnerDecisionRunConfig{PublicKey: "base64-public-key", ExpectedHead: ownerdecision.GenesisHead} + option, err := formatOwnerDecisionPushOption(want) + if err != nil { + t.Fatal(err) + } + got, err := parseOwnerDecisionPushOptions([]string{"ci.skip", option}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("owner-decision option = %+v, want %+v", got, want) + } + if _, err := parseOwnerDecisionPushOptions([]string{option, option}); err == nil { + t.Fatal("duplicate owner-decision push options were accepted") + } +} diff --git a/internal/cli/owner_private_key.go b/internal/cli/owner_private_key.go new file mode 100644 index 000000000..70a4a74b1 --- /dev/null +++ b/internal/cli/owner_private_key.go @@ -0,0 +1,3 @@ +package cli + +const maxOwnerPrivateKeyBytes = 4096 diff --git a/internal/cli/owner_private_key_unix.go b/internal/cli/owner_private_key_unix.go new file mode 100644 index 000000000..7e39d2495 --- /dev/null +++ b/internal/cli/owner_private_key_unix.go @@ -0,0 +1,50 @@ +//go:build darwin || linux + +package cli + +import ( + "fmt" + "io" + "os" + + "golang.org/x/sys/unix" +) + +// readOwnerPrivateKeyFile opens the final path without following a symlink and +// validates the already-open descriptor before reading. This keeps an offline +// signer from silently consuming a key redirected through a workload-writable +// symlink or exposed to another local account. +func readOwnerPrivateKeyFile(path string) (string, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return "", err + } + file := os.NewFile(uintptr(fd), path) + if file == nil { + _ = unix.Close(fd) + return "", fmt.Errorf("open private key descriptor") + } + defer file.Close() + + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return "", err + } + if stat.Mode&unix.S_IFMT != unix.S_IFREG { + return "", fmt.Errorf("private key must be a regular file") + } + if stat.Mode&0o7777 != 0o600 { + return "", fmt.Errorf("private key mode is %04o; require 0600", stat.Mode&0o7777) + } + if stat.Uid != uint32(os.Geteuid()) { + return "", fmt.Errorf("private key is not owned by the current user") + } + value, err := io.ReadAll(io.LimitReader(file, maxOwnerPrivateKeyBytes+1)) + if err != nil { + return "", err + } + if len(value) > maxOwnerPrivateKeyBytes { + return "", fmt.Errorf("private key exceeds %d bytes", maxOwnerPrivateKeyBytes) + } + return string(value), nil +} diff --git a/internal/cli/owner_private_key_windows.go b/internal/cli/owner_private_key_windows.go new file mode 100644 index 000000000..f3ad54946 --- /dev/null +++ b/internal/cli/owner_private_key_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package cli + +import "fmt" + +// Windows FileMode bits do not prove an owner-only ACL. Fail closed until the +// CLI has a native ACL verifier rather than pretending 0600 is authoritative. +func readOwnerPrivateKeyFile(path string) (string, error) { + return "", fmt.Errorf("secure owner-decision private-key loading is not supported on Windows") +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 42e5414e1..e3c34844e 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -410,6 +410,19 @@ func recoverOnStartup(d *db.DB, p *paths.Paths, mgr *RunManager, layout *worktre logStartupPhase("terminal_pr_runs", terminalPRStarted, "reconciled", terminalPRCount) } + protectedCrashStarted := time.Now() + protectedCrash, err := d.ReconcileProtectedCrashStates() + if err != nil { + slog.Error("failed to reconcile protected crash states", "error", err) + logStartupPhase("protected_crash_states", protectedCrashStarted, "failed", true) + } else { + cancelled := recoverCommittedProtectedCancellations(d, p, protectedCrash.CancellationRunIDs) + logStartupPhase("protected_crash_states", protectedCrashStarted, + "pending_failed", protectedCrash.PendingFailed, + "cancelled", cancelled, + ) + } + parkedStarted := time.Now() plans := mgr.recoverableParkedRuns(context.Background()) preserved := make(map[string]struct{}, len(plans)) @@ -469,6 +482,27 @@ func recoverOnStartup(d *db.DB, p *paths.Paths, mgr *RunManager, layout *worktre mgr.resumeRecoveredRuns(plans) } +func recoverCommittedProtectedCancellations(d *db.DB, p *paths.Paths, runIDs []string) int { + recovered := 0 + for _, runID := range runIDs { + run, err := d.GetRun(runID) + if err != nil || run == nil || run.Status != types.RunRunning { + continue + } + workDir := worktrees.RecordedDir(p, run.WorktreePath(), run.RepoID, run.ID) + if _, ok := preserveRunHead(d, workDir, run); !ok { + slog.Warn("protected cancellation remains stale because its worktree head could not be anchored", "run_id", runID) + continue + } + if err := d.RecoverCommittedOwnerCancellation(runID); err != nil { + slog.Warn("failed to project committed protected cancellation", "run_id", runID, "error", err) + continue + } + recovered++ + } + return recovered +} + func preserveStaleRunHeads(d *db.DB, p *paths.Paths, excluded map[string]struct{}) { active, err := d.ActiveRunWorktrees() if err != nil { @@ -1194,7 +1228,7 @@ func registerHandlers(srv *ipc.Server, mgr *RunManager, d *db.DB, shutdown func( if err := json.Unmarshal(params, &p); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } - runID, err := mgr.HandleRerun(ctx, p.RepoID, p.Branch, p.PreviousRunID, p.SkipSteps, p.Intent) + runID, err := mgr.HandleRerunWithOwnerDecision(ctx, p.RepoID, p.Branch, p.PreviousRunID, p.SkipSteps, p.Intent, p.OwnerDecision) if err != nil { return nil, err } @@ -1227,7 +1261,16 @@ func registerHandlers(srv *ipc.Server, mgr *RunManager, d *db.DB, shutdown func( if err := json.Unmarshal(params, &p); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } - if err := mgr.HandleRespondWithOverrides(p.RunID, p.Step, p.Action, p.FindingIDs, p.Instructions, p.AddedFindings); err != nil { + var err error + if p.Decision != nil { + if p.Step != "" || p.Action != "" || len(p.FindingIDs) != 0 || len(p.Instructions) != 0 || len(p.AddedFindings) != 0 { + return nil, fmt.Errorf("signed decision cannot be combined with legacy response fields") + } + err = mgr.HandleSignedRespond(p.RunID, *p.Decision) + } else { + err = mgr.HandleRespondWithOverrides(p.RunID, p.Step, p.Action, p.FindingIDs, p.Instructions, p.AddedFindings) + } + if err != nil { return nil, err } return &ipc.RespondResult{OK: true}, nil @@ -1241,12 +1284,47 @@ func registerHandlers(srv *ipc.Server, mgr *RunManager, d *db.DB, shutdown func( if err := json.Unmarshal(params, &p); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } - if err := mgr.HandleCancel(p.RunID); err != nil { + var err error + if p.Decision != nil { + err = mgr.HandleSignedCancel(p.RunID, *p.Decision) + } else { + err = mgr.HandleCancel(p.RunID) + } + if err != nil { return nil, err } return &ipc.CancelRunResult{OK: true}, nil }) + srv.Handle(ipc.MethodOwnerDecisionChallenge, func(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := refuseNested(ctx, false); err != nil { + return nil, err + } + var p ipc.OwnerDecisionChallengeParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + challenge, err := mgr.HandleOwnerDecisionChallenge(p.RunID, p.Purpose, p.ExpectedHead) + if err != nil { + return nil, err + } + return &ipc.OwnerDecisionChallengeResult{Challenge: challenge}, nil + }) + + srv.Handle(ipc.MethodOwnerDecisionCheckpoint, func(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := refuseNested(ctx, false); err != nil { + return nil, err + } + var p ipc.OwnerDecisionCheckpointParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := mgr.HandleOwnerDecisionCheckpoint(p.RunID, p.Decision); err != nil { + return nil, err + } + return &ipc.OwnerDecisionCheckpointResult{OK: true}, nil + }) + srv.HandleStream(ipc.MethodSubscribe, func(ctx context.Context, params json.RawMessage) (ipc.StreamFunc, error) { var p ipc.SubscribeParams if err := json.Unmarshal(params, &p); err != nil { @@ -1335,6 +1413,12 @@ func runToInfo(d *db.DB, r *db.Run, steps []*db.StepResult) *ipc.RunInfo { CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, } + if authority, err := d.GetOwnerDecisionAuthority(r.ID); err == nil && authority != nil { + info.OwnerDecisionProtected = true + if head, protected, headErr := d.OwnerDecisionHead(r.ID); headErr == nil && protected { + info.OwnerDecisionHead = head + } + } if len(steps) > 0 { info.Steps = make([]ipc.StepResultInfo, 0, len(steps)) for _, s := range steps { diff --git a/internal/daemon/manager.go b/internal/daemon/manager.go index 01febf298..dbfb0ed11 100644 --- a/internal/daemon/manager.go +++ b/internal/daemon/manager.go @@ -2,6 +2,9 @@ package daemon import ( "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" "fmt" "log/slog" "os" @@ -20,6 +23,7 @@ import ( "github.com/kunchenguid/no-mistakes/internal/gate" "github.com/kunchenguid/no-mistakes/internal/git" "github.com/kunchenguid/no-mistakes/internal/ipc" + "github.com/kunchenguid/no-mistakes/internal/ownerdecision" "github.com/kunchenguid/no-mistakes/internal/paths" "github.com/kunchenguid/no-mistakes/internal/pipeline" "github.com/kunchenguid/no-mistakes/internal/pipeline/steps" @@ -39,15 +43,16 @@ var fetchRecoveredRemoteBranch = git.FetchRemoteBranch // RunManager tracks active pipeline executors and manages run lifecycle. type RunManager struct { - mu sync.Mutex - executors map[string]*pipeline.Executor // runID → executor - cancels map[string]context.CancelCauseFunc // runID → cancel function with cause - dones map[string]chan struct{} // runID → closed when goroutine exits - wg sync.WaitGroup // tracks background run goroutines - shuttingDown atomic.Bool // prevents new runs during shutdown - db *db.DB - paths *paths.Paths - steps StepFactory + mu sync.Mutex + executors map[string]*pipeline.Executor // runID → executor + cancels map[string]context.CancelCauseFunc // runID → cancel function with cause + dones map[string]chan struct{} // runID → closed when goroutine exits + pendingProtected map[string]recoveredRunPlan // restart-fenced until signed checkpoint + wg sync.WaitGroup // tracks background run goroutines + shuttingDown atomic.Bool // prevents new runs during shutdown + db *db.DB + paths *paths.Paths + steps StepFactory branchLocks sync.Map // repoID+"/"+branch → *sync.Mutex @@ -81,26 +86,28 @@ func NewRunManager(database *db.DB, p *paths.Paths, stepFactory StepFactory) *Ru stepFactory = func() []pipeline.Step { return steps.AllSteps() } } return &RunManager{ - executors: make(map[string]*pipeline.Executor), - cancels: make(map[string]context.CancelCauseFunc), - dones: make(map[string]chan struct{}), - db: database, - paths: p, - steps: stepFactory, - subscribers: make(map[string][]*eventMailbox), - stateRevs: make(map[string]int64), - completedRuns: make(map[string]bool), + executors: make(map[string]*pipeline.Executor), + cancels: make(map[string]context.CancelCauseFunc), + dones: make(map[string]chan struct{}), + pendingProtected: make(map[string]recoveredRunPlan), + db: database, + paths: p, + steps: stepFactory, + subscribers: make(map[string][]*eventMailbox), + stateRevs: make(map[string]int64), + completedRuns: make(map[string]bool), } } type recoveredRunPlan struct { - run *db.Run - repo *db.Repo - workDir string - gateDir string - cfg *config.Config - agent agent.Agent - steps []pipeline.Step + run *db.Run + repo *db.Repo + workDir string + gateDir string + cfg *config.Config + agent agent.Agent + steps []pipeline.Step + ownerCheckpointChallenge *ownerdecision.Challenge } func (m *RunManager) recoverableParkedRuns(ctx context.Context) []recoveredRunPlan { @@ -133,6 +140,15 @@ func (m *RunManager) prepareRecoveredRun(ctx context.Context, run *db.Run) (*rec if run == nil || run.Status != types.RunRunning || run.AwaitingAgentSince == nil || run.Branch == "" { return nil, fmt.Errorf("run is not a parked running run") } + authority, err := m.db.GetOwnerDecisionAuthority(run.ID) + if err != nil { + return nil, fmt.Errorf("read recovered owner-decision authority: %w", err) + } + if authority != nil { + if err := m.db.VerifyRecoveredOwnerDecisionRun(run.ID); err != nil { + return nil, err + } + } repo, err := m.db.GetRepo(run.RepoID) if err != nil { return nil, fmt.Errorf("get repo: %w", err) @@ -308,17 +324,51 @@ func samePath(a, b string) bool { func (m *RunManager) resumeRecoveredRuns(plans []recoveredRunPlan) { for _, plan := range plans { - m.resumeRecoveredRun(plan) + authority, err := m.db.GetOwnerDecisionAuthority(plan.run.ID) + if err != nil { + reason := "recovered run owner-decision authority is unreadable; refusing restart" + if failErr := m.db.FailUnboundRecoveredRun(plan.run.ID, plan.run.HeadSHA, reason); failErr != nil { + slog.Error("failed to terminalize unreadable recovered owner-decision authority", "run_id", plan.run.ID, "error", failErr) + } + _ = plan.agent.Close() + continue + } + if authority != nil { + m.mu.Lock() + m.pendingProtected[plan.run.ID] = plan + m.mu.Unlock() + slog.Info("protected recovered run is waiting for a signed history checkpoint", "run_id", plan.run.ID) + continue + } + // After a restart, total deletion of a protected run's local authority + // row is indistinguishable from a historical legacy run. Auto-resuming + // either would create a same-UID downgrade path. Legacy/unbound parked + // runs therefore fail closed and retain their verified head for custody + // recovery; fresh legacy runs remain otherwise unchanged. + reason := "unbound legacy run cannot be resumed after daemon restart; start a new run" + if err := m.db.FailUnboundRecoveredRun(plan.run.ID, plan.run.HeadSHA, reason); err != nil { + slog.Error("failed to terminalize unbound recovered run", "run_id", plan.run.ID, "error", err) + } + _ = plan.agent.Close() } } func (m *RunManager) resumeRecoveredRun(plan recoveredRunPlan) { + _ = m.resumeRecoveredRunArmed(plan, "") +} + +func (m *RunManager) resumeRecoveredRunArmed(plan recoveredRunPlan, expectedOwnerHead string) error { if m.shuttingDown.Load() { _ = plan.agent.Close() - return + return fmt.Errorf("daemon is shutting down") } - runCtx, cancel := context.WithCancelCause(context.Background()) executor := pipeline.NewExecutor(m.db, m.paths, plan.cfg, plan.agent, plan.steps, m.broadcast) + if expectedOwnerHead != "" { + if err := executor.ArmOwnerDecisionHistory(plan.run.ID, expectedOwnerHead); err != nil { + return fmt.Errorf("arm protected recovered run: %w", err) + } + } + runCtx, cancel := context.WithCancelCause(context.Background()) executor.SetOnPRMerged(func(_ context.Context, runID string) { m.wg.Add(1) go func() { @@ -390,6 +440,7 @@ func (m *RunManager) resumeRecoveredRun(plan recoveredRunPlan) { addRunPerformanceSummary(m.db, plan.run.ID, fields) telemetry.Track("run", fields) }() + return nil } func agentListsEqual(a, b []types.AgentName) bool { @@ -693,7 +744,7 @@ func (m *RunManager) HandlePushReceived(ctx context.Context, params *ipc.PushRec } branch := branchFromRef(params.Ref) - return m.startRun(ctx, repo, branch, params.New, params.Old, "push", params.SkipSteps, params.Intent) + return m.startRunWithOwnerDecision(ctx, repo, branch, params.New, params.Old, "push", params.SkipSteps, params.Intent, params.OwnerDecision) } // HandleRerun creates a new run for the latest recoverable head on a branch: @@ -702,6 +753,10 @@ func (m *RunManager) HandlePushReceived(ctx context.Context, params *ipc.PushRec // selected run. Otherwise an authoritative intent is inherited byte-for-byte; // runs without one infer intent afresh. func (m *RunManager) HandleRerun(ctx context.Context, repoID, branch, previousRunID string, skipSteps []types.StepName, intent string) (string, error) { + return m.HandleRerunWithOwnerDecision(ctx, repoID, branch, previousRunID, skipSteps, intent, nil) +} + +func (m *RunManager) HandleRerunWithOwnerDecision(ctx context.Context, repoID, branch, previousRunID string, skipSteps []types.StepName, intent string, ownerConfig *ipc.OwnerDecisionRunConfig) (string, error) { repo, err := m.db.GetRepo(repoID) if err != nil { return "", fmt.Errorf("get repo: %w", err) @@ -771,7 +826,7 @@ func (m *RunManager) HandleRerun(ctx context.Context, repoID, branch, previousRu } } - return m.startRunWithIntentSource(ctx, repo, branch, headSHA, baseSHA, "rerun", skipSteps, intent, intentSource) + return m.startRunWithIntentSourceAndOwnerDecision(ctx, repo, branch, headSHA, baseSHA, "rerun", skipSteps, intent, intentSource, ownerConfig) } func resolveRerunHead(ctx context.Context, gateDir, branch string, latest *db.Run) (string, error) { @@ -836,10 +891,18 @@ func (m *RunManager) startRun(ctx context.Context, repo *db.Repo, branch, headSH return m.startRunWithIntentSource(ctx, repo, branch, headSHA, baseSHA, trigger, skipSteps, intent, db.RunIntentSourceAgent) } +func (m *RunManager) startRunWithOwnerDecision(ctx context.Context, repo *db.Repo, branch, headSHA, baseSHA, trigger string, skipSteps []types.StepName, intent string, ownerConfig *ipc.OwnerDecisionRunConfig) (string, error) { + return m.startRunWithIntentSourceAndOwnerDecision(ctx, repo, branch, headSHA, baseSHA, trigger, skipSteps, intent, db.RunIntentSourceAgent, ownerConfig) +} + // startRunWithIntentSource is the common run-creation path. source is empty // when no intent is supplied, RunIntentSourceAgent for a new explicit // override, and RunIntentSourceRerun for inherited explicit intent. func (m *RunManager) startRunWithIntentSource(ctx context.Context, repo *db.Repo, branch, headSHA, baseSHA, trigger string, skipSteps []types.StepName, intent, source string) (string, error) { + return m.startRunWithIntentSourceAndOwnerDecision(ctx, repo, branch, headSHA, baseSHA, trigger, skipSteps, intent, source, nil) +} + +func (m *RunManager) startRunWithIntentSourceAndOwnerDecision(ctx context.Context, repo *db.Repo, branch, headSHA, baseSHA, trigger string, skipSteps []types.StepName, intent, source string, ownerConfig *ipc.OwnerDecisionRunConfig) (string, error) { branchRole := telemetryBranchRole(branch, repo.DefaultBranch) trackStartFailure := func(stage string) { telemetry.Track("run", telemetry.Fields{ @@ -854,6 +917,21 @@ func (m *RunManager) startRunWithIntentSource(ctx context.Context, repo *db.Repo trackStartFailure("daemon_shutdown") return "", fmt.Errorf("daemon is shutting down") } + var ownerPublicKey ed25519.PublicKey + if ownerConfig != nil { + var err error + ownerPublicKey, err = ownerdecision.ParsePublicKey(ownerConfig.PublicKey) + if err != nil { + return "", err + } + genesisHead, err := ownerdecision.GenesisHeadForRun(ownerPublicKey, repo.ID, branch, headSHA) + if err != nil { + return "", err + } + if ownerConfig.ExpectedHead != genesisHead { + return "", fmt.Errorf("owner decision: a new protected run must start at the public-key-and-run-bound genesis history head") + } + } // Serialize per repo+branch to prevent two concurrent pushes from both // passing cancelActiveRuns and creating duplicate pipelines. @@ -874,8 +952,13 @@ func (m *RunManager) startRunWithIntentSource(ctx context.Context, repo *db.Repo repo = refreshed } - // Cancel any active run for this repo+branch. - m.cancelActiveRuns(repo.ID, branch) + // Cancel any active legacy run for this repo+branch. Protected runs cannot + // be superseded as an unsigned side effect of a new push: the controller + // must first submit a signed cancellation envelope. + if err := m.cancelActiveRuns(repo.ID, branch); err != nil { + trackStartFailure("cancel_active_run") + return "", err + } storedIntent := intent if source != db.RunIntentSourceRerun { @@ -894,6 +977,13 @@ func (m *RunManager) startRunWithIntentSource(ctx context.Context, repo *db.Repo trackStartFailure("create_run") return "", fmt.Errorf("create run: %w", err) } + if ownerConfig != nil { + if _, err := m.db.ProtectRunOwnerDecisions(run.ID, ownerPublicKey); err != nil { + _ = m.db.UpdateRunErrorStatus(run.ID, err.Error(), types.RunFailed) + trackStartFailure("protect_owner_decisions") + return "", err + } + } globalCfg, err := config.LoadGlobal(m.paths.ConfigFile()) if err != nil { @@ -1081,8 +1171,16 @@ func (m *RunManager) startRunWithIntentSource(ctx context.Context, repo *db.Repo }) // Create executor with event broadcast. - runCtx, cancel := context.WithCancelCause(context.Background()) executor := pipeline.NewExecutor(m.db, m.paths, cfg, ag, execSteps, m.broadcast) + if ownerConfig != nil { + if err := executor.ArmOwnerDecisionHistory(run.ID, ownerConfig.ExpectedHead); err != nil { + _ = ag.Close() + m.db.UpdateRunError(run.ID, err.Error()) + trackStartFailure("arm_owner_decisions") + return "", err + } + } + runCtx, cancel := context.WithCancelCause(context.Background()) executor.SetSkippedSteps(skipSteps) executor.SetOnPRMerged(func(_ context.Context, runID string) { m.wg.Add(1) @@ -1344,6 +1442,126 @@ func (m *RunManager) HandleRespondWithOverrides(runID string, step types.StepNam return exec.RespondWithOverrides(step, action, findingIDs, instructions, addedFindings) } +func (m *RunManager) HandleOwnerDecisionChallenge(runID, purpose, expectedHead string) (ownerdecision.Challenge, error) { + if purpose == ownerdecision.PurposeCheckpoint { + if strings.TrimSpace(expectedHead) == "" { + return ownerdecision.Challenge{}, fmt.Errorf("owner decision checkpoint requires the controller-held expected head") + } + m.mu.Lock() + plan, pending := m.pendingProtected[runID] + m.mu.Unlock() + if !pending { + return ownerdecision.Challenge{}, fmt.Errorf("run %s is not waiting for a protected restart checkpoint", runID) + } + authority, err := m.db.GetOwnerDecisionAuthority(runID) + if err != nil { + return ownerdecision.Challenge{}, fmt.Errorf("read owner decision checkpoint authority: %w", err) + } + if authority == nil { + return ownerdecision.Challenge{}, fmt.Errorf("run %s is not protected", runID) + } + if err := m.db.VerifyOwnerDecisionHistory(runID, expectedHead); err != nil { + return ownerdecision.Challenge{}, fmt.Errorf("verify owner decision checkpoint history: %w", err) + } + nonceBytes := make([]byte, 32) + if _, err := rand.Read(nonceBytes); err != nil { + return ownerdecision.Challenge{}, fmt.Errorf("generate owner decision checkpoint nonce: %w", err) + } + now := time.Now().UTC() + challenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, Purpose: ownerdecision.PurposeCheckpoint, + RunID: plan.run.ID, RepoID: authority.RepoID, Branch: authority.Branch, + HeadSHA: authority.InitialHeadSHA, GateHeadSHA: plan.run.HeadSHA, + PreviousHead: expectedHead, Nonce: "checkpoint:" + plan.run.ID + ":" + base64.RawURLEncoding.EncodeToString(nonceBytes), + IssuedAt: now.Unix(), ExpiresAt: now.Add(ownerdecision.MaxChallengeLifetime).Unix(), + } + m.mu.Lock() + current, stillPending := m.pendingProtected[runID] + if !stillPending { + m.mu.Unlock() + return ownerdecision.Challenge{}, fmt.Errorf("run %s is no longer waiting for a protected restart checkpoint", runID) + } + if current.ownerCheckpointChallenge != nil && time.Now().Unix() < current.ownerCheckpointChallenge.ExpiresAt { + existing := *current.ownerCheckpointChallenge + m.mu.Unlock() + if existing.PreviousHead != expectedHead { + return ownerdecision.Challenge{}, fmt.Errorf("owner decision checkpoint challenge is already issued for a different expected head") + } + return existing, nil + } + current.ownerCheckpointChallenge = &challenge + m.pendingProtected[runID] = current + m.mu.Unlock() + return challenge, nil + } + m.mu.Lock() + exec, ok := m.executors[runID] + m.mu.Unlock() + if !ok { + return ownerdecision.Challenge{}, fmt.Errorf("no active executor for run %s", runID) + } + return exec.OwnerDecisionChallenge(purpose) +} + +func (m *RunManager) HandleSignedRespond(runID string, envelope ownerdecision.Envelope) error { + m.mu.Lock() + exec, ok := m.executors[runID] + m.mu.Unlock() + if !ok { + return fmt.Errorf("no active executor for run %s", runID) + } + return exec.RespondAuthorized(envelope) +} + +// HandleOwnerDecisionCheckpoint resumes a protected parked run only after a +// fresh Ed25519-signed statement binds the controller-held expected history +// head. A local process cannot bless a rolled-back SQLite prefix by merely +// repeating the locally visible head. +func (m *RunManager) HandleOwnerDecisionCheckpoint(runID string, envelope ownerdecision.Envelope) error { + m.mu.Lock() + plan, ok := m.pendingProtected[runID] + if ok { + delete(m.pendingProtected, runID) + } + m.mu.Unlock() + if !ok { + return fmt.Errorf("run %s is not waiting for a protected restart checkpoint", runID) + } + started := false + defer func() { + if started { + return + } + m.mu.Lock() + if _, active := m.executors[runID]; !active { + m.pendingProtected[runID] = plan + } + m.mu.Unlock() + }() + authority, err := m.db.GetOwnerDecisionAuthority(runID) + if err != nil { + return err + } + if authority == nil { + return fmt.Errorf("run %s is not protected", runID) + } + if plan.ownerCheckpointChallenge == nil { + return fmt.Errorf("owner decision checkpoint has no fresh daemon-issued challenge") + } + challenge := *plan.ownerCheckpointChallenge + if err := ownerdecision.Verify(authority.PublicKey, envelope, challenge, time.Now().UTC()); err != nil { + return fmt.Errorf("verify owner decision checkpoint: %w", err) + } + if err := m.db.VerifyOwnerDecisionHistory(runID, challenge.PreviousHead); err != nil { + return fmt.Errorf("verify owner decision checkpoint history: %w", err) + } + if err := m.resumeRecoveredRunArmed(plan, challenge.PreviousHead); err != nil { + return err + } + started = true + return nil +} + // Shutdown cancels all active runs. Called during daemon shutdown to prevent // orphaned goroutines from continuing agent calls and git operations. func (m *RunManager) Shutdown() { @@ -1354,7 +1572,15 @@ func (m *RunManager) Shutdown() { for id, cancel := range m.cancels { cancels[id] = cancel } + pending := make([]recoveredRunPlan, 0, len(m.pendingProtected)) + for id, plan := range m.pendingProtected { + pending = append(pending, plan) + delete(m.pendingProtected, id) + } m.mu.Unlock() + for _, plan := range pending { + _ = plan.agent.Close() + } for id, cancel := range cancels { cancel(fmt.Errorf("daemon shutting down")) @@ -1377,49 +1603,88 @@ func (m *RunManager) Shutdown() { func (m *RunManager) HandleCancel(runID string) error { m.mu.Lock() cancel, ok := m.cancels[runID] + exec := m.executors[runID] m.mu.Unlock() if !ok { return fmt.Errorf("no active run %s", runID) } + if exec != nil && exec.OwnerDecisionProtected() { + return fmt.Errorf("owner decision: protected run requires a signed cancellation envelope") + } cancel(fmt.Errorf(types.RunCancelReasonAbortedByUser)) return nil } +func (m *RunManager) HandleSignedCancel(runID string, envelope ownerdecision.Envelope) error { + m.mu.Lock() + cancel, ok := m.cancels[runID] + exec := m.executors[runID] + m.mu.Unlock() + if !ok || exec == nil { + return fmt.Errorf("no active run %s", runID) + } + if err := exec.AuthorizeCancel(envelope); err != nil { + return err + } + cancel(fmt.Errorf(types.RunCancelReasonAbortedByUser)) + return nil +} + // cancelActiveRuns cancels any in-progress runs for the given repo+branch // and waits for their goroutines to finish before returning, preventing // concurrent pushes to upstream. // The cancellation cause is propagated to the executor via context.Cause, // which uses it as the run's error message in the DB. -func (m *RunManager) cancelActiveRuns(repoID, branch string) { +func (m *RunManager) cancelActiveRuns(repoID, branch string) error { runs, err := m.db.GetRunsByRepo(repoID) if err != nil { - slog.Error("failed to query active runs for cancellation", "repo", repoID, "branch", branch, "error", err) - return + return fmt.Errorf("query active runs for cancellation: %w", err) } - var toWait []chan struct{} + type activeRun struct { + run *db.Run + cancel context.CancelCauseFunc + done chan struct{} + } + var active []activeRun + // First classify every candidate. Cancelling a legacy run before later + // discovering a protected run would make the refusal itself destructive. + // The in-memory executor/pending-plan checks preserve protection even if a + // same-UID workload deletes the local authority row while the daemon lives. for _, run := range runs { - if run.Branch != branch { - continue - } - if run.Status != types.RunPending && run.Status != types.RunRunning { + if run.Branch != branch || (run.Status != types.RunPending && run.Status != types.RunRunning) { continue } - m.mu.Lock() - cancel, ok := m.cancels[run.ID] + exec := m.executors[run.ID] + _, pendingProtected := m.pendingProtected[run.ID] + cancel := m.cancels[run.ID] done := m.dones[run.ID] m.mu.Unlock() - if !ok { - continue + protected := pendingProtected || (exec != nil && exec.OwnerDecisionProtected()) + if !protected { + authority, authorityErr := m.db.GetOwnerDecisionAuthority(run.ID) + if authorityErr != nil { + return fmt.Errorf("classify active run %s owner-decision authority: %w", run.ID, authorityErr) + } + protected = authority != nil + } + if protected { + return fmt.Errorf("owner decision: active protected run %s requires signed cancellation before it can be superseded", run.ID) + } + if cancel != nil { + active = append(active, activeRun{run: run, cancel: cancel, done: done}) } + } - cancel(fmt.Errorf(types.RunCancelReasonSuperseded)) - slog.Info("cancelled active run", "run_id", run.ID, "repo_id", repoID, "branch", branch) - if done != nil { - toWait = append(toWait, done) + var toWait []chan struct{} + for _, candidate := range active { + candidate.cancel(fmt.Errorf(types.RunCancelReasonSuperseded)) + slog.Info("cancelled active run", "run_id", candidate.run.ID, "repo_id", repoID, "branch", branch) + if candidate.done != nil { + toWait = append(toWait, candidate.done) } } @@ -1429,7 +1694,8 @@ func (m *RunManager) cancelActiveRuns(repoID, branch string) { case <-done: case <-timeout: slog.Warn("timed out waiting for cancelled runs to finish") - return + return fmt.Errorf("timed out waiting for cancelled runs to finish") } } + return nil } diff --git a/internal/daemon/owner_decision_integration_test.go b/internal/daemon/owner_decision_integration_test.go new file mode 100644 index 000000000..3ede76f8b --- /dev/null +++ b/internal/daemon/owner_decision_integration_test.go @@ -0,0 +1,686 @@ +package daemon + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kunchenguid/no-mistakes/internal/db" + gitpkg "github.com/kunchenguid/no-mistakes/internal/git" + "github.com/kunchenguid/no-mistakes/internal/ipc" + "github.com/kunchenguid/no-mistakes/internal/ownerdecision" + "github.com/kunchenguid/no-mistakes/internal/paths" + "github.com/kunchenguid/no-mistakes/internal/pipeline" + "github.com/kunchenguid/no-mistakes/internal/types" + "github.com/kunchenguid/no-mistakes/internal/worktrees" +) + +func protectedRunConfig(t *testing.T, repoID, branch, initialHeadSHA string) (*ipc.OwnerDecisionRunConfig, ed25519.PrivateKey) { + t.Helper() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + encoded, err := ownerdecision.EncodePublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + genesisHead, err := ownerdecision.GenesisHeadForRun(publicKey, repoID, branch, initialHeadSHA) + if err != nil { + t.Fatal(err) + } + return &ipc.OwnerDecisionRunConfig{PublicKey: encoded, ExpectedHead: genesisHead}, privateKey +} + +func waitForProtectedStepStatus(t *testing.T, database interface { + GetStepsByRun(string) ([]*db.StepResult, error) +}, runID string, stepName types.StepName, status types.StepStatus) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + steps, err := database.GetStepsByRun(runID) + if err == nil { + for _, step := range steps { + if step.StepName == stepName && step.Status == status { + return + } + } + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("step %s did not reach %s", stepName, status) +} + +func waitForProtectedRunTerminal(t *testing.T, database *db.DB, runID string) *db.Run { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + run, err := database.GetRun(runID) + if err != nil { + t.Fatal(err) + } + if run != nil && run.Status.Terminal() { + return run + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("run %s did not reach terminal state", runID) + return nil +} + +func TestProtectedRunRequiresSignedGateDecisionAndPublishesVerifiableHead(t *testing.T) { + p, database := startTestDaemonWithSteps(t, func() []pipeline.Step { + return []pipeline.Step{&mockApprovalStep{name: types.StepReview}} + }) + repo, headSHA := setupTestGitRepo(t, p, database, "protected-gate-repo") + config, privateKey := protectedRunConfig(t, repo.ID, "main", headSHA) + client, err := ipc.Dial(p.Socket()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + wrong := *config + wrong.ExpectedHead = ownerdecision.DigestBytes([]byte("not-genesis")) + var rejected ipc.PushReceivedResult + if err := client.Call(ipc.MethodPushReceived, &ipc.PushReceivedParams{ + Gate: p.RepoDir("protected-gate-repo"), Ref: "refs/heads/main", Old: strings.Repeat("0", 40), New: headSHA, OwnerDecision: &wrong, + }, &rejected); err == nil { + t.Fatal("fresh protected run accepted a non-genesis expected head") + } + + var started ipc.PushReceivedResult + if err := client.Call(ipc.MethodPushReceived, &ipc.PushReceivedParams{ + Gate: p.RepoDir("protected-gate-repo"), Ref: "refs/heads/main", Old: strings.Repeat("0", 40), New: headSHA, OwnerDecision: config, + }, &started); err != nil { + t.Fatal(err) + } + waitForProtectedStepStatus(t, database, started.RunID, types.StepReview, types.StepStatusAwaitingApproval) + + var superseding ipc.PushReceivedResult + supersedeErr := client.Call(ipc.MethodPushReceived, &ipc.PushReceivedParams{ + Gate: p.RepoDir("protected-gate-repo"), Ref: "refs/heads/main", Old: strings.Repeat("0", 40), New: headSHA, + }, &superseding) + if supersedeErr == nil || !strings.Contains(supersedeErr.Error(), "signed cancellation") { + t.Fatalf("unsigned protected-run supersede error = %v", supersedeErr) + } + stillRunning, err := database.GetRun(started.RunID) + if err != nil { + t.Fatal(err) + } + if stillRunning.Status != types.RunRunning || stillRunning.AwaitingAgentSince == nil { + t.Fatalf("unsigned supersede changed protected run: status=%s awaiting=%v", stillRunning.Status, stillRunning.AwaitingAgentSince) + } + + var info ipc.GetRunResult + if err := client.Call(ipc.MethodGetRun, &ipc.GetRunParams{RunID: started.RunID}, &info); err != nil { + t.Fatal(err) + } + if info.Run == nil || !info.Run.OwnerDecisionProtected || info.Run.OwnerDecisionHead != config.ExpectedHead { + t.Fatalf("protected run info = %+v", info.Run) + } + var response ipc.RespondResult + legacyErr := client.Call(ipc.MethodRespond, &ipc.RespondParams{RunID: started.RunID, Step: types.StepReview, Action: types.ActionApprove}, &response) + if legacyErr == nil || !strings.Contains(legacyErr.Error(), "signed decision envelope") { + t.Fatalf("legacy protected response error = %v", legacyErr) + } + + var challenge ipc.OwnerDecisionChallengeResult + if err := client.Call(ipc.MethodOwnerDecisionChallenge, &ipc.OwnerDecisionChallengeParams{RunID: started.RunID, Purpose: ownerdecision.PurposeRespond}, &challenge); err != nil { + t.Fatal(err) + } + envelope, err := ownerdecision.Sign(privateKey, challenge.Challenge, ownerdecision.Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + if err := client.Call(ipc.MethodRespond, &ipc.RespondParams{RunID: started.RunID, Decision: &envelope}, &response); err != nil { + t.Fatal(err) + } + completed := waitForRunTerminalState(t, database, started.RunID) + if completed.Status != types.RunCompleted { + t.Fatalf("protected run status = %s", completed.Status) + } + head, protected, err := database.OwnerDecisionHead(started.RunID) + if err != nil || !protected || head == config.ExpectedHead { + t.Fatalf("protected history head = %q protected=%v err=%v", head, protected, err) + } + if err := database.VerifyOwnerDecisionHistory(started.RunID, head); err != nil { + t.Fatal(err) + } +} + +func TestProtectedRunRequiresSignedCancellation(t *testing.T) { + startedStep := make(chan struct{}) + p, database := startTestDaemonWithSteps(t, func() []pipeline.Step { + return []pipeline.Step{&mockSlowStep{name: types.StepTest, started: startedStep}} + }) + repo, headSHA := setupTestGitRepo(t, p, database, "protected-cancel-repo") + config, privateKey := protectedRunConfig(t, repo.ID, "main", headSHA) + client, err := ipc.Dial(p.Socket()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + var started ipc.PushReceivedResult + if err := client.Call(ipc.MethodPushReceived, &ipc.PushReceivedParams{ + Gate: p.RepoDir("protected-cancel-repo"), Ref: "refs/heads/main", Old: strings.Repeat("0", 40), New: headSHA, OwnerDecision: config, + }, &started); err != nil { + t.Fatal(err) + } + select { + case <-startedStep: + case <-time.After(5 * time.Second): + t.Fatal("protected run did not start") + } + var cancelResult ipc.CancelRunResult + if err := client.Call(ipc.MethodCancelRun, &ipc.CancelRunParams{RunID: started.RunID}, &cancelResult); err == nil || !strings.Contains(err.Error(), "signed cancellation envelope") { + t.Fatalf("unsigned cancellation error = %v", err) + } + var challenge ipc.OwnerDecisionChallengeResult + if err := client.Call(ipc.MethodOwnerDecisionChallenge, &ipc.OwnerDecisionChallengeParams{RunID: started.RunID, Purpose: ownerdecision.PurposeCancel}, &challenge); err != nil { + t.Fatal(err) + } + envelope, err := ownerdecision.Sign(privateKey, challenge.Challenge, ownerdecision.Response{Action: types.ActionAbort}) + if err != nil { + t.Fatal(err) + } + crossHead := envelope.Clone() + crossHead.Challenge.PreviousHead = ownerdecision.DigestBytes([]byte("cross-head")) + if err := client.Call(ipc.MethodCancelRun, &ipc.CancelRunParams{RunID: started.RunID, Decision: &crossHead}, &cancelResult); err == nil { + t.Fatal("cross-head cancellation was accepted") + } + if err := client.Call(ipc.MethodCancelRun, &ipc.CancelRunParams{RunID: started.RunID, Decision: &envelope}, &cancelResult); err != nil { + t.Fatal(err) + } + terminal := waitForProtectedRunTerminal(t, database, started.RunID) + if terminal.Status != types.RunCancelled && terminal.Status != types.RunFailed { + t.Fatalf("cancelled protected run status = %s", terminal.Status) + } +} + +func TestProtectedRestartStaysParkedUntilSignedExternalHeadCheckpoint(t *testing.T) { + root, err := os.MkdirTemp("", "dtest-protected-restart") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + p := paths.WithRoot(root) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + mockClaude := writeMockClaude(t, t.TempDir()) + if err := os.WriteFile(p.ConfigFile(), []byte("agent: claude\nagent_path_override:\n claude: "+mockClaude+"\n"), 0o644); err != nil { + t.Fatal(err) + } + database, err := db.Open(filepath.Join(root, "no-mistakes.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + repo, headSHA := setupTestGitRepo(t, p, database, "protected-restart-repo") + run, err := database.InsertRun(repo.ID, "main", headSHA, headSHA) + if err != nil { + t.Fatal(err) + } + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + if _, err := database.ProtectRunOwnerDecisions(run.ID, publicKey); err != nil { + t.Fatal(err) + } + authority, err := database.GetOwnerDecisionAuthority(run.ID) + if err != nil { + t.Fatal(err) + } + genesisHead := authority.GenesisHead + if err := database.UpdateRunStatus(run.ID, types.RunRunning); err != nil { + t.Fatal(err) + } + worktree := p.WorktreeDir(repo.ID, run.ID) + if err := gitpkg.WorktreeAdd(context.Background(), p.RepoDir(repo.ID), worktree, headSHA); err != nil { + t.Fatal(err) + } + step, err := database.InsertStepResult(run.ID, types.StepReview) + if err != nil { + t.Fatal(err) + } + if err := database.StartStep(step.ID); err != nil { + t.Fatal(err) + } + findings := `{"findings":[{"id":"review-1","severity":"high","description":"owner choice","action":"ask-user"}]}` + if err := database.SetStepFindings(step.ID, findings); err != nil { + t.Fatal(err) + } + if _, err := database.InsertReviewStepRound(step.ID, 1, "initial", &findings, nil, headSHA, 1); err != nil { + t.Fatal(err) + } + if err := database.UpdateStepStatusWithDuration(step.ID, types.StepStatusAwaitingApproval, 1); err != nil { + t.Fatal(err) + } + if err := database.SetRunAwaitingAgent(run.ID); err != nil { + t.Fatal(err) + } + + errCh := make(chan error, 1) + go func() { + errCh <- RunWithOptions(p, database, func() []pipeline.Step { + return []pipeline.Step{&mockApprovalStep{name: types.StepReview}} + }) + }() + defer func() { + client, dialErr := ipc.Dial(p.Socket()) + if dialErr == nil { + _ = client.Call(ipc.MethodShutdown, &ipc.ShutdownParams{}, nil) + _ = client.Close() + } + select { + case <-errCh: + case <-time.After(35 * time.Second): + t.Error("protected restart daemon did not stop") + } + }() + waitForDaemonReady(t, p) + client, err := ipc.Dial(p.Socket()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + var response ipc.RespondResult + if err := client.Call(ipc.MethodRespond, &ipc.RespondParams{RunID: run.ID, Step: types.StepReview, Action: types.ActionApprove}, &response); err == nil { + t.Fatal("protected recovered gate resumed before signed checkpoint") + } + stillParked, err := database.GetRun(run.ID) + if err != nil || stillParked.Status != types.RunRunning || stillParked.AwaitingAgentSince == nil { + t.Fatalf("protected restart state = %+v, %v", stillParked, err) + } + + var checkpointChallenge ipc.OwnerDecisionChallengeResult + if err := client.Call(ipc.MethodOwnerDecisionChallenge, &ipc.OwnerDecisionChallengeParams{ + RunID: run.ID, Purpose: ownerdecision.PurposeCheckpoint, ExpectedHead: genesisHead, + }, &checkpointChallenge); err != nil { + t.Fatal(err) + } + checkpoint, err := ownerdecision.Sign(privateKey, checkpointChallenge.Challenge, ownerdecision.Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + wrong := checkpointChallenge.Challenge + wrong.Nonce += "-replayed-or-tampered" + wrongCheckpoint, err := ownerdecision.Sign(privateKey, wrong, ownerdecision.Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + var checkpointResult ipc.OwnerDecisionCheckpointResult + if err := client.Call(ipc.MethodOwnerDecisionCheckpoint, &ipc.OwnerDecisionCheckpointParams{RunID: run.ID, Decision: wrongCheckpoint}, &checkpointResult); err == nil { + t.Fatal("wrong external history head resumed protected run") + } + if err := client.Call(ipc.MethodOwnerDecisionCheckpoint, &ipc.OwnerDecisionCheckpointParams{RunID: run.ID, Decision: checkpoint}, &checkpointResult); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(10 * time.Second) + var gateChallenge ipc.OwnerDecisionChallengeResult + for { + err = client.Call(ipc.MethodOwnerDecisionChallenge, &ipc.OwnerDecisionChallengeParams{RunID: run.ID, Purpose: ownerdecision.PurposeRespond}, &gateChallenge) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("protected recovered gate did not arm: %v", err) + } + time.Sleep(20 * time.Millisecond) + } + approval, err := ownerdecision.Sign(privateKey, gateChallenge.Challenge, ownerdecision.Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + if err := client.Call(ipc.MethodRespond, &ipc.RespondParams{RunID: run.ID, Decision: &approval}, &response); err != nil { + t.Fatal(err) + } + completed := waitForProtectedRunTerminal(t, database, run.ID) + if completed.Status != types.RunCompleted { + t.Fatalf("protected recovered run status = %s", completed.Status) + } +} + +func TestProtectedRestartCheckpointNonceRefusesRollbackReplay(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "checkpoint-replay.db") + database, err := db.Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer database.Close() + repo, err := database.InsertRepo(t.TempDir(), "https://example.invalid/repo.git", "main") + if err != nil { + t.Fatal(err) + } + run, err := database.InsertRun(repo.ID, "feature", "head-1", "base-1") + if err != nil { + t.Fatal(err) + } + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + authority, err := database.ProtectRunOwnerDecisions(run.ID, publicKey) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateRunStatus(run.ID, types.RunRunning); err != nil { + t.Fatal(err) + } + step, err := database.InsertStepResult(run.ID, types.StepReview) + if err != nil { + t.Fatal(err) + } + if err := database.StartStep(step.ID); err != nil { + t.Fatal(err) + } + findings := `{"findings":[{"id":"review-1","severity":"warning","description":"owner choice","action":"ask-user"}]}` + if err := database.SetStepFindings(step.ID, findings); err != nil { + t.Fatal(err) + } + round, err := database.InsertReviewStepRound(step.ID, 1, "initial", &findings, nil, run.HeadSHA, 1) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateStepStatusWithDuration(step.ID, types.StepStatusAwaitingApproval, 1); err != nil { + t.Fatal(err) + } + if err := database.SetRunAwaitingAgent(run.ID); err != nil { + t.Fatal(err) + } + + plan := recoveredRunPlan{run: run, repo: repo} + firstManager := NewRunManager(database, paths.WithRoot(t.TempDir()), func() []pipeline.Step { return nil }) + firstManager.pendingProtected[run.ID] = plan + checkpointH1, err := firstManager.HandleOwnerDecisionChallenge(run.ID, ownerdecision.PurposeCheckpoint, authority.GenesisHead) + if err != nil { + t.Fatal(err) + } + oldCheckpoint, err := ownerdecision.Sign(privateKey, checkpointH1, ownerdecision.Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + decisionChallenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, Purpose: ownerdecision.PurposeRespond, + RunID: run.ID, RepoID: repo.ID, Branch: run.Branch, HeadSHA: authority.InitialHeadSHA, GateHeadSHA: run.HeadSHA, + Step: types.StepReview, StepResultID: step.ID, RoundID: round.ID, + FindingsDigest: ownerdecision.DigestBytes([]byte(findings)), PreviousHead: authority.GenesisHead, + Nonce: "respond:" + round.ID + ":" + authority.GenesisHead, + IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), + } + decision, err := ownerdecision.Sign(privateKey, decisionChallenge, ownerdecision.Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + appended, err := database.AppendOwnerDecision(run.ID, ownerdecision.PurposeRespond+":"+round.ID, decision, decisionChallenge, &db.OwnerDecisionProjection{ + RoundID: round.ID, SelectedFindingIDs: db.DeclinedSelectionJSON, SelectionSource: db.RoundSelectionSourceUserDeclined, + }, now) + if err != nil { + t.Fatal(err) + } + + raw, err := sql.Open("sqlite", dbPath+"?_pragma=busy_timeout(5000)") + if err != nil { + t.Fatal(err) + } + if _, err := raw.Exec(`DELETE FROM owner_decision_events WHERE run_id = ?`, run.ID); err != nil { + raw.Close() + t.Fatal(err) + } + if _, err := raw.Exec(`UPDATE step_rounds SET selected_finding_ids = NULL, selection_source = NULL, user_findings_json = NULL WHERE id = ?`, round.ID); err != nil { + raw.Close() + t.Fatal(err) + } + if err := raw.Close(); err != nil { + t.Fatal(err) + } + + secondManager := NewRunManager(database, paths.WithRoot(t.TempDir()), func() []pipeline.Step { return nil }) + secondManager.pendingProtected[run.ID] = plan + if err := secondManager.HandleOwnerDecisionCheckpoint(run.ID, oldCheckpoint); err == nil || !strings.Contains(err.Error(), "fresh daemon-issued challenge") { + t.Fatalf("old checkpoint replay after restart error = %v", err) + } + if _, err := secondManager.HandleOwnerDecisionChallenge(run.ID, ownerdecision.PurposeCheckpoint, appended.Head); err == nil { + t.Fatal("fresh checkpoint challenge accepted controller H2 against rolled-back local H1") + } + freshH1, err := secondManager.HandleOwnerDecisionChallenge(run.ID, ownerdecision.PurposeCheckpoint, authority.GenesisHead) + if err != nil { + t.Fatal(err) + } + if freshH1.Nonce == checkpointH1.Nonce { + t.Fatal("daemon restart reused the prior checkpoint nonce") + } +} + +func TestStartupFailsIncompleteProtectedPendingBeforeProviderAndAllowsFreshRun(t *testing.T) { + p := paths.WithRoot(t.TempDir()) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + database, err := db.Open(p.DB()) + if err != nil { + t.Fatal(err) + } + defer database.Close() + repo, headSHA := setupTestGitRepo(t, p, database, "protected-pending-crash-repo") + run, err := database.InsertRun(repo.ID, "feature", headSHA, headSHA) + if err != nil { + t.Fatal(err) + } + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + if _, err := database.ProtectRunOwnerDecisions(run.ID, publicKey); err != nil { + t.Fatal(err) + } + cancelRun, err := database.InsertRun(repo.ID, "cancel-feature", headSHA, headSHA) + if err != nil { + t.Fatal(err) + } + cancelAuthority, err := database.ProtectRunOwnerDecisions(cancelRun.ID, publicKey) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateRunStatus(cancelRun.ID, types.RunRunning); err != nil { + t.Fatal(err) + } + cancelWorktree := p.WorktreeDir(repo.ID, cancelRun.ID) + if err := gitpkg.WorktreeAdd(context.Background(), p.RepoDir(repo.ID), cancelWorktree, headSHA); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cancelWorktree, "cancelled-work.txt"), []byte("preserve me\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := gitpkg.Run(context.Background(), cancelWorktree, "add", "cancelled-work.txt"); err != nil { + t.Fatal(err) + } + if _, err := gitpkg.Run(context.Background(), cancelWorktree, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "unpublished protected work"); err != nil { + t.Fatal(err) + } + advancedHead, err := gitpkg.HeadSHA(context.Background(), cancelWorktree) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateRunHeadSHA(cancelRun.ID, advancedHead); err != nil { + t.Fatal(err) + } + cancelRun.HeadSHA = advancedHead + now := time.Now().UTC() + cancelChallenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, Purpose: ownerdecision.PurposeCancel, + RunID: cancelRun.ID, RepoID: repo.ID, Branch: cancelRun.Branch, + HeadSHA: cancelAuthority.InitialHeadSHA, GateHeadSHA: cancelRun.HeadSHA, + PreviousHead: cancelAuthority.GenesisHead, Nonce: "cancel:" + cancelRun.ID + ":" + cancelAuthority.GenesisHead, + IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), + } + cancelEnvelope, err := ownerdecision.Sign(privateKey, cancelChallenge, ownerdecision.Response{Action: types.ActionAbort}) + if err != nil { + t.Fatal(err) + } + if _, err := database.AppendOwnerDecision(cancelRun.ID, ownerdecision.PurposeCancel+":"+cancelChallenge.Nonce, cancelEnvelope, cancelChallenge, nil, now); err != nil { + t.Fatal(err) + } + providerSetupCalls := 0 + mgr := NewRunManager(database, p, func() []pipeline.Step { + providerSetupCalls++ + return nil + }) + recoverOnStartup(database, p, mgr, worktrees.New(p, nil)) + after, err := database.GetRun(run.ID) + if err != nil { + t.Fatal(err) + } + if after.Status != types.RunFailed || after.HeadSHA != headSHA || providerSetupCalls != 0 { + t.Fatalf("incomplete protected startup recovery = %+v provider_setup_calls=%d", after, providerSetupCalls) + } + cancelAfter, err := database.GetRun(cancelRun.ID) + if err != nil { + t.Fatal(err) + } + if cancelAfter.Status != types.RunCancelled { + t.Fatalf("committed pre-crash cancellation recovered as %s", cancelAfter.Status) + } + if cancelAfter.TerminalHeadVerifiedAt == nil { + t.Fatal("committed cancellation was terminalized without anchored head evidence") + } + anchoredHead, err := gitpkg.Run(context.Background(), p.RepoDir(repo.ID), "rev-parse", "refs/no-mistakes/recover/"+cancelRun.ID) + if err != nil || anchoredHead != advancedHead { + t.Fatalf("committed cancellation lost unpublished head: anchor=%s want=%s err=%v", anchoredHead, advancedHead, err) + } + if err := mgr.cancelActiveRuns(repo.ID, run.Branch); err != nil { + t.Fatalf("failed protected row still wedged branch: %v", err) + } + if _, err := database.InsertRun(repo.ID, run.Branch, "fresh-head", "base"); err != nil { + t.Fatalf("fresh run could not proceed after recovery: %v", err) + } +} + +func TestProtectedRestartRefusesRunIdentityRewritesBeforeProviderSetup(t *testing.T) { + mutations := map[string]func(*testing.T, string, *db.DB, *db.Run){ + "repo": func(t *testing.T, dbPath string, database *db.DB, run *db.Run) { + other, err := database.InsertRepo(t.TempDir(), "https://example.invalid/other.git", "main") + if err != nil { + t.Fatal(err) + } + rewriteRunForOwnerDecisionTest(t, dbPath, run.ID, "repo_id", other.ID) + }, + "branch": func(t *testing.T, dbPath string, _ *db.DB, run *db.Run) { + rewriteRunForOwnerDecisionTest(t, dbPath, run.ID, "branch", "rewritten") + }, + "head": func(t *testing.T, dbPath string, _ *db.DB, run *db.Run) { + rewriteRunForOwnerDecisionTest(t, dbPath, run.ID, "head_sha", "rewritten-head") + }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + p := paths.WithRoot(t.TempDir()) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + dbPath := p.DB() + database, err := db.Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer database.Close() + repo, err := database.InsertRepo(t.TempDir(), "https://example.invalid/protected.git", "main") + if err != nil { + t.Fatal(err) + } + run, err := database.InsertRun(repo.ID, "feature", "sealed-head", "base") + if err != nil { + t.Fatal(err) + } + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + authority, err := database.ProtectRunOwnerDecisions(run.ID, publicKey) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateRunStatus(run.ID, types.RunRunning); err != nil { + t.Fatal(err) + } + step, err := database.InsertStepResult(run.ID, types.StepReview) + if err != nil { + t.Fatal(err) + } + if err := database.StartStep(step.ID); err != nil { + t.Fatal(err) + } + findings := `{"findings":[{"id":"review-1","severity":"high","description":"owner choice","action":"ask-user"}]}` + if err := database.SetStepFindings(step.ID, findings); err != nil { + t.Fatal(err) + } + round, err := database.InsertReviewStepRound(step.ID, 1, "initial", &findings, nil, run.HeadSHA, 1) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateStepStatusWithDuration(step.ID, types.StepStatusAwaitingApproval, 1); err != nil { + t.Fatal(err) + } + if err := database.SetRunAwaitingAgent(run.ID); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + challenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, Purpose: ownerdecision.PurposeRespond, + RunID: run.ID, RepoID: repo.ID, Branch: run.Branch, + HeadSHA: authority.InitialHeadSHA, GateHeadSHA: run.HeadSHA, + Step: types.StepReview, StepResultID: step.ID, RoundID: round.ID, + FindingsDigest: ownerdecision.DigestBytes([]byte(findings)), PreviousHead: authority.GenesisHead, + Nonce: "respond:" + round.ID + ":" + authority.GenesisHead, + IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), + } + envelope, err := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + if _, err := database.AppendOwnerDecision(run.ID, ownerdecision.PurposeRespond+":"+round.ID, envelope, challenge, &db.OwnerDecisionProjection{ + RoundID: round.ID, SelectedFindingIDs: db.DeclinedSelectionJSON, SelectionSource: db.RoundSelectionSourceUserDeclined, + }, now); err != nil { + t.Fatal(err) + } + mutate(t, dbPath, database, run) + rewritten, err := database.GetRun(run.ID) + if err != nil { + t.Fatal(err) + } + providerSetupCalls := 0 + mgr := NewRunManager(database, p, func() []pipeline.Step { + providerSetupCalls++ + return nil + }) + if _, err := mgr.prepareRecoveredRun(context.Background(), rewritten); err == nil { + t.Fatal("rewritten protected run prepared for restart") + } + if providerSetupCalls != 0 { + t.Fatalf("provider setup ran %d times before identity refusal", providerSetupCalls) + } + }) + } +} + +func rewriteRunForOwnerDecisionTest(t *testing.T, dbPath, runID, column, value string) { + t.Helper() + raw, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(on)&_pragma=busy_timeout(5000)") + if err != nil { + t.Fatal(err) + } + defer raw.Close() + if _, err := raw.Exec(`UPDATE runs SET `+column+` = ? WHERE id = ?`, value, runID); err != nil { + t.Fatal(err) + } +} diff --git a/internal/daemon/subscribe_recover_test.go b/internal/daemon/subscribe_recover_test.go index ad0378d35..de910fcda 100644 --- a/internal/daemon/subscribe_recover_test.go +++ b/internal/daemon/subscribe_recover_test.go @@ -447,7 +447,7 @@ func TestRecoverOnStartup_FinalizesLegacyTerminalPRRun(t *testing.T) { } } -func TestRecoverOnStartup_ResumesParkedRun(t *testing.T) { +func TestRecoverOnStartup_UnboundLegacyParkedRunFailsClosed(t *testing.T) { tmpDir, err := os.MkdirTemp("", "dtest") if err != nil { t.Fatal(err) @@ -529,60 +529,22 @@ func TestRecoverOnStartup_ResumesParkedRun(t *testing.T) { // where the git-backed recovery path can exceed the old five-second loop. waitForDaemonReady(t, p) - deadline := time.Now().Add(5 * time.Second) - var lastErr error - for { - if time.Now().After(deadline) { - recovered, getErr := d.GetRun(run.ID) - t.Fatalf("recovered gate never accepted an approval: last error %v, run %#v, get run error %v", lastErr, recovered, getErr) - } - client, err := ipc.Dial(p.Socket()) - if err == nil { - var response ipc.RespondResult - err = client.Call(ipc.MethodRespond, &ipc.RespondParams{ - RunID: run.ID, - Step: types.StepReview, - Action: types.ActionApprove, - }, &response) - _ = client.Close() - if err == nil { - break - } - lastErr = err - } else { - lastErr = err - } - time.Sleep(20 * time.Millisecond) + failed := waitForRunTerminalState(t, d, run.ID) + if failed.Status != types.RunFailed || failed.Error == nil || !strings.Contains(*failed.Error, "unbound legacy run") { + t.Fatalf("unbound recovered run = status %s error %v", failed.Status, failed.Error) } - - completed := waitForRunTerminalState(t, d, run.ID) - if completed.Status != types.RunCompleted { - t.Fatalf("recovered run status = %s, want completed", completed.Status) + if failed.AwaitingAgentSince != nil { + t.Fatal("unbound recovered run retained its awaiting-agent marker") } - if completed.AwaitingAgentSince != nil { - t.Fatal("recovered run remained parked after approval") + if failed.TerminalHeadVerifiedAt == nil || failed.HeadSHA != headSHA { + t.Fatalf("unbound recovery lost verified custody head: head=%s verified=%v", failed.HeadSHA, failed.TerminalHeadVerifiedAt) } - if completed.ReviewApprovedHeadSHA == nil || *completed.ReviewApprovedHeadSHA != headSHA { - t.Fatalf("recovered review approval = %#v, want %s", completed.ReviewApprovedHeadSHA, headSHA) - } - // The executor marks the run terminal before its owner goroutine performs - // worktree cleanup. Wait for that cleanup rather than assuming it completed - // in the same scheduling slice, which is especially unreliable on Windows. - cleanupDeadline := time.Now().Add(5 * time.Second) - for { - if _, err := os.Stat(worktree); os.IsNotExist(err) { - break - } else if err != nil { - t.Fatalf("stat recovered worktree: %v", err) - } - if time.Now().After(cleanupDeadline) { - t.Fatalf("recovered worktree still exists after cleanup: %s", worktree) - } - time.Sleep(20 * time.Millisecond) + if _, err := os.Stat(worktree); err != nil { + t.Fatalf("unbound recovery removed retained worktree: %v", err) } } -func TestRecoverOnStartup_ReconcilesHistoricalCIGateFromCurrentPRState(t *testing.T) { +func TestRecoverOnStartup_RefusesUnboundHistoricalCIGate(t *testing.T) { for _, state := range []string{"MERGED", "CLOSED"} { t.Run(state, func(t *testing.T) { tmpDir, err := os.MkdirTemp("", "dtest") @@ -664,9 +626,9 @@ func TestRecoverOnStartup_ReconcilesHistoricalCIGateFromCurrentPRState(t *testin }() waitForDaemonReady(t, p) - completed := waitForRunTerminalState(t, d, run.ID) - if completed.Status != types.RunCompleted || completed.AwaitingAgentSince != nil { - t.Fatalf("historical CI gate after %s reconciliation = status %s awaiting %v", state, completed.Status, completed.AwaitingAgentSince) + failed := waitForRunTerminalState(t, d, run.ID) + if failed.Status != types.RunFailed || failed.AwaitingAgentSince != nil || failed.Error == nil || !strings.Contains(*failed.Error, "unbound legacy run") { + t.Fatalf("unbound historical CI gate with provider state %s = status %s awaiting %v error %v", state, failed.Status, failed.AwaitingAgentSince, failed.Error) } active, err := lifecycle.ActiveRuns(p) if err != nil { @@ -675,13 +637,11 @@ func TestRecoverOnStartup_ReconcilesHistoricalCIGateFromCurrentPRState(t *testin if len(active) != 0 { t.Fatalf("update guard still sees %d active runs after reconciliation", len(active)) } - logData, err := os.ReadFile(ghLog) - if err != nil { + if logData, err := os.ReadFile(ghLog); err == nil && len(logData) != 0 { + t.Fatalf("unbound recovery consulted external PR state before failing closed: %s", logData) + } else if err != nil && !os.IsNotExist(err) { t.Fatal(err) } - if !strings.Contains(string(logData), "pr view 42") { - t.Fatalf("startup reconciliation did not read current PR state: %s", logData) - } }) } } diff --git a/internal/db/owner_decision.go b/internal/db/owner_decision.go new file mode 100644 index 000000000..937403f54 --- /dev/null +++ b/internal/db/owner_decision.go @@ -0,0 +1,1037 @@ +package db + +import ( + "bytes" + "crypto/ed25519" + "database/sql" + "encoding/json" + "errors" + "fmt" + "slices" + "time" + + "github.com/kunchenguid/no-mistakes/internal/ownerdecision" + "github.com/kunchenguid/no-mistakes/internal/types" +) + +type OwnerDecisionAuthority struct { + RunID string + PublicKey ed25519.PublicKey + KeyID string + RepoID string + Branch string + InitialHeadSHA string + GenesisHead string + CreatedAt int64 +} + +// OwnerDecisionProjection is the mutable round state materialized by one +// signed decision. It is included in the hash-chained journal record. +type OwnerDecisionProjection struct { + RoundID string `json:"round_id"` + SelectedFindingIDs string `json:"selected_finding_ids"` + SelectionSource string `json:"selection_source"` + UserFindingsJSON *string `json:"user_findings_json,omitempty"` +} + +type OwnerDecisionAppendResult struct { + Sequence int + Head string + Replay bool +} + +// ProtectedCrashRecovery reports protected active rows closed before ordinary +// stale-run recovery. +type ProtectedCrashRecovery struct { + PendingFailed int + CancellationRunIDs []string +} + +type ownerDecisionJournalRecord struct { + Envelope ownerdecision.Envelope `json:"envelope"` + Projection *OwnerDecisionProjection `json:"projection,omitempty"` +} + +type ownerDecisionEvent struct { + Sequence int + GateID string + PreviousHead string + RecordDigest string + HistoryHead string + EnvelopeJSON string + Projection *OwnerDecisionProjection +} + +// ProtectRunOwnerDecisions binds a run to exactly one Ed25519 public key and +// its immutable submitted repository, branch, and initial head identity. +// Repeating the same binding is idempotent; replacing it is forbidden. +func (d *DB) ProtectRunOwnerDecisions(runID string, publicKey ed25519.PublicKey) (*OwnerDecisionAuthority, error) { + if len(publicKey) != ed25519.PublicKeySize { + return nil, fmt.Errorf("protect owner decisions: invalid Ed25519 public key length %d", len(publicKey)) + } + keyID, err := ownerdecision.KeyID(publicKey) + if err != nil { + return nil, err + } + tx, err := d.sql.Begin() + if err != nil { + return nil, fmt.Errorf("begin owner decision protection: %w", err) + } + defer tx.Rollback() + + var repoID, branch, currentHead string + var submittedHead sql.NullString + if err := tx.QueryRow(`SELECT repo_id, branch, head_sha, submitted_head_sha FROM runs WHERE id = ?`, runID).Scan(&repoID, &branch, ¤tHead, &submittedHead); err != nil { + if err == sql.ErrNoRows { + return nil, errors.New("protect owner decisions: run not found") + } + return nil, fmt.Errorf("find protected run: %w", err) + } + if !submittedHead.Valid || submittedHead.String == "" || currentHead != submittedHead.String { + return nil, errors.New("protect owner decisions: run is not at its immutable submitted head") + } + genesisHead, err := ownerdecision.GenesisHeadForRun(publicKey, repoID, branch, submittedHead.String) + if err != nil { + return nil, err + } + var stored []byte + var storedKeyID string + var storedRepoID, storedBranch, storedInitialHead string + var storedGenesis string + var createdAt int64 + err = tx.QueryRow( + `SELECT public_key, key_id, repo_id, branch, initial_head_sha, genesis_head, created_at FROM owner_decision_authorities WHERE run_id = ?`, + runID, + ).Scan(&stored, &storedKeyID, &storedRepoID, &storedBranch, &storedInitialHead, &storedGenesis, &createdAt) + if err == nil { + if !bytes.Equal(stored, publicKey) || storedKeyID != keyID || storedRepoID != repoID || storedBranch != branch || + storedInitialHead != submittedHead.String || storedGenesis != genesisHead { + return nil, errors.New("protect owner decisions: run is already bound to a different authority") + } + return &OwnerDecisionAuthority{RunID: runID, PublicKey: slices.Clone(publicKey), KeyID: keyID, RepoID: repoID, Branch: branch, InitialHeadSHA: submittedHead.String, GenesisHead: genesisHead, CreatedAt: createdAt}, nil + } + if err != sql.ErrNoRows { + return nil, fmt.Errorf("read owner decision authority: %w", err) + } + createdAt = now() + if _, err := tx.Exec( + `INSERT INTO owner_decision_authorities (run_id, public_key, key_id, repo_id, branch, initial_head_sha, genesis_head, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + runID, []byte(publicKey), keyID, repoID, branch, submittedHead.String, genesisHead, createdAt, + ); err != nil { + return nil, fmt.Errorf("insert owner decision authority: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit owner decision authority: %w", err) + } + return &OwnerDecisionAuthority{RunID: runID, PublicKey: slices.Clone(publicKey), KeyID: keyID, RepoID: repoID, Branch: branch, InitialHeadSHA: submittedHead.String, GenesisHead: genesisHead, CreatedAt: createdAt}, nil +} + +func (d *DB) GetOwnerDecisionAuthority(runID string) (*OwnerDecisionAuthority, error) { + authority := &OwnerDecisionAuthority{RunID: runID} + var publicKey []byte + err := d.sql.QueryRow( + `SELECT public_key, key_id, repo_id, branch, initial_head_sha, genesis_head, created_at FROM owner_decision_authorities WHERE run_id = ?`, + runID, + ).Scan(&publicKey, &authority.KeyID, &authority.RepoID, &authority.Branch, &authority.InitialHeadSHA, &authority.GenesisHead, &authority.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get owner decision authority: %w", err) + } + if len(publicKey) != ed25519.PublicKeySize { + return nil, errors.New("get owner decision authority: stored public key is invalid") + } + authority.PublicKey = ed25519.PublicKey(slices.Clone(publicKey)) + derivedKeyID, err := ownerdecision.KeyID(authority.PublicKey) + if err != nil || derivedKeyID != authority.KeyID { + return nil, errors.New("get owner decision authority: stored key id is invalid") + } + if derived, err := ownerdecision.GenesisHeadForRun(authority.PublicKey, authority.RepoID, authority.Branch, authority.InitialHeadSHA); err != nil || derived != authority.GenesisHead { + return nil, errors.New("get owner decision authority: stored key binding is invalid") + } + return authority, nil +} + +// OwnerDecisionHead returns the recomputed current journal head. An +// unprotected legacy run returns ("", false, nil). +func (d *DB) OwnerDecisionHead(runID string) (string, bool, error) { + authority, err := d.GetOwnerDecisionAuthority(runID) + if err != nil || authority == nil { + return "", false, err + } + head, err := d.verifyOwnerDecisionHistory(runID, authority, "", d.sql) + return head, true, err +} + +// AppendOwnerDecision verifies and appends a signed journal record and +// materializes its round projection in one transaction. The transaction +// commits before the executor is allowed to release its approval wait. +func (d *DB) AppendOwnerDecision(runID, gateID string, envelope ownerdecision.Envelope, expected ownerdecision.Challenge, projection *OwnerDecisionProjection, admittedAt time.Time) (OwnerDecisionAppendResult, error) { + tx, err := d.sql.Begin() + if err != nil { + return OwnerDecisionAppendResult{}, fmt.Errorf("begin owner decision append: %w", err) + } + defer tx.Rollback() + + authority, err := getOwnerDecisionAuthorityTx(tx, runID) + if err != nil { + return OwnerDecisionAppendResult{}, err + } + if authority == nil { + return OwnerDecisionAppendResult{}, errors.New("append owner decision: run is not protected") + } + if err := ownerdecision.Verify(authority.PublicKey, envelope, expected, admittedAt); err != nil { + return OwnerDecisionAppendResult{}, fmt.Errorf("append owner decision: %w", err) + } + if err := validateOwnerChallengeTx(tx, authority, runID, gateID, expected); err != nil { + return OwnerDecisionAppendResult{}, err + } + if err := validateOwnerProjectionQuery(tx, runID, envelope, projection); err != nil { + return OwnerDecisionAppendResult{}, err + } + + recordDigest, envelopeJSON, err := encodeOwnerDecisionRecord(envelope, projection) + if err != nil { + return OwnerDecisionAppendResult{}, err + } + if existing, err := getOwnerDecisionEventTx(tx, runID, gateID); err != nil { + return OwnerDecisionAppendResult{}, err + } else if existing != nil { + if existing.RecordDigest != recordDigest || existing.EnvelopeJSON != envelopeJSON || !equalOwnerProjection(existing.Projection, projection) { + return OwnerDecisionAppendResult{}, errors.New("append owner decision: gate already has a different decision") + } + if err := verifyOwnerProjectionTx(tx, existing.Projection); err != nil { + return OwnerDecisionAppendResult{}, fmt.Errorf("append owner decision replay: %w", err) + } + currentHead, err := d.verifyOwnerDecisionHistory(runID, authority, "", tx) + if err != nil { + return OwnerDecisionAppendResult{}, fmt.Errorf("append owner decision replay: existing history: %w", err) + } + if existing.HistoryHead != currentHead { + return OwnerDecisionAppendResult{}, errors.New("append owner decision replay: gate event is not the history tip") + } + return OwnerDecisionAppendResult{Sequence: existing.Sequence, Head: existing.HistoryHead, Replay: true}, nil + } + + currentHead, err := d.verifyOwnerDecisionHistory(runID, authority, "", tx) + if err != nil { + return OwnerDecisionAppendResult{}, fmt.Errorf("append owner decision: existing history: %w", err) + } + if expected.PreviousHead != currentHead { + return OwnerDecisionAppendResult{}, fmt.Errorf("append owner decision: history head is %s, challenge expects %s", currentHead, expected.PreviousHead) + } + sequence, err := ownerDecisionEventCountTx(tx, runID) + if err != nil { + return OwnerDecisionAppendResult{}, err + } + sequence++ + envelopeDigest, err := ownerdecision.EnvelopeDigest(envelope) + if err != nil { + return OwnerDecisionAppendResult{}, err + } + historyHead, err := ownerdecision.NextHead(currentHead, envelopeDigest) + if err != nil { + return OwnerDecisionAppendResult{}, err + } + if projection != nil { + result, err := tx.Exec( + `UPDATE step_rounds + SET selected_finding_ids = ?, selection_source = ?, user_findings_json = ? + WHERE id = ? AND selection_source IS NULL AND selected_finding_ids IS NULL AND user_findings_json IS NULL`, + projection.SelectedFindingIDs, projection.SelectionSource, projection.UserFindingsJSON, projection.RoundID, + ) + if err != nil { + return OwnerDecisionAppendResult{}, fmt.Errorf("append owner decision projection: %w", err) + } + changed, err := result.RowsAffected() + if err != nil || changed != 1 { + return OwnerDecisionAppendResult{}, fmt.Errorf("append owner decision projection: expected one untouched round, changed %d: %w", changed, err) + } + } + createdAt := admittedAt.Unix() + var roundID, selected, source any + var userFindings any + if projection != nil { + roundID = projection.RoundID + selected = projection.SelectedFindingIDs + source = projection.SelectionSource + userFindings = projection.UserFindingsJSON + } + if _, err := tx.Exec( + `INSERT INTO owner_decision_events + (run_id, sequence, gate_id, previous_head, record_digest, history_head, envelope_json, + projection_round_id, selected_finding_ids, selection_source, user_findings_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + runID, sequence, gateID, currentHead, recordDigest, historyHead, envelopeJSON, + roundID, selected, source, userFindings, createdAt, + ); err != nil { + return OwnerDecisionAppendResult{}, fmt.Errorf("insert owner decision event: %w", err) + } + if err := tx.Commit(); err != nil { + return OwnerDecisionAppendResult{}, fmt.Errorf("commit owner decision append: %w", err) + } + return OwnerDecisionAppendResult{Sequence: sequence, Head: historyHead}, nil +} + +// VerifyOwnerDecisionHistory verifies signatures, the complete hash chain, +// the external expected head, and every mutable round projection. +func (d *DB) VerifyOwnerDecisionHistory(runID, expectedHead string) error { + authority, err := d.GetOwnerDecisionAuthority(runID) + if err != nil { + return err + } + if authority == nil { + return errors.New("verify owner decision history: run is not protected") + } + _, err = d.verifyOwnerDecisionHistory(runID, authority, expectedHead, d.sql) + return err +} + +// VerifyRecoveredOwnerDecisionRun verifies the locally available protected +// history and immutable run identity before restart recovery performs any +// agent or provider setup. If the parked gate already has a committed response +// event, it additionally binds the current run head to that exact signed gate; +// an old response can therefore never be replayed after a local head rewrite. +func (d *DB) VerifyRecoveredOwnerDecisionRun(runID string) error { + tx, err := d.sql.Begin() + if err != nil { + return fmt.Errorf("begin recovered owner decision verification: %w", err) + } + defer tx.Rollback() + authority, err := getOwnerDecisionAuthorityTx(tx, runID) + if err != nil { + return err + } + if authority == nil { + return errors.New("verify recovered owner decision run: run is not protected") + } + head, err := d.verifyOwnerDecisionHistory(runID, authority, "", tx) + if err != nil { + return fmt.Errorf("verify recovered owner decision run: %w", err) + } + rows, err := tx.Query( + `SELECT (SELECT r.id FROM step_rounds r WHERE r.step_result_id = sr.id ORDER BY r.round DESC LIMIT 1) + FROM step_results sr + WHERE sr.run_id = ? AND sr.status IN (?, ?) + ORDER BY sr.step_order`, + runID, types.StepStatusAwaitingApproval, types.StepStatusFixReview, + ) + if err != nil { + return fmt.Errorf("verify recovered owner decision gate: %w", err) + } + var gates []sql.NullString + for rows.Next() { + var gate sql.NullString + if err := rows.Scan(&gate); err != nil { + rows.Close() + return err + } + gates = append(gates, gate) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + if len(gates) != 1 || !gates[0].Valid { + return fmt.Errorf("verify recovered owner decision gate: expected one parked step with a round, found %d", len(gates)) + } + gateID := ownerdecision.PurposeRespond + ":" + gates[0].String + event, err := getOwnerDecisionEventTx(tx, runID, gateID) + if err != nil { + return err + } + if event == nil { + return nil + } + if event.HistoryHead != head { + return errors.New("verify recovered owner decision gate: committed response is not the history tip") + } + var envelope ownerdecision.Envelope + if err := json.Unmarshal([]byte(event.EnvelopeJSON), &envelope); err != nil { + return fmt.Errorf("verify recovered owner decision gate: decode envelope: %w", err) + } + if err := validateOwnerChallengeTx(tx, authority, runID, gateID, envelope.Challenge); err != nil { + return fmt.Errorf("verify recovered owner decision gate: %w", err) + } + return nil +} + +// CommittedOwnerResponse returns the already-admitted response for one +// recovered gate when that event is the verified journal tip bound by the +// controller's external expected head. This closes the crash window after the +// append transaction commits but before the in-memory approval channel is +// released: recovery replays the durable response, never a second event. +func (d *DB) CommittedOwnerResponse(runID, gateID, expectedHead string) (ownerdecision.Envelope, bool, error) { + tx, err := d.sql.Begin() + if err != nil { + return ownerdecision.Envelope{}, false, fmt.Errorf("begin committed owner response: %w", err) + } + defer tx.Rollback() + authority, err := getOwnerDecisionAuthorityTx(tx, runID) + if err != nil { + return ownerdecision.Envelope{}, false, err + } + if authority == nil { + return ownerdecision.Envelope{}, false, errors.New("committed owner response: run is not protected") + } + head, err := d.verifyOwnerDecisionHistory(runID, authority, expectedHead, tx) + if err != nil { + return ownerdecision.Envelope{}, false, fmt.Errorf("committed owner response: verify history: %w", err) + } + event, err := getOwnerDecisionEventTx(tx, runID, gateID) + if err != nil { + return ownerdecision.Envelope{}, false, err + } + if event == nil { + return ownerdecision.Envelope{}, false, nil + } + count, err := ownerDecisionEventCountTx(tx, runID) + if err != nil { + return ownerdecision.Envelope{}, false, err + } + if event.Sequence != count || event.HistoryHead != head { + return ownerdecision.Envelope{}, false, errors.New("committed owner response: recovered gate event is not the verified history tip") + } + var envelope ownerdecision.Envelope + if err := json.Unmarshal([]byte(event.EnvelopeJSON), &envelope); err != nil { + return ownerdecision.Envelope{}, false, fmt.Errorf("committed owner response: decode envelope: %w", err) + } + if envelope.Challenge.Purpose != ownerdecision.PurposeRespond || gateID != ownerGateID(envelope.Challenge) { + return ownerdecision.Envelope{}, false, errors.New("committed owner response: event is not a response for the recovered gate") + } + if err := validateOwnerChallengeTx(tx, authority, runID, gateID, envelope.Challenge); err != nil { + return ownerdecision.Envelope{}, false, fmt.Errorf("committed owner response: %w", err) + } + if err := validateOwnerProjection(envelope, event.Projection); err != nil { + return ownerdecision.Envelope{}, false, err + } + return envelope.Clone(), true, nil +} + +// CommittedOwnerCancellation reports whether the verified journal tip is a +// signed cancellation that committed before a process crash could invoke the +// in-memory cancel function. +func (d *DB) CommittedOwnerCancellation(runID, expectedHead string) (bool, error) { + tx, err := d.sql.Begin() + if err != nil { + return false, fmt.Errorf("begin committed owner cancellation: %w", err) + } + defer tx.Rollback() + return d.committedOwnerCancellationTx(tx, runID, expectedHead) +} + +func (d *DB) committedOwnerCancellationTx(tx *sql.Tx, runID, expectedHead string) (bool, error) { + authority, err := getOwnerDecisionAuthorityTx(tx, runID) + if err != nil { + return false, err + } + if authority == nil { + return false, errors.New("committed owner cancellation: run is not protected") + } + head, err := d.verifyOwnerDecisionHistory(runID, authority, expectedHead, tx) + if err != nil { + return false, fmt.Errorf("committed owner cancellation: verify history: %w", err) + } + row := tx.QueryRow( + `SELECT sequence, gate_id, previous_head, record_digest, history_head, envelope_json, + projection_round_id, selected_finding_ids, selection_source, user_findings_json + FROM owner_decision_events WHERE run_id = ? ORDER BY sequence DESC LIMIT 1`, + runID, + ) + event, err := scanOwnerDecisionEvent(row) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + if event.HistoryHead != head { + return false, errors.New("committed owner cancellation: tip does not match verified history head") + } + var envelope ownerdecision.Envelope + if err := json.Unmarshal([]byte(event.EnvelopeJSON), &envelope); err != nil { + return false, fmt.Errorf("committed owner cancellation: decode envelope: %w", err) + } + if envelope.Challenge.Purpose != ownerdecision.PurposeCancel { + return false, nil + } + if event.GateID != ownerGateID(envelope.Challenge) { + return false, errors.New("committed owner cancellation: gate binding is invalid") + } + if err := validateOwnerChallengeTx(tx, authority, runID, event.GateID, envelope.Challenge); err != nil { + return false, fmt.Errorf("committed owner cancellation: %w", err) + } + return true, nil +} + +// RecoverCommittedOwnerCancellation projects a reverified signed +// cancellation after its managed worktree head has been anchored by startup +// recovery. The verification and terminal transition share one transaction so +// a same-UID local rewrite cannot enter between them. +func (d *DB) RecoverCommittedOwnerCancellation(runID string) error { + tx, err := d.sql.Begin() + if err != nil { + return fmt.Errorf("begin recover committed owner cancellation: %w", err) + } + defer tx.Rollback() + committed, err := d.committedOwnerCancellationTx(tx, runID, "") + if err != nil { + return err + } + if !committed { + return errors.New("recover committed owner cancellation: cancellation is no longer the verified history tip") + } + ts := now() + changed, err := tx.Exec( + `UPDATE runs SET status = ?, error = ?, awaiting_agent_since = NULL, push_active = 0, updated_at = ? + WHERE id = ? AND status = ?`, + types.RunCancelled, types.RunCancelReasonAbortedByUser, ts, runID, types.RunRunning, + ) + if err != nil { + return fmt.Errorf("project committed protected cancellation: %w", err) + } + count, err := changed.RowsAffected() + if err != nil || count != 1 { + return fmt.Errorf("project committed protected cancellation: expected one row, changed %d: %w", count, err) + } + if _, err := tx.Exec( + `UPDATE step_results SET status = ?, error = ?, completed_at = ?, agent_pid = NULL + WHERE run_id = ? AND status IN (?, ?, ?, ?)`, + types.StepStatusFailed, types.RunCancelReasonAbortedByUser, ts, runID, + types.StepStatusRunning, types.StepStatusAwaitingApproval, types.StepStatusFixing, types.StepStatusFixReview, + ); err != nil { + return fmt.Errorf("project committed protected cancellation steps: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit recovered owner cancellation: %w", err) + } + return nil +} + +// FailUnboundRecoveredRun terminalizes a parked run whose protected-vs-legacy +// identity cannot be proven after restart. Because all local markers are +// writable by the workload UID, treating "authority row absent" as legacy +// would be a protected-mode downgrade. The already-validated worktree head is +// preserved as terminal custody evidence for a safe later rerun. +func (d *DB) FailUnboundRecoveredRun(runID, verifiedHead, reason string) error { + tx, err := d.sql.Begin() + if err != nil { + return err + } + defer tx.Rollback() + ts := now() + result, err := tx.Exec( + `UPDATE runs + SET status = ?, error = ?, head_sha = ?, terminal_head_verified_at = ?, + awaiting_agent_since = NULL, push_active = 0, updated_at = ? + WHERE id = ? AND status = ? AND awaiting_agent_since IS NOT NULL`, + types.RunFailed, reason, verifiedHead, ts, ts, runID, types.RunRunning, + ) + if err != nil { + return fmt.Errorf("fail unbound recovered run: %w", err) + } + changed, err := result.RowsAffected() + if err != nil || changed != 1 { + return fmt.Errorf("fail unbound recovered run: expected one parked run, changed %d: %w", changed, err) + } + if _, err := tx.Exec( + `UPDATE step_results SET status = ?, error = ?, completed_at = ?, agent_pid = NULL + WHERE run_id = ? AND status IN (?, ?)`, + types.StepStatusFailed, reason, ts, runID, types.StepStatusAwaitingApproval, types.StepStatusFixReview, + ); err != nil { + return fmt.Errorf("fail unbound recovered step: %w", err) + } + return tx.Commit() +} + +// ReconcileProtectedCrashStates closes protected crash seams that do not have +// an executor to resume. A protected pending row can only be the durable half +// of run creation, so it is restored to its sealed identity and failed before +// new work is admitted. Verified cancellation IDs are returned for the daemon +// to anchor their exact managed-worktree heads before a separate transactional +// terminal projection; neither phase recreates an executor or invokes a +// provider. +func (d *DB) ReconcileProtectedCrashStates() (ProtectedCrashRecovery, error) { + tx, err := d.sql.Begin() + if err != nil { + return ProtectedCrashRecovery{}, fmt.Errorf("begin protected crash recovery: %w", err) + } + defer tx.Rollback() + rows, err := tx.Query( + `SELECT r.id, r.status + FROM runs r JOIN owner_decision_authorities a ON a.run_id = r.id + WHERE r.status IN (?, ?) + ORDER BY r.created_at, r.id`, + types.RunPending, types.RunRunning, + ) + if err != nil { + return ProtectedCrashRecovery{}, fmt.Errorf("list protected crash states: %w", err) + } + type activeProtected struct { + id string + status types.RunStatus + } + var runs []activeProtected + for rows.Next() { + var run activeProtected + if err := rows.Scan(&run.id, &run.status); err != nil { + rows.Close() + return ProtectedCrashRecovery{}, err + } + runs = append(runs, run) + } + if err := rows.Err(); err != nil { + rows.Close() + return ProtectedCrashRecovery{}, err + } + if err := rows.Close(); err != nil { + return ProtectedCrashRecovery{}, err + } + + result := ProtectedCrashRecovery{} + for _, run := range runs { + authority, err := getOwnerDecisionAuthorityTx(tx, run.id) + if err != nil { + // The generic stale-run recovery that follows will still terminalize + // unreadable rows. Never let one corrupt authority prevent other + // protected pending rows from being closed first. + continue + } + if run.status == types.RunPending { + reason := "daemon crashed before protected executor registration" + ts := now() + changed, err := tx.Exec( + `UPDATE runs + SET repo_id = ?, branch = ?, head_sha = ?, submitted_head_sha = ?, + status = ?, error = ?, terminal_head_verified_at = ?, + awaiting_agent_since = NULL, push_active = 0, updated_at = ? + WHERE id = ? AND status = ?`, + authority.RepoID, authority.Branch, authority.InitialHeadSHA, authority.InitialHeadSHA, + types.RunFailed, reason, ts, ts, run.id, types.RunPending, + ) + if err != nil { + return ProtectedCrashRecovery{}, fmt.Errorf("fail incomplete protected run: %w", err) + } + count, err := changed.RowsAffected() + if err != nil || count != 1 { + return ProtectedCrashRecovery{}, fmt.Errorf("fail incomplete protected run: expected one row, changed %d: %w", count, err) + } + if _, err := tx.Exec( + `UPDATE step_results SET status = ?, error = ?, completed_at = ?, agent_pid = NULL + WHERE run_id = ? AND status IN (?, ?, ?, ?)`, + types.StepStatusFailed, reason, ts, run.id, + types.StepStatusRunning, types.StepStatusAwaitingApproval, types.StepStatusFixing, types.StepStatusFixReview, + ); err != nil { + return ProtectedCrashRecovery{}, fmt.Errorf("fail incomplete protected steps: %w", err) + } + result.PendingFailed++ + continue + } + + head, err := d.verifyOwnerDecisionHistory(run.id, authority, "", tx) + if err != nil { + continue + } + row := tx.QueryRow( + `SELECT sequence, gate_id, previous_head, record_digest, history_head, envelope_json, + projection_round_id, selected_finding_ids, selection_source, user_findings_json + FROM owner_decision_events WHERE run_id = ? ORDER BY sequence DESC LIMIT 1`, + run.id, + ) + event, err := scanOwnerDecisionEvent(row) + if err == sql.ErrNoRows { + continue + } + if err != nil { + return ProtectedCrashRecovery{}, err + } + var envelope ownerdecision.Envelope + if event.HistoryHead != head || json.Unmarshal([]byte(event.EnvelopeJSON), &envelope) != nil || envelope.Challenge.Purpose != ownerdecision.PurposeCancel { + continue + } + if err := validateOwnerChallengeTx(tx, authority, run.id, event.GateID, envelope.Challenge); err != nil { + continue + } + result.CancellationRunIDs = append(result.CancellationRunIDs, run.id) + } + if err := tx.Commit(); err != nil { + return ProtectedCrashRecovery{}, fmt.Errorf("commit protected crash recovery: %w", err) + } + return result, nil +} + +type ownerDecisionQuerier interface { + Query(query string, args ...any) (*sql.Rows, error) + QueryRow(query string, args ...any) *sql.Row +} + +func (d *DB) verifyOwnerDecisionHistory(runID string, authority *OwnerDecisionAuthority, expectedHead string, query ownerDecisionQuerier) (string, error) { + if err := verifyOwnerDecisionRunIdentityQuery(query, runID, authority); err != nil { + return "", err + } + rows, err := query.Query( + `SELECT sequence, gate_id, previous_head, record_digest, history_head, envelope_json, + projection_round_id, selected_finding_ids, selection_source, user_findings_json + FROM owner_decision_events WHERE run_id = ? ORDER BY sequence`, + runID, + ) + if err != nil { + return "", fmt.Errorf("read owner decision history: %w", err) + } + var events []*ownerDecisionEvent + for rows.Next() { + event, err := scanOwnerDecisionEvent(rows) + if err != nil { + rows.Close() + return "", err + } + events = append(events, event) + } + if err := rows.Err(); err != nil { + rows.Close() + return "", err + } + if err := rows.Close(); err != nil { + return "", err + } + head := authority.GenesisHead + wantSequence := 1 + for _, event := range events { + if event.Sequence != wantSequence || event.PreviousHead != head { + return "", errors.New("owner decision history sequence or previous head is invalid") + } + var envelope ownerdecision.Envelope + if err := json.Unmarshal([]byte(event.EnvelopeJSON), &envelope); err != nil { + return "", fmt.Errorf("decode owner decision envelope: %w", err) + } + if err := ownerdecision.VerifySignature(authority.PublicKey, envelope); err != nil { + return "", fmt.Errorf("verify owner decision event %d: %w", event.Sequence, err) + } + if envelope.Challenge.RunID != runID || envelope.Challenge.RepoID != authority.RepoID || + envelope.Challenge.Branch != authority.Branch || envelope.Challenge.HeadSHA != authority.InitialHeadSHA || + envelope.Challenge.PreviousHead != event.PreviousHead || event.GateID != ownerGateID(envelope.Challenge) { + return "", errors.New("owner decision history gate binding is invalid") + } + if err := verifyHistoricalOwnerChallengeQuery(query, runID, envelope.Challenge); err != nil { + return "", fmt.Errorf("verify owner decision gate at sequence %d: %w", event.Sequence, err) + } + recordDigest, canonicalEnvelope, err := encodeOwnerDecisionRecord(envelope, event.Projection) + if err != nil { + return "", err + } + if canonicalEnvelope != event.EnvelopeJSON || recordDigest != event.RecordDigest { + return "", errors.New("owner decision history record digest is invalid") + } + envelopeDigest, err := ownerdecision.EnvelopeDigest(envelope) + if err != nil { + return "", err + } + nextHead, err := ownerdecision.NextHead(head, envelopeDigest) + if err != nil || nextHead != event.HistoryHead { + return "", errors.New("owner decision history head is invalid") + } + if err := verifyOwnerProjectionQuery(query, event.Projection); err != nil { + return "", fmt.Errorf("verify owner decision projection at sequence %d: %w", event.Sequence, err) + } + if err := validateOwnerProjectionQuery(query, runID, envelope, event.Projection); err != nil { + return "", fmt.Errorf("verify owner decision projection authority at sequence %d: %w", event.Sequence, err) + } + head = nextHead + wantSequence++ + } + if expectedHead != "" && head != expectedHead { + return "", fmt.Errorf("owner decision history head is %s, expected %s", head, expectedHead) + } + return head, nil +} + +func getOwnerDecisionAuthorityTx(tx *sql.Tx, runID string) (*OwnerDecisionAuthority, error) { + authority := &OwnerDecisionAuthority{RunID: runID} + var publicKey []byte + err := tx.QueryRow( + `SELECT public_key, key_id, repo_id, branch, initial_head_sha, genesis_head, created_at FROM owner_decision_authorities WHERE run_id = ?`, + runID, + ).Scan(&publicKey, &authority.KeyID, &authority.RepoID, &authority.Branch, &authority.InitialHeadSHA, &authority.GenesisHead, &authority.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get owner decision authority: %w", err) + } + if len(publicKey) != ed25519.PublicKeySize { + return nil, errors.New("get owner decision authority: stored public key is invalid") + } + authority.PublicKey = ed25519.PublicKey(slices.Clone(publicKey)) + derivedKeyID, err := ownerdecision.KeyID(authority.PublicKey) + if err != nil || derivedKeyID != authority.KeyID { + return nil, errors.New("get owner decision authority: stored key id is invalid") + } + if derived, err := ownerdecision.GenesisHeadForRun(authority.PublicKey, authority.RepoID, authority.Branch, authority.InitialHeadSHA); err != nil || derived != authority.GenesisHead { + return nil, errors.New("get owner decision authority: stored key binding is invalid") + } + return authority, nil +} + +func verifyOwnerDecisionRunIdentityQuery(query ownerDecisionQuerier, runID string, authority *OwnerDecisionAuthority) error { + var repoID, branch string + var submittedHead sql.NullString + if err := query.QueryRow(`SELECT repo_id, branch, submitted_head_sha FROM runs WHERE id = ?`, runID).Scan(&repoID, &branch, &submittedHead); err != nil { + return fmt.Errorf("verify owner decision run identity: %w", err) + } + if repoID != authority.RepoID || branch != authority.Branch || !submittedHead.Valid || submittedHead.String != authority.InitialHeadSHA { + return errors.New("owner decision: run identity does not match its sealed authority") + } + return nil +} + +func validateOwnerChallengeTx(tx *sql.Tx, authority *OwnerDecisionAuthority, runID, gateID string, challenge ownerdecision.Challenge) error { + var repoID, branch, headSHA string + var submittedHead sql.NullString + var runStatus types.RunStatus + var awaitingAgent sql.NullInt64 + if err := tx.QueryRow(`SELECT repo_id, branch, head_sha, submitted_head_sha, status, awaiting_agent_since FROM runs WHERE id = ?`, runID).Scan(&repoID, &branch, &headSHA, &submittedHead, &runStatus, &awaitingAgent); err != nil { + return fmt.Errorf("append owner decision: read run binding: %w", err) + } + if runStatus != types.RunPending && runStatus != types.RunRunning { + return errors.New("append owner decision: run is already terminal") + } + if repoID != authority.RepoID || branch != authority.Branch || !submittedHead.Valid || submittedHead.String != authority.InitialHeadSHA { + return errors.New("append owner decision: current run identity does not match sealed authority") + } + if challenge.RunID != runID || challenge.RepoID != authority.RepoID || challenge.Branch != authority.Branch || + challenge.HeadSHA != authority.InitialHeadSHA || challenge.GateHeadSHA != headSHA { + return errors.New("append owner decision: challenge does not match current run identity") + } + if gateID != ownerGateID(challenge) { + return errors.New("append owner decision: gate id does not match challenge") + } + if challenge.Purpose != ownerdecision.PurposeRespond { + return nil + } + var storedRunID string + var step types.StepName + var stepStatus types.StepStatus + var findings *string + if err := tx.QueryRow( + `SELECT sr.run_id, sr.step_name, sr.status, r.findings_json + FROM step_rounds r JOIN step_results sr ON sr.id = r.step_result_id + WHERE r.id = ? AND sr.id = ?`, + challenge.RoundID, challenge.StepResultID, + ).Scan(&storedRunID, &step, &stepStatus, &findings); err != nil { + return fmt.Errorf("append owner decision: read response gate: %w", err) + } + if !awaitingAgent.Valid || (stepStatus != types.StepStatusAwaitingApproval && stepStatus != types.StepStatusFixReview) || + storedRunID != runID || step != challenge.Step || findings == nil || ownerdecision.DigestBytes([]byte(*findings)) != challenge.FindingsDigest { + return errors.New("append owner decision: response gate binding does not match durable round") + } + return nil +} + +func validateOwnerProjection(envelope ownerdecision.Envelope, projection *OwnerDecisionProjection) error { + if envelope.Challenge.Purpose == ownerdecision.PurposeCancel { + if projection != nil { + return errors.New("append owner decision: cancel cannot change a round projection") + } + return nil + } + if projection == nil || projection.RoundID != envelope.Challenge.RoundID { + return errors.New("append owner decision: response is missing its exact round projection") + } + var selected []string + if err := json.Unmarshal([]byte(projection.SelectedFindingIDs), &selected); err != nil || selected == nil { + return errors.New("append owner decision: selected finding ids must be a JSON array") + } + switch envelope.Response.Action { + case types.ActionApprove, types.ActionSkip, types.ActionAbort: + if len(selected) != 0 || projection.SelectionSource != RoundSelectionSourceUserDeclined || projection.UserFindingsJSON != nil { + return errors.New("append owner decision: decline projection does not match signed action") + } + case types.ActionFix: + if projection.SelectionSource != RoundSelectionSourceUser || len(selected) == 0 { + return errors.New("append owner decision: fix projection is incomplete") + } + for _, id := range envelope.Response.FindingIDs { + if !slices.Contains(selected, id) { + return fmt.Errorf("append owner decision: signed finding %q is absent from projection", id) + } + } + default: + return errors.New("append owner decision: unsupported signed action") + } + return nil +} + +func validateOwnerProjectionQuery(query ownerDecisionQuerier, runID string, envelope ownerdecision.Envelope, projection *OwnerDecisionProjection) error { + if err := validateOwnerProjection(envelope, projection); err != nil { + return err + } + if envelope.Challenge.Purpose != ownerdecision.PurposeRespond || envelope.Response.Action != types.ActionFix { + return nil + } + findings, err := ownerChallengeFindingsQuery(query, runID, envelope.Challenge) + if err != nil { + return err + } + expected, err := ownerdecision.MaterializeProjection(findings, envelope.Response) + if err != nil { + return err + } + if projection.SelectedFindingIDs != expected.SelectedFindingIDs { + return errors.New("append owner decision: selected finding projection does not exactly match signed response") + } + if projection.UserFindingsJSON == nil || expected.UserFindingsJSON == nil { + if projection.UserFindingsJSON != nil || expected.UserFindingsJSON != nil { + return errors.New("append owner decision: user findings projection does not exactly match signed response") + } + } else if *projection.UserFindingsJSON != *expected.UserFindingsJSON { + return errors.New("append owner decision: user findings projection does not exactly match signed response") + } + return nil +} + +func encodeOwnerDecisionRecord(envelope ownerdecision.Envelope, projection *OwnerDecisionProjection) (string, string, error) { + envelopeBytes, err := json.Marshal(envelope) + if err != nil { + return "", "", fmt.Errorf("encode owner decision envelope: %w", err) + } + recordBytes, err := json.Marshal(ownerDecisionJournalRecord{Envelope: envelope, Projection: projection}) + if err != nil { + return "", "", fmt.Errorf("encode owner decision record: %w", err) + } + return ownerdecision.DigestBytes(recordBytes), string(envelopeBytes), nil +} + +func getOwnerDecisionEventTx(tx *sql.Tx, runID, gateID string) (*ownerDecisionEvent, error) { + row := tx.QueryRow( + `SELECT sequence, gate_id, previous_head, record_digest, history_head, envelope_json, + projection_round_id, selected_finding_ids, selection_source, user_findings_json + FROM owner_decision_events WHERE run_id = ? AND gate_id = ?`, + runID, gateID, + ) + event, err := scanOwnerDecisionEvent(row) + if err == sql.ErrNoRows { + return nil, nil + } + return event, err +} + +type ownerDecisionScanner interface { + Scan(dest ...any) error +} + +func scanOwnerDecisionEvent(scanner ownerDecisionScanner) (*ownerDecisionEvent, error) { + event := &ownerDecisionEvent{} + var roundID, selected, source, userFindings sql.NullString + if err := scanner.Scan( + &event.Sequence, &event.GateID, &event.PreviousHead, &event.RecordDigest, &event.HistoryHead, &event.EnvelopeJSON, + &roundID, &selected, &source, &userFindings, + ); err != nil { + return nil, err + } + if roundID.Valid || selected.Valid || source.Valid || userFindings.Valid { + if !roundID.Valid || !selected.Valid || !source.Valid { + return nil, errors.New("owner decision event has a partial projection") + } + event.Projection = &OwnerDecisionProjection{ + RoundID: roundID.String, + SelectedFindingIDs: selected.String, + SelectionSource: source.String, + } + if userFindings.Valid { + value := userFindings.String + event.Projection.UserFindingsJSON = &value + } + } + return event, nil +} + +func verifyOwnerProjectionTx(tx *sql.Tx, projection *OwnerDecisionProjection) error { + return verifyOwnerProjectionQuery(tx, projection) +} + +func verifyOwnerProjectionQuery(query ownerDecisionQuerier, projection *OwnerDecisionProjection) error { + if projection == nil { + return nil + } + var selected, source, userFindings sql.NullString + if err := query.QueryRow( + `SELECT selected_finding_ids, selection_source, user_findings_json FROM step_rounds WHERE id = ?`, + projection.RoundID, + ).Scan(&selected, &source, &userFindings); err != nil { + return err + } + if !selected.Valid || selected.String != projection.SelectedFindingIDs || !source.Valid || source.String != projection.SelectionSource { + return errors.New("materialized round decision does not match journal") + } + if projection.UserFindingsJSON == nil { + if userFindings.Valid { + return errors.New("materialized user findings do not match journal") + } + } else if !userFindings.Valid || userFindings.String != *projection.UserFindingsJSON { + return errors.New("materialized user findings do not match journal") + } + return nil +} + +// verifyHistoricalOwnerChallengeQuery keeps the signed gate tied to the +// durable evidence it authorized even after that gate is no longer active. +// Run status and the mutable run head legitimately advance, but the reviewed +// round and its findings are append-only inputs to the decision history. +func verifyHistoricalOwnerChallengeQuery(query ownerDecisionQuerier, runID string, challenge ownerdecision.Challenge) error { + if challenge.Purpose != ownerdecision.PurposeRespond { + return nil + } + _, err := ownerChallengeFindingsQuery(query, runID, challenge) + return err +} + +func ownerChallengeFindingsQuery(query ownerDecisionQuerier, runID string, challenge ownerdecision.Challenge) (string, error) { + var storedRunID string + var step types.StepName + var findings *string + if err := query.QueryRow( + `SELECT sr.run_id, sr.step_name, r.findings_json + FROM step_rounds r JOIN step_results sr ON sr.id = r.step_result_id + WHERE r.id = ? AND sr.id = ?`, + challenge.RoundID, challenge.StepResultID, + ).Scan(&storedRunID, &step, &findings); err != nil { + return "", fmt.Errorf("read historical response gate: %w", err) + } + if storedRunID != runID || step != challenge.Step || findings == nil || + ownerdecision.DigestBytes([]byte(*findings)) != challenge.FindingsDigest { + return "", errors.New("historical response gate does not match signed findings") + } + return *findings, nil +} + +func ownerDecisionEventCountTx(tx *sql.Tx, runID string) (int, error) { + var count int + if err := tx.QueryRow(`SELECT COUNT(*) FROM owner_decision_events WHERE run_id = ?`, runID).Scan(&count); err != nil { + return 0, fmt.Errorf("count owner decision events: %w", err) + } + return count, nil +} + +func ownerGateID(challenge ownerdecision.Challenge) string { + if challenge.Purpose == ownerdecision.PurposeRespond { + return ownerdecision.PurposeRespond + ":" + challenge.RoundID + } + return ownerdecision.PurposeCancel + ":" + challenge.Nonce +} + +func equalOwnerProjection(left, right *OwnerDecisionProjection) bool { + if left == nil || right == nil { + return left == right + } + if left.RoundID != right.RoundID || left.SelectedFindingIDs != right.SelectedFindingIDs || left.SelectionSource != right.SelectionSource { + return false + } + if left.UserFindingsJSON == nil || right.UserFindingsJSON == nil { + return left.UserFindingsJSON == nil && right.UserFindingsJSON == nil + } + return *left.UserFindingsJSON == *right.UserFindingsJSON +} diff --git a/internal/db/owner_decision_test.go b/internal/db/owner_decision_test.go new file mode 100644 index 000000000..5c4570057 --- /dev/null +++ b/internal/db/owner_decision_test.go @@ -0,0 +1,481 @@ +package db + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/kunchenguid/no-mistakes/internal/ownerdecision" + "github.com/kunchenguid/no-mistakes/internal/types" +) + +func protectedDecisionFixture(t *testing.T) (*DB, *Run, *StepRound, ed25519.PublicKey, ed25519.PrivateKey, ownerdecision.Challenge) { + t.Helper() + database := openTestDB(t) + repo, err := database.InsertRepo(t.TempDir(), "https://example.invalid/owner-boundary.git", "main") + if err != nil { + t.Fatal(err) + } + run, err := database.InsertRun(repo.ID, "feature/protected", strings.Repeat("1", 40), strings.Repeat("0", 40)) + if err != nil { + t.Fatal(err) + } + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + if _, err := database.ProtectRunOwnerDecisions(run.ID, publicKey); err != nil { + t.Fatal(err) + } + genesisHead, err := ownerdecision.GenesisHeadForRun(publicKey, repo.ID, run.Branch, run.HeadSHA) + if err != nil { + t.Fatal(err) + } + step, err := database.InsertStepResult(run.ID, types.StepReview) + if err != nil { + t.Fatal(err) + } + findings := `{"findings":[{"id":"review-1","severity":"high","description":"problem","action":"fix"}]}` + round, err := database.InsertStepRound(step.ID, 1, "initial", &findings, nil, 10) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateRunStatus(run.ID, types.RunRunning); err != nil { + t.Fatal(err) + } + if err := database.UpdateStepStatusWithDuration(step.ID, types.StepStatusAwaitingApproval, 10); err != nil { + t.Fatal(err) + } + if err := database.SetRunAwaitingAgent(run.ID); err != nil { + t.Fatal(err) + } + now := time.Unix(1_800_000_000, 0).UTC() + challenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, + Purpose: ownerdecision.PurposeRespond, + RunID: run.ID, + RepoID: repo.ID, + Branch: run.Branch, + HeadSHA: run.HeadSHA, + GateHeadSHA: run.HeadSHA, + Step: types.StepReview, + StepResultID: step.ID, + RoundID: round.ID, + FindingsDigest: ownerdecision.DigestBytes([]byte(findings)), + PreviousHead: genesisHead, + Nonce: "gate-" + round.ID, + IssuedAt: now.Unix(), + ExpiresAt: now.Add(10 * time.Minute).Unix(), + } + return database, run, round, publicKey, privateKey, challenge +} + +func TestOwnerDecisionAppendIsTransactionalVerifiableAndIdempotent(t *testing.T) { + database, run, round, publicKey, privateKey, challenge := protectedDecisionFixture(t) + now := time.Unix(challenge.IssuedAt, 0).UTC() + envelope, err := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + projection := &OwnerDecisionProjection{ + RoundID: round.ID, + SelectedFindingIDs: DeclinedSelectionJSON, + SelectionSource: RoundSelectionSourceUserDeclined, + } + result, err := database.AppendOwnerDecision(run.ID, "respond:"+round.ID, envelope, challenge, projection, now) + if err != nil { + t.Fatalf("append: %v", err) + } + if result.Replay || result.Head == challenge.PreviousHead { + t.Fatalf("first append = %+v", result) + } + envelopeDigest, _ := ownerdecision.EnvelopeDigest(envelope) + controllerHead, _ := ownerdecision.NextHead(challenge.PreviousHead, envelopeDigest) + if result.Head != controllerHead { + t.Fatalf("history head = %s, controller-derived %s", result.Head, controllerHead) + } + if err := database.VerifyOwnerDecisionHistory(run.ID, result.Head); err != nil { + t.Fatalf("verify history: %v", err) + } + authority, err := database.GetOwnerDecisionAuthority(run.ID) + if err != nil || authority == nil || string(authority.PublicKey) != string(publicKey) { + t.Fatalf("authority = %+v, %v", authority, err) + } + stored, err := database.GetRoundsByStep(round.StepResultID) + if err != nil || len(stored) != 1 || stored[0].SelectionSource == nil || *stored[0].SelectionSource != RoundSelectionSourceUserDeclined { + t.Fatalf("projected decision = %+v, %v", stored, err) + } + + replay, err := database.AppendOwnerDecision(run.ID, "respond:"+round.ID, envelope, challenge, projection, now) + if err != nil { + t.Fatalf("exact replay: %v", err) + } + if !replay.Replay || replay.Head != result.Head { + t.Fatalf("replay = %+v; want head %s", replay, result.Head) + } + var count int + if err := database.sql.QueryRow(`SELECT COUNT(*) FROM owner_decision_events WHERE run_id = ?`, run.ID).Scan(&count); err != nil || count != 1 { + t.Fatalf("event count = %d, %v", count, err) + } +} + +func TestOwnerDecisionHistoryRefusesTamperRollbackReplayAndCrossBinding(t *testing.T) { + t.Run("projection tamper", func(t *testing.T) { + database, run, round, _, privateKey, challenge := protectedDecisionFixture(t) + envelope, _ := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionApprove}) + projection := &OwnerDecisionProjection{RoundID: round.ID, SelectedFindingIDs: DeclinedSelectionJSON, SelectionSource: RoundSelectionSourceUserDeclined} + result, err := database.AppendOwnerDecision(run.ID, "respond:"+round.ID, envelope, challenge, projection, time.Unix(challenge.IssuedAt, 0)) + if err != nil { + t.Fatal(err) + } + if _, err := database.sql.Exec(`UPDATE step_rounds SET selected_finding_ids = '["other"]' WHERE id = ?`, round.ID); err != nil { + t.Fatal(err) + } + if err := database.VerifyOwnerDecisionHistory(run.ID, result.Head); err == nil { + t.Fatal("tampered projection verified") + } + }) + + t.Run("signed findings tamper", func(t *testing.T) { + database, run, round, _, privateKey, challenge := protectedDecisionFixture(t) + envelope, _ := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionApprove}) + projection := &OwnerDecisionProjection{RoundID: round.ID, SelectedFindingIDs: DeclinedSelectionJSON, SelectionSource: RoundSelectionSourceUserDeclined} + result, err := database.AppendOwnerDecision(run.ID, "respond:"+round.ID, envelope, challenge, projection, time.Unix(challenge.IssuedAt, 0)) + if err != nil { + t.Fatal(err) + } + if _, err := database.sql.Exec(`UPDATE step_rounds SET findings_json = '{"findings":[]}' WHERE id = ?`, round.ID); err != nil { + t.Fatal(err) + } + if err := database.VerifyOwnerDecisionHistory(run.ID, result.Head); err == nil { + t.Fatal("tampered signed findings verified") + } + }) + + t.Run("projection and local record digest rewrite", func(t *testing.T) { + database, run, round, _, privateKey, challenge := protectedDecisionFixture(t) + envelope, _ := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionFix, FindingIDs: []string{"review-1"}}) + projection := &OwnerDecisionProjection{RoundID: round.ID, SelectedFindingIDs: `["review-1"]`, SelectionSource: RoundSelectionSourceUser} + result, err := database.AppendOwnerDecision(run.ID, "respond:"+round.ID, envelope, challenge, projection, time.Unix(challenge.IssuedAt, 0)) + if err != nil { + t.Fatal(err) + } + forged := &OwnerDecisionProjection{RoundID: round.ID, SelectedFindingIDs: `["review-1","other"]`, SelectionSource: RoundSelectionSourceUser} + forgedDigest, _, err := encodeOwnerDecisionRecord(envelope, forged) + if err != nil { + t.Fatal(err) + } + if _, err := database.sql.Exec( + `UPDATE owner_decision_events SET record_digest = ?, selected_finding_ids = ?, selection_source = ? WHERE run_id = ?`, + forgedDigest, forged.SelectedFindingIDs, forged.SelectionSource, run.ID, + ); err != nil { + t.Fatal(err) + } + if _, err := database.sql.Exec( + `UPDATE step_rounds SET selected_finding_ids = ?, selection_source = ? WHERE id = ?`, + forged.SelectedFindingIDs, forged.SelectionSource, round.ID, + ); err != nil { + t.Fatal(err) + } + if err := database.VerifyOwnerDecisionHistory(run.ID, result.Head); err == nil { + t.Fatal("rewritten local projection and digest verified under controller head") + } + }) + + t.Run("history rollback", func(t *testing.T) { + database, run, round, _, privateKey, challenge := protectedDecisionFixture(t) + envelope, _ := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionApprove}) + projection := &OwnerDecisionProjection{RoundID: round.ID, SelectedFindingIDs: DeclinedSelectionJSON, SelectionSource: RoundSelectionSourceUserDeclined} + result, err := database.AppendOwnerDecision(run.ID, "respond:"+round.ID, envelope, challenge, projection, time.Unix(challenge.IssuedAt, 0)) + if err != nil { + t.Fatal(err) + } + if _, err := database.sql.Exec(`DELETE FROM owner_decision_events WHERE run_id = ?`, run.ID); err != nil { + t.Fatal(err) + } + if err := database.VerifyOwnerDecisionHistory(run.ID, result.Head); err == nil { + t.Fatal("rolled-back history verified against external head") + } + }) + + t.Run("authority replacement", func(t *testing.T) { + database, run, _, _, _, challenge := protectedDecisionFixture(t) + attackerPublic, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + attackerKeyID, _ := ownerdecision.KeyID(attackerPublic) + attackerGenesis, _ := ownerdecision.GenesisHeadForRun(attackerPublic, challenge.RepoID, run.Branch, run.HeadSHA) + if _, err := database.sql.Exec( + `UPDATE owner_decision_authorities SET public_key = ?, key_id = ?, genesis_head = ? WHERE run_id = ?`, + []byte(attackerPublic), attackerKeyID, attackerGenesis, run.ID, + ); err != nil { + t.Fatal(err) + } + if err := database.VerifyOwnerDecisionHistory(run.ID, challenge.PreviousHead); err == nil { + t.Fatal("replaced public-key authority verified against controller head") + } + }) + + t.Run("cross run and conflicting replay", func(t *testing.T) { + database, run, round, _, privateKey, challenge := protectedDecisionFixture(t) + projection := &OwnerDecisionProjection{RoundID: round.ID, SelectedFindingIDs: DeclinedSelectionJSON, SelectionSource: RoundSelectionSourceUserDeclined} + crossRun := challenge + crossRun.RunID = "different-run" + envelope, _ := ownerdecision.Sign(privateKey, crossRun, ownerdecision.Response{Action: types.ActionApprove}) + if _, err := database.AppendOwnerDecision(run.ID, "respond:"+round.ID, envelope, challenge, projection, time.Unix(challenge.IssuedAt, 0)); err == nil { + t.Fatal("cross-run envelope appended") + } + exact, _ := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionApprove}) + if _, err := database.AppendOwnerDecision(run.ID, "respond:"+round.ID, exact, challenge, projection, time.Unix(challenge.IssuedAt, 0)); err != nil { + t.Fatal(err) + } + changed := exact.Clone() + changed.Response.Action = types.ActionSkip + changed, _ = ownerdecision.Sign(privateKey, challenge, changed.Response) + if _, err := database.AppendOwnerDecision(run.ID, "respond:"+round.ID, changed, challenge, projection, time.Unix(challenge.IssuedAt, 0)); err == nil { + t.Fatal("conflicting gate replay appended") + } + }) + + t.Run("old exact replay after head advanced", func(t *testing.T) { + database, run, round, _, privateKey, challenge := protectedDecisionFixture(t) + now := time.Unix(challenge.IssuedAt, 0) + projection := &OwnerDecisionProjection{RoundID: round.ID, SelectedFindingIDs: DeclinedSelectionJSON, SelectionSource: RoundSelectionSourceUserDeclined} + response, _ := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionApprove}) + first, err := database.AppendOwnerDecision(run.ID, "respond:"+round.ID, response, challenge, projection, now) + if err != nil { + t.Fatal(err) + } + cancelChallenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, Purpose: ownerdecision.PurposeCancel, + RunID: run.ID, RepoID: challenge.RepoID, Branch: run.Branch, HeadSHA: challenge.HeadSHA, GateHeadSHA: run.HeadSHA, + PreviousHead: first.Head, Nonce: "cancel:" + run.ID + ":" + first.Head, + IssuedAt: challenge.IssuedAt, ExpiresAt: challenge.ExpiresAt, + } + cancel, _ := ownerdecision.Sign(privateKey, cancelChallenge, ownerdecision.Response{Action: types.ActionAbort}) + if _, err := database.AppendOwnerDecision(run.ID, "cancel:"+cancelChallenge.Nonce, cancel, cancelChallenge, nil, now); err != nil { + t.Fatal(err) + } + if _, err := database.AppendOwnerDecision(run.ID, "respond:"+round.ID, response, challenge, projection, now); err == nil { + t.Fatal("old exact replay rewound the advanced history head") + } + }) + + t.Run("authorized current head may advance without changing sealed initial head", func(t *testing.T) { + database, run, round, _, privateKey, challenge := protectedDecisionFixture(t) + now := time.Unix(challenge.IssuedAt, 0) + projection := &OwnerDecisionProjection{RoundID: round.ID, SelectedFindingIDs: DeclinedSelectionJSON, SelectionSource: RoundSelectionSourceUserDeclined} + response, _ := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionApprove}) + first, err := database.AppendOwnerDecision(run.ID, "respond:"+round.ID, response, challenge, projection, now) + if err != nil { + t.Fatal(err) + } + advancedHead := strings.Repeat("8", 40) + if err := database.UpdateRunHeadSHA(run.ID, advancedHead); err != nil { + t.Fatal(err) + } + cancelChallenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, Purpose: ownerdecision.PurposeCancel, + RunID: run.ID, RepoID: challenge.RepoID, Branch: challenge.Branch, + HeadSHA: challenge.HeadSHA, GateHeadSHA: advancedHead, + PreviousHead: first.Head, Nonce: "cancel:" + run.ID + ":" + first.Head, + IssuedAt: challenge.IssuedAt, ExpiresAt: challenge.ExpiresAt, + } + cancel, err := ownerdecision.Sign(privateKey, cancelChallenge, ownerdecision.Response{Action: types.ActionAbort}) + if err != nil { + t.Fatal(err) + } + if _, err := database.AppendOwnerDecision(run.ID, "cancel:"+cancelChallenge.Nonce, cancel, cancelChallenge, nil, now); err != nil { + t.Fatalf("advanced current head lost the immutable run binding: %v", err) + } + }) +} + +func TestOwnerDecisionAuthorityIsImmutableAndLegacyRunsRemainUnprotected(t *testing.T) { + database := openTestDB(t) + repo, _ := database.InsertRepo(t.TempDir(), "https://example.invalid/repo.git", "main") + legacy, _ := database.InsertRun(repo.ID, "legacy", "head", "base") + if authority, err := database.GetOwnerDecisionAuthority(legacy.ID); err != nil || authority != nil { + t.Fatalf("legacy authority = %+v, %v", authority, err) + } + if head, protected, err := database.OwnerDecisionHead(legacy.ID); err != nil || protected || head != "" { + t.Fatalf("legacy head = %q, protected=%v, err=%v", head, protected, err) + } + + publicKey, _, _ := ed25519.GenerateKey(rand.Reader) + if _, err := database.ProtectRunOwnerDecisions(legacy.ID, publicKey); err != nil { + t.Fatal(err) + } + if _, err := database.ProtectRunOwnerDecisions(legacy.ID, publicKey); err != nil { + t.Fatalf("exact authority replay: %v", err) + } + otherKey, _, _ := ed25519.GenerateKey(rand.Reader) + if _, err := database.ProtectRunOwnerDecisions(legacy.ID, otherKey); err == nil { + t.Fatal("authority replacement succeeded") + } + encoded, _ := json.Marshal(publicKey) + if len(encoded) == 0 { + t.Fatal("test key did not encode") + } +} + +func TestOwnerDecisionAuthoritySealsImmutableRunIdentity(t *testing.T) { + mutations := map[string]func(*testing.T, *DB, *Run){ + "repo": func(t *testing.T, database *DB, run *Run) { + other, err := database.InsertRepo(t.TempDir(), "https://example.invalid/other.git", "main") + if err != nil { + t.Fatal(err) + } + if _, err := database.sql.Exec(`UPDATE runs SET repo_id = ? WHERE id = ?`, other.ID, run.ID); err != nil { + t.Fatal(err) + } + }, + "branch": func(t *testing.T, database *DB, run *Run) { + if _, err := database.sql.Exec(`UPDATE runs SET branch = ? WHERE id = ?`, "rewritten", run.ID); err != nil { + t.Fatal(err) + } + }, + "initial head": func(t *testing.T, database *DB, run *Run) { + if _, err := database.sql.Exec(`UPDATE runs SET submitted_head_sha = ? WHERE id = ?`, strings.Repeat("9", 40), run.ID); err != nil { + t.Fatal(err) + } + }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + database, run, _, _, _, challenge := protectedDecisionFixture(t) + mutate(t, database, run) + if err := database.VerifyOwnerDecisionHistory(run.ID, challenge.PreviousHead); err == nil { + t.Fatal("rewritten run identity verified against the sealed authority") + } + }) + } + + t.Run("authority identity rebind changes external genesis", func(t *testing.T) { + database, run, _, publicKey, _, challenge := protectedDecisionFixture(t) + attackerGenesis, err := ownerdecision.GenesisHeadForRun(publicKey, challenge.RepoID, "rewritten", challenge.HeadSHA) + if err != nil { + t.Fatal(err) + } + if _, err := database.sql.Exec( + `UPDATE owner_decision_authorities SET branch = ?, genesis_head = ? WHERE run_id = ?`, + "rewritten", attackerGenesis, run.ID, + ); err != nil { + t.Fatal(err) + } + if err := database.VerifyOwnerDecisionHistory(run.ID, challenge.PreviousHead); err == nil { + t.Fatal("rebound authority verified against controller-held original genesis") + } + }) +} + +func TestProtectedCrashRecoveryFailsIncompletePendingAndProjectsCommittedCancel(t *testing.T) { + database := openTestDB(t) + repo, err := database.InsertRepo(t.TempDir(), "https://example.invalid/recovery.git", "main") + if err != nil { + t.Fatal(err) + } + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + pending, err := database.InsertRun(repo.ID, "pending", "pending-head", "base") + if err != nil { + t.Fatal(err) + } + pendingAuthority, err := database.ProtectRunOwnerDecisions(pending.ID, publicKey) + if err != nil { + t.Fatal(err) + } + otherRepo, err := database.InsertRepo(t.TempDir(), "https://example.invalid/rewritten.git", "main") + if err != nil { + t.Fatal(err) + } + if _, err := database.sql.Exec( + `UPDATE runs SET repo_id = ?, branch = ?, head_sha = ?, submitted_head_sha = ? WHERE id = ?`, + otherRepo.ID, "rewritten", "rewritten-head", "rewritten-head", pending.ID, + ); err != nil { + t.Fatal(err) + } + + cancelled, err := database.InsertRun(repo.ID, "cancelled", "cancel-head", "base") + if err != nil { + t.Fatal(err) + } + cancelAuthority, err := database.ProtectRunOwnerDecisions(cancelled.ID, publicKey) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateRunStatus(cancelled.ID, types.RunRunning); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + challenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, Purpose: ownerdecision.PurposeCancel, + RunID: cancelled.ID, RepoID: repo.ID, Branch: cancelled.Branch, + HeadSHA: cancelAuthority.InitialHeadSHA, GateHeadSHA: cancelled.HeadSHA, + PreviousHead: cancelAuthority.GenesisHead, Nonce: "cancel:" + cancelled.ID + ":" + cancelAuthority.GenesisHead, + IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), + } + envelope, err := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionAbort}) + if err != nil { + t.Fatal(err) + } + if _, err := database.AppendOwnerDecision(cancelled.ID, ownerdecision.PurposeCancel+":"+challenge.Nonce, envelope, challenge, nil, now); err != nil { + t.Fatal(err) + } + + recovered, err := database.ReconcileProtectedCrashStates() + if err != nil { + t.Fatal(err) + } + if recovered.PendingFailed != 1 || len(recovered.CancellationRunIDs) != 1 || recovered.CancellationRunIDs[0] != cancelled.ID { + t.Fatalf("protected crash recovery = %+v", recovered) + } + pendingAfter, err := database.GetRun(pending.ID) + if err != nil { + t.Fatal(err) + } + if pendingAfter.Status != types.RunFailed || pendingAfter.RepoID != pendingAuthority.RepoID || + pendingAfter.Branch != pendingAuthority.Branch || pendingAfter.HeadSHA != pendingAuthority.InitialHeadSHA || + pendingAfter.SubmittedHeadSHA == nil || *pendingAfter.SubmittedHeadSHA != pendingAuthority.InitialHeadSHA { + t.Fatalf("incomplete protected run did not retain sealed identity: %+v", pendingAfter) + } + if err := database.RecoverCommittedOwnerCancellation(cancelled.ID); err != nil { + t.Fatal(err) + } + cancelledAfter, err := database.GetRun(cancelled.ID) + if err != nil { + t.Fatal(err) + } + if cancelledAfter.Status != types.RunCancelled { + t.Fatalf("committed cancellation recovered as %s", cancelledAfter.Status) + } +} + +func TestRecoveredProtectedRunUsesLatestRoundWithoutRejectingPriorRounds(t *testing.T) { + database, run, firstRound, _, privateKey, challenge := protectedDecisionFixture(t) + envelope, err := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionFix, FindingIDs: []string{"review-1"}}) + if err != nil { + t.Fatal(err) + } + projection := &OwnerDecisionProjection{RoundID: firstRound.ID, SelectedFindingIDs: `["review-1"]`, SelectionSource: RoundSelectionSourceUser} + if _, err := database.AppendOwnerDecision(run.ID, "respond:"+firstRound.ID, envelope, challenge, projection, time.Unix(challenge.IssuedAt, 0)); err != nil { + t.Fatal(err) + } + secondFindings := `{"findings":[{"id":"review-2","severity":"high","description":"next choice","action":"fix"}]}` + if err := database.SetStepFindings(firstRound.StepResultID, secondFindings); err != nil { + t.Fatal(err) + } + if _, err := database.InsertStepRound(firstRound.StepResultID, 2, "fix", &secondFindings, nil, 10); err != nil { + t.Fatal(err) + } + if err := database.VerifyRecoveredOwnerDecisionRun(run.ID); err != nil { + t.Fatalf("prior signed round made the current parked round unrecoverable: %v", err) + } +} diff --git a/internal/db/schema.go b/internal/db/schema.go index da172e5e5..45db0cf72 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -154,6 +154,42 @@ CREATE TABLE IF NOT EXISTS uncertified_pipeline_ranges ( created_at INTEGER NOT NULL, PRIMARY KEY (repo_id, branch) ); + +-- Protected runs bind every owner decision to an immutable Ed25519 public +-- key. Historical runs have no row and retain the legacy protocol. +CREATE TABLE IF NOT EXISTS owner_decision_authorities ( + run_id TEXT PRIMARY KEY REFERENCES runs(id) ON DELETE CASCADE, + public_key BLOB NOT NULL, + key_id TEXT NOT NULL, + repo_id TEXT NOT NULL, + branch TEXT NOT NULL, + initial_head_sha TEXT NOT NULL, + genesis_head TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +-- Append-only signed authorization journal. The externally retained history +-- head chains signed envelopes; this local record digest also covers the exact +-- deterministic round projection so direct edits are detected and refused. +CREATE TABLE IF NOT EXISTS owner_decision_events ( + run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + sequence INTEGER NOT NULL, + gate_id TEXT NOT NULL, + previous_head TEXT NOT NULL, + record_digest TEXT NOT NULL, + history_head TEXT NOT NULL, + envelope_json TEXT NOT NULL, + projection_round_id TEXT, + selected_finding_ids TEXT, + selection_source TEXT, + user_findings_json TEXT, + created_at INTEGER NOT NULL, + PRIMARY KEY (run_id, sequence), + UNIQUE (run_id, gate_id) +); + +CREATE INDEX IF NOT EXISTS idx_owner_decision_events_run_gate + ON owner_decision_events (run_id, gate_id); ` // migrationStatements hold additive schema changes applied to databases that @@ -161,6 +197,9 @@ CREATE TABLE IF NOT EXISTS uncertified_pipeline_ranges ( // idempotent via its error being tolerated when the column already exists. var migrationStatements = []string{ `ALTER TABLE repos ADD COLUMN fork_url TEXT`, + `ALTER TABLE owner_decision_authorities ADD COLUMN repo_id TEXT`, + `ALTER TABLE owner_decision_authorities ADD COLUMN branch TEXT`, + `ALTER TABLE owner_decision_authorities ADD COLUMN initial_head_sha TEXT`, `ALTER TABLE step_rounds ADD COLUMN selected_finding_ids TEXT`, `ALTER TABLE step_rounds ADD COLUMN selection_source TEXT`, `ALTER TABLE step_rounds ADD COLUMN fix_summary TEXT`, diff --git a/internal/ipc/protocol.go b/internal/ipc/protocol.go index e1970e7ce..d889190e6 100644 --- a/internal/ipc/protocol.go +++ b/internal/ipc/protocol.go @@ -4,25 +4,28 @@ import ( "encoding/json" "sync/atomic" + "github.com/kunchenguid/no-mistakes/internal/ownerdecision" "github.com/kunchenguid/no-mistakes/internal/types" ) // JSON-RPC 2.0 method names. const ( - MethodPushReceived = "push_received" - MethodGetRun = "get_run" - MethodGetStepDiff = "get_step_diff" - MethodGetRuns = "get_runs" - MethodGetRunsForHead = "get_runs_for_head" - MethodGetActiveRun = "get_active_run" - MethodRerun = "rerun" - MethodSubscribe = "subscribe" - MethodRespond = "respond" - MethodCancelRun = "cancel_run" - MethodGateContext = "gate_context" - MethodAdmitPush = "admit_push" - MethodHealth = "health" - MethodShutdown = "shutdown" + MethodPushReceived = "push_received" + MethodGetRun = "get_run" + MethodGetStepDiff = "get_step_diff" + MethodGetRuns = "get_runs" + MethodGetRunsForHead = "get_runs_for_head" + MethodGetActiveRun = "get_active_run" + MethodRerun = "rerun" + MethodSubscribe = "subscribe" + MethodRespond = "respond" + MethodCancelRun = "cancel_run" + MethodOwnerDecisionChallenge = "owner_decision_challenge" + MethodOwnerDecisionCheckpoint = "owner_decision_checkpoint" + MethodGateContext = "gate_context" + MethodAdmitPush = "admit_push" + MethodHealth = "health" + MethodShutdown = "shutdown" ) // JSON-RPC 2.0 error codes. @@ -67,12 +70,21 @@ func (e *RPCError) Error() string { return e.Message } // intent from local transcripts. type PushReceivedParams struct { // Gate is the absolute path to the gate bare repo. - Gate string `json:"gate"` - Ref string `json:"ref"` - Old string `json:"old"` - New string `json:"new"` - SkipSteps []types.StepName `json:"skip_steps,omitempty"` - Intent string `json:"intent,omitempty"` + Gate string `json:"gate"` + Ref string `json:"ref"` + Old string `json:"old"` + New string `json:"new"` + SkipSteps []types.StepName `json:"skip_steps,omitempty"` + Intent string `json:"intent,omitempty"` + OwnerDecision *OwnerDecisionRunConfig `json:"owner_decision,omitempty"` +} + +// OwnerDecisionRunConfig opts a newly created run into the protected protocol. +// PublicKey is base64-encoded Ed25519 public-key bytes. ExpectedHead is the +// controller-held history head and must be the genesis head for a new run. +type OwnerDecisionRunConfig struct { + PublicKey string `json:"public_key"` + ExpectedHead string `json:"expected_head"` } // GetRunParams requests a single run by ID. @@ -125,11 +137,12 @@ type GetActiveRunParams struct { // the daemon inherits authoritative intent from the selected prior run or // leaves the new run to perform fresh inference. type RerunParams struct { - RepoID string `json:"repo_id"` - Branch string `json:"branch"` - PreviousRunID string `json:"previous_run_id,omitempty"` - SkipSteps []types.StepName `json:"skip_steps,omitempty"` - Intent string `json:"intent,omitempty"` + RepoID string `json:"repo_id"` + Branch string `json:"branch"` + PreviousRunID string `json:"previous_run_id,omitempty"` + SkipSteps []types.StepName `json:"skip_steps,omitempty"` + Intent string `json:"intent,omitempty"` + OwnerDecision *OwnerDecisionRunConfig `json:"owner_decision,omitempty"` } // SubscribeParams starts an event stream for a run. @@ -145,17 +158,30 @@ type SubscribeParams struct { // alongside agent-produced ones. Both fields only apply when Action triggers // a fix round. type RespondParams struct { - RunID string `json:"run_id"` - Step types.StepName `json:"step"` - Action types.ApprovalAction `json:"action"` - FindingIDs []string `json:"finding_ids,omitempty"` - Instructions map[string]string `json:"instructions,omitempty"` - AddedFindings []types.Finding `json:"added_findings,omitempty"` + RunID string `json:"run_id"` + Step types.StepName `json:"step"` + Action types.ApprovalAction `json:"action"` + FindingIDs []string `json:"finding_ids,omitempty"` + Instructions map[string]string `json:"instructions,omitempty"` + AddedFindings []types.Finding `json:"added_findings,omitempty"` + Decision *ownerdecision.Envelope `json:"decision,omitempty"` } // CancelRunParams cancels an active pipeline run. type CancelRunParams struct { - RunID string `json:"run_id"` + RunID string `json:"run_id"` + Decision *ownerdecision.Envelope `json:"decision,omitempty"` +} + +type OwnerDecisionChallengeParams struct { + RunID string `json:"run_id"` + Purpose string `json:"purpose"` + ExpectedHead string `json:"expected_head,omitempty"` +} + +type OwnerDecisionCheckpointParams struct { + RunID string `json:"run_id"` + Decision ownerdecision.Envelope `json:"decision"` } // GateContextParams asks the daemon to classify the authenticated caller. @@ -214,6 +240,14 @@ type CancelRunResult struct { OK bool `json:"ok"` } +type OwnerDecisionChallengeResult struct { + Challenge ownerdecision.Challenge `json:"challenge"` +} + +type OwnerDecisionCheckpointResult struct { + OK bool `json:"ok"` +} + // GateContextResult is the privacy-safe execution-context classification. type GateContextResult struct { Nested bool `json:"nested"` @@ -244,17 +278,19 @@ type ShutdownResult struct { // RunInfo is the IPC representation of a pipeline run. type RunInfo struct { - ID string `json:"id"` - RepoID string `json:"repo_id"` - Branch string `json:"branch"` - HeadSHA string `json:"head_sha"` - SubmittedHeadSHA *string `json:"submitted_head_sha,omitempty"` - BaseSHA string `json:"base_sha"` - Status types.RunStatus `json:"status"` - PRURL *string `json:"pr_url,omitempty"` - Error *string `json:"error,omitempty"` - CIReady bool `json:"ci_ready,omitempty"` - CIReadyNoCI bool `json:"ci_ready_no_ci,omitempty"` + ID string `json:"id"` + RepoID string `json:"repo_id"` + Branch string `json:"branch"` + HeadSHA string `json:"head_sha"` + SubmittedHeadSHA *string `json:"submitted_head_sha,omitempty"` + BaseSHA string `json:"base_sha"` + Status types.RunStatus `json:"status"` + PRURL *string `json:"pr_url,omitempty"` + Error *string `json:"error,omitempty"` + CIReady bool `json:"ci_ready,omitempty"` + CIReadyNoCI bool `json:"ci_ready_no_ci,omitempty"` + OwnerDecisionProtected bool `json:"owner_decision_protected,omitempty"` + OwnerDecisionHead string `json:"owner_decision_head,omitempty"` // AwaitingAgent is true while the run is parked at a gate awaiting the // driving agent's response. AwaitingAgentSince is the unix-seconds time it // parked, so a supervisor can read "parked for N seconds" in one call. Both diff --git a/internal/ownerdecision/ownerdecision.go b/internal/ownerdecision/ownerdecision.go new file mode 100644 index 000000000..df473b4d0 --- /dev/null +++ b/internal/ownerdecision/ownerdecision.go @@ -0,0 +1,413 @@ +package ownerdecision + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "slices" + "strings" + "time" + + "github.com/kunchenguid/no-mistakes/internal/types" +) + +const ( + ChallengeSchema = "no-mistakes.owner-decision-challenge/v1" + EnvelopeSchema = "no-mistakes.owner-decision-envelope/v1" + + PurposeRespond = "respond" + PurposeCancel = "cancel" + PurposeCheckpoint = "checkpoint" + + MaxChallengeLifetime = 15 * time.Minute +) + +var GenesisHead = DigestBytes([]byte("no-mistakes.owner-decision-history/v1\n")) + +// GenesisHeadForRun makes the controller-held initial history head an +// external commitment to the exact public key and immutable submitted run +// identity. Replacing same-UID local authority or run rows after restart +// therefore cannot create a valid alternate history with the same expected +// head. +func GenesisHeadForRun(publicKey ed25519.PublicKey, repoID, branch, initialHeadSHA string) (string, error) { + keyID, err := KeyID(publicKey) + if err != nil { + return "", err + } + if strings.TrimSpace(repoID) == "" || strings.TrimSpace(branch) == "" || strings.TrimSpace(initialHeadSHA) == "" { + return "", errors.New("owner decision: immutable run identity is incomplete") + } + return DigestBytes([]byte( + "no-mistakes.owner-decision-history/v1\n" + + "key:" + keyID + "\n" + + "repo:" + repoID + "\n" + + "branch:" + branch + "\n" + + "initial-head:" + initialHeadSHA + "\n", + )), nil +} + +// Challenge binds a controller decision to one immutable run and gate state. +// Its field order is the canonical JSON order used for signing. +type Challenge struct { + Schema string `json:"schema"` + Purpose string `json:"purpose"` + RunID string `json:"run_id"` + RepoID string `json:"repo_id"` + Branch string `json:"branch"` + // HeadSHA is the immutable submitted head sealed into the authority and + // genesis. GateHeadSHA is the current run/worktree head at this gate; it + // may advance only through authorized pipeline work. + HeadSHA string `json:"head_sha"` + GateHeadSHA string `json:"gate_head_sha"` + Step types.StepName `json:"step,omitempty"` + StepResultID string `json:"step_result_id,omitempty"` + RoundID string `json:"round_id,omitempty"` + FindingsDigest string `json:"findings_digest,omitempty"` + PreviousHead string `json:"previous_head"` + Nonce string `json:"nonce"` + IssuedAt int64 `json:"issued_at"` + ExpiresAt int64 `json:"expires_at"` +} + +// Response is the complete state-changing instruction authorized by the +// controller. A cancel envelope uses ActionAbort and no finding selections. +type Response struct { + Action types.ApprovalAction `json:"action"` + FindingIDs []string `json:"finding_ids,omitempty"` + Instructions map[string]string `json:"instructions,omitempty"` + AddedFindings []types.Finding `json:"added_findings,omitempty"` +} + +// Projection is the exact mutable round state deterministically authorized by +// a fix response and its immutable gate findings. The external history head +// chains only signed envelopes so a controller can derive it independently; +// this projection is therefore verified separately, byte for byte. +type Projection struct { + SelectedFindingIDs string + UserFindingsJSON *string +} + +// Envelope carries a canonical challenge and response plus an Ed25519 +// signature over their exact schema-bound JSON representation. +type Envelope struct { + Schema string `json:"schema"` + Challenge Challenge `json:"challenge"` + Response Response `json:"response"` + Signature string `json:"signature"` +} + +type unsignedEnvelope struct { + Schema string `json:"schema"` + Challenge Challenge `json:"challenge"` + Response Response `json:"response"` +} + +func DigestBytes(value []byte) string { + digest := sha256.Sum256(value) + return hex.EncodeToString(digest[:]) +} + +func Sign(privateKey ed25519.PrivateKey, challenge Challenge, response Response) (Envelope, error) { + if len(privateKey) != ed25519.PrivateKeySize { + return Envelope{}, fmt.Errorf("owner decision: invalid Ed25519 private key length %d", len(privateKey)) + } + if err := validateChallenge(challenge); err != nil { + return Envelope{}, err + } + if err := validateResponse(challenge.Purpose, response); err != nil { + return Envelope{}, err + } + unsigned := unsignedEnvelope{Schema: EnvelopeSchema, Challenge: challenge, Response: response} + payload, err := json.Marshal(unsigned) + if err != nil { + return Envelope{}, fmt.Errorf("owner decision: encode signing payload: %w", err) + } + return Envelope{ + Schema: EnvelopeSchema, + Challenge: challenge, + Response: response, + Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, payload)), + }, nil +} + +// Verify proves an envelope is exact for the supplied challenge and currently +// usable. Historical verification should use VerifySignature because expiry +// limits admission, not the durability of an already-admitted decision. +func Verify(publicKey ed25519.PublicKey, envelope Envelope, expected Challenge, now time.Time) error { + if err := VerifySignature(publicKey, envelope); err != nil { + return err + } + actual, err := json.Marshal(envelope.Challenge) + if err != nil { + return fmt.Errorf("owner decision: encode challenge: %w", err) + } + want, err := json.Marshal(expected) + if err != nil { + return fmt.Errorf("owner decision: encode expected challenge: %w", err) + } + if !bytes.Equal(actual, want) { + return errors.New("owner decision: signed challenge does not match the active gate") + } + if now.Unix() < envelope.Challenge.IssuedAt { + return errors.New("owner decision: challenge is not yet valid") + } + if now.Unix() >= envelope.Challenge.ExpiresAt { + return errors.New("owner decision: challenge has expired") + } + return nil +} + +// VerifySignature validates schemas, values, and the Ed25519 signature without +// applying the admission-time validity window. +func VerifySignature(publicKey ed25519.PublicKey, envelope Envelope) error { + if len(publicKey) != ed25519.PublicKeySize { + return fmt.Errorf("owner decision: invalid Ed25519 public key length %d", len(publicKey)) + } + if envelope.Schema != EnvelopeSchema { + return fmt.Errorf("owner decision: unsupported envelope schema %q", envelope.Schema) + } + if err := validateChallenge(envelope.Challenge); err != nil { + return err + } + if err := validateResponse(envelope.Challenge.Purpose, envelope.Response); err != nil { + return err + } + signature, err := base64.StdEncoding.DecodeString(envelope.Signature) + if err != nil || len(signature) != ed25519.SignatureSize { + return errors.New("owner decision: invalid signature encoding") + } + payload, err := json.Marshal(unsignedEnvelope{ + Schema: envelope.Schema, + Challenge: envelope.Challenge, + Response: envelope.Response, + }) + if err != nil { + return fmt.Errorf("owner decision: encode signing payload: %w", err) + } + if !ed25519.Verify(publicKey, payload, signature) { + return errors.New("owner decision: signature verification failed") + } + return nil +} + +func EnvelopeDigest(envelope Envelope) (string, error) { + if envelope.Schema != EnvelopeSchema { + return "", fmt.Errorf("owner decision: unsupported envelope schema %q", envelope.Schema) + } + payload, err := json.Marshal(envelope) + if err != nil { + return "", fmt.Errorf("owner decision: encode envelope: %w", err) + } + return DigestBytes(payload), nil +} + +// MaterializeProjection derives the only valid round projection for a signed +// fix response. It deliberately mirrors the pipeline's user-override wire +// format using the shared findings primitives rather than trusting a +// daemon-supplied projection. +func MaterializeProjection(findingsRaw string, response Response) (Projection, error) { + if response.Action != types.ActionFix { + return Projection{}, errors.New("owner decision: only fix responses have a selected-findings projection") + } + findings, err := types.ParseFindingsJSON(findingsRaw) + if err != nil { + return Projection{}, fmt.Errorf("owner decision: decode gate findings: %w", err) + } + var selected types.Findings + if len(response.FindingIDs) == 0 { + selected = types.Findings{ + Summary: "0 selected findings", + Tested: findings.Tested, + TestingSummary: findings.TestingSummary, + RiskLevel: findings.RiskLevel, + RiskRationale: findings.RiskRationale, + RiskScope: findings.RiskScope, + } + } else { + selected = types.FilterFindings(findings, response.FindingIDs) + } + selectedRaw, err := types.MarshalFindingsJSON(selected) + if err != nil { + return Projection{}, fmt.Errorf("owner decision: encode selected findings: %w", err) + } + merged := selected + if len(response.Instructions) != 0 || len(response.AddedFindings) != 0 { + merged = types.MergeUserOverrides(selected, response.Instructions, response.AddedFindings) + } + mergedRaw, err := types.MarshalFindingsJSON(merged) + if err != nil { + return Projection{}, fmt.Errorf("owner decision: encode projected findings: %w", err) + } + + ids := slices.Clone(response.FindingIDs) + seen := make(map[string]bool, len(ids)+len(merged.Items)) + for _, id := range ids { + seen[id] = true + } + for _, finding := range merged.Items { + if finding.ID == "" || seen[finding.ID] { + continue + } + ids = append(ids, finding.ID) + seen[finding.ID] = true + } + idsJSON, err := json.Marshal(ids) + if err != nil { + return Projection{}, fmt.Errorf("owner decision: encode selected finding ids: %w", err) + } + projection := Projection{SelectedFindingIDs: string(idsJSON)} + if mergedRaw != selectedRaw { + projection.UserFindingsJSON = &mergedRaw + } + return projection, nil +} + +func NextHead(previousHead, envelopeDigest string) (string, error) { + if !validDigest(previousHead) { + return "", errors.New("owner decision: invalid previous history head") + } + if !validDigest(envelopeDigest) { + return "", errors.New("owner decision: invalid envelope digest") + } + return DigestBytes([]byte(previousHead + "\n" + envelopeDigest + "\n")), nil +} + +func (envelope Envelope) Clone() Envelope { + clone := envelope + clone.Response.FindingIDs = slices.Clone(envelope.Response.FindingIDs) + clone.Response.AddedFindings = slices.Clone(envelope.Response.AddedFindings) + if envelope.Response.Instructions != nil { + clone.Response.Instructions = make(map[string]string, len(envelope.Response.Instructions)) + for key, value := range envelope.Response.Instructions { + clone.Response.Instructions[key] = value + } + } + return clone +} + +func ParsePublicKey(encoded string) (ed25519.PublicKey, error) { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil || len(decoded) != ed25519.PublicKeySize { + return nil, errors.New("owner decision: public key must be base64-encoded Ed25519 bytes") + } + return ed25519.PublicKey(decoded), nil +} + +func ParsePrivateKey(encoded string) (ed25519.PrivateKey, error) { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil || len(decoded) != ed25519.PrivateKeySize { + return nil, errors.New("owner decision: private key must be base64-encoded Ed25519 bytes") + } + return ed25519.PrivateKey(decoded), nil +} + +func EncodePublicKey(publicKey ed25519.PublicKey) (string, error) { + if len(publicKey) != ed25519.PublicKeySize { + return "", fmt.Errorf("owner decision: invalid Ed25519 public key length %d", len(publicKey)) + } + return base64.StdEncoding.EncodeToString(publicKey), nil +} + +func EncodePrivateKey(privateKey ed25519.PrivateKey) (string, error) { + if len(privateKey) != ed25519.PrivateKeySize { + return "", fmt.Errorf("owner decision: invalid Ed25519 private key length %d", len(privateKey)) + } + return base64.StdEncoding.EncodeToString(privateKey), nil +} + +func KeyID(publicKey ed25519.PublicKey) (string, error) { + if len(publicKey) != ed25519.PublicKeySize { + return "", fmt.Errorf("owner decision: invalid Ed25519 public key length %d", len(publicKey)) + } + return DigestBytes(publicKey), nil +} + +// ValidateChallenge validates the schema and protocol bounds without signing. +func ValidateChallenge(challenge Challenge) error { + return validateChallenge(challenge) +} + +func validateChallenge(challenge Challenge) error { + if challenge.Schema != ChallengeSchema { + return fmt.Errorf("owner decision: unsupported challenge schema %q", challenge.Schema) + } + if challenge.Purpose != PurposeRespond && challenge.Purpose != PurposeCancel && challenge.Purpose != PurposeCheckpoint { + return fmt.Errorf("owner decision: unsupported purpose %q", challenge.Purpose) + } + if strings.TrimSpace(challenge.RunID) == "" || strings.TrimSpace(challenge.RepoID) == "" || + strings.TrimSpace(challenge.Branch) == "" || strings.TrimSpace(challenge.HeadSHA) == "" || strings.TrimSpace(challenge.GateHeadSHA) == "" || + strings.TrimSpace(challenge.PreviousHead) == "" || strings.TrimSpace(challenge.Nonce) == "" { + return errors.New("owner decision: challenge identity is incomplete") + } + if !validDigest(challenge.PreviousHead) { + return errors.New("owner decision: invalid previous history head") + } + if challenge.ExpiresAt <= challenge.IssuedAt { + return errors.New("owner decision: invalid validity window") + } + maxLifetimeSeconds := int64(MaxChallengeLifetime / time.Second) + if challenge.IssuedAt > math.MaxInt64-maxLifetimeSeconds || challenge.ExpiresAt > challenge.IssuedAt+maxLifetimeSeconds { + return errors.New("owner decision: validity window exceeds protocol maximum") + } + if challenge.Purpose == PurposeRespond { + if challenge.Step == "" || challenge.StepResultID == "" || challenge.RoundID == "" || + !validDigest(challenge.FindingsDigest) { + return errors.New("owner decision: response gate identity is incomplete") + } + } else if challenge.Step != "" || challenge.StepResultID != "" || challenge.RoundID != "" || challenge.FindingsDigest != "" { + return errors.New("owner decision: cancel challenge contains response-gate fields") + } + return nil +} + +func validateResponse(purpose string, response Response) error { + switch response.Action { + case types.ActionApprove, types.ActionFix, types.ActionSkip, types.ActionAbort: + default: + return fmt.Errorf("owner decision: unsupported action %q", response.Action) + } + if purpose == PurposeCancel || purpose == PurposeCheckpoint { + expectedAction := types.ActionAbort + if purpose == PurposeCheckpoint { + expectedAction = types.ActionApprove + } + if response.Action != expectedAction || len(response.FindingIDs) != 0 || + len(response.Instructions) != 0 || len(response.AddedFindings) != 0 { + return fmt.Errorf("owner decision: %s envelope has an invalid response", purpose) + } + return nil + } + if response.Action != types.ActionFix && (len(response.FindingIDs) != 0 || len(response.Instructions) != 0 || len(response.AddedFindings) != 0) { + return errors.New("owner decision: only a fix response may carry finding selections or overrides") + } + if response.Action == types.ActionFix && len(response.FindingIDs) == 0 && len(response.AddedFindings) == 0 { + return errors.New("owner decision: fix response has no selected or added findings") + } + seen := make(map[string]struct{}, len(response.FindingIDs)) + for _, id := range response.FindingIDs { + if strings.TrimSpace(id) == "" { + return errors.New("owner decision: blank finding id") + } + if _, exists := seen[id]; exists { + return fmt.Errorf("owner decision: duplicate finding id %q", id) + } + seen[id] = struct{}{} + } + for id := range response.Instructions { + if _, exists := seen[id]; !exists { + return fmt.Errorf("owner decision: instruction for unselected finding %q", id) + } + } + return nil +} + +func validDigest(value string) bool { + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value) +} diff --git a/internal/ownerdecision/ownerdecision_test.go b/internal/ownerdecision/ownerdecision_test.go new file mode 100644 index 000000000..7eabe4730 --- /dev/null +++ b/internal/ownerdecision/ownerdecision_test.go @@ -0,0 +1,158 @@ +package ownerdecision + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "testing" + "time" + + "github.com/kunchenguid/no-mistakes/internal/types" +) + +func testChallenge(now time.Time) Challenge { + return Challenge{ + Schema: ChallengeSchema, + Purpose: PurposeRespond, + RunID: "run-1", + RepoID: "repo-1", + Branch: "feature/owner-boundary", + HeadSHA: "1111111111111111111111111111111111111111", + GateHeadSHA: "1111111111111111111111111111111111111111", + Step: types.StepReview, + StepResultID: "step-1", + RoundID: "round-1", + FindingsDigest: DigestBytes([]byte(`{"findings":[{"id":"review-1"}]}`)), + PreviousHead: GenesisHead, + Nonce: "gate-round-1", + IssuedAt: now.Unix(), + ExpiresAt: now.Add(10 * time.Minute).Unix(), + } +} + +func TestMaterializeProjectionIsDeterministicFromSignedResponse(t *testing.T) { + findings := `{"findings":[{"id":"review-1","severity":"high","description":"problem","action":"fix"}],"summary":"one","risk_level":"high","risk_rationale":"reason"}` + response := Response{ + Action: types.ActionFix, + FindingIDs: []string{"review-1"}, + Instructions: map[string]string{"review-1": "preserve the boundary"}, + AddedFindings: []types.Finding{{ + Description: "add coverage", + Action: types.ActionAutoFix, + }}, + } + first, err := MaterializeProjection(findings, response) + if err != nil { + t.Fatal(err) + } + second, err := MaterializeProjection(findings, response) + if err != nil { + t.Fatal(err) + } + if first.SelectedFindingIDs != second.SelectedFindingIDs || first.UserFindingsJSON == nil || second.UserFindingsJSON == nil || *first.UserFindingsJSON != *second.UserFindingsJSON { + t.Fatalf("projection is not deterministic: %+v vs %+v", first, second) + } + var ids []string + if err := json.Unmarshal([]byte(first.SelectedFindingIDs), &ids); err != nil { + t.Fatal(err) + } + if len(ids) != 2 || ids[0] != "review-1" || ids[1] != "user-1" { + t.Fatalf("materialized ids = %v", ids) + } + projected, err := types.ParseFindingsJSON(*first.UserFindingsJSON) + if err != nil || len(projected.Items) != 2 || projected.Items[0].UserInstructions != "preserve the boundary" || projected.Items[1].ID != "user-1" { + t.Fatalf("materialized findings = %+v, %v", projected, err) + } +} + +func TestChallengeValidityWindowHasProtocolMaximum(t *testing.T) { + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Unix(1_800_000_000, 0).UTC() + challenge := testChallenge(now) + challenge.ExpiresAt = now.Add(MaxChallengeLifetime + time.Second).Unix() + if _, err := Sign(privateKey, challenge, Response{Action: types.ActionApprove}); err == nil { + t.Fatal("challenge longer than the protocol maximum was signed") + } +} + +func TestSignedDecisionBindsEveryChallengeAndResponseField(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Unix(1_800_000_000, 0).UTC() + challenge := testChallenge(now) + response := Response{ + Action: types.ActionFix, + FindingIDs: []string{"review-1"}, + Instructions: map[string]string{ + "review-1": "keep the owner-selected behavior", + }, + } + envelope, err := Sign(privateKey, challenge, response) + if err != nil { + t.Fatalf("sign: %v", err) + } + if err := Verify(publicKey, envelope, challenge, now); err != nil { + t.Fatalf("verify exact decision: %v", err) + } + + tests := map[string]func(*Envelope){ + "cross run": func(v *Envelope) { v.Challenge.RunID = "run-2" }, + "cross head": func(v *Envelope) { v.Challenge.HeadSHA = "2222222222222222222222222222222222222222" }, + "cross gate head": func(v *Envelope) { + v.Challenge.GateHeadSHA = "3333333333333333333333333333333333333333" + }, + "cross gate": func(v *Envelope) { v.Challenge.RoundID = "round-2" }, + "action": func(v *Envelope) { v.Response.Action = types.ActionApprove }, + "selection": func(v *Envelope) { v.Response.FindingIDs = []string{"review-2"} }, + "instructions": func(v *Envelope) { v.Response.Instructions["review-1"] = "different" }, + "previous head": func(v *Envelope) { v.Challenge.PreviousHead = DigestBytes([]byte("rollback")) }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + changed := envelope.Clone() + mutate(&changed) + if err := Verify(publicKey, changed, challenge, now); err == nil { + t.Fatal("mutated envelope verified") + } + }) + } +} + +func TestSignedDecisionExpiryAndReplayIdentity(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Unix(1_800_000_000, 0).UTC() + challenge := testChallenge(now) + envelope, err := Sign(privateKey, challenge, Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + if err := Verify(publicKey, envelope, challenge, now.Add(2*time.Hour)); err == nil { + t.Fatal("expired decision verified") + } + + firstDigest, err := EnvelopeDigest(envelope) + if err != nil { + t.Fatal(err) + } + firstHead, err := NextHead(GenesisHead, firstDigest) + if err != nil { + t.Fatal(err) + } + if firstHead == GenesisHead { + t.Fatal("history head did not advance") + } + if got, err := NextHead(GenesisHead, firstDigest); err != nil || got != firstHead { + t.Fatalf("exact replay head = %q, %v; want %q", got, err, firstHead) + } + if _, err := NextHead(DigestBytes([]byte("different-prefix")), firstDigest); err != nil { + t.Fatalf("valid alternate prefix should hash deterministically: %v", err) + } +} diff --git a/internal/pipeline/executor.go b/internal/pipeline/executor.go index 626f42436..48031c841 100644 --- a/internal/pipeline/executor.go +++ b/internal/pipeline/executor.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" "path/filepath" + "slices" "strings" "sync" "time" @@ -20,6 +21,7 @@ import ( "github.com/kunchenguid/no-mistakes/internal/gateguidance" "github.com/kunchenguid/no-mistakes/internal/git" "github.com/kunchenguid/no-mistakes/internal/ipc" + "github.com/kunchenguid/no-mistakes/internal/ownerdecision" "github.com/kunchenguid/no-mistakes/internal/paths" "github.com/kunchenguid/no-mistakes/internal/safeurl" "github.com/kunchenguid/no-mistakes/internal/telemetry" @@ -34,6 +36,8 @@ const ( defaultGateReconcileTimeout = 30 * time.Second ) +var errOwnerDecisionCancelled = errors.New(types.RunCancelReasonAbortedByUser) + type approvalResponse struct { action types.ApprovalAction findingIDs []string @@ -41,6 +45,18 @@ type approvalResponse struct { addedFindings []types.Finding } +type ownerDecisionGate struct { + runID string + repoID string + branch string + headSHA string + step types.StepName + stepResultID string + roundID string + findings string + findingsDigest string +} + // Executor runs pipeline steps sequentially and coordinates approval interactions. type Executor struct { db *db.DB @@ -62,12 +78,322 @@ type Executor struct { approvalCh chan approvalResponse // buffered channel for approval responses waiting bool // true when blocked on approval waitingStep types.StepName // which step is currently awaiting approval + ownerGate *ownerDecisionGate + + ownerAuthority *db.OwnerDecisionAuthority + ownerExpectedHead string + ownerArmed bool + ownerRun *db.Run + ownerRepo *db.Repo + // beforeOwnerResume is a deterministic test seam after the transaction has + // committed and before the approval channel is released. + beforeOwnerResume func() gateReconcileInterval time.Duration gateReconcileTimeout time.Duration onPRMerged func(context.Context, string) } +// ArmOwnerDecisionHistory supplies the controller-held expected history head. +// Protected recovered runs must call this explicitly before Resume; deriving +// the head only from local SQLite would make a valid-prefix rollback invisible. +func (e *Executor) ArmOwnerDecisionHistory(runID, expectedHead string) error { + if strings.TrimSpace(expectedHead) == "" { + return fmt.Errorf("owner decision history: expected head is required") + } + authority, err := e.db.GetOwnerDecisionAuthority(runID) + if err != nil { + return err + } + if authority == nil { + return fmt.Errorf("owner decision history: run %s is not protected", runID) + } + if err := e.db.VerifyOwnerDecisionHistory(runID, expectedHead); err != nil { + return err + } + e.mu.Lock() + defer e.mu.Unlock() + if e.ownerArmed && (e.ownerAuthority == nil || e.ownerAuthority.RunID != runID || e.ownerExpectedHead != expectedHead) { + return fmt.Errorf("owner decision history: executor is already armed to a different binding") + } + e.ownerAuthority = authority + e.ownerExpectedHead = expectedHead + e.ownerArmed = true + return nil +} + +// OwnerDecisionProtected reports whether this executor owns a protected run. +func (e *Executor) OwnerDecisionProtected() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.ownerAuthority != nil +} + +// OwnerDecisionChallenge returns the canonical challenge the external +// controller signs. The private key never enters the daemon or run worktree. +func (e *Executor) OwnerDecisionChallenge(purpose string) (ownerdecision.Challenge, error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.ownerAuthority == nil || e.ownerRun == nil || e.ownerRepo == nil || !e.ownerArmed { + return ownerdecision.Challenge{}, fmt.Errorf("owner decision: run is not protected and armed") + } + now := time.Now().UTC() + challenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, + Purpose: purpose, + RunID: e.ownerRun.ID, + RepoID: e.ownerAuthority.RepoID, + Branch: e.ownerAuthority.Branch, + HeadSHA: e.ownerAuthority.InitialHeadSHA, + GateHeadSHA: e.ownerRun.HeadSHA, + PreviousHead: e.ownerExpectedHead, + IssuedAt: now.Unix(), + ExpiresAt: now.Add(ownerdecision.MaxChallengeLifetime).Unix(), + } + switch purpose { + case ownerdecision.PurposeRespond: + if !e.waiting || e.ownerGate == nil { + return ownerdecision.Challenge{}, fmt.Errorf("owner decision: no step is awaiting approval") + } + challenge.RunID = e.ownerGate.runID + challenge.RepoID = e.ownerAuthority.RepoID + challenge.Branch = e.ownerAuthority.Branch + challenge.HeadSHA = e.ownerAuthority.InitialHeadSHA + challenge.GateHeadSHA = e.ownerGate.headSHA + challenge.Step = e.ownerGate.step + challenge.StepResultID = e.ownerGate.stepResultID + challenge.RoundID = e.ownerGate.roundID + challenge.FindingsDigest = e.ownerGate.findingsDigest + challenge.Nonce = "respond:" + e.ownerGate.roundID + ":" + e.ownerExpectedHead + case ownerdecision.PurposeCancel: + challenge.Nonce = "cancel:" + e.ownerRun.ID + ":" + e.ownerExpectedHead + default: + return ownerdecision.Challenge{}, fmt.Errorf("owner decision: unsupported purpose %q", purpose) + } + return challenge, nil +} + +// RespondAuthorized transactionally appends a valid owner decision before it +// releases the approval channel. Exact replay is idempotent; every mismatch +// leaves the gate waiting. +func (e *Executor) RespondAuthorized(envelope ownerdecision.Envelope) error { + e.mu.Lock() + if e.ownerAuthority == nil || !e.ownerArmed { + e.mu.Unlock() + return fmt.Errorf("owner decision: run is not protected and armed") + } + if !e.waiting || e.ownerGate == nil { + e.mu.Unlock() + return fmt.Errorf("no step awaiting approval") + } + if err := e.validateOwnerEnvelopeLocked(envelope, ownerdecision.PurposeRespond); err != nil { + e.mu.Unlock() + return err + } + projection, response, err := e.ownerProjectionLocked(envelope) + if err != nil { + e.mu.Unlock() + return err + } + gateID := ownerdecision.PurposeRespond + ":" + e.ownerGate.roundID + result, err := e.db.AppendOwnerDecision(e.ownerRun.ID, gateID, envelope, envelope.Challenge, projection, time.Now().UTC()) + if err != nil { + e.mu.Unlock() + return err + } + e.ownerExpectedHead = result.Head + if e.beforeOwnerResume != nil { + e.beforeOwnerResume() + } + e.waiting = false + e.waitingStep = "" + e.ownerGate = nil + e.mu.Unlock() + + e.approvalCh <- response + return nil +} + +// AuthorizeCancel appends a signed cancellation authorization before the +// manager invokes the run's cancellation function. +func (e *Executor) AuthorizeCancel(envelope ownerdecision.Envelope) error { + e.mu.Lock() + defer e.mu.Unlock() + if e.ownerAuthority == nil || !e.ownerArmed { + return fmt.Errorf("owner decision: run is not protected and armed") + } + if err := e.validateOwnerEnvelopeLocked(envelope, ownerdecision.PurposeCancel); err != nil { + return err + } + gateID := ownerdecision.PurposeCancel + ":" + envelope.Challenge.Nonce + result, err := e.db.AppendOwnerDecision(e.ownerRun.ID, gateID, envelope, envelope.Challenge, nil, time.Now().UTC()) + if err != nil { + return err + } + e.ownerExpectedHead = result.Head + return nil +} + +func (e *Executor) validateOwnerEnvelopeLocked(envelope ownerdecision.Envelope, purpose string) error { + challenge := envelope.Challenge + if challenge.Purpose != purpose || challenge.RunID != e.ownerRun.ID || challenge.RepoID != e.ownerAuthority.RepoID || + challenge.Branch != e.ownerAuthority.Branch || challenge.HeadSHA != e.ownerAuthority.InitialHeadSHA || + challenge.GateHeadSHA != e.ownerRun.HeadSHA || challenge.PreviousHead != e.ownerExpectedHead { + return fmt.Errorf("owner decision: envelope does not match the active run and history head") + } + if purpose == ownerdecision.PurposeCancel { + if challenge.Nonce != "cancel:"+e.ownerRun.ID+":"+e.ownerExpectedHead { + return fmt.Errorf("owner decision: cancellation nonce does not match the active run") + } + return ownerdecision.Verify(e.ownerAuthority.PublicKey, envelope, challenge, time.Now().UTC()) + } + gate := e.ownerGate + if gate == nil || challenge.Step != gate.step || challenge.StepResultID != gate.stepResultID || + challenge.RoundID != gate.roundID || challenge.FindingsDigest != gate.findingsDigest || + challenge.Nonce != "respond:"+gate.roundID+":"+e.ownerExpectedHead { + return fmt.Errorf("owner decision: envelope does not match the active approval gate") + } + return ownerdecision.Verify(e.ownerAuthority.PublicKey, envelope, challenge, time.Now().UTC()) +} + +func (e *Executor) ownerProjectionLocked(envelope ownerdecision.Envelope) (*db.OwnerDecisionProjection, approvalResponse, error) { + response := approvalResponse{ + action: envelope.Response.Action, + findingIDs: slices.Clone(envelope.Response.FindingIDs), + instructions: cloneInstructions(envelope.Response.Instructions), + addedFindings: slices.Clone(envelope.Response.AddedFindings), + } + projection := &db.OwnerDecisionProjection{ + RoundID: e.ownerGate.roundID, + SelectedFindingIDs: db.DeclinedSelectionJSON, + SelectionSource: db.RoundSelectionSourceUserDeclined, + } + if response.action != types.ActionFix { + return projection, response, nil + } + materialized, err := ownerdecision.MaterializeProjection(e.ownerGate.findings, envelope.Response) + if err != nil { + return nil, approvalResponse{}, err + } + projection.SelectedFindingIDs = materialized.SelectedFindingIDs + projection.SelectionSource = db.RoundSelectionSourceUser + projection.UserFindingsJSON = materialized.UserFindingsJSON + return projection, response, nil +} + +func cloneInstructions(source map[string]string) map[string]string { + if source == nil { + return nil + } + clone := make(map[string]string, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} + +func approvalResponseFromEnvelope(envelope ownerdecision.Envelope) approvalResponse { + return approvalResponse{ + action: envelope.Response.Action, + findingIDs: slices.Clone(envelope.Response.FindingIDs), + instructions: cloneInstructions(envelope.Response.Instructions), + addedFindings: slices.Clone(envelope.Response.AddedFindings), + } +} + +func (e *Executor) bindOwnerDecisionRun(run *db.Run, repo *db.Repo) error { + if run == nil || repo == nil { + return fmt.Errorf("owner decision: run and repository are required") + } + authority, err := e.db.GetOwnerDecisionAuthority(run.ID) + if err != nil { + return err + } + e.mu.Lock() + defer e.mu.Unlock() + if authority == nil { + if e.ownerArmed || e.ownerAuthority != nil { + return fmt.Errorf("owner decision: legacy run cannot use a protected executor binding") + } + e.ownerRun = run + e.ownerRepo = repo + return nil + } + if !e.ownerArmed || e.ownerAuthority == nil || e.ownerAuthority.RunID != run.ID { + return fmt.Errorf("owner decision: protected run requires an externally supplied expected history head") + } + if e.ownerExpectedHead == "" { + return fmt.Errorf("owner decision: protected run has no expected history head") + } + e.ownerAuthority = authority + e.ownerRun = run + e.ownerRepo = repo + if run.RepoID != authority.RepoID || repo.ID != authority.RepoID || run.Branch != authority.Branch || + run.SubmittedHeadSHA == nil || *run.SubmittedHeadSHA != authority.InitialHeadSHA { + return fmt.Errorf("owner decision: active run identity does not match its sealed authority") + } + return e.db.VerifyOwnerDecisionHistory(run.ID, e.ownerExpectedHead) +} + +func (e *Executor) verifyOwnerDecisionHistory() error { + e.mu.Lock() + defer e.mu.Unlock() + return e.verifyOwnerDecisionHistoryLocked() +} + +func (e *Executor) verifyOwnerDecisionHistoryLocked() error { + if e.ownerAuthority == nil { + return nil + } + runID := e.ownerAuthority.RunID + expectedHead := e.ownerExpectedHead + pinnedKeyID := e.ownerAuthority.KeyID + pinnedGenesis := e.ownerAuthority.GenesisHead + boundRun := e.ownerRun + boundRepo := e.ownerRepo + if boundRun == nil || boundRepo == nil { + return fmt.Errorf("owner decision run identity is not bound") + } + storedRun, err := e.db.GetRun(runID) + if err != nil { + return fmt.Errorf("owner decision run identity verification failed: %w", err) + } + if storedRun == nil || storedRun.RepoID != boundRepo.ID || storedRun.Branch != boundRun.Branch || storedRun.HeadSHA != boundRun.HeadSHA { + return fmt.Errorf("owner decision run identity verification failed: durable run binding changed") + } + current, err := e.db.GetOwnerDecisionAuthority(runID) + if err != nil { + return fmt.Errorf("owner decision authority verification failed: %w", err) + } + if current == nil || current.KeyID != pinnedKeyID || current.GenesisHead != pinnedGenesis { + return fmt.Errorf("owner decision authority verification failed: public-key binding changed") + } + if err := e.db.VerifyOwnerDecisionHistory(runID, expectedHead); err != nil { + return fmt.Errorf("owner decision history verification failed: %w", err) + } + return nil +} + +func (e *Executor) setWaitingOwnerGate(run *db.Run, repo *db.Repo, step types.StepName, stepResultID, roundID, findings string) { + e.waiting = true + e.waitingStep = step + if e.ownerAuthority == nil { + e.ownerGate = nil + return + } + e.ownerGate = &ownerDecisionGate{ + runID: run.ID, + repoID: repo.ID, + branch: run.Branch, + headSHA: run.HeadSHA, + step: step, + stepResultID: stepResultID, + roundID: roundID, + findings: findings, + findingsDigest: ownerdecision.DigestBytes([]byte(findings)), + } +} + // SetOnPRMerged registers a best-effort hook invoked after a merged PR state // is persisted. The pipeline never fails the run if the hook errors. func (e *Executor) SetOnPRMerged(fn func(context.Context, string)) { @@ -148,6 +474,10 @@ func (e *Executor) Respond(step types.StepName, action types.ApprovalAction, fin // findings on a fix action before the fix agent runs. func (e *Executor) RespondWithOverrides(step types.StepName, action types.ApprovalAction, findingIDs []string, instructions map[string]string, addedFindings []types.Finding) error { e.mu.Lock() + if e.ownerAuthority != nil { + e.mu.Unlock() + return fmt.Errorf("owner decision: protected run requires a signed decision envelope") + } if !e.waiting { e.mu.Unlock() return fmt.Errorf("no step awaiting approval") @@ -157,6 +487,7 @@ func (e *Executor) RespondWithOverrides(step types.StepName, action types.Approv return fmt.Errorf("step mismatch: responding to %q but %q is awaiting approval", step, e.waitingStep) } e.waiting = false + e.ownerGate = nil e.mu.Unlock() e.approvalCh <- approvalResponse{ @@ -174,6 +505,9 @@ func (e *Executor) RespondWithOverrides(step types.StepName, action types.Approv // the cause message is preserved as the run's error in the DB. func (e *Executor) Execute(ctx context.Context, run *db.Run, repo *db.Repo, workDir string) error { e.workDir = workDir + if err := e.bindOwnerDecisionRun(run, repo); err != nil { + return e.failRun(run, repo, err) + } // Mark run as running. Route write failures through failRun so the // in-memory lifecycle and subscriber stream still become terminal instead // of leaving a silent pending run. @@ -232,6 +566,9 @@ func (e *Executor) Execute(ctx context.Context, run *db.Run, repo *db.Repo, work } } + if err := e.verifyOwnerDecisionHistory(); err != nil { + return e.failRun(run, repo, fmt.Errorf("before run completion: %w", err)) + } // Mark run as completed. A failure here must emit a terminal failure rather // than leaving a silent running row after every step has finished. if err := e.completeRun(run, repo); err != nil { @@ -282,6 +619,9 @@ func (e *Executor) Resume(ctx context.Context, run *db.Run, repo *db.Repo, workD if repo == nil { return fmt.Errorf("recovered run has no repository") } + if err := e.bindOwnerDecisionRun(run, repo); err != nil { + return err + } if err := ValidateRecoveredRun(e.db, run, e.steps); err != nil { return err } @@ -297,6 +637,21 @@ func (e *Executor) Resume(ctx context.Context, run *db.Run, repo *db.Repo, workD parkStart := time.Unix(*run.AwaitingAgentSince, 0) duration := recoveredStepDuration(gate.stepResult) + if e.OwnerDecisionProtected() { + cancelled, cancelErr := e.db.CommittedOwnerCancellation(run.ID, e.ownerExpectedHead) + if cancelErr != nil { + return e.failRun(run, repo, fmt.Errorf("recover committed owner cancellation: %w", cancelErr), ctx) + } + if cancelled { + if dbErr := e.db.CompleteRunAwaitingAgent(run.ID, time.Since(parkStart).Milliseconds()); dbErr != nil { + return e.failRun(run, repo, fmt.Errorf("complete cancelled awaiting-agent state: %w", dbErr), ctx) + } + if dbErr := e.db.FailStep(gate.stepResult.ID, types.RunCancelReasonAbortedByUser, duration); dbErr != nil { + return e.failRun(run, repo, fmt.Errorf("complete cancelled recovered step: %w", dbErr), ctx) + } + return e.failRun(run, repo, errors.New(types.RunCancelReasonAbortedByUser), ctx) + } + } completeRecoveredGate := func() error { if gate.step.Name() == types.StepReview { if gate.reviewedHeadSHA == "" { @@ -336,53 +691,71 @@ func (e *Executor) Resume(ctx context.Context, run *db.Run, repo *db.Repo, workD LogFile: func(string) {}, OnPRMerged: e.onPRMerged, } - if reconciled, reconcileErr := e.reconcileApprovalGate(ctx, gate.step, reconcileCtx); reconciled { - if dbErr := e.db.CompleteRunAwaitingAgent(run.ID, time.Since(parkStart).Milliseconds()); dbErr != nil { - return e.failRun(run, repo, fmt.Errorf("complete reconciled awaiting-agent state: %w", dbErr), ctx) + var response approvalResponse + committedResponse := false + if e.OwnerDecisionProtected() { + envelope, found, committedErr := e.db.CommittedOwnerResponse(run.ID, ownerdecision.PurposeRespond+":"+gate.lastRoundID, e.ownerExpectedHead) + if committedErr != nil { + return e.failRun(run, repo, fmt.Errorf("recover committed owner response: %w", committedErr), ctx) + } + if found { + response = approvalResponseFromEnvelope(envelope) + committedResponse = true } - return completeReconciledGate() - } else if reconcileErr != nil && ctx.Err() == nil { - if errors.Is(reconcileErr, ErrFatalGateReconciliation) { + } + if !committedResponse { + if reconciled, reconcileErr := e.reconcileApprovalGate(ctx, gate.step, reconcileCtx); reconciled { if dbErr := e.db.CompleteRunAwaitingAgent(run.ID, time.Since(parkStart).Milliseconds()); dbErr != nil { - return e.failRun(run, repo, fmt.Errorf("complete fatal reconciliation awaiting-agent state: %w", dbErr), ctx) + return e.failRun(run, repo, fmt.Errorf("complete reconciled awaiting-agent state: %w", dbErr), ctx) } - if dbErr := e.db.FailStep(gate.stepResult.ID, reconcileErr.Error(), duration); dbErr != nil { - slog.Warn("failed to mark recovered step as failed in db", "step", gate.step.Name(), "error", dbErr) + return completeReconciledGate() + } else if reconcileErr != nil && ctx.Err() == nil { + if errors.Is(reconcileErr, ErrFatalGateReconciliation) { + if dbErr := e.db.CompleteRunAwaitingAgent(run.ID, time.Since(parkStart).Milliseconds()); dbErr != nil { + return e.failRun(run, repo, fmt.Errorf("complete fatal reconciliation awaiting-agent state: %w", dbErr), ctx) + } + if dbErr := e.db.FailStep(gate.stepResult.ID, reconcileErr.Error(), duration); dbErr != nil { + slog.Warn("failed to mark recovered step as failed in db", "step", gate.step.Name(), "error", dbErr) + } + e.emitStepEventWithFindingsAndError(ipc.EventStepCompleted, run, repo, gate.step.Name(), string(types.StepStatusFailed), "", reconcileErr.Error(), &duration) + return e.failRun(run, repo, fmt.Errorf("step %s: reconcile approval gate: %w", gate.step.Name(), reconcileErr), ctx) } - e.emitStepEventWithFindingsAndError(ipc.EventStepCompleted, run, repo, gate.step.Name(), string(types.StepStatusFailed), "", reconcileErr.Error(), &duration) - return e.failRun(run, repo, fmt.Errorf("step %s: reconcile approval gate: %w", gate.step.Name(), reconcileErr), ctx) + slog.Warn("could not reconcile recovered approval gate; preserving it", "run_id", run.ID, "step", gate.step.Name(), "error", reconcileErr) } - slog.Warn("could not reconcile recovered approval gate; preserving it", "run_id", run.ID, "step", gate.step.Name(), "error", reconcileErr) - } - e.mu.Lock() - e.waiting = true - e.waitingStep = gate.step.Name() - e.mu.Unlock() - e.emitStepEventWithFindingsAndError( - ipc.EventStepCompleted, - run, - repo, - gate.step.Name(), - string(gate.stepResult.Status), - gate.findings, - "", - gate.stepResult.DurationMS, - ) - - response, reconciled, err := e.waitForApprovalOrReconcile(ctx, gate.step, reconcileCtx, false) - if dbErr := e.db.CompleteRunAwaitingAgent(run.ID, time.Since(parkStart).Milliseconds()); dbErr != nil { - slog.Warn("failed to complete awaiting-agent state in db", "step", gate.step.Name(), "run", run.ID, "error", dbErr) - } - if err != nil { - if dbErr := e.db.FailStep(gate.stepResult.ID, err.Error(), duration); dbErr != nil { - slog.Warn("failed to mark recovered step as failed in db", "step", gate.step.Name(), "error", dbErr) + e.mu.Lock() + e.setWaitingOwnerGate(run, repo, gate.step.Name(), gate.stepResult.ID, gate.lastRoundID, gate.findings) + e.mu.Unlock() + e.emitStepEventWithFindingsAndError( + ipc.EventStepCompleted, + run, + repo, + gate.step.Name(), + string(gate.stepResult.Status), + gate.findings, + "", + gate.stepResult.DurationMS, + ) + + var reconciled bool + var waitErr error + response, reconciled, waitErr = e.waitForApprovalOrReconcile(ctx, gate.step, reconcileCtx, false) + if waitErr != nil { + if dbErr := e.db.FailStep(gate.stepResult.ID, waitErr.Error(), duration); dbErr != nil { + slog.Warn("failed to mark recovered step as failed in db", "step", gate.step.Name(), "error", dbErr) + } + e.emitStepEventWithFindingsAndError(ipc.EventStepCompleted, run, repo, gate.step.Name(), string(types.StepStatusFailed), "", waitErr.Error(), &duration) + return e.failRun(run, repo, fmt.Errorf("step %s: waiting for approval: %w", gate.step.Name(), waitErr), ctx) + } + if reconciled { + if dbErr := e.db.CompleteRunAwaitingAgent(run.ID, time.Since(parkStart).Milliseconds()); dbErr != nil { + return e.failRun(run, repo, fmt.Errorf("complete reconciled awaiting-agent state: %w", dbErr), ctx) + } + return completeReconciledGate() } - e.emitStepEventWithFindingsAndError(ipc.EventStepCompleted, run, repo, gate.step.Name(), string(types.StepStatusFailed), "", err.Error(), &duration) - return e.failRun(run, repo, fmt.Errorf("step %s: waiting for approval: %w", gate.step.Name(), err), ctx) } - if reconciled { - return completeReconciledGate() + if dbErr := e.db.CompleteRunAwaitingAgent(run.ID, time.Since(parkStart).Milliseconds()); dbErr != nil { + return e.failRun(run, repo, fmt.Errorf("complete owner-response awaiting-agent state: %w", dbErr), ctx) } approvalFields := telemetry.Fields{ @@ -423,7 +796,7 @@ func (e *Executor) Resume(ctx context.Context, run *db.Run, repo *db.Repo, workD telemetry.Track("fix", e.fixTelemetryFields("user", gate.step.Name(), selectedFindingCount(gate.findings, response.findingIDs), 0)) selected := filterFindingsJSON(gate.findings, response.findingIDs) merged := mergeUserOverridesJSON(selected, response.instructions, response.addedFindings) - if gate.lastRoundID != "" { + if gate.lastRoundID != "" && !e.OwnerDecisionProtected() { allSelectedIDs := combineSelectedFindingIDs(response.findingIDs, merged) if idsJSON := marshalFindingIDs(allSelectedIDs); idsJSON != "" { var userFindingsJSON *string @@ -541,6 +914,9 @@ func (e *Executor) executeRecoveredRemainder(ctx context.Context, run *db.Run, r return e.skipRecoveredRemainder(run, repo, index+1) } } + if err := e.verifyOwnerDecisionHistory(); err != nil { + return e.failRun(run, repo, fmt.Errorf("before recovered run completion: %w", err)) + } if err := e.completeRun(run, repo); err != nil { return e.failRun(run, repo, fmt.Errorf("complete recovered run: %w", err), ctx) } @@ -561,6 +937,9 @@ func (e *Executor) skipRecoveredRemainder(run *db.Run, repo *db.Repo, start int) } e.emitStepEventWithFindingsAndError(ipc.EventStepCompleted, run, repo, e.steps[index].Name(), string(types.StepStatusSkipped), "", "", nil) } + if err := e.verifyOwnerDecisionHistory(); err != nil { + return e.failRun(run, repo, fmt.Errorf("before recovered run completion: %w", err)) + } if err := e.completeRun(run, repo); err != nil { return e.failRun(run, repo, fmt.Errorf("complete recovered run: %w", err)) } @@ -592,6 +971,13 @@ func recoveredLogPath(step *db.StepResult) string { // Returns (skipRemaining, error). func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult, run *db.Run, repo *db.Repo, workDir, logDir string, state stepExecutionState) (bool, error) { stepName := step.Name() + // A protected run must prove its entire signed history against the + // controller-held expected head immediately before every step. In + // particular this is the final fail-closed boundary before push, PR, CI, + // or any other later step with external effects. + if err := e.verifyOwnerDecisionHistory(); err != nil { + return false, fmt.Errorf("before step %s: %w", stepName, err) + } logPath := filepath.Join(logDir, string(stepName)+".log") finalExitCode := 0 autoFixLimit := 0 @@ -765,6 +1151,13 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult // Execute with possible fix loop for { + // A step may execute again inside its fix loop without returning to the + // outer step boundary. Re-verify here as well so a signed decision cannot + // be followed by another agent/provider action after local history, + // authority, findings, or projection tampering. + if err := e.verifyOwnerDecisionHistory(); err != nil { + return false, fmt.Errorf("before step %s round %d: %w", stepName, roundNum+1, err) + } reviewStartingHeadSHA := run.HeadSHA sctx.ReviewStartingHeadSHA = reviewStartingHeadSHA outcome, err := step.Execute(sctx) @@ -898,8 +1291,7 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult // emitting events, so that callers who poll the DB status can // immediately call Respond once they see it. e.mu.Lock() - e.waiting = true - e.waitingStep = stepName + e.setWaitingOwnerGate(run, repo, stepName, sr.ID, currentRoundID, outcome.Findings) e.mu.Unlock() // Parking starts before the gate becomes observable. This includes the @@ -915,6 +1307,7 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult e.mu.Lock() e.waiting = false e.waitingStep = "" + e.ownerGate = nil e.mu.Unlock() return false, fmt.Errorf("persist %s approval gate: %w", stepName, dbErr) } @@ -988,7 +1381,7 @@ func (e *Executor) executeStep(ctx context.Context, step Step, sr *db.StepResult mergedFindings := mergeUserOverridesJSON(selectedFindings, response.instructions, response.addedFindings) sctx.PreviousFindings = mergedFindings nextTrigger = "auto_fix" - if currentRoundID != "" { + if currentRoundID != "" && !e.OwnerDecisionProtected() { allSelectedIDs := combineSelectedFindingIDs(response.findingIDs, mergedFindings) if idsJSON := marshalFindingIDs(allSelectedIDs); idsJSON != "" { var userFindingsJSON *string @@ -1055,6 +1448,12 @@ func (e *Executor) recordDeclinedRound(roundID, findingsJSON string, stepName ty if e == nil || e.db == nil || roundID == "" { return } + if e.OwnerDecisionProtected() { + // Protected decisions were transactionally materialized before the + // approval wait was released. A second mutable write here would weaken + // the journal-to-projection invariant. + return + } if findingsCount(findingsJSON) == 0 { // Nothing was declined, so there is no decision to record. return @@ -1194,6 +1593,7 @@ func (e *Executor) waitForApprovalOrReconcile(ctx context.Context, step Step, sc e.mu.Lock() e.waiting = false e.waitingStep = "" + e.ownerGate = nil e.mu.Unlock() // Drain any stale response that arrived after context cancellation or // raced with an external reconciliation. @@ -1255,6 +1655,7 @@ func (e *Executor) claimGateReconciliation() bool { } e.waiting = false e.waitingStep = "" + e.ownerGate = nil return true } @@ -1286,7 +1687,7 @@ func (e *Executor) failRun(run *db.Run, repo *db.Repo, err error, ctxs ...contex } } runStatus := types.RunFailed - if errMsg == types.RunCancelReasonAbortedByUser || errMsg == types.RunCancelReasonSuperseded { + if errors.Is(err, errOwnerDecisionCancelled) || errMsg == types.RunCancelReasonAbortedByUser || errMsg == types.RunCancelReasonSuperseded { runStatus = types.RunCancelled } verifiedHead, verified := e.reconcileTerminalRunHead(run) @@ -1308,6 +1709,20 @@ func (e *Executor) failRun(run *db.Run, repo *db.Repo, err error, ctxs ...contex } func (e *Executor) completeRun(run *db.Run, repo *db.Repo) error { + e.mu.Lock() + defer e.mu.Unlock() + if e.ownerAuthority != nil { + if err := e.verifyOwnerDecisionHistoryLocked(); err != nil { + return err + } + cancelled, err := e.db.CommittedOwnerCancellation(run.ID, e.ownerExpectedHead) + if err != nil { + return err + } + if cancelled { + return errOwnerDecisionCancelled + } + } verifiedHead, verified := e.reconcileTerminalRunHead(run) var err error if verified { diff --git a/internal/pipeline/executor_owner_decision_test.go b/internal/pipeline/executor_owner_decision_test.go new file mode 100644 index 000000000..bfe48fab5 --- /dev/null +++ b/internal/pipeline/executor_owner_decision_test.go @@ -0,0 +1,343 @@ +package pipeline + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "strings" + "testing" + "time" + + "github.com/kunchenguid/no-mistakes/internal/db" + "github.com/kunchenguid/no-mistakes/internal/ownerdecision" + "github.com/kunchenguid/no-mistakes/internal/types" +) + +func protectExecutorRun(t *testing.T, database *db.DB, exec *Executor, runID string) ed25519.PrivateKey { + t.Helper() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + if _, err := database.ProtectRunOwnerDecisions(runID, publicKey); err != nil { + t.Fatal(err) + } + authority, err := database.GetOwnerDecisionAuthority(runID) + if err != nil { + t.Fatal(err) + } + if err := exec.ArmOwnerDecisionHistory(runID, authority.GenesisHead); err != nil { + t.Fatal(err) + } + return privateKey +} + +func TestExecutorSignedResponseOnUnprotectedExecutorFailsWithoutPanic(t *testing.T) { + exec := &Executor{} + if err := exec.RespondAuthorized(ownerdecision.Envelope{}); err == nil { + t.Fatal("unprotected executor accepted a signed response") + } +} + +func TestExecutorProtectedGateAppendsBeforeResumeAndRefusesUnsignedTamperedExpired(t *testing.T) { + database, paths, run, repo := setupTest(t) + findings := `{"findings":[{"id":"review-1","severity":"high","description":"owner choice","action":"ask-user"}],"summary":"one"}` + exec := NewExecutor(database, paths, nil, nil, []Step{newApprovalStep(types.StepReview, findings)}, nil) + privateKey := protectExecutorRun(t, database, exec, run.ID) + + done, _ := startExecutor(t, exec, run, repo, t.TempDir()) + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) + if err := exec.Respond(types.StepReview, types.ActionApprove, nil); err == nil || !strings.Contains(err.Error(), "signed decision envelope") { + t.Fatalf("unsigned response error = %v", err) + } + + challenge, err := exec.OwnerDecisionChallenge(ownerdecision.PurposeRespond) + if err != nil { + t.Fatal(err) + } + valid, err := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + + tampered := valid.Clone() + tampered.Challenge.RunID = "cross-run" + if err := exec.RespondAuthorized(tampered); err == nil { + t.Fatal("cross-run response was accepted") + } + expiredChallenge := challenge + expiredChallenge.IssuedAt = time.Now().Add(-2 * time.Minute).Unix() + expiredChallenge.ExpiresAt = time.Now().Add(-time.Minute).Unix() + expired, err := ownerdecision.Sign(privateKey, expiredChallenge, ownerdecision.Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + if err := exec.RespondAuthorized(expired); err == nil { + t.Fatal("expired response was accepted") + } + if head, protected, err := database.OwnerDecisionHead(run.ID); err != nil || !protected || head != challenge.PreviousHead { + t.Fatalf("head after refusals = %q protected=%v err=%v", head, protected, err) + } + + appendObserved := false + exec.beforeOwnerResume = func() { + head, protected, err := database.OwnerDecisionHead(run.ID) + if err != nil || !protected || head == challenge.PreviousHead { + t.Errorf("journal was not committed before resume: head=%q protected=%v err=%v", head, protected, err) + return + } + rounds, err := database.GetRoundsByStep(challenge.StepResultID) + if err != nil || len(rounds) != 1 || rounds[0].SelectionSource == nil || *rounds[0].SelectionSource != db.RoundSelectionSourceUserDeclined { + t.Errorf("projection was not committed before resume: rounds=%+v err=%v", rounds, err) + return + } + appendObserved = true + } + if err := exec.RespondAuthorized(valid); err != nil { + t.Fatalf("valid response: %v", err) + } + waitExecutorDone(t, done) + if !appendObserved { + t.Fatal("append-before-resume seam was not observed") + } +} + +func TestExecutorProtectedHistoryTamperStopsLaterStepBeforeEffect(t *testing.T) { + database, paths, run, repo := setupTest(t) + findings := `{"findings":[{"id":"review-1","severity":"high","description":"owner choice","action":"ask-user"}]}` + effectCalls := 0 + exec := NewExecutor(database, paths, nil, nil, []Step{ + newApprovalStep(types.StepReview, findings), + &adaptiveCallStep{name: types.StepPush, fn: func(*StepContext) (*StepOutcome, error) { + effectCalls++ + return &StepOutcome{}, nil + }}, + }, nil) + privateKey := protectExecutorRun(t, database, exec, run.ID) + done, _ := startExecutor(t, exec, run, repo, t.TempDir()) + waitForStepStatus(t, database, run.ID, types.StepReview, types.StepStatusAwaitingApproval) + challenge, err := exec.OwnerDecisionChallenge(ownerdecision.PurposeRespond) + if err != nil { + t.Fatal(err) + } + envelope, err := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + exec.beforeOwnerResume = func() { + changed := `["attacker-selected"]` + if err := database.SetStepRoundUserDecision(challenge.RoundID, &changed, db.RoundSelectionSourceUser, nil); err != nil { + t.Errorf("inject projection tamper: %v", err) + } + } + if err := exec.RespondAuthorized(envelope); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "owner decision history verification failed") { + t.Fatalf("executor error = %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("executor did not fail closed after tamper") + } + if effectCalls != 0 { + t.Fatalf("later external-effect step executed %d times", effectCalls) + } +} + +func TestExecutorProtectedHistoryTamperStopsSignedFixReexecution(t *testing.T) { + database, paths, run, repo := setupTest(t) + findings := `{"findings":[{"id":"test-1","severity":"high","description":"owner choice","action":"ask-user"}]}` + executeCalls := 0 + step := &adaptiveCallStep{name: types.StepTest, fn: func(*StepContext) (*StepOutcome, error) { + executeCalls++ + if executeCalls == 1 { + return &StepOutcome{Findings: findings, NeedsApproval: true}, nil + } + return &StepOutcome{}, nil + }} + exec := NewExecutor(database, paths, nil, nil, []Step{step}, nil) + privateKey := protectExecutorRun(t, database, exec, run.ID) + done, _ := startExecutor(t, exec, run, repo, t.TempDir()) + waitForStepStatus(t, database, run.ID, types.StepTest, types.StepStatusAwaitingApproval) + challenge, err := exec.OwnerDecisionChallenge(ownerdecision.PurposeRespond) + if err != nil { + t.Fatal(err) + } + envelope, err := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionFix, FindingIDs: []string{"test-1"}}) + if err != nil { + t.Fatal(err) + } + exec.beforeOwnerResume = func() { + changed := `["test-1","attacker-selected"]` + if err := database.SetStepRoundUserDecision(challenge.RoundID, &changed, db.RoundSelectionSourceUser, nil); err != nil { + t.Errorf("inject projection tamper: %v", err) + } + } + if err := exec.RespondAuthorized(envelope); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "owner decision history verification failed") { + t.Fatalf("executor error = %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("executor did not fail closed before signed fix re-execution") + } + if executeCalls != 1 { + t.Fatalf("step executed %d times after tamper", executeCalls) + } +} + +func TestExecutorProtectedRecoveryRequiresExternalExpectedHead(t *testing.T) { + database, paths, run, repo := setupTest(t) + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + authority, err := database.ProtectRunOwnerDecisions(run.ID, publicKey) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateRunStatus(run.ID, types.RunRunning); err != nil { + t.Fatal(err) + } + step, err := database.InsertStepResult(run.ID, types.StepReview) + if err != nil { + t.Fatal(err) + } + if err := database.StartStep(step.ID); err != nil { + t.Fatal(err) + } + findings := `{"findings":[{"id":"review-1","severity":"high","description":"owner choice","action":"ask-user"}]}` + if err := database.SetStepFindings(step.ID, findings); err != nil { + t.Fatal(err) + } + if _, err := database.InsertReviewStepRound(step.ID, 1, "initial", &findings, nil, run.HeadSHA, 1); err != nil { + t.Fatal(err) + } + if err := database.UpdateStepStatusWithDuration(step.ID, types.StepStatusAwaitingApproval, 1); err != nil { + t.Fatal(err) + } + if err := database.SetRunAwaitingAgent(run.ID); err != nil { + t.Fatal(err) + } + run, _ = database.GetRun(run.ID) + + exec := NewExecutor(database, paths, nil, nil, []Step{newApprovalStep(types.StepReview, findings)}, nil) + if err := exec.Resume(context.Background(), run, repo, t.TempDir()); err == nil || !strings.Contains(err.Error(), "externally supplied expected history head") { + t.Fatalf("unarmed protected recovery error = %v", err) + } + if err := exec.ArmOwnerDecisionHistory(run.ID, ownerdecision.DigestBytes([]byte("rollback-head"))); err == nil { + t.Fatal("wrong external expected head armed recovery") + } + now := time.Now().UTC() + cancelChallenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, Purpose: ownerdecision.PurposeCancel, + RunID: run.ID, RepoID: repo.ID, Branch: run.Branch, HeadSHA: authority.InitialHeadSHA, GateHeadSHA: run.HeadSHA, + PreviousHead: authority.GenesisHead, Nonce: "cancel:" + run.ID + ":" + authority.GenesisHead, + IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), + } + cancelEnvelope, err := ownerdecision.Sign(privateKey, cancelChallenge, ownerdecision.Response{Action: types.ActionAbort}) + if err != nil { + t.Fatal(err) + } + appended, err := database.AppendOwnerDecision(run.ID, ownerdecision.PurposeCancel+":"+cancelChallenge.Nonce, cancelEnvelope, cancelChallenge, nil, now) + if err != nil { + t.Fatal(err) + } + recoveredRun, err := database.GetRun(run.ID) + if err != nil { + t.Fatal(err) + } + recovered := NewExecutor(database, paths, nil, nil, []Step{newApprovalStep(types.StepReview, findings)}, nil) + if err := recovered.ArmOwnerDecisionHistory(run.ID, appended.Head); err != nil { + t.Fatal(err) + } + if err := recovered.Resume(context.Background(), recoveredRun, repo, t.TempDir()); err == nil || !strings.Contains(err.Error(), types.RunCancelReasonAbortedByUser) { + t.Fatalf("committed cancellation recovery error = %v", err) + } + cancelled, err := database.GetRun(run.ID) + if err != nil || cancelled.Status != types.RunCancelled || cancelled.AwaitingAgentSince != nil { + t.Fatalf("committed cancellation recovery status=%+v err=%v", cancelled, err) + } +} + +func TestExecutorProtectedRecoveryReplaysCommittedResponseAfterAppendCrash(t *testing.T) { + database, paths, run, repo := setupTest(t) + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + authority, err := database.ProtectRunOwnerDecisions(run.ID, publicKey) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateRunStatus(run.ID, types.RunRunning); err != nil { + t.Fatal(err) + } + step, err := database.InsertStepResult(run.ID, types.StepReview) + if err != nil { + t.Fatal(err) + } + if err := database.StartStep(step.ID); err != nil { + t.Fatal(err) + } + findings := `{"findings":[{"id":"review-1","severity":"high","description":"owner choice","action":"ask-user"}]}` + if err := database.SetStepFindings(step.ID, findings); err != nil { + t.Fatal(err) + } + round, err := database.InsertReviewStepRound(step.ID, 1, "initial", &findings, nil, run.HeadSHA, 1) + if err != nil { + t.Fatal(err) + } + if err := database.UpdateStepStatusWithDuration(step.ID, types.StepStatusAwaitingApproval, 1); err != nil { + t.Fatal(err) + } + if err := database.SetRunAwaitingAgent(run.ID); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + challenge := ownerdecision.Challenge{ + Schema: ownerdecision.ChallengeSchema, Purpose: ownerdecision.PurposeRespond, + RunID: run.ID, RepoID: repo.ID, Branch: run.Branch, HeadSHA: authority.InitialHeadSHA, GateHeadSHA: run.HeadSHA, + Step: types.StepReview, StepResultID: step.ID, RoundID: round.ID, + FindingsDigest: ownerdecision.DigestBytes([]byte(findings)), PreviousHead: authority.GenesisHead, + Nonce: "respond:" + round.ID + ":" + authority.GenesisHead, + IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), + } + envelope, err := ownerdecision.Sign(privateKey, challenge, ownerdecision.Response{Action: types.ActionApprove}) + if err != nil { + t.Fatal(err) + } + appended, err := database.AppendOwnerDecision(run.ID, ownerdecision.PurposeRespond+":"+round.ID, envelope, challenge, &db.OwnerDecisionProjection{ + RoundID: round.ID, SelectedFindingIDs: db.DeclinedSelectionJSON, SelectionSource: db.RoundSelectionSourceUserDeclined, + }, now) + if err != nil { + t.Fatal(err) + } + + // This is the process-crash boundary: the append is durable, but no + // approval channel was released and the gate remains parked. + recoveredRun, err := database.GetRun(run.ID) + if err != nil { + t.Fatal(err) + } + exec := NewExecutor(database, paths, nil, nil, []Step{newApprovalStep(types.StepReview, findings)}, nil) + if err := exec.ArmOwnerDecisionHistory(run.ID, appended.Head); err != nil { + t.Fatal(err) + } + if err := exec.Resume(context.Background(), recoveredRun, repo, t.TempDir()); err != nil { + t.Fatal(err) + } + completed, err := database.GetRun(run.ID) + if err != nil || completed.Status != types.RunCompleted { + t.Fatalf("recovered committed response status=%v err=%v", completed, err) + } + head, protected, err := database.OwnerDecisionHead(run.ID) + if err != nil || !protected || head != appended.Head { + t.Fatalf("recovery appended a second event: head=%s want=%s protected=%v err=%v", head, appended.Head, protected, err) + } +} diff --git a/workflow_release_test.go b/workflow_release_test.go index 4f6e84c24..e56c703a0 100644 --- a/workflow_release_test.go +++ b/workflow_release_test.go @@ -88,6 +88,35 @@ func TestReleaseWorkflowEmbedsSelfHostedTelemetryConfig(t *testing.T) { } } +func TestNormalAndReleaseBuildsEmbedFullSourceRevision(t *testing.T) { + makeData, err := os.ReadFile("Makefile") + if err != nil { + t.Fatalf("read Makefile: %v", err) + } + makeContent := string(makeData) + if !strings.Contains(makeContent, "git rev-parse HEAD 2>/dev/null") { + t.Fatalf("normal builds must embed the full source revision") + } + if strings.Contains(makeContent, "rev-parse --short") { + t.Fatalf("normal builds must not shorten the source revision") + } + + workflowData, err := os.ReadFile(".github/workflows/release.yml") + if err != nil { + t.Fatalf("read workflow: %v", err) + } + workflow := string(workflowData) + for _, job := range []string{"build-darwin", "build-and-upload"} { + block := extractJobBlock(t, workflow, job) + if got := strings.Count(block, `COMMIT="$(git rev-parse HEAD)"`); got != 1 { + t.Fatalf("%s must bind exactly one full source revision, got %d", job, got) + } + if strings.Contains(block, "rev-parse --short") { + t.Fatalf("%s must not shorten the source revision", job) + } + } +} + // Partial-release protection: release-please must create drafts so that a // release is never marked "latest" until all binaries and checksums are // uploaded. A separate finalize job gates the promotion on every asset job