diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c301442..96033f6 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Notarius Credential Forwarding Implementation Plan -Status: Not started +Status: In progress — 2026-09-16 Implement the [feature roadmap](notarius-credential-forwarding.md) in the order below. Each stage is a bounded assignment for a gpt-5.6-terra coding agent. @@ -50,7 +50,21 @@ ConfigKit upgrade plan is preserved in Git history. ## Stage 1 — Add typed configuration and safe projections -Status: Not started +Status: Complete — 2026-09-16 + +Completion: Added the optional typed Notarius credential reference and a safe +direct-YAML projection. Existing ConfigKit loading, effective projections, +schema-derived provenance, normalization copies, and effective digests carry +the field without exposing its resolved value. Coverage now protects omitted +and null values, custom and invalid references, disabled-section resolution, +profile replacement/removal, root/profile ownership, safe YAML/JSON output, and +reference-name versus value-rotation digest behavior. + +Checks: + +- `GOWORK=off go test ./internal/config` — pass. +- `GOWORK=off go test ./...` — pass; maintained examples load and validate. +- `git diff --check` — pass. Read: `internal/config/config.go`, `credential_projection.go`, `effective_projection.go`, `effective_pipeline.go`, `effective_digest.go`, diff --git a/internal/config/config.go b/internal/config/config.go index 31ddeae..6335c74 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -309,6 +309,7 @@ type ScriptoriumInputConfig struct { type NotariusConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` Binary string `yaml:"binary" json:"binary"` + LLMAPIKey *configkit.Secret `yaml:"llm_api_key,omitempty" json:"llm_api_key,omitempty"` ConfigPath string `yaml:"config_path" json:"config_path"` PipelineID string `yaml:"pipeline_id" json:"pipeline_id"` Timeout string `yaml:"timeout" json:"timeout"` diff --git a/internal/config/credential_projection.go b/internal/config/credential_projection.go index 85fad96..6e93277 100644 --- a/internal/config/credential_projection.go +++ b/internal/config/credential_projection.go @@ -50,6 +50,26 @@ func (cfg AuditaConfig) MarshalYAML() (any, error) { }, nil } +// MarshalYAML exposes only the public Notarius credential reference name. +func (cfg NotariusConfig) MarshalYAML() (any, error) { + type safeNotarius struct { + Enabled bool `yaml:"enabled"` + Binary string `yaml:"binary"` + LLMAPIKey *string `yaml:"llm_api_key,omitempty"` + ConfigPath string `yaml:"config_path"` + PipelineID string `yaml:"pipeline_id"` + Timeout string `yaml:"timeout"` + WorkingDirectory string `yaml:"working_directory"` + References map[string]string `yaml:"references"` + Outputs map[string]NotariusOutputConfig `yaml:"outputs"` + } + return safeNotarius{ + Enabled: cfg.Enabled, Binary: cfg.Binary, LLMAPIKey: secretReferenceName(cfg.LLMAPIKey), + ConfigPath: cfg.ConfigPath, PipelineID: cfg.PipelineID, Timeout: cfg.Timeout, + WorkingDirectory: cfg.WorkingDirectory, References: cfg.References, Outputs: cfg.Outputs, + }, nil +} + func secretReferenceName(secret *configkit.Secret) *string { if secret == nil { return nil diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index 4a8f249..82863c9 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -92,6 +92,7 @@ func TestCredentialValuesNeverEnterEffectiveProjectionOrDigest(t *testing.T) { t.Setenv("NARRATIO_TEST_ACCESS_KEY", firstCanary) t.Setenv("NARRATIO_TEST_SECRET_KEY", firstCanary) t.Setenv("NARRATIO_TEST_AUDITA_KEY", firstCanary) + t.Setenv("NARRATIO_TEST_NOTARIUS_KEY", firstCanary) pipelineYAML := strings.Replace(testPipelineBaseYAML, "audita:\n binary: audita\n", "audita:\n binary: audita\n llm_api_key: NARRATIO_TEST_AUDITA_KEY\n", 1) + ` storage: backend: s3 @@ -99,6 +100,9 @@ storage: bucket: test-bucket access_key_id: NARRATIO_TEST_ACCESS_KEY secret_access_key: NARRATIO_TEST_SECRET_KEY +notarius: + enabled: false + llm_api_key: NARRATIO_TEST_NOTARIUS_KEY ` pipelinePath, _ := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML) @@ -130,7 +134,7 @@ storage: if strings.Contains(output, firstCanary) { t.Fatalf("effective output exposed credential value: %q", output) } - for _, reference := range []string{"NARRATIO_TEST_ACCESS_KEY", "NARRATIO_TEST_SECRET_KEY", "NARRATIO_TEST_AUDITA_KEY"} { + for _, reference := range []string{"NARRATIO_TEST_ACCESS_KEY", "NARRATIO_TEST_SECRET_KEY", "NARRATIO_TEST_AUDITA_KEY", "NARRATIO_TEST_NOTARIUS_KEY"} { if !strings.Contains(output, reference) { t.Fatalf("effective output omitted safe reference %q: %q", reference, output) } @@ -140,6 +144,7 @@ storage: t.Setenv("NARRATIO_TEST_ACCESS_KEY", secondCanary) t.Setenv("NARRATIO_TEST_SECRET_KEY", secondCanary) t.Setenv("NARRATIO_TEST_AUDITA_KEY", secondCanary) + t.Setenv("NARRATIO_TEST_NOTARIUS_KEY", secondCanary) second, err := LoadPipeline(pipelinePath) if err != nil { t.Fatal(err) @@ -156,6 +161,15 @@ storage: if EffectivePipelineDigest(first) == EffectivePipelineDigest(alternate) { t.Fatal("credential reference-name change did not alter effective digest") } + t.Setenv("NARRATIO_TEST_NOTARIUS_KEY_ALTERNATE", secondCanary) + alternateNotariusPath, _ := writeConfigFiles(t, strings.Replace(pipelineYAML, "NARRATIO_TEST_NOTARIUS_KEY", "NARRATIO_TEST_NOTARIUS_KEY_ALTERNATE", 1), testSessionBaseYAML) + alternateNotarius, err := LoadPipeline(alternateNotariusPath) + if err != nil { + t.Fatal(err) + } + if EffectivePipelineDigest(first) == EffectivePipelineDigest(alternateNotarius) { + t.Fatal("Notarius credential reference-name change did not alter effective digest") + } } func TestEffectivePipelineValuesPreserveNilEmptyAndExactIntegers(t *testing.T) { diff --git a/internal/config/notarius_test.go b/internal/config/notarius_test.go index ec04a8a..42a5a58 100644 --- a/internal/config/notarius_test.go +++ b/internal/config/notarius_test.go @@ -50,6 +50,72 @@ notarius: } } +func TestNotariusCredentialReferenceLoading(t *testing.T) { + const envName = "NARRATIO_TEST_NOTARIUS_KEY" + const canary = "notarius-credential-canary" + t.Setenv(envName, canary) + + tests := []struct { + name string + credential string + wantNil bool + }{ + {name: "omitted", wantNil: true}, + {name: "null", credential: " llm_api_key: null\n", wantNil: true}, + {name: "custom reference", credential: " llm_api_key: " + envName + "\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "pipeline.yml") + pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n" + test.credential + if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := LoadPipeline(path) + if err != nil { + t.Fatal(err) + } + if test.wantNil { + if cfg.Notarius.LLMAPIKey != nil { + t.Fatal("credential reference should be nil") + } + return + } + if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != envName || + !cfg.Notarius.LLMAPIKey.IsResolved() || cfg.Notarius.LLMAPIKey.Value() != canary { + t.Fatal("credential reference was not loaded and resolved") + } + }) + } +} + +func TestNotariusCredentialReferenceFailures(t *testing.T) { + const missing = "NARRATIO_TEST_NOTARIUS_MISSING" + if err := os.Unsetenv(missing); err != nil { + t.Fatal(err) + } + tests := []struct { + name, reference, want string + }{ + {name: "missing", reference: missing, want: "secret.unset"}, + {name: "empty", reference: `""`, want: "secret.invalid_name"}, + {name: "invalid", reference: "123-NOT-AN-ENV-NAME", want: "secret.invalid_name"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "pipeline.yml") + pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n llm_api_key: " + test.reference + "\n" + if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil { + t.Fatal(err) + } + _, err := LoadPipeline(path) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("LoadPipeline() error = %v, want classification %q", err, test.want) + } + }) + } +} + func TestNotariusEnabledDefaultsAndPathResolution(t *testing.T) { dir := t.TempDir() pipelinePath := filepath.Join(dir, "deployment", "pipeline.yml") diff --git a/internal/config/pipeline_configkit_test.go b/internal/config/pipeline_configkit_test.go index d829784..ef97a24 100644 --- a/internal/config/pipeline_configkit_test.go +++ b/internal/config/pipeline_configkit_test.go @@ -117,6 +117,71 @@ func TestPipelineProfileDoesNotRequireUnusedBaseCredentials(t *testing.T) { } } +func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) { + const baseKey = "NARRATIO_NOTARIUS_UNUSED_BASE_KEY" + const profileKey = "NARRATIO_NOTARIUS_PROFILE_KEY" + if err := os.Unsetenv(baseKey); err != nil { + t.Fatal(err) + } + t.Setenv(profileKey, "synthetic-notarius-profile-key") + + for _, credential := range []string{profileKey, "null"} { + t.Run(credential, func(t *testing.T) { + dir := t.TempDir() + root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+baseKey+"\n") + overlay := writePipelineFixture(t, dir, "profiles/testing.yml", "notarius:\n llm_api_key: "+credential+"\n") + profile := "testing" + cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile}) + if err != nil { + t.Fatal(err) + } + if credential == "null" { + if cfg.Notarius.LLMAPIKey != nil { + t.Fatal("profile did not remove the base credential") + } + return + } + if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != profileKey || !cfg.Notarius.LLMAPIKey.IsResolved() { + t.Fatal("profile credential was not resolved") + } + sources, err := EffectivePipelineSources(cfg, ResolvedParty{}) + if err != nil { + t.Fatal(err) + } + found := false + for _, source := range sources { + if source.Path == "notarius.llm_api_key" { + found = source.Source == overlay + } + } + if !found { + t.Fatal("Notarius credential provenance did not identify the selected profile") + } + }) + } +} + +func TestNotariusCredentialRootProvenance(t *testing.T) { + const envName = "NARRATIO_NOTARIUS_ROOT_KEY" + t.Setenv(envName, "synthetic-notarius-root-key") + dir := t.TempDir() + root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n") + cfg, err := LoadPipeline(root) + if err != nil { + t.Fatal(err) + } + sources, err := EffectivePipelineSources(cfg, ResolvedParty{}) + if err != nil { + t.Fatal(err) + } + for _, source := range sources { + if source.Path == "notarius.llm_api_key" && source.Source == root { + return + } + } + t.Fatal("Notarius credential provenance did not identify the root pipeline") +} + func TestPipelineConfigKitAllowsOneBaseDeclarationWithProfileOverride(t *testing.T) { dir := t.TempDir() writePipelineFixture(t, dir, "pipeline.yml", `configkit: