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
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,12 +724,22 @@ constructor recomputes the blob object ID, copies and canonically orders attache
evidence, and bounds content, evidence count, and validity. A pure trusted-time check classifies the
snapshot as fresh, future, or stale; it performs no I/O.

`DesiredChange` remains a later separately reviewed contract. The snapshot contains no desired
bytes, PR metadata, handler binding, actor, intent, policy decision, approval, credential, endpoint,
persistence, dispatch, mutation, or execution state, and it is not wired into the Brain, resolver,
connector runtime, PEP, or Hub. R2 and R4 remain advisory-only. This offline contract adds no API
request, egress, storage, cloud resource, telemetry cardinality, or recurring cost; a future live
adapter must separately own least-privilege contents-read credentials, rate limits, and freshness.
The separately gated output half now exists as immutable `desired-change/v1`. A `DesiredChange`
defensively embeds one exact validated snapshot, one lowercase canonical `<transformer>/<version>`
identity, exact bounded non-NUL UTF-8 output bytes, and 2–32 unique stable evidence references that
must attach the snapshot's affected resource and observed blob. It preserves bytes without Unicode,
line-ending, whitespace, or YAML normalization, rejects exact no-op output, and canonically orders
copied evidence.

There is deliberately no exported desired-change constructor. Until a concrete deterministic
transformer or declarative renderer is separately reviewed, request and runtime code cannot label
arbitrary bytes as trusted output. The snapshot still contains no desired bytes, and neither
contract contains PR metadata, handler binding, actor, intent, policy decision, approval,
credential, endpoint, persistence, dispatch, mutation, or execution state. Neither is wired into
the Brain, resolver, connector runtime, PEP, or Hub, so R2 and R4 remain advisory-only. These
offline contracts add no API request, egress, storage, cloud resource, telemetry cardinality, or
recurring cost; a future live adapter and transformer separately own credentials, rate limits,
freshness, format semantics, and evidence-to-output policy.

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
Expand Down
23 changes: 19 additions & 4 deletions docs/specs/E2-readfed-brain-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -647,10 +647,25 @@ fresh. A zero clock or internally invalid snapshot fails closed.

`GitSourceSnapshot` deliberately has no desired bytes, PR title/body, commit message, handler
contract, actor, role, intent ID, policy or approval decision, credential, endpoint, signature,
persistence, dispatch, mutation, or execution state. `DesiredChange` is a later separately reviewed
transformer/renderer contract that must bind its input snapshot version, evidence, and output. The
snapshot is not wired into the existing resolver, Brain, connector runtime, PEP, or Hub. R2 and R4
therefore remain operator-facing advisory rules; this split adds no production read or write path.
persistence, dispatch, mutation, or execution state.

The separately reviewed output contract is `desired-change/v1`. One opaque `DesiredChange` binds a
defensive copy of one valid snapshot to one lowercase canonical `<transformer>/<version>` identity,
exact proposed UTF-8 bytes, and 2–32 unique stable evidence references. Construction preserves the
output byte sequence exactly, applies the same 64 KiB and non-NUL bounds as the snapshot, rejects an
exact no-op against current content, canonically sorts copied evidence, and requires both the
affected resource and exact observed blob to remain attached. The embedded snapshot preserves the
repository, base ref, commit, path, blob, current bytes, evidence, and validity window as one exact
later composition precondition.

Desired-change construction is package-private. No concrete R2 memory-limit transformer, R4
live-to-Git reconciler, YAML/Helm/Kustomize renderer, or file mapper is approved in this contract,
so callers cannot relabel supplied replacement bytes as trusted output. The change exposes only its
closed contract version and has no PR metadata, handler binding, actor, role, intent ID, policy or
approval decision, credential, endpoint, signature, persistence, dispatch, mutation, or execution
state. Neither half is wired into the existing resolver, Brain, connector runtime, PEP, or Hub.
R2 and R4 therefore remain operator-facing advisory rules; this split adds no production read or
write path.

### 3.7 Where the Brain lives (open decision)

Expand Down
69 changes: 69 additions & 0 deletions internal/remediation/boundary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package remediation

import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
Expand Down Expand Up @@ -137,6 +138,74 @@ func TestGitSourceSnapshotBoundaryIsObservedOnlyAndOpaque(t *testing.T) {
}
}

func TestDesiredChangeBoundaryIsTransformerOwnedAndOpaque(t *testing.T) {
t.Parallel()
change := reflect.TypeFor[DesiredChange]()
if change.NumField() != 5 {
t.Fatalf("DesiredChange fields = %d, want exact reviewed shape", change.NumField())
}
for index := range change.NumField() {
field := change.Field(index)
if field.IsExported() {
t.Fatalf("DesiredChange exposes mutable field %s", field.Name)
}
name := strings.ToLower(field.Name)
for _, forbidden := range []string{
"title", "body", "commitmessage", "handler", "actor", "role", "intent", "approval",
"policy", "credential", "token", "secret", "signature", "endpoint", "dispatch", "execute",
} {
if strings.Contains(name, forbidden) {
t.Fatalf("DesiredChange exposes forbidden authority field %s", field.Name)
}
}
}
if change.NumMethod() != 1 || change.Method(0).Name != "Version" {
t.Fatalf("DesiredChange methods = %#v, want only Version", change)
}

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, parseErr := parser.ParseFile(token.NewFileSet(), path, nil, 0)
if parseErr != nil {
return parseErr
}
for _, rawDeclaration := range file.Decls {
declaration, ok := rawDeclaration.(*ast.FuncDecl)
if ok && declaration.Recv == nil && token.IsExported(declaration.Name.Name) &&
returnsDesiredChange(declaration) {
t.Errorf("DesiredChange construction escaped the reviewed package boundary as %s in %s", declaration.Name.Name, path)
}
}
return nil
})
if err != nil {
t.Fatalf("inspect DesiredChange package boundary: %v", err)
}
}

func returnsDesiredChange(declaration *ast.FuncDecl) bool {
if declaration.Type.Results == nil {
return false
}
found := false
for _, result := range declaration.Type.Results.List {
ast.Inspect(result.Type, func(node ast.Node) bool {
identifier, ok := node.(*ast.Ident)
if ok && identifier.Name == "DesiredChange" {
found = true
return false
}
return !found
})
}
return found
}

func assertExactFields(t *testing.T, value reflect.Type, expected []string) {
t.Helper()
if value.NumField() != len(expected) {
Expand Down
117 changes: 117 additions & 0 deletions internal/remediation/desired_change.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: Apache-2.0

package remediation

import (
"fmt"
"sort"
"strings"

"github.com/ArdurAI/sith/internal/fleet"
)

const (
// DesiredChangeVersion is the first immutable snapshot-to-output binding contract.
DesiredChangeVersion = "desired-change/v1"

maxDesiredChangeEvidenceRefs = 32
)

// DesiredChange is immutable after construction. It binds exact proposed bytes to one exact
// GitSourceSnapshot, one reviewed transformer version, and cited evidence. Its fields remain
// private, and construction remains package-private until a concrete transformer policy is
// separately reviewed.
type DesiredChange struct {
version string
snapshot GitSourceSnapshot
transformerVersion string
desiredContent string
evidenceRefs []fleet.ResourceRef
}

// Version reports the closed desired-change contract without exposing proposed bytes or source
// state.
func (change DesiredChange) Version() string { return change.version }

// newDesiredChange is deliberately package-private. Only a separately reviewed deterministic
// transformer or declarative renderer in this package may turn output bytes into a DesiredChange;
// request and runtime callers cannot relabel arbitrary bytes as trusted transformer output.
func newDesiredChange(
snapshot GitSourceSnapshot,
transformerVersion string,
desiredContent string,
evidenceRefs []fleet.ResourceRef,
) (DesiredChange, error) {
change := DesiredChange{
version: DesiredChangeVersion,
snapshot: cloneGitSourceSnapshot(snapshot),
transformerVersion: transformerVersion,
desiredContent: desiredContent,
evidenceRefs: cloneResourceRefs(evidenceRefs),
}
sort.Slice(change.evidenceRefs, func(left, right int) bool {
return resourceRefLess(change.evidenceRefs[left], change.evidenceRefs[right])
})
if err := change.validate(); err != nil {
return DesiredChange{}, fmt.Errorf("construct desired change: change is invalid")
}
return change, nil
}

func (change DesiredChange) validate() error {
if change.version != DesiredChangeVersion || change.snapshot.validate() != nil ||
!validTransformerVersion(change.transformerVersion) ||
!validGitSourceContent(change.desiredContent) ||
change.desiredContent == change.snapshot.currentContent || len(change.evidenceRefs) < 2 ||
len(change.evidenceRefs) > maxDesiredChangeEvidenceRefs {
return fmt.Errorf("desired change is invalid")
}

subjectAttached := false
blobAttached := false
for index, ref := range change.evidenceRefs {
if validateStableRef(ref) != nil ||
(index > 0 && !resourceRefLess(change.evidenceRefs[index-1], ref)) {
return fmt.Errorf("desired change evidence is invalid")
}
subjectAttached = subjectAttached || sameResourceRef(ref, change.snapshot.subject)
blobAttached = blobAttached || gitSourceBlobRefMatches(ref, change.snapshot)
}
if !subjectAttached || !blobAttached {
return fmt.Errorf("desired change evidence is unattached")
}
return nil
}

func cloneGitSourceSnapshot(snapshot GitSourceSnapshot) GitSourceSnapshot {
cloned := snapshot
cloned.subject = cloneResourceRef(snapshot.subject)
cloned.evidenceRefs = cloneResourceRefs(snapshot.evidenceRefs)
return cloned
}

func validTransformerVersion(value string) bool {
if validateSafeText(value, maxIdentityBytes, false) != nil || value != strings.ToLower(value) {
return false
}
parts := strings.Split(value, "/")
if len(parts) != 2 {
return false
}
for _, part := range parts {
if part == "" || !isLowerAlphaNumeric(part[0]) || !isLowerAlphaNumeric(part[len(part)-1]) {
return false
}
for _, character := range part {
if (character < 'a' || character > 'z') && (character < '0' || character > '9') &&
character != '-' && character != '_' && character != '.' {
return false
}
}
}
return true
}

func isLowerAlphaNumeric(value byte) bool {
return (value >= 'a' && value <= 'z') || (value >= '0' && value <= '9')
}
Loading