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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/lint-workflow-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions internal/lintverify/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,6 +87,7 @@ type approval struct {
expires time.Time
}
type captureResult struct {
facts *runner.ExecutionFacts
capture ls.ESLintCapture
cleanupVerified bool
runtimeCode string
Expand Down
16 changes: 12 additions & 4 deletions internal/lintverify/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
158 changes: 158 additions & 0 deletions internal/lintverify/facts_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
8 changes: 5 additions & 3 deletions internal/lintverify/github_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
36 changes: 30 additions & 6 deletions internal/lintverify/harness_diagnostic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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:
Expand All @@ -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)
}
2 changes: 1 addition & 1 deletion internal/lintverify/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading