Secure prompt debug filesystem writes
This commit is contained in:
@@ -32,6 +32,8 @@ Promptkit receives the YAML data package as an inline input and returns structur
|
|||||||
When capture is enabled, its preparation artifact projects a provider endpoint
|
When capture is enabled, its preparation artifact projects a provider endpoint
|
||||||
to its scheme and host and retains only reviewed execution settings. Provider
|
to its scheme and host and retains only reviewed execution settings. Provider
|
||||||
extras and URL user information, paths, queries, and fragments are omitted.
|
extras and URL user information, paths, queries, and fragments are omitted.
|
||||||
|
Capture storage remains confined to the operator-selected debug root; an unsafe
|
||||||
|
filesystem path causes the requested execution to fail.
|
||||||
|
|
||||||
## Comparison Execution
|
## Comparison Execution
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ Preparation captures retain only the provider endpoint origin and reviewed
|
|||||||
execution settings. URL user information, paths, queries, fragments, and
|
execution settings. URL user information, paths, queries, fragments, and
|
||||||
unrecognized provider parameters are omitted.
|
unrecognized provider parameters are omitted.
|
||||||
|
|
||||||
|
Capture writes are confined to the requested root and fail if an unsafe
|
||||||
|
filesystem component prevents secure artifact creation.
|
||||||
|
|
||||||
If capture creation or writing fails, the affected run fails rather than
|
If capture creation or writing fails, the affected run fails rather than
|
||||||
silently continuing without the requested diagnostics.
|
silently continuing without the requested diagnostics.
|
||||||
|
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -7,6 +7,7 @@ require gopkg.in/yaml.v3 v3.0.1
|
|||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/distributor v0.5.0
|
gitea.maximumdirect.net/eric/distributor v0.5.0
|
||||||
gitea.maximumdirect.net/eric/promptkit v0.5.0
|
gitea.maximumdirect.net/eric/promptkit v0.5.0
|
||||||
|
golang.org/x/sys v0.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
}
|
}
|
||||||
|
defer func() { _ = debugWriter.Close() }()
|
||||||
inspection, err := InspectPromptExecution(ctx, PromptInspectionRequest{
|
inspection, err := InspectPromptExecution(ctx, PromptInspectionRequest{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Executor: req.Executor,
|
Executor: req.Executor,
|
||||||
@@ -309,6 +310,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
}
|
}
|
||||||
|
defer func() { _ = debugWriter.Close() }()
|
||||||
candidates, err := batchInspectionCandidates(req, now)
|
candidates, err := batchInspectionCandidates(req, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ func compareDetailed(ctx context.Context, req ComparisonRequest, publish compari
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
}
|
}
|
||||||
|
defer func() { _ = debugWriter.Close() }()
|
||||||
inspection, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{
|
inspection, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{
|
||||||
Resolved: resolved, ProfileIDs: req.ProfileIDs, Executor: req.Executor, LookupEnv: os.LookupEnv,
|
Resolved: resolved, ProfileIDs: req.ProfileIDs, Executor: req.Executor, LookupEnv: os.LookupEnv,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -25,7 +24,8 @@ const (
|
|||||||
// PromptDebugWriter stores explicitly requested content-rich diagnostics. A
|
// PromptDebugWriter stores explicitly requested content-rich diagnostics. A
|
||||||
// writer created without a root is disabled.
|
// writer created without a root is disabled.
|
||||||
type PromptDebugWriter struct {
|
type PromptDebugWriter struct {
|
||||||
root string
|
root string
|
||||||
|
directory *secureDirectory
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptDebugRef identifies one debug capture directory.
|
// PromptDebugRef identifies one debug capture directory.
|
||||||
@@ -140,14 +140,27 @@ func NewPromptDebugWriter(root string) (*PromptDebugWriter, error) {
|
|||||||
if cleaned == string(filepath.Separator) {
|
if cleaned == string(filepath.Separator) {
|
||||||
return nil, fmt.Errorf("prompt debug root must not be the filesystem root")
|
return nil, fmt.Errorf("prompt debug root must not be the filesystem root")
|
||||||
}
|
}
|
||||||
if err := ensureSecureDirectory(cleaned); err != nil {
|
directory, err := openSecureDirectory(cleaned)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("initialize prompt debug root %q: %w", cleaned, err)
|
return nil, fmt.Errorf("initialize prompt debug root %q: %w", cleaned, err)
|
||||||
}
|
}
|
||||||
return &PromptDebugWriter{root: cleaned}, nil
|
return &PromptDebugWriter{root: cleaned, directory: directory}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *PromptDebugWriter) Enabled() bool {
|
func (w *PromptDebugWriter) Enabled() bool {
|
||||||
return w != nil && w.root != ""
|
return w != nil && w.root != "" && w.directory != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the secure directory handle retained for an enabled writer.
|
||||||
|
// It is safe to call on disabled writers.
|
||||||
|
func (w *PromptDebugWriter) Close() error {
|
||||||
|
if w == nil || w.directory == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
directory := w.directory
|
||||||
|
w.directory = nil
|
||||||
|
w.root = ""
|
||||||
|
return directory.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WritePreparation stores the explicitly captured preparation details and
|
// WritePreparation stores the explicitly captured preparation details and
|
||||||
@@ -156,10 +169,11 @@ func (w *PromptDebugWriter) WritePreparation(ref PromptDebugRef, preparation pro
|
|||||||
if !w.Enabled() {
|
if !w.Enabled() {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
directory, err := w.runDirectory(ref)
|
directory, secureDirectory, err := w.runDirectory(ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
defer secureDirectory.Close()
|
||||||
artifact := PromptPreparationDebugArtifact{
|
artifact := PromptPreparationDebugArtifact{
|
||||||
SchemaVersion: promptPreparationDebugSchemaVersion,
|
SchemaVersion: promptPreparationDebugSchemaVersion,
|
||||||
ReportID: ref.ReportID,
|
ReportID: ref.ReportID,
|
||||||
@@ -177,7 +191,7 @@ func (w *PromptDebugWriter) WritePreparation(ref PromptDebugRef, preparation pro
|
|||||||
}
|
}
|
||||||
artifact.Parameters = parameters
|
artifact.Parameters = parameters
|
||||||
}
|
}
|
||||||
if err := writeSecureJSON(filepath.Join(directory, "preparation.json"), artifact); err != nil {
|
if err := secureDirectory.writeJSON("preparation.json", artifact); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return directory, nil
|
return directory, nil
|
||||||
@@ -189,10 +203,11 @@ func (w *PromptDebugWriter) WriteExecution(ref PromptDebugRef, execution prompte
|
|||||||
if !w.Enabled() {
|
if !w.Enabled() {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
directory, err := w.runDirectory(ref)
|
directory, secureDirectory, err := w.runDirectory(ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
defer secureDirectory.Close()
|
||||||
artifact := PromptExecutionDebugArtifact{
|
artifact := PromptExecutionDebugArtifact{
|
||||||
SchemaVersion: promptExecutionDebugSchemaVersion,
|
SchemaVersion: promptExecutionDebugSchemaVersion,
|
||||||
ReportID: ref.ReportID,
|
ReportID: ref.ReportID,
|
||||||
@@ -208,27 +223,25 @@ func (w *PromptDebugWriter) WriteExecution(ref PromptDebugRef, execution prompte
|
|||||||
}
|
}
|
||||||
artifact.DebugValidationDetails = append([]string(nil), execution.Debug.ValidationDiagnostics...)
|
artifact.DebugValidationDetails = append([]string(nil), execution.Debug.ValidationDiagnostics...)
|
||||||
}
|
}
|
||||||
if err := writeSecureJSON(filepath.Join(directory, "execution.json"), artifact); err != nil {
|
if err := secureDirectory.writeJSON("execution.json", artifact); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return directory, nil
|
return directory, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *PromptDebugWriter) runDirectory(ref PromptDebugRef) (string, error) {
|
func (w *PromptDebugWriter) runDirectory(ref PromptDebugRef) (string, *secureDirectory, error) {
|
||||||
if err := validatePromptDebugRef(ref); err != nil {
|
if err := validatePromptDebugRef(ref); err != nil {
|
||||||
return "", err
|
return "", nil, err
|
||||||
}
|
|
||||||
if err := ensureSecureDirectory(w.root); err != nil {
|
|
||||||
return "", fmt.Errorf("validate prompt debug root %q: %w", w.root, err)
|
|
||||||
}
|
}
|
||||||
directory := filepath.Join(w.root, string(ref.ReportID), ref.ValidDate, ref.RunID)
|
directory := filepath.Join(w.root, string(ref.ReportID), ref.ValidDate, ref.RunID)
|
||||||
if !isWithinDirectory(w.root, directory) {
|
if !isWithinDirectory(w.root, directory) {
|
||||||
return "", fmt.Errorf("prompt debug path escapes root")
|
return "", nil, fmt.Errorf("prompt debug path escapes root")
|
||||||
}
|
}
|
||||||
if err := ensureSecureDirectory(directory); err != nil {
|
secureDirectory, err := w.directory.openDirectory(string(ref.ReportID), ref.ValidDate, ref.RunID)
|
||||||
return "", fmt.Errorf("create prompt debug directory %q: %w", directory, err)
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("create prompt debug directory %q: %w", directory, err)
|
||||||
}
|
}
|
||||||
return directory, nil
|
return directory, secureDirectory, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validatePromptDebugRef(ref PromptDebugRef) error {
|
func validatePromptDebugRef(ref PromptDebugRef) error {
|
||||||
@@ -254,100 +267,11 @@ func validatePromptDebugSegment(name string, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureSecureDirectory(path string) error {
|
|
||||||
if !filepath.IsAbs(path) {
|
|
||||||
return fmt.Errorf("directory must be absolute")
|
|
||||||
}
|
|
||||||
cleaned := filepath.Clean(path)
|
|
||||||
volume := filepath.VolumeName(cleaned)
|
|
||||||
current := volume + string(filepath.Separator)
|
|
||||||
for _, component := range strings.Split(strings.TrimPrefix(cleaned, current), string(filepath.Separator)) {
|
|
||||||
if component == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
current = filepath.Join(current, component)
|
|
||||||
info, err := os.Lstat(current)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
if err := os.Mkdir(current, debugDirectoryMode); err != nil {
|
|
||||||
if !os.IsExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
info, err = os.Lstat(current)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
|
||||||
return fmt.Errorf("directory component %q must not be a symlink", current)
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return fmt.Errorf("directory component %q is not a directory", current)
|
|
||||||
}
|
|
||||||
if err := os.Chmod(current, debugDirectoryMode); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := os.Chmod(current, debugDirectoryMode); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
|
||||||
return fmt.Errorf("directory component %q must not be a symlink", current)
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return fmt.Errorf("directory component %q is not a directory", current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := os.Chmod(cleaned, debugDirectoryMode); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isWithinDirectory(root string, path string) bool {
|
func isWithinDirectory(root string, path string) bool {
|
||||||
relative, err := filepath.Rel(root, path)
|
relative, err := filepath.Rel(root, path)
|
||||||
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
|
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSecureJSON(path string, value any) error {
|
|
||||||
data, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal %q: %w", path, err)
|
|
||||||
}
|
|
||||||
if info, err := os.Lstat(path); err == nil {
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
||||||
return fmt.Errorf("prompt debug file %q is not a regular file", path)
|
|
||||||
}
|
|
||||||
} else if !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
temporary, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
temporaryPath := temporary.Name()
|
|
||||||
defer os.Remove(temporaryPath)
|
|
||||||
if err := temporary.Chmod(debugFileMode); err != nil {
|
|
||||||
temporary.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := temporary.Write(data); err != nil {
|
|
||||||
temporary.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := temporary.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.Rename(temporaryPath, path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func promptDebugPreparation(value promptexec.Preparation) PromptDebugPreparation {
|
func promptDebugPreparation(value promptexec.Preparation) PromptDebugPreparation {
|
||||||
return PromptDebugPreparation{
|
return PromptDebugPreparation{
|
||||||
PromptID: value.PromptID, PromptVersion: value.PromptVersion, PromptHash: value.PromptHash,
|
PromptID: value.PromptID, PromptVersion: value.PromptVersion, PromptHash: value.PromptHash,
|
||||||
|
|||||||
@@ -233,6 +233,48 @@ func TestPromptDebugWriterCreatesSharedMissingAncestorsConcurrently(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPromptDebugWriterKeepsWritesAnchoredToOpenedRoot(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("directory replacement behavior is covered on Unix hosts")
|
||||||
|
}
|
||||||
|
parent := t.TempDir()
|
||||||
|
root := filepath.Join(parent, "debug")
|
||||||
|
writer, err := NewPromptDebugWriter(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
||||||
|
}
|
||||||
|
defer writer.Close()
|
||||||
|
|
||||||
|
anchoredRoot := filepath.Join(parent, "anchored-debug")
|
||||||
|
outside := filepath.Join(parent, "outside")
|
||||||
|
if err := os.Mkdir(outside, debugDirectoryMode); err != nil {
|
||||||
|
t.Fatalf("create outside directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(root, anchoredRoot); err != nil {
|
||||||
|
t.Fatalf("replace opened root: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outside, root); err != nil {
|
||||||
|
t.Skipf("symlink creation is unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := promptDebugRef()
|
||||||
|
ref.RunID = "run-anchored"
|
||||||
|
if _, err := writer.WritePreparation(ref, promptDebugPreparationFixture(), nil); err != nil {
|
||||||
|
t.Fatalf("WritePreparation() error = %v", err)
|
||||||
|
}
|
||||||
|
anchoredArtifact := filepath.Join(anchoredRoot, "daily", "2026-05-29", "run-anchored", "preparation.json")
|
||||||
|
if _, err := os.Stat(anchoredArtifact); err != nil {
|
||||||
|
t.Fatalf("anchored preparation artifact: %v", err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(outside)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read outside directory: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("outside directory received debug artifacts: %#v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPromptDebugWriterDisabledDoesNotAccessFilesystem(t *testing.T) {
|
func TestPromptDebugWriterDisabledDoesNotAccessFilesystem(t *testing.T) {
|
||||||
writer, err := NewPromptDebugWriter("")
|
writer, err := NewPromptDebugWriter("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
143
internal/promptdebug/secure_directory_other.go
Normal file
143
internal/promptdebug/secure_directory_other.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package promptdebug
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type secureDirectory struct {
|
||||||
|
root *os.Root
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSecureDirectory(path string) (*secureDirectory, error) {
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return nil, fmt.Errorf("directory must be absolute")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(path, debugDirectoryMode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("prompt debug root is not a directory")
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, debugDirectoryMode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
root, err := os.OpenRoot(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &secureDirectory{root: root}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (directory *secureDirectory) Close() error {
|
||||||
|
if directory == nil || directory.root == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
root := directory.root
|
||||||
|
directory.root = nil
|
||||||
|
return root.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (directory *secureDirectory) openDirectory(components ...string) (*secureDirectory, error) {
|
||||||
|
if directory == nil || directory.root == nil {
|
||||||
|
return nil, fmt.Errorf("prompt debug directory is closed")
|
||||||
|
}
|
||||||
|
for _, component := range components {
|
||||||
|
if err := validateSecureDirectoryName(component); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := filepath.Join(components...)
|
||||||
|
if err := directory.root.MkdirAll(path, debugDirectoryMode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
child, err := directory.root.OpenRoot(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &secureDirectory{root: child}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSecureDirectoryName(name string) error {
|
||||||
|
if name == "" || name == "." || name == ".." || strings.ContainsRune(name, filepath.Separator) {
|
||||||
|
return fmt.Errorf("prompt debug directory component %q is invalid", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (directory *secureDirectory) writeJSON(name string, value any) error {
|
||||||
|
if directory == nil || directory.root == nil {
|
||||||
|
return fmt.Errorf("prompt debug directory is closed")
|
||||||
|
}
|
||||||
|
if err := validateSecureDirectoryName(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal prompt debug artifact: %w", err)
|
||||||
|
}
|
||||||
|
if info, err := directory.root.Lstat(name); err == nil {
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("prompt debug file %q is not a regular file", name)
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temporaryName, temporary, err := directory.createTemporaryFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if temporary != nil {
|
||||||
|
_ = temporary.Close()
|
||||||
|
}
|
||||||
|
_ = directory.root.Remove(temporaryName)
|
||||||
|
}()
|
||||||
|
if err := temporary.Chmod(debugFileMode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if written, err := temporary.Write(data); err != nil {
|
||||||
|
return err
|
||||||
|
} else if written != len(data) {
|
||||||
|
return io.ErrShortWrite
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temporary = nil
|
||||||
|
if err := directory.root.Rename(temporaryName, name); err != nil {
|
||||||
|
return fmt.Errorf("replace prompt debug file %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (directory *secureDirectory) createTemporaryFile(name string) (string, *os.File, error) {
|
||||||
|
for attempt := 0; attempt < 16; attempt++ {
|
||||||
|
random := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(random); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
temporaryName := "." + name + "." + hex.EncodeToString(random) + ".tmp"
|
||||||
|
temporary, err := directory.root.OpenFile(temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, debugFileMode)
|
||||||
|
if os.IsExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return temporaryName, temporary, nil
|
||||||
|
}
|
||||||
|
return "", nil, fmt.Errorf("create temporary prompt debug file: too many name collisions")
|
||||||
|
}
|
||||||
196
internal/promptdebug/secure_directory_unix.go
Normal file
196
internal/promptdebug/secure_directory_unix.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package promptdebug
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// secureDirectory is an opened directory descriptor. Every operation remains
|
||||||
|
// relative to that descriptor so later pathname swaps cannot redirect writes.
|
||||||
|
type secureDirectory struct {
|
||||||
|
fd int
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSecureDirectory(path string) (*secureDirectory, error) {
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return nil, fmt.Errorf("directory must be absolute")
|
||||||
|
}
|
||||||
|
cleaned := filepath.Clean(path)
|
||||||
|
current, err := unix.Open(string(filepath.Separator), unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if current != -1 {
|
||||||
|
_ = unix.Close(current)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for _, component := range strings.Split(strings.TrimPrefix(cleaned, string(filepath.Separator)), string(filepath.Separator)) {
|
||||||
|
if component == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next, created, err := openOrCreateSecureDirectory(current, component)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
if err := unix.Fchmod(next, debugDirectoryMode); err != nil {
|
||||||
|
_ = unix.Close(next)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = unix.Close(current)
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
if err := unix.Fchmod(current, debugDirectoryMode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := &secureDirectory{fd: current}
|
||||||
|
current = -1
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (directory *secureDirectory) Close() error {
|
||||||
|
if directory == nil || directory.fd < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fd := directory.fd
|
||||||
|
directory.fd = -1
|
||||||
|
return unix.Close(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (directory *secureDirectory) openDirectory(components ...string) (*secureDirectory, error) {
|
||||||
|
if directory == nil || directory.fd < 0 {
|
||||||
|
return nil, fmt.Errorf("prompt debug directory is closed")
|
||||||
|
}
|
||||||
|
current := directory.fd
|
||||||
|
owned := false
|
||||||
|
defer func() {
|
||||||
|
if owned {
|
||||||
|
_ = unix.Close(current)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for _, component := range components {
|
||||||
|
if err := validateSecureDirectoryName(component); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
next, _, err := openOrCreateSecureDirectory(current, component)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := unix.Fchmod(next, debugDirectoryMode); err != nil {
|
||||||
|
_ = unix.Close(next)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if owned {
|
||||||
|
_ = unix.Close(current)
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
owned = true
|
||||||
|
}
|
||||||
|
result := &secureDirectory{fd: current}
|
||||||
|
owned = false
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openOrCreateSecureDirectory(parent int, name string) (int, bool, error) {
|
||||||
|
fd, err := unix.Openat(parent, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||||
|
if err == nil {
|
||||||
|
return fd, false, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, unix.ENOENT) {
|
||||||
|
return -1, false, fmt.Errorf("open secure directory %q: %w", name, err)
|
||||||
|
}
|
||||||
|
if err := unix.Mkdirat(parent, name, debugDirectoryMode); err != nil && !errors.Is(err, unix.EEXIST) {
|
||||||
|
return -1, false, fmt.Errorf("create secure directory %q: %w", name, err)
|
||||||
|
}
|
||||||
|
fd, err = unix.Openat(parent, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||||
|
if err != nil {
|
||||||
|
return -1, false, fmt.Errorf("open created secure directory %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return fd, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSecureDirectoryName(name string) error {
|
||||||
|
if name == "" || name == "." || name == ".." || strings.ContainsRune(name, filepath.Separator) {
|
||||||
|
return fmt.Errorf("prompt debug directory component %q is invalid", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (directory *secureDirectory) writeJSON(name string, value any) error {
|
||||||
|
if directory == nil || directory.fd < 0 {
|
||||||
|
return fmt.Errorf("prompt debug directory is closed")
|
||||||
|
}
|
||||||
|
if err := validateSecureDirectoryName(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal prompt debug artifact: %w", err)
|
||||||
|
}
|
||||||
|
var information unix.Stat_t
|
||||||
|
if err := unix.Fstatat(directory.fd, name, &information, unix.AT_SYMLINK_NOFOLLOW); err == nil {
|
||||||
|
if information.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||||
|
return fmt.Errorf("prompt debug file %q is not a regular file", name)
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, unix.ENOENT) {
|
||||||
|
return fmt.Errorf("inspect prompt debug file %q: %w", name, err)
|
||||||
|
}
|
||||||
|
temporaryName, temporary, err := directory.createTemporaryFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if temporary != nil {
|
||||||
|
_ = temporary.Close()
|
||||||
|
}
|
||||||
|
_ = unix.Unlinkat(directory.fd, temporaryName, 0)
|
||||||
|
}()
|
||||||
|
if err := temporary.Chmod(debugFileMode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if written, err := temporary.Write(data); err != nil {
|
||||||
|
return err
|
||||||
|
} else if written != len(data) {
|
||||||
|
return io.ErrShortWrite
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temporary = nil
|
||||||
|
if err := unix.Renameat(directory.fd, temporaryName, directory.fd, name); err != nil {
|
||||||
|
return fmt.Errorf("replace prompt debug file %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (directory *secureDirectory) createTemporaryFile(name string) (string, *os.File, error) {
|
||||||
|
for attempt := 0; attempt < 16; attempt++ {
|
||||||
|
random := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(random); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
temporaryName := "." + name + "." + hex.EncodeToString(random) + ".tmp"
|
||||||
|
fd, err := unix.Openat(directory.fd, temporaryName, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, debugFileMode)
|
||||||
|
if errors.Is(err, unix.EEXIST) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("create temporary prompt debug file: %w", err)
|
||||||
|
}
|
||||||
|
return temporaryName, os.NewFile(uintptr(fd), temporaryName), nil
|
||||||
|
}
|
||||||
|
return "", nil, fmt.Errorf("create temporary prompt debug file: too many name collisions")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user