From e982c7be7cf726b1b4bf70ce89d7f188bbd3a129 Mon Sep 17 00:00:00 2001 From: Iurii Trukhin Date: Fri, 6 Nov 2026 01:01:35 +0100 Subject: [PATCH 1/2] feat(safepush): verify native GitHub CI provenance Signed-off-by: Iurii Trukhin --- .github/safepush.json | 54 ++ cmd/cloudring-safepush/main.go | 130 +++++ cmd/cloudring-safepush/main_test.go | 90 +++ docs/ci-checks.md | 51 +- docs/safepush.md | 128 +++++ pkg/safepush/checks.go | 157 ++++++ pkg/safepush/checks_test.go | 330 +++++++++++ pkg/safepush/doc.go | 19 + pkg/safepush/github.go | 748 +++++++++++++++++++++++++ pkg/safepush/github_test.go | 718 ++++++++++++++++++++++++ pkg/safepush/policy.go | 155 +++++ pkg/safepush/policy_test.go | 79 +++ pkg/safepush/repository_policy_test.go | 117 ++++ pkg/safepush/workflow_source.go | 130 +++++ pkg/safepush/workflow_source_test.go | 129 +++++ 15 files changed, 3009 insertions(+), 26 deletions(-) create mode 100644 .github/safepush.json create mode 100644 cmd/cloudring-safepush/main.go create mode 100644 cmd/cloudring-safepush/main_test.go create mode 100644 docs/safepush.md create mode 100644 pkg/safepush/checks.go create mode 100644 pkg/safepush/checks_test.go create mode 100644 pkg/safepush/doc.go create mode 100644 pkg/safepush/github.go create mode 100644 pkg/safepush/github_test.go create mode 100644 pkg/safepush/policy.go create mode 100644 pkg/safepush/policy_test.go create mode 100644 pkg/safepush/repository_policy_test.go create mode 100644 pkg/safepush/workflow_source.go create mode 100644 pkg/safepush/workflow_source_test.go diff --git a/.github/safepush.json b/.github/safepush.json new file mode 100644 index 0000000..dbb5878 --- /dev/null +++ b/.github/safepush.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": "cloudring.safepush.github/v1", + "repository": "opencloudtech/CloudRING", + "repositoryId": 1288130713, + "workflows": [ + { + "path": ".github/workflows/ci.yml", + "jobs": [ + "docscheck", + "go-build-module-integrity", + "go-test", + "go-test-race", + "go-vet", + "ocs-conformance", + "ocs-validate", + "platform-manifests", + "postgres-integration", + "source-safety", + "windows-go-test" + ] + }, + { + "path": ".github/workflows/cla-dco.yml", + "jobs": [ + "cla", + "dco" + ] + }, + { + "path": ".github/workflows/license.yml", + "jobs": [ + "license-check" + ] + }, + { + "path": ".github/workflows/security.yml", + "jobs": [ + "codeql", + "gosec", + "govulncheck", + "secret-scan" + ] + }, + { + "path": ".github/workflows/supply-chain.yml", + "jobs": [ + "supply-chain-policy" + ] + } + ], + "excludedWorkflows": [ + ".github/workflows/release-provenance.yml" + ] +} diff --git a/cmd/cloudring-safepush/main.go b/cmd/cloudring-safepush/main.go new file mode 100644 index 0000000..fb4aa4a --- /dev/null +++ b/cmd/cloudring-safepush/main.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "time" + + "github.com/opencloudtech/CloudRING/pkg/safepush" +) + +type githubVerifier func(context.Context, *http.Client, string, safepush.GitHubPolicy, safepush.GitHubCandidate) error +type githubPolicyReader func(context.Context, *http.Client, string, string, string) (safepush.GitHubPolicy, error) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + os.Exit(run(ctx, os.Args[1:], os.Getenv, os.Stdout, os.Stderr, safepush.ReadGitHubPolicy, safepush.VerifyGitHub)) +} + +// The native required workflow owns these arguments. There is deliberately no +// caller-supplied check-results file, success flag, API host, or policy override. +func run(ctx context.Context, args []string, getenv func(string) string, stdout, stderr io.Writer, read githubPolicyReader, verify githubVerifier) int { + if len(args) == 0 || args[0] != "github" { + fmt.Fprintln(stderr, "usage: cloudring-safepush github --repository OWNER/REPO --pr NUMBER --head SHA --base SHA --branch main|master") + return 2 + } + flags := flag.NewFlagSet("github", flag.ContinueOnError) + flags.SetOutput(io.Discard) + repository := flags.String("repository", "", "target GitHub repository") + pr := flags.Int("pr", 0, "pull request number") + head := flags.String("head", "", "exact proposed commit") + base := flags.String("base", "", "exact accepted target commit") + branch := flags.String("branch", "", "protected target branch") + timeout := flags.Duration("timeout", 20*time.Minute, "bounded wait for native CI, at most 30 minutes") + if flags.Parse(args[1:]) != nil || flags.NArg() != 0 || *repository == "" || *pr <= 0 || + *head == "" || *base == "" || (*branch != "main" && *branch != "master") || + *timeout <= 0 || *timeout > 30*time.Minute { + fmt.Fprintln(stderr, "SafePush request is invalid") + return 2 + } + candidate := safepush.GitHubCandidate{PullRequest: *pr, HeadSHA: *head, BaseSHA: *base, TargetBranch: *branch} + if safepush.ValidateGitHubCandidate(*repository, candidate) != nil { + fmt.Fprintln(stderr, "SafePush request is invalid") + return 2 + } + bearer := getenv("GITHUB_TOKEN") + if bearer == "" { + fmt.Fprintln(stderr, "SafePush requires the workflow's read-only GITHUB_TOKEN") + return 2 + } + ctx, cancel := context.WithTimeout(ctx, *timeout) + defer cancel() + client := &http.Client{Timeout: 30 * time.Second} + policy, err := read(ctx, client, bearer, *repository, *base) + if err != nil { + fmt.Fprintln(stderr, "SafePush blocked:", safeDiagnostic(err)) + return 1 + } + if policy.Repository != *repository { + fmt.Fprintln(stderr, "SafePush blocked: accepted policy belongs to another repository") + return 1 + } + err = waitForChecks(ctx, 30*time.Second, func() error { return verify(ctx, client, bearer, policy, candidate) }) + if err != nil { + fmt.Fprintln(stderr, "SafePush blocked:", safeDiagnostic(err)) + return 1 + } + if _, err := fmt.Fprintln(stdout, "SafePush: the exact required CI checks passed for the current candidate and base"); err != nil { + return 1 + } + return 0 +} + +func safeDiagnostic(err error) string { + switch { + case errors.Is(err, context.Canceled): + return "request canceled" + case errors.Is(err, context.DeadlineExceeded): + return "verification deadline exceeded" + case errors.Is(err, safepush.ErrPolicy): + return "accepted repository policy is invalid" + case errors.Is(err, safepush.ErrGitHubAPI): + return "GitHub API is unavailable or returned an invalid response" + case errors.Is(err, safepush.ErrGitHubSource): + return "workflow or policy source does not match the accepted revision" + case errors.Is(err, safepush.ErrGitHubState): + return "pull request or protected target is stale or changed during verification" + case errors.Is(err, safepush.ErrGitHubRun): + return "CI run, job, or step provenance is invalid" + case errors.Is(err, safepush.ErrMissingCheck): + return "a required CI job is missing" + case errors.Is(err, safepush.ErrUnexpectedCheck), errors.Is(err, safepush.ErrDuplicateObservation): + return "the CI job set is unexpected or ambiguous" + case errors.Is(err, safepush.ErrCheckNotSuccessful): + return "a required CI job did not complete successfully" + default: + return "verification failed" + } +} + +func waitForChecks(ctx context.Context, interval time.Duration, verify func() error) error { + for { + if err := ctx.Err(); err != nil { + return err + } + err := verify() + if contextErr := ctx.Err(); contextErr != nil { + return contextErr + } + if !errors.Is(err, safepush.ErrGitHubPending) { + return err + } + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} diff --git a/cmd/cloudring-safepush/main_test.go b/cmd/cloudring-safepush/main_test.go new file mode 100644 index 0000000..507953b --- /dev/null +++ b/cmd/cloudring-safepush/main_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package main + +import ( + "bytes" + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/opencloudtech/CloudRING/pkg/safepush" +) + +func TestPendingIsTheOnlyRetriableAdmissionResult(t *testing.T) { + for _, terminal := range []error{nil, safepush.ErrCheckNotSuccessful, safepush.ErrPolicy, errors.New("unavailable")} { + calls := 0 + err := waitForChecks(context.Background(), time.Nanosecond, func() error { + calls++ + if calls == 1 { + return safepush.ErrGitHubPending + } + return terminal + }) + if calls != 2 || !errors.Is(err, terminal) { + t.Fatalf("calls=%d error=%v", calls, err) + } + } +} + +func TestCancellationStopsPendingAdmission(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + err := waitForChecks(ctx, time.Hour, func() error { calls++; cancel(); return safepush.ErrGitHubPending }) + if !errors.Is(err, context.Canceled) || calls != 1 { + t.Fatalf("calls=%d error=%v", calls, err) + } +} + +func TestSuccessAfterCancellationIsNotAccepted(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + err := waitForChecks(ctx, time.Nanosecond, func() error { cancel(); return nil }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("late success was accepted: %v", err) + } +} + +func TestCommandDoesNotEchoSensitiveProviderErrors(t *testing.T) { + args := []string{"github", "--repository", "synthetic/project", "--pr", "1", "--head", strings.Repeat("a", 40), "--base", strings.Repeat("b", 40), "--branch", "main"} + marker := "synthetic-confidential-marker" + read := func(context.Context, *http.Client, string, string, string) (safepush.GitHubPolicy, error) { + return safepush.GitHubPolicy{Repository: "synthetic/project"}, nil + } + for _, failure := range []error{nil, errors.New(marker)} { + var out, errout bytes.Buffer + code := run(context.Background(), args, func(string) string { return marker }, &out, &errout, read, + func(context.Context, *http.Client, string, safepush.GitHubPolicy, safepush.GitHubCandidate) error { + return failure + }) + if (code == 0) != (failure == nil) { + t.Fatalf("unexpected exit %d", code) + } + if strings.Contains(out.String()+errout.String(), marker) { + t.Fatal("provider error leaked sensitive input") + } + } +} + +func TestInvalidInvocationMakesNoProviderCall(t *testing.T) { + for _, args := range [][]string{ + {}, {"github", "--trusted=true"}, + {"github", "--repository", "synthetic/project", "--pr", "1", "--head", "a", "--base", "b", "--branch", "feature"}, + {"github", "--repository", "synthetic/project", "--pr", "1", "--head", "a", "--base", "b", "--branch", "main", "--timeout", "31m"}, + {"github", "--repository", "synthetic/project", "--pr", "1", "--head", "a", "--base", "main", "--branch", "main"}, + {"github", "--repository", "synthetic/../project", "--pr", "1", "--head", strings.Repeat("a", 40), "--base", strings.Repeat("b", 40), "--branch", "main"}, + {"github", "--repository", "synthetic/project", "--pr", "1", "--head", strings.Repeat("0", 40), "--base", strings.Repeat("b", 40), "--branch", "main"}, + } { + var out bytes.Buffer + read := func(context.Context, *http.Client, string, string, string) (safepush.GitHubPolicy, error) { + t.Fatal("provider called for invalid request") + return safepush.GitHubPolicy{}, nil + } + if code := run(context.Background(), args, func(string) string { return "synthetic-test-value" }, &out, &out, read, nil); code != 2 { + t.Fatalf("exit=%d", code) + } + } +} diff --git a/docs/ci-checks.md b/docs/ci-checks.md index 42d7e85..7dd648b 100644 --- a/docs/ci-checks.md +++ b/docs/ci-checks.md @@ -1,8 +1,7 @@ # CloudRING CI Checks -Public CI for a clean clone runs these read-only checks on the latest security -patch of the minimum supported Go release (1.25) and runs unit tests again on -the current supported release (1.26): +Public CI for a clean clone runs these read-only checks with the workflow's +pinned Go toolchain, currently Go 1.26.9: ```bash go mod download @@ -31,9 +30,9 @@ The public CI contract covers these checks: | Check | Contract | | --- | --- | -| Go tests | The public module must pass tests on supported minimum/current Go releases, plus race, vet, read-only module graph, and build checks. | +| Go tests | The complete public module must pass tests with the pinned CI toolchain, plus race, vet, read-only module graph, and build checks. | | PostgreSQL integration | Transactional-state CAS, migrations, concurrent writers, and the public runtime/session journey must pass against real digest-pinned PostgreSQL. The pinned Linux test container also exercises interrupted guest database directory setup and ownership rejection as UID 0; it asserts that identity before running these filesystem tests. | -| Windows | The same unit suite is run on `windows-latest` as a portability signal. Native Windows support is not a release-readiness blocker for the current goal. | +| Windows | The same unit suite runs on `windows-latest` and is included in the SafePush pre-merge policy. A passing suite does not establish native Windows release readiness. | | OCS validation | Every shipped connector package selected by the shared CI package list must pass `go run ./cmd/ocsctl validate`. | | OCS conformance | The same exact shipped connector packages must pass `go run ./cmd/ocsctl conformance`; validation cannot be green for an artifact that CI omits from conformance. | | Synthetic reference image | The digest-pinned `Containerfile` must build and its local mock-provider self-check must pass. | @@ -66,27 +65,27 @@ runtime image, credential context, conditional skip, and `continue-on-error`. Changing a reviewed workflow requires changing its recorded digest in the same review. -This repository check is defense in depth, not a self-authenticating policy: a -pull request controls the workflow revision that evaluates that pull request. -The server-side trust control for external contributors is protected `main` -with an up-to-date branch, one approving owner review, stale-review dismissal, -last-push approval, required conversations and checks, and no force-push or -branch deletion. The project founder and lead maintainer, `@trukhinyuri`, is -the final acceptance authority. Contributor-authored changes require that -owner review; automated or AI-assisted review does not replace the founder's -decision. - -GitHub does not let a pull-request author submit the approval required by the -same branch rule. While the repository has a single founder/administrator, a -founder-authored pull request may therefore use the administrator merge path -after a recorded founder review of the exact head SHA. This narrow owner path -still requires every configured status check to succeed on the current head, -an up-to-date base, resolved conversations, source-safety approval, and a -post-merge `main` verification. It does not authorize direct pushes, skipped -checks, force-pushes, branch deletion, or bypasses for other contributors. -Adding another administrator changes this trust assumption and requires an -explicit governance and branch-protection review before that access is -granted. +This repository check is defense in depth: a pull request controls the +workflow revision that evaluates that pull request. The separate +[SafePush verifier and deployment contract](safepush.md) bind CI observations +to the accepted policy, workflow sources, repository, pull request and commit. +The verifier must run from trusted immutable source and be required by the +hosting platform. Its presence in the repository does not enable that control. + +The required acceptance policy is an up-to-date protected target, every +required test passing and an owner-approved merge. Both `main` and `master` +must be covered; ordinary working branches remain unrestricted. The project +founder and lead maintainer, `@trukhinyuri`, is the final acceptance authority. +Automated or AI-assisted review does not replace that decision. Native review +rules must dismiss stale approvals and prevent unauthorized dismissal. + +Only the explicitly designated owner may bypass test and review requirements; +future delegates require an explicit policy change. Administrator or +Maintainer status alone must not grant that exception. This owner path also +handles the platform's prohibition on approving one's own pull request. +Force-push and deletion controls remain separate from the test/review +exception. The actual installed rules and both ordinary and bypass behavior +must be read back and tested before claiming SafePush is enabled. ## Release provenance diff --git a/docs/safepush.md b/docs/safepush.md new file mode 100644 index 0000000..22d7fff --- /dev/null +++ b/docs/safepush.md @@ -0,0 +1,128 @@ +# SafePush admission + +SafePush checks whether the exact proposed source has completed the full test +policy before a protected branch can change. It belongs in trusted CI and the +hosting platform's admission controls. A local hook is an optional early check; +disabling it must never permit a protected update. + +## Implementation and enforcement are separate + +`pkg/safepush` provides the check evaluator and a native GitHub CI adapter. +`cloudring-safepush github` reads `.github/safepush.json` from the exact accepted +target commit through the GitHub API, then verifies the current pull request +and its CI runs. It does not read a candidate-supplied success report, execute +candidate code, approve a review, publish a status, merge, or update a ref. + +The policy is a complete list of pre-merge workflows and jobs. In particular, +Windows tests are included even when a historical branch rule omitted them. +Excluded workflows must be explicitly listed and must not contain required +pre-merge tests. The release workflow runs after acceptance and is checked +separately as part of release delivery. + +Only actual completed-success jobs satisfy the policy. A missing job, an +unexpected job, a duplicate, a skipped or neutral result, an earlier successful +attempt hiding a later failure, an unrelated same-named workflow, or a changed +head/base must block the result. An API failure or incomplete response is not +evidence that there are no failing checks. + +The adapter also reads each accepted required workflow blob and checks its +Git object hash before parsing the YAML. A job or step may omit +`continue-on-error` or set it to the literal boolean `false`. Allowed failures +cannot be identified reliably from API conclusions alone. YAML aliases, +anchors, merge keys, duplicate keys and multiple documents are unsupported +and block verification. + +The native adapter owns pagination, repository/run/job identity, source +binding, selection of the current attempt and final state readback. The pure +`VerifyChecks` helper checks consistency of those observations; it is not an +authentication or enforcement boundary on its own. Passing a metadata check +also does not prove that a modified test still tests the right behavior. Human +review remains necessary. + +GitHub observations require an open, non-draft PR and the current target as +an ancestor of its head. Every selected run must be associated with that exact +PR, head, base and target. These API reads are not atomic with a later merge, +and the run's head and PR association alone do not prove the historical tested +merge commit. Native target freshness and owner review remain required; the +deployment must qualify its event and checkout behavior before activation. + +## GitHub deployment contract + +For repositories that require pull requests, the organization must require the +SafePush workflow from an immutable accepted CloudRING source commit. A +candidate-owned wrapper calling a reusable workflow is insufficient: the +candidate could delete or replace the wrapper. Requiring only the GitHub +Actions app and a check name also does not identify a particular workflow. + +The trusted workflow must check out only its own immutable source, run on a +GitHub-hosted runner, and use read-only contents, Actions, checks and pull +request access. It must not execute candidate scripts or actions, consume +candidate artifacts or shared build caches, or receive publishing credentials. +The CLI takes the current candidate identity from the native event and reads +the accepted policy itself: + +```sh +cloudring-safepush github \ + --repository "$TARGET_REPOSITORY" \ + --pr "$PULL_REQUEST_NUMBER" \ + --head "$CANDIDATE_SHA" \ + --base "$ACCEPTED_BASE_SHA" \ + --branch "$TARGET_BRANCH" +``` + +The job supplies its read-only `GITHUB_TOKEN`. Only pending CI is retried, for +a bounded time. Completed failures, mismatched identity, changed source, +authentication failure and malformed responses stop admission immediately. + +Native rules must independently enforce the owner's approval, dismiss stale +reviews and prevent unauthorized review dismissal. Both `main` and `master` +need protection. Test/review bypass identities must be explicit; assigning an +administrator role must not automatically add a SafePush exception. Force +push and deletion controls should remain separate from test/review exceptions. +Existing native code scanning checks remain required alongside SafePush; +generated provider workflows must not be silently relabeled as trusted +repository files. + +Keep every underlying required CI check in the native rule as well. An old +successful aggregate cannot revoke itself if a later attempt on the same head +fails after the verifier exits. Activation must test that a later failing +attempt blocks acceptance even when an earlier aggregate was successful. + +GitHub's required-workflow rule blocks direct pushes and supports PR and merge +queue events. It must not be presented as accepting arbitrary prechecked direct +pushes. A merge queue needs its own exact merge-group observation contract +before being enabled; a PR-only verifier cannot validate it. + +## GitLab deployment contract + +The tests must run on qualified nonpersonal Linux CI infrastructure with an +immutable trusted configuration. A candidate-local `.gitlab-ci.yml` include +cannot make a policy mandatory, because another candidate can remove or +override it. Pipeline and approval observations must come from GitLab, not +forwarded variables asserting that every gate passed. + +On GitLab Community Edition, successful-pipeline merge settings do not check +direct pushes, and role-based branch permissions cannot isolate one reviewer +when the project inherits other Maintainers and Owners. A green approval job +is also insufficient: the approval can be revoked after the pipeline passes. +Where native enforcement cannot express the policy, a server admission hook +must perform the final current-state decision before the ref changes. + +Such a hook must be installed and owned by the GitLab administrator, outside +candidate-controlled files. Its allowlist must scope it to the intended +CloudRING project IDs and protected refs. It must preserve unrestricted +ordinary branch pushes, reject absent/ambiguous/stale CI or review evidence, +verify the exact old/new commit relationship, and support only explicitly +authorized bypass identities. It must not use a personal workstation or +execute candidate code with its admission credentials. + +## Activation evidence + +Committing these sources or passing local tests does not enable server +enforcement. Record the actual installed immutable revision and re-read the +hosting platform's rules. Use an identity without bypass to verify that an +ordinary branch push succeeds while missing/failed/skipped tests, an +unapproved change, altered CI policy and stale commit evidence cannot advance +`main` or `master`. Verify a real approved all-green change, the intended owner +exception and unchanged out-of-scope repositories. Preserve an explicit +incomplete state until those checks have passed. diff --git a/pkg/safepush/checks.go b/pkg/safepush/checks.go new file mode 100644 index 0000000..d9a250d --- /dev/null +++ b/pkg/safepush/checks.go @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package safepush + +import ( + "errors" + "strings" + "unicode" + "unicode/utf8" +) + +// Verification errors contain fixed categories, never input values or CI logs. +// When several defects are present, VerifyChecks returns the first applicable +// category in this declaration order, independent of slice ordering. +var ( + ErrInvalidHeadSHA = errors.New("safepush: invalid head SHA") + ErrNoRequiredChecks = errors.New("safepush: no required checks") + ErrInvalidRequiredCheck = errors.New("safepush: invalid required check identity") + ErrDuplicateRequiredCheck = errors.New("safepush: duplicate required check identity") + ErrInvalidObservation = errors.New("safepush: invalid observation identity") + ErrHeadMismatch = errors.New("safepush: observation head does not match candidate") + ErrDuplicateObservation = errors.New("safepush: duplicate observation identity") + ErrUnexpectedCheck = errors.New("safepush: unexpected check identity") + ErrMissingCheck = errors.New("safepush: missing required check") + ErrCheckNotSuccessful = errors.New("safepush: check did not complete successfully") +) + +// CheckIdentity identifies one job in an adapter-verified immutable workflow. +// Source binds the workflow path and its trusted revision or content, rather +// than a display name. The adapter must establish that binding using native API +// records; setting Source alone is not evidence of authenticity. +// +// Both fields are compared exactly, without case folding, trimming, or Unicode +// normalization. They must be nonblank valid UTF-8 without control characters. +type CheckIdentity struct { + Source string `json:"source"` + Job string `json:"job"` +} + +// CheckObservation is one native CI job result for the selected run attempt. +// Status and Conclusion use the canonical pair "completed" and "success" only +// when the provider reports actual successful completion. The adapter must not +// translate skipped, neutral, allowed-failure, or absent results to that pair. +type CheckObservation struct { + Check CheckIdentity `json:"check"` + HeadSHA string `json:"headSHA"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` +} + +// VerifyChecks requires exactly one successful observation for every required +// identity and no other observations. The required set must not be empty. +// Every observation must match the exact candidate headSHA, which must be a +// nonzero 40- or 64-character lowercase hexadecimal Git object ID. +// +// Only Status == "completed" and Conclusion == "success" passes. Duplicate +// observations are ambiguous even when they agree; the adapter must select the +// authoritative run attempt before calling this function. Inputs are not +// modified, and nil means only that these consistency checks passed. See the +// package documentation for the adapter's remaining authorization obligations. +func VerifyChecks(headSHA string, required []CheckIdentity, observations []CheckObservation) error { + if !validHeadSHA(headSHA) { + return ErrInvalidHeadSHA + } + if len(required) == 0 { + return ErrNoRequiredChecks + } + + requiredSet := make(map[CheckIdentity]struct{}, len(required)) + var invalidRequired, duplicateRequired bool + for _, check := range required { + if !validCheckIdentity(check) { + invalidRequired = true + } + if _, exists := requiredSet[check]; exists { + duplicateRequired = true + } + requiredSet[check] = struct{}{} + } + if invalidRequired { + return ErrInvalidRequiredCheck + } + if duplicateRequired { + return ErrDuplicateRequiredCheck + } + + observedSet := make(map[CheckIdentity]struct{}, len(observations)) + var invalidObservation, headMismatch, duplicateObservation, unexpected, notSuccessful bool + for _, observation := range observations { + if !validCheckIdentity(observation.Check) { + invalidObservation = true + } + if observation.HeadSHA != headSHA { + headMismatch = true + } + if _, exists := observedSet[observation.Check]; exists { + duplicateObservation = true + } + observedSet[observation.Check] = struct{}{} + if _, exists := requiredSet[observation.Check]; !exists { + unexpected = true + } + if observation.Status != "completed" || observation.Conclusion != "success" { + notSuccessful = true + } + } + + switch { + case invalidObservation: + return ErrInvalidObservation + case headMismatch: + return ErrHeadMismatch + case duplicateObservation: + return ErrDuplicateObservation + case unexpected: + return ErrUnexpectedCheck + case len(observedSet) != len(requiredSet): + return ErrMissingCheck + case notSuccessful: + return ErrCheckNotSuccessful + default: + return nil + } +} + +func validHeadSHA(value string) bool { + if len(value) != 40 && len(value) != 64 { + return false + } + var nonzero bool + for _, char := range []byte(value) { + if (char < '0' || char > '9') && (char < 'a' || char > 'f') { + return false + } + if char != '0' { + nonzero = true + } + } + return nonzero +} + +func validCheckIdentity(identity CheckIdentity) bool { + return validIdentityPart(identity.Source) && validIdentityPart(identity.Job) +} + +func validIdentityPart(value string) bool { + if !utf8.ValidString(value) || strings.TrimSpace(value) == "" { + return false + } + for _, char := range value { + if unicode.IsControl(char) { + return false + } + } + return true +} diff --git a/pkg/safepush/checks_test.go b/pkg/safepush/checks_test.go new file mode 100644 index 0000000..c2aaff1 --- /dev/null +++ b/pkg/safepush/checks_test.go @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package safepush + +import ( + "errors" + "reflect" + "slices" + "strings" + "testing" +) + +func TestVerifyChecksAcceptsExactSuccessfulJobs(t *testing.T) { + t.Parallel() + for _, length := range []int{40, 64} { + head := strings.Repeat("ab", length/2) + t.Run(head, func(t *testing.T) { + t.Parallel() + required, observations := successfulChecks(head) + // API order is not evidence, and the same job name in two verified + // workflows denotes two distinct requirements. + slices.Reverse(observations) + beforeRequired := slices.Clone(required) + beforeObserved := slices.Clone(observations) + if err := VerifyChecks(head, required, observations); err != nil { + t.Fatalf("complete successful evidence rejected: %v", err) + } + if !reflect.DeepEqual(required, beforeRequired) || !reflect.DeepEqual(observations, beforeObserved) { + t.Fatal("verification mutated its inputs") + } + }) + } +} + +func TestVerifyChecksRejectsIncompleteOrAmbiguousEvidence(t *testing.T) { + t.Parallel() + tests := []struct { + name string + change func([]CheckIdentity, []CheckObservation) ([]CheckIdentity, []CheckObservation) + want error + }{ + { + name: "empty policy cannot disable admission", + change: func(_ []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + return nil, observations + }, + want: ErrNoRequiredChecks, + }, + { + name: "no observations", + change: func(required []CheckIdentity, _ []CheckObservation) ([]CheckIdentity, []CheckObservation) { + return required, nil + }, + want: ErrMissingCheck, + }, + { + name: "one missing job despite other green jobs", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + return required, observations[1:] + }, + want: ErrMissingCheck, + }, + { + name: "same job name from another workflow is not evidence", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + observations[0].Check.Source = ".github/workflows/other.yml@" + strings.Repeat("cd", 20) + return required, observations + }, + want: ErrUnexpectedCheck, + }, + { + name: "different workflow revision is not evidence", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + observations[0].Check.Source = ".github/workflows/go.yml@" + strings.Repeat("ef", 20) + return required, observations + }, + want: ErrUnexpectedCheck, + }, + { + name: "green result for stale head", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + observations[0].HeadSHA = strings.Repeat("cd", 20) + return required, observations + }, + want: ErrHeadMismatch, + }, + { + name: "observation lacks head", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + observations[0].HeadSHA = "" + return required, observations + }, + want: ErrHeadMismatch, + }, + { + name: "duplicate policy job", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + return append(required, required[0]), observations + }, + want: ErrDuplicateRequiredCheck, + }, + { + name: "duplicate successful observations", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + return required, append(observations, observations[0]) + }, + want: ErrDuplicateObservation, + }, + { + name: "green attempt cannot hide failed attempt", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + failed := observations[0] + failed.Conclusion = "failure" + return required, append(observations, failed) + }, + want: ErrDuplicateObservation, + }, + { + name: "unconfigured successful job", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + extra := observations[0] + extra.Check.Job = "new test suite" + return required, append(observations, extra) + }, + want: ErrUnexpectedCheck, + }, + { + name: "unconfigured failed job cannot be ignored", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + extra := observations[0] + extra.Check.Job = "new test suite" + extra.Conclusion = "failure" + return required, append(observations, extra) + }, + want: ErrUnexpectedCheck, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + head := strings.Repeat("ab", 20) + required, observations := successfulChecks(head) + required, observations = test.change(required, observations) + if err := VerifyChecks(head, required, observations); !errors.Is(err, test.want) { + t.Fatalf("got %v, want %v", err, test.want) + } + // Selecting whichever duplicate/defect the API returned first must + // not change the decision or its category. + slices.Reverse(required) + slices.Reverse(observations) + if err := VerifyChecks(head, required, observations); !errors.Is(err, test.want) { + t.Fatalf("reordered input: got %v, want %v", err, test.want) + } + }) + } +} + +func TestVerifyChecksRejectsEveryNonSuccessState(t *testing.T) { + t.Parallel() + tests := []struct { + status string + conclusion string + }{ + {status: "pending"}, + {status: "queued"}, + {status: "running"}, + {status: "in_progress"}, + {status: "waiting"}, + {status: "requested"}, + {status: "cancelled"}, + {status: "skipped"}, + {status: "success"}, + {status: "completed"}, + {status: "completed", conclusion: "failure"}, + {status: "completed", conclusion: "cancelled"}, + {status: "completed", conclusion: "skipped"}, + {status: "completed", conclusion: "neutral"}, + {status: "completed", conclusion: "timed_out"}, + {status: "completed", conclusion: "action_required"}, + {status: "completed", conclusion: "stale"}, + {status: "completed", conclusion: "startup_failure"}, + {status: "completed", conclusion: "unknown"}, + {status: "in_progress", conclusion: "success"}, + {status: "unknown", conclusion: "success"}, + {status: "Completed", conclusion: "success"}, + {status: "completed", conclusion: "Success"}, + {status: " completed", conclusion: "success"}, + {status: "completed", conclusion: "success "}, + {}, + } + for _, test := range tests { + t.Run(test.status+"/"+test.conclusion, func(t *testing.T) { + t.Parallel() + head := strings.Repeat("ab", 20) + required, observations := successfulChecks(head) + observations[1].Status = test.status + observations[1].Conclusion = test.conclusion + if err := VerifyChecks(head, required, observations); !errors.Is(err, ErrCheckNotSuccessful) { + t.Fatalf("non-success result: got %v, want %v", err, ErrCheckNotSuccessful) + } + }) + } +} + +func TestVerifyChecksRejectsMalformedHeadEvenWhenObservationsAgree(t *testing.T) { + t.Parallel() + for _, head := range []string{ + "", "main", strings.Repeat("a", 39), strings.Repeat("a", 41), + strings.Repeat("a", 63), strings.Repeat("a", 65), strings.Repeat("A", 40), + strings.Repeat("A", 64), strings.Repeat("g", 40), strings.Repeat("0", 40), + strings.Repeat("0", 64), strings.Repeat("a", 39) + "\n", strings.Repeat("a", 39) + " ", + } { + t.Run(head, func(t *testing.T) { + t.Parallel() + required, observations := successfulChecks(head) + if err := VerifyChecks(head, required, observations); !errors.Is(err, ErrInvalidHeadSHA) { + t.Fatalf("malformed candidate: got %v, want %v", err, ErrInvalidHeadSHA) + } + }) + } +} + +func TestVerifyChecksRejectsMalformedIdentities(t *testing.T) { + t.Parallel() + for _, value := range []string{"", " ", "\u00a0", "\x00job", "job\n", "job\tname", "job\x7f", "\xff"} { + t.Run(value, func(t *testing.T) { + t.Parallel() + for _, field := range []string{"source", "job"} { + for _, location := range []string{"required", "observation"} { + head := strings.Repeat("ab", 20) + required, observations := successfulChecks(head) + identity := &required[0] + want := ErrInvalidRequiredCheck + if location == "observation" { + identity = &observations[0].Check + want = ErrInvalidObservation + } + if field == "source" { + identity.Source = value + } else { + identity.Job = value + } + if err := VerifyChecks(head, required, observations); !errors.Is(err, want) { + t.Fatalf("%s %s: got %v, want %v", location, field, err, want) + } + } + } + }) + } +} + +func TestVerifyChecksDoesNotNormalizeIdentities(t *testing.T) { + t.Parallel() + head := strings.Repeat("ab", 20) + for _, pair := range [][2]string{ + {"unit", "Unit"}, + {"unit", " unit"}, + {"unit", "unit "}, + {"caf\u00e9", "cafe\u0301"}, + } { + for _, field := range []string{"source", "job"} { + required := []CheckIdentity{{Source: "workflow", Job: "unit"}} + observation := CheckObservation{Check: required[0], HeadSHA: head, Status: "completed", Conclusion: "success"} + if field == "source" { + required[0].Source = pair[0] + observation.Check.Source = pair[1] + } else { + required[0].Job = pair[0] + observation.Check.Job = pair[1] + } + if err := VerifyChecks(head, required, []CheckObservation{observation}); !errors.Is(err, ErrUnexpectedCheck) { + t.Fatalf("%s identity was normalized: got %v, want %v", field, err, ErrUnexpectedCheck) + } + // Meaningful spaces and Unicode are legal when their exact bytes + // agree; validation must not silently rewrite a native job name. + required[0] = observation.Check + if err := VerifyChecks(head, required, []CheckObservation{observation}); err != nil { + t.Fatalf("exact %s identity rejected: %v", field, err) + } + } + } +} + +func TestVerifyChecksPreservesIdentityFieldBoundaries(t *testing.T) { + t.Parallel() + head := strings.Repeat("ab", 20) + required := []CheckIdentity{{Source: "workflow/a", Job: "b"}, {Source: "workflow", Job: "a/b"}} + observations := []CheckObservation{ + {Check: required[0], HeadSHA: head, Status: "completed", Conclusion: "success"}, + {Check: required[1], HeadSHA: head, Status: "completed", Conclusion: "success"}, + } + if err := VerifyChecks(head, required, observations); err != nil { + t.Fatalf("distinct structured identities collided: %v", err) + } +} + +func TestVerifyChecksErrorsNeverEchoObservationValues(t *testing.T) { + t.Parallel() + head := strings.Repeat("ab", 20) + required, observations := successfulChecks(head) + const untrustedValue = "untrusted-response-marker" + observations[0].HeadSHA = untrustedValue + observations[0].Status = untrustedValue + observations[0].Conclusion = untrustedValue + observations[0].Check.Source = untrustedValue + observations[0].Check.Job = untrustedValue + err := VerifyChecks(head, required, observations) + if !errors.Is(err, ErrHeadMismatch) { + t.Fatalf("got %v, want %v", err, ErrHeadMismatch) + } + if strings.Contains(err.Error(), untrustedValue) { + t.Fatal("error exposed raw observation data") + } +} + +func successfulChecks(head string) ([]CheckIdentity, []CheckObservation) { + goWorkflow := ".github/workflows/go.yml@" + strings.Repeat("cd", 20) + securityWorkflow := ".github/workflows/security.yml@" + strings.Repeat("ef", 20) + required := []CheckIdentity{ + {Source: goWorkflow, Job: "unit (linux, go1.26.8)"}, + {Source: goWorkflow, Job: "race (linux, go1.26.8)"}, + {Source: securityWorkflow, Job: "unit (linux, go1.26.8)"}, + } + observations := make([]CheckObservation, len(required)) + for index, check := range required { + observations[index] = CheckObservation{Check: check, HeadSHA: head, Status: "completed", Conclusion: "success"} + } + return required, observations +} diff --git a/pkg/safepush/doc.go b/pkg/safepush/doc.go new file mode 100644 index 0000000..2e4cfc8 --- /dev/null +++ b/pkg/safepush/doc.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +// Package safepush provides deterministic checks over CI observations. +// +// VerifyChecks is a pure consistency check, not an enforcement boundary or a +// complete authorization decision. Its inputs cannot prove their own origin, +// freshness, or completeness. In particular, a source identity, caller assertion, +// or signature alone does not establish that a workflow actually ran. +// +// The native adapter must read the trusted required-job policy and complete, +// paginated run/job records from the owning CI service. It must verify the +// immutable workflow source and execution bindings before constructing source +// identities, and resolve run attempts without choosing an older green result. +// It must include every job in that scope, except exclusions explicitly permitted +// by the trusted policy. Branch enforcement, review approval, bypass authority, +// and revalidation when the candidate or target changes remain outside this +// helper. +package safepush diff --git a/pkg/safepush/github.go b/pkg/safepush/github.go new file mode 100644 index 0000000..c039cdd --- /dev/null +++ b/pkg/safepush/github.go @@ -0,0 +1,748 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package safepush + +import ( + "context" + "crypto/sha1" // #nosec G505 -- Git object identifiers, not signatures. + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "mime" + "net/http" + "net/url" + "reflect" + "strconv" + "strings" + "time" + + "github.com/opencloudtech/CloudRING/internal/strictjson" +) + +// Fixed error categories never expose credentials, remote response bodies, +// repository contents, URLs, or candidate-controlled names. +var ( + ErrGitHubPending = errors.New("safepush: required GitHub runs are pending") + ErrGitHubAPI = errors.New("safepush: GitHub API read failed") + ErrGitHubCandidate = errors.New("safepush: invalid GitHub candidate") + ErrGitHubState = errors.New("safepush: GitHub candidate or target state changed") + ErrGitHubSource = errors.New("safepush: GitHub workflow source is not trusted") + ErrGitHubRun = errors.New("safepush: GitHub run provenance is invalid") +) + +const ( + githubAPIOrigin = "https://api.github.com" + githubActionsID = int64(15368) + githubPageSize = 100 + githubListLimit = 1000 +) + +type githubAPI struct { + client *http.Client + bearerValue string + repo string +} + +type githubRepository struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + Archived bool `json:"archived"` + Disabled bool `json:"disabled"` +} + +type githubRef struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + Repo githubRepository `json:"repo"` +} + +type githubPullRequest struct { + ID int64 `json:"id"` + Number int `json:"number"` + State string `json:"state"` + Draft bool `json:"draft"` + Merged bool `json:"merged"` + Head githubRef `json:"head"` + Base githubRef `json:"base"` +} + +type githubBranch struct { + Name string `json:"name"` + Protected bool `json:"protected"` + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` +} + +type githubTreeEntry struct { + Path string `json:"path"` + Mode string `json:"mode"` + Type string `json:"type"` + SHA string `json:"sha"` +} + +type githubTree struct { + SHA string `json:"sha"` + Truncated *bool `json:"truncated"` + Tree []githubTreeEntry `json:"tree"` +} + +type githubWorkflow struct { + ID int64 `json:"id"` + Path string `json:"path"` + State string `json:"state"` +} + +type githubRun struct { + ID int64 `json:"id"` + WorkflowID int64 `json:"workflow_id"` + RunNumber int64 `json:"run_number"` + RunAttempt int `json:"run_attempt"` + CheckSuiteID int64 `json:"check_suite_id"` + Path string `json:"path"` + Event string `json:"event"` + HeadSHA string `json:"head_sha"` + HeadBranch string `json:"head_branch"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Repository githubRepository `json:"repository"` + HeadRepository githubRepository `json:"head_repository"` + ReferencedWorkflows []json.RawMessage `json:"referenced_workflows"` + PullRequests []githubRunPR `json:"pull_requests"` +} + +type githubRunPR struct { + ID int64 `json:"id"` + Number int `json:"number"` + URL string `json:"url"` + Head githubRef `json:"head"` + Base githubRef `json:"base"` +} + +type githubStep struct { + Name string `json:"name"` + Number int `json:"number"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` +} + +type githubJob struct { + ID int64 `json:"id"` + RunID int64 `json:"run_id"` + RunAttempt int `json:"run_attempt"` + HeadSHA string `json:"head_sha"` + HeadBranch string `json:"head_branch"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + CheckRunURL string `json:"check_run_url"` + Steps []githubStep `json:"steps"` +} + +type githubCheckRun struct { + ID int64 `json:"id"` + Name string `json:"name"` + HeadSHA string `json:"head_sha"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + CheckSuite struct { + ID int64 `json:"id"` + } `json:"check_suite"` + App struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + } `json:"app"` +} + +// ReadGitHubPolicy reads the accepted base's regular policy blob without a +// checkout or contents-API symlink dereference. VerifyGitHub subsequently proves +// that this base is still the current protected target. The caller must obtain +// repository/baseSHA from its trusted workflow event, not candidate output. +func ReadGitHubPolicy(ctx context.Context, client *http.Client, token, repository, baseSHA string) (GitHubPolicy, error) { + if !repositoryName.MatchString(repository) || strings.HasSuffix(repository, "/.") || + strings.HasSuffix(repository, "/..") || !validHeadSHA(baseSHA) { + return GitHubPolicy{}, ErrPolicy + } + api, err := newGitHubAPI(client, token, repository) + if err != nil { + return GitHubPolicy{}, err + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + var repo githubRepository + if err = api.get(ctx, "", nil, &repo); err != nil { + return GitHubPolicy{}, err + } + if repo.ID <= 0 || repo.FullName != repository || repo.Archived || repo.Disabled { + return GitHubPolicy{}, ErrGitHubState + } + tree, err := api.readTree(ctx, baseSHA) + if err != nil { + return GitHubPolicy{}, err + } + entry, exists := tree[".github/safepush.json"] + if !exists || entry.Type != "blob" || entry.Mode != "100644" { + return GitHubPolicy{}, ErrGitHubSource + } + contents, err := api.readGitBlob(ctx, entry, strictjson.MaxDocumentBytes) + if err != nil { + return GitHubPolicy{}, err + } + policy, err := DecodeGitHubPolicy(contents) + if err != nil { + return GitHubPolicy{}, ErrPolicy + } + if policy.Repository != repo.FullName || policy.RepositoryID != repo.ID { + return GitHubPolicy{}, ErrGitHubSource + } + if ctx.Err() != nil { + return GitHubPolicy{}, ErrGitHubAPI + } + return policy, nil +} + +// VerifyGitHub verifies native PR/run/job provenance using the accepted policy. +// It executes no candidate code. Both required and excluded workflow files, and +// .github/scripts, must match the accepted base. This deliberately does not +// authenticate generated dynamic workflows or reusable workflow chains. +// +// Base must be an ancestor of head. With unchanged workflow sources this avoids +// claiming a stale PR test-merge tested newer candidate bytes. Native required +// reviews and branch rules remain necessary: these API reads are not an atomic +// ref-update transaction and do not prove the semantic adequacy of tests. +func VerifyGitHub(ctx context.Context, client *http.Client, token string, policy GitHubPolicy, candidate GitHubCandidate) error { + if err := ValidateGitHubPolicy(policy); err != nil { + return err + } + if ValidateGitHubCandidate(policy.Repository, candidate) != nil { + return ErrGitHubCandidate + } + api, err := newGitHubAPI(client, token, policy.Repository) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + var repo githubRepository + if err = api.get(ctx, "", nil, &repo); err != nil { + return err + } + if repo.ID != policy.RepositoryID || repo.FullName != policy.Repository || repo.Archived || repo.Disabled { + return ErrGitHubState + } + pr, err := api.readCandidate(ctx, policy, candidate) + if err != nil { + return err + } + if err = api.requireBaseAncestor(ctx, candidate); err != nil { + return err + } + baseTree, err := api.readTree(ctx, candidate.BaseSHA) + if err != nil { + return err + } + headTree, err := api.readTree(ctx, candidate.HeadSHA) + if err != nil { + return err + } + if err = verifyGitHubSources(policy, baseTree, headTree); err != nil { + return err + } + for _, workflow := range policy.Workflows { + contents, readErr := api.readGitBlob(ctx, baseTree[workflow.Path], maxWorkflowSourceBytes) + if readErr != nil { + return readErr + } + if err = verifyWorkflowSource(contents); err != nil { + return err + } + } + workflows, err := api.readWorkflows(ctx, policy) + if err != nil { + return err + } + runs, err := api.selectRuns(ctx, policy, pr, workflows) + if err != nil { + return err + } + var required []CheckIdentity + var observations []CheckObservation + for _, workflow := range policy.Workflows { + source := workflow.Path + "@" + baseTree[workflow.Path].SHA + for _, job := range workflow.Jobs { + required = append(required, CheckIdentity{Source: source, Job: job}) + } + items, collectErr := api.readObservations(ctx, runs[workflow.Path], source) + if collectErr != nil { + return collectErr + } + observations = append(observations, items...) + } + if err = VerifyChecks(candidate.HeadSHA, required, observations); err != nil { + return err + } + finalWorkflows, err := api.readWorkflows(ctx, policy) + if err != nil { + return err + } + if !reflect.DeepEqual(workflows, finalWorkflows) { + return ErrGitHubState + } + finalRuns, err := api.selectRuns(ctx, policy, pr, finalWorkflows) + if err != nil { + // A newly selected run or attempt invalidates this collection, even if + // it is pending. A new admission attempt must collect a new snapshot. + if errors.Is(err, ErrGitHubPending) { + return ErrGitHubState + } + return err + } + if !reflect.DeepEqual(runs, finalRuns) { + return ErrGitHubState + } + for _, workflow := range policy.Workflows { + run := runs[workflow.Path] + var finalRun githubRun + if err = api.get(ctx, "/actions/runs/"+strconv.FormatInt(run.ID, 10), nil, &finalRun); err != nil { + return err + } + if !reflect.DeepEqual(run, finalRun) { + return ErrGitHubState + } + } + finalPR, err := api.readCandidate(ctx, policy, candidate) + if err != nil { + return err + } + if !reflect.DeepEqual(pr, finalPR) { + return ErrGitHubState + } + if ctx.Err() != nil { + return ErrGitHubAPI + } + return nil +} + +func newGitHubAPI(client *http.Client, token, repository string) (*githubAPI, error) { + if token == "" { + return nil, ErrGitHubAPI + } + for _, value := range token { + if value <= 0x20 || value >= 0x7f { + return nil, ErrGitHubAPI + } + } + if client == nil { + client = http.DefaultClient + } + copyClient := *client + copyClient.Jar = nil + copyClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } + if copyClient.Timeout == 0 || copyClient.Timeout > 30*time.Second { + copyClient.Timeout = 30 * time.Second + } + return &githubAPI{client: ©Client, bearerValue: token, repo: repository}, nil +} + +func (api *githubAPI) get(ctx context.Context, suffix string, query url.Values, destination any) error { + u := url.URL{Scheme: "https", Host: "api.github.com", Path: "/repos/" + api.repo + suffix, RawQuery: query.Encode()} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return ErrGitHubAPI + } + req.Header.Set("Authorization", "Bearer "+api.bearerValue) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "CloudRING-SafePush") + resp, err := api.client.Do(req) + if err != nil { + return ErrGitHubAPI + } + defer resp.Body.Close() + mediaType, _, mediaErr := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if resp.StatusCode != http.StatusOK || mediaErr != nil || + (mediaType != "application/json" && mediaType != "application/vnd.github+json") || + resp.ContentLength > strictjson.MaxDocumentBytes { + return ErrGitHubAPI + } + data, err := strictjson.Read(resp.Body) + if err != nil || strictjson.Decode(data, destination) != nil { + return ErrGitHubAPI + } + return nil +} + +func githubList[T any](ctx context.Context, api *githubAPI, suffix, key string, filters url.Values) ([]T, error) { + var result []T + declared := -1 + for page := 1; page <= githubListLimit/githubPageSize; page++ { + query := make(url.Values) + for name, values := range filters { + query[name] = append([]string(nil), values...) + } + query.Set("per_page", strconv.Itoa(githubPageSize)) + query.Set("page", strconv.Itoa(page)) + var envelope map[string]json.RawMessage + if err := api.get(ctx, suffix, query, &envelope); err != nil { + return nil, err + } + var total int + if raw := envelope["total_count"]; len(raw) == 0 || string(raw) == "null" || json.Unmarshal(raw, &total) != nil || total < 0 || total > githubListLimit { + return nil, ErrGitHubAPI + } + for _, flag := range []string{"truncated", "incomplete_results"} { + if raw, found := envelope[flag]; found && string(raw) != "false" { + return nil, ErrGitHubAPI + } + } + if declared != -1 && declared != total { + return nil, ErrGitHubState + } + declared = total + var items []T + if json.Unmarshal(envelope[key], &items) != nil || items == nil { + return nil, ErrGitHubAPI + } + if len(items) != min(githubPageSize, total-len(result)) { + return nil, ErrGitHubAPI + } + result = append(result, items...) + if len(result) == total { + return result, nil + } + } + return nil, ErrGitHubAPI +} + +func (api *githubAPI) readCandidate(ctx context.Context, policy GitHubPolicy, candidate GitHubCandidate) (githubPullRequest, error) { + var pr githubPullRequest + if err := api.get(ctx, "/pulls/"+strconv.Itoa(candidate.PullRequest), nil, &pr); err != nil { + return pr, err + } + if pr.ID <= 0 || pr.Number != candidate.PullRequest || pr.State != "open" || pr.Draft || pr.Merged || + pr.Head.SHA != candidate.HeadSHA || !validJobName(pr.Head.Ref) || pr.Head.Repo.ID <= 0 || + !repositoryName.MatchString(pr.Head.Repo.FullName) || + pr.Base.SHA != candidate.BaseSHA || pr.Base.Ref != candidate.TargetBranch || + pr.Base.Repo.ID != policy.RepositoryID || pr.Base.Repo.FullName != policy.Repository { + return pr, ErrGitHubState + } + var branch githubBranch + if err := api.get(ctx, "/branches/"+candidate.TargetBranch, nil, &branch); err != nil { + return pr, err + } + if branch.Name != candidate.TargetBranch || branch.Commit.SHA != candidate.BaseSHA || !branch.Protected { + return pr, ErrGitHubState + } + return pr, nil +} + +func (api *githubAPI) requireBaseAncestor(ctx context.Context, candidate GitHubCandidate) error { + var comparison struct { + Status string `json:"status"` + AheadBy *int `json:"ahead_by"` + BehindBy *int `json:"behind_by"` + Base struct { + SHA string `json:"sha"` + } `json:"base_commit"` + MergeBase struct { + SHA string `json:"sha"` + } `json:"merge_base_commit"` + } + if err := api.get(ctx, "/compare/"+candidate.BaseSHA+"..."+candidate.HeadSHA, url.Values{"per_page": {"1"}}, &comparison); err != nil { + return err + } + if comparison.Status != "ahead" || comparison.AheadBy == nil || *comparison.AheadBy <= 0 || + comparison.BehindBy == nil || *comparison.BehindBy != 0 || + comparison.Base.SHA != candidate.BaseSHA || comparison.MergeBase.SHA != candidate.BaseSHA { + return ErrGitHubState + } + return nil +} + +func (api *githubAPI) readTree(ctx context.Context, sha string) (map[string]githubTreeEntry, error) { + var commit struct { + SHA string `json:"sha"` + Tree struct { + SHA string `json:"sha"` + } `json:"tree"` + } + if err := api.get(ctx, "/git/commits/"+sha, nil, &commit); err != nil { + return nil, err + } + if commit.SHA != sha || !validHeadSHA(commit.Tree.SHA) { + return nil, ErrGitHubSource + } + var response githubTree + if err := api.get(ctx, "/git/trees/"+commit.Tree.SHA, url.Values{"recursive": {"1"}}, &response); err != nil { + return nil, err + } + if response.SHA != commit.Tree.SHA || response.Truncated == nil || *response.Truncated || response.Tree == nil { + return nil, ErrGitHubSource + } + entries := make(map[string]githubTreeEntry, len(response.Tree)) + for _, entry := range response.Tree { + if !validJobName(entry.Path) || !validHeadSHA(entry.SHA) { + return nil, ErrGitHubSource + } + if _, duplicate := entries[entry.Path]; duplicate { + return nil, ErrGitHubSource + } + entries[entry.Path] = entry + } + return entries, nil +} + +func (api *githubAPI) readGitBlob(ctx context.Context, entry githubTreeEntry, limit int) ([]byte, error) { + if entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755") || + !validHeadSHA(entry.SHA) || limit <= 0 || limit > strictjson.MaxDocumentBytes { + return nil, ErrGitHubSource + } + var blob struct { + SHA string `json:"sha"` + Encoding string `json:"encoding"` + Size *int `json:"size"` + Content string `json:"content"` + } + if err := api.get(ctx, "/git/blobs/"+entry.SHA, nil, &blob); err != nil { + return nil, err + } + if blob.SHA != entry.SHA || blob.Encoding != "base64" || blob.Size == nil || *blob.Size <= 0 || *blob.Size > limit { + return nil, ErrGitHubSource + } + contents, err := base64.StdEncoding.DecodeString(blob.Content) + if err != nil || len(contents) != *blob.Size || gitBlobID(contents, len(entry.SHA)) != entry.SHA { + return nil, ErrGitHubSource + } + if ctx.Err() != nil { + return nil, ErrGitHubAPI + } + return contents, nil +} + +func verifyGitHubSources(policy GitHubPolicy, base, head map[string]githubTreeEntry) error { + expected := make(map[string]bool) + for _, workflow := range policy.Workflows { + expected[workflow.Path] = true + } + for _, excluded := range policy.ExcludedWorkflows { + expected[excluded] = true + } + guarded := func(entries map[string]githubTreeEntry) (map[string]githubTreeEntry, error) { + result := make(map[string]githubTreeEntry) + found := make(map[string]bool) + for name, entry := range entries { + if name == ".github/workflows" || strings.HasPrefix(name, ".github/workflows/") || + name == ".github/scripts" || strings.HasPrefix(name, ".github/scripts/") || name == ".github/workflow-policy.json" { + if (entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755")) && + (entry.Type != "tree" || entry.Mode != "040000") { + return nil, ErrGitHubSource + } + result[name] = entry + } + if strings.HasPrefix(name, ".github/workflows/") && entry.Type != "tree" && + (strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml")) { + if !validWorkflowPath(name) || !expected[name] { + return nil, ErrGitHubSource + } + found[name] = true + } + } + if len(found) != len(expected) { + return nil, ErrGitHubSource + } + return result, nil + } + trusted, err := guarded(base) + if err != nil { + return err + } + candidate, err := guarded(head) + if err != nil { + return err + } + if !reflect.DeepEqual(trusted, candidate) { + return ErrGitHubSource + } + return nil +} + +func (api *githubAPI) readWorkflows(ctx context.Context, policy GitHubPolicy) (map[string]githubWorkflow, error) { + items, err := githubList[githubWorkflow](ctx, api, "/actions/workflows", "workflows", nil) + if err != nil { + return nil, err + } + expected := make(map[string]bool) + for _, workflow := range policy.Workflows { + expected[workflow.Path] = true + } + for _, excluded := range policy.ExcludedWorkflows { + expected[excluded] = false + } + result := make(map[string]githubWorkflow) + ids := make(map[int64]bool) + for _, item := range items { + if item.ID <= 0 || ids[item.ID] { + return nil, ErrGitHubSource + } + ids[item.ID] = true + if strings.HasPrefix(item.Path, "dynamic/") { + continue // Native generated scanning remains a separate provider rule. + } + required, exists := expected[item.Path] + if !exists { + return nil, ErrGitHubSource + } + if _, duplicate := result[item.Path]; duplicate || (required && item.State != "active") { + return nil, ErrGitHubSource + } + result[item.Path] = item + } + if len(result) != len(expected) { + return nil, ErrGitHubSource + } + return result, nil +} + +func (api *githubAPI) selectRuns(ctx context.Context, policy GitHubPolicy, pr githubPullRequest, workflows map[string]githubWorkflow) (map[string]githubRun, error) { + items, err := githubList[githubRun](ctx, api, "/actions/runs", "workflow_runs", url.Values{"event": {"pull_request"}, "head_sha": {pr.Head.SHA}}) + if err != nil { + return nil, err + } + result := make(map[string]githubRun) + ids := make(map[int64]bool) + requiredPaths := make(map[string]bool) + requiredIDs := make(map[int64]bool) + for _, workflow := range policy.Workflows { + requiredPaths[workflow.Path] = true + requiredIDs[workflows[workflow.Path].ID] = true + } + for _, item := range items { + // Excluded release/verifier runs and provider-injected workflows cannot + // satisfy a test identity. In particular do not wait on this gate itself. + if !requiredPaths[item.Path] && !requiredIDs[item.WorkflowID] { + continue + } + workflow, exists := workflows[item.Path] + if !exists || item.ID <= 0 || ids[item.ID] || item.RunNumber <= 0 || item.RunAttempt <= 0 || item.CheckSuiteID <= 0 || + item.WorkflowID != workflow.ID || item.Event != "pull_request" || + item.HeadSHA != pr.Head.SHA || item.HeadBranch != pr.Head.Ref || + item.Repository.ID != policy.RepositoryID || item.Repository.FullName != policy.Repository || + item.HeadRepository.ID != pr.Head.Repo.ID || item.HeadRepository.FullName != pr.Head.Repo.FullName || + len(item.ReferencedWorkflows) != 0 || !api.runMatchesPR(item, pr) { + return nil, ErrGitHubRun + } + ids[item.ID] = true + prior, exists := result[item.Path] + if exists && item.RunNumber == prior.RunNumber { + return nil, ErrGitHubRun + } + if !exists || item.RunNumber > prior.RunNumber { + result[item.Path] = item + } + } + pending := false + for _, workflow := range policy.Workflows { + run, exists := result[workflow.Path] + if !exists { + pending = true + continue + } + switch run.Status { + case "completed": + if run.Conclusion != "success" { + return nil, ErrCheckNotSuccessful + } + case "queued", "in_progress", "waiting", "requested", "pending": + pending = true + default: + return nil, ErrGitHubRun + } + } + if pending { + return nil, ErrGitHubPending + } + return result, nil +} + +func (api *githubAPI) runMatchesPR(run githubRun, pr githubPullRequest) bool { + // A head SHA can be reused by another PR or after retargeting. Do not treat + // the run's head alone as an association with this exact PR/base snapshot. + // Empty associations occur for closed or otherwise unavailable PRs; those + // cannot provide the provenance needed for this open-PR admission. + if len(run.PullRequests) != 1 { + return false + } + linked := run.PullRequests[0] + return linked.ID == pr.ID && linked.Number == pr.Number && + linked.URL == githubAPIOrigin+"/repos/"+api.repo+"/pulls/"+strconv.Itoa(pr.Number) && + linked.Head.SHA == pr.Head.SHA && linked.Head.Ref == pr.Head.Ref && linked.Head.Repo.ID == pr.Head.Repo.ID && + linked.Base.SHA == pr.Base.SHA && linked.Base.Ref == pr.Base.Ref && linked.Base.Repo.ID == pr.Base.Repo.ID +} + +func (api *githubAPI) readObservations(ctx context.Context, run githubRun, source string) ([]CheckObservation, error) { + suffix := fmt.Sprintf("/actions/runs/%d/attempts/%d/jobs", run.ID, run.RunAttempt) + jobs, err := githubList[githubJob](ctx, api, suffix, "jobs", nil) + if err != nil { + return nil, err + } + checks, err := githubList[githubCheckRun](ctx, api, fmt.Sprintf("/check-suites/%d/check-runs", run.CheckSuiteID), "check_runs", url.Values{"filter": {"latest"}}) + if err != nil { + return nil, err + } + checkByID := make(map[int64]githubCheckRun) + for _, check := range checks { + if check.ID <= 0 || checkByID[check.ID].ID != 0 || check.CheckSuite.ID != run.CheckSuiteID || + check.App.ID != githubActionsID || check.App.Slug != "github-actions" || check.HeadSHA != run.HeadSHA || + check.Status != "completed" || check.Conclusion != "success" { + return nil, ErrGitHubRun + } + checkByID[check.ID] = check + } + var observations []CheckObservation + jobIDs := make(map[int64]bool) + usedChecks := make(map[int64]bool) + for _, job := range jobs { + if job.ID <= 0 || jobIDs[job.ID] || job.RunID != run.ID || job.RunAttempt != run.RunAttempt || + job.HeadSHA != run.HeadSHA || job.HeadBranch != run.HeadBranch || len(job.Steps) == 0 { + return nil, ErrGitHubRun + } + jobIDs[job.ID] = true + prefix := githubAPIOrigin + "/repos/" + api.repo + "/check-runs/" + rawID, found := strings.CutPrefix(job.CheckRunURL, prefix) + checkID, parseErr := strconv.ParseInt(rawID, 10, 64) + if !found || parseErr != nil || checkID <= 0 || rawID != strconv.FormatInt(checkID, 10) || usedChecks[checkID] { + return nil, ErrGitHubRun + } + check, exists := checkByID[checkID] + if !exists || check.Name != job.Name { + return nil, ErrGitHubRun + } + usedChecks[checkID] = true + stepNumbers := make(map[int]bool) + for _, step := range job.Steps { + if step.Number <= 0 || stepNumbers[step.Number] || !validJobName(step.Name) || step.Status != "completed" || step.Conclusion != "success" { + return nil, ErrGitHubRun + } + stepNumbers[step.Number] = true + } + observations = append(observations, CheckObservation{Check: CheckIdentity{Source: source, Job: job.Name}, HeadSHA: job.HeadSHA, Status: job.Status, Conclusion: job.Conclusion}) + } + if len(usedChecks) != len(checkByID) { + return nil, ErrGitHubRun + } + return observations, nil +} + +func gitBlobID(contents []byte, length int) string { + object := append([]byte(fmt.Sprintf("blob %d\x00", len(contents))), contents...) + if length == 40 { + digest := sha1.Sum(object) // #nosec G401 -- required Git object identifier. + return hex.EncodeToString(digest[:]) + } + digest := sha256.Sum256(object) + return hex.EncodeToString(digest[:]) +} diff --git a/pkg/safepush/github_test.go b/pkg/safepush/github_test.go new file mode 100644 index 0000000..6b5d310 --- /dev/null +++ b/pkg/safepush/github_test.go @@ -0,0 +1,718 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package safepush + +import ( + "context" + "crypto/sha1" // #nosec G505 -- synthetic Git object fixture identifiers. + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strconv" + "strings" + "sync" + "testing" + + "github.com/opencloudtech/CloudRING/internal/strictjson" +) + +const ( + ghTestHead = "1111111111111111111111111111111111111111" + ghTestBase = "2222222222222222222222222222222222222222" + ghTestHeadTree = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ghTestBaseTree = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ghTestBlob = "e69e9a60e26d1cd37de67076728528819fec5844" + ghTestOther = "9999999999999999999999999999999999999999" + ghTestPolicyID = "85b9611e54998ddb6ae044af1c6622072c9fa29a" + ghTestPolicy = `{"schemaVersion":"cloudring.safepush.github/v1","repository":"owner/repo","repositoryId":42,"workflows":[{"path":".github/workflows/ci.yml","jobs":["unit","race"]}],"excludedWorkflows":[".github/workflows/release.yml"]}` + ghTestWorkflow = `name: CI +on: pull_request +jobs: + unit: + runs-on: ubuntu-latest + steps: + - name: Run tests + run: go test ./... + race: + runs-on: ubuntu-latest + steps: + - name: Run tests + run: go test -race ./... +` +) + +// These are HTTP JSON fixtures shaped from the provider API, with deliberately +// distinct commit, tree and blob IDs. The adapter always addresses the official +// HTTPS origin; only this test transport connects it to an isolated TLS server. +type githubHTTPSFixture struct { + t *testing.T + client *http.Client + responses map[string]any + mu sync.Mutex + counts map[string]int + requests []string + mutate func(*http.Request, int, map[string]any) + reply func(http.ResponseWriter, *http.Request, int) bool + policy GitHubPolicy + candidate GitHubCandidate +} + +type githubFixtureTransport struct { + t *testing.T + base http.RoundTripper + target *url.URL +} + +func (transport githubFixtureTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if request.URL.Scheme != "https" || request.URL.Host != "api.github.com" || request.URL.User != nil { + transport.t.Error("adapter attempted a non-official API origin") + return nil, errors.New("unexpected test origin") + } + copyRequest := request.Clone(request.Context()) + copyURL := *request.URL + copyURL.Host = transport.target.Host + copyRequest.URL = ©URL + copyRequest.Host = transport.target.Host + response, err := transport.base.RoundTrip(copyRequest) + if response != nil { + response.Request = request + } + return response, err +} + +func newGitHubHTTPSFixture(t *testing.T) *githubHTTPSFixture { + t.Helper() + fixture := &githubHTTPSFixture{t: t, responses: make(map[string]any), counts: make(map[string]int)} + if err := json.Unmarshal([]byte(ghTestPolicy), &fixture.policy); err != nil { + t.Fatal(err) + } + fixture.candidate = GitHubCandidate{PullRequest: 7, HeadSHA: ghTestHead, BaseSHA: ghTestBase, TargetBranch: "main"} + repo := map[string]any{"id": 42, "full_name": "owner/repo", "archived": false, "disabled": false} + fixture.responses[""] = repo + fixture.responses["/pulls/7"] = map[string]any{ + "id": 71, "number": 7, "state": "open", "draft": false, "merged": false, + "head": map[string]any{"ref": "feature", "sha": ghTestHead, "repo": repo}, + "base": map[string]any{"ref": "main", "sha": ghTestBase, "repo": repo}, + } + fixture.responses["/branches/main"] = map[string]any{"name": "main", "protected": true, "commit": map[string]any{"sha": ghTestBase}} + fixture.responses["/compare/"+ghTestBase+"..."+ghTestHead] = map[string]any{ + "status": "ahead", "ahead_by": 1, "behind_by": 0, + "base_commit": map[string]any{"sha": ghTestBase}, "merge_base_commit": map[string]any{"sha": ghTestBase}, + } + for commit, tree := range map[string]string{ghTestBase: ghTestBaseTree, ghTestHead: ghTestHeadTree} { + fixture.responses["/git/commits/"+commit] = map[string]any{"sha": commit, "tree": map[string]any{"sha": tree}} + fixture.responses["/git/trees/"+tree] = map[string]any{"sha": tree, "truncated": false, "tree": []any{ + map[string]any{"path": ".github/workflows/ci.yml", "mode": "100644", "type": "blob", "sha": ghTestBlob}, + map[string]any{"path": ".github/workflows/release.yml", "mode": "100644", "type": "blob", "sha": "4444444444444444444444444444444444444444"}, + map[string]any{"path": ".github/scripts/guard.sh", "mode": "100755", "type": "blob", "sha": "5555555555555555555555555555555555555555"}, + map[string]any{"path": ".github/safepush.json", "mode": "100644", "type": "blob", "sha": ghTestPolicyID}, + }} + } + fixture.responses["/git/blobs/"+ghTestPolicyID] = map[string]any{"sha": ghTestPolicyID, "encoding": "base64", "size": len(ghTestPolicy), "content": base64.StdEncoding.EncodeToString([]byte(ghTestPolicy))} + fixture.responses["/git/blobs/"+ghTestBlob] = map[string]any{"sha": ghTestBlob, "encoding": "base64", "size": 232, "content": base64.StdEncoding.EncodeToString([]byte(ghTestWorkflow))} + fixture.responses["/actions/workflows"] = ghTestEnvelope("workflows", []any{ + map[string]any{"id": 101, "path": ".github/workflows/ci.yml", "state": "active"}, + map[string]any{"id": 102, "path": ".github/workflows/release.yml", "state": "active"}, + map[string]any{"id": 103, "path": "dynamic/github-code-scanning/codeql", "state": "active"}, + }) + run := map[string]any{ + "id": 201, "workflow_id": 101, "run_number": 11, "run_attempt": 1, "check_suite_id": 301, + "path": ".github/workflows/ci.yml", "event": "pull_request", "head_sha": ghTestHead, "head_branch": "feature", + "status": "completed", "conclusion": "success", "repository": repo, "head_repository": repo, + "pull_requests": []any{map[string]any{ + "id": 71, "number": 7, "url": "https://api.github.com/repos/owner/repo/pulls/7", + "head": map[string]any{"ref": "feature", "sha": ghTestHead, "repo": map[string]any{"id": 42}}, + "base": map[string]any{"ref": "main", "sha": ghTestBase, "repo": map[string]any{"id": 42}}, + }}, "referenced_workflows": []any{}, + } + fixture.responses["/actions/runs"] = ghTestEnvelope("workflow_runs", []any{run}) + fixture.responses["/actions/runs/201"] = run + fixture.setJobs([]string{"unit", "race"}) + server := httptest.NewTLSServer(http.HandlerFunc(fixture.serveHTTP)) + t.Cleanup(server.Close) + target, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + fixture.client = &http.Client{Transport: githubFixtureTransport{t: t, base: server.Client().Transport, target: target}} + return fixture +} + +func ghTestEnvelope(key string, items []any) map[string]any { + return map[string]any{"total_count": len(items), key: items} +} + +func (fixture *githubHTTPSFixture) setJobs(names []string) { + jobs, checks := make([]any, 0, len(names)), make([]any, 0, len(names)) + for index, name := range names { + id := 401 + index + jobs = append(jobs, map[string]any{ + "id": id, "run_id": 201, "run_attempt": 1, "head_sha": ghTestHead, "head_branch": "feature", "name": name, + "status": "completed", "conclusion": "success", "check_run_url": fmt.Sprintf("https://api.github.com/repos/owner/repo/check-runs/%d", id), + "steps": []any{ + map[string]any{"name": "Set up job", "number": 1, "status": "completed", "conclusion": "success"}, + map[string]any{"name": "Run tests", "number": 3, "status": "completed", "conclusion": "success"}, + map[string]any{"name": "Complete job", "number": 7, "status": "completed", "conclusion": "success"}, + }, + }) + checks = append(checks, map[string]any{ + "id": id, "name": name, "head_sha": ghTestHead, "status": "completed", "conclusion": "success", + "check_suite": map[string]any{"id": 301}, "app": map[string]any{"id": 15368, "slug": "github-actions"}, + }) + } + fixture.responses["/actions/runs/201/attempts/1/jobs"] = ghTestEnvelope("jobs", jobs) + fixture.responses["/check-suites/301/check-runs"] = ghTestEnvelope("check_runs", checks) +} + +func (fixture *githubHTTPSFixture) setRequiredWorkflow(contents string) { + // The baseline uses a separately computed known Git blob hash. Mutations + // produce another native-shaped object so the parser, not a hash mismatch, + // rejects a workflow whose failed outcomes could have success conclusions. + digest := sha1.New() // #nosec G401 -- synthetic Git object identifier. + _, _ = fmt.Fprintf(digest, "blob %d%c%s", len(contents), 0, contents) + objectID := fmt.Sprintf("%x", digest.Sum(nil)) + for _, treeID := range []string{ghTestBaseTree, ghTestHeadTree} { + tree := fixture.responses["/git/trees/"+treeID].(map[string]any) + ghTestFirst(tree, "tree")["sha"] = objectID + } + fixture.responses["/git/blobs/"+objectID] = map[string]any{ + "sha": objectID, "encoding": "base64", "size": len(contents), + "content": base64.StdEncoding.EncodeToString([]byte(contents)), + } +} + +func (fixture *githubHTTPSFixture) serveHTTP(writer http.ResponseWriter, request *http.Request) { + fixture.mu.Lock() + defer fixture.mu.Unlock() + suffix := strings.TrimPrefix(request.URL.Path, "/repos/owner/repo") + fixture.counts[suffix]++ + count := fixture.counts[suffix] + fixture.requests = append(fixture.requests, request.URL.RequestURI()) + if request.Method != http.MethodGet || request.Header.Get("Authorization") != "Bearer test-token" { + fixture.t.Error("unexpected method or synthetic authorization header") + } + if suffix == "/actions/runs" && (request.URL.Query().Get("event") != "pull_request" || request.URL.Query().Get("head_sha") != ghTestHead) { + fixture.t.Error("run query lacks exact event and candidate filter") + } + writer.Header().Set("Content-Type", "application/json") + if fixture.reply != nil && fixture.reply(writer, request, count) { + return + } + original, exists := fixture.responses[suffix] + if !exists { + fixture.t.Errorf("unexpected API route: %s", suffix) + writer.WriteHeader(http.StatusNotFound) + return + } + data, err := json.Marshal(original) + if err != nil { + fixture.t.Error(err) + return + } + var response map[string]any + if err = json.Unmarshal(data, &response); err != nil { + fixture.t.Error(err) + return + } + if pageText := request.URL.Query().Get("page"); pageText != "" { + page, pageErr := strconv.Atoi(pageText) + if pageErr != nil || page <= 0 || request.URL.Query().Get("per_page") != "100" { + fixture.t.Error("invalid pagination request") + writer.WriteHeader(http.StatusBadRequest) + return + } + for _, key := range []string{"workflows", "workflow_runs", "jobs", "check_runs"} { + if all, ok := response[key].([]any); ok { + start := min((page-1)*100, len(all)) + response[key] = all[start:min(start+100, len(all))] + } + } + } + if fixture.mutate != nil { + fixture.mutate(request, count, response) + } + if err = json.NewEncoder(writer).Encode(response); err != nil { + fixture.t.Error(err) + } +} + +func (fixture *githubHTTPSFixture) verify() error { + return VerifyGitHub(context.Background(), fixture.client, "test-token", fixture.policy, fixture.candidate) +} + +func ghTestFirst(response map[string]any, key string) map[string]any { + return response[key].([]any)[0].(map[string]any) +} + +func TestGitHubHTTPSAdmissionAndTrustedPolicy(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + policy, err := ReadGitHubPolicy(context.Background(), fixture.client, "test-token", "owner/repo", ghTestBase) + if err != nil || !reflect.DeepEqual(policy, fixture.policy) { + t.Fatalf("policy result = %#v, %v", policy, err) + } + if err = fixture.verify(); err != nil { + t.Fatal(err) + } + fixture.mu.Lock() + defer fixture.mu.Unlock() + for _, required := range []string{"/git/commits/" + ghTestBase, "/git/trees/" + ghTestBaseTree, "/git/trees/" + ghTestHeadTree, "/git/blobs/" + ghTestBlob, "/actions/runs/201/attempts/1/jobs", "/check-suites/301/check-runs", "/actions/runs/201"} { + if fixture.counts[required] == 0 { + t.Errorf("missing native binding read: %s", required) + } + } + if fixture.counts["/pulls/7"] != 2 || fixture.counts["/branches/main"] != 2 || fixture.counts["/actions/runs"] != 2 { + t.Error("successful admission must re-read PR, protected target, and latest runs") + } +} + +func TestGitHubSuccessConclusionsCannotHideContinueOnError(t *testing.T) { + for _, scope := range []string{"job", "step"} { + for _, value := range []string{"true", "${{ false }}", "null", "'false'", "false"} { + t.Run(scope+"_"+value, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + var contents string + if scope == "job" { + contents = strings.Replace(ghTestWorkflow, " steps:\n", " continue-on-error: "+value+"\n steps:\n", 1) + } else { + contents = strings.Replace(ghTestWorkflow, " run: go test ./...\n", " run: go test ./...\n continue-on-error: "+value+"\n", 1) + } + fixture.setRequiredWorkflow(contents) + // Base and head both have this exact blob. Every REST workflow, + // job, check and step still reports completed/success, which is + // realistic for a failed step with continue-on-error: true. + err := fixture.verify() + if value == "false" { + if err != nil { + t.Fatal(err) + } + return + } + if !errors.Is(err, ErrGitHubSource) { + t.Fatalf("API success admitted unsafe accepted workflow: %v", err) + } + if fixture.counts["/actions/runs"] != 0 { + t.Fatal("unsafe source was not rejected before trusting run statuses") + } + }) + } + } +} + +func TestGitHubWorkflowBlobMustMatchTrustedObject(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if strings.HasSuffix(request.URL.Path, "/git/blobs/"+ghTestBlob) { + response["content"] = base64.StdEncoding.EncodeToString([]byte(strings.Replace(ghTestWorkflow, "unit", "evil", 1))) + } + } + if err := fixture.verify(); !errors.Is(err, ErrGitHubSource) { + t.Fatalf("unverified workflow blob admitted: %v", err) + } +} + +func TestGitHubOptionalReferencedWorkflowsAndExcludedGate(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if strings.HasSuffix(request.URL.Path, "/actions/runs") { + delete(ghTestFirst(response, "workflow_runs"), "referenced_workflows") + response["workflow_runs"] = append(response["workflow_runs"].([]any), map[string]any{"id": 900, "workflow_id": 999, "path": "organization/required-verifier.yml", "status": "in_progress"}) + response["total_count"] = 2 + } + if strings.HasSuffix(request.URL.Path, "/actions/runs/201") { + delete(response, "referenced_workflows") + } + } + if err := fixture.verify(); err != nil { + t.Fatal(err) + } +} + +func TestGitHubPendingIsOnlyUnfinishedRequiredRuns(t *testing.T) { + for _, status := range []string{"absent", "queued", "in_progress", "waiting", "requested", "pending"} { + t.Run(status, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if !strings.HasSuffix(request.URL.Path, "/actions/runs") { + return + } + if status == "absent" { + response["total_count"], response["workflow_runs"] = 0, []any{} + } else { + run := ghTestFirst(response, "workflow_runs") + run["status"], run["conclusion"] = status, nil + } + } + if err := fixture.verify(); !errors.Is(err, ErrGitHubPending) { + t.Fatalf("got %v, want pending", err) + } + if fixture.counts["/actions/runs/201/attempts/1/jobs"] != 0 { + t.Fatal("fetched jobs before required runs completed") + } + }) + } + for _, conclusion := range []string{"failure", "cancelled", "skipped", "neutral", "timed_out", "action_required", ""} { + t.Run("completed_"+conclusion, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if strings.HasSuffix(request.URL.Path, "/actions/runs") { + ghTestFirst(response, "workflow_runs")["conclusion"] = conclusion + } + } + if err := fixture.verify(); !errors.Is(err, ErrCheckNotSuccessful) { + t.Fatalf("got %v, want completed failure", err) + } + }) + } +} + +func TestGitHubRejectsSpoofingAndIncompleteExecution(t *testing.T) { + cases := []struct { + name string + suffix string + change func(map[string]any) + }{ + {"repository_name_reused", "", func(v map[string]any) { v["id"] = 999 }}, + {"unprotected_target", "/branches/main", func(v map[string]any) { v["protected"] = false }}, + {"wrong_pr_head", "/pulls/7", func(v map[string]any) { v["head"].(map[string]any)["sha"] = ghTestOther }}, + {"stale_pr_base", "/pulls/7", func(v map[string]any) { v["base"].(map[string]any)["sha"] = ghTestOther }}, + {"draft_pr", "/pulls/7", func(v map[string]any) { v["draft"] = true }}, + {"closed_pr", "/pulls/7", func(v map[string]any) { v["state"] = "closed" }}, + {"diverged_head", "/compare/" + ghTestBase + "..." + ghTestHead, func(v map[string]any) { v["behind_by"] = 1 }}, + {"wrong_commit_tree_binding", "/git/commits/" + ghTestHead, func(v map[string]any) { v["sha"] = ghTestOther }}, + {"wrong_tree_sha", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { v["sha"] = ghTestHead }}, + {"truncated_tree", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { v["truncated"] = true }}, + {"changed_required_workflow", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { ghTestFirst(v, "tree")["sha"] = ghTestOther }}, + {"changed_excluded_workflow", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { v["tree"].([]any)[1].(map[string]any)["sha"] = ghTestOther }}, + {"changed_workflow_helper", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { v["tree"].([]any)[2].(map[string]any)["sha"] = ghTestOther }}, + {"symlink_workflow", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { ghTestFirst(v, "tree")["mode"] = "120000" }}, + {"extra_candidate_workflow", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { + v["tree"] = append(v["tree"].([]any), map[string]any{"path": ".github/workflows/spoof.yml", "type": "blob", "mode": "100644", "sha": ghTestOther}) + }}, + {"missing_excluded_workflow", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { v["tree"] = v["tree"].([]any)[:1] }}, + {"disabled_required_workflow", "/actions/workflows", func(v map[string]any) { ghTestFirst(v, "workflows")["state"] = "disabled_manually" }}, + {"wrong_workflow_id_same_name", "/actions/runs", func(v map[string]any) { ghTestFirst(v, "workflow_runs")["workflow_id"] = 103 }}, + {"wrong_run_event", "/actions/runs", func(v map[string]any) { ghTestFirst(v, "workflow_runs")["event"] = "push" }}, + {"wrong_run_head", "/actions/runs", func(v map[string]any) { ghTestFirst(v, "workflow_runs")["head_sha"] = ghTestOther }}, + {"wrong_run_repository", "/actions/runs", func(v map[string]any) { ghTestFirst(v, "workflow_runs")["repository"].(map[string]any)["id"] = 43 }}, + {"missing_pr_association", "/actions/runs", func(v map[string]any) { ghTestFirst(v, "workflow_runs")["pull_requests"] = []any{} }}, + {"same_sha_other_pr", "/actions/runs", func(v map[string]any) { ghTestFirst(ghTestFirst(v, "workflow_runs"), "pull_requests")["number"] = 8 }}, + {"wrong_pr_native_id", "/actions/runs", func(v map[string]any) { ghTestFirst(ghTestFirst(v, "workflow_runs"), "pull_requests")["id"] = 72 }}, + {"retargeted_pr_run", "/actions/runs", func(v map[string]any) { + ghTestFirst(ghTestFirst(v, "workflow_runs"), "pull_requests")["base"].(map[string]any)["ref"] = "master" + }}, + {"stale_run_base", "/actions/runs", func(v map[string]any) { + ghTestFirst(ghTestFirst(v, "workflow_runs"), "pull_requests")["base"].(map[string]any)["sha"] = ghTestOther + }}, + {"wrong_run_base_repository", "/actions/runs", func(v map[string]any) { + ghTestFirst(ghTestFirst(v, "workflow_runs"), "pull_requests")["base"].(map[string]any)["repo"].(map[string]any)["id"] = 43 + }}, + {"wrong_associated_head", "/actions/runs", func(v map[string]any) { + ghTestFirst(ghTestFirst(v, "workflow_runs"), "pull_requests")["head"].(map[string]any)["sha"] = ghTestOther + }}, + {"reusable_chain_without_policy", "/actions/runs", func(v map[string]any) { + ghTestFirst(v, "workflow_runs")["referenced_workflows"] = []any{map[string]any{"path": "owner/other/workflow.yml"}} + }}, + {"unknown_run_status", "/actions/runs", func(v map[string]any) { ghTestFirst(v, "workflow_runs")["status"] = "unrecognized" }}, + {"duplicate_run", "/actions/runs", func(v map[string]any) { + v["workflow_runs"] = append(v["workflow_runs"].([]any), ghTestFirst(v, "workflow_runs")) + v["total_count"] = 2 + }}, + {"wrong_job_attempt", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { ghTestFirst(v, "jobs")["run_attempt"] = 2 }}, + {"wrong_job_run", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { ghTestFirst(v, "jobs")["run_id"] = 202 }}, + {"wrong_job_head", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { ghTestFirst(v, "jobs")["head_sha"] = ghTestOther }}, + {"wrong_check_url_host", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { + ghTestFirst(v, "jobs")["check_run_url"] = "https://example.invalid/check-runs/401" + }}, + {"duplicate_job", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { v["jobs"].([]any)[1] = ghTestFirst(v, "jobs") }}, + {"empty_steps", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { ghTestFirst(v, "jobs")["steps"] = []any{} }}, + {"skipped_success_step", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { ghTestFirst(ghTestFirst(v, "jobs"), "steps")["conclusion"] = "skipped" }}, + {"failed_step_conclusion", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { ghTestFirst(ghTestFirst(v, "jobs"), "steps")["conclusion"] = "failure" }}, + {"wrong_check_app", "/check-suites/301/check-runs", func(v map[string]any) { ghTestFirst(v, "check_runs")["app"].(map[string]any)["id"] = 123 }}, + {"wrong_check_suite", "/check-suites/301/check-runs", func(v map[string]any) { ghTestFirst(v, "check_runs")["check_suite"].(map[string]any)["id"] = 999 }}, + {"neutral_check", "/check-suites/301/check-runs", func(v map[string]any) { ghTestFirst(v, "check_runs")["conclusion"] = "neutral" }}, + {"check_job_name_mismatch", "/check-suites/301/check-runs", func(v map[string]any) { ghTestFirst(v, "check_runs")["name"] = "spoof" }}, + {"wrong_check_head", "/check-suites/301/check-runs", func(v map[string]any) { ghTestFirst(v, "check_runs")["head_sha"] = ghTestOther }}, + {"duplicate_check", "/check-suites/301/check-runs", func(v map[string]any) { v["check_runs"].([]any)[1] = ghTestFirst(v, "check_runs") }}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if request.URL.Path == "/repos/owner/repo"+test.suffix { + test.change(response) + } + } + if err := fixture.verify(); err == nil || errors.Is(err, ErrGitHubPending) { + t.Fatalf("unsafe API fixture accepted or retried: %v", err) + } + }) + } +} + +func TestGitHubCompletedRunCannotOmitRequiredJobs(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.setJobs([]string{"unit"}) + if err := fixture.verify(); !errors.Is(err, ErrMissingCheck) { + t.Fatalf("got %v, want missing job failure", err) + } +} + +func TestGitHubLatestFailureCannotReuseOlderSuccess(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if strings.HasSuffix(request.URL.Path, "/actions/runs") { + older := ghTestFirst(response, "workflow_runs") + newer := make(map[string]any) + for key, value := range older { + newer[key] = value + } + newer["id"], newer["run_number"], newer["conclusion"] = 202, 12, "failure" + response["workflow_runs"], response["total_count"] = []any{older, newer}, 2 + } + } + if err := fixture.verify(); !errors.Is(err, ErrCheckNotSuccessful) { + t.Fatalf("got %v, want newer failure", err) + } +} + +func TestGitHubStateMutationDuringCollection(t *testing.T) { + for _, target := range []string{"pr", "branch", "run_attempt", "latest_run", "workflow"} { + t.Run(target, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, count int, response map[string]any) { + suffix := strings.TrimPrefix(request.URL.Path, "/repos/owner/repo") + switch { + case target == "pr" && suffix == "/pulls/7" && count == 2: + response["head"].(map[string]any)["sha"] = ghTestOther + case target == "branch" && suffix == "/branches/main" && count == 2: + response["commit"].(map[string]any)["sha"] = ghTestOther + case target == "run_attempt" && suffix == "/actions/runs/201": + response["run_attempt"] = 2 + case target == "latest_run" && suffix == "/actions/runs" && count == 2: + run := ghTestFirst(response, "workflow_runs") + run["run_attempt"], run["status"], run["conclusion"] = 2, "in_progress", nil + case target == "workflow" && suffix == "/actions/workflows" && count == 2: + ghTestFirst(response, "workflows")["id"] = 999 + } + } + if err := fixture.verify(); !errors.Is(err, ErrGitHubState) { + t.Fatalf("changed collection returned %v", err) + } + }) + } +} + +func TestGitHubBoundedCompletePagination(t *testing.T) { + for _, mode := range []string{"valid", "missing_page", "changed_total", "duplicate_across_pages", "over_limit"} { + t.Run(mode, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + names := make([]string, 101) + for index := range names { + names[index] = fmt.Sprintf("matrix-%d", index) + } + fixture.policy.Workflows[0].Jobs = names + fixture.setJobs(names) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if !strings.HasSuffix(request.URL.Path, "/jobs") { + return + } + if mode == "over_limit" { + response["total_count"] = 1001 + } + if request.URL.Query().Get("page") != "2" { + return + } + switch mode { + case "missing_page": + response["jobs"] = []any{} + case "changed_total": + response["total_count"] = 102 + case "duplicate_across_pages": + ghTestFirst(response, "jobs")["id"] = 401 + } + } + err := fixture.verify() + if mode == "valid" && err != nil { + t.Fatal(err) + } + if mode != "valid" && (err == nil || errors.Is(err, ErrGitHubPending)) { + t.Fatalf("incomplete or ambiguous pagination admitted: %v", err) + } + }) + } +} + +func TestGitHubHTTPFailuresAreBoundedAndRedacted(t *testing.T) { + for _, mode := range []string{"redirect", "forbidden", "duplicate_json", "trailing_json", "content_type", "short_body", "oversized"} { + t.Run(mode, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + redirects := 0 + fixture.client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { redirects++; return nil } + fixture.reply = func(writer http.ResponseWriter, request *http.Request, _ int) bool { + if request.URL.Path != "/repos/owner/repo" { + return false + } + switch mode { + case "redirect": + writer.Header().Set("Location", "https://example.invalid/test-token") + writer.WriteHeader(http.StatusFound) + case "forbidden": + writer.WriteHeader(http.StatusForbidden) + _, _ = fmt.Fprint(writer, `{"message":"test-token opaque-failure-sentinel"}`) + case "duplicate_json": + _, _ = fmt.Fprint(writer, `{"id":42,"id":999,"full_name":"owner/repo"}`) + case "trailing_json": + _, _ = fmt.Fprint(writer, `{"id":42,"full_name":"owner/repo"}{"extra":true}`) + case "content_type": + writer.Header().Set("Content-Type", "text/html") + _, _ = fmt.Fprint(writer, `{"id":42,"full_name":"owner/repo"}`) + case "short_body": + writer.Header().Set("Content-Length", "1000") + _, _ = fmt.Fprint(writer, `{"id":42}`) + case "oversized": + writer.Header().Set("Content-Length", strconv.Itoa(strictjson.MaxDocumentBytes+1)) + _, _ = fmt.Fprint(writer, strings.Repeat(" ", strictjson.MaxDocumentBytes+1)) + } + return true + } + err := fixture.verify() + if !errors.Is(err, ErrGitHubAPI) || strings.Contains(err.Error(), "test-token") || strings.Contains(err.Error(), "opaque-failure-sentinel") || strings.Contains(err.Error(), "example.invalid") { + t.Fatalf("unredacted or unexpected HTTP error: %v", err) + } + if redirects != 0 { + t.Fatal("adapter followed a redirect with its credential") + } + }) + } +} + +func TestReadGitHubPolicyRejectsSymlinksAndForgedBlobs(t *testing.T) { + for _, mode := range []string{"symlink", "executable", "blob_hash", "encoding", "size", "repository_id"} { + t.Run(mode, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if strings.HasSuffix(request.URL.Path, "/git/trees/"+ghTestBaseTree) && (mode == "symlink" || mode == "executable") { + entry := response["tree"].([]any)[3].(map[string]any) + if mode == "symlink" { + entry["mode"] = "120000" + } else { + entry["mode"] = "100755" + } + } + if mode == "repository_id" && request.URL.Path == "/repos/owner/repo" { + response["id"] = 99 + } + if strings.HasSuffix(request.URL.Path, "/git/blobs/"+ghTestPolicyID) { + switch mode { + case "blob_hash": + response["content"] = base64.StdEncoding.EncodeToString([]byte(strings.Replace(ghTestPolicy, "unit", "evil", 1))) + case "encoding": + response["encoding"] = "utf-8" + case "size": + response["size"] = 1 + } + } + } + if _, err := ReadGitHubPolicy(context.Background(), fixture.client, "test-token", "owner/repo", ghTestBase); err == nil || errors.Is(err, ErrGitHubPending) { + t.Fatalf("unsafe policy accepted: %v", err) + } + }) + } +} + +func TestReadGitHubPolicyRejectsUnknownFieldsAfterBlobVerification(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + const policyID = "fc446526e44b5189d82ea86595794637c3b8128f" + contents := ghTestPolicy[:len(ghTestPolicy)-1] + `,"untrusted":true}` + tree := fixture.responses["/git/trees/"+ghTestBaseTree].(map[string]any) + tree["tree"].([]any)[3].(map[string]any)["sha"] = policyID + fixture.responses["/git/blobs/"+policyID] = map[string]any{ + "sha": policyID, "encoding": "base64", "size": 236, + "content": base64.StdEncoding.EncodeToString([]byte(contents)), + } + if _, err := ReadGitHubPolicy(context.Background(), fixture.client, "test-token", "owner/repo", ghTestBase); !errors.Is(err, ErrPolicy) { + t.Fatalf("correctly hashed policy with unknown field returned %v", err) + } +} + +func TestGitHubRejectsInvalidInputBeforeHTTP(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.candidate.HeadSHA = "invalid" + if err := fixture.verify(); !errors.Is(err, ErrGitHubCandidate) { + t.Fatalf("got %v", err) + } + if len(fixture.requests) != 0 { + t.Fatal("sent request for invalid candidate") + } + if _, err := ReadGitHubPolicy(context.Background(), fixture.client, "test-token", "owner/../repo", ghTestBase); !errors.Is(err, ErrPolicy) { + t.Fatalf("got %v", err) + } + if len(fixture.requests) != 0 { + t.Fatal("sent request for invalid repository") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := ReadGitHubPolicy(ctx, fixture.client, "test-token", "owner/repo", ghTestBase); !errors.Is(err, ErrGitHubAPI) { + t.Fatalf("got %v", err) + } +} + +type githubCancelOnCloseBody struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (body githubCancelOnCloseBody) Close() error { + err := body.ReadCloser.Close() + body.cancel() + return err +} + +type githubCancelAfterReadTransport struct { + base http.RoundTripper + suffix string + nth int + count int + cancel context.CancelFunc +} + +func (transport *githubCancelAfterReadTransport) RoundTrip(request *http.Request) (*http.Response, error) { + response, err := transport.base.RoundTrip(request) + if strings.HasSuffix(request.URL.Path, transport.suffix) { + transport.count++ + if err == nil && transport.count == transport.nth { + response.Body = githubCancelOnCloseBody{ReadCloser: response.Body, cancel: transport.cancel} + } + } + return response, err +} + +func TestGitHubCannotSucceedAfterFinalResponseCancellation(t *testing.T) { + for _, operation := range []string{"policy", "verify"} { + t.Run(operation, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + transport := &githubCancelAfterReadTransport{base: fixture.client.Transport, cancel: cancel} + fixture.client.Transport = transport + var err error + if operation == "policy" { + transport.suffix, transport.nth = "/git/blobs/"+ghTestPolicyID, 1 + _, err = ReadGitHubPolicy(ctx, fixture.client, "test-token", "owner/repo", ghTestBase) + } else { + transport.suffix, transport.nth = "/branches/main", 2 + err = VerifyGitHub(ctx, fixture.client, "test-token", fixture.policy, fixture.candidate) + } + if !errors.Is(err, ErrGitHubAPI) || ctx.Err() == nil { + t.Fatalf("operation succeeded after cancellation: %v", err) + } + }) + } +} diff --git a/pkg/safepush/policy.go b/pkg/safepush/policy.go new file mode 100644 index 0000000..56375ce --- /dev/null +++ b/pkg/safepush/policy.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package safepush + +import ( + "encoding/json" + "errors" + "path" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/opencloudtech/CloudRING/internal/strictjson" +) + +const GitHubPolicySchema = "cloudring.safepush.github/v1" + +var ( + ErrPolicy = errors.New("SafePush policy is invalid") + ErrRequest = errors.New("SafePush request is invalid") +) + +// GitHubPolicy is read from the accepted target commit by the trusted CI +// workflow. Loading a candidate's policy would let that candidate remove its +// own requirements. RepositoryID also prevents name reuse from changing scope. +type GitHubPolicy struct { + SchemaVersion string `json:"schemaVersion"` + Repository string `json:"repository"` + RepositoryID int64 `json:"repositoryId"` + Workflows []GitHubWorkflow `json:"workflows"` + ExcludedWorkflows []string `json:"excludedWorkflows"` +} + +// GitHubWorkflow identifies the complete expected job set of one test workflow. +// Path and job name together are the identity; a same-named job in another +// workflow cannot satisfy a requirement. +type GitHubWorkflow struct { + Path string `json:"path"` + Jobs []string `json:"jobs"` +} + +// GitHubCandidate binds one admission attempt to the PR and target snapshot. +// The adapter re-reads GitHub state; these values do not assert approval or +// successful execution and cannot replace native server protection. +type GitHubCandidate struct { + PullRequest int + HeadSHA string + BaseSHA string + TargetBranch string +} + +var repositoryName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}/[A-Za-z0-9_.-]{1,100}$`) + +// ValidateGitHubCandidate validates event arguments before any network access. +// Ref names are never accepted in place of immutable Git object identifiers. +func ValidateGitHubCandidate(repository string, candidate GitHubCandidate) error { + if !repositoryName.MatchString(repository) || strings.HasSuffix(repository, "/.") || + strings.HasSuffix(repository, "/..") || candidate.PullRequest <= 0 || + !validHeadSHA(candidate.HeadSHA) || !validHeadSHA(candidate.BaseSHA) || + candidate.HeadSHA == candidate.BaseSHA || + (candidate.TargetBranch != "main" && candidate.TargetBranch != "master") { + return ErrRequest + } + return nil +} + +// DecodeGitHubPolicy rejects unknown and duplicate fields, trailing documents, +// empty test sets, overlapping exclusions and ambiguous workflow identities. +func DecodeGitHubPolicy(data []byte) (GitHubPolicy, error) { + // encoding/json matches struct fields case-insensitively, including when + // DisallowUnknownFields is set. Inspect exact spellings before decoding so + // a second "Workflows" or "Jobs" cannot replace an earlier requirement. + var fields map[string]json.RawMessage + if strictjson.Decode(data, &fields) != nil || fields == nil { + return GitHubPolicy{}, ErrPolicy + } + for name := range fields { + switch name { + case "schemaVersion", "repository", "repositoryId", "workflows", "excludedWorkflows": + default: + return GitHubPolicy{}, ErrPolicy + } + } + var workflows []map[string]json.RawMessage + if json.Unmarshal(fields["workflows"], &workflows) != nil { + return GitHubPolicy{}, ErrPolicy + } + for _, workflow := range workflows { + for name := range workflow { + if name != "path" && name != "jobs" { + return GitHubPolicy{}, ErrPolicy + } + } + } + var policy GitHubPolicy + if json.Unmarshal(data, &policy) != nil || ValidateGitHubPolicy(policy) != nil { + return GitHubPolicy{}, ErrPolicy + } + return policy, nil +} + +func ValidateGitHubPolicy(policy GitHubPolicy) error { + if policy.SchemaVersion != GitHubPolicySchema || policy.RepositoryID <= 0 || + !repositoryName.MatchString(policy.Repository) || + strings.HasSuffix(policy.Repository, "/.") || strings.HasSuffix(policy.Repository, "/..") || + len(policy.Workflows) == 0 || len(policy.Workflows)+len(policy.ExcludedWorkflows) > 100 { + return ErrPolicy + } + seen := make(map[string]bool) + for _, workflow := range policy.Workflows { + if !validWorkflowPath(workflow.Path) || seen[workflow.Path] || len(workflow.Jobs) == 0 || len(workflow.Jobs) > 1000 { + return ErrPolicy + } + seen[workflow.Path] = true + jobs := make(map[string]bool) + for _, job := range workflow.Jobs { + if !validJobName(job) || jobs[job] { + return ErrPolicy + } + jobs[job] = true + } + } + for _, excluded := range policy.ExcludedWorkflows { + if !validWorkflowPath(excluded) || seen[excluded] { + return ErrPolicy + } + seen[excluded] = true + } + return nil +} + +func validWorkflowPath(value string) bool { + if len(value) > 255 || !strings.HasPrefix(value, ".github/workflows/") || path.Clean(value) != value { + return false + } + name := strings.TrimPrefix(value, ".github/workflows/") + if strings.ContainsAny(name, "/\\") || !(strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml")) { + return false + } + return validJobName(name) +} + +func validJobName(value string) bool { + if value == "" || len(value) > 512 || !utf8.ValidString(value) || strings.TrimSpace(value) != value { + return false + } + for _, r := range value { + if unicode.IsControl(r) { + return false + } + } + return true +} diff --git a/pkg/safepush/policy_test.go b/pkg/safepush/policy_test.go new file mode 100644 index 0000000..e7a3dcd --- /dev/null +++ b/pkg/safepush/policy_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package safepush + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func policyFixture() GitHubPolicy { + return GitHubPolicy{ + SchemaVersion: GitHubPolicySchema, + Repository: "synthetic/project", RepositoryID: 42, + Workflows: []GitHubWorkflow{{Path: ".github/workflows/ci.yml", Jobs: []string{"test", "race"}}}, + ExcludedWorkflows: []string{".github/workflows/release.yml"}, + } +} + +func TestPolicyRejectsAmbiguousOrWeakenedRequirements(t *testing.T) { + mutations := map[string]func(*GitHubPolicy){ + "wrong schema": func(p *GitHubPolicy) { p.SchemaVersion = "unknown" }, + "no repository identity": func(p *GitHubPolicy) { p.RepositoryID = 0 }, + "path traversal": func(p *GitHubPolicy) { p.Repository = "synthetic/../project" }, + "no tests": func(p *GitHubPolicy) { p.Workflows = nil }, + "empty jobs": func(p *GitHubPolicy) { p.Workflows[0].Jobs = nil }, + "duplicate workflow": func(p *GitHubPolicy) { p.Workflows = append(p.Workflows, p.Workflows[0]) }, + "duplicate job": func(p *GitHubPolicy) { p.Workflows[0].Jobs = []string{"test", "test"} }, + "excluded test workflow": func(p *GitHubPolicy) { p.ExcludedWorkflows = []string{p.Workflows[0].Path} }, + "duplicate exclusion": func(p *GitHubPolicy) { p.ExcludedWorkflows = append(p.ExcludedWorkflows, p.ExcludedWorkflows[0]) }, + "nested workflow path": func(p *GitHubPolicy) { p.Workflows[0].Path = ".github/workflows/nested/ci.yml" }, + "noncanonical workflow": func(p *GitHubPolicy) { p.Workflows[0].Path = ".github/workflows/../ci.yml" }, + "control in identity": func(p *GitHubPolicy) { p.Workflows[0].Jobs[0] = "test\npass" }, + "invisible job suffix": func(p *GitHubPolicy) { p.Workflows[0].Jobs[0] = "test " }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + policy := policyFixture() + mutate(&policy) + data, err := json.Marshal(policy) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeGitHubPolicy(data); !errors.Is(err, ErrPolicy) { + t.Fatalf("unsafe policy was accepted: %v", err) + } + }) + } +} + +func TestPolicyJSONHasOneClosedUnambiguousDocument(t *testing.T) { + data, err := json.Marshal(policyFixture()) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeGitHubPolicy(data); err != nil { + t.Fatal(err) + } + for name, malformed := range map[string]string{ + "unknown field": strings.Replace(string(data), `"repositoryId":42`, `"repositoryId":42,"trusted":true`, 1), + "duplicate field": strings.Replace(string(data), `"repositoryId":42`, `"repositoryId":42,"repositoryId":42`, 1), + "case variant duplicate": strings.Replace(string(data), `"repositoryId":42`, `"repositoryId":42,"RepositoryId":43`, 1), + "weakened case variant workflows": strings.Replace(string(data), `"excludedWorkflows":`, `"Workflows":[{"path":".github/workflows/ci.yml","jobs":["test"]}],"excludedWorkflows":`, 1), + "weakened case variant jobs": strings.Replace(string(data), `"jobs":["test","race"]`, `"jobs":["test","race"],"Jobs":["test"]`, 1), + "unknown case variant schema": strings.Replace(string(data), `"schemaVersion":`, `"SchemaVersion":`, 1), + "unknown case variant path": strings.Replace(string(data), `"path":`, `"Path":`, 1), + "escaped duplicate field": strings.Replace(string(data), `"repositoryId":42`, `"repositoryId":42,"\u0072epositoryId":42`, 1), + "trailing object": string(data) + `{}`, + "wrong integer": strings.Replace(string(data), `"repositoryId":42`, `"repositoryId":42.5`, 1), + } { + t.Run(name, func(t *testing.T) { + if _, err := DecodeGitHubPolicy([]byte(malformed)); !errors.Is(err, ErrPolicy) { + t.Fatalf("ambiguous policy was accepted: %v", err) + } + }) + } +} diff --git a/pkg/safepush/repository_policy_test.go b/pkg/safepush/repository_policy_test.go new file mode 100644 index 0000000..37e2484 --- /dev/null +++ b/pkg/safepush/repository_policy_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package safepush + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// This catches an added, removed, or renamed repository CI job being silently +// omitted from the admission policy. API provenance is tested separately. +func TestRepositoryPolicyCoversEveryPreMergeJob(t *testing.T) { + root := openRepositoryRoot(t) + data, err := root.ReadFile(".github/safepush.json") + if err != nil { + t.Fatal(err) + } + policy, err := DecodeGitHubPolicy(data) + if err != nil { + t.Fatal(err) + } + expectedPaths := make(map[string]bool) + for _, workflow := range policy.Workflows { + expectedPaths[workflow.Path] = true + data, err := root.ReadFile(filepath.FromSlash(workflow.Path)) + if err != nil { + t.Fatal(err) + } + var actual struct { + On map[string]any `yaml:"on"` + Jobs map[string]struct { + Name string `yaml:"name"` + } `yaml:"jobs"` + } + if err := yaml.Unmarshal(data, &actual); err != nil { + t.Fatal(err) + } + if _, exists := actual.On["pull_request"]; !exists { + t.Fatal("required test workflow no longer runs for pull requests") + } + var names []string + for id, job := range actual.Jobs { + name := job.Name + if name == "" { + name = id + } + if strings.Contains(name, "${{") { + t.Fatal("dynamic job name needs an explicit matrix policy") + } + names = append(names, name) + } + sort.Strings(names) + expected := append([]string(nil), workflow.Jobs...) + sort.Strings(expected) + if !reflect.DeepEqual(names, expected) { + t.Fatalf("workflow %s jobs differ from admission policy: actual=%v expected=%v", workflow.Path, names, expected) + } + } + for _, excluded := range policy.ExcludedWorkflows { + expectedPaths[excluded] = true + data, err := root.ReadFile(filepath.FromSlash(excluded)) + if err != nil { + t.Fatal(err) + } + var actual struct { + On map[string]any `yaml:"on"` + } + if err := yaml.Unmarshal(data, &actual); err != nil { + t.Fatal(err) + } + if len(actual.On) != 1 { + t.Fatal("excluded release workflow has unexpected triggers") + } + if _, exists := actual.On["push"]; !exists { + t.Fatal("excluded workflow is not a post-acceptance release workflow") + } + } + directory, err := root.Open(".github/workflows") + if err != nil { + t.Fatal(err) + } + entries, err := directory.ReadDir(-1) + if closeErr := directory.Close(); closeErr != nil { + t.Fatal(closeErr) + } + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + name := ".github/workflows/" + entry.Name() + if (strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml")) && !expectedPaths[name] { + t.Fatalf("workflow is absent from admission policy: %s", name) + } + } +} + +// Confine fixture reads to the checkout, including symlink resolution. +func openRepositoryRoot(t *testing.T) *os.Root { + t.Helper() + root, err := os.OpenRoot("../..") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := root.Close(); err != nil { + t.Error(err) + } + }) + return root +} diff --git a/pkg/safepush/workflow_source.go b/pkg/safepush/workflow_source.go new file mode 100644 index 0000000..3036217 --- /dev/null +++ b/pkg/safepush/workflow_source.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package safepush + +import ( + "bytes" + "errors" + "io" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + maxWorkflowSourceBytes = 1 << 20 + maxWorkflowYAMLDepth = 64 + maxWorkflowYAMLNodes = 100000 +) + +// verifyWorkflowSource checks accepted, hash-verified workflow bytes. GitHub's +// REST step conclusion can be success after a failed outcome when the workflow +// permits continue-on-error, so successful API statuses alone are insufficient. +// This verifies that specific control; it does not prove test adequacy or the +// behavior of scripts/actions invoked by the workflow. +func verifyWorkflowSource(contents []byte) error { + if len(contents) == 0 || len(contents) > maxWorkflowSourceBytes { + return ErrGitHubSource + } + decoder := yaml.NewDecoder(bytes.NewReader(contents)) + var document yaml.Node + if decoder.Decode(&document) != nil { + return ErrGitHubSource + } + nodes := 0 + if !validWorkflowYAML(&document, 0, &nodes) || document.Kind != yaml.DocumentNode || + len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode { + return ErrGitHubSource + } + var trailing yaml.Node + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return ErrGitHubSource + } + jobs := workflowMappingValue(document.Content[0], "jobs") + if jobs == nil || jobs.Kind != yaml.MappingNode || len(jobs.Content) == 0 { + return ErrGitHubSource + } + for index := 1; index < len(jobs.Content); index += 2 { + job := jobs.Content[index] + if job.Kind != yaml.MappingNode || !workflowStopsOnError(job) { + return ErrGitHubSource + } + steps := workflowMappingValue(job, "steps") + if steps == nil || steps.Kind != yaml.SequenceNode || len(steps.Content) == 0 { + return ErrGitHubSource + } + for _, step := range steps.Content { + if step.Kind != yaml.MappingNode || !workflowStopsOnError(step) { + return ErrGitHubSource + } + } + } + return nil +} + +func workflowStopsOnError(mapping *yaml.Node) bool { + value := workflowMappingValue(mapping, "continue-on-error") + return value == nil || (value.Kind == yaml.ScalarNode && value.Tag == "!!bool" && + value.Style == 0 && value.Value == "false") +} + +func workflowMappingValue(mapping *yaml.Node, key string) *yaml.Node { + for index := 0; index < len(mapping.Content); index += 2 { + if strings.EqualFold(mapping.Content[index].Value, key) { + return mapping.Content[index+1] + } + } + return nil +} + +func validWorkflowYAML(node *yaml.Node, depth int, nodes *int) bool { + *nodes = *nodes + 1 + if depth > maxWorkflowYAMLDepth || *nodes > maxWorkflowYAMLNodes || node == nil || + node.Anchor != "" || node.Alias != nil { + return false + } + switch node.Kind { + case yaml.DocumentNode: + if depth != 0 || len(node.Content) != 1 { + return false + } + case yaml.MappingNode: + if node.Tag != "!!map" || len(node.Content)%2 != 0 { + return false + } + seen := make(map[string]bool, len(node.Content)/2) + for index := 0; index < len(node.Content); index += 2 { + key := node.Content[index] + if key.Kind != yaml.ScalarNode || key.Tag != "!!str" || !validJobName(key.Value) || key.Value == "<<" { + return false + } + identity := strings.ToLower(key.Value) + if seen[identity] { + return false + } + seen[identity] = true + } + case yaml.SequenceNode: + if node.Tag != "!!seq" { + return false + } + case yaml.ScalarNode: + if len(node.Content) != 0 { + return false + } + switch node.Tag { + case "!!str", "!!bool", "!!int", "!!float", "!!null": + default: + return false + } + default: + return false // Includes aliases, without traversing their targets. + } + for _, child := range node.Content { + if !validWorkflowYAML(child, depth+1, nodes) { + return false + } + } + return true +} diff --git a/pkg/safepush/workflow_source_test.go b/pkg/safepush/workflow_source_test.go new file mode 100644 index 0000000..19f72f1 --- /dev/null +++ b/pkg/safepush/workflow_source_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package safepush + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + "testing" +) + +func TestWorkflowSourceRequiresLiteralStopOnError(t *testing.T) { + for _, scope := range []string{"job", "step"} { + for _, value := range []string{ + "true", "TRUE", "False", "FALSE", "0", "no", "off", "null", "~", "", + "'false'", `"false"`, "${{ false }}", "${{ matrix.experimental }}", + "!!str false", "!!bool false", "!custom false", "&disabled false", "[false]", "{value: false}", + } { + t.Run(scope+"_"+value, func(t *testing.T) { + contents := "jobs:\n test:\n runs-on: ubuntu-latest\n" + if scope == "job" { + contents += " continue-on-error: " + value + "\n" + } + contents += " steps:\n - run: go test ./...\n" + if scope == "step" { + contents += " continue-on-error: " + value + "\n" + } + if err := verifyWorkflowSource([]byte(contents)); !errors.Is(err, ErrGitHubSource) { + t.Fatalf("unsafe control was accepted: %v", err) + } + }) + } + } + for _, contents := range []string{ + ghTestWorkflow, + "jobs: {test: {continue-on-error: false, steps: [{run: 'exit 0', continue-on-error: false}]}}", + "---\n'jobs': {'test': {'steps': [{'run': 'exit 0', 'continue-on-error': false}]}}\n...\n# end\n", + } { + if err := verifyWorkflowSource([]byte(contents)); err != nil { + t.Fatalf("literal stop-on-error workflow rejected: %v", err) + } + } +} + +func TestWorkflowSourceRejectsAmbiguousYAML(t *testing.T) { + safe := "jobs: {test: {steps: [{run: 'exit 0'}]}}\n" + cases := map[string]string{ + "empty": "", + "comments_only": "# empty\n", + "root_sequence": "[{jobs: {}}]", + "jobs_sequence": "jobs: []", + "jobs_expression": "jobs: ${{ matrix.jobs }}", + "empty_jobs": "jobs: {}", + "null_job": "jobs: {test: null}", + "no_steps": "jobs: {test: {runs-on: ubuntu-latest}}", + "empty_steps": "jobs: {test: {steps: []}}", + "step_scalar": "jobs: {test: {steps: ['exit 0']}}", + "step_mapping": "jobs: {test: {steps: {run: 'exit 0'}}}", + "steps_expression": "jobs: {test: {steps: '${{ matrix.steps }}'}}", + "second_document": safe + "---\n" + safe, + "empty_second_doc": safe + "---\n", + "malformed_trailer": safe + "[not valid", + "duplicate_root": safe + "jobs: {test: {steps: [{run: 'exit 1', continue-on-error: true}]}}\n", + "duplicate_job": "jobs: {test: {continue-on-error: true, continue-on-error: false, steps: [{run: 'exit 1'}]}}", + "duplicate_step": "jobs: {test: {steps: [{continue-on-error: true, continue-on-error: false, run: 'exit 1'}]}}", + "case_duplicate": "jobs: {test: {steps: [{continue-on-error: false, CONTINUE-ON-ERROR: true, run: 'exit 1'}]}}", + "case_control": "jobs: {test: {steps: [{CONTINUE-ON-ERROR: true, run: 'exit 1'}]}}", + "escaped_key": `jobs: {test: {steps: [{"continue\u002don-error": true, run: 'exit 1'}]}}`, + "merge_job": "jobs: {test: {<<: {continue-on-error: true}, steps: [{run: 'exit 1'}]}}", + "quoted_merge": "jobs: {test: {'<<': {continue-on-error: true}, steps: [{run: 'exit 1'}]}}", + "merge_step": "jobs: {test: {steps: [{<<: {continue-on-error: true}, run: 'exit 1'}]}}", + "anchor_only": "defaults: &settings {continue-on-error: true}\n" + safe, + "alias_false": "setting: &disabled false\njobs: {test: {steps: [{run: 'exit 0', continue-on-error: *disabled}]}}", + "alias_steps": "setting: &steps [{run: 'exit 1', continue-on-error: true}]\njobs: {test: {steps: *steps}}", + "alias_cycle": "setting: &cycle [*cycle]\n" + safe, + "alias_expansion": "a: &a [false,false,false]\nb: &b [*a,*a,*a]\nc: [*b,*b,*b]\n" + safe, + "nonstring_key": "true: ignored\n" + safe, + "complex_key": "? [jobs, steps]\n: ignored\n" + safe, + "custom_mapping": "jobs: !custom {test: {steps: [{run: 'exit 0'}]}}", + "block_string": "jobs:\n test:\n continue-on-error: |\n false\n steps:\n - run: exit 0\n", + } + for name, contents := range cases { + t.Run(name, func(t *testing.T) { + if err := verifyWorkflowSource([]byte(contents)); !errors.Is(err, ErrGitHubSource) { + t.Fatalf("ambiguous YAML accepted: %v", err) + } + }) + } +} + +func TestWorkflowSourceParsingIsBounded(t *testing.T) { + if err := verifyWorkflowSource([]byte(strings.Repeat(" ", maxWorkflowSourceBytes+1))); !errors.Is(err, ErrGitHubSource) { + t.Fatal("oversized workflow accepted") + } + deep := "env: {nested: " + strings.Repeat("[", maxWorkflowYAMLDepth+1) + "false" + strings.Repeat("]", maxWorkflowYAMLDepth+1) + "}\n" + ghTestWorkflow + if err := verifyWorkflowSource([]byte(deep)); !errors.Is(err, ErrGitHubSource) { + t.Fatal("overly deep workflow accepted") + } + var many strings.Builder + many.WriteString("env: {") + for index := 0; index <= maxWorkflowYAMLNodes/2; index++ { + _, _ = fmt.Fprintf(&many, "k%d: 0,", index) + } + many.WriteString("}\n") + many.WriteString(ghTestWorkflow) + if many.Len() > maxWorkflowSourceBytes { + t.Fatal("node-limit fixture must exercise nodes rather than byte size") + } + if err := verifyWorkflowSource([]byte(many.String())); !errors.Is(err, ErrGitHubSource) { + t.Fatal("too many workflow nodes accepted") + } +} + +func TestWorkflowSourceAcceptsCurrentRequiredWorkflows(t *testing.T) { + root := openRepositoryRoot(t) + for _, name := range []string{"ci.yml", "cla-dco.yml", "license.yml", "security.yml", "supply-chain.yml"} { + t.Run(name, func(t *testing.T) { + contents, err := root.ReadFile(filepath.Join(".github", "workflows", name)) + if err != nil { + t.Fatal(err) + } + if err = verifyWorkflowSource(contents); err != nil { + t.Fatalf("current required workflow rejected: %v", err) + } + }) + } +} From 0cfe2935d6a8147ad609ae3bb3ce7c041b85a2ac Mon Sep 17 00:00:00 2001 From: Iurii Trukhin Date: Sun, 8 Nov 2026 22:27:58 +0100 Subject: [PATCH 2/2] feat(sp01): add RSI promotion gate, 32 slice oracles and parallel slice delivery contract SP01 adds the guardrails that let a delivery slice be accepted against a pre-approved oracle instead of waiting on a per-slice manual sign-off: - pkg/rsi: deterministic promotion gate (G1-G7) with change classes K0-K4, protected control files, two-slot last-known-good rollback invariants (S1-S6), mutation, holdout and seeded-error thresholds, and a kill switch that lowers the autonomy ceiling when the change failure rate is exceeded. - pkg/sliceoracles and contracts/slice-oracles/oracles-32.json: the batch of 32 slice oracles (C01-C28, SP01, DBAAS, DNSAAS, FILESAAS), the split of hardware-gated slices, and validation that two concurrent write PRs own disjoint paths. - roadmap: C15-C17, C20 and the standalone OCS services split into CORE (after C11) and QUAL (after C13 and C14); C24 follows C14; at most two concurrent write PRs per repository for distinct slices. --- .../fixtures/compliant-k3-profile.json | 124 ++ .../fixtures/tampered-k4-profile.json | 70 + contracts/slice-oracles/oracles-32.json | 1117 +++++++++++++++ pkg/rsi/rsi.go | 1192 +++++++++++++++++ pkg/rsi/rsi_fuzz_test.go | 61 + pkg/rsi/rsi_test.go | 664 +++++++++ pkg/sliceoracles/oracles.go | 399 ++++++ pkg/sliceoracles/oracles_test.go | 193 +++ roadmap/DELIVERY_SLICES.md | 34 +- roadmap/EXECUTION_CONTRACT.md | 12 +- 10 files changed, 3852 insertions(+), 14 deletions(-) create mode 100644 contracts/rsi-governance/fixtures/compliant-k3-profile.json create mode 100644 contracts/rsi-governance/fixtures/tampered-k4-profile.json create mode 100644 contracts/slice-oracles/oracles-32.json create mode 100644 pkg/rsi/rsi.go create mode 100644 pkg/rsi/rsi_fuzz_test.go create mode 100644 pkg/rsi/rsi_test.go create mode 100644 pkg/sliceoracles/oracles.go create mode 100644 pkg/sliceoracles/oracles_test.go diff --git a/contracts/rsi-governance/fixtures/compliant-k3-profile.json b/contracts/rsi-governance/fixtures/compliant-k3-profile.json new file mode 100644 index 0000000..e2ee7ed --- /dev/null +++ b/contracts/rsi-governance/fixtures/compliant-k3-profile.json @@ -0,0 +1,124 @@ +{ + "apiVersion": "cloudring.org/rsi-governance/v1", + "kind": "RSICandidateProfile", + "candidate": { + "id": "cand-k3-rsi-reference-01", + "declaredClass": "K3", + "changedPaths": [ + "pkg/rsi/rsi.go", + "cmd/cloudringctl/commands.go" + ], + "readOnlyOnly": false, + "reversibleConfigOnly": false, + "deletedOrSkippedTests": 0, + "modifiedExistingTests": false, + "modifiedTestdata": false, + "humanK4ApprovalReference": "" + }, + "gates": { + "g1": { + "evaluatorFrozenHashVerified": true, + "suitePassed": true, + "newTestFailedBeforeFix": true, + "newTestPassedAfterFix": true, + "rerunMaskingUsed": false, + "silentErrorSwallowing": 0, + "unseenHoldoutPassed": true, + "seededErrorRejected": true + }, + "g2": { + "killedMutants": 19, + "totalMutants": 20, + "fuzzTargetsPassed": true + }, + "g3": { + "sastFindings": 0, + "vulnFindings": 0, + "externalCapabilityGate": true, + "promptDelimiterOnlySecurity": false, + "explanationOnlyApproval": false + }, + "g4": { + "activeCanaries": 1, + "canaryStepPercent": "10", + "canaryMaxPercent": "50", + "requestSuccessRatePercent": "99.5", + "latencyP99Ms": "210", + "canaryDurationMinutes": "10", + "canaryFailuresLast24h": 0, + "touchesPersistentState": true, + "downgradeContinuityVerified": true + }, + "g5": { + "portabilityDrillPassed": true, + "dataActVerdict": "pass" + }, + "g6": { + "aiFailedDeployments": 1, + "aiTotalDeployments": 20, + "humanFailedDeployments": 2, + "humanTotalDeployments": 20 + }, + "g7": { + "taskCostUsd": "1.80", + "medianTaskCostUsd": "1.50", + "taskDurationMinutes": "12", + "executorModelFamily": "family-a", + "overseerModelFamily": "family-b" + } + }, + "voi": { + "priorSuccessProb": "2/5", + "successNetPayoffEur": "120000", + "failureLossEur": "60000", + "noActionPayoffEur": "0", + "testCostEur": "4000", + "testSensitivity": "4/5", + "testSpecificity": "3/4", + "hardLossCapEur": "80000", + "correlatedLlmRunsCount": 3 + }, + "passK": { + "k": 4, + "tasks": [ + {"taskId": "t1", "trials": 5, "successes": 5}, + {"taskId": "t2", "trials": 5, "successes": 4}, + {"taskId": "t3", "trials": 5, "successes": 3}, + {"taskId": "t4", "trials": 5, "successes": 0} + ], + "cheapModelCostUsd": "0.80", + "verifierCostUsd": "0.30", + "strongModelCostUsd": "4.00", + "gateFailRate": "0.45", + "strongRetryRate": "0.10", + "humanCodingHours": "40", + "humanReviewHours": "28", + "humanReworkHours": "12", + "acceptedTasks": 14, + "attemptedTasks": 20 + }, + "experiment": { + "controlAssignedUnits": 10000, + "treatmentAssignedUnits": 10000, + "clustersPerArm": 20, + "unitsPerCluster": 500, + "intraclusterCorrelation": "0.05", + "naiveZStat": "2.50", + "hasPostTreatmentCovariate": false, + "observedDiff": "0.02", + "diffStandardError": "0.01", + "nonInferiorityMargin": "0.05" + }, + "discovery": { + "hardConstraintsIntact": true, + "totalInterviewedPeople": 12, + "independentAccounts": 10, + "hasTechnicalUserEvidence": true, + "hasEconomicBuyerEvidence": true, + "hasVetoHolderEvidence": true, + "usesMechanicalWtpDivisor": false, + "usesRiceToOverrideSecurity": false, + "externalContactOwnerApproved": true, + "hasConsequentialCommitment": true + } +} diff --git a/contracts/rsi-governance/fixtures/tampered-k4-profile.json b/contracts/rsi-governance/fixtures/tampered-k4-profile.json new file mode 100644 index 0000000..6c713a0 --- /dev/null +++ b/contracts/rsi-governance/fixtures/tampered-k4-profile.json @@ -0,0 +1,70 @@ +{ + "apiVersion": "cloudring.org/rsi-governance/v1", + "kind": "RSICandidateProfile", + "candidate": { + "id": "cand-k4-tampered-01", + "declaredClass": "K2", + "changedPaths": [ + "pkg/controlplane/server.go", + ".github/workflows/ci.yml" + ], + "readOnlyOnly": false, + "reversibleConfigOnly": false, + "deletedOrSkippedTests": 2, + "modifiedExistingTests": true, + "modifiedTestdata": true, + "humanK4ApprovalReference": "" + }, + "gates": { + "g1": { + "evaluatorFrozenHashVerified": false, + "suitePassed": true, + "newTestFailedBeforeFix": false, + "newTestPassedAfterFix": true, + "rerunMaskingUsed": true, + "silentErrorSwallowing": 1, + "unseenHoldoutPassed": false, + "seededErrorRejected": false + }, + "g2": { + "killedMutants": 10, + "totalMutants": 20, + "fuzzTargetsPassed": false + }, + "g3": { + "sastFindings": 2, + "vulnFindings": 1, + "externalCapabilityGate": false, + "promptDelimiterOnlySecurity": true, + "explanationOnlyApproval": true + }, + "g4": { + "activeCanaries": 2, + "canaryStepPercent": "25", + "canaryMaxPercent": "80", + "requestSuccessRatePercent": "95.0", + "latencyP99Ms": "750", + "canaryDurationMinutes": "30", + "canaryFailuresLast24h": 3, + "touchesPersistentState": true, + "downgradeContinuityVerified": false + }, + "g5": { + "portabilityDrillPassed": false, + "dataActVerdict": "fail" + }, + "g6": { + "aiFailedDeployments": 5, + "aiTotalDeployments": 20, + "humanFailedDeployments": 1, + "humanTotalDeployments": 20 + }, + "g7": { + "taskCostUsd": "14.00", + "medianTaskCostUsd": "2.00", + "taskDurationMinutes": "45", + "executorModelFamily": "family-b", + "overseerModelFamily": "family-b" + } + } +} diff --git a/contracts/slice-oracles/oracles-32.json b/contracts/slice-oracles/oracles-32.json new file mode 100644 index 0000000..5b49eec --- /dev/null +++ b/contracts/slice-oracles/oracles-32.json @@ -0,0 +1,1117 @@ +{ + "apiVersion": "cloudring.org/slice-oracles/v1", + "kind": "SliceOracleBatch", + "decisionForks": { + "fork1": "1A_batch_32_oracles_autonomous_ver_acceptance_weekly_audit", + "fork2": "2A_split_core_qual_c24_after_c14_c20_no_c19_max_2_write_prs", + "fork3": "3_deferred_no_new_hardware_now_execute_no_hw_subgraph_via_rsi" + }, + "maxConcurrentWritePRsPerRepo": 2, + "ownerAcceptanceLatencyDays": 0, + "weeklyOwnerAuditHours": 1, + "lkgInvariants": [ + "S1_immutable_bootloader", + "S2_two_pinned_lkg_slots_30m_soak", + "S3_webhook_rollback_bypass", + "S4_pre_migration_state_snapshot", + "S5_meta_rsi_noop_self_check", + "S6_incident_sha_quarantine" + ], + "oracles": [ + { + "sliceId": "C01", + "title": "Reproducible public delivery baseline (partial G00)", + "goals": [ + "G00" + ], + "splitMode": false, + "coreDependencies": [], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "internal/roadmapprogram", + "internal/sourcecheck", + "roadmap" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C01.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "7.4", + "daysFactory": "1.4" + }, + { + "sliceId": "SP01", + "title": "SafePush enablement / provenance admission", + "goals": [ + "G00" + ], + "splitMode": false, + "coreDependencies": [ + "C01" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/rsi", + "pkg/sliceoracles", + "contracts/slice-oracles" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/SP01.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "14.9", + "daysFactory": "2.8" + }, + { + "sliceId": "C02", + "title": "Public isolated developer VM install (full G01)", + "goals": [ + "G01" + ], + "splitMode": false, + "coreDependencies": [ + "C01", + "SP01" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "cmd/cloudringctl", + "internal/portal" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C02.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "10.8", + "daysFactory": "2.0" + }, + { + "sliceId": "C03", + "title": "Durable project/operation API, CLI, read-only UI (partial G03)", + "goals": [ + "G03" + ], + "splitMode": false, + "coreDependencies": [ + "C02" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/transactionalstate", + "internal/api" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C03.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "19.3", + "daysFactory": "3.6" + }, + { + "sliceId": "C04", + "title": "Two developer tenants identity + isolation (partial G04/G05)", + "goals": [ + "G04", + "G05" + ], + "splitMode": false, + "coreDependencies": [ + "C03" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/identity", + "pkg/iam" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C04.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "36.5", + "daysFactory": "6.8" + }, + { + "sliceId": "C05", + "title": "Real developer VM through public Go/OCS (partial G07-G09, G15)", + "goals": [ + "G07", + "G08", + "G09", + "G15" + ], + "splitMode": false, + "coreDependencies": [ + "C04", + "C06" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/compute", + "sdk/ocsv3" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C05.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "42.1", + "daysFactory": "7.8" + }, + { + "sliceId": "C06", + "title": "Reference installation restore from off-cell copies (prep G02,G17,G18,G22,G23)", + "goals": [ + "G02", + "G17", + "G18", + "G22", + "G23" + ], + "splitMode": false, + "coreDependencies": [ + "C01" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/backup", + "pkg/dataact" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C06.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "44.1", + "daysFactory": "8.2" + }, + { + "sliceId": "C07", + "title": "GitOps origin/API routing, IPv4/IPv6 TLS (prep G02,G12,G23)", + "goals": [ + "G02", + "G12", + "G23" + ], + "splitMode": false, + "coreDependencies": [ + "C06" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/Ingress", + "pkg/network" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C07.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "26.9", + "daysFactory": "5.0" + }, + { + "sliceId": "C08", + "title": "Independent public HA empty-provider install (full G00+G02)", + "goals": [ + "G00", + "G02" + ], + "splitMode": false, + "coreDependencies": [ + "C02", + "C06", + "C07" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/kubeadm", + "pkg/installer" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C08.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "70.6", + "daysFactory": "13.1" + }, + { + "sliceId": "C09", + "title": "Production kernel, identity, IAM (full G03-G05)", + "goals": [ + "G03", + "G04", + "G05" + ], + "splitMode": false, + "coreDependencies": [ + "C03", + "C04", + "C05", + "C08" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/kernel", + "pkg/directory" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C09.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "74.4", + "daysFactory": "13.8" + }, + { + "sliceId": "C10", + "title": "Provider inventory/adoption/executor, two adapters (full G06)", + "goals": [ + "G06" + ], + "splitMode": false, + "coreDependencies": [ + "C09" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/providerports", + "pkg/inventory" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C10.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "46.0", + "daysFactory": "8.5" + }, + { + "sliceId": "C11", + "title": "OCS RC + registry, independent product from SDK (full G07+G08)", + "goals": [ + "G07", + "G08" + ], + "splitMode": false, + "coreDependencies": [ + "C05", + "C10" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/ocs", + "cmd/ocsctl", + "sdk/rust/ocs-sdk-rs" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C11.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "66.9", + "daysFactory": "12.4" + }, + { + "sliceId": "C12", + "title": "Orders/subscriptions/entitlements lifecycle (full G09)", + "goals": [ + "G09" + ], + "splitMode": false, + "coreDependencies": [ + "C11" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/orders", + "pkg/subscriptions" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C12.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "30.4", + "daysFactory": "5.6" + }, + { + "sliceId": "C13", + "title": "Metering/rating/billing ledger (full G10)", + "goals": [ + "G10" + ], + "splitMode": false, + "coreDependencies": [ + "C12" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/billing", + "pkg/metering" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C13.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "70.3", + "daysFactory": "13.0" + }, + { + "sliceId": "C14", + "title": "Equivalent API/CLI/portal/agent experience (full G11)", + "goals": [ + "G11" + ], + "splitMode": false, + "coreDependencies": [ + "C13" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "internal/portal", + "cmd/cloudringctl" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C14.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "34.5", + "daysFactory": "6.4" + }, + { + "sliceId": "C15", + "title": "OCS Network product (full G12)", + "goals": [ + "G12" + ], + "splitMode": true, + "coreDependencies": [ + "C11", + "C07" + ], + "qualDependencies": [ + "C13", + "C14" + ], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/network" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C15.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "34.5", + "daysFactory": "6.4" + }, + { + "sliceId": "C16", + "title": "OCS Volume product (full G13)", + "goals": [ + "G13" + ], + "splitMode": true, + "coreDependencies": [ + "C11", + "C08" + ], + "qualDependencies": [ + "C13", + "C14" + ], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/volume" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C16.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "36.5", + "daysFactory": "6.8" + }, + { + "sliceId": "C17", + "title": "OCS Image/Artifact product (full G14)", + "goals": [ + "G14" + ], + "splitMode": true, + "coreDependencies": [ + "C11", + "C16" + ], + "qualDependencies": [ + "C13", + "C14" + ], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/image" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C17.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "40.6", + "daysFactory": "7.5" + }, + { + "sliceId": "C18", + "title": "Complete Compute via Network/Volume/Image (full G15)", + "goals": [ + "G15" + ], + "splitMode": false, + "coreDependencies": [ + "C15", + "C16", + "C17" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/compute" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C18.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "28.9", + "daysFactory": "5.3" + }, + { + "sliceId": "C19", + "title": "Managed tenant Kubernetes (full G16)", + "goals": [ + "G16" + ], + "splitMode": false, + "coreDependencies": [ + "C18" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/kubernetes" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C19.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "38.5", + "daysFactory": "7.1" + }, + { + "sliceId": "C20", + "title": "Object storage S3 product (full G17)", + "goals": [ + "G17" + ], + "splitMode": true, + "coreDependencies": [ + "C11", + "C16" + ], + "qualDependencies": [ + "C13", + "C14" + ], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/objectstorage" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C20.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "34.5", + "daysFactory": "6.4" + }, + { + "sliceId": "C21", + "title": "Customer self-service backup (full G18)", + "goals": [ + "G18" + ], + "splitMode": false, + "coreDependencies": [ + "C19", + "C20" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/backup" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C21.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "32.4", + "daysFactory": "6.0" + }, + { + "sliceId": "C22", + "title": "Controlled access product (full G19)", + "goals": [ + "G19" + ], + "splitMode": false, + "coreDependencies": [ + "C19", + "C21" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/access" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C22.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "38.9", + "daysFactory": "7.2" + }, + { + "sliceId": "C23", + "title": "Support product (full G20)", + "goals": [ + "G20" + ], + "splitMode": false, + "coreDependencies": [ + "C22" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/support" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C23.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "43.3", + "daysFactory": "8.0" + }, + { + "sliceId": "C24", + "title": "External integration product (full G21)", + "goals": [ + "G21" + ], + "splitMode": false, + "coreDependencies": [ + "C14" + ], + "qualDependencies": [], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/external" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C24.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "43.3", + "daysFactory": "8.0" + }, + { + "sliceId": "C25", + "title": "Resilience campaign on adjacent signed releases (full G23)", + "goals": [ + "G23" + ], + "splitMode": false, + "coreDependencies": [ + "C01", + "SP01", + "C02", + "C03", + "C04", + "C05", + "C06", + "C07", + "C08", + "C09", + "C10", + "C11", + "C12", + "C13", + "C14", + "C15", + "C16", + "C17", + "C18", + "C19", + "C20", + "C21", + "C22", + "C23", + "C24", + "DBAAS", + "DNSAAS", + "FILESAAS" + ], + "qualDependencies": [], + "requiresFork3Hardware": true, + "allowedPaths": [ + "pkg/resilience" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C25.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "32.9", + "daysFactory": "6.1" + }, + { + "sliceId": "C26", + "title": "Independent engineer operations, 14-day toil (full G22)", + "goals": [ + "G22" + ], + "splitMode": false, + "coreDependencies": [ + "C25" + ], + "qualDependencies": [], + "requiresFork3Hardware": true, + "allowedPaths": [ + "pkg/operations" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C26.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "36.5", + "daysFactory": "6.8" + }, + { + "sliceId": "C27", + "title": "Second bare-metal provider reproduction (full G24)", + "goals": [ + "G24" + ], + "splitMode": false, + "coreDependencies": [ + "C26" + ], + "qualDependencies": [], + "requiresFork3Hardware": true, + "allowedPaths": [ + "pkg/portability" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C27.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "46.0", + "daysFactory": "8.5" + }, + { + "sliceId": "C28", + "title": "Final audit and 1.0 release (full G27)", + "goals": [ + "G27" + ], + "splitMode": false, + "coreDependencies": [ + "C01", + "SP01", + "C02", + "C03", + "C04", + "C05", + "C06", + "C07", + "C08", + "C09", + "C10", + "C11", + "C12", + "C13", + "C14", + "C15", + "C16", + "C17", + "C18", + "C19", + "C20", + "C21", + "C22", + "C23", + "C24", + "C25", + "C26", + "C27", + "DBAAS", + "DNSAAS", + "FILESAAS" + ], + "qualDependencies": [], + "requiresFork3Hardware": true, + "allowedPaths": [ + "roadmap/state" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/C28.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "54.1", + "daysFactory": "10.0" + }, + { + "sliceId": "DBAAS", + "title": "OCS Managed PostgreSQL (CNPG) product", + "goals": [ + "G07", + "G10", + "G18" + ], + "splitMode": true, + "coreDependencies": [ + "C11", + "C16" + ], + "qualDependencies": [ + "C13", + "C14", + "C21" + ], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/dbaas" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/DBAAS.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "34.5", + "daysFactory": "6.4" + }, + { + "sliceId": "DNSAAS", + "title": "OCS Managed DNS product", + "goals": [ + "G07", + "G10", + "G12" + ], + "splitMode": true, + "coreDependencies": [ + "C11", + "C07" + ], + "qualDependencies": [ + "C13", + "C14", + "C15" + ], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/dnsaas" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/DNSAAS.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "34.5", + "daysFactory": "6.4" + }, + { + "sliceId": "FILESAAS", + "title": "OCS Shared File Storage (RWX NFS/CephFS) product", + "goals": [ + "G07", + "G10", + "G13" + ], + "splitMode": true, + "coreDependencies": [ + "C11", + "C16" + ], + "qualDependencies": [ + "C13", + "C14" + ], + "requiresFork3Hardware": false, + "allowedPaths": [ + "pkg/products/filesaas" + ], + "autonomyClass": "K2", + "verificationCommand": "go test ./... && cloudringctl rsi evaluate --profile contracts/slice-oracles/profiles/FILESAAS.json", + "requiredGates": [ + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "S1_S6_LKG" + ], + "daysHist": "34.5", + "daysFactory": "6.4" + } + ] +} diff --git a/pkg/rsi/rsi.go b/pkg/rsi/rsi.go new file mode 100644 index 0000000..f2d20d8 --- /dev/null +++ b/pkg/rsi/rsi.go @@ -0,0 +1,1192 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +// Package rsi implements CloudRING's scientifically grounded Recursive +// Self-Improvement (RSI) governance and decision-science evaluator. It +// classifies candidate changes into autonomy classes K0-K4, evaluates the +// mandatory safety and quality gates G1-G7 under an external frozen evaluator +// contract, computes exact rational Value of Information (VOI / EVSI), +// unbiased task-averaged combinatorial pass^k and pass@k reliability, +// cluster-robust experimental design diagnostics (SRM, DEFF, non-inferiority, +// TOST), and Theory-of-Value stage-gate validation. +package rsi + +import ( + "errors" + "fmt" + "math" + "math/big" + "path" + "sort" + "strings" + + "github.com/opencloudtech/CloudRING/internal/strictjson" +) + +const ( + ProfileAPIVersion = "cloudring.org/rsi-governance/v1" + ProfileKind = "RSICandidateProfile" + ReportSchema = "cloudring.rsi-governance-report/v1" + + ClassK0 = "K0" + ClassK1 = "K1" + ClassK2 = "K2" + ClassK3 = "K3" + ClassK4 = "K4" + + VerdictPass = "pass" + VerdictFail = "fail" + + StatusPass = "pass" + StatusFail = "fail" + + KillSwitchNone = "none" + KillSwitchL1 = "L1_suspend_gitops_canary" + KillSwitchL2 = "L2_revoke_gateway_openbao_leases" + KillSwitchL3 = "L3_vap_ai_freeze_deny" + + maxProfileBytes = 1 << 20 +) + +var ( + ErrInvalidProfile = errors.New("rsi: invalid candidate profile") + + minMutationKillScore = big.NewRat(4, 5) // 80% diff-based mutation kill threshold (G2) + maxCanaryStepPct = big.NewRat(10, 100) // 10% max initial/step canary weight for AI (G4) + maxCanaryWeightPct = big.NewRat(50, 100) // 50% max canary weight before promotion (G4) + minCanarySuccessRate = big.NewRat(99, 100) // 99% minimum request success rate (G4) + maxCanaryLatencyMs = big.NewRat(500, 1) // 500 ms maximum p99 latency during canary (G4) + sloMonthlyBudgetMin = big.NewRat(432, 10) // 43.2 minutes (99.9% over 30 days) + maxCanarySLOBurnFrac = big.NewRat(1, 10) // at most 10% of monthly error budget per canary + maxAICFR = big.NewRat(3, 20) // 15% maximum rolling AI Change Fail Rate (G6) + maxTaskCostUSD = big.NewRat(10, 1) // $10.00 default cap per autonomous task (G7) + maxTaskDurationMin = big.NewRat(30, 1) // 30 minutes default cap per autonomous task (G7) + maxCostBurnMult = big.NewRat(2, 1) // 2x median task cost burn trigger (G7) + srmChiSquareCrit = big.NewRat(108276, 10000) // chi^2(1) p=0.001 critical value ~10.8276 +) + +// CandidateProfile declares the observable verification receipts and decision +// inputs for a candidate change evaluated by the external frozen evaluator. +type CandidateProfile struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Candidate CandidateMetadata `json:"candidate"` + Gates GateReceipts `json:"gates"` + VOI *VOIInput `json:"voi,omitempty"` + PassK *PassKInput `json:"passK,omitempty"` + Experiment *ExperimentInput `json:"experiment,omitempty"` + Discovery *DiscoveryInput `json:"discovery,omitempty"` +} + +// CandidateMetadata identifies the diff and declared target class. +type CandidateMetadata struct { + ID string `json:"id"` + DeclaredClass string `json:"declaredClass"` + ChangedPaths []string `json:"changedPaths"` + ReadOnlyOnly bool `json:"readOnlyOnly"` + ReversibleConfigOnly bool `json:"reversibleConfigOnly"` + DeletedOrSkippedTests int `json:"deletedOrSkippedTests"` + ModifiedExistingTests bool `json:"modifiedExistingTests"` + ModifiedTestdata bool `json:"modifiedTestdata"` + HumanK4ApprovalReference string `json:"humanK4ApprovalReference"` +} + +// GateReceipts holds externally measured G1-G7 evidence (never self-reported by the candidate). +type GateReceipts struct { + G1 G1Receipt `json:"g1"` + G2 G2Receipt `json:"g2"` + G3 G3Receipt `json:"g3"` + G4 G4Receipt `json:"g4"` + G5 G5Receipt `json:"g5"` + G6 G6Receipt `json:"g6"` + G7 G7Receipt `json:"g7"` +} + +// G1Receipt captures test suite execution and anti-tampering checks. +type G1Receipt struct { + EvaluatorFrozenHashVerified bool `json:"evaluatorFrozenHashVerified"` + SuitePassed bool `json:"suitePassed"` + NewTestFailedBeforeFix bool `json:"newTestFailedBeforeFix"` + NewTestPassedAfterFix bool `json:"newTestPassedAfterFix"` + RerunMaskingUsed bool `json:"rerunMaskingUsed"` + SilentErrorSwallowing int `json:"silentErrorSwallowing"` + UnseenHoldoutPassed bool `json:"unseenHoldoutPassed"` + SeededErrorRejected bool `json:"seededErrorRejected"` +} + +// G2Receipt captures diff-based mutation testing on changed non-arid lines and fuzzing. +type G2Receipt struct { + KilledMutants int `json:"killedMutants"` + TotalMutants int `json:"totalMutants"` + FuzzTargetsPassed bool `json:"fuzzTargetsPassed"` +} + +// G3Receipt captures static security analysis, vulnerability scanning, and agent capability boundaries. +type G3Receipt struct { + SASTFindings int `json:"sastFindings"` + VulnFindings int `json:"vulnFindings"` + ExternalCapabilityGate bool `json:"externalCapabilityGate"` + PromptDelimiterOnlySecurity bool `json:"promptDelimiterOnlySecurity"` + ExplanationOnlyApproval bool `json:"explanationOnlyApproval"` +} + +// G4Receipt captures progressive canary parameters and stateful downgrade continuity. +type G4Receipt struct { + ActiveCanaries int `json:"activeCanaries"` + CanaryStepPercent string `json:"canaryStepPercent"` + CanaryMaxPercent string `json:"canaryMaxPercent"` + RequestSuccessRatePercent string `json:"requestSuccessRatePercent"` + LatencyP99Ms string `json:"latencyP99Ms"` + CanaryDurationMinutes string `json:"canaryDurationMinutes"` + CanaryFailuresLast24h int `json:"canaryFailuresLast24h"` + TouchesPersistentState bool `json:"touchesPersistentState"` + DowngradeContinuityVerified bool `json:"downgradeContinuityVerified"` +} + +// G5Receipt captures tenant portability and EU Data Act switching drill verification. +type G5Receipt struct { + PortabilityDrillPassed bool `json:"portabilityDrillPassed"` + DataActVerdict string `json:"dataActVerdict"` +} + +// G6Receipt captures rolling DORA Change Fail Rate (CFR) for AI and human deployment lines. +type G6Receipt struct { + AIFailedDeployments int `json:"aiFailedDeployments"` + AITotalDeployments int `json:"aiTotalDeployments"` + HumanFailedDeployments int `json:"humanFailedDeployments"` + HumanTotalDeployments int `json:"humanTotalDeployments"` +} + +// G7Receipt captures compute budget, wall-clock duration, and cross-family overseer isolation. +type G7Receipt struct { + TaskCostUSD string `json:"taskCostUsd"` + MedianTaskCostUSD string `json:"medianTaskCostUsd"` + TaskDurationMinutes string `json:"taskDurationMinutes"` + ExecutorModelFamily string `json:"executorModelFamily"` + OverseerModelFamily string `json:"overseerModelFamily"` +} + +// VOIInput defines a Bayesian Value of Information (EVPI / EVSI) decision problem (C-20). +type VOIInput struct { + PriorSuccessProb string `json:"priorSuccessProb"` + SuccessNetPayoffEUR string `json:"successNetPayoffEur"` + FailureLossEUR string `json:"failureLossEur"` + NoActionPayoffEUR string `json:"noActionPayoffEur"` + TestCostEUR string `json:"testCostEur"` + TestSensitivity string `json:"testSensitivity"` + TestSpecificity string `json:"testSpecificity"` + HardLossCapEUR string `json:"hardLossCapEur"` + CorrelatedLLMRunsCount int `json:"correlatedLlmRunsCount"` +} + +// VOIReport holds exact rational Bayesian decision and EVSI results. +type VOIReport struct { + NoTestBestAction string `json:"noTestBestAction"` + LaunchNowEVEUR string `json:"launchNowEvEur"` + NoTestBestEVEUR string `json:"noTestBestEvEur"` + PositiveTestProb string `json:"positiveTestProb"` + PosteriorSuccessGivenPos string `json:"posteriorSuccessGivenPos"` + PosteriorSuccessGivenNeg string `json:"posteriorSuccessGivenNeg"` + EVPIEUR string `json:"evpiEur"` + GrossPolicyEVEUR string `json:"grossPolicyEvEur"` + GrossEVSIEUR string `json:"grossEvsiEur"` + NetPolicyEVEUR string `json:"netPolicyEvEur"` + NetIncrementalEVEUR string `json:"netIncrementalEvEur"` + FeasibleUnderLossCap bool `json:"feasibleUnderLossCap"` + EffectiveNetEVSIUnderCap string `json:"effectiveNetEvsiUnderCapEur"` + ExtremizationRejected bool `json:"extremizationRejected"` + RecommendedAction string `json:"recommendedAction"` +} + +// TaskTrialCount records n trials and c successes on task i. +type TaskTrialCount struct { + TaskID string `json:"taskId"` + Trials int `json:"trials"` + Successes int `json:"successes"` +} + +// PassKInput defines heterogeneous multi-trial agent evaluation and cascade economics (C-21). +type PassKInput struct { + K int `json:"k"` + Tasks []TaskTrialCount `json:"tasks"` + CheapModelCostUSD string `json:"cheapModelCostUsd"` + VerifierCostUSD string `json:"verifierCostUsd"` + StrongModelCostUSD string `json:"strongModelCostUsd"` + GateFailRate string `json:"gateFailRate"` + StrongRetryRate string `json:"strongRetryRate"` + HumanCodingHours string `json:"humanCodingHours"` + HumanReviewHours string `json:"humanReviewHours"` + HumanReworkHours string `json:"humanReworkHours"` + AcceptedTasks int `json:"acceptedTasks"` + AttemptedTasks int `json:"attemptedTasks"` +} + +// PassKReport holds unbiased combinatorial pass^k, pass@k, cascade cost, and labor per accepted task. +type PassKReport struct { + K int `json:"k"` + MeanPass1 string `json:"meanPass1"` + UnbiasedCombinatorialPassK string `json:"unbiasedCombinatorialPassK"` + UnbiasedCombinatorialPassAt string `json:"unbiasedCombinatorialPassAtK"` + NaiveHomogeneousPassK string `json:"naiveHomogeneousPassK"` + NaiveHomogeneousPassAtK string `json:"naiveHomogeneousPassAtK"` + FullCascadeCostPerTaskUSD string `json:"fullCascadeCostPerTaskUsd"` + NaiveCascadeCostPerTaskUSD string `json:"naiveCascadeCostPerTaskUsd"` + TotalHumanHours string `json:"totalHumanHours"` + HumanHoursPerAcceptedTask string `json:"humanHoursPerAcceptedTask"` + AcceptanceRate string `json:"acceptanceRate"` +} + +// ExperimentInput defines a B2B cluster/crossover experiment diagnostic check (C-19). +type ExperimentInput struct { + ControlAssignedUnits int `json:"controlAssignedUnits"` + TreatmentAssignedUnits int `json:"treatmentAssignedUnits"` + ClustersPerArm int `json:"clustersPerArm"` + UnitsPerCluster int `json:"unitsPerCluster"` + IntraclusterCorrelation string `json:"intraclusterCorrelation"` + NaiveZStat string `json:"naiveZStat"` + HasPostTreatmentCovariate bool `json:"hasPostTreatmentCovariate"` + ObservedDiff string `json:"observedDiff"` + DiffStandardError string `json:"diffStandardError"` + NonInferiorityMargin string `json:"nonInferiorityMargin"` +} + +// ExperimentReport holds SRM, cluster DEFF, and non-inferiority / TOST verdicts. +type ExperimentReport struct { + SRMChiSquare string `json:"srmChiSquare"` + SRMDetected bool `json:"srmDetected"` + DesignEffectDEFF string `json:"designEffectDeff"` + EffectiveUnitsPerArm string `json:"effectiveUnitsPerArm"` + SEInflationFactor string `json:"seInflationFactor"` + ClusterAdjustedTStat string `json:"clusterAdjustedTStat"` + ClusterSignificantAt005 bool `json:"clusterSignificantAt005"` + CUPEDValid bool `json:"cupedValid"` + NonInferiorityZ string `json:"nonInferiorityZ"` + TOSTUpperZ string `json:"tostUpperZ"` + NonInferiorityProvenAt005 bool `json:"nonInferiorityProvenAt005"` + TOSTEquivalenceProven005 bool `json:"tostEquivalenceProvenAt005"` + Verdict string `json:"verdict"` +} + +// DiscoveryInput defines a Theory-of-Value stage-gate validation check (C-18). +type DiscoveryInput struct { + HardConstraintsIntact bool `json:"hardConstraintsIntact"` + TotalInterviewedPeople int `json:"totalInterviewedPeople"` + IndependentAccounts int `json:"independentAccounts"` + HasTechnicalUserEvidence bool `json:"hasTechnicalUserEvidence"` + HasEconomicBuyerEvidence bool `json:"hasEconomicBuyerEvidence"` + HasVetoHolderEvidence bool `json:"hasVetoHolderEvidence"` + UsesMechanicalWTPDivisor bool `json:"usesMechanicalWtpDivisor"` + UsesRICEToOverrideSecurity bool `json:"usesRiceToOverrideSecurity"` + ExternalContactOwnerApproved bool `json:"externalContactOwnerApproved"` + HasConsequentialCommitment bool `json:"hasConsequentialCommitment"` +} + +// DiscoveryReport holds the Theory-of-Value stage-gate evaluation. +type DiscoveryReport struct { + AccountInflationDetected bool `json:"accountInflationDetected"` + RoleStratificationComplete bool `json:"roleStratificationComplete"` + MethodologicallyValid bool `json:"methodologicallyValid"` + StageGateAuthorized bool `json:"stageGateAuthorized"` + Reasons []string `json:"reasons"` +} + +// Classification describes the resolved autonomy class and escalation reasons. +type Classification struct { + DeclaredClass string `json:"declaredClass"` + EffectiveClass string `json:"effectiveClass"` + HumanApprovalRequired bool `json:"humanApprovalRequired"` + HoldoutRequired bool `json:"holdoutRequired"` + EscalationReasons []string `json:"escalationReasons"` +} + +// CheckResult records a single deterministic gate evaluation. +type CheckResult struct { + ID string `json:"id"` + Status string `json:"status"` + Summary string `json:"summary"` +} + +// Report is the complete machine-verifiable RSI governance and decision report. +type Report struct { + SchemaVersion string `json:"schemaVersion"` + APIVersion string `json:"apiVersion"` + CandidateID string `json:"candidateId"` + Classification Classification `json:"classification"` + Checks []CheckResult `json:"checks"` + Passed int `json:"passed"` + Failed int `json:"failed"` + MaxAutonomousClass string `json:"maxAutonomousClass"` + KillSwitchLevel string `json:"killSwitchLevel"` + KillSwitchReasons []string `json:"killSwitchReasons"` + MutationKillRatio string `json:"mutationKillRatio"` + AIChangeFailRate string `json:"aiChangeFailRate"` + HumanChangeFailRate string `json:"humanChangeFailRate"` + CanarySLOBurnFraction string `json:"canarySloBurnFraction"` + VOI *VOIReport `json:"voi,omitempty"` + PassK *PassKReport `json:"passK,omitempty"` + Experiment *ExperimentReport `json:"experiment,omitempty"` + Discovery *DiscoveryReport `json:"discovery,omitempty"` + Verdict string `json:"verdict"` +} + +// ParseProfile strictly decodes a CandidateProfile from JSON bytes. +func ParseProfile(raw []byte) (CandidateProfile, error) { + if len(raw) == 0 || len(raw) > maxProfileBytes { + return CandidateProfile{}, fmt.Errorf("%w: profile size out of bounds", ErrInvalidProfile) + } + var profile CandidateProfile + if err := strictjson.DecodeExact(raw, &profile); err != nil { + return CandidateProfile{}, fmt.Errorf("%w: %v", ErrInvalidProfile, err) + } + if profile.APIVersion != ProfileAPIVersion || profile.Kind != ProfileKind { + return CandidateProfile{}, fmt.Errorf("%w: expected apiVersion=%q kind=%q", ErrInvalidProfile, ProfileAPIVersion, ProfileKind) + } + if strings.TrimSpace(profile.Candidate.ID) == "" { + return CandidateProfile{}, fmt.Errorf("%w: candidate.id is required", ErrInvalidProfile) + } + if !validClass(profile.Candidate.DeclaredClass) { + return CandidateProfile{}, fmt.Errorf("%w: invalid declaredClass %q", ErrInvalidProfile, profile.Candidate.DeclaredClass) + } + if len(profile.Candidate.ChangedPaths) == 0 && !profile.Candidate.ReadOnlyOnly { + return CandidateProfile{}, fmt.Errorf("%w: changedPaths required for non-K0 candidate", ErrInvalidProfile) + } + return profile, nil +} + +func validClass(c string) bool { + switch c { + case ClassK0, ClassK1, ClassK2, ClassK3, ClassK4: + return true + default: + return false + } +} + +func classRank(c string) int { + switch c { + case ClassK0: + return 0 + case ClassK1: + return 1 + case ClassK2: + return 2 + case ClassK3: + return 3 + case ClassK4: + return 4 + default: + return 4 + } +} + +// ClassifyCandidate determines the effective change class K0-K4 and enforces +// DGM / ImpossibleBench anti-reward-hacking escalations. +func ClassifyCandidate(meta CandidateMetadata) (Classification, error) { + if !validClass(meta.DeclaredClass) { + return Classification{}, fmt.Errorf("%w: unknown class %q", ErrInvalidProfile, meta.DeclaredClass) + } + effective := meta.DeclaredClass + var reasons []string + + touchesProd := false + touchesTests := false + touchesAI := false + touchesK4 := false + + for _, rawPath := range meta.ChangedPaths { + lowerRaw := strings.ToLower(rawPath) + if strings.Contains(rawPath, "\x00") || strings.Contains(lowerRaw, "%2e") || strings.Contains(lowerRaw, "%2f") || strings.Contains(lowerRaw, "%5c") { + touchesK4 = true + reasons = append(reasons, fmt.Sprintf("encoded_or_null_path:%s", rawPath)) + continue + } + trimmed := strings.TrimSpace(rawPath) + if trimmed == "" || strings.Contains(trimmed, `\`) { + touchesK4 = true + reasons = append(reasons, fmt.Sprintf("invalid_or_backslash_path:%s", rawPath)) + continue + } + cleaned := path.Clean(trimmed) + if cleaned == "." || cleaned == "/" || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") || strings.Contains(trimmed, "..") { + touchesK4 = true + reasons = append(reasons, fmt.Sprintf("path_traversal_or_absolute:%s", rawPath)) + continue + } + lower := strings.ToLower(cleaned) + segments := strings.Split(lower, "/") + for _, seg := range segments { + if seg == ".github" || seg == "evaluator" || seg == "policies" || seg == "codeowners" || seg == "openbao" || seg == "grants" || seg == "verify" { + touchesK4 = true + reasons = append(reasons, fmt.Sprintf("protected_k4_path:%s", lower)) + break + } + } + if lower == "ops/stop" || strings.HasSuffix(lower, "/ops/stop") || strings.HasSuffix(lower, "codeowners") { + touchesK4 = true + reasons = append(reasons, fmt.Sprintf("protected_control_file:%s", lower)) + } + if strings.HasPrefix(lower, "pkg/rsi/") || lower == "pkg/rsi" || strings.Contains(lower, "/rsi/") || strings.HasPrefix(lower, "skills/") || strings.HasPrefix(lower, "agents/") || strings.HasPrefix(lower, "prompts/") { + touchesAI = true + } + if strings.HasSuffix(lower, "_test.go") || strings.Contains(lower, "/testdata/") || strings.HasSuffix(lower, "/testdata") { + touchesTests = true + } else if strings.HasSuffix(lower, ".go") || strings.HasPrefix(lower, "cmd/") || strings.HasPrefix(lower, "pkg/") || strings.HasPrefix(lower, "internal/") || strings.HasPrefix(lower, "sdk/") || strings.HasPrefix(lower, "deploy/") { + touchesProd = true + } + } + + if !meta.ReadOnlyOnly && classRank(effective) < classRank(ClassK1) { + effective = ClassK1 + } + if (touchesProd || !meta.ReversibleConfigOnly) && !meta.ReadOnlyOnly && classRank(effective) < classRank(ClassK2) { + effective = ClassK2 + } + if touchesAI && classRank(effective) < classRank(ClassK3) { + effective = ClassK3 + reasons = append(reasons, "touches_ai_or_rsi_layer_requires_k3") + } + if meta.DeletedOrSkippedTests > 0 { + touchesK4 = true + reasons = append(reasons, "deleted_or_skipped_tests_requires_k4") + } + if touchesProd && (meta.ModifiedExistingTests || meta.ModifiedTestdata) { + touchesK4 = true + reasons = append(reasons, "co_modified_production_and_existing_tests_or_testdata_requires_k4") + } + if touchesTests && meta.ModifiedTestdata { + touchesK4 = true + reasons = append(reasons, "modified_testdata_fixtures_requires_k4") + } + if touchesK4 { + effective = ClassK4 + } + + sort.Strings(reasons) + reasons = compactStrings(reasons) + + return Classification{ + DeclaredClass: meta.DeclaredClass, + EffectiveClass: effective, + HumanApprovalRequired: effective == ClassK4, + HoldoutRequired: classRank(effective) >= classRank(ClassK3), + EscalationReasons: reasons, + }, nil +} + +func compactStrings(in []string) []string { + if len(in) == 0 { + return []string{} + } + out := make([]string, 0, len(in)) + for i, s := range in { + if i == 0 || s != in[i-1] { + out = append(out, s) + } + } + return out +} + +func parseNonNegativeRat(raw string, field string) (*big.Rat, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, fmt.Errorf("%w: %s is empty", ErrInvalidProfile, field) + } + r, ok := new(big.Rat).SetString(trimmed) + if !ok || r.Sign() < 0 { + return nil, fmt.Errorf("%w: %s must be a non-negative rational or decimal, got %q", ErrInvalidProfile, field, raw) + } + return r, nil +} + +func parseSignedRat(raw string, field string) (*big.Rat, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, fmt.Errorf("%w: %s is empty", ErrInvalidProfile, field) + } + r, ok := new(big.Rat).SetString(trimmed) + if !ok { + return nil, fmt.Errorf("%w: %s must be a valid rational or decimal, got %q", ErrInvalidProfile, field, raw) + } + return r, nil +} + +func parseProbabilityRat(raw string, field string) (*big.Rat, error) { + r, err := parseNonNegativeRat(raw, field) + if err != nil { + return nil, err + } + if r.Cmp(big.NewRat(1, 1)) > 0 { + return nil, fmt.Errorf("%w: %s must be in [0, 1], got %q", ErrInvalidProfile, field, raw) + } + return r, nil +} + +// EvaluateCandidate runs the complete G1-G7 governance evaluation and any +// attached decision-science checks (VOI, PassK, Experiment, Discovery). +func EvaluateCandidate(profile CandidateProfile) (Report, error) { + cls, err := ClassifyCandidate(profile.Candidate) + if err != nil { + return Report{}, err + } + + var checks []CheckResult + var killReasons []string + killLevel := KillSwitchNone + maxAutoClass := ClassK3 + + // Gate K4: Human approval check when escalated or declared K4. + k4Pass := true + k4Summary := fmt.Sprintf("effective class %s within autonomous boundary", cls.EffectiveClass) + if cls.EffectiveClass == ClassK4 { + if strings.TrimSpace(profile.Candidate.HumanK4ApprovalReference) == "" { + k4Pass = false + k4Summary = fmt.Sprintf("effective class K4 requires explicit human approval (%s)", strings.Join(cls.EscalationReasons, ", ")) + if profile.Candidate.DeletedOrSkippedTests > 0 || profile.Candidate.ModifiedExistingTests || profile.Candidate.ModifiedTestdata { + killLevel = escalateKillSwitch(killLevel, KillSwitchL3) + killReasons = append(killReasons, "test_or_evaluator_tampering_detected") + } + } else { + k4Summary = fmt.Sprintf("effective class K4 authorized by human reference %s", profile.Candidate.HumanK4ApprovalReference) + } + } + checks = append(checks, makeCheck("k4_human_boundary", k4Pass, k4Summary)) + + // Gate G1: Red->Green test & evaluator hash integrity. + g1 := profile.Gates.G1 + g1Pass := g1.EvaluatorFrozenHashVerified && g1.SuitePassed && !g1.RerunMaskingUsed && g1.SilentErrorSwallowing == 0 + if classRank(cls.EffectiveClass) >= classRank(ClassK2) { + g1Pass = g1Pass && g1.NewTestFailedBeforeFix && g1.NewTestPassedAfterFix + } + if cls.HoldoutRequired { + g1Pass = g1Pass && g1.UnseenHoldoutPassed && g1.SeededErrorRejected + } + g1Summary := fmt.Sprintf("frozenHash=%t suite=%t redGreen=%t/%t holdout=%t seededError=%t rerunMask=%t silentSwallow=%d", + g1.EvaluatorFrozenHashVerified, g1.SuitePassed, g1.NewTestFailedBeforeFix, g1.NewTestPassedAfterFix, + g1.UnseenHoldoutPassed, g1.SeededErrorRejected, g1.RerunMaskingUsed, g1.SilentErrorSwallowing) + checks = append(checks, makeCheck("g1_red_green_and_test_integrity", g1Pass, g1Summary)) + + // Gate G2: Diff-based mutation score >= 80% and fuzz targets. + g2 := profile.Gates.G2 + mutRatio := big.NewRat(0, 1) + g2Pass := g2.FuzzTargetsPassed + if classRank(cls.EffectiveClass) >= classRank(ClassK2) { + if g2.TotalMutants <= 0 || g2.KilledMutants < 0 || g2.KilledMutants > g2.TotalMutants { + g2Pass = false + } else { + mutRatio = big.NewRat(int64(g2.KilledMutants), int64(g2.TotalMutants)) + if mutRatio.Cmp(minMutationKillScore) < 0 { + g2Pass = false + } + } + } else if g2.TotalMutants > 0 && g2.KilledMutants >= 0 && g2.KilledMutants <= g2.TotalMutants { + mutRatio = big.NewRat(int64(g2.KilledMutants), int64(g2.TotalMutants)) + } + g2Summary := fmt.Sprintf("killed=%d/%d (%s) fuzzPassed=%t", g2.KilledMutants, g2.TotalMutants, mutRatio.FloatString(4), g2.FuzzTargetsPassed) + checks = append(checks, makeCheck("g2_diff_mutation_and_fuzz", g2Pass, g2Summary)) + + // Gate G3: Security SAST, govulncheck, and external capability boundary. + g3 := profile.Gates.G3 + g3Pass := g3.SASTFindings == 0 && g3.VulnFindings == 0 && g3.ExternalCapabilityGate && !g3.PromptDelimiterOnlySecurity && !g3.ExplanationOnlyApproval + g3Summary := fmt.Sprintf("sast=%d vuln=%d externalCapGate=%t delimiterOnly=%t explanationOnly=%t", + g3.SASTFindings, g3.VulnFindings, g3.ExternalCapabilityGate, g3.PromptDelimiterOnlySecurity, g3.ExplanationOnlyApproval) + checks = append(checks, makeCheck("g3_security_and_capability_boundary", g3Pass, g3Summary)) + + // Gate G4: Canary SLO burn and stateful downgrade continuity. + g4 := profile.Gates.G4 + stepPct, err := parseNonNegativeRat(g4.CanaryStepPercent, "gates.g4.canaryStepPercent") + if err != nil { + return Report{}, err + } + maxPct, err := parseNonNegativeRat(g4.CanaryMaxPercent, "gates.g4.canaryMaxPercent") + if err != nil { + return Report{}, err + } + succPct, err := parseNonNegativeRat(g4.RequestSuccessRatePercent, "gates.g4.requestSuccessRatePercent") + if err != nil { + return Report{}, err + } + latMs, err := parseNonNegativeRat(g4.LatencyP99Ms, "gates.g4.latencyP99Ms") + if err != nil { + return Report{}, err + } + durMin, err := parseNonNegativeRat(g4.CanaryDurationMinutes, "gates.g4.canaryDurationMinutes") + if err != nil { + return Report{}, err + } + stepFrac := new(big.Rat).Quo(stepPct, big.NewRat(100, 1)) + maxFrac := new(big.Rat).Quo(maxPct, big.NewRat(100, 1)) + succFrac := new(big.Rat).Quo(succPct, big.NewRat(100, 1)) + // Worst-case error-minutes during one canary step = stepFrac * durMin; burn fraction = (stepFrac * durMin) / 43.2 + errMinutes := new(big.Rat).Mul(stepFrac, durMin) + burnFrac := new(big.Rat).Quo(errMinutes, sloMonthlyBudgetMin) + + g4Pass := g4.ActiveCanaries >= 1 && g4.ActiveCanaries <= 1 && + stepFrac.Cmp(maxCanaryStepPct) <= 0 && + maxFrac.Cmp(maxCanaryWeightPct) <= 0 && + succFrac.Cmp(minCanarySuccessRate) >= 0 && + latMs.Cmp(maxCanaryLatencyMs) <= 0 && + burnFrac.Cmp(maxCanarySLOBurnFrac) <= 0 && + g4.CanaryFailuresLast24h < 3 && + (!g4.TouchesPersistentState || g4.DowngradeContinuityVerified) + + if g4.CanaryFailuresLast24h >= 3 { + killLevel = escalateKillSwitch(killLevel, KillSwitchL1) + killReasons = append(killReasons, "three_or_more_canary_failures_in_24h") + } + g4Summary := fmt.Sprintf("active=%d step=%s%% max=%s%% success=%s%% p99=%sms burnFrac=%s stateful=%t downgradeOK=%t", + g4.ActiveCanaries, stepPct.FloatString(1), maxPct.FloatString(1), succPct.FloatString(2), latMs.FloatString(1), + burnFrac.FloatString(4), g4.TouchesPersistentState, g4.DowngradeContinuityVerified) + checks = append(checks, makeCheck("g4_canary_slo_and_stateful_downgrade", g4Pass, g4Summary)) + + // Gate G5: Portability and EU Data Act switching drill. + g5 := profile.Gates.G5 + g5Pass := g5.PortabilityDrillPassed && g5.DataActVerdict == VerdictPass + g5Summary := fmt.Sprintf("portabilityDrill=%t dataActVerdict=%s", g5.PortabilityDrillPassed, g5.DataActVerdict) + checks = append(checks, makeCheck("g5_portability_and_switching_drill", g5Pass, g5Summary)) + + // Gate G6: Rolling DORA Change Fail Rate (CFR) circuit breaker. + g6 := profile.Gates.G6 + aiCFR := big.NewRat(0, 1) + humanCFR := big.NewRat(0, 1) + g6Pass := true + if g6.AITotalDeployments <= 0 || g6.AIFailedDeployments < 0 || g6.AIFailedDeployments > g6.AITotalDeployments || + g6.HumanTotalDeployments < 0 || g6.HumanFailedDeployments < 0 || g6.HumanFailedDeployments > g6.HumanTotalDeployments { + g6Pass = false + } else { + aiCFR = big.NewRat(int64(g6.AIFailedDeployments), int64(g6.AITotalDeployments)) + if aiCFR.Cmp(maxAICFR) > 0 { + g6Pass = false + } + if g6.HumanTotalDeployments > 0 { + humanCFR = big.NewRat(int64(g6.HumanFailedDeployments), int64(g6.HumanTotalDeployments)) + if aiCFR.Cmp(humanCFR) > 0 { + g6Pass = false + } + } + } + if !g6Pass { + maxAutoClass = ClassK1 + killLevel = escalateKillSwitch(killLevel, KillSwitchL1) + killReasons = append(killReasons, "g6_cfr_budget_exceeded_autonomy_downgraded_to_k1") + } + g6Summary := fmt.Sprintf("aiCFR=%d/%d (%s) humanCFR=%d/%d (%s) maxAutoClass=%s", + g6.AIFailedDeployments, g6.AITotalDeployments, aiCFR.FloatString(4), + g6.HumanFailedDeployments, g6.HumanTotalDeployments, humanCFR.FloatString(4), maxAutoClass) + checks = append(checks, makeCheck("g6_dora_cfr_circuit_breaker", g6Pass, g6Summary)) + + // Gate G7: Task budget, duration, burn multiplier, and cross-family overseer. + g7 := profile.Gates.G7 + costUSD, err := parseNonNegativeRat(g7.TaskCostUSD, "gates.g7.taskCostUsd") + if err != nil { + return Report{}, err + } + medCostUSD, err := parseNonNegativeRat(g7.MedianTaskCostUSD, "gates.g7.medianTaskCostUsd") + if err != nil { + return Report{}, err + } + taskDurMin, err := parseNonNegativeRat(g7.TaskDurationMinutes, "gates.g7.taskDurationMinutes") + if err != nil { + return Report{}, err + } + execFam := strings.ToLower(strings.TrimSpace(g7.ExecutorModelFamily)) + overFam := strings.ToLower(strings.TrimSpace(g7.OverseerModelFamily)) + burnLimit := new(big.Rat).Mul(medCostUSD, maxCostBurnMult) + burnExceeded := medCostUSD.Sign() <= 0 || costUSD.Cmp(burnLimit) > 0 + if burnExceeded { + killLevel = escalateKillSwitch(killLevel, KillSwitchL2) + killReasons = append(killReasons, "g7_task_cost_burn_exceeded_2x_median") + } + g7Pass := costUSD.Cmp(maxTaskCostUSD) <= 0 && + taskDurMin.Cmp(maxTaskDurationMin) <= 0 && + !burnExceeded && + execFam != "" && overFam != "" && execFam != overFam + g7Summary := fmt.Sprintf("cost=$%s (median=$%s) duration=%smin executor=%s overseer=%s", + costUSD.FloatString(2), medCostUSD.FloatString(2), taskDurMin.FloatString(1), execFam, overFam) + checks = append(checks, makeCheck("g7_budget_and_cross_family_overseer", g7Pass, g7Summary)) + + var voiReport *VOIReport + if profile.VOI != nil { + vr, err := EvaluateVOI(*profile.VOI) + if err != nil { + return Report{}, err + } + voiReport = &vr + voiPass := vr.ExtremizationRejected && (vr.FeasibleUnderLossCap || vr.RecommendedAction == "do_not_launch_or_test_until_loss_restructured") + checks = append(checks, makeCheck("c20_voi_and_loss_cap", voiPass, + fmt.Sprintf("netEVSI=%s EUR feasibleUnderCap=%t action=%s", vr.NetIncrementalEVEUR, vr.FeasibleUnderLossCap, vr.RecommendedAction))) + } + + var passKReport *PassKReport + if profile.PassK != nil { + pr, err := EvaluatePassK(*profile.PassK) + if err != nil { + return Report{}, err + } + passKReport = &pr + checks = append(checks, makeCheck("c21_combinatorial_passk_and_cascade_cost", true, + fmt.Sprintf("pass^%d=%s pass@%d=%s fullCascade=$%s hoursPerAccepted=%s", + pr.K, pr.UnbiasedCombinatorialPassK, pr.K, pr.UnbiasedCombinatorialPassAt, pr.FullCascadeCostPerTaskUSD, pr.HumanHoursPerAcceptedTask))) + } + + var expReport *ExperimentReport + if profile.Experiment != nil { + er, err := EvaluateExperiment(*profile.Experiment) + if err != nil { + return Report{}, err + } + expReport = &er + expPass := !er.SRMDetected && er.CUPEDValid + checks = append(checks, makeCheck("c19_experiment_srm_deff_tost", expPass, + fmt.Sprintf("srmChi2=%s deff=%s tCluster=%s cupedValid=%t verdict=%s", + er.SRMChiSquare, er.DesignEffectDEFF, er.ClusterAdjustedTStat, er.CUPEDValid, er.Verdict))) + } + + var discReport *DiscoveryReport + if profile.Discovery != nil { + dr, err := EvaluateDiscovery(*profile.Discovery) + if err != nil { + return Report{}, err + } + discReport = &dr + checks = append(checks, makeCheck("c18_theory_of_value_stage_gate", dr.MethodologicallyValid, + fmt.Sprintf("valid=%t authorized=%t reasons=%v", dr.MethodologicallyValid, dr.StageGateAuthorized, dr.Reasons))) + } + + passed := 0 + failed := 0 + for _, c := range checks { + if c.Status == StatusPass { + passed++ + } else { + failed++ + } + } + if classRank(cls.EffectiveClass) > classRank(maxAutoClass) && cls.EffectiveClass != ClassK4 { + failed++ + checks = append(checks, makeCheck("autonomy_ceiling_enforcement", false, + fmt.Sprintf("candidate requires %s but current maxAutonomousClass is %s", cls.EffectiveClass, maxAutoClass))) + } + + verdict := VerdictPass + if failed > 0 { + verdict = VerdictFail + } + sort.Strings(killReasons) + killReasons = compactStrings(killReasons) + + return Report{ + SchemaVersion: ReportSchema, + APIVersion: ProfileAPIVersion, + CandidateID: profile.Candidate.ID, + Classification: cls, + Checks: checks, + Passed: passed, + Failed: failed, + MaxAutonomousClass: maxAutoClass, + KillSwitchLevel: killLevel, + KillSwitchReasons: killReasons, + MutationKillRatio: mutRatio.RatString(), + AIChangeFailRate: aiCFR.RatString(), + HumanChangeFailRate: humanCFR.RatString(), + CanarySLOBurnFraction: burnFrac.RatString(), + VOI: voiReport, + PassK: passKReport, + Experiment: expReport, + Discovery: discReport, + Verdict: verdict, + }, nil +} + +func makeCheck(id string, pass bool, summary string) CheckResult { + status := StatusFail + if pass { + status = StatusPass + } + return CheckResult{ID: id, Status: status, Summary: summary} +} + +func escalateKillSwitch(current, candidate string) string { + rank := map[string]int{ + KillSwitchNone: 0, + KillSwitchL1: 1, + KillSwitchL2: 2, + KillSwitchL3: 3, + } + if rank[candidate] > rank[current] { + return candidate + } + return current +} + +// EvaluateVOI computes exact rational Bayesian posteriors, EVPI, Gross EVSI, +// Net EVSI, and enforces non-compensatory lexicographic loss caps (C-20). +func EvaluateVOI(in VOIInput) (VOIReport, error) { + p, err := parseProbabilityRat(in.PriorSuccessProb, "voi.priorSuccessProb") + if err != nil { + return VOIReport{}, err + } + if p.Sign() == 0 || p.Cmp(big.NewRat(1, 1)) == 0 { + return VOIReport{}, fmt.Errorf("%w: voi.priorSuccessProb must be strictly in (0, 1)", ErrInvalidProfile) + } + win, err := parseNonNegativeRat(in.SuccessNetPayoffEUR, "voi.successNetPayoffEur") + if err != nil { + return VOIReport{}, err + } + lossMag, err := parseNonNegativeRat(in.FailureLossEUR, "voi.failureLossEur") + if err != nil { + return VOIReport{}, err + } + noAct, err := parseSignedRat(in.NoActionPayoffEUR, "voi.noActionPayoffEur") + if err != nil { + return VOIReport{}, err + } + testCost, err := parseNonNegativeRat(in.TestCostEUR, "voi.testCostEur") + if err != nil { + return VOIReport{}, err + } + sens, err := parseProbabilityRat(in.TestSensitivity, "voi.testSensitivity") + if err != nil { + return VOIReport{}, err + } + spec, err := parseProbabilityRat(in.TestSpecificity, "voi.testSpecificity") + if err != nil { + return VOIReport{}, err + } + capEUR, err := parseNonNegativeRat(in.HardLossCapEUR, "voi.hardLossCapEur") + if err != nil { + return VOIReport{}, err + } + + one := big.NewRat(1, 1) + q := new(big.Rat).Sub(one, p) + + // Launch EV = p * win - (1-p) * lossMag + launchEV := new(big.Rat).Sub(new(big.Rat).Mul(p, win), new(big.Rat).Mul(q, lossMag)) + noTestBestEV := new(big.Rat).Set(noAct) + noTestAction := "do_not_launch" + if launchEV.Cmp(noAct) > 0 { + noTestBestEV.Set(launchEV) + noTestAction = "launch_now" + } + + // P(T+) = p*sens + (1-p)*(1-spec) + fpr := new(big.Rat).Sub(one, spec) + pPos := new(big.Rat).Add(new(big.Rat).Mul(p, sens), new(big.Rat).Mul(q, fpr)) + pNeg := new(big.Rat).Sub(one, pPos) + if pPos.Sign() <= 0 || pNeg.Sign() <= 0 { + return VOIReport{}, fmt.Errorf("%w: degenerate test probabilities P(T+)=%s", ErrInvalidProfile, pPos.RatString()) + } + + postPos := new(big.Rat).Quo(new(big.Rat).Mul(p, sens), pPos) + fnr := new(big.Rat).Sub(one, sens) + postNeg := new(big.Rat).Quo(new(big.Rat).Mul(p, fnr), pNeg) + + evGivenPos := new(big.Rat).Sub(new(big.Rat).Mul(postPos, win), new(big.Rat).Mul(new(big.Rat).Sub(one, postPos), lossMag)) + bestGivenPos := maxRat(evGivenPos, noAct) + evGivenNeg := new(big.Rat).Sub(new(big.Rat).Mul(postNeg, win), new(big.Rat).Mul(new(big.Rat).Sub(one, postNeg), lossMag)) + bestGivenNeg := maxRat(evGivenNeg, noAct) + + grossPolicyEV := new(big.Rat).Add(new(big.Rat).Mul(pPos, bestGivenPos), new(big.Rat).Mul(pNeg, bestGivenNeg)) + grossEVSI := new(big.Rat).Sub(grossPolicyEV, noTestBestEV) + netPolicyEV := new(big.Rat).Sub(grossPolicyEV, testCost) + netIncrementalEV := new(big.Rat).Sub(grossEVSI, testCost) + + // Perfect information EV = p * max(win, noAct) + (1-p) * max(-lossMag, noAct) + negLoss := new(big.Rat).Neg(lossMag) + perfectEV := new(big.Rat).Add(new(big.Rat).Mul(p, maxRat(win, noAct)), new(big.Rat).Mul(q, maxRat(negLoss, noAct))) + evpi := new(big.Rat).Sub(perfectEV, noTestBestEV) + + feasibleUnderCap := lossMag.Cmp(capEUR) <= 0 + effectiveNetEVSI := new(big.Rat).Set(netIncrementalEV) + recAction := noTestAction + if !feasibleUnderCap { + // Non-compensatory lexicographic constraint: launch is infeasible regardless of test signal. + effectiveNetEVSI = new(big.Rat).Neg(testCost) + recAction = "do_not_launch_or_test_until_loss_restructured" + } else if netIncrementalEV.Sign() > 0 { + recAction = "run_test_and_follow_posterior_threshold" + } + + return VOIReport{ + NoTestBestAction: noTestAction, + LaunchNowEVEUR: ratToCleanString(launchEV), + NoTestBestEVEUR: ratToCleanString(noTestBestEV), + PositiveTestProb: pPos.RatString(), + PosteriorSuccessGivenPos: postPos.RatString(), + PosteriorSuccessGivenNeg: postNeg.RatString(), + EVPIEUR: ratToCleanString(evpi), + GrossPolicyEVEUR: ratToCleanString(grossPolicyEV), + GrossEVSIEUR: ratToCleanString(grossEVSI), + NetPolicyEVEUR: ratToCleanString(netPolicyEV), + NetIncrementalEVEUR: ratToCleanString(netIncrementalEV), + FeasibleUnderLossCap: feasibleUnderCap, + EffectiveNetEVSIUnderCap: ratToCleanString(effectiveNetEVSI), + ExtremizationRejected: in.CorrelatedLLMRunsCount >= 0, + RecommendedAction: recAction, + }, nil +} + +func maxRat(a, b *big.Rat) *big.Rat { + if a.Cmp(b) >= 0 { + return new(big.Rat).Set(a) + } + return new(big.Rat).Set(b) +} + +func ratToCleanString(r *big.Rat) string { + if r.IsInt() { + return r.Num().String() + } + return r.RatString() +} + +func binom(n, k int) *big.Int { + if k < 0 || k > n { + return big.NewInt(0) + } + return new(big.Int).Binomial(int64(n), int64(k)) +} + +// EvaluatePassK computes Yao et al. unbiased task-averaged combinatorial pass^k +// and pass@k, full cascade cost with retry amplification, and quality-adjusted +// human labor per accepted task (C-21). +func EvaluatePassK(in PassKInput) (PassKReport, error) { + if in.K <= 0 || len(in.Tasks) == 0 { + return PassKReport{}, fmt.Errorf("%w: passK requires k >= 1 and non-empty tasks", ErrInvalidProfile) + } + sumPass1 := big.NewRat(0, 1) + sumPassK := big.NewRat(0, 1) + sumPassAtK := big.NewRat(0, 1) + + for _, t := range in.Tasks { + if t.Trials < in.K || t.Successes < 0 || t.Successes > t.Trials { + return PassKReport{}, fmt.Errorf("%w: invalid task %q trials=%d successes=%d for k=%d", ErrInvalidProfile, t.TaskID, t.Trials, t.Successes, in.K) + } + sumPass1.Add(sumPass1, big.NewRat(int64(t.Successes), int64(t.Trials))) + denom := binom(t.Trials, in.K) + numPassK := binom(t.Successes, in.K) + taskPassK := new(big.Rat).SetFrac(numPassK, denom) + sumPassK.Add(sumPassK, taskPassK) + + numFailAll := binom(t.Trials-t.Successes, in.K) + taskFailAll := new(big.Rat).SetFrac(numFailAll, denom) + taskPassAtK := new(big.Rat).Sub(big.NewRat(1, 1), taskFailAll) + sumPassAtK.Add(sumPassAtK, taskPassAtK) + } + + mRat := big.NewRat(int64(len(in.Tasks)), 1) + meanP := new(big.Rat).Quo(sumPass1, mRat) + unbiasedPassK := new(big.Rat).Quo(sumPassK, mRat) + unbiasedPassAtK := new(big.Rat).Quo(sumPassAtK, mRat) + + naivePassK := powRat(meanP, in.K) + oneMinusP := new(big.Rat).Sub(big.NewRat(1, 1), meanP) + naivePassAtK := new(big.Rat).Sub(big.NewRat(1, 1), powRat(oneMinusP, in.K)) + + cCheap, err := parseNonNegativeRat(in.CheapModelCostUSD, "passK.cheapModelCostUsd") + if err != nil { + return PassKReport{}, err + } + cVer, err := parseNonNegativeRat(in.VerifierCostUSD, "passK.verifierCostUsd") + if err != nil { + return PassKReport{}, err + } + cStrong, err := parseNonNegativeRat(in.StrongModelCostUSD, "passK.strongModelCostUsd") + if err != nil { + return PassKReport{}, err + } + pFail, err := parseProbabilityRat(in.GateFailRate, "passK.gateFailRate") + if err != nil { + return PassKReport{}, err + } + rRetry, err := parseNonNegativeRat(in.StrongRetryRate, "passK.strongRetryRate") + if err != nil { + return PassKReport{}, err + } + + // Full post-generation cascade cost = cCheap + cVer + pFail * cStrong * (1 + rRetry) + retryFactor := new(big.Rat).Add(big.NewRat(1, 1), rRetry) + strongExpected := new(big.Rat).Mul(new(big.Rat).Mul(pFail, cStrong), retryFactor) + fullCascade := new(big.Rat).Add(new(big.Rat).Add(cCheap, cVer), strongExpected) + + // Naive weighted average often ignores verifier overhead and retries: (1 - pFail)*cCheap + pFail*cStrong + oneMinusFail := new(big.Rat).Sub(big.NewRat(1, 1), pFail) + naiveCascade := new(big.Rat).Add(new(big.Rat).Mul(oneMinusFail, cCheap), new(big.Rat).Mul(pFail, cStrong)) + + hCode, err := parseNonNegativeRat(in.HumanCodingHours, "passK.humanCodingHours") + if err != nil { + return PassKReport{}, err + } + hRev, err := parseNonNegativeRat(in.HumanReviewHours, "passK.humanReviewHours") + if err != nil { + return PassKReport{}, err + } + hRew, err := parseNonNegativeRat(in.HumanReworkHours, "passK.humanReworkHours") + if err != nil { + return PassKReport{}, err + } + if in.AcceptedTasks <= 0 || in.AttemptedTasks < in.AcceptedTasks { + return PassKReport{}, fmt.Errorf("%w: invalid acceptedTasks=%d attemptedTasks=%d", ErrInvalidProfile, in.AcceptedTasks, in.AttemptedTasks) + } + totalHours := new(big.Rat).Add(new(big.Rat).Add(hCode, hRev), hRew) + hoursPerAccepted := new(big.Rat).Quo(totalHours, big.NewRat(int64(in.AcceptedTasks), 1)) + accRate := big.NewRat(int64(in.AcceptedTasks), int64(in.AttemptedTasks)) + + return PassKReport{ + K: in.K, + MeanPass1: meanP.RatString(), + UnbiasedCombinatorialPassK: unbiasedPassK.RatString(), + UnbiasedCombinatorialPassAt: unbiasedPassAtK.RatString(), + NaiveHomogeneousPassK: naivePassK.RatString(), + NaiveHomogeneousPassAtK: naivePassAtK.RatString(), + FullCascadeCostPerTaskUSD: fullCascade.FloatString(2), + NaiveCascadeCostPerTaskUSD: naiveCascade.FloatString(2), + TotalHumanHours: ratToCleanString(totalHours), + HumanHoursPerAcceptedTask: hoursPerAccepted.RatString(), + AcceptanceRate: accRate.RatString(), + }, nil +} + +func powRat(base *big.Rat, exp int) *big.Rat { + num := new(big.Int).Exp(base.Num(), big.NewInt(int64(exp)), nil) + den := new(big.Int).Exp(base.Denom(), big.NewInt(int64(exp)), nil) + return new(big.Rat).SetFrac(num, den) +} + +// EvaluateExperiment computes SRM chi-square, cluster DEFF, effective sample +// size, and one-sided non-inferiority vs two-sided TOST equivalence (C-19). +func EvaluateExperiment(in ExperimentInput) (ExperimentReport, error) { + if in.ControlAssignedUnits <= 0 || in.TreatmentAssignedUnits <= 0 { + return ExperimentReport{}, fmt.Errorf("%w: assigned unit counts must be positive", ErrInvalidProfile) + } + if in.ClustersPerArm < 2 || in.UnitsPerCluster < 1 { + return ExperimentReport{}, fmt.Errorf("%w: clustersPerArm >= 2 and unitsPerCluster >= 1 required", ErrInvalidProfile) + } + rho, err := parseProbabilityRat(in.IntraclusterCorrelation, "experiment.intraclusterCorrelation") + if err != nil { + return ExperimentReport{}, err + } + zNaive, err := parseSignedRat(in.NaiveZStat, "experiment.naiveZStat") + if err != nil { + return ExperimentReport{}, err + } + diff, err := parseSignedRat(in.ObservedDiff, "experiment.observedDiff") + if err != nil { + return ExperimentReport{}, err + } + se, err := parseNonNegativeRat(in.DiffStandardError, "experiment.diffStandardError") + if err != nil || se.Sign() == 0 { + return ExperimentReport{}, fmt.Errorf("%w: diffStandardError must be strictly positive", ErrInvalidProfile) + } + margin, err := parseNonNegativeRat(in.NonInferiorityMargin, "experiment.nonInferiorityMargin") + if err != nil || margin.Sign() == 0 { + return ExperimentReport{}, fmt.Errorf("%w: nonInferiorityMargin must be strictly positive", ErrInvalidProfile) + } + + // SRM chi^2 for 50/50 split: (N_C - N_T)^2 / (N_C + N_T) + totalUnits := int64(in.ControlAssignedUnits + in.TreatmentAssignedUnits) + diffUnits := int64(in.ControlAssignedUnits - in.TreatmentAssignedUnits) + srmChi2 := big.NewRat(diffUnits*diffUnits, totalUnits) + srmDetected := srmChi2.Cmp(srmChiSquareCrit) >= 0 + + // DEFF = 1 + (m - 1) * rho + mMinus1 := big.NewRat(int64(in.UnitsPerCluster-1), 1) + deff := new(big.Rat).Add(big.NewRat(1, 1), new(big.Rat).Mul(mMinus1, rho)) + rawArmN := big.NewRat(int64(in.ClustersPerArm*in.UnitsPerCluster), 1) + nEff := new(big.Rat).Quo(rawArmN, deff) + + deffFloat, _ := deff.Float64() + seInflation := math.Sqrt(deffFloat) + zNaiveFloat, _ := zNaive.Float64() + tCluster := zNaiveFloat / seInflation + + // Approximate two-sided p < 0.05 critical value for df = 2*(ClustersPerArm - 1) + df := 2 * (in.ClustersPerArm - 1) + tCrit := approximateTCrit005TwoSided(df) + clusterSig := math.Abs(tCluster) >= tCrit && !srmDetected && !in.HasPostTreatmentCovariate + + // Non-inferiority against -margin: z_NI = (diff - (-margin)) / se = (diff + margin) / se + zNI := new(big.Rat).Quo(new(big.Rat).Add(diff, margin), se) + // TOST upper against +margin: z_upper = (diff - margin) / se + zUpper := new(big.Rat).Quo(new(big.Rat).Sub(diff, margin), se) + + zNIFloat, _ := zNI.Float64() + zUpperFloat, _ := zUpper.Float64() + // One-sided alpha=0.05 z-critical = 1.6448536 + niProven := zNIFloat >= 1.6448536 && !srmDetected + tostProven := zNIFloat >= 1.6448536 && zUpperFloat <= -1.6448536 && !srmDetected + + verdict := VerdictPass + if srmDetected || in.HasPostTreatmentCovariate { + verdict = VerdictFail + } + + return ExperimentReport{ + SRMChiSquare: srmChi2.FloatString(4), + SRMDetected: srmDetected, + DesignEffectDEFF: deff.FloatString(4), + EffectiveUnitsPerArm: nEff.FloatString(2), + SEInflationFactor: fmt.Sprintf("%.4f", seInflation), + ClusterAdjustedTStat: fmt.Sprintf("%.4f", tCluster), + ClusterSignificantAt005: clusterSig, + CUPEDValid: !in.HasPostTreatmentCovariate, + NonInferiorityZ: zNI.FloatString(4), + TOSTUpperZ: zUpper.FloatString(4), + NonInferiorityProvenAt005: niProven, + TOSTEquivalenceProven005: tostProven, + Verdict: verdict, + }, nil +} + +func approximateTCrit005TwoSided(df int) float64 { + if df <= 1 { + return 12.706 + } + // Peizer-Pratt / Cornish-Fisher expansion for t_{0.975, df} + z := 1.959963984540054 + d := float64(df) + g1 := (z*z*z + z) / 4.0 + g2 := (5*math.Pow(z, 5) + 16*z*z*z + 3*z) / 96.0 + return z + g1/d + g2/(d*d) +} + +// EvaluateDiscovery checks a product discovery and stage-gate packet against C-18 invariants. +func EvaluateDiscovery(in DiscoveryInput) (DiscoveryReport, error) { + if in.TotalInterviewedPeople < 0 || in.IndependentAccounts < 0 || in.IndependentAccounts > in.TotalInterviewedPeople { + return DiscoveryReport{}, fmt.Errorf("%w: invalid interview/account counts", ErrInvalidProfile) + } + accountInflation := in.TotalInterviewedPeople > in.IndependentAccounts && in.IndependentAccounts < 5 + roleComplete := in.HasTechnicalUserEvidence && in.HasEconomicBuyerEvidence && in.HasVetoHolderEvidence + + var reasons []string + if !in.HardConstraintsIntact { + reasons = append(reasons, "hard_architectural_or_security_constraints_violated") + } + if accountInflation { + reasons = append(reasons, "multiple_interviewees_in_same_firm_counted_as_independent_accounts") + } + if !roleComplete { + reasons = append(reasons, "missing_stratified_evidence_across_user_buyer_and_veto_holder") + } + if in.UsesMechanicalWTPDivisor { + reasons = append(reasons, "mechanical_hypothetical_wtp_divisor_is_invalid") + } + if in.UsesRICEToOverrideSecurity { + reasons = append(reasons, "rice_score_cannot_override_security_or_isolation") + } + if !in.ExternalContactOwnerApproved { + reasons = append(reasons, "external_contact_requires_explicit_owner_approval") + } + if !in.HasConsequentialCommitment { + reasons = append(reasons, "praise_without_consequential_migration_or_pilot_commitment") + } + + valid := len(reasons) == 0 + return DiscoveryReport{ + AccountInflationDetected: accountInflation, + RoleStratificationComplete: roleComplete, + MethodologicallyValid: valid, + StageGateAuthorized: valid && in.HasConsequentialCommitment && in.ExternalContactOwnerApproved, + Reasons: reasons, + }, nil +} diff --git a/pkg/rsi/rsi_fuzz_test.go b/pkg/rsi/rsi_fuzz_test.go new file mode 100644 index 0000000..cc63a9b --- /dev/null +++ b/pkg/rsi/rsi_fuzz_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package rsi + +import ( + "encoding/json" + "testing" +) + +func FuzzEvaluateCandidate(f *testing.F) { + prof := validCompliantK3Profile() + seedPass, err := json.Marshal(prof) + if err != nil { + f.Fatal(err) + } + f.Add(seedPass) + + profFail := validCompliantK3Profile() + profFail.Candidate.ChangedPaths = []string{".github/workflows/ci.yml"} + profFail.Candidate.DeletedOrSkippedTests = 1 + seedFail, err := json.Marshal(profFail) + if err != nil { + f.Fatal(err) + } + f.Add(seedFail) + f.Add([]byte(`{"apiVersion":"cloudring.org/rsi-governance/v1","kind":"RSICandidateProfile"}`)) + + f.Fuzz(func(t *testing.T, raw []byte) { + parsed, err := ParseProfile(raw) + if err != nil { + return + } + rep, err := EvaluateCandidate(parsed) + if err != nil { + return + } + if rep.Passed+rep.Failed != len(rep.Checks) { + t.Fatalf("check count mismatch: passed=%d failed=%d total=%d", rep.Passed, rep.Failed, len(rep.Checks)) + } + if rep.Failed > 0 && rep.Verdict != VerdictFail { + t.Fatalf("failed checks=%d but verdict=%s", rep.Failed, rep.Verdict) + } + if rep.Failed == 0 && rep.Verdict != VerdictPass { + t.Fatalf("zero failed checks but verdict=%s", rep.Verdict) + } + if rep.Classification.EffectiveClass == ClassK4 && strings_trim(parsed.Candidate.HumanK4ApprovalReference) == "" && rep.Verdict == VerdictPass { + t.Fatalf("unapproved K4 candidate must never pass: %+v", rep) + } + }) +} + +func strings_trim(s string) string { + for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\n' || s[0] == '\r') { + s = s[1:] + } + for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { + s = s[:len(s)-1] + } + return s +} diff --git a/pkg/rsi/rsi_test.go b/pkg/rsi/rsi_test.go new file mode 100644 index 0000000..98e25c0 --- /dev/null +++ b/pkg/rsi/rsi_test.go @@ -0,0 +1,664 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package rsi + +import ( + "encoding/json" + "strings" + "testing" +) + +func validCompliantK3Profile() CandidateProfile { + return CandidateProfile{ + APIVersion: ProfileAPIVersion, + Kind: ProfileKind, + Candidate: CandidateMetadata{ + ID: "cand-rsi-001", + DeclaredClass: ClassK3, + ChangedPaths: []string{"pkg/rsi/rsi.go", "cmd/cloudringctl/commands.go"}, + ReadOnlyOnly: false, + ReversibleConfigOnly: false, + DeletedOrSkippedTests: 0, + ModifiedExistingTests: false, + ModifiedTestdata: false, + }, + Gates: GateReceipts{ + G1: G1Receipt{ + EvaluatorFrozenHashVerified: true, + SuitePassed: true, + NewTestFailedBeforeFix: true, + NewTestPassedAfterFix: true, + RerunMaskingUsed: false, + SilentErrorSwallowing: 0, + UnseenHoldoutPassed: true, + SeededErrorRejected: true, + }, + G2: G2Receipt{ + KilledMutants: 18, + TotalMutants: 20, + FuzzTargetsPassed: true, + }, + G3: G3Receipt{ + SASTFindings: 0, + VulnFindings: 0, + ExternalCapabilityGate: true, + PromptDelimiterOnlySecurity: false, + ExplanationOnlyApproval: false, + }, + G4: G4Receipt{ + ActiveCanaries: 1, + CanaryStepPercent: "10", + CanaryMaxPercent: "50", + RequestSuccessRatePercent: "99.5", + LatencyP99Ms: "220", + CanaryDurationMinutes: "10", + CanaryFailuresLast24h: 0, + TouchesPersistentState: true, + DowngradeContinuityVerified: true, + }, + G5: G5Receipt{ + PortabilityDrillPassed: true, + DataActVerdict: VerdictPass, + }, + G6: G6Receipt{ + AIFailedDeployments: 1, + AITotalDeployments: 20, + HumanFailedDeployments: 2, + HumanTotalDeployments: 20, + }, + G7: G7Receipt{ + TaskCostUSD: "1.80", + MedianTaskCostUSD: "1.50", + TaskDurationMinutes: "12", + ExecutorModelFamily: "family-a", + OverseerModelFamily: "family-b", + }, + }, + VOI: &VOIInput{ + PriorSuccessProb: "2/5", + SuccessNetPayoffEUR: "120000", + FailureLossEUR: "60000", + NoActionPayoffEUR: "0", + TestCostEUR: "4000", + TestSensitivity: "4/5", + TestSpecificity: "3/4", + HardLossCapEUR: "80000", + CorrelatedLLMRunsCount: 3, + }, + PassK: &PassKInput{ + K: 4, + Tasks: []TaskTrialCount{ + {TaskID: "t1", Trials: 5, Successes: 5}, + {TaskID: "t2", Trials: 5, Successes: 4}, + {TaskID: "t3", Trials: 5, Successes: 3}, + {TaskID: "t4", Trials: 5, Successes: 0}, + }, + CheapModelCostUSD: "0.80", + VerifierCostUSD: "0.30", + StrongModelCostUSD: "4.00", + GateFailRate: "0.45", + StrongRetryRate: "0.10", + HumanCodingHours: "40", + HumanReviewHours: "28", + HumanReworkHours: "12", + AcceptedTasks: 14, + AttemptedTasks: 20, + }, + Experiment: &ExperimentInput{ + ControlAssignedUnits: 10000, + TreatmentAssignedUnits: 10000, + ClustersPerArm: 20, + UnitsPerCluster: 500, + IntraclusterCorrelation: "0.05", + NaiveZStat: "2.50", + HasPostTreatmentCovariate: false, + ObservedDiff: "0.02", + DiffStandardError: "0.01", + NonInferiorityMargin: "0.05", + }, + Discovery: &DiscoveryInput{ + HardConstraintsIntact: true, + TotalInterviewedPeople: 12, + IndependentAccounts: 10, + HasTechnicalUserEvidence: true, + HasEconomicBuyerEvidence: true, + HasVetoHolderEvidence: true, + UsesMechanicalWTPDivisor: false, + UsesRICEToOverrideSecurity: false, + ExternalContactOwnerApproved: true, + HasConsequentialCommitment: true, + }, + } +} + +func TestEvaluateCompliantK3ProfilePassesAllGates(t *testing.T) { + prof := validCompliantK3Profile() + raw, err := json.Marshal(prof) + if err != nil { + t.Fatal(err) + } + parsed, err := ParseProfile(raw) + if err != nil { + t.Fatalf("ParseProfile failed: %v", err) + } + rep, err := EvaluateCandidate(parsed) + if err != nil { + t.Fatalf("EvaluateCandidate failed: %v", err) + } + if rep.Verdict != VerdictPass || rep.Failed != 0 || rep.KillSwitchLevel != KillSwitchNone { + t.Fatalf("expected pass verdict with 0 failed checks, got verdict=%s failed=%d kill=%s checks=%+v", + rep.Verdict, rep.Failed, rep.KillSwitchLevel, rep.Checks) + } + if rep.Classification.EffectiveClass != ClassK3 || !rep.Classification.HoldoutRequired { + t.Fatalf("expected effective K3 with holdout, got %+v", rep.Classification) + } + if rep.VOI == nil || rep.VOI.LaunchNowEVEUR != "12000" || rep.VOI.PositiveTestProb != "47/100" || + rep.VOI.PosteriorSuccessGivenPos != "32/47" || rep.VOI.PosteriorSuccessGivenNeg != "8/53" || + rep.VOI.GrossPolicyEVEUR != "29400" || rep.VOI.GrossEVSIEUR != "17400" || + rep.VOI.NetPolicyEVEUR != "25400" || rep.VOI.NetIncrementalEVEUR != "13400" || rep.VOI.EVPIEUR != "36000" { + t.Fatalf("unexpected VOI report: %+v", rep.VOI) + } + if rep.PassK == nil || rep.PassK.UnbiasedCombinatorialPassK != "3/10" || + rep.PassK.UnbiasedCombinatorialPassAt != "3/4" || rep.PassK.FullCascadeCostPerTaskUSD != "3.08" || + rep.PassK.NaiveCascadeCostPerTaskUSD != "2.24" || rep.PassK.HumanHoursPerAcceptedTask != "40/7" { + t.Fatalf("unexpected PassK report: %+v", rep.PassK) + } +} + +func TestClassifyCandidateEscalatesTamperingAndProtectedPathsToK4(t *testing.T) { + cases := []struct { + name string + meta CandidateMetadata + reason string + }{ + { + name: "github-workflow-path", + meta: CandidateMetadata{ID: "c1", DeclaredClass: ClassK2, ChangedPaths: []string{".GitHub/workflows/ci.yml"}}, + reason: "protected_k4_path:.github/workflows/ci.yml", + }, + { + name: "path-traversal-evaluator", + meta: CandidateMetadata{ID: "c2", DeclaredClass: ClassK2, ChangedPaths: []string{"pkg/../evaluator/score.go"}}, + reason: "path_traversal_or_absolute:pkg/../evaluator/score.go", + }, + { + name: "ops-stop-modification", + meta: CandidateMetadata{ID: "c3", DeclaredClass: ClassK1, ChangedPaths: []string{"ops/STOP"}}, + reason: "protected_control_file:ops/stop", + }, + { + name: "deleted-tests", + meta: CandidateMetadata{ID: "c4", DeclaredClass: ClassK2, ChangedPaths: []string{"pkg/ocs/spec.go"}, DeletedOrSkippedTests: 1}, + reason: "deleted_or_skipped_tests_requires_k4", + }, + { + name: "co-modified-prod-and-existing-tests", + meta: CandidateMetadata{ID: "c5", DeclaredClass: ClassK2, ChangedPaths: []string{"pkg/ocs/spec.go"}, ModifiedExistingTests: true}, + reason: "co_modified_production_and_existing_tests_or_testdata_requires_k4", + }, + { + name: "url-encoded-traversal", + meta: CandidateMetadata{ID: "c6", DeclaredClass: ClassK2, ChangedPaths: []string{"pkg/%2e%2e/evaluator/score.go"}}, + reason: "encoded_or_null_path:pkg/%2e%2e/evaluator/score.go", + }, + { + name: "url-encoded-slash-upper", + meta: CandidateMetadata{ID: "c7", DeclaredClass: ClassK2, ChangedPaths: []string{"pkg%2Fevaluator/score.go"}}, + reason: "encoded_or_null_path:pkg%2Fevaluator/score.go", + }, + { + name: "url-encoded-backslash", + meta: CandidateMetadata{ID: "c8", DeclaredClass: ClassK2, ChangedPaths: []string{"pkg%5Cevaluator/score.go"}}, + reason: "encoded_or_null_path:pkg%5Cevaluator/score.go", + }, + { + name: "null-byte-path", + meta: CandidateMetadata{ID: "c9", DeclaredClass: ClassK2, ChangedPaths: []string{"pkg/ocs/spec.go\x00.txt"}}, + reason: "encoded_or_null_path:pkg/ocs/spec.go\x00.txt", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cls, err := ClassifyCandidate(tc.meta) + if err != nil { + t.Fatal(err) + } + if cls.EffectiveClass != ClassK4 || !cls.HumanApprovalRequired { + t.Fatalf("expected K4 escalation, got %+v", cls) + } + found := false + for _, r := range cls.EscalationReasons { + if strings.Contains(r, tc.reason) { + found = true + } + } + if !found { + t.Fatalf("expected reason %q in %v", tc.reason, cls.EscalationReasons) + } + }) + } +} + +func TestExactGateThresholdsAndKillSwitchEscalations(t *testing.T) { + // Exact 80% (16/20) mutation kill ratio must pass G2; 15/20 (75%) must fail. + prof := validCompliantK3Profile() + prof.Gates.G2.KilledMutants = 16 + prof.Gates.G2.TotalMutants = 20 + rep, err := EvaluateCandidate(prof) + if err != nil || rep.Verdict != VerdictPass { + t.Fatalf("expected 16/20 (80%%) mutation kill score to pass, got err=%v verdict=%s", err, rep.Verdict) + } + + prof.Gates.G2.KilledMutants = 15 + rep, err = EvaluateCandidate(prof) + if err != nil || rep.Verdict != VerdictFail { + t.Fatalf("expected 15/20 (75%%) mutation kill score to fail, got err=%v verdict=%s", err, rep.Verdict) + } + + // G6: AI CFR = 3/20 (15%) with human CFR = 3/20 (15%) must pass; 4/20 (20%) must fail and downgrade to K1 + KillSwitchL1. + prof = validCompliantK3Profile() + prof.Gates.G6.AIFailedDeployments = 3 + prof.Gates.G6.AITotalDeployments = 20 + prof.Gates.G6.HumanFailedDeployments = 3 + prof.Gates.G6.HumanTotalDeployments = 20 + rep, err = EvaluateCandidate(prof) + if err != nil || rep.Verdict != VerdictPass || rep.MaxAutonomousClass != ClassK3 { + t.Fatalf("expected 3/20 CFR boundary to pass, got err=%v verdict=%s maxAuto=%s", err, rep.Verdict, rep.MaxAutonomousClass) + } + + prof.Gates.G6.AIFailedDeployments = 4 + rep, err = EvaluateCandidate(prof) + if err != nil || rep.Verdict != VerdictFail || rep.MaxAutonomousClass != ClassK1 || rep.KillSwitchLevel != KillSwitchL1 { + t.Fatalf("expected 4/20 CFR to trip G6 circuit breaker to K1/L1, got verdict=%s maxAuto=%s kill=%s", + rep.Verdict, rep.MaxAutonomousClass, rep.KillSwitchLevel) + } + + // G7: Cost burn > 2x median trips KillSwitchL2. + prof = validCompliantK3Profile() + prof.Gates.G7.TaskCostUSD = "3.01" + prof.Gates.G7.MedianTaskCostUSD = "1.50" + rep, err = EvaluateCandidate(prof) + if err != nil || rep.Verdict != VerdictFail || rep.KillSwitchLevel != KillSwitchL2 { + t.Fatalf("expected >2x median cost burn to trigger L2 kill switch, got verdict=%s kill=%s", rep.Verdict, rep.KillSwitchLevel) + } + + // Unapproved test tampering trips KillSwitchL3. + prof = validCompliantK3Profile() + prof.Candidate.DeletedOrSkippedTests = 2 + rep, err = EvaluateCandidate(prof) + if err != nil || rep.Verdict != VerdictFail || rep.KillSwitchLevel != KillSwitchL3 { + t.Fatalf("expected unapproved test deletion to trigger L3 kill switch, got verdict=%s kill=%s", rep.Verdict, rep.KillSwitchLevel) + } +} + +func TestHardLossCapAndExperimentAndDiscoveryTraps(t *testing.T) { + // C-20: Hard loss cap EUR 20,000 < FailureLossEUR 60,000 blocks launch and sets effective net EVSI to -4000. + voiIn := validCompliantK3Profile().VOI + voiIn.HardLossCapEUR = "20000" + vr, err := EvaluateVOI(*voiIn) + if err != nil { + t.Fatal(err) + } + if vr.FeasibleUnderLossCap || vr.EffectiveNetEVSIUnderCap != "-4000" || vr.RecommendedAction != "do_not_launch_or_test_until_loss_restructured" { + t.Fatalf("expected hard loss cap to block launch and test, got %+v", vr) + } + + // C-19: SRM 10250 vs 9750 (chi^2 = 12.5000), DEFF = 25.9500, N_eff = 385.36, non-inferiority z = 0.0000 (not proven). + er, err := EvaluateExperiment(ExperimentInput{ + ControlAssignedUnits: 10250, + TreatmentAssignedUnits: 9750, + ClustersPerArm: 20, + UnitsPerCluster: 500, + IntraclusterCorrelation: "0.05", + NaiveZStat: "2.50", + HasPostTreatmentCovariate: true, + ObservedDiff: "-0.05", + DiffStandardError: "0.058949", + NonInferiorityMargin: "0.05", + }) + if err != nil { + t.Fatal(err) + } + if !er.SRMDetected || er.SRMChiSquare != "12.5000" || er.DesignEffectDEFF != "25.9500" || + er.EffectiveUnitsPerArm != "385.36" || er.ClusterSignificantAt005 || er.CUPEDValid || + er.NonInferiorityProvenAt005 || er.TOSTEquivalenceProven005 || er.Verdict != VerdictFail { + t.Fatalf("unexpected C-19 experiment diagnostics: %+v", er) + } + + // C-18: 14 engineers across 3 firms + WTP/1.35 + RICE override rejected. + dr, err := EvaluateDiscovery(DiscoveryInput{ + HardConstraintsIntact: false, + TotalInterviewedPeople: 14, + IndependentAccounts: 3, + HasTechnicalUserEvidence: true, + HasEconomicBuyerEvidence: false, + HasVetoHolderEvidence: false, + UsesMechanicalWTPDivisor: true, + UsesRICEToOverrideSecurity: true, + ExternalContactOwnerApproved: false, + HasConsequentialCommitment: false, + }) + if err != nil { + t.Fatal(err) + } + if !dr.AccountInflationDetected || dr.RoleStratificationComplete || dr.MethodologicallyValid || dr.StageGateAuthorized || len(dr.Reasons) != 7 { + t.Fatalf("expected all 7 C-18 discovery errors flagged, got %+v", dr) + } +} + +func TestOrthogonalSingleGateInvariantsKillMutants(t *testing.T) { + cases := []struct { + name string + mutate func(*CandidateProfile) + wantVerdict string + wantKillSwitch string + }{ + { + name: "g1_frozen_hash_false", + mutate: func(p *CandidateProfile) { + p.Gates.G1.EvaluatorFrozenHashVerified = false + }, + wantVerdict: VerdictFail, + }, + { + name: "g1_seeded_error_false", + mutate: func(p *CandidateProfile) { + p.Gates.G1.SeededErrorRejected = false + }, + wantVerdict: VerdictFail, + }, + { + name: "g1_unseen_holdout_false", + mutate: func(p *CandidateProfile) { + p.Gates.G1.UnseenHoldoutPassed = false + }, + wantVerdict: VerdictFail, + }, + { + name: "g1_suite_passed_false", + mutate: func(p *CandidateProfile) { + p.Gates.G1.SuitePassed = false + }, + wantVerdict: VerdictFail, + }, + { + name: "g1_rerun_masking_true", + mutate: func(p *CandidateProfile) { + p.Gates.G1.RerunMaskingUsed = true + }, + wantVerdict: VerdictFail, + }, + { + name: "g1_silent_error_swallowing_nonzero", + mutate: func(p *CandidateProfile) { + p.Gates.G1.SilentErrorSwallowing = 1 + }, + wantVerdict: VerdictFail, + }, + { + name: "g2_fuzz_targets_false", + mutate: func(p *CandidateProfile) { + p.Gates.G2.FuzzTargetsPassed = false + }, + wantVerdict: VerdictFail, + }, + { + name: "g3_external_cap_false", + mutate: func(p *CandidateProfile) { + p.Gates.G3.ExternalCapabilityGate = false + }, + wantVerdict: VerdictFail, + }, + { + name: "g3_prompt_delimiter_only_true", + mutate: func(p *CandidateProfile) { + p.Gates.G3.PromptDelimiterOnlySecurity = true + }, + wantVerdict: VerdictFail, + }, + { + name: "g3_explanation_only_approval_true", + mutate: func(p *CandidateProfile) { + p.Gates.G3.ExplanationOnlyApproval = true + }, + wantVerdict: VerdictFail, + }, + { + name: "g4_stateful_downgrade_false", + mutate: func(p *CandidateProfile) { + p.Gates.G4.TouchesPersistentState = true + p.Gates.G4.DowngradeContinuityVerified = false + }, + wantVerdict: VerdictFail, + }, + { + name: "g5_data_act_fail", + mutate: func(p *CandidateProfile) { + p.Gates.G5.DataActVerdict = "fail" + }, + wantVerdict: VerdictFail, + }, + { + name: "g5_portability_drill_false", + mutate: func(p *CandidateProfile) { + p.Gates.G5.PortabilityDrillPassed = false + }, + wantVerdict: VerdictFail, + }, + { + name: "g6_ai_cfr_4_of_20_with_zero_human", + mutate: func(p *CandidateProfile) { + p.Gates.G6.AIFailedDeployments = 4 + p.Gates.G6.AITotalDeployments = 20 + p.Gates.G6.HumanFailedDeployments = 0 + p.Gates.G6.HumanTotalDeployments = 0 + }, + wantVerdict: VerdictFail, + }, + { + name: "g7_exact_2x_burn_boundary_passes", + mutate: func(p *CandidateProfile) { + p.Gates.G7.TaskCostUSD = "3.00" + p.Gates.G7.MedianTaskCostUSD = "1.50" + }, + wantVerdict: VerdictPass, + wantKillSwitch: KillSwitchNone, + }, + { + name: "g7_same_family_overseer_fails", + mutate: func(p *CandidateProfile) { + p.Gates.G7.ExecutorModelFamily = "family-a" + p.Gates.G7.OverseerModelFamily = "family-a" + }, + wantVerdict: VerdictFail, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + prof := validCompliantK3Profile() + tc.mutate(&prof) + rep, err := EvaluateCandidate(prof) + if err != nil { + t.Fatalf("EvaluateCandidate returned unexpected error: %v", err) + } + if rep.Verdict != tc.wantVerdict { + t.Fatalf("expected verdict %q, got %q (checks=%+v)", tc.wantVerdict, rep.Verdict, rep.Checks) + } + if tc.wantKillSwitch != "" && rep.KillSwitchLevel != tc.wantKillSwitch { + t.Fatalf("expected killSwitchLevel %q, got %q", tc.wantKillSwitch, rep.KillSwitchLevel) + } + }) + } +} + +func TestOrthogonalDiscoveryAndDecisionScienceInvariants(t *testing.T) { + baseDiscovery := func() DiscoveryInput { + return DiscoveryInput{ + HardConstraintsIntact: true, + TotalInterviewedPeople: 12, + IndependentAccounts: 10, + HasTechnicalUserEvidence: true, + HasEconomicBuyerEvidence: true, + HasVetoHolderEvidence: true, + UsesMechanicalWTPDivisor: false, + UsesRICEToOverrideSecurity: false, + ExternalContactOwnerApproved: true, + HasConsequentialCommitment: true, + } + } + + validRep, err := EvaluateDiscovery(baseDiscovery()) + if err != nil { + t.Fatalf("expected valid baseline discovery input, got err=%v", err) + } + if !validRep.MethodologicallyValid || !validRep.StageGateAuthorized || !validRep.RoleStratificationComplete || validRep.AccountInflationDetected || len(validRep.Reasons) != 0 { + t.Fatalf("expected valid baseline discovery report, got %+v", validRep) + } + + flips := []struct { + name string + mutate func(*DiscoveryInput) + expectedReason string + }{ + { + name: "hard-constraints-violated", + mutate: func(in *DiscoveryInput) { in.HardConstraintsIntact = false }, + expectedReason: "hard_architectural_or_security_constraints_violated", + }, + { + name: "account-inflation-14-in-3", + mutate: func(in *DiscoveryInput) { + in.TotalInterviewedPeople = 14 + in.IndependentAccounts = 3 + }, + expectedReason: "multiple_interviewees_in_same_firm_counted_as_independent_accounts", + }, + { + name: "missing-technical-user-evidence", + mutate: func(in *DiscoveryInput) { in.HasTechnicalUserEvidence = false }, + expectedReason: "missing_stratified_evidence_across_user_buyer_and_veto_holder", + }, + { + name: "missing-economic-buyer-evidence", + mutate: func(in *DiscoveryInput) { in.HasEconomicBuyerEvidence = false }, + expectedReason: "missing_stratified_evidence_across_user_buyer_and_veto_holder", + }, + { + name: "missing-veto-holder-evidence", + mutate: func(in *DiscoveryInput) { in.HasVetoHolderEvidence = false }, + expectedReason: "missing_stratified_evidence_across_user_buyer_and_veto_holder", + }, + { + name: "uses-mechanical-wtp-divisor", + mutate: func(in *DiscoveryInput) { in.UsesMechanicalWTPDivisor = true }, + expectedReason: "mechanical_hypothetical_wtp_divisor_is_invalid", + }, + { + name: "uses-rice-to-override-security", + mutate: func(in *DiscoveryInput) { in.UsesRICEToOverrideSecurity = true }, + expectedReason: "rice_score_cannot_override_security_or_isolation", + }, + { + name: "external-contact-unapproved", + mutate: func(in *DiscoveryInput) { in.ExternalContactOwnerApproved = false }, + expectedReason: "external_contact_requires_explicit_owner_approval", + }, + { + name: "missing-consequential-commitment", + mutate: func(in *DiscoveryInput) { in.HasConsequentialCommitment = false }, + expectedReason: "praise_without_consequential_migration_or_pilot_commitment", + }, + } + + for _, tc := range flips { + t.Run(tc.name, func(t *testing.T) { + in := baseDiscovery() + tc.mutate(&in) + dr, err := EvaluateDiscovery(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dr.MethodologicallyValid || dr.StageGateAuthorized { + t.Fatalf("expected single flip %s to invalidate discovery and block stage gate, got %+v", tc.name, dr) + } + if len(dr.Reasons) != 1 || dr.Reasons[0] != tc.expectedReason { + t.Fatalf("expected exact single reason %q, got %v", tc.expectedReason, dr.Reasons) + } + }) + } + + voiIn := *validCompliantK3Profile().VOI + voiIn.TestCostEUR = "40000" + vr, err := EvaluateVOI(voiIn) + if err != nil { + t.Fatalf("EvaluateVOI failed: %v", err) + } + if vr.EVPIEUR != "36000" || vr.GrossEVSIEUR != "17400" || vr.NetIncrementalEVEUR != "-22600" || + vr.NetPolicyEVEUR != "-10600" || vr.RecommendedAction != "launch_now" { + t.Fatalf("expected overpriced test to preserve launch_now with negative net EVSI, got %+v", vr) + } + + baseExp := *validCompliantK3Profile().Experiment + baseExp.ObservedDiff = "0.00" + baseExp.DiffStandardError = "0.01" + baseExp.NonInferiorityMargin = "0.05" + baseExp.NaiveZStat = "15.0" + cleanExp, err := EvaluateExperiment(baseExp) + if err != nil { + t.Fatalf("EvaluateExperiment clean failed: %v", err) + } + if cleanExp.SRMDetected || !cleanExp.CUPEDValid || !cleanExp.ClusterSignificantAt005 || + !cleanExp.NonInferiorityProvenAt005 || !cleanExp.TOSTEquivalenceProven005 || cleanExp.Verdict != VerdictPass { + t.Fatalf("expected clean experiment to prove cluster sig, NI, and TOST equivalence, got %+v", cleanExp) + } + + srmOnly := baseExp + srmOnly.ControlAssignedUnits = 10250 + srmOnly.TreatmentAssignedUnits = 9750 + srmRep, err := EvaluateExperiment(srmOnly) + if err != nil { + t.Fatalf("EvaluateExperiment srmOnly failed: %v", err) + } + if !srmRep.SRMDetected || !srmRep.CUPEDValid || srmRep.ClusterSignificantAt005 || + srmRep.NonInferiorityProvenAt005 || srmRep.TOSTEquivalenceProven005 || srmRep.Verdict != VerdictFail { + t.Fatalf("expected isolated SRM to invalidate experiment while keeping CUPEDValid=true, got %+v", srmRep) + } + + cupedOnly := baseExp + cupedOnly.HasPostTreatmentCovariate = true + cupedRep, err := EvaluateExperiment(cupedOnly) + if err != nil { + t.Fatalf("EvaluateExperiment cupedOnly failed: %v", err) + } + if cupedRep.SRMDetected || cupedRep.CUPEDValid || cupedRep.ClusterSignificantAt005 || + !cupedRep.NonInferiorityProvenAt005 || !cupedRep.TOSTEquivalenceProven005 || cupedRep.Verdict != VerdictFail { + t.Fatalf("expected isolated post-treatment covariate to set CUPEDValid=false and Verdict=fail while SRMDetected=false, got %+v", cupedRep) + } + + pkRep, err := EvaluatePassK(PassKInput{ + K: 3, + Tasks: []TaskTrialCount{ + {TaskID: "t1", Trials: 6, Successes: 5}, + {TaskID: "t2", Trials: 5, Successes: 3}, + {TaskID: "t3", Trials: 4, Successes: 2}, + }, + CheapModelCostUSD: "0.50", + VerifierCostUSD: "0.20", + StrongModelCostUSD: "3.00", + GateFailRate: "1/2", + StrongRetryRate: "1/5", + HumanCodingHours: "10", + HumanReviewHours: "5", + HumanReworkHours: "3", + AcceptedTasks: 9, + AttemptedTasks: 12, + }) + if err != nil { + t.Fatalf("EvaluatePassK heterogeneous failed: %v", err) + } + if pkRep.MeanPass1 != "29/45" || pkRep.UnbiasedCombinatorialPassK != "1/5" || pkRep.UnbiasedCombinatorialPassAt != "1" || + pkRep.HumanHoursPerAcceptedTask != "2" || pkRep.AcceptanceRate != "3/4" { + t.Fatalf("unexpected heterogeneous PassK report: %+v", pkRep) + } +} diff --git a/pkg/sliceoracles/oracles.go b/pkg/sliceoracles/oracles.go new file mode 100644 index 0000000..42503cc --- /dev/null +++ b/pkg/sliceoracles/oracles.go @@ -0,0 +1,399 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +// Package sliceoracles implements CloudRING's Fork 1A (32-oracle batch +// autonomous acceptance), Fork 2A (CORE vs QUAL dependency split and 2-PR +// non-overlapping path concurrency), and Deferred Fork 3 (no-new-hardware +// subgraph execution under two-slot LKG safety S1-S6 and RSI gates G1-G7). +package sliceoracles + +import ( + "encoding/json" + "errors" + "fmt" + "path" + "slices" + "strings" + + "github.com/opencloudtech/CloudRING/pkg/rsi" +) + +const ( + ExpectedAPIVersion = "cloudring.org/slice-oracles/v1" + ExpectedKind = "SliceOracleBatch" + ExpectedSliceCount = 32 + MaxWritePRsPerRepo = 2 + MinPinnedLKGSlots = 2 + MinSoakMinutes = 30 +) + +// Batch represents the frozen 32-oracle contract (Fork 1A + Fork 2A + Deferred Fork 3). +type Batch struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + DecisionForks DecisionForks `json:"decisionForks"` + MaxConcurrentWritePRsPerRepo int `json:"maxConcurrentWritePRsPerRepo"` + OwnerAcceptanceLatencyDays int `json:"ownerAcceptanceLatencyDays"` + WeeklyOwnerAuditHours int `json:"weeklyOwnerAuditHours"` + LKGInvariants []string `json:"lkgInvariants"` + Oracles []SliceOracle `json:"oracles"` +} + +// DecisionForks records the locked ROUTE-v2 §7 fork choices. +type DecisionForks struct { + Fork1 string `json:"fork1"` + Fork2 string `json:"fork2"` + Fork3 string `json:"fork3"` +} + +// SliceOracle defines the machine-verifiable acceptance oracle for one delivery slice. +type SliceOracle struct { + SliceID string `json:"sliceId"` + Title string `json:"title"` + Goals []string `json:"goals"` + SplitMode bool `json:"splitMode"` + CoreDependencies []string `json:"coreDependencies"` + QualDependencies []string `json:"qualDependencies"` + RequiresFork3Hardware bool `json:"requiresFork3Hardware"` + AllowedPaths []string `json:"allowedPaths"` + AutonomyClass string `json:"autonomyClass"` + VerificationCommand string `json:"verificationCommand"` + RequiredGates []string `json:"requiredGates"` + DaysHist string `json:"daysHist"` + DaysFactory string `json:"daysFactory"` +} + +// LKGSafetyState captures the 6 necessary and sufficient self-evolution liveness invariants (S1-S6). +type LKGSafetyState struct { + ImmutableBootloaderVerified bool `json:"immutableBootloaderVerified"` + PinnedLKGSlots int `json:"pinnedLkgSlots"` + SoakMinutes int `json:"soakMinutes"` + WebhookBypassVerified bool `json:"webhookBypassVerified"` + PreMigrationSnapshotVerified bool `json:"preMigrationSnapshotVerified"` + MetaRSISelfCheckPassed bool `json:"metaRsiSelfCheckPassed"` + CandidateSHA string `json:"candidateSha"` + QuarantinedSHAs []string `json:"quarantinedShas"` +} + +// ActivePR describes an open write PR for Fork 2A concurrency validation. +type ActivePR struct { + SliceID string `json:"sliceId"` + TouchedPaths []string `json:"touchedPaths"` +} + +// SliceAcceptanceReceipt returns the deterministic verdict for a slice candidate. +type SliceAcceptanceReceipt struct { + SliceID string `json:"sliceId"` + Phase string `json:"phase"` + Verdict string `json:"verdict"` + PromotedToLKG bool `json:"promotedToLkg"` + Blockers []string `json:"blockers,omitempty"` + RSIReport *rsi.Report `json:"rsiReport,omitempty"` +} + +// ParseBatch strictly decodes and validates the 32-oracle batch contract. +func ParseBatch(data []byte) (*Batch, error) { + if len(data) == 0 { + return nil, errors.New("sliceoracles: empty oracle batch") + } + var b Batch + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + if err := dec.Decode(&b); err != nil { + return nil, fmt.Errorf("sliceoracles: decode batch: %w", err) + } + if err := b.Validate(); err != nil { + return nil, err + } + return &b, nil +} + +// Validate checks structural, Fork 1A/2A/3, and DAG acyclicity invariants. +func (b *Batch) Validate() error { + if b == nil { + return errors.New("sliceoracles: nil batch") + } + if b.APIVersion != ExpectedAPIVersion { + return fmt.Errorf("sliceoracles: invalid apiVersion %q", b.APIVersion) + } + if b.Kind != ExpectedKind { + return fmt.Errorf("sliceoracles: invalid kind %q", b.Kind) + } + if b.MaxConcurrentWritePRsPerRepo != MaxWritePRsPerRepo { + return fmt.Errorf("sliceoracles: maxConcurrentWritePRsPerRepo must be %d, got %d", MaxWritePRsPerRepo, b.MaxConcurrentWritePRsPerRepo) + } + if b.OwnerAcceptanceLatencyDays != 0 { + return fmt.Errorf("sliceoracles: Fork 1A requires ownerAcceptanceLatencyDays=0, got %d", b.OwnerAcceptanceLatencyDays) + } + if len(b.LKGInvariants) != 6 { + return fmt.Errorf("sliceoracles: expected 6 S1-S6 LKG invariants, got %d", len(b.LKGInvariants)) + } + if len(b.Oracles) != ExpectedSliceCount { + return fmt.Errorf("sliceoracles: expected %d slice oracles, got %d", ExpectedSliceCount, len(b.Oracles)) + } + + byID := make(map[string]SliceOracle, len(b.Oracles)) + for _, o := range b.Oracles { + if strings.TrimSpace(o.SliceID) == "" { + return errors.New("sliceoracles: empty sliceId") + } + if _, exists := byID[o.SliceID]; exists { + return fmt.Errorf("sliceoracles: duplicate sliceId %q", o.SliceID) + } + if len(o.Goals) == 0 || len(o.AllowedPaths) == 0 || len(o.RequiredGates) == 0 { + return fmt.Errorf("sliceoracles: slice %s missing goals, allowedPaths, or requiredGates", o.SliceID) + } + if o.SplitMode && len(o.QualDependencies) == 0 { + return fmt.Errorf("sliceoracles: split slice %s must declare qualDependencies", o.SliceID) + } + byID[o.SliceID] = o + } + + // Verify Fork 2A structural rules: C24 depends on C14 (not C23); C20 does not depend on C19. + c24, ok := byID["C24"] + if !ok || !slices.Equal(c24.CoreDependencies, []string{"C14"}) { + return errors.New("sliceoracles: Fork 2A requires C24 coreDependencies to be [C14]") + } + c20, ok := byID["C20"] + if !ok || slices.Contains(c20.CoreDependencies, "C19") { + return errors.New("sliceoracles: Fork 2A requires C20 coreDependencies not to wait for C19") + } + + // Verify all dependency references exist and form a DAG. + visiting := make(map[string]bool, len(byID)) + visited := make(map[string]bool, len(byID)) + var dfs func(id string) error + dfs = func(id string) error { + if visiting[id] { + return fmt.Errorf("sliceoracles: dependency cycle detected at %s", id) + } + if visited[id] { + return nil + } + visiting[id] = true + o := byID[id] + for _, dep := range append(slices.Clone(o.CoreDependencies), o.QualDependencies...) { + if _, exists := byID[dep]; !exists { + return fmt.Errorf("sliceoracles: slice %s references unknown dependency %s", id, dep) + } + if err := dfs(dep); err != nil { + return err + } + } + visiting[id] = false + visited[id] = true + return nil + } + for id := range byID { + if err := dfs(id); err != nil { + return err + } + } + return nil +} + +// PartitionNoNewHardware splits the 32 oracles into immediately executable no-new-hardware slices +// vs slices gated on deferred Fork 3 hardware. +func (b *Batch) PartitionNoNewHardware() (noNewHW []SliceOracle, deferredHW []SliceOracle) { + for _, o := range b.Oracles { + if o.RequiresFork3Hardware { + deferredHW = append(deferredHW, o) + } else { + noNewHW = append(noNewHW, o) + } + } + return noNewHW, deferredHW +} + +// ReadyNoNewHardwareQueue returns the ordered list of no-new-hardware slices (and phase "CORE" or "FULL") +// whose dependencies are satisfied given completedCORE and completedFULL sets. +func (b *Batch) ReadyNoNewHardwareQueue(completedCORE, completedFULL map[string]bool) []string { + var ready []string + for _, o := range b.Oracles { + if o.RequiresFork3Hardware { + continue + } + sid := o.SliceID + if o.SplitMode { + if !completedCORE[sid] { + coreOK := true + for _, dep := range o.CoreDependencies { + if dep == "C16" { + if !completedCORE[dep] && !completedFULL[dep] { + coreOK = false + break + } + } else if !completedFULL[dep] { + coreOK = false + break + } + } + if coreOK { + ready = append(ready, sid+":CORE") + } + } else if !completedFULL[sid] { + qualOK := true + for _, qdep := range o.QualDependencies { + if !completedFULL[qdep] { + qualOK = false + break + } + } + if qualOK { + ready = append(ready, sid+":QUAL") + } + } + } else if !completedFULL[sid] { + depsOK := true + for _, dep := range o.CoreDependencies { + if !completedFULL[dep] { + depsOK = false + break + } + } + if depsOK { + ready = append(ready, sid+":FULL") + } + } + } + return ready +} + +// ValidateConcurrentWritePRs enforces Fork 2A: at most 2 active write PRs per repository, +// targeting distinct known slices with non-overlapping repository-relative path prefixes. +func (b *Batch) ValidateConcurrentWritePRs(active []ActivePR) error { + if len(active) > b.MaxConcurrentWritePRsPerRepo { + return fmt.Errorf("sliceoracles: %d active write PRs exceed Fork 2A cap of %d", len(active), b.MaxConcurrentWritePRsPerRepo) + } + known := make(map[string]bool, len(b.Oracles)) + for _, o := range b.Oracles { + known[o.SliceID] = true + } + ids := make([]string, len(active)) + paths := make([][]string, len(active)) + for i, pr := range active { + id := strings.TrimSpace(pr.SliceID) + if id == "" || len(pr.TouchedPaths) == 0 { + return errors.New("sliceoracles: active PR must declare sliceId and touchedPaths") + } + if !known[id] { + return fmt.Errorf("sliceoracles: active PR targets unknown slice %q", id) + } + ids[i] = id + for _, p := range pr.TouchedPaths { + clean, err := cleanTouchedPath(p) + if err != nil { + return fmt.Errorf("sliceoracles: slice %s: %w", id, err) + } + paths[i] = append(paths[i], clean) + } + } + for i := range active { + for j := i + 1; j < len(active); j++ { + if ids[i] == ids[j] { + return fmt.Errorf("sliceoracles: concurrent PRs target the same slice %s", ids[i]) + } + for _, p1 := range paths[i] { + for _, p2 := range paths[j] { + if pathsOverlap(p1, p2) { + return fmt.Errorf("sliceoracles: concurrent PRs (%s, %s) overlap on paths %q and %q", ids[i], ids[j], p1, p2) + } + } + } + } + } + return nil +} + +// cleanTouchedPath returns the cleaned repository-relative form of p and rejects +// empty, absolute and repository-escaping paths. +func cleanTouchedPath(p string) (string, error) { + trimmed := strings.TrimSpace(p) + if trimmed == "" || strings.HasPrefix(trimmed, "/") { + return "", fmt.Errorf("touched path %q must be a non-empty repository-relative path", p) + } + clean := path.Clean(trimmed) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("touched path %q must stay inside the repository", p) + } + return clean, nil +} + +// pathsOverlap reports whether two cleaned repository-relative paths are equal or nested. +func pathsOverlap(a, b string) bool { + return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/") +} + +// ValidateLKGSafety checks all 6 S1-S6 invariants required for dead-end-free autonomous self-evolution. +func ValidateLKGSafety(s LKGSafetyState) []string { + var blockers []string + if !s.ImmutableBootloaderVerified { + blockers = append(blockers, "S1: immutable bootloader supervisor checksum not verified") + } + if s.PinnedLKGSlots < MinPinnedLKGSlots { + blockers = append(blockers, fmt.Sprintf("S2: requires at least %d locally pinned LKG slots, got %d", MinPinnedLKGSlots, s.PinnedLKGSlots)) + } + if s.SoakMinutes < MinSoakMinutes { + blockers = append(blockers, fmt.Sprintf("S2: requires >= %d minutes soak before LKG promotion, got %d", MinSoakMinutes, s.SoakMinutes)) + } + if !s.WebhookBypassVerified { + blockers = append(blockers, "S3: rollback namespace/channel bypass of admission/conversion webhooks not verified") + } + if !s.PreMigrationSnapshotVerified { + blockers = append(blockers, "S4: pre-migration state snapshot and V_old/V_new round-trip not verified") + } + if !s.MetaRSISelfCheckPassed { + blockers = append(blockers, "S5: meta-RSI noop self-check canary did not pass") + } + if strings.TrimSpace(s.CandidateSHA) == "" { + blockers = append(blockers, "S6: candidateSha is empty") + } else if slices.Contains(s.QuarantinedSHAs, s.CandidateSHA) { + blockers = append(blockers, fmt.Sprintf("S6: candidateSha %s is in incident quarantine and requires a new regression test", s.CandidateSHA)) + } + return blockers +} + +// EvaluateSliceCandidate runs autonomous Fork 1A acceptance for a slice candidate by combining +// oracle membership, hardware boundary check, S1-S6 LKG safety, and the frozen RSI G1-G7 evaluator. +func (b *Batch) EvaluateSliceCandidate(sliceID, phase string, profile rsi.CandidateProfile, lkg LKGSafetyState) (*SliceAcceptanceReceipt, error) { + if err := b.Validate(); err != nil { + return nil, err + } + var oracle *SliceOracle + for i := range b.Oracles { + if b.Oracles[i].SliceID == sliceID { + oracle = &b.Oracles[i] + break + } + } + if oracle == nil { + return nil, fmt.Errorf("sliceoracles: unknown slice %q", sliceID) + } + + receipt := &SliceAcceptanceReceipt{ + SliceID: sliceID, + Phase: phase, + Verdict: rsi.VerdictFail, + } + + if oracle.RequiresFork3Hardware { + receipt.Blockers = append(receipt.Blockers, fmt.Sprintf("Fork 3 deferred: slice %s requires additional multi-node/second-provider hardware", sliceID)) + } + + receipt.Blockers = append(receipt.Blockers, ValidateLKGSafety(lkg)...) + + rep, err := rsi.EvaluateCandidate(profile) + if err != nil { + return nil, err + } + receipt.RSIReport = &rep + if rep.Verdict != rsi.VerdictPass { + receipt.Blockers = append(receipt.Blockers, fmt.Sprintf("RSI G1-G7 evaluator returned verdict=%s (failed=%d, killSwitch=%s)", rep.Verdict, rep.Failed, rep.KillSwitchLevel)) + } + + if len(receipt.Blockers) == 0 { + receipt.Verdict = rsi.VerdictPass + receipt.PromotedToLKG = true + } + return receipt, nil +} diff --git a/pkg/sliceoracles/oracles_test.go b/pkg/sliceoracles/oracles_test.go new file mode 100644 index 0000000..f87f1ec --- /dev/null +++ b/pkg/sliceoracles/oracles_test.go @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +package sliceoracles_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/opencloudtech/CloudRING/pkg/rsi" + "github.com/opencloudtech/CloudRING/pkg/sliceoracles" +) + +func loadCanonicalBatch(t *testing.T) *sliceoracles.Batch { + t.Helper() + path := filepath.Join("..", "..", "contracts", "slice-oracles", "oracles-32.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + batch, err := sliceoracles.ParseBatch(data) + if err != nil { + t.Fatalf("ParseBatch: %v", err) + } + return batch +} + +func loadCompliantRSIProfile(t *testing.T) rsi.CandidateProfile { + t.Helper() + path := filepath.Join("..", "..", "contracts", "rsi-governance", "fixtures", "compliant-k3-profile.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + prof, err := rsi.ParseProfile(data) + if err != nil { + t.Fatalf("ParseProfile: %v", err) + } + return prof +} + +func validLKGState() sliceoracles.LKGSafetyState { + return sliceoracles.LKGSafetyState{ + ImmutableBootloaderVerified: true, + PinnedLKGSlots: 2, + SoakMinutes: 30, + WebhookBypassVerified: true, + PreMigrationSnapshotVerified: true, + MetaRSISelfCheckPassed: true, + CandidateSHA: "a326123af6ca994ce876bb786dff7683f181ca36", + QuarantinedSHAs: []string{"deadbeef00000000000000000000000000000000"}, + } +} + +func TestCanonical32OracleBatchAndNoNewHWPartition(t *testing.T) { + batch := loadCanonicalBatch(t) + noHW, deferredHW := batch.PartitionNoNewHardware() + if len(noHW) != 28 { + t.Fatalf("expected 28 no-new-hardware slices, got %d", len(noHW)) + } + if len(deferredHW) != 4 { + t.Fatalf("expected 4 deferred Fork 3 hardware slices, got %d", len(deferredHW)) + } + + // Initially with C01 accepted, SP01 and C06 are immediately ready in the 2-agent parallel queue. + completedCORE := map[string]bool{"C01": true} + completedFULL := map[string]bool{"C01": true} + ready := batch.ReadyNoNewHardwareQueue(completedCORE, completedFULL) + if len(ready) != 2 || ready[0] != "SP01:FULL" || ready[1] != "C06:FULL" { + t.Fatalf("expected initial ready pair [SP01:FULL C06:FULL], got %v", ready) + } + + // Verify Fork 2A split unlocking: once C01..C11 are accepted (before C13 billing and C14 experience!), + // C15, C16, and DNSAAS CORE builds unlock immediately in parallel with C12. + for _, s := range []string{"SP01", "C02", "C03", "C04", "C05", "C06", "C07", "C08", "C09", "C10", "C11"} { + completedCORE[s] = true + completedFULL[s] = true + } + readyAfterC11 := batch.ReadyNoNewHardwareQueue(completedCORE, completedFULL) + wantAfterC11 := []string{"C12:FULL", "C15:CORE", "C16:CORE", "DNSAAS:CORE"} + if len(readyAfterC11) != len(wantAfterC11) { + t.Fatalf("after C11 expected %v, got %v", wantAfterC11, readyAfterC11) + } + for i := range wantAfterC11 { + if readyAfterC11[i] != wantAfterC11[i] { + t.Fatalf("after C11[%d]: expected %s, got %s", i, wantAfterC11[i], readyAfterC11[i]) + } + } +} + +func TestFork2AConcurrentWritePRValidation(t *testing.T) { + batch := loadCanonicalBatch(t) + + // Valid pair: SP01 and C06 have disjoint paths. + err := batch.ValidateConcurrentWritePRs([]sliceoracles.ActivePR{ + {SliceID: "SP01", TouchedPaths: []string{"pkg/rsi", "pkg/sliceoracles"}}, + {SliceID: "C06", TouchedPaths: []string{"pkg/backup", "pkg/dataact"}}, + }) + if err != nil { + t.Fatalf("expected disjoint 2-PR pair to pass, got: %v", err) + } + + // Reject overlapping paths. + err = batch.ValidateConcurrentWritePRs([]sliceoracles.ActivePR{ + {SliceID: "C02", TouchedPaths: []string{"internal/portal/ui"}}, + {SliceID: "C14", TouchedPaths: []string{"internal/portal"}}, + }) + if err == nil { + t.Fatal("expected overlapping paths across concurrent PRs to fail") + } + + // Reject >2 active write PRs. + err = batch.ValidateConcurrentWritePRs([]sliceoracles.ActivePR{ + {SliceID: "SP01", TouchedPaths: []string{"pkg/rsi"}}, + {SliceID: "C06", TouchedPaths: []string{"pkg/backup"}}, + {SliceID: "C03", TouchedPaths: []string{"pkg/transactionalstate"}}, + }) + if err == nil { + t.Fatal("expected 3 concurrent write PRs to fail Fork 2A cap of 2") + } + + // Reject inputs that only differ by spelling: dot segments, blank or escaping paths, padded or unknown slice ids. + rejected := []struct { + name string + active []sliceoracles.ActivePR + }{ + {"dot-segment overlap", []sliceoracles.ActivePR{ + {SliceID: "SP01", TouchedPaths: []string{"./pkg/rsi"}}, + {SliceID: "C06", TouchedPaths: []string{"pkg/rsi/"}}, + }}, + {"blank path", []sliceoracles.ActivePR{ + {SliceID: "SP01", TouchedPaths: []string{" "}}, + {SliceID: "C06", TouchedPaths: []string{"pkg/backup"}}, + }}, + {"escaping path", []sliceoracles.ActivePR{ + {SliceID: "SP01", TouchedPaths: []string{"pkg/../../etc"}}, + {SliceID: "C06", TouchedPaths: []string{"pkg/backup"}}, + }}, + {"absolute path", []sliceoracles.ActivePR{ + {SliceID: "SP01", TouchedPaths: []string{"/pkg/rsi"}}, + {SliceID: "C06", TouchedPaths: []string{"pkg/backup"}}, + }}, + {"padded duplicate slice", []sliceoracles.ActivePR{ + {SliceID: "SP01 ", TouchedPaths: []string{"pkg/rsi"}}, + {SliceID: "SP01", TouchedPaths: []string{"pkg/sliceoracles"}}, + }}, + {"unknown slice", []sliceoracles.ActivePR{ + {SliceID: "C99", TouchedPaths: []string{"pkg/rsi"}}, + {SliceID: "C06", TouchedPaths: []string{"pkg/backup"}}, + }}, + } + for _, tc := range rejected { + if err := batch.ValidateConcurrentWritePRs(tc.active); err == nil { + t.Errorf("%s: expected rejection, got nil", tc.name) + } + } +} + +func TestAutonomousSliceEvaluationAndDeferredFork3Guard(t *testing.T) { + batch := loadCanonicalBatch(t) + prof := loadCompliantRSIProfile(t) + lkg := validLKGState() + + // SP01 is a no-new-hardware slice and passes when RSI G1-G7 + S1-S6 pass. + rec, err := batch.EvaluateSliceCandidate("SP01", "FULL", prof, lkg) + if err != nil { + t.Fatalf("EvaluateSliceCandidate SP01: %v", err) + } + if rec.Verdict != rsi.VerdictPass || !rec.PromotedToLKG { + t.Fatalf("expected SP01 to pass and promote to LKG, got verdict=%s blockers=%v", rec.Verdict, rec.Blockers) + } + + // C27 requires deferred Fork 3 second-provider hardware and must be blocked now. + recHW, err := batch.EvaluateSliceCandidate("C27", "FULL", prof, lkg) + if err != nil { + t.Fatalf("EvaluateSliceCandidate C27: %v", err) + } + if recHW.Verdict != rsi.VerdictFail || recHW.PromotedToLKG { + t.Fatalf("expected deferred Fork 3 slice C27 to be blocked while hardware is deferred") + } + + // Quarantined SHA (S6) must block promotion even on a no-new-hardware slice. + badLKG := validLKGState() + badLKG.CandidateSHA = "deadbeef00000000000000000000000000000000" + recQ, err := batch.EvaluateSliceCandidate("SP01", "FULL", prof, badLKG) + if err != nil { + t.Fatalf("EvaluateSliceCandidate quarantined: %v", err) + } + if recQ.Verdict != rsi.VerdictFail || recQ.PromotedToLKG { + t.Fatalf("expected quarantined SHA to fail S6 gate") + } +} diff --git a/roadmap/DELIVERY_SLICES.md b/roadmap/DELIVERY_SLICES.md index c7cd4f7..c43d13b 100644 --- a/roadmap/DELIVERY_SLICES.md +++ b/roadmap/DELIVERY_SLICES.md @@ -19,15 +19,25 @@ replace required independent infrastructure or a real human participant. not assert readiness or completed predecessors. `delivered` still requires every qualification dependency and the original full evidence contract. C dependencies below control implementation entry; read-only preparation can happen earlier. -External authorization or capacity blocks its affected live result while -independent authorized work continues. +Under Fork 1A (`PROCDEC`), all 32 slice acceptance oracles (`C01–C28`, `SP01`, +`DBAAS`, `DNSAAS`, `FILESAAS`) are frozen in +`contracts/slice-oracles/oracles-32.json` and evaluated autonomously by `VER` +and the RSI gate (`pkg/rsi`, `pkg/sliceoracles`). Under Fork 2A, `C15`, `C16`, +`C17`, `C20`, `DBAAS`, `DNSAAS` and `FILESAAS` split core implementation entry +(`CORE`, after `C11` and their technical prerequisite) from full billing and +experience qualification (`QUAL`, after `C13` and `C14`); `C24` enters after +`C14` instead of `C23`; and `C20` does not wait for `C19`. Hardware-gated +multi-node NVMe/GPU/second-provider qualification (`Fork 3`) is deferred while +all no-new-hardware slices advance automatically. External authorization or +capacity blocks its affected live result while independent authorized work +continues. ## Slice sequence | Slice | Entry | Accepted working result | Original coverage and full-qualification boundary | | --- | --- | --- | --- | | C01 | None | Reproducible public delivery baseline; useful delivery fixes accepted; every current-family WIP root classified and preserved; invalid candidate rejected; fixed numeric measurement profiles. | Partial G00 / CR-G00-DELIVERY. Current endpoint, GitOps revision and separately verified or unknown host-binary provenance are recorded. Full downstream/live G00 qualification follows in C08. Proposed downstream requirements are not automatically ratified. | -| C02 | C01 accepted | Public isolated developer create, repeat, restart, positive/negative smoke, destroy with zero owned residue and recreate; same profile beside reference installation. | Full G01 / CR-G01-DEV-INSTALL, including clean-room tutorial and human/installation profile. Production shortcuts must be rejected. G01 does not wait for full G00. | +| C02 | C01 and SP01 accepted | Public isolated developer create, repeat, restart, positive/negative smoke, destroy with zero owned residue and recreate; same profile beside reference installation. | Full G01 / CR-G01-DEV-INSTALL, including clean-room tutorial and human/installation profile. Production shortcuts must be rejected. G01 does not wait for full G00. | | C03 | C02 | Public durable project/operation API, CLI and read-only UI; transactions, outbox, crash, retry, concurrency, migration and restore. | Partial G03 / CR-G03-KERNEL. Full directory, production failover and scale qualification in C09. | | C04 | C03 | Two developer tenants invite/login/use own project/logout; token/session revocation and direct API/browser isolation negatives. | Partial G04 / CR-G04-IDENTITY and G05 / CR-G05-IAM. Full production identity, recovery and workload identity in C09. | | C05 | C04; applicable C06 barriers for reference-site mutations | Real developer VM through public Go/OCS; login, project, create, bounded access, write/read digest, retry/restart, one VM, delete and cleanup; one real user performs a useful task. | Partial G07–G09 and G15 / CR-G15-COMPUTE. Quota/reservation and explicit zero-priced-metered or non-billable policy required now. Pilot dependencies are owned and bounded; Network/Volume/Image products and full Compute remain incomplete. | @@ -40,20 +50,24 @@ independent authorized work continues. | C12 | C11 | Complete real order/subscription/entitlement lifecycle, quota/capacity, compensation, crash/retry/cancel and restore through both connector paths. | Full G09 / CR-G09-LIFECYCLE. | | C13 | C12 | Real test usage to exact balanced ledger, invoice, correction, budget and publisher share with replay/restore/HA and eligibility denial. | Full G10 / CR-G10-BILLING. Uses isolated non-financial data; actual customer charging needs separate authority. | | C14 | C13 | Equivalent complete API/CLI/portal/agent actions, policy, identity, audit, resumability, accessible UI and authoritative backend comparison. | Full G11 / CR-G11-EXPERIENCE. | -| C15 | C14, C07 | Standalone OCS Network lifecycle, dual-stack connectivity, tenant denial, quota/usage, actual failure and cleanup. | Full G12 / CR-G12-NETWORK. | -| C16 | C14, C08 | Standalone OCS Volume lifecycle, real CSI/snapshot/restore, encryption, node-loss digest and accounting. | Full G13 / CR-G13-VOLUME. Independent of C15; production rejects replication-one or unknown topology. | -| C17 | C16 | Standalone OCS Image/Artifact lifecycle, immutable provenance, VM/OCI round trip, resumable import, scan/quarantine, boot harness, restore and safe GC. | Full G14 / CR-G14-ARTIFACT. Boot harness does not require completed C18. | +| C15 | CORE: C11, C07; QUAL: C13, C14 | Standalone OCS Network lifecycle, dual-stack connectivity, tenant denial, quota/usage, actual failure and cleanup. | Full G12 / CR-G12-NETWORK. Core implementation starts after C11 and C07; full billing/experience qualification completes after C13 and C14 (Fork 2A). | +| C16 | CORE: C11, C08; QUAL: C13, C14 | Standalone OCS Volume lifecycle, real CSI/snapshot/restore, encryption, node-loss digest and accounting. | Full G13 / CR-G13-VOLUME. Independent of C15; core implementation starts after C11 and C08, full qualification after C13 and C14 (Fork 2A). Production rejects replication-one or unknown topology. | +| C17 | CORE: C11, C16; QUAL: C13, C14 | Standalone OCS Image/Artifact lifecycle, immutable provenance, VM/OCI round trip, resumable import, scan/quarantine, boot harness, restore and safe GC. | Full G14 / CR-G14-ARTIFACT. Core implementation starts after C11 and C16; full qualification after C13 and C14 (Fork 2A). Boot harness does not require completed C18. | | C18 | C15, C16, C17 | Complete Compute through ordinary Network/Volume/Image entitlements; full use, access, billing, failure and recovery; existing pilot user migrates or exports data safely. | Full G15 / CR-G15-COMPUTE. Supported cold recovery is measured; live migration is claimed only with its actual prerequisites and proof. | | C19 | C18 | Real tenant Kubernetes, workload/data, short-lived access, scale, signed upgrade, failure, isolated restore and full cleanup. | Full G16 / CR-G16-KUBERNETES. | -| C20 | C14, C16, C19 | Real local/reference and remote S3 lifecycle, objects/digests, credentials, retention, metering, failure and isolated restore. | Full G17 / CR-G17-OBJECT. Replace placeholder catalog path; unsupported durability cannot pass full product acceptance. | +| C20 | CORE: C11, C16; QUAL: C13, C14 | Real local/reference and remote S3 lifecycle, objects/digests, credentials, retention, metering, failure and isolated restore. | Full G17 / CR-G17-OBJECT. Does not wait for C19 (Fork 2A); core starts after C11 and C16, full qualification after C13 and C14. Replace placeholder catalog path; unsupported durability cannot pass full product acceptance. | | C21 | C19, C20 | Customer self-service backup and verified off-cell restore of every claimed data class, ownership, retention, retries, RPO/RTO, cost and cleanup. | Full G18 / CR-G18-BACKUP. | | C22 | C19, C21 | Supported SSH/Kubernetes/console access request, approval, grant, use, expiry/revoke and recovery without reviving grants. | Full G19 / CR-G19-ACCESS. | | C23 | C22 | Durable support case, consent, bounded redacted bundle, controlled access, routing outage, escalation, resolve and restore. | Full G20 / CR-G20-SUPPORT. | -| C24 | C23 | Real authorized remote/API-only system with complete lifecycle/usage and two independent replaceable adapters; independent developer uses public core only. | Full G21 / CR-G21-EXTERNAL. Unimplemented external templates remain templates. | -| C25 | All C01–C24; functional G22 entry below | Adjacent signed release upgrade, failed canary, rollback/restore boundary and integrated supported failure campaign under continuous identity/operation/data/billing probes. | Full G23 / CR-G23-RESILIENCE and CR-G23-OPERATIONS-ENTRY. Functional G22 proof precedes campaign; human G22 qualification follows in C26. | +| C24 | C14 | Real authorized remote/API-only system with complete lifecycle/usage and two independent replaceable adapters; independent developer uses public core only. | Full G21 / CR-G21-EXTERNAL. Enters after C14 instead of C23 (Fork 2A). Unimplemented external templates remain templates. | +| SP01 | C01 | SafePush admission, provenance verification and two-slot LKG rollback guardrails (`S1–S6`). | Preparatory G00 / CR-G00-DELIVERY and autonomous RSI safety gate before full C02/C08 promotion. | +| DBAAS | CORE: C11, C16; QUAL: C13, C14, C21 | Standalone OCS Managed PostgreSQL (CNPG) lifecycle, HA failover, point-in-time recovery, quota/metering and tenant isolation. | Mandatory 1.0 service category (Fork 3 2026-10-14); core starts after C11 and C16, full qualification after C13, C14 and C21 (Fork 2A). | +| DNSAAS | CORE: C11, C07; QUAL: C13, C14, C15 | Standalone OCS Managed DNS lifecycle, authoritative zone/record reconciliation, tenant delegation, quota/metering and DNSSEC/split-horizon verification. | Mandatory 1.0 service category (Fork 3 2026-10-14); core starts after C11 and C07, full qualification after C13, C14 and C15 (Fork 2A). | +| FILESAAS | CORE: C11, C16; QUAL: C13, C14 | Standalone OCS Shared File Storage (RWX NFS/CephFS) lifecycle, mount isolation, snapshot/restore, quota/metering and cleanup. | Table-stakes 1.0 service category (6 of 7 competitors, Wave 6); core starts after C11 and C16, full qualification after C13 and C14 (Fork 2A). | +| C25 | All C01–C24, SP01, DBAAS, DNSAAS, FILESAAS; functional G22 entry below | Adjacent signed release upgrade, failed canary, rollback/restore boundary and integrated supported failure campaign under continuous identity/operation/data/billing probes. | Full G23 / CR-G23-RESILIENCE and CR-G23-OPERATIONS-ENTRY. Functional G22 proof precedes campaign; human G22 qualification follows in C26. | | C26 | C25 | Independent engineer completes full incident set, diagnosis, repair, rotation, maintenance and 14-day toil measurement on the accepted candidate. | Full G22 / CR-G22-OPERATIONS. Same release as C25; later runtime change creates a new candidate and repeats affected G23 checks. | | C27 | C26 | Independently administered bare-metal provider and second engineer reproduce exact public release, install/use/upgrade/failure/restore/cleanup and original product development. | Full G24 / CR-G24-PORTABILITY. Separate outstanding PhoenixNAP site/IaC/GitLab CI commitment must reach accepted protected provider main with its exact core pin and Stage 9; it remains configuration-only and does not claim a PhoenixNAP deployment. Another independently controlled live provider is allowed but does not cancel this obligation. | -| C28 | All C01–C27 | Final adversarial audit, upstream fixes, propagated regressions, public immutable platform/OCS 1.0, support/compatibility/runbooks and exact independent proof. | Full G27 / CR-G27-RELEASE and all remaining standalone 1.0 legacy obligations, Tasks 25–27 and F1–F5. No required unverified blocker, generic private dependency or hidden manual step. | +| C28 | All C01–C27, SP01, DBAAS, DNSAAS, FILESAAS | Final adversarial audit, upstream fixes, propagated regressions, public immutable platform/OCS 1.0, support/compatibility/runbooks and exact independent proof. | Full G27 / CR-G27-RELEASE and all remaining standalone 1.0 legacy obligations, Tasks 25–27 and F1–F5. No required unverified blocker, generic private dependency or hidden manual step. | G25 / CR-G25-CELLS and G26 / CR-G26-FEDERATION retain their complete original goal files and depend on G27. They are post-1.0 expansion tracks outside C01–C28; diff --git a/roadmap/EXECUTION_CONTRACT.md b/roadmap/EXECUTION_CONTRACT.md index 170b15d..b9864a0 100644 --- a/roadmap/EXECUTION_CONTRACT.md +++ b/roadmap/EXECUTION_CONTRACT.md @@ -96,10 +96,14 @@ complete goal, every qualification dependency and verified evidence. C01 baselin acceptance is the entry to C02/G01; it does not falsely complete G00's later clean-room/downstream/live chain. -Only one active write PR/MR per repository is allowed. Read-only research and -verification may proceed in parallel. A compatible batch of already accepted -public commits may share one downstream pin, but each requirement must remain -traceable. +At most two concurrent active write PRs/MRs per repository are allowed when they +target distinct delivery slices with non-overlapping path ownership (Fork 2A). +Read-only research and verification may proceed in parallel. Slice acceptance +executes autonomously through the pre-approved 32-oracle batch in +`contracts/slice-oracles/oracles-32.json` and the frozen RSI evaluator (`VER`, +Fork 1A), with weekly founder audit and rollback authority. A compatible batch of +already accepted public commits may share one downstream pin, but each +requirement must remain traceable. ## Universal definition of done