Contain prompt content paths within source roots
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
254
engine_test.go
254
engine_test.go
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
98
internal/promptdef/content_source.go
Normal file
98
internal/promptdef/content_source.go
Normal 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))
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(`
|
||||||
|
|||||||
Reference in New Issue
Block a user