Protect captured prompt configuration files

This commit is contained in:
2026-10-04 00:56:14 +00:00
parent f8d00e2f20
commit 9b82eb7da2
6 changed files with 187 additions and 6 deletions

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View 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
}

View 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)
}
}