From a8eb5aea4d9530d673c13ea314bfd7013995d51e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 10 Nov 2026 20:17:54 +0000 Subject: [PATCH] Resolve linked prompts in their host context --- docs/roadmap/remediation.md | 2 + internal/framework/llm/engine_sources_test.go | 37 +++++++++++++++++++ internal/framework/llm/runtime.go | 23 +++++++----- internal/framework/llm/source_paths.go | 5 +-- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index c54f598..68e432b 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -87,6 +87,8 @@ report failures. ## R2 — P2: Preserve Host Prompt Source Context Through Symlinks +**Status:** Completed. Prompt imports use the recorded native host context and protect the resolved sources. + **Category** Main-relative resource loading divergence in Stage 3. diff --git a/internal/framework/llm/engine_sources_test.go b/internal/framework/llm/engine_sources_test.go index 49b1975..1bc2670 100644 --- a/internal/framework/llm/engine_sources_test.go +++ b/internal/framework/llm/engine_sources_test.go @@ -161,3 +161,40 @@ func TestEngineUsesRecordedContextWithWiderRoot(t *testing.T) { } } } + +func TestEngineResolvesLinkedPromptMessageBesideDefinition(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "defs"), 0755); err != nil { + t.Fatal(err) + } + for name, contents := range map[string]string{ + "config.yml": "version: 3\n", + "defs/resources.yml": "prompts:\n - id: linked\n messages:\n - role: user\n content_file: message.txt\n output:\n format: text\n validation_mode: none\n", + "defs/message.txt": "message beside definition", + "message.txt": "message beside symlink", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink("defs/resources.yml", filepath.Join(root, "prompts.yml")); err != nil { + t.Fatal(err) + } + loaded, err := config.Load(ck.Input{Source: ck.File(filepath.Join(root, "config.yml"))}, "") + if err != nil { + t.Fatal(err) + } + runtime, err := NewRuntime(context.Background(), loaded, nil) + if err != nil { + t.Fatal(err) + } + prompt, err := runtime.Engine.InspectPrompt(context.Background(), "linked") + if err != nil || prompt.Messages[0].Content != "message beside definition" { + t.Fatalf("linked prompt: %+v, %v", prompt, err) + } + for _, name := range []string{"defs/resources.yml", "defs/message.txt"} { + if !slices.Contains(runtime.SourcePaths, filepath.Join(root, name)) { + t.Errorf("missing consumed source %s in %v", name, runtime.SourcePaths) + } + } +} diff --git a/internal/framework/llm/runtime.go b/internal/framework/llm/runtime.go index 44b152c..d97efc1 100644 --- a/internal/framework/llm/runtime.go +++ b/internal/framework/llm/runtime.go @@ -30,7 +30,6 @@ type AssetError struct{ Err error } func (e *AssetError) Error() string { return e.Err.Error() } func (e *AssetError) Unwrap() error { return e.Err } -const operatorFilesystem = "Audita operator prompts" const operatorImports = `config: imports: - path: prompts.yml @@ -57,7 +56,6 @@ func newRuntimeWithPromptFS(ctx context.Context, loaded *ck.Result[config.Config return nil, &AssetError{Err: fmt.Errorf("discover embedded prompts: %w", err)} } sources := pk.ConfigurationSources{Underlays: []ck.Underlay{{Inputs: embedded}}} - var hostRoot string for _, source := range loaded.Sources() { if source.Layer != ck.LayerMain || source.ImportedFrom != nil { continue @@ -66,19 +64,24 @@ func newRuntimeWithPromptFS(ctx context.Context, loaded *ck.Result[config.Config if !ok { break } - filesystem := ck.NewFilesystem(operatorFilesystem, context.FS) - sources.Underlays = append(sources.Underlays, ck.Underlay{Inputs: []ck.Input{{ - Source: ck.Bytes("Audita automatic prompt imports", []byte(operatorImports)), Imports: true, - Filesystem: &ck.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: context.BaseDir}, - }}}) + var filesystemContext ck.FilesystemContext if source.Kind == ck.SourceKindFile && source.Path != "" { - hostRoot = filepath.Dir(source.Path) + base := filepath.Dir(source.Path) + root := base if context.BaseDir != "." { for range strings.Split(context.BaseDir, "/") { - hostRoot = filepath.Dir(hostRoot) + root = filepath.Dir(root) } } + filesystemContext = ck.FilesystemContext{Root: root, BaseDir: base} + } else { + filesystem := ck.NewFilesystem("Audita operator prompts", context.FS) + filesystemContext = ck.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: context.BaseDir} } + sources.Underlays = append(sources.Underlays, ck.Underlay{Inputs: []ck.Input{{ + Source: ck.Bytes("Audita automatic prompt imports", []byte(operatorImports)), Imports: true, + Filesystem: &filesystemContext, + }}}) break } engine, err := pk.NewEngine(pk.Config{Sources: sources, HTTPClient: client}, pk.WithSchemaFS(responseschema.Filesystem(), responseschema.AssetRoot)) @@ -90,7 +93,7 @@ func newRuntimeWithPromptFS(ctx context.Context, loaded *ck.Result[config.Config return nil, embeddedAssetOrInputError(ctx, embedded, fmt.Errorf("inspect prompt %s: %w", meta.PromptID, err)) } } - paths, err := capturedSourcePaths(engine, hostRoot) + paths, err := capturedSourcePaths(engine) if err != nil { return nil, fmt.Errorf("inspect captured LLM sources: %w", err) } diff --git a/internal/framework/llm/source_paths.go b/internal/framework/llm/source_paths.go index 9c9e350..84f7d70 100644 --- a/internal/framework/llm/source_paths.go +++ b/internal/framework/llm/source_paths.go @@ -12,7 +12,7 @@ import ( "gopkg.in/yaml.v3" ) -func capturedSourcePaths(engine *pk.Engine, hostRoot string) ([]string, error) { +func capturedSourcePaths(engine *pk.Engine) ([]string, error) { inspection, err := engine.InspectConfiguration() if err != nil { return nil, fmt.Errorf("inspect configuration: %w", err) @@ -25,9 +25,6 @@ func capturedSourcePaths(engine *pk.Engine, hostRoot string) ([]string, error) { if source.Kind == ck.SourceKindFile { return source.Path } - if hostRoot != "" && source.Kind == ck.SourceKindFilesystem && strings.HasPrefix(source.Label, operatorFilesystem+"/") { - return filepath.Join(hostRoot, filepath.FromSlash(source.Path)) - } return "" } for _, source := range sources {