diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index fc907a70..81695bf1 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -325,6 +325,8 @@ No unchecked narrowing or YAML round-trip remains at these consumers. ## Stage 6 — Prepare Debug Rendering For The ConfigKit Result +**Status:** Completed 2026-09-13. + **Goal:** Prepare the publication boundary before changing command loading. **Work:** diff --git a/internal/core/config/load.go b/internal/core/config/load.go index 4189bc7b..cfcbb065 100644 --- a/internal/core/config/load.go +++ b/internal/core/config/load.go @@ -39,6 +39,7 @@ func documentDefinition(state *documentLoadState) configkit.Definition[Document] Path: configkit.Keys("version"), Check: validateDocumentVersion, }, + Secret: sensitiveDocumentPath, } } diff --git a/internal/core/config/loaded_config_test.go b/internal/core/config/loaded_config_test.go index 96458fc7..9a35934b 100644 --- a/internal/core/config/loaded_config_test.go +++ b/internal/core/config/loaded_config_test.go @@ -127,6 +127,52 @@ pipelines: } } +func TestLoadedConfigRedactsSensitiveNestedOptions(t *testing.T) { + t.Parallel() + + loaded, err := loadConfigSource(configkit.Bytes("document.yml", []byte(`version: 5 +pipelines: + session: + input: + module: input + options: + nested: + api_key: input-secret + artifacts: + score: + extract: + module: extract + validators: + - module: configured-a + options: + credentials: + token: validator-secret +`)), LoadOptions{}) + if err != nil { + t.Fatalf("loadConfigSource() error = %v", err) + } + var effective bytes.Buffer + if err := loaded.WriteEffective(&effective); err != nil { + t.Fatalf("WriteEffective() error = %v", err) + } + for _, secret := range []string{"input-secret", "validator-secret"} { + if strings.Contains(effective.String(), secret) { + t.Fatalf("effective configuration exposes %q: %q", secret, effective.String()) + } + } + if !strings.Contains(effective.String(), "[REDACTED]") { + t.Fatalf("effective configuration did not mark sensitive values: %q", effective.String()) + } + + var explanation bytes.Buffer + if err := loaded.WriteExplanation(&explanation, "/pipelines/session/artifacts/score/extract/validators/0/options/credentials/token"); err != nil { + t.Fatalf("WriteExplanation() error = %v", err) + } + if strings.Contains(explanation.String(), "validator-secret") || !strings.Contains(explanation.String(), "[REDACTED]") { + t.Fatalf("explanation exposes sensitive value: %q", explanation.String()) + } +} + func pipelineProfileWithChangedOption(profile pipeline.PipelineProfile) pipeline.PipelineProfile { profile = clonePipelineProfile(profile) nested := profile.Artifacts["score"].Extract.Options["nested"].(map[string]any) diff --git a/internal/core/config/redaction.go b/internal/core/config/redaction.go index c35b7c9f..ddbaafee 100644 --- a/internal/core/config/redaction.go +++ b/internal/core/config/redaction.go @@ -4,6 +4,7 @@ import ( "reflect" "strings" + configkit "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) @@ -188,3 +189,21 @@ func sensitiveConfigKey(key string) bool { key = strings.ToLower(key) return strings.Contains(key, "api_key") || strings.Contains(key, "apikey") || strings.Contains(key, "authorization") || strings.Contains(key, "bearer") || strings.Contains(key, "password") || strings.Contains(key, "secret") || strings.Contains(key, "token") } + +// sensitiveDocumentPath applies the existing option-key classification to the +// fully typed ConfigKit document. Dynamic option maps can be nested below any +// producer or validator, so every key after an options container participates. +func sensitiveDocumentPath(path configkit.Path) bool { + segments := strings.Split(strings.TrimPrefix(path.String(), "/"), "/") + withinOptions := false + for _, segment := range segments { + segment = strings.ReplaceAll(strings.ReplaceAll(segment, "~1", "/"), "~0", "~") + if withinOptions && sensitiveConfigKey(segment) { + return true + } + if segment == "options" { + withinOptions = true + } + } + return false +} diff --git a/internal/core/debugbundle/bundle_test.go b/internal/core/debugbundle/bundle_test.go index c3d8b7a2..cece357b 100644 --- a/internal/core/debugbundle/bundle_test.go +++ b/internal/core/debugbundle/bundle_test.go @@ -3,6 +3,8 @@ package debugbundle import ( "bytes" "encoding/json" + "errors" + "io" "os" "path/filepath" "strings" @@ -92,6 +94,9 @@ func TestSummaryWriterWritesEverySummaryArtifact(t *testing.T) { if err := summary.WriteRedactedEffectiveConfig(testRedactedSummaryPayload{}); err != nil { t.Fatal(err) } + if err := summary.WriteEffectiveConfig(testEffectiveConfigWriter{content: "version: 5\n"}); err != nil { + t.Fatal(err) + } if err := summary.WriteResolvedPipeline(testRedactedResolvedPipelinePayload{}); err != nil { t.Fatal(err) } @@ -120,6 +125,7 @@ func TestSummaryWriterWritesEverySummaryArtifact(t *testing.T) { for _, name := range []string{ ArtifactInvocationMetadata, ArtifactEffectiveConfig, + ArtifactEffectiveConfigYAML, ArtifactResolvedPipeline, ArtifactResolvedReferences, ArtifactCheckpointEvents, @@ -139,6 +145,20 @@ func TestSummaryWriterWritesEverySummaryArtifact(t *testing.T) { } } +func TestWriteEffectiveConfigDoesNotPublishPartialRendering(t *testing.T) { + bundle, err := Allocate(t.TempDir(), testBundleRunID, time.Unix(0, 42)) + if err != nil { + t.Fatal(err) + } + if err := bundle.Summary().WriteEffectiveConfig(testEffectiveConfigWriter{err: errors.New("render failed")}); err == nil { + t.Fatal("WriteEffectiveConfig() error = nil, want render failure") + } + path := filepath.Join(bundle.SummaryRoot(), ArtifactEffectiveConfigYAML) + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("effective configuration artifact exists after rendering failure: %v", err) + } +} + func TestWriteInvocationPreservesReasoningEffortOverrideStates(t *testing.T) { replacement := "focused" cleared := "" @@ -222,6 +242,19 @@ func (testRedactedSummaryPayload) RedactedSummaryPayload() any { return map[string]any{"redacted": true} } +type testEffectiveConfigWriter struct { + content string + err error +} + +func (writer testEffectiveConfigWriter) WriteEffective(output io.Writer) error { + if writer.err != nil { + return writer.err + } + _, err := io.WriteString(output, writer.content) + return err +} + type testRedactedResolvedPipelinePayload struct{} func (testRedactedResolvedPipelinePayload) RedactedResolvedPipelinePayload() pipeline.ResolvedPipeline { diff --git a/internal/core/debugbundle/summary.go b/internal/core/debugbundle/summary.go index 9c31363f..8afb75ed 100644 --- a/internal/core/debugbundle/summary.go +++ b/internal/core/debugbundle/summary.go @@ -1,7 +1,9 @@ package debugbundle import ( + "bytes" "fmt" + "io" "strings" "time" @@ -12,19 +14,21 @@ import ( ) const ( - ArtifactInvocationMetadata = "invocation.json" - ArtifactEffectiveConfig = "effective-config.json" - ArtifactResolvedPipeline = "resolved-pipeline.json" - ArtifactResolvedReferences = "resolved-references.json" - ArtifactCheckpointEvents = "checkpoint-events.json" - ArtifactRunManifest = "run-manifest.json" - ArtifactChunkPlan = "chunk-plan.json" - ArtifactRunReport = "run-report.json" - ArtifactDiagnostics = "final-diagnostics.json" - ArtifactErrorLog = "error.log" + ArtifactInvocationMetadata = "invocation.json" + ArtifactEffectiveConfig = "effective-config.json" + ArtifactEffectiveConfigYAML = "effective-config.yml" + ArtifactResolvedPipeline = "resolved-pipeline.json" + ArtifactResolvedReferences = "resolved-references.json" + ArtifactCheckpointEvents = "checkpoint-events.json" + ArtifactRunManifest = "run-manifest.json" + ArtifactChunkPlan = "chunk-plan.json" + ArtifactRunReport = "run-report.json" + ArtifactDiagnostics = "final-diagnostics.json" + ArtifactErrorLog = "error.log" ) type RedactedSummaryPayload interface{ RedactedSummaryPayload() any } +type EffectiveConfigWriter interface{ WriteEffective(io.Writer) error } type RedactedResolvedPipelinePayload interface { RedactedResolvedPipelinePayload() pipeline.ResolvedPipeline } @@ -86,6 +90,16 @@ func (w *SummaryWriter) WriteRedactedEffectiveConfig(payload RedactedSummaryPayl } return w.writeJSON(ArtifactEffectiveConfig, payload.RedactedSummaryPayload()) } +func (w *SummaryWriter) WriteEffectiveConfig(payload EffectiveConfigWriter) error { + if payload == nil { + return fmt.Errorf("effective configuration writer must not be nil") + } + var rendered bytes.Buffer + if err := payload.WriteEffective(&rendered); err != nil { + return fmt.Errorf("render effective configuration: %w", err) + } + return w.writeBytes(ArtifactEffectiveConfigYAML, rendered.Bytes()) +} func (w *SummaryWriter) WriteResolvedPipeline(payload RedactedResolvedPipelinePayload) error { if payload == nil { return fmt.Errorf("redacted resolved pipeline payload must not be nil")