Protect captured prompt configuration files
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
65
internal/framework/llm/source_paths.go
Normal file
65
internal/framework/llm/source_paths.go
Normal file
@@ -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
|
||||
}
|
||||
61
internal/framework/llm/source_paths_test.go
Normal file
61
internal/framework/llm/source_paths_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user