diff --git a/README.md b/README.md index dce7253..b7cca3a 100644 --- a/README.md +++ b/README.md @@ -698,6 +698,24 @@ require; the candidate contains no target, arguments, approval, or execution cap performs no I/O. It imports only the side-effect-free closed intent vocabulary—not connector planning or execution, PEP, MCP, persistence, network, or local-operation paths. +The contract-only GitOps provenance resolver is the next deliberately separate stage. It accepts +only a confirmed, entity-local R2/R4 `gitops.open-pr` candidate and exactly one immutable +`gitops-provenance/v1` bundle from the pinned canonical GitHub source contract. The bundle binds one +workspace and affected resource to a repository, non-symbolic base branch, exact base commit, +single update path, observed blob, exact desired content, evidence references, a maximum five-minute +validity interval, and the live planning handler's adapter version plus argument-schema digest. +Resolution reuses the GitHub handler's pure validation/canonicalization seam, rechecks the handler +contract, request cancellation, and bundle freshness after canonicalization, and returns only the +normalized repository target, exact canonical arguments and SHA-256 digest, copied evidence +references, or a closed abstention reason. It rejects missing, duplicate, stale, future, foreign, +unattached, drifted, handler-mutated, and handler-invalid inputs without I/O. + +Provenance readiness is not a PEP proposal, policy verdict, approval, credential, dispatch, Git +write, or execution capability. The resolver does not query GitHub and cannot independently prove +remote ref, commit, tree, or blob state; a future authorized read adapter must acquire those exact +facts and construct the bundle. That adapter's API calls, rate-limit/egress impact, credential +custody, and freshness policy are outside this offline slice. + Phase-L kubeconfig hydration supplies LIVE pod/workload/node evidence and discrete Kubernetes Events for TIMELINE when present. DESIRED and TELEMETRY remain unavailable unless a future connector supplies entity-attached facts. Consequently, an OOM or repeated failure is detected diff --git a/docs/specs/E2-readfed-brain-integrations.md b/docs/specs/E2-readfed-brain-integrations.md index 511df2f..4029b16 100644 --- a/docs/specs/E2-readfed-brain-integrations.md +++ b/docs/specs/E2-readfed-brain-integrations.md @@ -589,6 +589,45 @@ import, proposal, approval, persistence, network, dispatch, mutation, or executi Brain. This preserves the same deterministic rules across local and hub modes without allowing human prose to become an implicit action contract. +The first post-Brain resolver contract is GitOps-only and remains pre-PEP. For a confirmed, +entity-local R2 or R4 candidate, it requires exactly one immutable `gitops-provenance/v1` bundle +owned by the pinned GitHub source adapter contract. That bundle binds one workspace and cited +resource to one repository, a non-symbolic configured base branch, exact base commit, one update +path, observed blob identity, exact desired content, bounded PR metadata, immutable evidence +references, and a validity interval of at most five minutes. It also pins the exact +`gitops.open-pr` handler adapter version and raw argument-schema digest. + +Exact desired content is the source adapter's validated UTF-8 byte sequence. Neither the bundle nor +resolver performs Unicode, line-ending, whitespace, or YAML normalization; the handler embeds those +bytes as one JSON string in its canonical argument document. The returned `ArgumentsDigest` is +SHA-256 over that complete canonical JSON byte sequence, so it binds repository preconditions, PR +metadata, path, blob, and content together. There is intentionally no second standalone content +digest in this contract. + +The bundle carries `ObservedAt` and `ValidUntil`, normalized to UTC at construction. It requires +`ObservedAt < ValidUntil` and permits an interval of at most five minutes, inclusive. Resolution +uses an injected trusted server clock normalized to UTC: `now < ObservedAt` is future provenance, +`now >= ValidUntil` is stale, and only `ObservedAt <= now < ValidUntil` is fresh. This contract has +no implicit clock-skew allowance; a future adapter needing one must make it an explicit reviewed +source policy rather than silently widening the resolver window. + +The pure resolver fails closed on zero or multiple bundles, stale or future observations, +cross-workspace or unattached resources, noncanonical candidates or descriptors, handler/schema +drift, unsafe handler arguments, target mismatch, or any canonical output that changes the source's +repository, base, commit, path, blob, content, or PR metadata. GitHub argument semantics stay owned +by the planning handler: the resolver calls its I/O-free canonicalization seam and checks the +contract, request cancellation, and source validity window again immediately before returning. A +ready result contains only the normalized repository target, canonical arguments, their SHA-256 +digest, and evidence references. It contains no actor, role, intent ID, credential, signature, +policy decision, approval, persistence, dispatch, mutation, or execution state. + +This contract does not fetch GitHub state. A later authorized canonical read adapter must use exact +reference, commit, and tree/blob observations to construct the bundle; the offline resolver treats +that immutable adapter output as the source claim and never substitutes caller data. The later Hub +composition must separately derive workspace, actor, role, and intent ID from authenticated server +state, invoke the planner, and call the PEP. Therefore this stage is provenance-complete only; +F14.6 and the local-versus-hub exit criterion remain open. + ### 3.7 Where the Brain lives (open decision) Two placements, both viable: diff --git a/internal/connector/github/action_plan.go b/internal/connector/github/action_plan.go index fd945f3..878eb21 100644 --- a/internal/connector/github/action_plan.go +++ b/internal/connector/github/action_plan.go @@ -185,23 +185,9 @@ func (planner *OpenPRPlanner) Plan(_ context.Context, request connector.Intent) if err := validateOpenPRIntent(planner.config, request); err != nil { return connector.ActionPlan{}, err } - if err := planner.schema.Validate(request.Args); err != nil { - return connector.ActionPlan{}, fmt.Errorf("plan GitHub pull request: args are invalid") - } - - var args openPRArgs - if err := json.Unmarshal(request.Args, &args); err != nil { - return connector.ActionPlan{}, fmt.Errorf("plan GitHub pull request: decode validated args") - } - if err := validateOpenPRArgs(planner.config, args); err != nil { - return connector.ActionPlan{}, err - } - args.Changes = append([]fileChange(nil), args.Changes...) - sort.Slice(args.Changes, func(left, right int) bool { return args.Changes[left].Path < args.Changes[right].Path }) - - canonical, err := json.Marshal(args) + args, canonical, err := planner.canonicalizeOpenPRArgs(request.Args) if err != nil { - return connector.ActionPlan{}, fmt.Errorf("plan GitHub pull request: canonicalize args") + return connector.ActionPlan{}, err } identityInput := strings.Join([]string{request.Workspace, planner.config.Host, planner.config.Owner, planner.config.Repository}, "\x00") + "\x00" identity := sha256.Sum256(append([]byte(identityInput), canonical...)) @@ -237,6 +223,43 @@ func (planner *OpenPRPlanner) Plan(_ context.Context, request connector.Intent) }, nil } +// CanonicalizeOpenPRArgs applies the exact handler-owned schema and semantic policy without +// planning or performing I/O. A provenance resolver may use this seam to prove that its output is +// acceptable to the live handler, but the returned target and arguments are not authorization, +// approval, or execution capability. +func (planner *OpenPRPlanner) CanonicalizeOpenPRArgs(arguments json.RawMessage) (fleet.ResourceRef, json.RawMessage, error) { + _, canonical, err := planner.canonicalizeOpenPRArgs(arguments) + if err != nil { + return fleet.ResourceRef{}, nil, err + } + return normalizedOpenPRTarget(planner.config), append(json.RawMessage(nil), canonical...), nil +} + +func (planner *OpenPRPlanner) canonicalizeOpenPRArgs(arguments json.RawMessage) (openPRArgs, json.RawMessage, error) { + if planner == nil || planner.schema == nil { + return openPRArgs{}, nil, fmt.Errorf("plan GitHub pull request: planner is required") + } + if err := planner.schema.Validate(arguments); err != nil { + return openPRArgs{}, nil, fmt.Errorf("plan GitHub pull request: args are invalid") + } + + var args openPRArgs + if err := json.Unmarshal(arguments, &args); err != nil { + return openPRArgs{}, nil, fmt.Errorf("plan GitHub pull request: decode validated args") + } + if err := validateOpenPRArgs(planner.config, args); err != nil { + return openPRArgs{}, nil, err + } + args.Changes = append([]fileChange(nil), args.Changes...) + sort.Slice(args.Changes, func(left, right int) bool { return args.Changes[left].Path < args.Changes[right].Path }) + + canonical, err := json.Marshal(args) + if err != nil { + return openPRArgs{}, nil, fmt.Errorf("plan GitHub pull request: canonicalize args") + } + return args, json.RawMessage(canonical), nil +} + func validateOpenPRConfig(config OpenPRPlannerConfig) error { if validateHost(config.Host) != nil || validatePathComponent("owner", config.Owner, maxOwnerBytes) != nil || validatePathComponent("repository", config.Repository, maxRepositoryBytes) != nil || @@ -316,6 +339,8 @@ func validateOpenPRArgs(config OpenPRPlannerConfig, args openPRArgs) error { func validBaseRef(value string) bool { if validateBoundedText(value, maxBaseRefBytes, false, false) != nil || strings.HasPrefix(value, ".") || + strings.HasPrefix(value, "-") || strings.HasPrefix(strings.ToLower(value), "refs/") || + strings.EqualFold(value, "HEAD") || validCommitSHA(value) || strings.HasPrefix(value, "/") || strings.HasSuffix(value, ".") || strings.HasSuffix(value, "/") || strings.Contains(value, "..") || strings.Contains(value, "//") || strings.Contains(value, "@{") || strings.HasSuffix(strings.ToLower(value), ".lock") { diff --git a/internal/connector/github/action_plan_test.go b/internal/connector/github/action_plan_test.go index 2b40f92..d12a4e5 100644 --- a/internal/connector/github/action_plan_test.go +++ b/internal/connector/github/action_plan_test.go @@ -67,6 +67,10 @@ func TestNewOpenPRPlannerRejectsUnsafeRepositoryPolicies(t *testing.T) { {"base reflog", func(config *OpenPRPlannerConfig) { config.BaseRef = "main@{1}" }}, {"base lock", func(config *OpenPRPlannerConfig) { config.BaseRef = "main.lock" }}, {"base hidden", func(config *OpenPRPlannerConfig) { config.BaseRef = ".hidden" }}, + {"base option", func(config *OpenPRPlannerConfig) { config.BaseRef = "-delete" }}, + {"base symbolic HEAD", func(config *OpenPRPlannerConfig) { config.BaseRef = "HEAD" }}, + {"base full ref", func(config *OpenPRPlannerConfig) { config.BaseRef = "refs/heads/dev" }}, + {"base commit shaped", func(config *OpenPRPlannerConfig) { config.BaseRef = strings.Repeat("a", 40) }}, {"base control", func(config *OpenPRPlannerConfig) { config.BaseRef = "main\nother" }}, } for _, test := range tests { @@ -82,6 +86,42 @@ func TestNewOpenPRPlannerRejectsUnsafeRepositoryPolicies(t *testing.T) { } } +func TestOpenPRPlannerCanonicalizesArgumentsWithoutPlanning(t *testing.T) { + t.Parallel() + planner := newTestOpenPRPlanner(t) + request := validOpenPRIntent(t) + + target, canonical, err := planner.CanonicalizeOpenPRArgs(request.Args) + if err != nil { + t.Fatalf("CanonicalizeOpenPRArgs() error = %v", err) + } + wantTarget := fleet.ResourceRef{SourceKind: Kind, Scope: "github.com", Kind: openPRTargetKind, Namespace: "ArdurAI", Name: "sith"} + if !target.Equal(wantTarget) || len(target.Attributes) != 0 { + t.Fatalf("target = %#v, want %#v", target, wantTarget) + } + var got openPRArgs + if err := json.Unmarshal(canonical, &got); err != nil { + t.Fatalf("decode canonical args: %v", err) + } + if len(got.Changes) != 3 || got.Changes[0].Path != "config/new.yaml" || + got.Changes[1].Path != "deploy/api.yaml" || got.Changes[2].Path != "deploy/old.yaml" { + t.Fatalf("canonical changes = %#v, want stable path ordering", got.Changes) + } + + canonical[0] = '!' + _, second, err := planner.CanonicalizeOpenPRArgs(request.Args) + if err != nil || len(second) == 0 || second[0] != '{' { + t.Fatalf("second canonicalization = %q, %v, want isolated result", second, err) + } + if _, _, err := planner.CanonicalizeOpenPRArgs([]byte(`{"base_ref":"dev","changes":[]}`)); err == nil { + t.Fatal("CanonicalizeOpenPRArgs() accepted incomplete arguments") + } + var nilPlanner *OpenPRPlanner + if _, _, err := nilPlanner.CanonicalizeOpenPRArgs(request.Args); err == nil { + t.Fatal("nil CanonicalizeOpenPRArgs() accepted arguments") + } +} + func TestOpenPRPlannerProducesDeterministicDigestOnlyPlan(t *testing.T) { t.Parallel() planner := newTestOpenPRPlanner(t) diff --git a/internal/connector/github/boundary_test.go b/internal/connector/github/boundary_test.go index d869e61..34d5a0e 100644 --- a/internal/connector/github/boundary_test.go +++ b/internal/connector/github/boundary_test.go @@ -37,6 +37,7 @@ var allowedProductionImports = map[string]bool{ var allowedProductionDeclarations = map[string]bool{ "APIVersion": true, + "CanonicalizeOpenPRArgs": true, "Capabilities": true, "Descriptor": true, "Kind": true, @@ -52,6 +53,7 @@ var allowedProductionDeclarations = map[string]bool{ "WorkflowRunProjection": true, "WorkflowRunProtocolVersion": true, "changeObservation": true, + "canonicalizeOpenPRArgs": true, "commitLookupParams": true, "consumeUniqueJSON": true, "matchingDelimiter": true, diff --git a/internal/remediation/boundary_test.go b/internal/remediation/boundary_test.go new file mode 100644 index 0000000..53f9974 --- /dev/null +++ b/internal/remediation/boundary_test.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 + +package remediation + +import ( + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" +) + +var allowedProductionImports = map[string]bool{ + "context": true, + "crypto/sha256": true, + "encoding/hex": true, + "encoding/json": true, + "fmt": true, + "reflect": true, + "slices": true, + "sort": true, + "strings": true, + "time": true, + "unicode": true, + "unicode/utf8": true, + "github.com/ArdurAI/sith/internal/brain": true, + "github.com/ArdurAI/sith/internal/connector": true, + "github.com/ArdurAI/sith/internal/fleet": true, + "github.com/ArdurAI/sith/internal/intent": true, + "github.com/ArdurAI/sith/internal/intentargs": true, + "github.com/ArdurAI/sith/internal/tenancy": true, +} + +func TestRemediationPackageTreeHasNoIOAuthorityOrPolicyImports(t *testing.T) { + t.Parallel() + err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imported := range file.Imports { + importPath, err := strconv.Unquote(imported.Path.Value) + if err != nil { + return err + } + if !allowedProductionImports[importPath] { + t.Errorf("remediation production file %s imports unreviewed package %q", path, importPath) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk remediation package tree: %v", err) + } +} + +func TestGitOpsBoundaryOmitsAuthorityAndKeepsBundleOpaque(t *testing.T) { + t.Parallel() + assertExactFields(t, reflect.TypeFor[GitOpsProvenanceInput](), []string{ + "Workspace", "Subject", "Sources", "ObservedAt", "ValidUntil", "Handler", "Repository", + "BaseRef", "BaseCommit", "FilePath", "ObservedBlobSHA", "DesiredContent", "Title", "Body", + "CommitMessage", "EvidenceRefs", + }) + assertExactFields(t, reflect.TypeFor[Resolution](), []string{ + "Status", "Target", "Arguments", "ArgumentsDigest", "EvidenceRefs", "Reasons", + }) + assertExactFields(t, reflect.TypeFor[GitOpsResolver](), []string{"handler", "now"}) + + bundle := reflect.TypeFor[GitOpsProvenanceBundle]() + if bundle.NumField() != 17 { + t.Fatalf("GitOpsProvenanceBundle fields = %d, want exact reviewed shape", bundle.NumField()) + } + for index := range bundle.NumField() { + field := bundle.Field(index) + if field.IsExported() { + t.Fatalf("GitOpsProvenanceBundle exposes mutable field %s", field.Name) + } + } + + for _, value := range []reflect.Type{ + reflect.TypeFor[GitOpsProvenanceInput](), reflect.TypeFor[GitOpsProvenanceBundle](), reflect.TypeFor[Resolution](), + } { + for index := range value.NumField() { + name := strings.ToLower(value.Field(index).Name) + for _, forbidden := range []string{"actor", "role", "intent", "approval", "policy", "credential", "token", "secret", "signature", "endpoint"} { + if strings.Contains(name, forbidden) { + t.Fatalf("%s exposes forbidden authority field %s", value.Name(), value.Field(index).Name) + } + } + } + } +} + +func assertExactFields(t *testing.T, value reflect.Type, expected []string) { + t.Helper() + if value.NumField() != len(expected) { + t.Fatalf("%s fields = %d, want exactly %d", value.Name(), value.NumField(), len(expected)) + } + for index, name := range expected { + if value.Field(index).Name != name { + t.Fatalf("%s field %d = %s, want %s", value.Name(), index, value.Field(index).Name, name) + } + } +} diff --git a/internal/remediation/gitops.go b/internal/remediation/gitops.go new file mode 100644 index 0000000..c39123b --- /dev/null +++ b/internal/remediation/gitops.go @@ -0,0 +1,611 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package remediation resolves inert Brain candidates against source-owned provenance without +// authorizing, persisting, dispatching, or executing an action. +package remediation + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "slices" + "sort" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/ArdurAI/sith/internal/brain" + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/intent" + "github.com/ArdurAI/sith/internal/intentargs" + "github.com/ArdurAI/sith/internal/tenancy" +) + +const ( + // GitOpsProvenanceVersion is the first immutable source-to-resolver contract. + GitOpsProvenanceVersion = "gitops-provenance/v1" + // GitOpsSourceAdapterVersion pins the canonical GitHub Git-object observation contract. + GitOpsSourceAdapterVersion = "github-gitops-provenance/2026-03-10" + maxIdentityBytes = 512 + maxBundleContentBytes = 64 << 10 + maxBundleValidity = 5 * time.Minute + gitHubSourceKind = "github" + openPRTargetKind = "Repository" +) + +// ResolutionStatus distinguishes a provenance-complete result from a fail-closed abstention. +type ResolutionStatus string + +// Closed resolution states. +const ( + ResolutionReady ResolutionStatus = "ready" + ResolutionAbstained ResolutionStatus = "abstained" +) + +// AbstentionReason is a bounded, non-sensitive explanation for refusing to resolve a candidate. +type AbstentionReason string + +// Closed abstention reasons. Callers must branch on these values rather than parse error text. +const ( + ReasonCandidateMissing AbstentionReason = "candidate-missing" + ReasonCandidateInvalid AbstentionReason = "candidate-invalid" + ReasonCandidateUnsupported AbstentionReason = "candidate-unsupported" + ReasonVerdictInvalid AbstentionReason = "verdict-invalid" + ReasonVerdictUnconfirmed AbstentionReason = "verdict-unconfirmed" + ReasonFleetAmbiguous AbstentionReason = "fleet-ambiguous" + ReasonProvenanceMissing AbstentionReason = "provenance-missing" + ReasonProvenanceAmbiguous AbstentionReason = "provenance-ambiguous" + ReasonProvenanceInvalid AbstentionReason = "provenance-invalid" + ReasonProvenanceFuture AbstentionReason = "provenance-future" + ReasonProvenanceStale AbstentionReason = "provenance-stale" + ReasonWorkspaceMismatch AbstentionReason = "workspace-mismatch" + ReasonSubjectMismatch AbstentionReason = "subject-mismatch" + ReasonHandlerContractDrift AbstentionReason = "handler-contract-drift" + ReasonHandlerRejected AbstentionReason = "handler-rejected" + ReasonHandlerTargetMismatch AbstentionReason = "handler-target-mismatch" + ReasonHandlerOutputMismatch AbstentionReason = "handler-output-mismatch" +) + +// SourceIdentity identifies the one canonical adapter observation that owns a provenance bundle. +type SourceIdentity struct { + Kind string + AdapterVersion string + NativeID string +} + +// HandlerContract pins provenance to the exact typed-action adapter and argument schema it was +// assembled for. A handler upgrade invalidates an older bundle instead of silently reinterpreting +// it. +type HandlerContract struct { + Kind string + AdapterVersion string + SchemaDigest string +} + +// RepositoryIdentity is one configured Git repository, without a credential or endpoint URL. +type RepositoryIdentity struct { + Host string + Owner string + Repository string +} + +// GitOpsProvenanceInput is accepted only from a canonical source adapter boundary. Actor, role, +// intent ID, approval state, and policy decisions intentionally have no field here. +type GitOpsProvenanceInput struct { + Workspace tenancy.WorkspaceID + Subject fleet.ResourceRef + Sources []SourceIdentity + ObservedAt time.Time + ValidUntil time.Time + Handler HandlerContract + Repository RepositoryIdentity + BaseRef string + BaseCommit string + FilePath string + ObservedBlobSHA string + DesiredContent string + Title string + Body string + CommitMessage string + EvidenceRefs []fleet.ResourceRef +} + +// GitOpsProvenanceBundle is immutable after construction. Its fields stay private so downstream +// request code cannot rewrite source identity or Git preconditions after validation. +type GitOpsProvenanceBundle struct { + version string + workspace tenancy.WorkspaceID + subject fleet.ResourceRef + source SourceIdentity + observedAt time.Time + validUntil time.Time + handler HandlerContract + repository RepositoryIdentity + baseRef string + baseCommit string + filePath string + observedBlobSHA string + desiredContent string + title string + body string + commitMessage string + evidenceRefs []fleet.ResourceRef +} + +// Version reports the closed bundle contract without exposing mutable provenance fields. +func (bundle GitOpsProvenanceBundle) Version() string { return bundle.version } + +// NewGitOpsProvenanceBundle validates and defensively copies one source-owned bundle. Exact +// GitHub path, SHA, size, and repository policy remain handler-owned and are revalidated during +// resolution. +func NewGitOpsProvenanceBundle(input GitOpsProvenanceInput) (GitOpsProvenanceBundle, error) { + if len(input.Sources) != 1 { + return GitOpsProvenanceBundle{}, fmt.Errorf("construct GitOps provenance: exactly one source is required") + } + bundle := GitOpsProvenanceBundle{ + version: GitOpsProvenanceVersion, + workspace: input.Workspace, + subject: cloneResourceRef(input.Subject), + source: input.Sources[0], + observedAt: input.ObservedAt.UTC(), + validUntil: input.ValidUntil.UTC(), + handler: input.Handler, + repository: input.Repository, + baseRef: input.BaseRef, + baseCommit: input.BaseCommit, + filePath: input.FilePath, + observedBlobSHA: input.ObservedBlobSHA, + desiredContent: input.DesiredContent, + title: input.Title, + body: input.Body, + commitMessage: input.CommitMessage, + evidenceRefs: cloneResourceRefs(input.EvidenceRefs), + } + if err := bundle.validate(); err != nil { + return GitOpsProvenanceBundle{}, fmt.Errorf("construct GitOps provenance: bundle is invalid") + } + sort.Slice(bundle.evidenceRefs, func(left, right int) bool { + return resourceRefLess(bundle.evidenceRefs[left], bundle.evidenceRefs[right]) + }) + for index := 1; index < len(bundle.evidenceRefs); index++ { + if sameResourceRef(bundle.evidenceRefs[index-1], bundle.evidenceRefs[index]) { + return GitOpsProvenanceBundle{}, fmt.Errorf("construct GitOps provenance: evidence references are not unique") + } + } + return bundle, nil +} + +// GitOpsHandler is the pure validation seam implemented by the planning-only GitHub adapter. +// CanonicalizeOpenPRArgs performs no network request or mutation. +type GitOpsHandler interface { + Descriptor() connector.Descriptor + CanonicalizeOpenPRArgs(json.RawMessage) (fleet.ResourceRef, json.RawMessage, error) +} + +// GitOpsResolver resolves one candidate using a trusted server clock and the live handler +// contract. It retains no bundle or result state. +type GitOpsResolver struct { + handler GitOpsHandler + now func() time.Time +} + +// NewGitOpsResolver validates the injected pure handler and server clock before accepting work. +func NewGitOpsResolver(handler GitOpsHandler, now func() time.Time) (*GitOpsResolver, error) { + if isNilHandler(handler) || now == nil { + return nil, fmt.Errorf("construct GitOps resolver: handler and clock are required") + } + if _, _, err := inspectHandler(handler.Descriptor()); err != nil { + return nil, fmt.Errorf("construct GitOps resolver: handler contract is invalid") + } + return &GitOpsResolver{handler: handler, now: now}, nil +} + +// HandlerContractFor returns the immutable adapter/schema binding a canonical source uses when it +// constructs provenance. It does not validate provenance or grant authority. +func HandlerContractFor(handler GitOpsHandler) (HandlerContract, error) { + if isNilHandler(handler) { + return HandlerContract{}, fmt.Errorf("inspect GitOps handler: handler is required") + } + contract, _, err := inspectHandler(handler.Descriptor()) + if err != nil { + return HandlerContract{}, fmt.Errorf("inspect GitOps handler: contract is invalid") + } + return contract, nil +} + +// Resolution contains either a provenance-complete, handler-validated argument document or one +// or more closed abstention reasons. Ready output is still not a proposal or authorization. +type Resolution struct { + Status ResolutionStatus + Target fleet.ResourceRef + Arguments json.RawMessage + ArgumentsDigest string + EvidenceRefs []fleet.ResourceRef + Reasons []AbstentionReason +} + +// Resolve fails closed unless one confirmed R2/R4 candidate and one exact source-owned bundle +// satisfy the live handler contract. workspace must later come from authenticated server scope; +// it is deliberately separate from candidate and provenance data. +func (resolver *GitOpsResolver) Resolve( + ctx context.Context, + workspace tenancy.WorkspaceID, + verdict brain.Verdict, + bundles []GitOpsProvenanceBundle, +) (Resolution, error) { + if resolver == nil || isNilHandler(resolver.handler) || resolver.now == nil || ctx == nil { + return Resolution{}, fmt.Errorf("resolve GitOps provenance: resolver and context are required") + } + if err := ctx.Err(); err != nil { + return Resolution{}, fmt.Errorf("resolve GitOps provenance: %w", err) + } + if err := tenancy.ValidateWorkspaceID(workspace); err != nil { + return Resolution{}, fmt.Errorf("resolve GitOps provenance: workspace is invalid") + } + if verdict.RemediationCandidate == nil { + return abstain(ReasonCandidateMissing), nil + } + if err := verdict.RemediationCandidate.Validate(); err != nil { + return abstain(ReasonCandidateInvalid), nil + } + if !validBrainVerdictRef(verdict) { + return abstain(ReasonVerdictInvalid), nil + } + if verdict.RemediationCandidate.Verb != intent.VerbGitOpsOpenPR || + (verdict.Rule != brain.RuleOOMKilled && verdict.Rule != brain.RuleConfigDrift) { + return abstain(ReasonCandidateUnsupported), nil + } + if verdict.FleetWide { + return abstain(ReasonFleetAmbiguous), nil + } + if verdict.Status != brain.StatusConfirmed { + return abstain(ReasonVerdictUnconfirmed), nil + } + if len(bundles) == 0 { + return abstain(ReasonProvenanceMissing), nil + } + if len(bundles) != 1 { + return abstain(ReasonProvenanceAmbiguous), nil + } + + bundle := bundles[0] + if err := bundle.validate(); err != nil { + return abstain(ReasonProvenanceInvalid), nil + } + reason, err := resolver.freshness(bundle) + if err != nil { + return Resolution{}, err + } + if reason != "" { + return abstain(reason), nil + } + if bundle.workspace != workspace { + return abstain(ReasonWorkspaceMismatch), nil + } + if !sameResourceRef(bundle.subject, verdict.Ref) { + return abstain(ReasonSubjectMismatch), nil + } + + contract, schema, err := inspectHandler(resolver.handler.Descriptor()) + if err != nil || contract != bundle.handler || bundle.source.Kind != contract.Kind { + return abstain(ReasonHandlerContractDrift), nil + } + arguments, err := bundle.arguments() + if err != nil { + return abstain(ReasonProvenanceInvalid), nil + } + target, canonical, err := resolver.handler.CanonicalizeOpenPRArgs(arguments) + if err != nil { + return abstain(ReasonHandlerRejected), nil + } + if err := ctx.Err(); err != nil { + return Resolution{}, fmt.Errorf("resolve GitOps provenance: %w", err) + } + if !targetMatchesRepository(target, contract.Kind, bundle.repository) { + return abstain(ReasonHandlerTargetMismatch), nil + } + if err := schema.Validate(canonical); err != nil { + return abstain(ReasonHandlerContractDrift), nil + } + if !canonicalMatchesBundle(canonical, bundle) { + return abstain(ReasonHandlerOutputMismatch), nil + } + postContract, _, err := inspectHandler(resolver.handler.Descriptor()) + if err != nil || postContract != contract { + return abstain(ReasonHandlerContractDrift), nil + } + if err := ctx.Err(); err != nil { + return Resolution{}, fmt.Errorf("resolve GitOps provenance: %w", err) + } + reason, err = resolver.freshness(bundle) + if err != nil { + return Resolution{}, err + } + if reason != "" { + return abstain(reason), nil + } + digest := sha256.Sum256(canonical) + return Resolution{ + Status: ResolutionReady, + Target: cloneResourceRef(target), + Arguments: append(json.RawMessage(nil), canonical...), + ArgumentsDigest: "sha256:" + hex.EncodeToString(digest[:]), + EvidenceRefs: cloneResourceRefs(bundle.evidenceRefs), + }, nil +} + +func (resolver *GitOpsResolver) freshness(bundle GitOpsProvenanceBundle) (AbstentionReason, error) { + now := resolver.now().UTC() + if now.IsZero() { + return "", fmt.Errorf("resolve GitOps provenance: clock returned an invalid time") + } + if now.Before(bundle.observedAt) { + return ReasonProvenanceFuture, nil + } + if !now.Before(bundle.validUntil) { + return ReasonProvenanceStale, nil + } + return "", nil +} + +type openPRArguments struct { + BaseRef string `json:"base_ref"` + ExpectedBaseSHA string `json:"expected_base_sha"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + CommitMessage string `json:"commit_message"` + Changes []fileChange `json:"changes"` +} + +type fileChange struct { + Operation string `json:"operation"` + Path string `json:"path"` + Content *string `json:"content"` + ExpectedBlobSHA *string `json:"expected_blob_sha"` +} + +func (bundle GitOpsProvenanceBundle) arguments() (json.RawMessage, error) { + content := bundle.desiredContent + observed := bundle.observedBlobSHA + encoded, err := json.Marshal(openPRArguments{ + BaseRef: bundle.baseRef, ExpectedBaseSHA: bundle.baseCommit, + Title: bundle.title, Body: bundle.body, CommitMessage: bundle.commitMessage, + Changes: []fileChange{{ + Operation: "update", Path: bundle.filePath, + Content: &content, ExpectedBlobSHA: &observed, + }}, + }) + if err != nil { + return nil, fmt.Errorf("encode GitOps provenance arguments") + } + return json.RawMessage(encoded), nil +} + +func (bundle GitOpsProvenanceBundle) validate() error { + if bundle.version != GitOpsProvenanceVersion || tenancy.ValidateWorkspaceID(bundle.workspace) != nil || + validateStableRef(bundle.subject) != nil || bundle.observedAt.IsZero() || bundle.validUntil.IsZero() || + !bundle.observedAt.Before(bundle.validUntil) || bundle.validUntil.Sub(bundle.observedAt) > maxBundleValidity || + validateSource(bundle.source) != nil || + validateHandlerContract(bundle.handler) != nil || bundle.source.Kind != bundle.handler.Kind || + validateRepository(bundle.repository) != nil || bundle.source.NativeID != bundle.repository.nativeID() || + !validProvenanceBaseRef(bundle.baseRef) || !validObjectID(bundle.baseCommit) || + validateSafeText(bundle.filePath, maxIdentityBytes, false) != nil || + !validObjectID(bundle.observedBlobSHA) || + validateSafeText(bundle.title, maxIdentityBytes, false) != nil || + validateMultilineText(bundle.body, 16<<10, true) != nil || + validateSafeText(bundle.commitMessage, maxIdentityBytes, false) != nil || + !validBundleContent(bundle.desiredContent) || len(bundle.evidenceRefs) == 0 { + return fmt.Errorf("GitOps provenance bundle is invalid") + } + for _, ref := range bundle.evidenceRefs { + if validateStableRef(ref) != nil { + return fmt.Errorf("GitOps provenance evidence is invalid") + } + } + return nil +} + +func inspectHandler(descriptor connector.Descriptor) (HandlerContract, *intentargs.Schema, error) { + if descriptor.Kind != gitHubSourceKind || + descriptor.ConnKind != connector.KindTypedAction || descriptor.Owner != "sith" || + validateSafeText(descriptor.AdapterVersion, maxIdentityBytes, false) != nil || + !slices.Equal(descriptor.WireVersions, []connector.WireVersion{connector.CurrentWireVersion()}) || + !slices.Equal(descriptor.Capabilities, []connector.Capability{connector.CapPlan}) || + !slices.Equal(descriptor.Verbs, []intent.Verb{intent.VerbGitOpsOpenPR}) || + len(descriptor.ArgSchemas) != 1 { + return HandlerContract{}, nil, fmt.Errorf("handler descriptor is not the exact GitOps planning contract") + } + raw, present := descriptor.ArgSchemas[intent.VerbGitOpsOpenPR] + if !present || len(raw) == 0 { + return HandlerContract{}, nil, fmt.Errorf("handler schema is missing") + } + schema, err := intentargs.Compile(append(json.RawMessage(nil), raw...)) + if err != nil { + return HandlerContract{}, nil, fmt.Errorf("handler schema is invalid") + } + digest := sha256.Sum256(raw) + return HandlerContract{ + Kind: descriptor.Kind, AdapterVersion: descriptor.AdapterVersion, + SchemaDigest: "sha256:" + hex.EncodeToString(digest[:]), + }, schema, nil +} + +func validateHandlerContract(contract HandlerContract) error { + if validateSafeText(contract.Kind, maxIdentityBytes, false) != nil || + validateSafeText(contract.AdapterVersion, maxIdentityBytes, false) != nil || !validDigest(contract.SchemaDigest) { + return fmt.Errorf("handler contract is invalid") + } + return nil +} + +func validateSource(source SourceIdentity) error { + if source.Kind != gitHubSourceKind || source.AdapterVersion != GitOpsSourceAdapterVersion || + validateSafeText(source.NativeID, maxIdentityBytes, false) != nil { + return fmt.Errorf("source identity is invalid") + } + return nil +} + +func validateRepository(repository RepositoryIdentity) error { + if validateSafeText(repository.Host, maxIdentityBytes, false) != nil || + validateSafeText(repository.Owner, maxIdentityBytes, false) != nil || + validateSafeText(repository.Repository, maxIdentityBytes, false) != nil || + repository.Host != strings.ToLower(repository.Host) || strings.Contains(repository.Host, "://") || + strings.ContainsAny(repository.Host+repository.Owner+repository.Repository, "/\\") || + strings.HasSuffix(strings.ToLower(repository.Repository), ".git") { + return fmt.Errorf("repository identity is invalid") + } + return nil +} + +func (repository RepositoryIdentity) nativeID() string { + return repository.Host + "/" + repository.Owner + "/" + repository.Repository +} + +func validBrainVerdictRef(verdict brain.Verdict) bool { + if validateStableRef(verdict.Ref) != nil || len(verdict.Citations) == 0 { + return false + } + attached := false + for _, citation := range verdict.Citations { + if citation.Stale || validateStableRef(citation.Ref) != nil { + return false + } + attached = attached || sameResourceRef(citation.Ref, verdict.Ref) + } + return attached +} + +func targetMatchesRepository(target fleet.ResourceRef, sourceKind string, repository RepositoryIdentity) bool { + return len(target.Attributes) == 0 && target.SourceKind == sourceKind && target.Scope == repository.Host && + target.Kind == openPRTargetKind && target.Namespace == repository.Owner && target.Name == repository.Repository +} + +func validateStableRef(ref fleet.ResourceRef) error { + if len(ref.Attributes) != 0 || validateSafeText(ref.SourceKind, maxIdentityBytes, false) != nil || + validateSafeText(ref.Scope, maxIdentityBytes, false) != nil || + validateSafeText(ref.Kind, maxIdentityBytes, false) != nil || + validateSafeText(ref.Namespace, maxIdentityBytes, true) != nil || + validateSafeText(ref.Name, maxIdentityBytes, false) != nil { + return fmt.Errorf("resource reference is invalid") + } + return nil +} + +func validateSafeText(value string, maximum int, allowEmpty bool) error { + if (!allowEmpty && value == "") || len(value) > maximum || !utf8.ValidString(value) || + strings.TrimSpace(value) != value || strings.ContainsRune(value, '\x00') { + return fmt.Errorf("text is invalid") + } + for _, character := range value { + if unicode.IsControl(character) { + return fmt.Errorf("text is invalid") + } + } + return nil +} + +func validateMultilineText(value string, maximum int, allowEmpty bool) error { + if (!allowEmpty && value == "") || len(value) > maximum || !utf8.ValidString(value) || + strings.TrimSpace(value) != value || strings.ContainsRune(value, '\x00') { + return fmt.Errorf("text is invalid") + } + for _, character := range value { + if unicode.IsControl(character) && character != '\n' && character != '\t' { + return fmt.Errorf("text is invalid") + } + } + return nil +} + +func validBundleContent(value string) bool { + return len(value) <= maxBundleContentBytes && utf8.ValidString(value) && !strings.ContainsRune(value, '\x00') +} + +func validDigest(value string) bool { + if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+sha256.Size*2 { + return false + } + hexValue := strings.TrimPrefix(value, "sha256:") + _, err := hex.DecodeString(hexValue) + return err == nil && hexValue == strings.ToLower(hexValue) +} + +func validObjectID(value string) bool { + if len(value) != 40 && len(value) != 64 { + return false + } + _, err := hex.DecodeString(value) + return err == nil && value == strings.ToLower(value) +} + +func validProvenanceBaseRef(value string) bool { + return validateSafeText(value, maxIdentityBytes, false) == nil && !strings.EqualFold(value, "HEAD") && + !strings.HasPrefix(value, "-") && !strings.HasPrefix(strings.ToLower(value), "refs/") && !validObjectID(value) +} + +func canonicalMatchesBundle(canonical json.RawMessage, bundle GitOpsProvenanceBundle) bool { + var decoded openPRArguments + if err := json.Unmarshal(canonical, &decoded); err != nil || len(decoded.Changes) != 1 { + return false + } + change := decoded.Changes[0] + return decoded.BaseRef == bundle.baseRef && decoded.ExpectedBaseSHA == bundle.baseCommit && + decoded.Title == bundle.title && decoded.Body == bundle.body && decoded.CommitMessage == bundle.commitMessage && + change.Operation == "update" && change.Path == bundle.filePath && change.Content != nil && + *change.Content == bundle.desiredContent && change.ExpectedBlobSHA != nil && + *change.ExpectedBlobSHA == bundle.observedBlobSHA +} + +func abstain(reasons ...AbstentionReason) Resolution { + return Resolution{Status: ResolutionAbstained, Reasons: append([]AbstentionReason(nil), reasons...)} +} + +func isNilHandler(handler GitOpsHandler) bool { + if handler == nil { + return true + } + value := reflect.ValueOf(handler) + return (value.Kind() == reflect.Chan || value.Kind() == reflect.Func || value.Kind() == reflect.Interface || + value.Kind() == reflect.Map || value.Kind() == reflect.Pointer || value.Kind() == reflect.Slice) && value.IsNil() +} + +func cloneResourceRefs(refs []fleet.ResourceRef) []fleet.ResourceRef { + cloned := make([]fleet.ResourceRef, len(refs)) + for index, ref := range refs { + cloned[index] = cloneResourceRef(ref) + } + return cloned +} + +func cloneResourceRef(ref fleet.ResourceRef) fleet.ResourceRef { + cloned := ref + if ref.Attributes != nil { + cloned.Attributes = make(map[string]string, len(ref.Attributes)) + for key, value := range ref.Attributes { + cloned.Attributes[key] = value + } + } + return cloned +} + +func sameResourceRef(left, right fleet.ResourceRef) bool { + return left.SourceKind == right.SourceKind && left.Scope == right.Scope && left.Kind == right.Kind && + left.Namespace == right.Namespace && left.Name == right.Name && len(left.Attributes) == 0 && len(right.Attributes) == 0 +} + +func resourceRefLess(left, right fleet.ResourceRef) bool { + leftParts := [...]string{left.SourceKind, left.Scope, left.Kind, left.Namespace, left.Name} + rightParts := [...]string{right.SourceKind, right.Scope, right.Kind, right.Namespace, right.Name} + for index := range leftParts { + if leftParts[index] != rightParts[index] { + return leftParts[index] < rightParts[index] + } + } + return false +} diff --git a/internal/remediation/gitops_test.go b/internal/remediation/gitops_test.go new file mode 100644 index 0000000..31d3ea7 --- /dev/null +++ b/internal/remediation/gitops_test.go @@ -0,0 +1,713 @@ +// SPDX-License-Identifier: Apache-2.0 + +package remediation + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/ArdurAI/sith/internal/brain" + "github.com/ArdurAI/sith/internal/connector" + githubconnector "github.com/ArdurAI/sith/internal/connector/github" + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/intent" + "github.com/ArdurAI/sith/internal/tenancy" +) + +const testWorkspace tenancy.WorkspaceID = "workspace-a" + +const ( + testBaseSHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + testBlobSHA = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +var testNow = time.Date(2026, 7, 22, 18, 0, 0, 0, time.UTC) + +func TestGitOpsResolverProducesDeterministicR2AndR4Arguments(t *testing.T) { + t.Parallel() + for _, rule := range []brain.RuleID{brain.RuleOOMKilled, brain.RuleConfigDrift} { + rule := rule + t.Run(string(rule), func(t *testing.T) { + t.Parallel() + fixture := newGitOpsFixture(t, rule) + first, err := fixture.resolver.Resolve(context.Background(), testWorkspace, fixture.verdict, []GitOpsProvenanceBundle{fixture.bundle}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + assertReadyResolution(t, first) + + secondInput := validGitOpsInput(fixture.bundle.handler) + slices.Reverse(secondInput.EvidenceRefs) + secondBundle, err := NewGitOpsProvenanceBundle(secondInput) + if err != nil { + t.Fatalf("construct reordered bundle: %v", err) + } + second, err := fixture.resolver.Resolve(context.Background(), testWorkspace, fixture.verdict, []GitOpsProvenanceBundle{secondBundle}) + if err != nil { + t.Fatalf("second Resolve() error = %v", err) + } + if !slices.Equal(first.Arguments, second.Arguments) || first.ArgumentsDigest != second.ArgumentsDigest || + !slices.EqualFunc(first.EvidenceRefs, second.EvidenceRefs, sameResourceRef) { + t.Fatalf("resolution changed with evidence ordering:\nfirst=%#v\nsecond=%#v", first, second) + } + }) + } +} + +func TestGitOpsProvenanceAndResolutionAreMutationIsolated(t *testing.T) { + t.Parallel() + planner := newGitHubPlanner(t) + contract, err := HandlerContractFor(planner) + if err != nil { + t.Fatal(err) + } + input := validGitOpsInput(contract) + bundle, err := NewGitOpsProvenanceBundle(input) + if err != nil { + t.Fatal(err) + } + + input.Subject.Name = "mutated-subject" + input.Sources[0].NativeID = "github.com/other/repository" + input.EvidenceRefs[0].Name = "mutated-evidence" + resolver, err := NewGitOpsResolver(planner, func() time.Time { return testNow }) + if err != nil { + t.Fatal(err) + } + verdict := validGitOpsVerdict(brain.RuleConfigDrift) + first, err := resolver.Resolve(context.Background(), testWorkspace, verdict, []GitOpsProvenanceBundle{bundle}) + if err != nil { + t.Fatal(err) + } + assertReadyResolution(t, first) + + first.Arguments[0] = '!' + first.Target.Name = "mutated-target" + first.Target.Attributes = map[string]string{"token": "forbidden"} + first.EvidenceRefs[0].Name = "mutated-output" + first.Reasons = append(first.Reasons, ReasonCandidateInvalid) + + second, err := resolver.Resolve(context.Background(), testWorkspace, verdict, []GitOpsProvenanceBundle{bundle}) + if err != nil { + t.Fatal(err) + } + assertReadyResolution(t, second) + if len(second.Arguments) == 0 || second.Arguments[0] != '{' || second.Target.Name != "sith" || + len(second.Target.Attributes) != 0 || second.EvidenceRefs[0].Name == "mutated-output" || len(second.Reasons) != 0 { + t.Fatalf("second resolution retained caller mutation: %#v", second) + } +} + +func TestGitOpsResolverAbstainsClosed(t *testing.T) { + fixture := newGitOpsFixture(t, brain.RuleConfigDrift) + tests := []struct { + name string + want AbstentionReason + mutate func(*brain.Verdict, *[]GitOpsProvenanceBundle) + }{ + {"candidate missing", ReasonCandidateMissing, func(verdict *brain.Verdict, _ *[]GitOpsProvenanceBundle) { verdict.RemediationCandidate = nil }}, + {"candidate mutated", ReasonCandidateInvalid, func(verdict *brain.Verdict, _ *[]GitOpsProvenanceBundle) { + verdict.RemediationCandidate.RequiredProvenance[0] = brain.ProvenanceArgoRevision + }}, + {"verdict ref attributes", ReasonVerdictInvalid, func(verdict *brain.Verdict, _ *[]GitOpsProvenanceBundle) { + verdict.Ref.Attributes = map[string]string{"native": "untrusted"} + }}, + {"stale citation", ReasonVerdictInvalid, func(verdict *brain.Verdict, _ *[]GitOpsProvenanceBundle) { verdict.Citations[0].Stale = true }}, + {"unattached citation", ReasonVerdictInvalid, func(verdict *brain.Verdict, _ *[]GitOpsProvenanceBundle) { verdict.Citations[0].Ref.Name = "other" }}, + {"unsupported R1", ReasonCandidateUnsupported, func(verdict *brain.Verdict, _ *[]GitOpsProvenanceBundle) { + verdict.Rule = brain.RuleBadDeploy + verdict.RemediationCandidate = &brain.RemediationCandidate{Verb: intent.VerbArgoCDRollback, RequiredProvenance: []brain.ProvenanceRequirement{ + brain.ProvenanceArgoApplicationTarget, brain.ProvenanceArgoRevision, + }} + }}, + {"fleet verdict", ReasonFleetAmbiguous, func(verdict *brain.Verdict, _ *[]GitOpsProvenanceBundle) { verdict.FleetWide = true }}, + {"unconfirmed verdict", ReasonVerdictUnconfirmed, func(verdict *brain.Verdict, _ *[]GitOpsProvenanceBundle) { verdict.Status = brain.StatusUnconfirmed }}, + {"missing provenance", ReasonProvenanceMissing, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { *bundles = nil }}, + {"ambiguous provenance", ReasonProvenanceAmbiguous, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { *bundles = append(*bundles, (*bundles)[0]) }}, + {"invalid provenance", ReasonProvenanceInvalid, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { (*bundles)[0].version = "forged/v9" }}, + {"future provenance", ReasonProvenanceFuture, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { + (*bundles)[0].observedAt = testNow.Add(time.Second) + (*bundles)[0].validUntil = testNow.Add(2 * time.Minute) + }}, + {"stale provenance", ReasonProvenanceStale, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { (*bundles)[0].validUntil = testNow }}, + {"foreign workspace", ReasonWorkspaceMismatch, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { (*bundles)[0].workspace = "workspace-b" }}, + {"unattached subject", ReasonSubjectMismatch, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { (*bundles)[0].subject.Name = "other" }}, + {"source contract forged", ReasonProvenanceInvalid, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { + (*bundles)[0].source.AdapterVersion = "forged/v1" + }}, + {"handler adapter drift", ReasonHandlerContractDrift, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { + (*bundles)[0].handler.AdapterVersion = "gitops-open-pr/2099-01-01" + }}, + {"handler schema drift", ReasonHandlerContractDrift, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { + (*bundles)[0].handler.SchemaDigest = "sha256:" + strings.Repeat("c", 64) + }}, + {"unsafe path rejected by handler", ReasonHandlerRejected, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { (*bundles)[0].filePath = "../secret.yaml" }}, + {"configured base mismatch rejected by handler", ReasonHandlerRejected, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { (*bundles)[0].baseRef = "main" }}, + {"oversized content rejected by handler", ReasonHandlerRejected, func(_ *brain.Verdict, bundles *[]GitOpsProvenanceBundle) { + (*bundles)[0].desiredContent = strings.Repeat("x", (16<<10)+1) + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + verdict := cloneVerdict(fixture.verdict) + bundles := []GitOpsProvenanceBundle{cloneBundle(fixture.bundle)} + test.mutate(&verdict, &bundles) + got, err := fixture.resolver.Resolve(context.Background(), testWorkspace, verdict, bundles) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + assertAbstention(t, got, test.want) + }) + } +} + +func TestNewGitOpsProvenanceBundleRejectsInvalidClaims(t *testing.T) { + planner := newGitHubPlanner(t) + contract, err := HandlerContractFor(planner) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + mutate func(*GitOpsProvenanceInput) + }{ + {"no source", func(input *GitOpsProvenanceInput) { input.Sources = nil }}, + {"multiple sources", func(input *GitOpsProvenanceInput) { input.Sources = append(input.Sources, input.Sources[0]) }}, + {"invalid workspace", func(input *GitOpsProvenanceInput) { input.Workspace = " workspace-a" }}, + {"subject attributes", func(input *GitOpsProvenanceInput) { input.Subject.Attributes = map[string]string{"uid": "private"} }}, + {"source kind", func(input *GitOpsProvenanceInput) { input.Sources[0].Kind = "gitlab" }}, + {"source adapter", func(input *GitOpsProvenanceInput) { input.Sources[0].AdapterVersion = "future/v2" }}, + {"source repository mismatch", func(input *GitOpsProvenanceInput) { input.Sources[0].NativeID = "github.com/ArdurAI/other" }}, + {"invalid repository", func(input *GitOpsProvenanceInput) { input.Repository.Repository = "sith.git" }}, + {"zero observation", func(input *GitOpsProvenanceInput) { input.ObservedAt = time.Time{} }}, + {"reversed validity", func(input *GitOpsProvenanceInput) { input.ValidUntil = input.ObservedAt }}, + {"unbounded validity", func(input *GitOpsProvenanceInput) { + input.ValidUntil = input.ObservedAt.Add(maxBundleValidity + time.Nanosecond) + }}, + {"symbolic base", func(input *GitOpsProvenanceInput) { input.BaseRef = "HEAD" }}, + {"full base ref", func(input *GitOpsProvenanceInput) { input.BaseRef = "refs/heads/dev" }}, + {"commit-shaped base", func(input *GitOpsProvenanceInput) { input.BaseRef = testBaseSHA }}, + {"invalid base commit", func(input *GitOpsProvenanceInput) { input.BaseCommit = strings.ToUpper(testBaseSHA) }}, + {"invalid blob", func(input *GitOpsProvenanceInput) { input.ObservedBlobSHA = "not-a-blob" }}, + {"empty title", func(input *GitOpsProvenanceInput) { input.Title = "" }}, + {"NUL content", func(input *GitOpsProvenanceInput) { input.DesiredContent = "secret\x00value" }}, + {"unbounded content", func(input *GitOpsProvenanceInput) { + input.DesiredContent = strings.Repeat("x", maxBundleContentBytes+1) + }}, + {"no evidence", func(input *GitOpsProvenanceInput) { input.EvidenceRefs = nil }}, + {"duplicate evidence", func(input *GitOpsProvenanceInput) { + input.EvidenceRefs = append(input.EvidenceRefs, input.EvidenceRefs[0]) + }}, + {"unsafe evidence", func(input *GitOpsProvenanceInput) { input.EvidenceRefs[0].Name = "commit\nforged" }}, + {"noncanonical digest", func(input *GitOpsProvenanceInput) { input.Handler.SchemaDigest = "sha256:" + strings.Repeat("A", 64) }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := validGitOpsInput(contract) + test.mutate(&input) + bundle, err := NewGitOpsProvenanceBundle(input) + if err == nil || bundle.Version() != "" { + t.Fatalf("NewGitOpsProvenanceBundle() = %#v, %v, want rejection", bundle, err) + } + if strings.Contains(err.Error(), "secret") || len(err.Error()) > 160 { + t.Fatalf("constructor leaked or returned unbounded error: %q", err) + } + }) + } +} + +func TestGitOpsResolverRejectsHandlerMismatchAndDrift(t *testing.T) { + tests := []struct { + name string + want AbstentionReason + proxy func(GitOpsHandler) GitOpsHandler + }{ + {"target mismatch", ReasonHandlerTargetMismatch, func(base GitOpsHandler) GitOpsHandler { + return &handlerProxy{base: base, canonicalize: func(target fleet.ResourceRef, document json.RawMessage) (fleet.ResourceRef, json.RawMessage) { + target.Name = "other" + return target, document + }} + }}, + {"output commit mismatch", ReasonHandlerOutputMismatch, func(base GitOpsHandler) GitOpsHandler { + return &handlerProxy{base: base, canonicalize: func(target fleet.ResourceRef, document json.RawMessage) (fleet.ResourceRef, json.RawMessage) { + var args openPRArguments + if err := json.Unmarshal(document, &args); err != nil { + panic(err) + } + args.ExpectedBaseSHA = strings.Repeat("c", 40) + encoded, err := json.Marshal(args) + if err != nil { + panic(err) + } + return target, encoded + }} + }}, + {"output blob mismatch", ReasonHandlerOutputMismatch, func(base GitOpsHandler) GitOpsHandler { + return &handlerProxy{base: base, canonicalize: func(target fleet.ResourceRef, document json.RawMessage) (fleet.ResourceRef, json.RawMessage) { + var args openPRArguments + if err := json.Unmarshal(document, &args); err != nil { + panic(err) + } + blob := strings.Repeat("d", 40) + args.Changes[0].ExpectedBlobSHA = &blob + encoded, err := json.Marshal(args) + if err != nil { + panic(err) + } + return target, encoded + }} + }}, + {"repository mismatch", ReasonHandlerTargetMismatch, func(base GitOpsHandler) GitOpsHandler { + return &handlerProxy{base: base, canonicalize: func(target fleet.ResourceRef, document json.RawMessage) (fleet.ResourceRef, json.RawMessage) { + target.Namespace = "Other" + return target, document + }} + }}, + {"post-canonicalization descriptor drift", ReasonHandlerContractDrift, func(base GitOpsHandler) GitOpsHandler { + return &handlerProxy{base: base, mutateDescriptor: func(descriptor connector.Descriptor, call int) connector.Descriptor { + if call >= 3 { + descriptor.AdapterVersion += "/drifted" + } + return descriptor + }} + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + base := newGitHubPlanner(t) + contract, err := HandlerContractFor(base) + if err != nil { + t.Fatal(err) + } + bundle, err := NewGitOpsProvenanceBundle(validGitOpsInput(contract)) + if err != nil { + t.Fatal(err) + } + resolver, err := NewGitOpsResolver(test.proxy(base), func() time.Time { return testNow }) + if err != nil { + t.Fatal(err) + } + got, err := resolver.Resolve(context.Background(), testWorkspace, validGitOpsVerdict(brain.RuleConfigDrift), []GitOpsProvenanceBundle{bundle}) + if err != nil { + t.Fatal(err) + } + assertAbstention(t, got, test.want) + }) + } +} + +func TestGitOpsResolverRejectsInvalidDependenciesAndCancellation(t *testing.T) { + t.Parallel() + var typedNil *handlerProxy + if resolver, err := NewGitOpsResolver(typedNil, func() time.Time { return testNow }); err == nil || resolver != nil { + t.Fatalf("NewGitOpsResolver(typed nil) = %#v, %v", resolver, err) + } + if resolver, err := NewGitOpsResolver(newGitHubPlanner(t), nil); err == nil || resolver != nil { + t.Fatalf("NewGitOpsResolver(nil clock) = %#v, %v", resolver, err) + } + if _, err := HandlerContractFor(typedNil); err == nil { + t.Fatal("HandlerContractFor() accepted typed nil") + } + + fixture := newGitOpsFixture(t, brain.RuleConfigDrift) + var nilResolver *GitOpsResolver + if _, err := nilResolver.Resolve(context.Background(), testWorkspace, fixture.verdict, []GitOpsProvenanceBundle{fixture.bundle}); err == nil { + t.Fatal("Resolve() accepted a nil resolver") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := fixture.resolver.Resolve(ctx, testWorkspace, fixture.verdict, []GitOpsProvenanceBundle{fixture.bundle}); err == nil || !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("Resolve(canceled) error = %v", err) + } + var missingContext context.Context + if _, err := fixture.resolver.Resolve(missingContext, testWorkspace, fixture.verdict, []GitOpsProvenanceBundle{fixture.bundle}); err == nil { + t.Fatal("Resolve(nil context) succeeded") + } + if _, err := fixture.resolver.Resolve(context.Background(), " invalid", fixture.verdict, []GitOpsProvenanceBundle{fixture.bundle}); err == nil { + t.Fatal("Resolve(invalid workspace) succeeded") + } + zeroClock, err := NewGitOpsResolver(newGitHubPlanner(t), func() time.Time { return time.Time{} }) + if err != nil { + t.Fatal(err) + } + if _, err := zeroClock.Resolve(context.Background(), testWorkspace, fixture.verdict, []GitOpsProvenanceBundle{fixture.bundle}); err == nil { + t.Fatal("Resolve(zero clock) succeeded") + } +} + +func TestNewGitOpsResolverRejectsNoncanonicalHandlerDescriptors(t *testing.T) { + t.Parallel() + tests := []struct { + name string + mutate func(connector.Descriptor) connector.Descriptor + }{ + {"wrong owner", func(descriptor connector.Descriptor) connector.Descriptor { + descriptor.Owner = "caller" + return descriptor + }}, + {"wire version missing", func(descriptor connector.Descriptor) connector.Descriptor { + descriptor.WireVersions = nil + return descriptor + }}, + {"execute capability", func(descriptor connector.Descriptor) connector.Descriptor { + descriptor.Capabilities = append(descriptor.Capabilities, connector.CapExecute) + return descriptor + }}, + {"extra verb", func(descriptor connector.Descriptor) connector.Descriptor { + descriptor.Verbs = append(descriptor.Verbs, intent.VerbDeploymentRestart) + return descriptor + }}, + {"invalid schema", func(descriptor connector.Descriptor) connector.Descriptor { + descriptor.ArgSchemas[intent.VerbGitOpsOpenPR] = json.RawMessage(`{"type":"not-a-type"}`) + return descriptor + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + proxy := &handlerProxy{base: newGitHubPlanner(t), mutateDescriptor: func(descriptor connector.Descriptor, _ int) connector.Descriptor { + return test.mutate(descriptor) + }} + if resolver, err := NewGitOpsResolver(proxy, func() time.Time { return testNow }); err == nil || resolver != nil { + t.Fatalf("NewGitOpsResolver() = %#v, %v, want descriptor rejection", resolver, err) + } + }) + } +} + +func TestGitOpsResolverHonorsCancellationAfterHandlerValidation(t *testing.T) { + t.Parallel() + base := newGitHubPlanner(t) + contract, err := HandlerContractFor(base) + if err != nil { + t.Fatal(err) + } + bundle, err := NewGitOpsProvenanceBundle(validGitOpsInput(contract)) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + proxy := &handlerProxy{base: base, canonicalize: func(target fleet.ResourceRef, document json.RawMessage) (fleet.ResourceRef, json.RawMessage) { + cancel() + return target, document + }} + resolver, err := NewGitOpsResolver(proxy, func() time.Time { return testNow }) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.Resolve(ctx, testWorkspace, validGitOpsVerdict(brain.RuleConfigDrift), []GitOpsProvenanceBundle{bundle}); err == nil || !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("Resolve() error = %v, want cancellation", err) + } +} + +func TestGitOpsResolverHonorsCancellationAfterFinalContractCheck(t *testing.T) { + t.Parallel() + base := newGitHubPlanner(t) + contract, err := HandlerContractFor(base) + if err != nil { + t.Fatal(err) + } + bundle, err := NewGitOpsProvenanceBundle(validGitOpsInput(contract)) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + proxy := &handlerProxy{base: base, mutateDescriptor: func(descriptor connector.Descriptor, call int) connector.Descriptor { + if call >= 3 { + cancel() + } + return descriptor + }} + resolver, err := NewGitOpsResolver(proxy, func() time.Time { return testNow }) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.Resolve(ctx, testWorkspace, validGitOpsVerdict(brain.RuleConfigDrift), []GitOpsProvenanceBundle{bundle}); err == nil || !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("Resolve() error = %v, want final-check cancellation", err) + } +} + +func TestGitOpsResolverRechecksFreshnessBeforeReturningReady(t *testing.T) { + t.Parallel() + planner := newGitHubPlanner(t) + contract, err := HandlerContractFor(planner) + if err != nil { + t.Fatal(err) + } + bundle, err := NewGitOpsProvenanceBundle(validGitOpsInput(contract)) + if err != nil { + t.Fatal(err) + } + clockCalls := 0 + resolver, err := NewGitOpsResolver(planner, func() time.Time { + clockCalls++ + if clockCalls == 1 { + return testNow + } + return testNow.Add(2 * time.Minute) + }) + if err != nil { + t.Fatal(err) + } + got, err := resolver.Resolve(context.Background(), testWorkspace, validGitOpsVerdict(brain.RuleConfigDrift), []GitOpsProvenanceBundle{bundle}) + if err != nil { + t.Fatal(err) + } + assertAbstention(t, got, ReasonProvenanceStale) +} + +func TestGitOpsResolverIsConcurrentAndDeterministic(t *testing.T) { + t.Parallel() + fixture := newGitOpsFixture(t, brain.RuleConfigDrift) + want, err := fixture.resolver.Resolve(context.Background(), testWorkspace, fixture.verdict, []GitOpsProvenanceBundle{fixture.bundle}) + if err != nil { + t.Fatal(err) + } + wantJSON, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + + const workers = 64 + errors := make(chan error, workers) + var wait sync.WaitGroup + for range workers { + wait.Add(1) + go func() { + defer wait.Done() + got, resolveErr := fixture.resolver.Resolve(context.Background(), testWorkspace, fixture.verdict, []GitOpsProvenanceBundle{fixture.bundle}) + if resolveErr != nil { + errors <- resolveErr + return + } + encoded, marshalErr := json.Marshal(got) + if marshalErr != nil { + errors <- marshalErr + return + } + if !slices.Equal(encoded, wantJSON) { + errors <- fmt.Errorf("nondeterministic resolution") + } + }() + } + wait.Wait() + close(errors) + for err := range errors { + t.Error(err) + } +} + +func FuzzGitOpsResolverNeverPanics(f *testing.F) { + fixture := newGitOpsFixture(f, brain.RuleConfigDrift) + f.Add("deploy/payments.yaml", "replicas: 4\n", "dev") + f.Add("../secret", "marker\x00value", "HEAD") + f.Fuzz(func(t *testing.T, path, content, baseRef string) { + bundle := cloneBundle(fixture.bundle) + bundle.filePath = path + bundle.desiredContent = content + bundle.baseRef = baseRef + got, err := fixture.resolver.Resolve(context.Background(), testWorkspace, fixture.verdict, []GitOpsProvenanceBundle{bundle}) + if err != nil { + if len(err.Error()) > 160 { + t.Fatalf("unbounded error length %d", len(err.Error())) + } + return + } + encoded, marshalErr := json.Marshal(got) + if marshalErr != nil { + t.Fatal(marshalErr) + } + if len(encoded) > maxBundleContentBytes+(16<<10) { + t.Fatalf("unbounded resolution length %d", len(encoded)) + } + }) +} + +type gitOpsFixture struct { + resolver *GitOpsResolver + verdict brain.Verdict + bundle GitOpsProvenanceBundle +} + +func newGitOpsFixture(t testing.TB, rule brain.RuleID) gitOpsFixture { + t.Helper() + planner := newGitHubPlanner(t) + contract, err := HandlerContractFor(planner) + if err != nil { + t.Fatalf("HandlerContractFor() error = %v", err) + } + bundle, err := NewGitOpsProvenanceBundle(validGitOpsInput(contract)) + if err != nil { + t.Fatalf("NewGitOpsProvenanceBundle() error = %v", err) + } + resolver, err := NewGitOpsResolver(planner, func() time.Time { return testNow }) + if err != nil { + t.Fatalf("NewGitOpsResolver() error = %v", err) + } + return gitOpsFixture{resolver: resolver, verdict: validGitOpsVerdict(rule), bundle: bundle} +} + +func newGitHubPlanner(t testing.TB) *githubconnector.OpenPRPlanner { + t.Helper() + planner, err := githubconnector.NewOpenPRPlanner(githubconnector.OpenPRPlannerConfig{ + Host: "github.com", Owner: "ArdurAI", Repository: "sith", BaseRef: "dev", + }) + if err != nil { + t.Fatalf("NewOpenPRPlanner() error = %v", err) + } + return planner +} + +func validGitOpsInput(contract HandlerContract) GitOpsProvenanceInput { + subject := testSubjectRef() + return GitOpsProvenanceInput{ + Workspace: testWorkspace, + Subject: subject, + Sources: []SourceIdentity{{ + Kind: gitHubSourceKind, AdapterVersion: GitOpsSourceAdapterVersion, NativeID: "github.com/ArdurAI/sith", + }}, + ObservedAt: testNow.Add(-time.Minute), ValidUntil: testNow.Add(time.Minute), Handler: contract, + Repository: RepositoryIdentity{Host: "github.com", Owner: "ArdurAI", Repository: "sith"}, + BaseRef: "dev", BaseCommit: testBaseSHA, FilePath: "deploy/payments.yaml", ObservedBlobSHA: testBlobSHA, + DesiredContent: "replicas: 4\n", Title: "Reconcile payments resources", Body: "Source-owned drift remediation\n\nEvidence is attached.", + CommitMessage: "Reconcile payments resources", + EvidenceRefs: []fleet.ResourceRef{ + subject, + {SourceKind: gitHubSourceKind, Scope: "github.com", Kind: "Blob", Namespace: "ArdurAI/sith", Name: testBlobSHA}, + }, + } +} + +func validGitOpsVerdict(rule brain.RuleID) brain.Verdict { + ref := testSubjectRef() + lens, predicate, observed := fleet.LensDesired, "desired.drift", "OutOfSync" + if rule == brain.RuleOOMKilled { + lens, predicate, observed = fleet.LensLive, "pod.reason", "OOMKilled" + } + return brain.Verdict{ + Rule: rule, Status: brain.StatusConfirmed, Ref: ref, + Citations: []brain.Citation{{ + Ref: ref, Lens: lens, Predicate: predicate, Observed: observed, + Weight: 60, ObservedAt: testNow.Add(-2 * time.Minute), Source: "fixture", + }}, + RemediationCandidate: &brain.RemediationCandidate{ + Verb: intent.VerbGitOpsOpenPR, + RequiredProvenance: []brain.ProvenanceRequirement{ + brain.ProvenanceGitRepository, brain.ProvenanceGitBaseRef, brain.ProvenanceGitBaseCommit, + brain.ProvenanceGitFilePath, brain.ProvenanceGitObservedBlob, brain.ProvenanceGitDesiredContent, + }, + }, + } +} + +func testSubjectRef() fleet.ResourceRef { + return fleet.ResourceRef{SourceKind: "kubeconfig", Scope: "alpha", Kind: "Deployment", Namespace: "prod", Name: "payments"} +} + +func assertReadyResolution(t testing.TB, got Resolution) { + t.Helper() + if got.Status != ResolutionReady || len(got.Reasons) != 0 || got.ArgumentsDigest == "" || len(got.EvidenceRefs) != 2 { + t.Fatalf("resolution = %#v, want ready", got) + } + wantTarget := fleet.ResourceRef{SourceKind: gitHubSourceKind, Scope: "github.com", Kind: openPRTargetKind, Namespace: "ArdurAI", Name: "sith"} + if !sameResourceRef(got.Target, wantTarget) { + t.Fatalf("target = %#v, want %#v", got.Target, wantTarget) + } + var args openPRArguments + if err := json.Unmarshal(got.Arguments, &args); err != nil { + t.Fatalf("decode arguments: %v", err) + } + if args.BaseRef != "dev" || args.ExpectedBaseSHA != testBaseSHA || len(args.Changes) != 1 || + args.Changes[0].Path != "deploy/payments.yaml" || args.Changes[0].Content == nil || + *args.Changes[0].Content != "replicas: 4\n" || args.Changes[0].ExpectedBlobSHA == nil || + *args.Changes[0].ExpectedBlobSHA != testBlobSHA { + t.Fatalf("arguments = %#v, want exact source-owned values", args) + } + digest := sha256.Sum256(got.Arguments) + wantDigest := "sha256:" + hex.EncodeToString(digest[:]) + if got.ArgumentsDigest != wantDigest { + t.Fatalf("digest = %q, want %q", got.ArgumentsDigest, wantDigest) + } + if got.EvidenceRefs[0].String() > got.EvidenceRefs[1].String() { + t.Fatalf("evidence refs are not canonical: %#v", got.EvidenceRefs) + } +} + +func assertAbstention(t testing.TB, got Resolution, want AbstentionReason) { + t.Helper() + if got.Status != ResolutionAbstained || !slices.Equal(got.Reasons, []AbstentionReason{want}) || + !zeroResourceRef(got.Target) || len(got.Arguments) != 0 || got.ArgumentsDigest != "" || len(got.EvidenceRefs) != 0 { + t.Fatalf("resolution = %#v, want closed abstention %q", got, want) + } +} + +func zeroResourceRef(ref fleet.ResourceRef) bool { + return ref.SourceKind == "" && ref.Scope == "" && ref.Kind == "" && ref.Namespace == "" && ref.Name == "" && len(ref.Attributes) == 0 +} + +func cloneVerdict(verdict brain.Verdict) brain.Verdict { + cloned := verdict + cloned.Ref = cloneResourceRef(verdict.Ref) + cloned.Citations = slices.Clone(verdict.Citations) + for index := range cloned.Citations { + cloned.Citations[index].Ref = cloneResourceRef(cloned.Citations[index].Ref) + } + if verdict.RemediationCandidate != nil { + candidate := *verdict.RemediationCandidate + candidate.RequiredProvenance = slices.Clone(verdict.RemediationCandidate.RequiredProvenance) + cloned.RemediationCandidate = &candidate + } + return cloned +} + +func cloneBundle(bundle GitOpsProvenanceBundle) GitOpsProvenanceBundle { + cloned := bundle + cloned.subject = cloneResourceRef(bundle.subject) + cloned.evidenceRefs = cloneResourceRefs(bundle.evidenceRefs) + return cloned +} + +type handlerProxy struct { + base GitOpsHandler + descriptorCalls int + mutateDescriptor func(connector.Descriptor, int) connector.Descriptor + canonicalize func(fleet.ResourceRef, json.RawMessage) (fleet.ResourceRef, json.RawMessage) +} + +func (proxy *handlerProxy) Descriptor() connector.Descriptor { + proxy.descriptorCalls++ + descriptor := cloneDescriptor(proxy.base.Descriptor()) + if proxy.mutateDescriptor != nil { + return proxy.mutateDescriptor(descriptor, proxy.descriptorCalls) + } + return descriptor +} + +func (proxy *handlerProxy) CanonicalizeOpenPRArgs(arguments json.RawMessage) (fleet.ResourceRef, json.RawMessage, error) { + target, document, err := proxy.base.CanonicalizeOpenPRArgs(arguments) + if err != nil || proxy.canonicalize == nil { + return target, document, err + } + target, document = proxy.canonicalize(target, append(json.RawMessage(nil), document...)) + return target, document, nil +} + +func cloneDescriptor(descriptor connector.Descriptor) connector.Descriptor { + cloned := descriptor + cloned.WireVersions = slices.Clone(descriptor.WireVersions) + cloned.Capabilities = slices.Clone(descriptor.Capabilities) + cloned.Verbs = slices.Clone(descriptor.Verbs) + cloned.ArgSchemas = make(map[intent.Verb]json.RawMessage, len(descriptor.ArgSchemas)) + for verb, schema := range descriptor.ArgSchemas { + cloned.ArgSchemas[verb] = append(json.RawMessage(nil), schema...) + } + return cloned +} diff --git a/sessions/2026-07-22-e14-gitops-provenance-resolver.md b/sessions/2026-07-22-e14-gitops-provenance-resolver.md new file mode 100644 index 0000000..f7d374e --- /dev/null +++ b/sessions/2026-07-22-e14-gitops-provenance-resolver.md @@ -0,0 +1,113 @@ +# Session — 2026-07-22 — E14 GitOps provenance resolver + +**Builder:** Gnani Rahul Nutakki · **Branch:** `gnanirahulnutakki/gitops-provenance-20260722` +**Slice:** [#301](https://github.com/ArdurAI/sith/issues/301), E14 +[#46](https://github.com/ArdurAI/sith/issues/46) · **Status:** complete local proof; hosted proof pending + +## [G] Goal + +Implement the owner-approved F14.6b contract-only GitOps stage: resolve one confirmed canonical +Brain candidate against one fresh, source-owned provenance bundle without allowing a caller to +invent target or handler arguments and without creating a PEP proposal or write capability. + +## [S] Scope + +- Add one versioned immutable provenance bundle outside `internal/brain`, owned by the canonical + GitHub source contract and bound to one workspace, cited subject, repository, exact Git objects, + desired content, evidence set, and bounded validity interval. +- Pin provenance to the exact planning handler adapter version and argument-schema digest. +- Resolve confirmed entity-local R2/R4 `gitops.open-pr` candidates only; keep R1 and all other rules + candidate-only or advisory-only. +- Reuse the GitHub planner's pure schema and semantic validation boundary and verify that its + canonical output preserves every source-owned value. +- Exclude endpoint, PEP, persistence, database, credential, network, Git access, PR creation, + dispatch, shell, filesystem, cluster mutation, or execution behavior. + +## [A] Decision and implementation + +- Parent issue 46 records GR's approval of the source-owned-bundle plus pure-resolver design; child + issue 301 locks the bounded acceptance contract. +- `GitOpsProvenanceBundle` has private fields and is constructible only after exact source count, + source version, workspace, subject, repository, object-ID, validity, content-bound, and evidence + validation. Mutable references and slices are defensively copied and evidence ordering compares + the complete resource-identity tuple. +- The bundle expires no later than five minutes after observation and rejects symbolic `HEAD`, full + `refs/...`, option-shaped, and commit-shaped base branch identities. The shared GitHub planner now + rejects those ambiguous configured base branches too. +- `OpenPRPlanner.CanonicalizeOpenPRArgs` exposes the planner's existing I/O-free schema and semantic + checks without constructing an action plan. `Plan` now calls the same internal seam, so policy + cannot drift between resolver and planner. +- The resolver requires one confirmed non-fleet R2/R4 verdict with an attached fresh citation and + exactly one fresh matching bundle. It verifies the live descriptor, wire version, adapter + version, schema digest, repository target, canonical schema, and exact commit/blob/content output, + then rechecks the descriptor to close an in-process time-of-check/time-of-use gap. +- Ready output is limited to normalized target, canonical arguments, SHA-256 argument digest, and + copied evidence references. All expected refusals use bounded closed abstention reasons. +- A recursive import guard prevents I/O, policy, persistence, or authority packages from entering + the remediation package, and reflection tests lock authority-free public shapes plus opaque bundle + fields. + +## [T] Proof + +- Focused package tests pass under the race detector for the GitHub planner and remediation resolver. +- Twenty consecutive focused resolver race runs pass. The exact focused linter passes with zero + issues, and the resolver fuzz target passes 50,000 executions. +- Positive R2/R4 tests prove stable canonical arguments and digests across repeated calls and + evidence input ordering. Sixty-four concurrent resolutions remain byte-identical. +- Adversarial tests cover missing/mutated/unsupported candidates; stale/unattached verdict evidence; + zero/multiple/stale/future/foreign/unattached/multi-source provenance; unsafe/floating inputs; + validity, content, repository, base, commit, and blob failures; descriptor, wire, adapter, and + schema drift; handler target/output mutation; nil dependencies; cancellation before and after + handler validation; output aliases; and fuzzed paths/content/base refs. +- `make ci` passes formatting, vet, zero-issue lint, `govulncheck` with no vulnerabilities, every + race test, shell/tooling policies, nine Prometheus rules, performance, binary end-to-end, and the + production build. The new resolver package reaches 94.1% statement coverage. +- `make e2e-isolation` passes PostgreSQL 18.4 forced-RLS coverage and both cross-workspace fuzzers at + 50,000 executions each. +- `make release-check` passes module verification, two reproducible four-platform snapshots, SPDX + SBOMs, checksums, formula generation, and the release-derived amd64/arm64 distroless OCI layout. +- The pinned Kubernetes 1.36.1 Kind gate passes two-cluster fleet fan-out, OCI image, and Argo + Application projection under the race detector in 243.913 seconds. Teardown leaves no Kind + cluster, Sith container, or isolated release builder. +- The first CodeRabbit pass found two valid documentation omissions: evidence references in the + README result list and exact byte/digest/freshness semantics in the spec. Both were corrected. + A second whole-diff review covering all nine changed files reports zero findings. +- A final manual execution trace then found a last-check time-of-check/time-of-use gap: cancellation + or expiry during the post-canonicalization descriptor check could otherwise return ready. The + resolver now rechecks both immediately before output, with dedicated regressions. +- The exact post-hardening focused fuzz, full CI, isolation, release, and real-cluster matrices all + pass again, and the third whole-diff CodeRabbit review reports zero findings. +- Hosted review, exact-head CI/CodeQL, and post-merge `dev` proofs remain pending. + +## [S] Security, reliability, and cost + +The resolver is pure and offline. Actor, role, authenticated scope, server-owned intent ID, policy +decision, approval state, credential, endpoint, and execution state are absent from the bundle and +result. A ready result is provenance-complete, not authorized. This slice adds no API request, +egress, storage, cloud resource, telemetry cardinality, or recurring cost. A future GitHub read +adapter must separately account for credential custody, rate limits, egress, and remote-state +freshness. + +## [R] Primary references + +- [GitHub REST references](https://docs.github.com/en/rest/git/refs?apiVersion=2026-03-10) +- [GitHub REST commits](https://docs.github.com/en/rest/git/commits?apiVersion=2026-03-10) +- [GitHub REST trees](https://docs.github.com/en/rest/git/trees?apiVersion=2026-03-10) +- [Argo CD Application specification](https://argo-cd.readthedocs.io/en/latest/user-guide/application-specification/) + +## [N] Next + +Create one SSH-signed DCO/GSTACK commit, open a PR to `dev`, and require exact-head hosted +CI/CodeQL/review plus exact post-merge proof. Close only child issue 301. F14.6 and E14 remain open +for the separately reviewed authenticated Hub-to-PEP composition and the live canonical provenance +adapter. + +## [C] Checkpoint #1 + +The resolver contract, shared handler canonicalization seam, ambiguous-base hardening, adversarial +tests, import/public-shape guards, README/spec correction, session record, clean independent review, +and complete local gate matrix are frozen on exact base +`4b562abe6a16cf5f7ba77b6b16b682361d43f23b`. README was reviewed and updated before commit because +the public architecture now includes a provenance-resolution stage. Remaining gates are the signed +commit, exact-head hosted CI/CodeQL/review, merge without rewriting the signed feature commit, and +exact post-merge `dev` proof.