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

@@ -86,9 +86,16 @@ Each message has a non-empty `role` and exactly one of:
- `content`, containing an inline Go template; or - `content`, containing an inline Go template; or
- `content_file`, naming a file whose contents are the Go template. - `content_file`, naming a file whose contents are the Go template.
For directory and `fs.FS` prompt sources, `content_file` resolves relative to `content_file` must be a relative path. It resolves from the directory that
the prompt file and remains within the source root. `WithPromptFile` also contains the prompt file and must remain within the configured prompt source
resolves it relative to that file. root; parent components are allowed only when the resolved target remains
inside that root. Absolute paths and paths that escape the root are rejected.
Operating-system directory and single-file sources also reject symlink targets
outside the root, while injected `fs.FS` sources apply containment in that
filesystem's relative path namespace. For `WithPromptFile`, the source root is
the directory containing the selected prompt file. Promptkit uses the parsed
path text exactly after checking separately that it is not blank, so leading
and trailing whitespace can name real filesystem entries.
Request variables are the template data, so a variable named `audience` is Request variables are the template data, so a variable named `audience` is
referenced as `{{.audience}}`. The `{{input "note"}}` helper renders the body referenced as `{{.audience}}`. The `{{input "note"}}` helper renders the body

View File

@@ -14,7 +14,13 @@ validation modes, built-in catalog, and source precedence.
`internal/promptdef` discovers YAML deterministically, decodes and validates `internal/promptdef` discovers YAML deterministically, decodes and validates
definitions, selects an ID and optional version, and resolves file-backed definitions, selects an ID and optional version, and resolves file-backed
message content within the selected operating-system or `fs.FS` source. message content through an explicit source-root abstraction. Operating-system
sources enforce containment against canonical roots and targets so symlinks
cannot escape. Injected `fs.FS` sources enforce containment in their clean
relative path namespace. A single-file source uses the selected prompt file's
containing directory as its root. Every content path must be relative and is
opened from its exact parsed text after a separate blank check; contained
parent components and whitespace-bearing names remain valid.
Exact prompt inspection performs one point-in-time lookup through that same Exact prompt inspection performs one point-in-time lookup through that same
repository and validates referenced message content before returning declared repository and validates referenced message content before returning declared

View File

@@ -219,7 +219,7 @@ func WithPromptFile(path string) Option {
if err != nil { if err != nil {
return err return err
} }
options.promptDefs = promptdef.NewFSRepository(fsys, root) options.promptDefs = promptdef.NewFileRepository(fsys, root, filepath.Dir(path))
options.promptSource = true options.promptSource = true
return nil return nil
}) })

View File

@@ -1538,127 +1538,189 @@ unexpected: true
} }
} }
func TestPrepareWorksWithPromptFSAndRelativeContentFile(t *testing.T) { func TestPromptContentFilePathsAcrossSources(t *testing.T) {
promptFS := fstest.MapFS{ type pathCase struct {
"assets/prompts/fs-summary.yaml": &fstest.MapFile{Data: []byte(` name string
id: fs.summary 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" version: "1.0.0"
default_profile: contract-fast default_profile: content-profile
inputs:
- name: transcript
required: true
messages: messages:
- role: user - role: user
content_file: ./messages/summary.tmpl content_file: %q
output: output:
format: text format: text
validation_mode: none validation_mode: none
repair_attempts: 0 `, promptID, contentFile))
`)},
"assets/prompts/messages/summary.tmpl": &fstest.MapFile{Data: []byte(`Summarize {{input "transcript"}} from prompt fs.`)},
}
engine, err := promptkit.NewEngine(promptkit.Config{ var engine *promptkit.Engine
PromptDir: t.TempDir(), var err error
ProfileDir: frameworkProfileDir, if source.injectedFS {
SchemaDir: frameworkSchemaDir, promptFS := fstest.MapFS{
}, promptkit.WithPromptFS(promptFS, "assets/prompts")) "prompts/nested/prompt.yaml": &fstest.MapFile{Data: promptDocument},
if err != nil { "outside.tmpl": &fstest.MapFile{Data: []byte("Outside root.")},
t.Fatalf("expected engine construction to succeed, got %v", err) }
} 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{ prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: promptID})
PromptID: "fs.summary", if tc.wantErr != "" {
Inputs: map[string]promptkit.ArtifactRef{ if !errors.Is(err, promptkit.ErrPromptLoad) {
"transcript": promptkit.Inline("Rin opens the gate."), t.Fatalf("expected ErrPromptLoad, got %v", err)
}, }
}) if !strings.Contains(err.Error(), tc.wantErr) {
if err != nil { t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err)
t.Fatalf("expected prepare to succeed, got %v", err) }
} return
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) 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{ promptFS := fstest.MapFS{
"assets/prompts/fs-escape.yaml": &fstest.MapFile{Data: []byte(` "prompts/prompt.yaml": &fstest.MapFile{Data: []byte(`
id: fs.escape id: rejected-content-path
version: "1.0.0" version: "1.0.0"
default_profile: contract-fast
messages: messages:
- role: user - role: user
content_file: ../outside.tmpl content_file: ../outside.tmpl
output: output:
format: text format: text
validation_mode: none 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{}, promptkit.WithPromptFS(promptFS, "prompts"))
engine, err := promptkit.NewEngine(promptkit.Config{
PromptDir: t.TempDir(),
ProfileDir: frameworkProfileDir,
SchemaDir: frameworkSchemaDir,
}, promptkit.WithPromptFS(promptFS, "assets/prompts"))
if err != nil { 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"}) operations := []struct {
if !errors.Is(err, promptkit.ErrPromptLoad) { name string
t.Fatalf("expected ErrPromptLoad, got %v", err) 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
}},
} }
} for _, operation := range operations {
t.Run(operation.name, func(t *testing.T) {
func TestPrepareWorksWithPromptFile(t *testing.T) { if err := operation.run(); !errors.Is(err, promptkit.ErrPromptLoad) {
promptDir := t.TempDir() t.Fatalf("expected ErrPromptLoad, got %v", err)
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)
} }
} }

View File

@@ -102,22 +102,21 @@ func DisplayPath(root string, name string) string {
// ResolveFSPath resolves userPath from baseDir and keeps it inside root. // ResolveFSPath resolves userPath from baseDir and keeps it inside root.
func ResolveFSPath(root string, baseDir string, userPath string) (string, string, error) { func ResolveFSPath(root string, baseDir string, userPath string) (string, string, error) {
cleanRoot := CleanFSRoot(root) cleanRoot := CleanFSRoot(root)
cleanBase := path.Clean(strings.TrimSpace(baseDir)) cleanBase := path.Clean(baseDir)
if cleanBase == "" { if strings.TrimSpace(baseDir) == "" {
cleanBase = cleanRoot cleanBase = cleanRoot
} }
if !containsFSPath(cleanRoot, cleanBase) { if !containsFSPath(cleanRoot, cleanBase) {
return "", "", fmt.Errorf("base path %q is outside source root %q", cleanBase, cleanRoot) return "", "", fmt.Errorf("base path %q is outside source root %q", cleanBase, cleanRoot)
} }
cleanUserPath := strings.TrimSpace(userPath) if strings.TrimSpace(userPath) == "" {
if cleanUserPath == "" {
return "", "", fmt.Errorf("path is required") return "", "", fmt.Errorf("path is required")
} }
cleanUserPath = path.Clean(cleanUserPath) if path.IsAbs(userPath) {
if path.IsAbs(cleanUserPath) {
return "", "", fmt.Errorf("path %q must be relative", userPath) return "", "", fmt.Errorf("path %q must be relative", userPath)
} }
cleanUserPath := path.Clean(userPath)
resolved := path.Clean(path.Join(cleanBase, cleanUserPath)) resolved := path.Clean(path.Join(cleanBase, cleanUserPath))
if !containsFSPath(cleanRoot, resolved) { if !containsFSPath(cleanRoot, resolved) {

View File

@@ -161,6 +161,22 @@ func TestResolveFSPath(t *testing.T) {
wantPath: "prompts/shared/user.tmpl", wantPath: "prompts/shared/user.tmpl",
wantDisplay: "shared/user.tmpl", wantDisplay: "shared/user.tmpl",
}, },
{
name: "leading whitespace preserved",
root: "prompts",
baseDir: "prompts/nested",
userPath: " user.tmpl",
wantPath: "prompts/nested/ user.tmpl",
wantDisplay: "nested/ user.tmpl",
},
{
name: "trailing whitespace preserved",
root: "prompts",
baseDir: "prompts/nested",
userPath: "user.tmpl ",
wantPath: "prompts/nested/user.tmpl ",
wantDisplay: "nested/user.tmpl ",
},
{ {
name: "escape rejected", name: "escape rejected",
root: "prompts", root: "prompts",

View File

@@ -0,0 +1,98 @@
package promptdef
import (
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
)
type contentSourceRoot interface {
readContentFile(sourcePath string, contentFile string) (string, string, error)
}
type osContentSourceRoot struct {
root string
sourcePathsRelative bool
}
func (r osContentSourceRoot) readContentFile(sourcePath string, contentFile string) (string, string, error) {
if strings.TrimSpace(contentFile) == "" {
return "", "", fmt.Errorf("path is required")
}
if filepath.IsAbs(contentFile) {
return "", "", fmt.Errorf("path %q must be relative", contentFile)
}
root, err := filepath.Abs(r.root)
if err != nil {
return "", "", fmt.Errorf("resolve source root %q: %w", r.root, err)
}
canonicalRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return "", "", fmt.Errorf("resolve source root %q: %w", r.root, err)
}
promptPath := sourcePath
if r.sourcePathsRelative && !filepath.IsAbs(promptPath) {
promptPath = filepath.Join(root, filepath.FromSlash(promptPath))
} else {
promptPath, err = filepath.Abs(promptPath)
if err != nil {
return "", "", fmt.Errorf("resolve prompt source %q: %w", sourcePath, err)
}
}
resolvedPath := filepath.Clean(filepath.Join(filepath.Dir(promptPath), contentFile))
if !containsOSPath(root, resolvedPath) {
return "", "", fmt.Errorf("path %q escapes source root %q", contentFile, r.root)
}
canonicalPath, err := filepath.EvalSymlinks(resolvedPath)
if err != nil {
return "", "", err
}
if !containsOSPath(canonicalRoot, canonicalPath) {
return "", "", fmt.Errorf("path %q escapes source root %q", contentFile, r.root)
}
body, err := os.ReadFile(canonicalPath)
if err != nil {
return "", "", err
}
return string(body), resolvedPath, nil
}
type fsContentSourceRoot struct {
fsys fs.FS
root string
}
func (r fsContentSourceRoot) readContentFile(sourcePath string, contentFile string) (string, string, error) {
root := filecatalog.CleanFSRoot(r.root)
cleanSourcePath := path.Clean(sourcePath)
if cleanSourcePath == root {
root = path.Dir(root)
}
resolvedPath, _, err := filecatalog.ResolveFSPath(root, path.Dir(cleanSourcePath), contentFile)
if err != nil {
return "", "", err
}
body, err := fs.ReadFile(r.fsys, resolvedPath)
if err != nil {
return "", "", err
}
return string(body), resolvedPath, nil
}
func containsOSPath(root string, name string) bool {
relative, err := filepath.Rel(root, name)
if err != nil || filepath.IsAbs(relative) {
return false
}
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}

View File

@@ -23,12 +23,14 @@ var (
) )
type filesystemRepository struct { type filesystemRepository struct {
dir string dir string
sourceRoot contentSourceRoot
} }
type fsRepository struct { type fsRepository struct {
fsys fs.FS fsys fs.FS
root string root string
sourceRoot contentSourceRoot
} }
type promptDefinitionFile struct { type promptDefinitionFile struct {
@@ -69,11 +71,30 @@ type promptOutputContractFile struct {
} }
func NewFilesystemRepository(dir string) Repository { func NewFilesystemRepository(dir string) Repository {
return &filesystemRepository{dir: dir} return &filesystemRepository{
dir: dir,
sourceRoot: osContentSourceRoot{root: dir},
}
} }
func NewFSRepository(fsys fs.FS, root string) Repository { func NewFSRepository(fsys fs.FS, root string) Repository {
return &fsRepository{fsys: fsys, root: root} return &fsRepository{
fsys: fsys,
root: root,
sourceRoot: fsContentSourceRoot{fsys: fsys, root: root},
}
}
// NewFileRepository constructs a repository for one operating-system prompt file.
func NewFileRepository(fsys fs.FS, file string, sourceDir string) Repository {
return &fsRepository{
fsys: fsys,
root: file,
sourceRoot: osContentSourceRoot{
root: sourceDir,
sourcePathsRelative: true,
},
}
} }
func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) { func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
@@ -105,7 +126,7 @@ func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id strin
continue continue
} }
def, err := normalizePromptDefinition(raw, fullPath) def, err := normalizePromptDefinition(raw, r.sourceRoot, fullPath)
if err != nil { if err != nil {
if fileMatch || strings.TrimSpace(raw.ID) == id { if fileMatch || strings.TrimSpace(raw.ID) == id {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err) return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
@@ -144,7 +165,7 @@ func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id strin
} }
func (r *fsRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) { func (r *fsRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
return loadPromptDefinition(ctx, r.fsys, r.root, id, version) return loadPromptDefinition(ctx, r.fsys, r.root, r.sourceRoot, id, version)
} }
type promptDefinitionMatch struct { type promptDefinitionMatch struct {
@@ -181,7 +202,7 @@ func promptDefinitionFileHasID(path string, id string) bool {
return strings.TrimSpace(raw.ID) == id return strings.TrimSpace(raw.ID) == id
} }
func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id string, version string) (*domain.PromptDefinition, error) { func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, sourceRoot contentSourceRoot, id string, version string) (*domain.PromptDefinition, error) {
if strings.TrimSpace(id) == "" { if strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition) return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition)
} }
@@ -193,12 +214,6 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id strin
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err) return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
} }
cleanRoot := filecatalog.CleanFSRoot(root)
rootInfo, err := fs.Stat(fsys, cleanRoot)
if err != nil {
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
}
var matches []promptDefinitionMatch var matches []promptDefinitionMatch
for _, fullPath := range files { for _, fullPath := range files {
select { select {
@@ -225,7 +240,7 @@ func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id strin
continue continue
} }
def, err := normalizePromptDefinitionFromFS(raw, fsys, root, fullPath, rootInfo.IsDir()) def, err := normalizePromptDefinition(raw, sourceRoot, fullPath)
if err != nil { if err != nil {
if fileMatch || strings.TrimSpace(raw.ID) == id { if fileMatch || strings.TrimSpace(raw.ID) == id {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err) return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
@@ -283,46 +298,9 @@ func promptDefinitionDataHasID(data []byte, id string) bool {
return strings.TrimSpace(raw.ID) == id return strings.TrimSpace(raw.ID) == id
} }
func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*domain.PromptDefinition, error) { func normalizePromptDefinition(raw *promptDefinitionFile, sourceRoot contentSourceRoot, sourcePath string) (*domain.PromptDefinition, error) {
promptDir := filepath.Dir(sourcePath)
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) { return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
resolvedPath := strings.TrimSpace(contentFile) return sourceRoot.readContentFile(sourcePath, contentFile)
if !filepath.IsAbs(resolvedPath) {
resolvedPath = filepath.Join(promptDir, resolvedPath)
}
resolvedPath = filepath.Clean(resolvedPath)
body, err := os.ReadFile(resolvedPath)
if err != nil {
return "", "", err
}
return string(body), resolvedPath, nil
})
}
func normalizePromptDefinitionFromFS(raw *promptDefinitionFile, fsys fs.FS, root string, sourcePath string, rootIsDir bool) (*domain.PromptDefinition, error) {
promptDir := path.Dir(sourcePath)
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
var resolvedPath string
if rootIsDir {
var err error
resolvedPath, _, err = filecatalog.ResolveFSPath(root, promptDir, contentFile)
if err != nil {
return "", "", err
}
} else {
resolvedPath = strings.TrimSpace(contentFile)
if !path.IsAbs(resolvedPath) {
resolvedPath = path.Join(promptDir, resolvedPath)
}
resolvedPath = strings.TrimPrefix(path.Clean(resolvedPath), "/")
}
body, err := fs.ReadFile(fsys, resolvedPath)
if err != nil {
return "", "", err
}
return string(body), resolvedPath, nil
}) })
} }

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"testing/fstest" "testing/fstest"
@@ -396,7 +397,7 @@ output:
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{ fsys := &recordingFS{FS: fstest.MapFS{
"prompts/prompt.yaml": &fstest.MapFile{Data: []byte(` "prompts/prompt.yaml": &fstest.MapFile{Data: []byte(`
id: fs-escaped-prompt id: fs-escaped-prompt
version: "1.0.0" version: "1.0.0"
@@ -409,7 +410,8 @@ output:
repair_attempts: 0 repair_attempts: 0
`)}, `)},
"outside.tmpl": &fstest.MapFile{Data: []byte(`Outside root.`)}, "outside.tmpl": &fstest.MapFile{Data: []byte(`Outside root.`)},
}, "prompts") }}
repo := NewFSRepository(fsys, "prompts")
_, err := repo.GetPromptDefinition(context.Background(), "fs-escaped-prompt", "") _, err := repo.GetPromptDefinition(context.Background(), "fs-escaped-prompt", "")
if !errors.Is(err, ErrInvalidPromptDefinition) { if !errors.Is(err, ErrInvalidPromptDefinition) {
@@ -418,10 +420,37 @@ output:
if !strings.Contains(err.Error(), tc.wantErr) { if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err) t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err)
} }
if fsys.wasOpened("outside.tmpl") {
t.Fatal("rejected content path opened the outside file")
}
}) })
} }
} }
type recordingFS struct {
fs.FS
mu sync.Mutex
opened []string
}
func (f *recordingFS) Open(name string) (fs.File, error) {
f.mu.Lock()
f.opened = append(f.opened, name)
f.mu.Unlock()
return f.FS.Open(name)
}
func (f *recordingFS) wasOpened(name string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, opened := range f.opened {
if opened == name {
return true
}
}
return false
}
func TestFSRepositoryRejectsDuplicatePromptIDs(t *testing.T) { func TestFSRepositoryRejectsDuplicatePromptIDs(t *testing.T) {
repo := NewFSRepository(fstest.MapFS{ repo := NewFSRepository(fstest.MapFS{
"one.yaml": &fstest.MapFile{Data: []byte(` "one.yaml": &fstest.MapFile{Data: []byte(`