Contain prompt content paths within source roots

This commit is contained in:
2026-08-11 22:03:32 +00:00
parent 58ac3ce298
commit a718762da1
9 changed files with 358 additions and 163 deletions

View File

@@ -1538,127 +1538,189 @@ unexpected: true
}
}
func TestPrepareWorksWithPromptFSAndRelativeContentFile(t *testing.T) {
promptFS := fstest.MapFS{
"assets/prompts/fs-summary.yaml": &fstest.MapFile{Data: []byte(`
id: fs.summary
func TestPromptContentFilePathsAcrossSources(t *testing.T) {
type pathCase struct {
name string
directoryPath string
singleFilePath string
directoryTarget string
singleTarget string
absolute bool
symlink bool
wantErr string
}
tests := []pathCase{
{name: "ordinary sibling", directoryPath: "sibling.tmpl", singleFilePath: "sibling.tmpl", directoryTarget: "nested/sibling.tmpl", singleTarget: "sibling.tmpl"},
{name: "parent remains inside root", directoryPath: "../shared.tmpl", singleFilePath: "nested/../shared.tmpl", directoryTarget: "shared.tmpl", singleTarget: "shared.tmpl"},
{name: "parent escapes root", directoryPath: "../../outside.tmpl", singleFilePath: "../outside.tmpl", wantErr: "escapes source root"},
{name: "absolute path", absolute: true, wantErr: "must be relative"},
{name: "symlink escapes root", directoryPath: "escape.tmpl", singleFilePath: "escape.tmpl", symlink: true, wantErr: "escapes source root"},
{name: "leading whitespace preserved", directoryPath: " body.tmpl", singleFilePath: " body.tmpl", directoryTarget: "nested/ body.tmpl", singleTarget: " body.tmpl"},
{name: "trailing whitespace preserved", directoryPath: "body.tmpl ", singleFilePath: "body.tmpl ", directoryTarget: "nested/body.tmpl ", singleTarget: "body.tmpl "},
}
sources := []struct {
name string
singleFile bool
injectedFS bool
supportsSymlink bool
}{
{name: "operating system directory", supportsSymlink: true},
{name: "injected filesystem", injectedFS: true},
{name: "single file", singleFile: true, supportsSymlink: true},
}
profile := promptkit.Profile{ID: "content-profile", Endpoint: "http://example.test/v1", Model: "content-model"}
const promptID = "content-path-prompt"
const body = "Exact content body."
for _, source := range sources {
for _, tc := range tests {
t.Run(source.name+"/"+tc.name, func(t *testing.T) {
if tc.symlink && !source.supportsSymlink {
t.Skip("source does not expose operating-system symlink semantics")
}
workspace := t.TempDir()
sourceRoot := filepath.Join(workspace, "prompts")
outsidePath := filepath.Join(workspace, "outside.tmpl")
if err := os.MkdirAll(filepath.Join(sourceRoot, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(outsidePath, []byte("Outside root."), 0o644); err != nil {
t.Fatal(err)
}
contentFile := tc.directoryPath
target := tc.directoryTarget
promptRelativePath := "nested/prompt.yaml"
if source.singleFile {
contentFile = tc.singleFilePath
target = tc.singleTarget
promptRelativePath = "prompt.yaml"
}
if tc.absolute {
contentFile = outsidePath
if source.injectedFS {
contentFile = "/outside.tmpl"
}
}
promptDocument := []byte(fmt.Sprintf(`
id: %s
version: "1.0.0"
default_profile: contract-fast
inputs:
- name: transcript
required: true
default_profile: content-profile
messages:
- role: user
content_file: ./messages/summary.tmpl
content_file: %q
output:
format: text
validation_mode: none
repair_attempts: 0
`)},
"assets/prompts/messages/summary.tmpl": &fstest.MapFile{Data: []byte(`Summarize {{input "transcript"}} from prompt fs.`)},
}
`, promptID, contentFile))
engine, err := promptkit.NewEngine(promptkit.Config{
PromptDir: t.TempDir(),
ProfileDir: frameworkProfileDir,
SchemaDir: frameworkSchemaDir,
}, promptkit.WithPromptFS(promptFS, "assets/prompts"))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
var engine *promptkit.Engine
var err error
if source.injectedFS {
promptFS := fstest.MapFS{
"prompts/nested/prompt.yaml": &fstest.MapFile{Data: promptDocument},
"outside.tmpl": &fstest.MapFile{Data: []byte("Outside root.")},
}
if target != "" {
promptFS["prompts/"+filepath.ToSlash(target)] = &fstest.MapFile{Data: []byte(body)}
}
engine, err = promptkit.NewEngine(promptkit.Config{}, promptkit.WithPromptFS(promptFS, "prompts"), promptkit.WithProfiles(profile))
} else {
promptPath := filepath.Join(sourceRoot, filepath.FromSlash(promptRelativePath))
if err := os.WriteFile(promptPath, promptDocument, 0o644); err != nil {
t.Fatal(err)
}
if target != "" {
targetPath := filepath.Join(sourceRoot, filepath.FromSlash(target))
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(targetPath, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
if tc.symlink {
linkPath := filepath.Join(filepath.Dir(promptPath), "escape.tmpl")
if err := os.Symlink(outsidePath, linkPath); err != nil {
t.Skipf("symlinks are not supported: %v", err)
}
}
if source.singleFile {
engine, err = promptkit.NewEngine(promptkit.Config{}, promptkit.WithPromptFile(promptPath), promptkit.WithProfiles(profile))
} else {
engine, err = promptkit.NewEngine(promptkit.Config{PromptDir: sourceRoot}, promptkit.WithProfiles(profile))
}
}
if err != nil {
t.Fatalf("construct engine: %v", err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: "fs.summary",
Inputs: map[string]promptkit.ArtifactRef{
"transcript": promptkit.Inline("Rin opens the gate."),
},
})
if err != nil {
t.Fatalf("expected prepare to succeed, got %v", err)
}
if len(prepared.Messages) != 1 || !strings.Contains(prepared.Messages[0].Content, "prompt fs") {
t.Fatalf("expected content_file body from prompt fs, got %+v", prepared.Messages)
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: promptID})
if tc.wantErr != "" {
if !errors.Is(err, promptkit.ErrPromptLoad) {
t.Fatalf("expected ErrPromptLoad, got %v", err)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("prepare: %v", err)
}
if len(prepared.Messages) != 1 || prepared.Messages[0].Content != body {
t.Fatalf("expected exact content body, got %+v", prepared.Messages)
}
})
}
}
}
func TestPrepareWithPromptFSRejectsEscapedContentFile(t *testing.T) {
func TestPromptContentFileFailuresPreservePublicError(t *testing.T) {
promptFS := fstest.MapFS{
"assets/prompts/fs-escape.yaml": &fstest.MapFile{Data: []byte(`
id: fs.escape
"prompts/prompt.yaml": &fstest.MapFile{Data: []byte(`
id: rejected-content-path
version: "1.0.0"
default_profile: contract-fast
messages:
- role: user
content_file: ../outside.tmpl
output:
format: text
validation_mode: none
repair_attempts: 0
`)},
"assets/outside.tmpl": &fstest.MapFile{Data: []byte(`Outside root.`)},
"outside.tmpl": &fstest.MapFile{Data: []byte("Outside root.")},
}
engine, err := promptkit.NewEngine(promptkit.Config{
PromptDir: t.TempDir(),
ProfileDir: frameworkProfileDir,
SchemaDir: frameworkSchemaDir,
}, promptkit.WithPromptFS(promptFS, "assets/prompts"))
engine, err := promptkit.NewEngine(promptkit.Config{}, promptkit.WithPromptFS(promptFS, "prompts"))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
t.Fatalf("construct engine: %v", err)
}
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "fs.escape"})
if !errors.Is(err, promptkit.ErrPromptLoad) {
t.Fatalf("expected ErrPromptLoad, got %v", err)
operations := []struct {
name string
run func() error
}{
{name: "inspect prompt", run: func() error {
_, err := engine.InspectPrompt(context.Background(), "rejected-content-path", "")
return err
}},
{name: "prepare execution", run: func() error {
_, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "rejected-content-path"})
return err
}},
{name: "run", run: func() error {
_, err := engine.Run(context.Background(), promptkit.RunRequest{PromptID: "rejected-content-path"})
return err
}},
}
}
func TestPrepareWorksWithPromptFile(t *testing.T) {
promptDir := t.TempDir()
promptPath := filepath.Join(promptDir, "single.yaml")
if err := os.WriteFile(promptPath, []byte(`
id: single.file.prompt
version: "1.0.0"
default_profile: contract-fast
inputs:
- name: transcript
required: true
messages:
- role: user
content_file: ./single.tmpl
output:
format: text
validation_mode: none
repair_attempts: 0
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(promptDir, "single.tmpl"), []byte(`Summarize {{input "transcript"}} from file.`), 0o644); err != nil {
t.Fatal(err)
}
engine, err := promptkit.NewEngine(promptkit.Config{
ProfileDir: frameworkProfileDir,
SchemaDir: frameworkSchemaDir,
}, promptkit.WithPromptFile(promptPath))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: "single.file.prompt",
Inputs: map[string]promptkit.ArtifactRef{
"transcript": promptkit.Inline("Rin opens the gate."),
},
})
if err != nil {
t.Fatalf("expected prepare to succeed, got %v", err)
}
if prepared.PromptID != "single.file.prompt" {
t.Fatalf("unexpected prompt id: %q", prepared.PromptID)
}
if len(prepared.Messages) != 1 || !strings.Contains(prepared.Messages[0].Content, "from file") {
t.Fatalf("expected content_file body from prompt file, got %+v", prepared.Messages)
for _, operation := range operations {
t.Run(operation.name, func(t *testing.T) {
if err := operation.run(); !errors.Is(err, promptkit.ErrPromptLoad) {
t.Fatalf("expected ErrPromptLoad, got %v", err)
}
})
}
}