diff --git a/docs/lint-workflow-control.md b/docs/lint-workflow-control.md index bd00c4f..a951ee5 100644 --- a/docs/lint-workflow-control.md +++ b/docs/lint-workflow-control.md @@ -74,3 +74,19 @@ content or the following review check failed. Source-change precedence remains These stages identify a workflow boundary, not the underlying cause or Node execution. No raw errors, outputs, paths or credentials are reported; acceptance, consent, pins, limits, report schema and diagnostic IDs do not change. + +## Independent execution and cleanup facts + +The existing internal runner result now retains whether Start was requested, +a closed primary failure code and whether Remove was attempted. Cleanup remains +derived from the existing verified-absence flag; no-start does not become +verified cleanup. A failed Start and unverified Remove therefore survive together +even though cleanup still wins the legacy rejection reason. These optional facts +are excluded from Result JSON; existing wire/report schemas and IDs do not change. + +One allowlisted projection carries the same producer facts through capture, +source-change, cleanup, cancellation and expiry decisions without promoting +execution or efficacy. Nil facts mean unavailable metadata, not no-start. +Unknown causes become UNSPECIFIED; raw errors, streams, paths and tokens are +never copied. A requested Start is not proof of container creation or Node +execution. No new gate, option, admission or relaxation of cleanup is introduced. diff --git a/internal/lintverify/catalog.go b/internal/lintverify/catalog.go index 849f2a4..3d820c4 100644 --- a/internal/lintverify/catalog.go +++ b/internal/lintverify/catalog.go @@ -12,6 +12,7 @@ import ( "github.com/dnlrsls/ci-trust/internal/lintcatalog" ls "github.com/dnlrsls/ci-trust/internal/lintsensitivity" + "github.com/dnlrsls/ci-trust/internal/runner" ) // Catalog and dependency seams are private: input files/CLI arguments cannot @@ -86,6 +87,7 @@ type approval struct { expires time.Time } type captureResult struct { + facts *runner.ExecutionFacts capture ls.ESLintCapture cleanupVerified bool runtimeCode string diff --git a/internal/lintverify/engine.go b/internal/lintverify/engine.go index 6f3c86e..85379ce 100644 --- a/internal/lintverify/engine.go +++ b/internal/lintverify/engine.go @@ -71,16 +71,16 @@ func evaluate(ctx context.Context, snapshot string, d dependencies) outcome { // assertion alone never establishes completed/preserved execution. after, err := d.snapshot() if err != nil || after != snapshot { - return unresolved("LINT_SOURCE_CHANGED") + return captureFailure("LINT_SOURCE_CHANGED", item.Role, captured) } if !captured.cleanupVerified { - return unresolved("LINT_CLEANUP_UNVERIFIED") + return captureFailure("LINT_CLEANUP_UNVERIFIED", item.Role, captured) } if ctx.Err() != nil { - return unresolved("LINT_CANCELLED") + return captureFailure("LINT_CANCELLED", item.Role, captured) } if !d.now().Before(approved.expires) { - return unresolved("LINT_REVIEW_EXPIRED") + return captureFailure("LINT_REVIEW_EXPIRED", item.Role, captured) } if runErr != nil { captured.capture.Status = ls.TimedOut @@ -93,3 +93,11 @@ func evaluate(ctx context.Context, snapshot string, d dependencies) outcome { } return outcome{decision: ls.Classify(observations), observations: observations} } + +func captureFailure(reason string, role ls.Role, captured captureResult) outcome { + out := unresolved(reason) + if captured.facts != nil { + out.harness = failedHarness(role, captured) + } + return out +} diff --git a/internal/lintverify/facts_test.go b/internal/lintverify/facts_test.go new file mode 100644 index 0000000..5fd3e1f --- /dev/null +++ b/internal/lintverify/facts_test.go @@ -0,0 +1,158 @@ +package lintverify + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/dnlrsls/ci-trust/internal/discovery" + "github.com/dnlrsls/ci-trust/internal/runner" +) + +const privateFacts = "ghp_private_failure /private/failure" + +type factsDriver struct { + mode string + starts, removes int + after func() +} + +func (d *factsDriver) Preflight(context.Context, runner.Plan) error { + if d.mode == "pre-start" { + return errors.New(privateFacts) + } + return nil +} +func (d *factsDriver) Start(context.Context, string, []string, io.Writer) (int, error) { + d.starts++ + if d.mode == "start" || d.mode == "both" { + return -1, errors.New(privateFacts) + } + return 0, nil +} +func (d *factsDriver) Remove(context.Context, string) error { + d.removes++ + if d.after != nil { + defer d.after() + } + if d.mode == "cleanup" || d.mode == "both" { + return errors.New(privateFacts) + } + return nil +} + +type factsWorkflow struct { + workflowRuntime + driver *factsDriver + trace *workflowTrace +} + +func (w factsWorkflow) execute(ctx context.Context, p runner.Plan, c runner.Consent, measure runner.Snapshot) runner.Result { + w.trace.event(p.Fixture) + w.trace.captures++ + if !c.Approved || c.Digest != p.Digest() { + w.trace.t.Fatal("lost owned consent binding") + } + // Exercise the actual shared Runner.Execute producer using an owned fake, + // not a pre-shaped Result. The generic transport cannot launch a process. + smoke := p + smoke.Adapter, smoke.Tool, smoke.Fixture, smoke.Config, smoke.Transport = "owned-smoke-v1", "owned-smoke-v1", "smoke-v1", "owned-v1", "" + smoke.Effects = "owned fixture only; no host mounts; no network" + r, err := runner.NewOwned(w.driver, smoke, measure) + if err != nil { + w.trace.t.Fatal(err) + } + return r.Execute(ctx, smoke, runner.Consent{Approved: true, Digest: smoke.Digest()}) +} + +func TestProducerFactsSurviveComposedReport(t *testing.T) { + for _, tc := range []struct{ mode, reason, primary, cleanup, requested string }{ + {"pre-start", "LINT_CLEANUP_UNVERIFIED", "PREFLIGHT_FAILED", "NOT_ATTEMPTED", "false"}, + {"start", "LINT_HARNESS_FAILURE", "START_FAILED", "VERIFIED", "true"}, + {"both", "LINT_CLEANUP_UNVERIFIED", "START_FAILED", "REMOVE_UNVERIFIED", "true"}, + {"cleanup", "LINT_CLEANUP_UNVERIFIED", "NONE", "REMOVE_UNVERIFIED", "true"}, + {"source", "LINT_SOURCE_CHANGED", "START_FAILED", "REMOVE_UNVERIFIED", "true"}, + {"cancel", "LINT_CANCELLED", "WALLTIME_OR_CANCEL", "VERIFIED", "true"}, + {"expiry", "LINT_REVIEW_EXPIRED", "START_FAILED", "VERIFIED", "true"}, + } { + t.Run(tc.mode, func(t *testing.T) { + s, f := workflowFixture(t, "sensitive") + driver := &factsDriver{mode: tc.mode} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + switch tc.mode { + case "source": + driver.mode = "both" + measure := s.snapshot + s.snapshot = func() (string, error) { + hash, e := measure() + if f.snapshots == 5 { + return strings.Repeat("c", 64), nil + } + return hash, e + } + case "cancel": + driver.mode = "start" + driver.after = cancel + case "expiry": + driver.mode = "start" + driver.after = func() { f.clock = f.clock.Add(16 * time.Minute) } + } + original := s.newRuntime + s.newRuntime = func() (workflowRuntime, error) { r, e := original(); return factsWorkflow{r, driver, f}, e } + source := []byte(`{"scripts":{"lint":"eslint ."}}`) + snapshot := discovery.Snapshot{Documents: []discovery.Document{{Path: "package.json", Content: source}}} + got, err := analyze(snapshot, func(hash string) outcome { + p, _ := planFor(s.catalog, hash) + r, e := makeGitHubReviewPlan(p, f.r.Revision, f.r.RunID, f.clock) + if e != nil { + t.Fatal(e) + } + f.r = r + return evaluateGitHubWorkflow(ctx, hash, r, s) + }) + if err != nil { + t.Fatal(err) + } + base, err := analyze(snapshot, func(string) outcome { return unresolved(tc.reason) }) + if err != nil { + t.Fatal(err) + } + message := "Owned lint sensitivity: UNVERIFIABLE. " + scopeLimit + detail := " Execution facts: role=CLEAN; start_requested=" + tc.requested + "; primary=" + tc.primary + "; cleanup_attempted=" + tc.requested + "; cleanup=" + tc.cleanup + ". This metadata does not establish Node execution." + want := bytes.Replace(base, []byte(message), []byte(message+detail), 1) + if !bytes.Equal(got, want) { + t.Error("primary/cleanup facts lost or report changed beyond diagnostic") + } + if strings.Contains(string(got), "ghp_private_failure") || strings.Contains(string(got), "/private/failure") { + t.Fatal("raw error leaked") + } + calls := 1 + snapshots := 5 + if tc.mode == "pre-start" { + calls = 0 + snapshots = 4 + } + if driver.starts != calls || driver.removes != calls || f.captures != 1 || f.loads != 1 || f.snapshots != snapshots || string(source) != `{"scripts":{"lint":"eslint ."}}` { + t.Fatalf("changed calls/source: %+v %+v", driver, f) + } + }) + } +} + +func TestFailureFactsUnknownCauseCannotLeak(t *testing.T) { + snapshot := discovery.Snapshot{Documents: []discovery.Document{{Path: "package.json", Content: []byte(`{"scripts":{"lint":"eslint ."}}`)}}} + b, e := analyze(snapshot, func(string) outcome { + return captureFailure("LINT_CLEANUP_UNVERIFIED", "CLEAN", captureResult{facts: &runner.ExecutionFacts{Primary: privateFacts}}) + }) + if e != nil { + t.Fatal(e) + } + if strings.Contains(string(b), "ghp_private_failure") || strings.Contains(string(b), "/private/failure") || !strings.Contains(string(b), "primary=UNSPECIFIED") || !strings.Contains(string(b), "cleanup=NOT_ATTEMPTED") { + t.Fatal("unknown failure facts escaped or fabricated cleanup") + } +} diff --git a/internal/lintverify/github_workflow.go b/internal/lintverify/github_workflow.go index 7912f5b..681fda0 100644 --- a/internal/lintverify/github_workflow.go +++ b/internal/lintverify/github_workflow.go @@ -128,7 +128,7 @@ func (d dockerWorkflow) load(ctx context.Context, a, i []byte) error { func (d dockerWorkflow) execute(ctx context.Context, p runner.Plan, a runner.Consent, source runner.Snapshot) runner.Result { r, e := runner.NewLintOwned(d.docker, p, source) if e != nil { - return runner.Result{Outcome: runner.Unverifiable, Reason: "lint runner unavailable"} + return runner.Result{Outcome: runner.Unverifiable, Reason: "lint runner unavailable", Facts: &runner.ExecutionFacts{Primary: "RUNNER_SETUP"}} } return r.Execute(ctx, p, a) } @@ -196,10 +196,11 @@ func evaluateGitHubWorkflow(ctx context.Context, snapshot string, r githubReview return nil } d.capture = func(ctx context.Context, p plan, item ownedCase, binding string) captureResult { - failed := captureResult{capture: ls.ESLintCapture{Status: ls.Unavailable}} + failed := captureResult{capture: ls.ESLintCapture{Status: ls.Unavailable}, facts: &runner.ExecutionFacts{Primary: "CAPTURE_GUARD"}} if !validReview() || ctx.Err() != nil || binding != p.digest() { return failed } + failed.facts.Primary = "RUNTIME_CREATION" rt := first first = nil if item.Role != ls.Clean { @@ -212,6 +213,7 @@ func evaluateGitHubWorkflow(ctx context.Context, snapshot string, r githubReview if rt == nil { return failed } + failed.facts.Primary = "ROLE_PLAN_OR_REVIEW" role, e := runner.FixedLintPlan(snapshot, item.Role, binding) if e != nil || !validReview() { return failed @@ -223,7 +225,7 @@ func evaluateGitHubWorkflow(ctx context.Context, snapshot string, r githubReview if result.Outcome == runner.Completed { status = ls.Completed } - return captureResult{cleanupVerified: result.CleanupVerified, runtimeCode: runtimeFailureCode(result.Reason), capture: ls.ESLintCapture{Status: status, ExitCode: result.ExitCode, Stdout: []byte(result.Stdout), Stderr: []byte(result.Stderr)}} + return captureResult{facts: result.Facts, cleanupVerified: result.CleanupVerified, runtimeCode: runtimeFailureCode(result.Reason), capture: ls.ESLintCapture{Status: status, ExitCode: result.ExitCode, Stdout: []byte(result.Stdout), Stderr: []byte(result.Stderr)}} } out := evaluate(ctx, snapshot, d) if changed { diff --git a/internal/lintverify/harness_diagnostic.go b/internal/lintverify/harness_diagnostic.go index b9275c5..b92eaef 100644 --- a/internal/lintverify/harness_diagnostic.go +++ b/internal/lintverify/harness_diagnostic.go @@ -6,16 +6,19 @@ import ( "strings" ls "github.com/dnlrsls/ci-trust/internal/lintsensitivity" + "github.com/dnlrsls/ci-trust/internal/runner" ) // Diagnostic data cannot change acceptance or carry runtime output into reports. // The combined receipt/output stage deliberately does not guess a parser cause. type harnessDetail struct { - role ls.Role - stage string - status ls.Status - outerExit int - runtimeCode string + facts *runner.ExecutionFacts + cleanupVerified bool + role ls.Role + stage string + status ls.Status + outerExit int + runtimeCode string } func failedHarness(role ls.Role, result captureResult) harnessDetail { @@ -29,7 +32,7 @@ func failedHarness(role ls.Role, result captureResult) harnessDetail { case len(c.Stderr) != 0: stage = "OUTER_STDERR" } - return harnessDetail{role, stage, c.Status, c.ExitCode, result.runtimeCode} + return harnessDetail{role: role, stage: stage, status: c.Status, outerExit: c.ExitCode, runtimeCode: result.runtimeCode, facts: result.facts, cleanupVerified: result.cleanupVerified} } func runtimeFailureCode(reason string) string { @@ -56,6 +59,9 @@ func (d harnessDetail) text() string { default: return "" } + if d.facts != nil { + return d.factText() + } switch d.stage { case "OUTER_CAPTURE", "OUTER_EXIT", "OUTER_STDERR", "RECEIPT_OR_ESLINT_OUTPUT": default: @@ -77,3 +83,21 @@ func (d harnessDetail) text() string { } return fmt.Sprintf(" Harness detail: role=%s; stage=%s; capture=%s; outer_exit=%s; runtime=%s. This metadata does not establish Node execution.", d.role, d.stage, status, exit, code) } + +func (d harnessDetail) factText() string { + primary := "UNSPECIFIED" + switch d.facts.Primary { + case "": + primary = "NONE" + case "PLAN_INVALID", "CONSENT_INVALID", "CONTEXT_ENDED", "TRANSPORT_UNAVAILABLE", "PREFLIGHT_FAILED", "SOURCE_CHANGED", "OUTPUT_LIMIT", "WALLTIME_OR_CANCEL", "START_FAILED", "LAUNCHER_EXIT", "CAPTURE_GUARD", "RUNTIME_CREATION", "ROLE_PLAN_OR_REVIEW", "RUNNER_SETUP": + primary = d.facts.Primary + } + cleanup := "NOT_ATTEMPTED" + if d.facts.CleanupAttempted { + cleanup = "REMOVE_UNVERIFIED" + if d.cleanupVerified { + cleanup = "VERIFIED" + } + } + return fmt.Sprintf(" Execution facts: role=%s; start_requested=%t; primary=%s; cleanup_attempted=%t; cleanup=%s. This metadata does not establish Node execution.", d.role, d.facts.StartRequested, primary, d.facts.CleanupAttempted, cleanup) +} diff --git a/internal/lintverify/report.go b/internal/lintverify/report.go index fa802a6..5b23fff 100644 --- a/internal/lintverify/report.go +++ b/internal/lintverify/report.go @@ -67,7 +67,7 @@ func analyze(s discovery.Snapshot, invoke func(string) outcome) ([]byte, error) } reason := safeReason(out.decision.Reason) message := "Owned lint sensitivity: " + string(out.decision.Outcome) + ". " + scopeLimit - if reason == "LINT_HARNESS_FAILURE" && out.decision.Outcome == ls.Unverifiable { + if (reason == "LINT_HARNESS_FAILURE" || reason == "LINT_CLEANUP_UNVERIFIED" || reason == "LINT_SOURCE_CHANGED" || reason == "LINT_CANCELLED" || reason == "LINT_REVIEW_EXPIRED") && out.decision.Outcome == ls.Unverifiable { message += out.harness.text() } if reason == "LINT_PREFLIGHT_UNAVAILABLE" && out.decision.Outcome == ls.Unverifiable { diff --git a/internal/runner/facts_test.go b/internal/runner/facts_test.go new file mode 100644 index 0000000..cb45693 --- /dev/null +++ b/internal/runner/facts_test.go @@ -0,0 +1,73 @@ +package runner + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestIndependentExecutionAndCleanupFacts(t *testing.T) { + for _, tc := range []struct { + name, primary, reason string + pre, run, clean error + started, removed, verified bool + }{ + {"pre-start", "PREFLIGHT_FAILED", "preflight: private probe", errors.New("private probe"), nil, nil, false, false, false}, + {"start", "START_FAILED", "harness: private start", nil, errors.New("private start"), nil, true, true, true}, + {"both", "START_FAILED", "cleanup unverified: private remove", nil, errors.New("private start"), errors.New("private remove"), true, true, false}, + {"cleanup", "", "cleanup unverified: private remove", nil, nil, errors.New("private remove"), true, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + p := owned() + f := &fake{pre: tc.pre, run: tc.run, clean: tc.clean} + r, err := NewOwned(f, p, func(context.Context) (string, error) { return p.Snapshot, nil }) + if err != nil { + t.Fatal(err) + } + got := r.Execute(context.Background(), p, Consent{Digest: p.Digest(), Approved: true}) + if got.Outcome != Unverifiable || got.Reason != tc.reason || got.CleanupVerified != tc.verified || (f.starts == 1) != tc.started || (f.removes == 1) != tc.removed { + t.Fatalf("legacy contract changed: %+v %+v", got, f) + } + if got.Facts == nil { + t.Fatal("producer discarded independent facts") + } + if got.Facts.StartRequested != tc.started || got.Facts.CleanupAttempted != tc.removed || got.Facts.Primary != tc.primary { + t.Fatalf("incorrect producer facts: %+v", got.Facts) + } + b, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + code := 0 + if !tc.started { + code = -1 + } + want, e := json.Marshal(Result{Outcome: Unverifiable, Reason: tc.reason, ExitCode: code, CleanupVerified: tc.verified}) + if e != nil { + t.Fatal(e) + } + if !bytes.Equal(b, want) { + t.Fatal("legacy Result JSON changed") + } + if strings.Contains(string(b), "Facts") || strings.Contains(string(b), "StartRequested") || strings.Contains(string(b), "START_FAILED") { + t.Fatal("internal metadata changed serialized Result") + } + }) + } +} + +func TestSplitTransportRetainsBothFailures(t *testing.T) { + p, _ := FixedLintPlan(strings.Repeat("a", 64), "CLEAN", strings.Repeat("b", 64)) + rt := &lintRuntimeFixture{capture: capturedRun{ExitCode: -1}, startErr: errors.New("private start"), cleanupErr: errors.New("private cleanup")} + r, e := newLintOwned(rt, p, rt.snapshot) + if e != nil { + t.Fatal(e) + } + got := r.Execute(context.Background(), p, Consent{Approved: true, Digest: p.Digest()}) + if got.Facts == nil || got.Facts.Primary != "START_FAILED" || !got.Facts.StartRequested || !got.Facts.CleanupAttempted || got.CleanupVerified || got.Outcome != Unverifiable || got.Reason != "cleanup unverified: private cleanup" || rt.calls != 3 || rt.snapshots != 1 { + t.Fatalf("split transport discarded a cause or changed calls: %+v %+v", got, rt) + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index dfe87eb..f46fe7d 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -52,7 +52,15 @@ const ( // Result preserves a tool exit code without interpreting it as validator PASS/FAIL. // CleanupVerified is true only when the trusted runtime attests container absence. +// ExecutionFacts observe harness calls; they never grant execution consent. +// StartRequested does not attest that a container or validator actually started. +type ExecutionFacts struct { + StartRequested, CleanupAttempted bool + Primary string +} + type Result struct { + Facts *ExecutionFacts `json:"-"` Outcome Outcome Reason string ExitCode int @@ -127,22 +135,25 @@ func (p Plan) Display() string { // Consent is not authentication: callers must never translate an agent's yes flag // into Approved. This package has no CLI or autonomous consent facility. func (r *Runner) Execute(ctx context.Context, p Plan, c Consent) Result { - result := Result{Outcome: Invalid, ExitCode: -1} + result := Result{Outcome: Invalid, ExitCode: -1, Facts: &ExecutionFacts{}} allowed := valid(p) if r.lint { allowed = validLint(p) } if !allowed || p != r.owned { + result.Facts.Primary = "PLAN_INVALID" result.Reason = "plan outside owned allowlist" return result } if !c.Approved || c.Digest != p.Digest() { result.Outcome = Denied + result.Facts.Primary = "CONSENT_INVALID" result.Reason = "absent or stale explicit consent" return result } result.Outcome = Unverifiable if err := ctx.Err(); err != nil { + result.Facts.Primary = "CONTEXT_ENDED" result.Reason = err.Error() return result } @@ -151,20 +162,24 @@ func (r *Runner) Execute(ctx context.Context, p Plan, c Consent) Result { var ok bool split, ok = r.runtime.(capturedRuntime) if !ok { + result.Facts.Primary = "TRANSPORT_UNAVAILABLE" result.Reason = "split container transport unavailable" return result } } if err := r.runtime.Preflight(ctx, p); err != nil { + result.Facts.Primary = "PREFLIGHT_FAILED" result.Reason = "preflight: " + err.Error() return result } snap, err := r.snapshot(ctx) if err != nil || snap != p.Snapshot { + result.Facts.Primary = "SOURCE_CHANGED" result.Reason = "source snapshot changed or unavailable" return result } if err := ctx.Err(); err != nil { + result.Facts.Primary = "CONTEXT_ENDED" result.Reason = err.Error() return result } @@ -176,6 +191,7 @@ func (r *Runner) Execute(ctx context.Context, p Plan, c Consent) Result { var code int var runErr error var captured *splitOutput + result.Facts.StartRequested = true if split != nil { // Both owned entrypoints receive empty container stdin. The lint // launcher selects and feeds its fixed fixture internally from the role. @@ -191,6 +207,7 @@ func (r *Runner) Execute(ctx context.Context, p Plan, c Consent) Result { result.OOMEvent = source.OOMEvent() } cleanupCtx, stop := context.WithTimeout(context.Background(), 3*time.Second) + result.Facts.CleanupAttempted = true cleanupErr := r.runtime.Remove(cleanupCtx, name) stop() output, overflow := out.state() @@ -200,6 +217,17 @@ func (r *Runner) Execute(ctx context.Context, p Plan, c Consent) Result { } result.ExitCode = code result.CleanupVerified = cleanupErr == nil + // Retain the execution failure before cleanup wins the legacy Reason. + switch { + case overflow: + result.Facts.Primary = "OUTPUT_LIMIT" + case runCtx.Err() != nil: + result.Facts.Primary = "WALLTIME_OR_CANCEL" + case runErr != nil: + result.Facts.Primary = "START_FAILED" + case r.lint && code != 0: + result.Facts.Primary = "LAUNCHER_EXIT" + } switch { case cleanupErr != nil: result.Reason = "cleanup unverified: " + cleanupErr.Error()