diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 392735d..d6ad7ce 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement shared source version checks -Status: Stages 1–9 complete; historical and dependency sensitivity remains. +Status: Stages 1–10 complete; public source evidence remains private. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. The [feature roadmap](source-version-checks.md) owns target behavior and @@ -517,6 +517,25 @@ Validate: source-version/history/inheritance/resolution/sensitivity tests, `go test -race ./internal/engine`, and everyday check. Done: established sensitivity closure applies without polluting payload history. +Completion evidence: added original mounted evidence paths and ancestors to the +existing classification path set before schema/predicate evaluation and the +shared dependency closure. This lets existing recorded dependencies and +inheritance links carry evidence sensitivity into dependent and displaced +historical values, including evidence-only paths absent from final metadata; +Resolution.Record validation and dependency policy are unchanged. Added a +load-level regression using a valid recorded dependency on the credentials +container (not a synthetic dependency on the header), plus inherited displaced +history coverage. Neighboring values remain visible, and consumed headers create +no metadata, explanation, or Diff paths. These checks passed with Go +`go1.26.8 linux/amd64`: + +- `GOTOOLCHAIN=local GOPROXY=off go test ./internal/engine -run 'SourceVersion|Secret|Sensitivity|History|Inheritance|Resolution' -count=1` +- `GOTOOLCHAIN=local GOPROXY=off go test -race ./internal/engine` +- `GOTOOLCHAIN=local ./scripts/check.sh` +- `git diff --check` + +No environmental limitation affected validation; checks ran on Linux/amd64. + ## Stage 11 — Publish detached sanitized source evidence 1. Add private/public SourceVersionCheck and SourceInfo.VersionCheck records. diff --git a/internal/engine/inheritance_secret_test.go b/internal/engine/inheritance_secret_test.go index ac70be1..31492df 100644 --- a/internal/engine/inheritance_secret_test.go +++ b/internal/engine/inheritance_secret_test.go @@ -92,6 +92,40 @@ func TestInheritanceSensitivityIncludesSchemaTaggedSource(t *testing.T) { } } +func TestSourceVersionEvidenceSensitivityFollowsInheritanceAndDisplacedHistory(t *testing.T) { + const canary = "EVIDENCE-INHERITANCE-CANARY" + input := parseCompositionDocument(t, "source-1", "parent: {branch: {token: "+canary+", plain: visible}}\nchild: {extends: parent, branch: public}\n") + inspection, _ := inheritanceInspectionForTest(t, input) + secretPath := Keys("parent", "branch", "token") + evidence := &sourceVersionEvidence{ + path: secretPath.clone(), resolvedPath: secretPath.clone(), + version: Version{Present: true, Text: canary}, + ancestors: []sourceVersionAncestor{{}, {path: Keys("parent")}, {path: Keys("parent", "branch")}, {path: secretPath.clone()}}, + } + inspection.secrets = classifySecretPathsWithEvidence(nil, func(path Path) bool { + return path.String() == secretPath.String() + }, inspection.metadata, inspection.final, inspection.applications, []*sourceVersionEvidence{evidence}, inspection.input) + + path := Keys("child", "branch", "token") + explanation, found := inspection.Explain(path) + if !found || explanation.Effective.Present { + t.Fatalf("displaced inherited path = %#v, %v", explanation, found) + } + for _, change := range explanation.History { + if strings.Contains(change.Before.YAML, canary) || strings.Contains(change.After.YAML, canary) || + (change.After.Present && !change.After.Redacted) { + t.Fatalf("displaced inherited canary escaped redaction: %#v", explanation.History) + } + } + plain, found := inspection.Explain(Keys("parent", "branch", "plain")) + if !found || plain.Effective.Redacted || plain.Effective.YAML != "visible\n" { + t.Fatalf("neighboring value was classified with evidence: %#v, %v", plain, found) + } + if _, found := inspection.Metadata().Lookup(Keys("evidence-only")); found { + t.Fatal("evidence classification created a metadata path") + } +} + func TestInheritedSecretReferencesResolveOnlyAfterExpansion(t *testing.T) { type entry struct { Credential Secret `yaml:"credential"` diff --git a/internal/engine/secret.go b/internal/engine/secret.go index 916c904..b2db37f 100644 --- a/internal/engine/secret.go +++ b/internal/engine/secret.go @@ -75,19 +75,6 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa classifyProjection(tree) } classifyProjection(final) - evidenceSecrets := make(map[string]bool) - seenEvidence := make(map[string][]Path) - classifyEvidence := func(destination, local Path) { - path := appendDestination(destination, local) - pointer := path.String() - for _, seen := range seenEvidence[pointer] { - if sameSecretPath(seen, path) { - return - } - } - seenEvidence[pointer] = append(seenEvidence[pointer], path.clone()) - classifyEvidencePath(evidenceSecrets, schema, predicate, path) - } for _, item := range evidence { if item == nil { continue @@ -96,9 +83,9 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa if !candidate.valid() { candidate = item.path } - classifyEvidence(item.destination, candidate) + addSecretEvidencePath(paths, item.destination, candidate) for _, ancestor := range item.ancestors { - classifyEvidence(item.destination, ancestor.path) + addSecretEvidencePath(paths, item.destination, ancestor.path) } } for pointer, path := range paths { @@ -109,36 +96,12 @@ func classifySecretPathsWithEvidence(schema *schemaDescriptor, predicate func(Pa secret[pointer] = true } } - classified := closeSecretDependencies(paths, secret, dependencies, inherited) - for pointer, sensitive := range evidenceSecrets { - if sensitive { - classified[pointer] = true - } - } - return classified + return closeSecretDependencies(paths, secret, dependencies, inherited) } -func classifyEvidencePath(secret map[string]bool, schema *schemaDescriptor, predicate func(Path) bool, path Path) { - pointer := path.String() - if root, ok := schemaSecretRoot(schema, path); ok { - secret[root.String()] = true - } - if predicate != nil && predicate(path.clone()) { - secret[pointer] = true - } -} - -func sameSecretPath(left, right Path) bool { - if len(left.segments) != len(right.segments) { - return false - } - for index, segment := range left.segments { - other := right.segments[index] - if segment.kind != other.kind || segment.key != other.key || segment.index != other.index { - return false - } - } - return true +func addSecretEvidencePath(paths map[string]Path, destination, local Path) { + path := appendDestination(destination, local) + paths[path.String()] = path.clone() } func addSecretPath(paths map[string]Path, pointer string) { diff --git a/internal/engine/source_version_test.go b/internal/engine/source_version_test.go index 3208dbd..7bfb733 100644 --- a/internal/engine/source_version_test.go +++ b/internal/engine/source_version_test.go @@ -974,6 +974,74 @@ func TestSourceVersionUnderlaysProtectRemainingMountedPayload(t *testing.T) { } } +func TestSourceVersionEvidencePropagatesThroughRecordedResolutionDependency(t *testing.T) { + t.Parallel() + + type credentials struct { + Password string `yaml:"password"` + Public string `yaml:"public"` + } + type configuration struct { + Credentials credentials `yaml:"credentials"` + Derived string `yaml:"derived"` + } + load := func(header string) *Loaded[configuration] { + t.Helper() + loaded, err := LoadLayered(Input{ + Source: Bytes("main", []byte("credentials: {password: main-value}\n")), + }, testSecretBinding(), LayeredOptions[configuration]{ + SourceVersionMode: sourceVersionModeIfPresent, + Underlays: []Underlay{{Inputs: []Input{{Source: Bytes("underlay", []byte( + "credentials:\n version: "+header+"\n public: visible-neighbor\n", + ))}}}}, + Definition: Definition[configuration]{ + Version: &VersionRule{ + Path: Keys("credentials", "version"), + Check: func(Version, *Validation) error { return nil }, + }, + Secret: func(path Path) bool { + return path.String() == "/credentials/version" + }, + Resolve: func(value *configuration, resolution *Resolution) error { + value.Derived = value.Credentials.Password + resolution.Record(Keys("derived"), ChangeNormalization, "derive from credentials", Keys("credentials")) + return nil + }, + }, + }) + if err != nil || loaded == nil { + t.Fatalf("LoadLayered with header %q = %#v, %v", header, loaded, err) + } + return loaded + } + + left, right := load("202"), load("303") + if left.Config.Derived != "main-value" || right.Config.Derived != "main-value" { + t.Fatalf("derived values = %q, %q", left.Config.Derived, right.Config.Derived) + } + if !left.Inspection.secrets["/derived"] || !right.Inspection.secrets["/derived"] { + t.Fatalf("evidence did not activate recorded dependency closure: left=%#v right=%#v", left.Inspection.secrets, right.Inspection.secrets) + } + derived, found := left.Inspection.Explain(Keys("derived")) + if !found || !derived.Effective.Redacted { + t.Fatalf("derived explanation was not redacted: %#v, found=%v", derived, found) + } + public, found := left.Inspection.Explain(Keys("credentials", "public")) + if !found || public.Effective.Redacted || public.Effective.YAML != "visible-neighbor\n" { + t.Fatalf("neighboring payload = %#v, found=%v", public, found) + } + headerPath := Keys("credentials", "version") + if _, found := left.Inspection.Explain(headerPath); found { + t.Fatal("consumed declaration created an effective or historical explanation path") + } + if _, found := left.Inspection.Metadata().Lookup(headerPath); found { + t.Fatal("consumed declaration created a metadata path") + } + if differences, err := Diff(left.Inspection, right.Inspection); err != nil || len(differences) != 0 { + t.Fatalf("different consumed headers changed effective diff: %#v, %v", differences, err) + } +} + func TestSourceVersionSelectedOverlayConsumesHeaderAndRetainsWarning(t *testing.T) { t.Parallel()