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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,21 @@ remote ref, commit, tree, or blob state; a future authorized read adapter must a
facts and construct the bundle. That adapter's API calls, rate-limit/egress impact, credential
custody, and freshness policy are outside this offline slice.

The approved next decomposition now defines the observed half independently as an immutable
`git-source-snapshot/v1`. It contains one workspace and affected resource, one pinned GitHub source
identity and repository, a configured non-symbolic base ref plus exact resolved commit, one safe
repository-relative path, and exact current file bytes plus the matching Git blob identity. The
constructor recomputes the blob object ID, copies and canonically orders attached subject/blob
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.

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
24 changes: 24 additions & 0 deletions docs/specs/E2-readfed-brain-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,30 @@ composition must separately derive workspace, actor, role, and intent ID from au
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.

The owner-approved follow-on decomposition starts with the observed side only:
`git-source-snapshot/v1`. A `GitSourceSnapshot` binds one validated workspace and affected resource
to exactly one `github-git-source-snapshot/2026-03-10` source identity, one repository, one configured
non-symbolic base ref, its exact resolved commit object ID, one repository-relative path, the exact
current UTF-8 bytes, and their exact blob object ID. Construction recomputes the Git blob identity
over `blob <byte-count>\0<content>` and rejects a mismatched claim. Forty-hex SHA-1 identifiers match
GitHub's current Git database API; 64-hex SHA-256 identifiers are accepted under Git's hash-transition
format. SHA-1 here is an interoperability identity, not a security digest.

Snapshot input is bounded to 64 KiB of non-NUL UTF-8 content, a five-minute validity interval, and
2–32 unique stable evidence references. The canonical evidence set must attach both the affected
resource and the exact repository blob. Mutable resource references and evidence slices are copied,
evidence is deterministically ordered, timestamps are normalized to UTC, and all validated snapshot
fields remain private. Its only state-dependent operation is a pure trusted-time classification:
`now < ObservedAt` is future, `now >= ValidUntil` is stale, and the half-open interval between them is
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.

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

Two placements, both viable:
Expand Down
37 changes: 37 additions & 0 deletions internal/remediation/boundary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import (

var allowedProductionImports = map[string]bool{
"context": true,
"crypto/sha1": true,
"crypto/sha256": true,
"encoding/hex": true,
"encoding/json": true,
"fmt": true,
"path": true,
"reflect": true,
"slices": true,
"sort": true,
Expand Down Expand Up @@ -100,6 +102,41 @@ func TestGitOpsBoundaryOmitsAuthorityAndKeepsBundleOpaque(t *testing.T) {
}
}

func TestGitSourceSnapshotBoundaryIsObservedOnlyAndOpaque(t *testing.T) {
t.Parallel()
assertExactFields(t, reflect.TypeFor[GitSourceSnapshotInput](), []string{
"Workspace", "Subject", "Sources", "ObservedAt", "ValidUntil", "Repository", "BaseRef",
"BaseCommit", "FilePath", "ObservedBlobSHA", "CurrentContent", "EvidenceRefs",
})

snapshot := reflect.TypeFor[GitSourceSnapshot]()
if snapshot.NumField() != 13 {
t.Fatalf("GitSourceSnapshot fields = %d, want exact reviewed shape", snapshot.NumField())
}
for index := range snapshot.NumField() {
if field := snapshot.Field(index); field.IsExported() {
t.Fatalf("GitSourceSnapshot exposes mutable field %s", field.Name)
}
}
if snapshot.NumMethod() != 2 || snapshot.Method(0).Name != "Freshness" || snapshot.Method(1).Name != "Version" {
t.Fatalf("GitSourceSnapshot methods = %#v, want only Freshness and Version", snapshot)
}

for _, value := range []reflect.Type{reflect.TypeFor[GitSourceSnapshotInput](), snapshot} {
for index := range value.NumField() {
name := strings.ToLower(value.Field(index).Name)
for _, forbidden := range []string{
"desired", "title", "body", "commitmessage", "handler", "actor", "role", "intent",
"approval", "policy", "credential", "token", "secret", "signature", "endpoint", "dispatch",
} {
if strings.Contains(name, forbidden) {
t.Fatalf("%s exposes forbidden change or 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) {
Expand Down
231 changes: 231 additions & 0 deletions internal/remediation/git_snapshot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// SPDX-License-Identifier: Apache-2.0

package remediation

import (
"crypto/sha1" //nolint:gosec // GitHub Git object identity is SHA-1; this is not a security digest.
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"sort"
"strings"
"time"
"unicode"
"unicode/utf8"

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

const (
// GitSourceSnapshotVersion is the first immutable observed-Git-state contract.
GitSourceSnapshotVersion = "git-source-snapshot/v1"
// GitSourceSnapshotAdapterVersion pins the canonical GitHub Git-object observation contract.
GitSourceSnapshotAdapterVersion = "github-git-source-snapshot/2026-03-10"

maxGitSourceSnapshotContentBytes = 64 << 10
maxGitSourceSnapshotValidity = 5 * time.Minute
maxGitSourceSnapshotEvidenceRefs = 32
)

// GitSourceFreshness classifies an immutable observation against a caller-supplied trusted time.
type GitSourceFreshness string

// Closed freshness states. The interval is fresh exactly when ObservedAt <= now < ValidUntil.
const (
GitSourceFresh GitSourceFreshness = "fresh"
GitSourceFuture GitSourceFreshness = "future"
GitSourceStale GitSourceFreshness = "stale"
)

// GitSourceSnapshotInput is accepted only from a canonical source adapter boundary. It contains
// observed Git state, never desired bytes, PR metadata, authority, credentials, or execution state.
type GitSourceSnapshotInput struct {
Workspace tenancy.WorkspaceID
Subject fleet.ResourceRef
Sources []SourceIdentity
ObservedAt time.Time
ValidUntil time.Time
Repository RepositoryIdentity
BaseRef string
BaseCommit string
FilePath string
ObservedBlobSHA string
CurrentContent string
EvidenceRefs []fleet.ResourceRef
}

// GitSourceSnapshot is immutable after construction. Fields remain private so later composition
// cannot rewrite an observed identity, Git precondition, or byte sequence.
type GitSourceSnapshot struct {
version string
workspace tenancy.WorkspaceID
subject fleet.ResourceRef
source SourceIdentity
observedAt time.Time
validUntil time.Time
repository RepositoryIdentity
baseRef string
baseCommit string
filePath string
observedBlobSHA string
currentContent string
evidenceRefs []fleet.ResourceRef
}

// Version reports the closed snapshot contract without exposing mutable observation fields.
func (snapshot GitSourceSnapshot) Version() string { return snapshot.version }

// NewGitSourceSnapshot validates and defensively copies one canonical Git observation. It performs
// no I/O and does not infer or render a desired change.
func NewGitSourceSnapshot(input GitSourceSnapshotInput) (GitSourceSnapshot, error) {
if len(input.Sources) != 1 {
return GitSourceSnapshot{}, fmt.Errorf("construct Git source snapshot: exactly one source is required")
}
snapshot := GitSourceSnapshot{
version: GitSourceSnapshotVersion,
workspace: input.Workspace,
subject: cloneResourceRef(input.Subject),
source: input.Sources[0],
observedAt: input.ObservedAt.UTC(),
validUntil: input.ValidUntil.UTC(),
repository: input.Repository,
baseRef: input.BaseRef,
baseCommit: input.BaseCommit,
filePath: input.FilePath,
observedBlobSHA: input.ObservedBlobSHA,
currentContent: input.CurrentContent,
evidenceRefs: cloneResourceRefs(input.EvidenceRefs),
}
sort.Slice(snapshot.evidenceRefs, func(left, right int) bool {
return resourceRefLess(snapshot.evidenceRefs[left], snapshot.evidenceRefs[right])
})
if err := snapshot.validate(); err != nil {
return GitSourceSnapshot{}, fmt.Errorf("construct Git source snapshot: snapshot is invalid")
}
return snapshot, nil
}

// Freshness classifies the snapshot against a trusted clock value. Construction deliberately does
// not read a clock so identical source input always produces the same immutable value.
func (snapshot GitSourceSnapshot) Freshness(now time.Time) (GitSourceFreshness, error) {
if err := snapshot.validate(); err != nil {
return "", fmt.Errorf("inspect Git source snapshot: snapshot is invalid")
}
now = now.UTC()
if now.IsZero() {
return "", fmt.Errorf("inspect Git source snapshot: trusted time is required")
}
if now.Before(snapshot.observedAt) {
return GitSourceFuture, nil
}
if !now.Before(snapshot.validUntil) {
return GitSourceStale, nil
}
return GitSourceFresh, nil
}

func (snapshot GitSourceSnapshot) validate() error {
if snapshot.version != GitSourceSnapshotVersion || tenancy.ValidateWorkspaceID(snapshot.workspace) != nil ||
validateStableRef(snapshot.subject) != nil || validateGitSourceSnapshotSource(snapshot.source) != nil ||
validateRepository(snapshot.repository) != nil || snapshot.source.NativeID != snapshot.repository.nativeID() ||
snapshot.observedAt.IsZero() || snapshot.validUntil.IsZero() ||
!snapshot.observedAt.Before(snapshot.validUntil) ||
snapshot.validUntil.Sub(snapshot.observedAt) > maxGitSourceSnapshotValidity ||
!validGitSourceBaseRef(snapshot.baseRef) || !validObjectID(snapshot.baseCommit) ||
!validGitSourcePath(snapshot.filePath) || !validGitSourceContent(snapshot.currentContent) ||
len(snapshot.baseCommit) != len(snapshot.observedBlobSHA) ||
!gitSourceBlobMatchesContent(snapshot.observedBlobSHA, snapshot.currentContent) || len(snapshot.evidenceRefs) < 2 ||
len(snapshot.evidenceRefs) > maxGitSourceSnapshotEvidenceRefs {
return fmt.Errorf("git source snapshot is invalid")
}

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

func validateGitSourceSnapshotSource(source SourceIdentity) error {
if source.Kind != gitHubSourceKind || source.AdapterVersion != GitSourceSnapshotAdapterVersion ||
validateSafeText(source.NativeID, maxIdentityBytes, false) != nil {
return fmt.Errorf("git source snapshot source is invalid")
}
return nil
}

func validGitSourceBaseRef(value string) bool {
if validateSafeText(value, maxIdentityBytes, false) != nil || strings.HasPrefix(value, ".") ||
strings.HasPrefix(value, "-") || strings.HasPrefix(value, "/") ||
strings.HasPrefix(strings.ToLower(value), "refs/") || strings.EqualFold(value, "HEAD") ||
value == "@" || validObjectID(value) || strings.HasSuffix(value, ".") || strings.HasSuffix(value, "/") ||
strings.Contains(value, "..") || strings.Contains(value, "//") || strings.Contains(value, "@{") ||
strings.HasSuffix(strings.ToLower(value), ".lock") {
return false
}
for _, character := range value {
if unicode.IsSpace(character) || strings.ContainsRune("~^:?*[]\\", character) {
return false
}
}
for _, component := range strings.Split(value, "/") {
if component == "" || strings.HasPrefix(component, ".") || strings.HasSuffix(strings.ToLower(component), ".lock") {
return false
}
}
return true
}

func validGitSourcePath(value string) bool {
if validateSafeText(value, maxIdentityBytes, false) != nil || strings.HasPrefix(value, "/") ||
strings.HasSuffix(value, "/") || strings.Contains(value, "\\") || path.Clean(value) != value {
return false
}
for _, component := range strings.Split(value, "/") {
if component == "" || component == "." || component == ".." || strings.EqualFold(component, ".git") {
return false
}
}
return true
}

func validGitSourceContent(value string) bool {
return len(value) <= maxGitSourceSnapshotContentBytes && utf8.ValidString(value) &&
!strings.ContainsRune(value, '\x00')
}

func gitSourceBlobMatchesContent(objectID, content string) bool {
if !validObjectID(objectID) {
return false
}
payload := make([]byte, 0, len(content)+64)
payload = fmt.Appendf(payload, "blob %d\x00", len(content))
payload = append(payload, content...)
switch len(objectID) {
case sha1.Size * 2:
digest := sha1.Sum(payload) //nolint:gosec // Git object identity, not a security digest.
return objectID == hex.EncodeToString(digest[:])
case sha256.Size * 2:
digest := sha256.Sum256(payload)
return objectID == hex.EncodeToString(digest[:])
default:
return false
}
}

func gitSourceBlobRefMatches(ref fleet.ResourceRef, snapshot GitSourceSnapshot) bool {
return ref.SourceKind == snapshot.source.Kind && ref.Scope == snapshot.repository.Host &&
ref.Kind == "Blob" && ref.Namespace == snapshot.repository.Owner+"/"+snapshot.repository.Repository &&
ref.Name == snapshot.observedBlobSHA && len(ref.Attributes) == 0
}
Loading