diff --git a/cmd/audita/main_integration_test.go b/cmd/audita/main_integration_test.go index 1566747..b60c31a 100644 --- a/cmd/audita/main_integration_test.go +++ b/cmd/audita/main_integration_test.go @@ -1399,4 +1399,47 @@ func TestSubprocessProfileSources(t *testing.T) { if code != 2 || !bytes.Equal(before, after) || f.provider.callCount() != 0 { t.Fatalf("loaded profile not protected: %d %s", code, stderr) } + + parentDir := filepath.Join(f.dir, "profiles", "catalog") + childDir := filepath.Join(f.dir, "profiles", "nested", "child") + for _, directory := range []string{parentDir, childDir} { + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + } + parent := filepath.Join(parentDir, "arbitrary-name.yaml") + child := filepath.Join(childDir, "override.yaml") + resource := filepath.Join(parentDir, "report.md") + for path, content := range map[string]string{ + parent: "prompts:\n source:\n release/1~draft:\n messages:\n - role: user\n content_file: ./report.md\n output:\n format: text\n validation_mode: basic\n", + child: "prompts:\n literal/~child:\n extends: source\n", + resource: "Captured operator message.\n", + } { + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + resourceBefore, err := os.ReadFile(resource) + if err != nil { + t.Fatal(err) + } + f.output = resource + code, _, stderr = f.run(t, true, "--reporting=false") + resourceAfter, err := os.ReadFile(resource) + if err != nil { + t.Fatal(err) + } + if code != 2 || !bytes.Equal(resourceBefore, resourceAfter) || f.provider.callCount() != 0 { + t.Fatalf("consumed message file not protected as transcript output: %d %s", code, stderr) + } + + f.output = filepath.Join(f.dir, "output.json") + code, _, stderr = f.run(t, true, "--report-dir", parentDir) + resourceAfter, err = os.ReadFile(resource) + if err != nil { + t.Fatal(err) + } + if code != 2 || !bytes.Equal(resourceBefore, resourceAfter) || f.provider.callCount() != 0 { + t.Fatalf("consumed message file not protected as report output: %d %s", code, stderr) + } } diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 228fa6d..cabc62d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,7 +1,7 @@ # Implementation Plan: PromptKit v0.12.0 Adoption -**Last updated:** 2026-10-03 -**Status:** In progress; Stages 1–4 completed. +**Last updated:** 2026-10-04 +**Status:** In progress; Stages 1–5 completed. **Audience:** Luna coding agent. ## Scope and Execution @@ -309,6 +309,14 @@ collision cases, plus `git diff --check`, then commit. Check that resource accounting uses the frozen snapshot and never changes the engine's source or resource-resolution behavior to facilitate tracking. +**Completion marker:** Stage 5 complete. `Runtime.SourcePaths` now projects +host YAML and effective source-relative message files from PromptKit's captured +configuration inspection, using field provenance for inherited references. +Process output protection consumes that ledger. Focused source-accounting and +subprocess collision tests passed, including unchanged input bytes, no provider +dispatch, JSON Pointer-escaped keys, and acceptance of an unused alternate +output; `git diff --check` passed. + ## Stage 6 — Verify Operator Prompt Overrides and Captured Execution **Primary files:** `internal/framework/llm/adapter_test.go` and focused runtime diff --git a/internal/cli/process.go b/internal/cli/process.go index 57b1c0a..9259d6c 100644 --- a/internal/cli/process.go +++ b/internal/cli/process.go @@ -87,7 +87,7 @@ func runProcess(ctx context.Context, args []string, stdout, stderr io.Writer) in protected = append(protected, source.Path) } } - protected = append(protected, runtime.ProfilePaths...) + protected = append(protected, runtime.SourcePaths...) session, err := diagnostics.PrepareOutput(diagnostics.OutputSelection{InputTranscriptPath: input.TranscriptPath, TranscriptPath: input.OutputPath, ReportDir: reportDir, ReportingEnabled: cfg.Reporting.Enabled, ProtectedPaths: protected}) if err != nil { diff --git a/internal/framework/llm/runtime.go b/internal/framework/llm/runtime.go index 0b8ac53..bc3e9af 100644 --- a/internal/framework/llm/runtime.go +++ b/internal/framework/llm/runtime.go @@ -24,9 +24,9 @@ type Runtime struct { Config config.Config Engine *pk.Engine Profiles map[string]pk.ProfileInspection - // ProfilePaths contains filesystem sources actually read by PromptKit. - ProfilePaths []string - nextCallID atomic.Uint64 + // SourcePaths contains host configuration and message files captured by PromptKit. + SourcePaths []string + nextCallID atomic.Uint64 } // AssetError indicates a broken embedded prompt or schema rather than an @@ -125,6 +125,10 @@ func newRuntimeWithPromptFS(ctx context.Context, c config.Config, mainPath strin } runtime.Profiles[selection.role] = *profile } + runtime.SourcePaths, err = capturedSourcePaths(engine) + if err != nil { + return nil, fmt.Errorf("inspect captured LLM sources: %w", err) + } return runtime, nil } diff --git a/internal/framework/llm/source_paths.go b/internal/framework/llm/source_paths.go new file mode 100644 index 0000000..9de79d7 --- /dev/null +++ b/internal/framework/llm/source_paths.go @@ -0,0 +1,65 @@ +package llm + +import ( + "fmt" + "path/filepath" + "sort" + "strconv" + "strings" + + ck "gitea.maximumdirect.net/eric/ConfigKit" + pk "gitea.maximumdirect.net/eric/promptkit" + "gopkg.in/yaml.v3" +) + +func capturedSourcePaths(engine *pk.Engine) ([]string, error) { + inspection, err := engine.InspectConfiguration() + if err != nil { + return nil, fmt.Errorf("inspect configuration: %w", err) + } + + sources := inspection.Sources() + byID := make(map[string]ck.SourceInfo, len(sources)) + paths := make(map[string]struct{}, len(sources)) + for _, source := range sources { + byID[source.ID] = source + if source.Kind == ck.SourceKindFile && source.Path != "" { + paths[source.Path] = struct{}{} + } + } + + metadata := inspection.Metadata() + for _, path := range metadata.HistoricalPaths() { + segments := strings.Split(strings.TrimPrefix(path.String(), "/"), "/") + if len(segments) != 6 || segments[0] != "prompts" || segments[3] != "messages" || segments[5] != "content_file" { + continue + } + if messageIndex, err := strconv.Atoi(segments[4]); err != nil || messageIndex < 0 { + continue + } + field, ok := metadata.Lookup(path) + if !ok || field.Null || len(field.Origin.Locations) != 1 { + continue + } + source, ok := byID[field.Origin.Locations[0].SourceID] + if !ok || source.Kind != ck.SourceKindFile || source.Path == "" { + continue + } + explanation, ok := inspection.Explain(path) + if !ok || !explanation.Effective.Present || explanation.Effective.Redacted { + continue + } + var resource string + if err := yaml.Unmarshal([]byte(explanation.Effective.YAML), &resource); err != nil { + return nil, fmt.Errorf("decode captured message resource at %s: %w", path.String(), err) + } + paths[filepath.Join(filepath.Dir(source.Path), filepath.FromSlash(resource))] = struct{}{} + } + + result := make([]string, 0, len(paths)) + for path := range paths { + result = append(result, path) + } + sort.Strings(result) + return result, nil +} diff --git a/internal/framework/llm/source_paths_test.go b/internal/framework/llm/source_paths_test.go new file mode 100644 index 0000000..f206a60 --- /dev/null +++ b/internal/framework/llm/source_paths_test.go @@ -0,0 +1,61 @@ +package llm + +import ( + "context" + "net/http" + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +func TestRuntimeTracksCapturedConfigurationAndInheritedMessageFiles(t *testing.T) { + cfg, main := runtimeAssemblyFixture(t, " proposal_profile: proposal\n validation_profile: validation\n", map[string]string{ + "selected.yml": "profiles:\n proposal:\n backend: offline\n model: proposal\n validation:\n backend: offline\n model: validation\n", + }) + profiles := filepath.Join(filepath.Dir(main), cfg.ProfilesDir) + parentDir := filepath.Join(profiles, "catalog") + childDir := filepath.Join(profiles, "nested", "child") + for _, directory := range []string{parentDir, childDir} { + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + } + parent := filepath.Join(parentDir, "arbitrary-name.yaml") + child := filepath.Join(childDir, "override.yaml") + resource := filepath.Join(parentDir, "instruction.md") + parentDocument := "prompts:\n source:\n release/1~draft:\n messages:\n - role: user\n content_file: ./instruction.md\n output:\n format: text\n validation_mode: basic\n" + childDocument := "prompts:\n literal/~child:\n extends: source\n" + for path, content := range map[string]string{ + parent: parentDocument, + child: childDocument, + resource: "Captured operator message.\n", + } { + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + + runtime, err := NewRuntime(context.Background(), cfg, main, &http.Client{Transport: rejectingTransport{}}) + if err != nil { + t.Fatal(err) + } + want := []string{ + filepath.Join(profiles, "nested", "child", "override.yaml"), + filepath.Join(profiles, "catalog", "arbitrary-name.yaml"), + filepath.Join(profiles, "selected.yml"), + resource, + } + // Runtime paths are canonical paths from PromptKit's captured sources. + for i := range want { + want[i], err = filepath.EvalSymlinks(want[i]) + if err != nil { + t.Fatal(err) + } + } + sort.Strings(want) + if !reflect.DeepEqual(runtime.SourcePaths, want) { + t.Fatalf("captured source paths = %#v, want %#v", runtime.SourcePaths, want) + } +}