diff --git a/README.md b/README.md index b7cca3a..5de0b52 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/specs/E2-readfed-brain-integrations.md b/docs/specs/E2-readfed-brain-integrations.md index 4029b16..910b3fd 100644 --- a/docs/specs/E2-readfed-brain-integrations.md +++ b/docs/specs/E2-readfed-brain-integrations.md @@ -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 \0` 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: diff --git a/internal/remediation/boundary_test.go b/internal/remediation/boundary_test.go index 53f9974..4d49788 100644 --- a/internal/remediation/boundary_test.go +++ b/internal/remediation/boundary_test.go @@ -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, @@ -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) { diff --git a/internal/remediation/git_snapshot.go b/internal/remediation/git_snapshot.go new file mode 100644 index 0000000..545db96 --- /dev/null +++ b/internal/remediation/git_snapshot.go @@ -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 +} diff --git a/internal/remediation/git_snapshot_test.go b/internal/remediation/git_snapshot_test.go new file mode 100644 index 0000000..3cb7a8c --- /dev/null +++ b/internal/remediation/git_snapshot_test.go @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: Apache-2.0 + +package remediation + +import ( + "fmt" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/ArdurAI/sith/internal/fleet" +) + +const testSnapshotBlobSHA = "c63745ccdd30a4492aed8a39e04b7f482ace3612" + +const testSnapshotSHA256Blob = "c611609efc93463233f57218a49edeea21922ab692dbb3b1fe535a52afe4545a" + +const emptyGitBlobSHA = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" + +func TestGitSourceSnapshotPreservesExactObservedState(t *testing.T) { + t.Parallel() + input := validGitSourceSnapshotInput() + slices.Reverse(input.EvidenceRefs) + snapshot, err := NewGitSourceSnapshot(input) + if err != nil { + t.Fatalf("NewGitSourceSnapshot() error = %v", err) + } + if snapshot.Version() != GitSourceSnapshotVersion || snapshot.workspace != testWorkspace || + !sameResourceRef(snapshot.subject, testSubjectRef()) || snapshot.source != input.Sources[0] || + snapshot.repository != input.Repository || snapshot.baseRef != "dev" || snapshot.baseCommit != testBaseSHA || + snapshot.filePath != "deploy/payments.yaml" || snapshot.observedBlobSHA != testSnapshotBlobSHA || + snapshot.currentContent != input.CurrentContent || !snapshot.observedAt.Equal(input.ObservedAt) || + !snapshot.validUntil.Equal(input.ValidUntil) { + t.Fatalf("snapshot did not preserve exact observed state: %#v", snapshot) + } + if len(snapshot.evidenceRefs) != 2 || !sameResourceRef(snapshot.evidenceRefs[0], testBlobRef()) || + !sameResourceRef(snapshot.evidenceRefs[1], testSubjectRef()) { + t.Fatalf("evidence = %#v, want canonical blob and subject order", snapshot.evidenceRefs) + } + got, err := snapshot.Freshness(testNow) + if err != nil || got != GitSourceFresh { + t.Fatalf("Freshness() = %q, %v, want %q", got, err, GitSourceFresh) + } + + reordered := validGitSourceSnapshotInput() + reorderedSnapshot, err := NewGitSourceSnapshot(reordered) + if err != nil { + t.Fatalf("NewGitSourceSnapshot(reordered) error = %v", err) + } + if !slices.EqualFunc(snapshot.evidenceRefs, reorderedSnapshot.evidenceRefs, sameResourceRef) { + t.Fatalf("evidence ordering changed with caller order: %#v != %#v", snapshot.evidenceRefs, reorderedSnapshot.evidenceRefs) + } +} + +func TestGitSourceSnapshotConstructionIsMutationIsolated(t *testing.T) { + t.Parallel() + input := validGitSourceSnapshotInput() + snapshot, err := NewGitSourceSnapshot(input) + if err != nil { + t.Fatal(err) + } + + input.Workspace = "workspace-b" + input.Subject.Name = "mutated-subject" + input.Sources[0].NativeID = "github.com/other/repository" + input.Repository.Repository = "other" + input.BaseRef = "main" + input.BaseCommit = strings.Repeat("c", 40) + input.FilePath = "other/file.yaml" + input.ObservedBlobSHA = strings.Repeat("d", 40) + input.CurrentContent = "mutated" + input.EvidenceRefs[0].Name = "mutated-evidence" + + if err := snapshot.validate(); err != nil { + t.Fatalf("snapshot retained caller mutation: %v", err) + } + if snapshot.workspace != testWorkspace || snapshot.subject.Name != "payments" || + snapshot.source.NativeID != "github.com/ArdurAI/sith" || snapshot.repository.Repository != "sith" || + snapshot.baseRef != "dev" || snapshot.baseCommit != testBaseSHA || snapshot.filePath != "deploy/payments.yaml" || + snapshot.observedBlobSHA != testSnapshotBlobSHA || snapshot.currentContent == "mutated" || + snapshot.evidenceRefs[1].Name == "mutated-evidence" { + t.Fatalf("snapshot retained caller-owned state: %#v", snapshot) + } +} + +func TestGitSourceSnapshotAcceptsSHA256ObjectIdentity(t *testing.T) { + t.Parallel() + input := validGitSourceSnapshotInput() + input.BaseCommit = strings.Repeat("a", 64) + input.ObservedBlobSHA = testSnapshotSHA256Blob + input.EvidenceRefs[1].Name = testSnapshotSHA256Blob + snapshot, err := NewGitSourceSnapshot(input) + if err != nil { + t.Fatalf("NewGitSourceSnapshot() error = %v", err) + } + if snapshot.baseCommit != input.BaseCommit || snapshot.observedBlobSHA != testSnapshotSHA256Blob { + t.Fatalf("SHA-256 identities were not preserved: %#v", snapshot) + } +} + +func TestGitSourceSnapshotAcceptsExactEmptyFile(t *testing.T) { + t.Parallel() + input := validGitSourceSnapshotInput() + input.CurrentContent = "" + input.ObservedBlobSHA = emptyGitBlobSHA + input.EvidenceRefs[1].Name = emptyGitBlobSHA + snapshot, err := NewGitSourceSnapshot(input) + if err != nil { + t.Fatalf("NewGitSourceSnapshot() error = %v", err) + } + if snapshot.currentContent != "" || snapshot.observedBlobSHA != emptyGitBlobSHA { + t.Fatalf("empty file observation was not preserved: %#v", snapshot) + } +} + +func TestNewGitSourceSnapshotRejectsInvalidClaims(t *testing.T) { + tests := []struct { + name string + mutate func(*GitSourceSnapshotInput) + }{ + {"no source", func(input *GitSourceSnapshotInput) { input.Sources = nil }}, + {"multiple sources", func(input *GitSourceSnapshotInput) { input.Sources = append(input.Sources, input.Sources[0]) }}, + {"invalid workspace", func(input *GitSourceSnapshotInput) { input.Workspace = " workspace-a" }}, + {"subject attributes", func(input *GitSourceSnapshotInput) { input.Subject.Attributes = map[string]string{"uid": "private"} }}, + {"source kind", func(input *GitSourceSnapshotInput) { input.Sources[0].Kind = "gitlab" }}, + {"source adapter", func(input *GitSourceSnapshotInput) { input.Sources[0].AdapterVersion = "future/v2" }}, + {"source repository mismatch", func(input *GitSourceSnapshotInput) { input.Sources[0].NativeID = "github.com/ArdurAI/other" }}, + {"repository URL", func(input *GitSourceSnapshotInput) { input.Repository.Host = "https://github.com" }}, + {"repository suffix", func(input *GitSourceSnapshotInput) { input.Repository.Repository = "sith.git" }}, + {"zero observation", func(input *GitSourceSnapshotInput) { input.ObservedAt = time.Time{} }}, + {"zero validity", func(input *GitSourceSnapshotInput) { input.ValidUntil = time.Time{} }}, + {"reversed validity", func(input *GitSourceSnapshotInput) { input.ValidUntil = input.ObservedAt }}, + {"unbounded validity", func(input *GitSourceSnapshotInput) { + input.ValidUntil = input.ObservedAt.Add(maxGitSourceSnapshotValidity + time.Nanosecond) + }}, + {"symbolic base", func(input *GitSourceSnapshotInput) { input.BaseRef = "HEAD" }}, + {"full base ref", func(input *GitSourceSnapshotInput) { input.BaseRef = "refs/heads/dev" }}, + {"option-shaped base", func(input *GitSourceSnapshotInput) { input.BaseRef = "--upload-pack=evil" }}, + {"commit-shaped base", func(input *GitSourceSnapshotInput) { input.BaseRef = testBaseSHA }}, + {"reflog base", func(input *GitSourceSnapshotInput) { input.BaseRef = "dev@{1}" }}, + {"single at base", func(input *GitSourceSnapshotInput) { input.BaseRef = "@" }}, + {"ambiguous base", func(input *GitSourceSnapshotInput) { input.BaseRef = "release..next" }}, + {"locked base", func(input *GitSourceSnapshotInput) { input.BaseRef = "dev.lock" }}, + {"invalid base commit", func(input *GitSourceSnapshotInput) { input.BaseCommit = strings.ToUpper(testBaseSHA) }}, + {"short base commit", func(input *GitSourceSnapshotInput) { input.BaseCommit = testBaseSHA[:12] }}, + {"mixed object algorithms", func(input *GitSourceSnapshotInput) { input.BaseCommit = strings.Repeat("a", 64) }}, + {"invalid blob", func(input *GitSourceSnapshotInput) { input.ObservedBlobSHA = "not-a-blob" }}, + {"blob content mismatch", func(input *GitSourceSnapshotInput) { input.ObservedBlobSHA = testBlobSHA }}, + {"unsafe relative path", func(input *GitSourceSnapshotInput) { input.FilePath = "../secret.yaml" }}, + {"absolute path", func(input *GitSourceSnapshotInput) { input.FilePath = "/deploy/payments.yaml" }}, + {"unclean path", func(input *GitSourceSnapshotInput) { input.FilePath = "deploy//payments.yaml" }}, + {"backslash path", func(input *GitSourceSnapshotInput) { input.FilePath = "deploy\\payments.yaml" }}, + {"Git metadata path", func(input *GitSourceSnapshotInput) { input.FilePath = ".git/config" }}, + {"invalid UTF-8 content", func(input *GitSourceSnapshotInput) { input.CurrentContent = string([]byte{0xff}) }}, + {"NUL content", func(input *GitSourceSnapshotInput) { input.CurrentContent = "secret\x00value" }}, + {"unbounded content", func(input *GitSourceSnapshotInput) { + input.CurrentContent = strings.Repeat("x", maxGitSourceSnapshotContentBytes+1) + }}, + {"no evidence", func(input *GitSourceSnapshotInput) { input.EvidenceRefs = nil }}, + {"subject-only evidence", func(input *GitSourceSnapshotInput) { input.EvidenceRefs = []fleet.ResourceRef{input.Subject} }}, + {"blob-only evidence", func(input *GitSourceSnapshotInput) { input.EvidenceRefs = []fleet.ResourceRef{testBlobRef()} }}, + {"foreign blob evidence", func(input *GitSourceSnapshotInput) { input.EvidenceRefs[1].Name = strings.Repeat("e", 40) }}, + {"duplicate evidence", func(input *GitSourceSnapshotInput) { + input.EvidenceRefs = append(input.EvidenceRefs, input.EvidenceRefs[0]) + }}, + {"unsafe evidence", func(input *GitSourceSnapshotInput) { input.EvidenceRefs[0].Name = "commit\nforged" }}, + {"too much evidence", func(input *GitSourceSnapshotInput) { + for index := len(input.EvidenceRefs); index <= maxGitSourceSnapshotEvidenceRefs; index++ { + input.EvidenceRefs = append(input.EvidenceRefs, fleet.ResourceRef{ + SourceKind: "github", Scope: "github.com", Kind: "Observation", + Namespace: "ArdurAI/sith", Name: fmt.Sprintf("evidence-%02d", index), + }) + } + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := validGitSourceSnapshotInput() + test.mutate(&input) + snapshot, err := NewGitSourceSnapshot(input) + if err == nil || snapshot.Version() != "" { + t.Fatalf("NewGitSourceSnapshot() = %#v, %v, want rejection", snapshot, err) + } + if strings.Contains(err.Error(), "secret") || len(err.Error()) > 160 { + t.Fatalf("constructor leaked or returned unbounded error: %q", err) + } + }) + } +} + +func TestGitSourceSnapshotFreshnessUsesTrustedTime(t *testing.T) { + t.Parallel() + snapshot, err := NewGitSourceSnapshot(validGitSourceSnapshotInput()) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + now time.Time + want GitSourceFreshness + }{ + {"future", snapshot.observedAt.Add(-time.Nanosecond), GitSourceFuture}, + {"observed boundary", snapshot.observedAt, GitSourceFresh}, + {"fresh", testNow, GitSourceFresh}, + {"expiry boundary", snapshot.validUntil, GitSourceStale}, + {"stale", snapshot.validUntil.Add(time.Nanosecond), GitSourceStale}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := snapshot.Freshness(test.now) + if err != nil || got != test.want { + t.Fatalf("Freshness(%s) = %q, %v, want %q", test.now, got, err, test.want) + } + }) + } + if got, err := snapshot.Freshness(time.Time{}); err == nil || got != "" { + t.Fatalf("Freshness(zero) = %q, %v, want closed error", got, err) + } + forged := snapshot + forged.version = "forged/v9" + if got, err := forged.Freshness(testNow); err == nil || got != "" { + t.Fatalf("Freshness(forged) = %q, %v, want closed error", got, err) + } +} + +func TestGitSourceSnapshotFreshnessIsConcurrentAndReadOnly(t *testing.T) { + t.Parallel() + snapshot, err := NewGitSourceSnapshot(validGitSourceSnapshotInput()) + if err != nil { + t.Fatal(err) + } + const readers = 64 + errors := make(chan error, readers) + var group sync.WaitGroup + for range readers { + group.Add(1) + go func() { + defer group.Done() + got, freshnessErr := snapshot.Freshness(testNow) + if freshnessErr != nil { + errors <- freshnessErr + return + } + if got != GitSourceFresh { + errors <- fmt.Errorf("freshness = %q", got) + } + }() + } + group.Wait() + close(errors) + for err := range errors { + t.Error(err) + } +} + +func FuzzGitSourceSnapshotConstructor(f *testing.F) { + for _, seed := range []struct{ baseRef, filePath, content string }{ + {"dev", "deploy/payments.yaml", "replicas: 3\n"}, + {"HEAD", "../secret", "\x00"}, + {"release/v1", "manifests/café.yaml", "name: café\r\n"}, + } { + f.Add(seed.baseRef, seed.filePath, seed.content) + } + f.Fuzz(func(t *testing.T, baseRef, filePath, content string) { + input := validGitSourceSnapshotInput() + input.BaseRef = baseRef + input.FilePath = filePath + input.CurrentContent = content + snapshot, err := NewGitSourceSnapshot(input) + if err != nil { + return + } + if snapshot.baseRef != baseRef || snapshot.filePath != filePath || snapshot.currentContent != content { + t.Fatalf("accepted snapshot changed exact source bytes: %#v", snapshot) + } + if got, freshnessErr := snapshot.Freshness(testNow); freshnessErr != nil || got != GitSourceFresh { + t.Fatalf("accepted snapshot Freshness() = %q, %v", got, freshnessErr) + } + }) +} + +func validGitSourceSnapshotInput() GitSourceSnapshotInput { + subject := testSubjectRef() + return GitSourceSnapshotInput{ + Workspace: testWorkspace, + Subject: subject, + Sources: []SourceIdentity{{ + Kind: gitHubSourceKind, AdapterVersion: GitSourceSnapshotAdapterVersion, + NativeID: "github.com/ArdurAI/sith", + }}, + ObservedAt: testNow.Add(-time.Minute), ValidUntil: testNow.Add(time.Minute), + Repository: RepositoryIdentity{Host: "github.com", Owner: "ArdurAI", Repository: "sith"}, + BaseRef: "dev", BaseCommit: testBaseSHA, FilePath: "deploy/payments.yaml", + ObservedBlobSHA: testSnapshotBlobSHA, + CurrentContent: "apiVersion: v1\r\nmetadata:\n name: café\t\n", + EvidenceRefs: []fleet.ResourceRef{subject, testBlobRef()}, + } +} + +func testBlobRef() fleet.ResourceRef { + return fleet.ResourceRef{ + SourceKind: gitHubSourceKind, Scope: "github.com", Kind: "Blob", + Namespace: "ArdurAI/sith", Name: testSnapshotBlobSHA, + } +} diff --git a/sessions/2026-07-22-e14-git-source-snapshot.md b/sessions/2026-07-22-e14-git-source-snapshot.md new file mode 100644 index 0000000..7480d25 --- /dev/null +++ b/sessions/2026-07-22-e14-git-source-snapshot.md @@ -0,0 +1,105 @@ +# Session — 2026-07-22 — E14 immutable Git source snapshot + +**Builder:** Gnani Rahul Nutakki · **Branch:** `gnanirahulnutakki/git-source-snapshot-20260722` +**Slice:** [#303](https://github.com/ArdurAI/sith/issues/303), E14 +[#46](https://github.com/ArdurAI/sith/issues/46) · **Status:** complete local proof; hosted proof pending + +## [G] Goal + +Implement the owner-approved first half of the F14.6 provenance split: one immutable +`GitSourceSnapshot` containing only canonical observed Git state. Defer `DesiredChange` to a later +separately reviewed transformer/renderer and keep R2/R4 advisory-only. + +## [S] Scope + +- Add a versioned source snapshot outside `internal/brain`, bound to one workspace, affected + resource, pinned GitHub source identity, repository, configured base ref, exact resolved commit, + one path, exact current bytes, matching blob identity, evidence set, and short validity interval. +- Keep validated fields private, copy mutable inputs, canonicalize evidence ordering, and expose + only the contract version plus a trusted-time freshness classification. +- Exclude desired bytes, PR metadata, handler binding, actor, intent, policy, approval, credential, + endpoint, persistence, dispatch, mutation, and execution behavior. + +## [A] Decision and implementation + +- The owner decision is recorded on E14 in + [issue comment 5051813734](https://github.com/ArdurAI/sith/issues/46#issuecomment-5051813734), + and child issue 303 locks this bounded acceptance contract as an E14 sub-issue. +- `git-source-snapshot/v1` accepts exactly one + `github-git-source-snapshot/2026-03-10` source whose native repository identity must match the + separately validated host/owner/repository tuple. +- The configured base ref rejects symbolic `HEAD`, full `refs/...`, object-ID-shaped, + option-shaped, reflog, lock, traversal-like, whitespace, and malformed Git ref names. Commit and + blob identities are canonical lowercase 40-hex SHA-1 or 64-hex SHA-256 values and must use the + same object format. +- The snapshot recomputes the exact Git blob identity over + `blob \0` and rejects a blob/content mismatch. SHA-1 is used only for + current GitHub Git-object interoperability; SHA-256 is supported under Git's transition format. +- Current content is an exact non-NUL UTF-8 byte sequence capped at 64 KiB. No Unicode, + line-ending, whitespace, or YAML normalization occurs; empty files and CRLF are preserved. +- The repository-relative path rejects absolute, unclean, traversal, backslash-ambiguous, and Git + metadata paths while retaining otherwise valid Unicode file names. +- Evidence is capped at 32 unique stable references, defensively copied, and canonically sorted. + It must attach both the affected resource and the exact repository blob. +- Construction normalizes observation times to UTC and permits at most five minutes. `Freshness` + uses only a supplied trusted time: before observation is future, at/after expiry is stale, and the + half-open interval between is fresh. Zero time or a forged invalid snapshot fails closed. +- Reflection tests lock the exact input, private snapshot, and two-method public shapes. A recursive + import guard prevents I/O, policy, persistence, authority, connector-runtime, or Brain imports. + +## [T] Proof + +- Focused remediation race tests pass; 50 repeated snapshot runs remain green and the package + reaches 94.0% statement coverage in the full race suite. +- Snapshot fuzzing passes 50,000 executions. Adversarial tests cover zero/multiple/mismatched + sources, invalid workspace/subject/repository/evidence, symbolic and malformed refs, malformed or + mixed-format object IDs, blob/content mismatch, unsafe paths, invalid/oversized content, invalid + validity windows, future/stale/zero clocks, input alias mutation, deterministic ordering, and 64 + concurrent readers. +- `make ci` passes formatting, vet, zero-issue lint, current vulnerability scanning, every race + test, shell/tooling policy, nine Prometheus rules, performance, binary end-to-end, and production + build gates. +- `make e2e-isolation` passes PostgreSQL 18.4 forced RLS and two 50,000-execution cross-workspace + fuzzers. +- One intervening repeat exposed an existing host-clock/database-clock exact-boundary race in the + approval-expiry test fixture (`expired approval consume error = `). The production predicate + remained database-clock authoritative, a clean repeat passed, and follow-up issue + [#304](https://github.com/ArdurAI/sith/issues/304) tracks the test-only repair outside this PR. +- `make release-check` passes module verification, two reproducible four-platform builds, SPDX + SBOMs, formula generation, and the 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 242.424 seconds. Teardown leaves no Kind + cluster or isolated release builder. +- Final isolated-GOPATH module verification and `govulncheck` v1.6.0 report no failures or reachable + vulnerabilities. + +## [S] Security, reliability, and cost + +The snapshot is pure and offline. It stores neither secrets nor authority and adds no API request, +egress, storage, cloud resource, telemetry cardinality, or recurring cost. A later live adapter +must separately own least-privilege contents-read credentials, GitHub rate limits, egress, +content-size policy, and remote-state freshness. A later `DesiredChange` contract must bind this +snapshot version and evidence without gaining implicit authorization. + +## [R] Primary references + +- [GitHub REST Git references](https://docs.github.com/en/rest/git/refs?apiVersion=2026-03-10) +- [GitHub REST Git commits](https://docs.github.com/en/rest/git/commits?apiVersion=2026-03-10) +- [GitHub REST Git blobs](https://docs.github.com/en/rest/git/blobs?apiVersion=2026-03-10) +- [Git hash transition](https://git-scm.com/docs/hash-function-transition) + +## [N] Next + +Create one signed DCO/GSTACK commit, open a PR to `dev`, and require exact-head hosted CI, CodeQL, +and review plus exact post-merge `dev` proof. Close only child issue 303. F14.6 and E14 remain open; +`DesiredChange`, live Git reads, Hub composition, PEP, and runtime execution are separate slices. +After this snapshot lands, repair the independently tracked approval-expiry test flake in #304. + +## [C] Checkpoint #1 + +The snapshot contract, hash binding, adversarial tests, import/public-shape guards, README/spec +documentation, local review, and complete local gate matrix are frozen on exact base +`430eea3faff4c889c8435b155b042e2104b1aeda`. README was reviewed and updated before commit because +the public architecture now includes the observed-only provenance stage. Remaining gates are the +signed commit, exact-head hosted proof, merge without rewriting the signed feature commit, and exact +post-merge `dev` proof.