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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions docs/specs/E2-readfed-brain-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
57 changes: 41 additions & 16 deletions internal/connector/github/action_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...))
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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") {
Expand Down
40 changes: 40 additions & 0 deletions internal/connector/github/action_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions internal/connector/github/boundary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ var allowedProductionImports = map[string]bool{

var allowedProductionDeclarations = map[string]bool{
"APIVersion": true,
"CanonicalizeOpenPRArgs": true,
"Capabilities": true,
"Descriptor": true,
"Kind": true,
Expand All @@ -52,6 +53,7 @@ var allowedProductionDeclarations = map[string]bool{
"WorkflowRunProjection": true,
"WorkflowRunProtocolVersion": true,
"changeObservation": true,
"canonicalizeOpenPRArgs": true,
"commitLookupParams": true,
"consumeUniqueJSON": true,
"matchingDelimiter": true,
Expand Down
113 changes: 113 additions & 0 deletions internal/remediation/boundary_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading