Merge pull request #2885 from charmbracelet/harden-directory-bounds

Christian Rocha created

chore: harden directory bounds

Change summary

internal/config/config.go     |  18 ++-
internal/config/load.go       |  53 ++++++++++
internal/config/load_test.go  | 185 +++++++++++++++++++++++++++++++++++++
internal/fsext/lookup.go      | 140 ++++++++++++++++++++++++++++
internal/fsext/lookup_test.go | 125 +++++++++++++++++++++++++
internal/swagger/docs.go      |   2 
internal/swagger/swagger.json |   2 
internal/swagger/swagger.yaml |   6 +
schema.json                   |   2 
9 files changed, 519 insertions(+), 14 deletions(-)

Detailed changes

internal/config/config.go 🔗

@@ -259,13 +259,17 @@ func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) {
 }
 
 type Options struct {
-	ContextPaths              []string     `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
-	SkillsPaths               []string     `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"`
-	TUI                       *TUIOptions  `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
-	Debug                     bool         `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
-	DebugLSP                  bool         `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`
-	DisableAutoSummarize      bool         `json:"disable_auto_summarize,omitempty" jsonschema:"description=Disable automatic conversation summarization,default=false"`
-	DataDirectory             string       `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd
+	ContextPaths         []string    `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
+	SkillsPaths          []string    `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"`
+	TUI                  *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
+	Debug                bool        `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`
+	DebugLSP             bool        `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`
+	DisableAutoSummarize bool        `json:"disable_auto_summarize,omitempty" jsonschema:"description=Disable automatic conversation summarization,default=false"`
+	// DataDirectory is where Crush keeps per-project state such as
+	// the SQLite database and workspace overrides. Relative paths are
+	// resolved against the working directory; absolute paths are used
+	// verbatim. After defaulting the stored value is always absolute.
+	DataDirectory             string       `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data. Relative paths are resolved against the working directory; absolute paths are used as-is.,default=.crush,example=.crush"`
 	DisabledTools             []string     `json:"disabled_tools,omitempty" jsonschema:"description=List of built-in tools to disable and hide from the agent,example=bash,example=sourcegraph"`
 	DisableProviderAutoUpdate bool         `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
 	DisableDefaultProviders   bool         `json:"disable_default_providers,omitempty" jsonschema:"description=Ignore all default/embedded providers. When enabled\\, providers must be fully specified in the config file with base_url\\, models\\, and api_key - no merging with defaults occurs,default=false"`

internal/config/load.go 🔗

@@ -424,7 +424,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 	if dataDir != "" {
 		c.Options.DataDirectory = dataDir
 	} else if c.Options.DataDirectory == "" {
-		if path, ok := fsext.LookupClosest(workingDir, defaultDataDirectory); ok {
+		if path, ok := fsext.LookupClosestBounded(workingDir, projectBoundary(workingDir), defaultDataDirectory); ok {
 			c.Options.DataDirectory = path
 		} else {
 			c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
@@ -702,7 +702,12 @@ func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provid
 	return nil
 }
 
-// lookupConfigs searches config files recursively from CWD up to FS root
+// lookupConfigs searches config files starting at cwd and walking up
+// through the current project. The upward walk stops at the git
+// working tree root when one can be detected, otherwise at cwd itself,
+// so an unrelated crush.json placed above the project is never picked
+// up. Global user-level config locations are always included
+// regardless of the boundary.
 func lookupConfigs(cwd string) []string {
 	// prepend default config paths
 	configPaths := []string{
@@ -712,7 +717,7 @@ func lookupConfigs(cwd string) []string {
 
 	configNames := []string{appName + ".json", "." + appName + ".json"}
 
-	foundConfigs, err := fsext.Lookup(cwd, configNames...)
+	foundConfigs, err := fsext.LookupBounded(cwd, projectBoundary(cwd), configNames...)
 	if err != nil {
 		// returns at least default configs
 		return configPaths
@@ -873,6 +878,48 @@ func isInsideWorktree() bool {
 	return err == nil && strings.TrimSpace(string(bts)) == "true"
 }
 
+// worktreeRoot returns the absolute path of the git working tree root for
+// dir, or the empty string if dir is not inside a working tree (bare
+// repositories, missing git binary, plain directories, or any other
+// failure mode). Linked worktrees and submodules each report their own
+// top-level, which is what callers want when bounding lookups.
+func worktreeRoot(dir string) string {
+	cmd := exec.CommandContext(
+		context.Background(),
+		"git", "rev-parse", "--show-toplevel",
+	)
+	cmd.Dir = dir
+	out, err := cmd.Output()
+	if err != nil {
+		return ""
+	}
+	root := strings.TrimSpace(string(out))
+	if root == "" {
+		return ""
+	}
+	abs, err := filepath.Abs(root)
+	if err != nil {
+		return ""
+	}
+	return abs
+}
+
+// projectBoundary returns the directory at which an upward configuration
+// search rooted at dir should stop. It is the git working tree root when
+// one can be detected, otherwise dir itself. Returning dir as a
+// fallback keeps Crush from silently adopting state files placed above
+// the current project.
+func projectBoundary(dir string) string {
+	if root := worktreeRoot(dir); root != "" {
+		return root
+	}
+	abs, err := filepath.Abs(dir)
+	if err != nil {
+		return dir
+	}
+	return abs
+}
+
 // GlobalSkillsDirs returns the default directories for Agent Skills.
 // Skills in these directories are auto-discovered and their files can be read
 // without permission prompts.

internal/config/load_test.go 🔗

@@ -4,6 +4,7 @@ import (
 	"io"
 	"log/slog"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"testing"
 
@@ -36,6 +37,98 @@ func TestConfig_LoadFromBytes(t *testing.T) {
 	require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
 }
 
+func TestLookupConfigs_BoundedByProject(t *testing.T) {
+	// Force GlobalConfig and GlobalConfigData to point at locations we
+	// control so they can be present in the result without polluting
+	// the developer's real config.
+	globalDir := t.TempDir()
+	t.Setenv("CRUSH_GLOBAL_CONFIG", globalDir)
+	t.Setenv("CRUSH_GLOBAL_DATA", globalDir)
+
+	t.Run("does not pick up crush.json above non-git project", func(t *testing.T) {
+		parent := t.TempDir()
+
+		// crush.json above the project must not be adopted.
+		require.NoError(t, os.WriteFile(
+			filepath.Join(parent, "crush.json"),
+			[]byte(`{}`),
+			0o644,
+		))
+
+		project := filepath.Join(parent, "project")
+		require.NoError(t, os.Mkdir(project, 0o755))
+
+		got := lookupConfigs(project)
+		for _, p := range got {
+			require.NotEqual(t, filepath.Join(parent, "crush.json"), p)
+		}
+	})
+
+	t.Run("does not climb out of git worktree to find crush.json", func(t *testing.T) {
+		if _, err := exec.LookPath("git"); err != nil {
+			t.Skip("git not available")
+		}
+
+		parent := t.TempDir()
+
+		require.NoError(t, os.WriteFile(
+			filepath.Join(parent, "crush.json"),
+			[]byte(`{}`),
+			0o644,
+		))
+
+		worktree := filepath.Join(parent, "worktree")
+		require.NoError(t, os.Mkdir(worktree, 0o755))
+		gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
+		gitInit.Dir = worktree
+		require.NoError(t, gitInit.Run())
+
+		got := lookupConfigs(worktree)
+		strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, "crush.json"))
+		require.NoError(t, err)
+		for _, p := range got {
+			pEval, err := filepath.EvalSymlinks(p)
+			if err != nil {
+				continue
+			}
+			require.NotEqual(t, strayEval, pEval, "must not adopt parent crush.json")
+		}
+	})
+
+	t.Run("picks up crush.json inside the project", func(t *testing.T) {
+		project := t.TempDir()
+		local := filepath.Join(project, "crush.json")
+		require.NoError(t, os.WriteFile(local, []byte(`{}`), 0o644))
+
+		got := lookupConfigs(project)
+
+		localEval, err := filepath.EvalSymlinks(local)
+		require.NoError(t, err)
+		var foundLocal bool
+		for _, p := range got {
+			pEval, err := filepath.EvalSymlinks(p)
+			if err != nil {
+				continue
+			}
+			if pEval == localEval {
+				foundLocal = true
+				break
+			}
+		}
+		require.True(t, foundLocal, "expected project crush.json to be in lookup result: %v", got)
+	})
+
+	t.Run("global config is always included regardless of boundary", func(t *testing.T) {
+		project := t.TempDir()
+
+		got := lookupConfigs(project)
+		// Global config and global data path are always prepended,
+		// even when no project file exists.
+		require.Contains(t, got, GlobalConfig())
+		require.Contains(t, got, GlobalConfigData())
+	})
+}
+
 func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) {
 	t.Parallel()
 
@@ -111,6 +204,98 @@ func TestConfig_setDefaults(t *testing.T) {
 
 		require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
 	})
+
+	t.Run("preserves absolute configured data directory", func(t *testing.T) {
+		// Use a platform-appropriate absolute path so the test runs
+		// the same way on POSIX and Windows.
+		absDir := filepath.Join(t.TempDir(), "data")
+		cfg := &Config{Options: &Options{DataDirectory: absDir}}
+
+		cfg.setDefaults(filepath.Join(t.TempDir(), "worktree"), "")
+
+		require.Equal(t, absDir, cfg.Options.DataDirectory)
+	})
+
+	t.Run("workspace merge re-entry keeps an absolute data directory", func(t *testing.T) {
+		// Simulate the load and reload paths: defaults are applied
+		// twice with the data directory potentially carried through
+		// from an earlier merge as a relative string.
+		workingDir := filepath.Join(t.TempDir(), "worktree")
+		cfg := &Config{}
+		cfg.setDefaults(workingDir, "")
+
+		// Workspace JSON sets data_directory to a relative value; the
+		// merge replaces the struct, then setDefaults runs again.
+		cfg.Options.DataDirectory = "./state"
+		cfg.setDefaults(workingDir, "")
+
+		require.True(t, filepath.IsAbs(cfg.Options.DataDirectory),
+			"data directory must remain absolute after re-merge, got %q",
+			cfg.Options.DataDirectory)
+		require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
+	})
+
+	t.Run("does not adopt .crush from a parent project", func(t *testing.T) {
+		parent := t.TempDir()
+
+		// .crush in the parent: it should not be reused by the child
+		// because there is no git context joining them.
+		require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
+
+		child := filepath.Join(parent, "child")
+		require.NoError(t, os.Mkdir(child, 0o755))
+
+		cfg := &Config{}
+		cfg.setDefaults(child, "")
+
+		require.Equal(t,
+			filepath.Clean(filepath.Join(child, defaultDataDirectory)),
+			filepath.Clean(cfg.Options.DataDirectory),
+		)
+	})
+
+	t.Run("does not climb out of git worktree to find .crush", func(t *testing.T) {
+		if _, err := exec.LookPath("git"); err != nil {
+			t.Skip("git not available")
+		}
+
+		parent := t.TempDir()
+
+		// Stray .crush above the worktree root.
+		require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
+
+		worktree := filepath.Join(parent, "worktree")
+		require.NoError(t, os.Mkdir(worktree, 0o755))
+
+		sub := filepath.Join(worktree, "pkg")
+		require.NoError(t, os.Mkdir(sub, 0o755))
+
+		// Make worktree a real git repo so the boundary detection
+		// resolves to it, mirroring what happens with linked worktrees
+		// in real usage.
+		gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
+		gitInit.Dir = worktree
+		require.NoError(t, gitInit.Run())
+
+		cfg := &Config{}
+		cfg.setDefaults(sub, "")
+
+		// Resolve symlinks because TempDir on macOS sits under /var
+		// which is a symlink to /private/var. The data directory has
+		// not been created yet, so resolve its parent and join.
+		gotDir, gotName := filepath.Split(cfg.Options.DataDirectory)
+		gotEvalDir, err := filepath.EvalSymlinks(filepath.Clean(gotDir))
+		require.NoError(t, err)
+		gotEval := filepath.Join(gotEvalDir, gotName)
+
+		strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, defaultDataDirectory))
+		require.NoError(t, err)
+		require.NotEqual(t, strayEval, gotEval, "must not adopt parent .crush")
+
+		subEval, err := filepath.EvalSymlinks(sub)
+		require.NoError(t, err)
+		require.Equal(t, filepath.Join(subEval, defaultDataDirectory), gotEval)
+	})
 }
 
 func TestConfig_configureProviders(t *testing.T) {

internal/fsext/lookup.go 🔗

@@ -82,6 +82,81 @@ func LookupClosest(dir, target string) (string, bool) {
 	return found, err == nil && found != ""
 }
 
+// LookupClosestBounded behaves like LookupClosest but constrains the
+// upward search to stopDir. The walk inspects dir, then each ancestor up
+// to and including stopDir, then terminates regardless of whether the
+// target was found. Use this when the caller wants to avoid adopting
+// matches from outside a project boundary (for example a sibling
+// worktree or a parent project).
+//
+// If stopDir is empty, only dir itself is searched. If stopDir is not an
+// ancestor of dir, the walk still terminates at the filesystem root.
+// The $HOME and ownership safeguards from LookupClosest are preserved
+// as outer bounds.
+func LookupClosestBounded(dir, stopDir, target string) (string, bool) {
+	var found string
+
+	err := traverseUpBounded(dir, stopDir, func(cwd string, owner int) error {
+		fpath := filepath.Join(cwd, target)
+
+		err := probeEnt(fpath, owner)
+		if errors.Is(err, os.ErrNotExist) {
+			return nil
+		}
+
+		if err != nil {
+			return fmt.Errorf("error probing file %s: %w", fpath, err)
+		}
+
+		if cwd == home.Dir() {
+			return filepath.SkipAll
+		}
+
+		found = fpath
+		return filepath.SkipAll
+	})
+
+	return found, err == nil && found != ""
+}
+
+// LookupBounded behaves like Lookup but constrains the upward search to
+// stopDir. The walk inspects dir, then each ancestor up to and including
+// stopDir, then terminates. If stopDir is empty, only dir itself is
+// searched.
+func LookupBounded(dir, stopDir string, targets ...string) ([]string, error) {
+	if len(targets) == 0 {
+		return nil, nil
+	}
+
+	var found []string
+
+	err := traverseUpBounded(dir, stopDir, func(cwd string, owner int) error {
+		for _, target := range targets {
+			fpath := filepath.Join(cwd, target)
+			err := probeEnt(fpath, owner)
+
+			// skip to the next file on permission denied
+			if errors.Is(err, os.ErrNotExist) ||
+				errors.Is(err, os.ErrPermission) {
+				continue
+			}
+
+			if err != nil {
+				return fmt.Errorf("error probing file %s: %w", fpath, err)
+			}
+
+			found = append(found, fpath)
+		}
+
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return found, nil
+}
+
 // traverseUp walks up from given directory up until filesystem root reached.
 // It passes absolute path of current directory and staring directory owner ID
 // to callback function. It is up to user to check ownership.
@@ -116,6 +191,71 @@ func traverseUp(dir string, walkFn func(dir string, owner int) error) error {
 	}
 }
 
+// traverseUpBounded walks up from dir, visiting each ancestor up to and
+// including stopDir, then terminates. If stopDir is empty, only dir
+// itself is visited; callers that want an unbounded walk should use
+// traverseUp instead. If stopDir is set but is not an ancestor of dir
+// the walk still stops at the filesystem root, so callers cannot
+// accidentally produce an infinite walk by passing a sibling path.
+//
+// Boundary comparison is performed against symlink-resolved paths so
+// that callers passing logically equivalent paths (a symlinked /var vs
+// the underlying /private/var, for example) still terminate at the
+// expected directory.
+func traverseUpBounded(dir, stopDir string, walkFn func(dir string, owner int) error) error {
+	cwd, err := filepath.Abs(dir)
+	if err != nil {
+		return fmt.Errorf("cannot convert CWD to absolute path: %w", err)
+	}
+
+	stop := cwd
+	if stopDir != "" {
+		stop, err = filepath.Abs(stopDir)
+		if err != nil {
+			return fmt.Errorf("cannot convert stop dir to absolute path: %w", err)
+		}
+	}
+	canonStop := canonicalize(stop)
+
+	owner, err := Owner(dir)
+	if err != nil {
+		return fmt.Errorf("cannot get ownership: %w", err)
+	}
+
+	for {
+		err := walkFn(cwd, owner)
+		if err == nil || errors.Is(err, filepath.SkipDir) {
+			if canonicalize(cwd) == canonStop {
+				return nil
+			}
+
+			parent := filepath.Dir(cwd)
+			if parent == cwd {
+				return nil
+			}
+
+			cwd = parent
+			continue
+		}
+
+		if errors.Is(err, filepath.SkipAll) {
+			return nil
+		}
+
+		return err
+	}
+}
+
+// canonicalize resolves any symbolic links in path. If resolution fails
+// (typically because path does not exist yet) the original path is
+// returned cleaned, so callers can still perform stable equality checks.
+func canonicalize(path string) string {
+	if resolved, err := filepath.EvalSymlinks(path); err == nil {
+		return resolved
+	}
+	return filepath.Clean(path)
+}
+
 // probeEnt checks if entity at given path exists and belongs to given owner
 func probeEnt(fspath string, owner int) error {
 	_, err := os.Stat(fspath)

internal/fsext/lookup_test.go 🔗

@@ -353,6 +353,131 @@ func TestLookup(t *testing.T) {
 	})
 }
 
+func TestLookupClosestBounded(t *testing.T) {
+	t.Run("found in starting directory", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		targetFile := filepath.Join(testDir, "target.txt")
+		require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644))
+
+		foundPath, found := LookupClosestBounded(testDir, testDir, "target.txt")
+		require.True(t, found)
+		require.Equal(t, targetFile, foundPath)
+	})
+
+	t.Run("found at boundary directory", func(t *testing.T) {
+		boundary := t.TempDir()
+
+		subDir := filepath.Join(boundary, "subdir")
+		require.NoError(t, os.Mkdir(subDir, 0o755))
+
+		targetFile := filepath.Join(boundary, "target.txt")
+		require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644))
+
+		foundPath, found := LookupClosestBounded(subDir, boundary, "target.txt")
+		require.True(t, found)
+		require.Equal(t, targetFile, foundPath)
+	})
+
+	t.Run("does not climb past boundary", func(t *testing.T) {
+		parent := t.TempDir()
+
+		// Target lives above the boundary.
+		require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("test"), 0o644))
+
+		boundary := filepath.Join(parent, "project")
+		require.NoError(t, os.Mkdir(boundary, 0o755))
+
+		subDir := filepath.Join(boundary, "subdir")
+		require.NoError(t, os.Mkdir(subDir, 0o755))
+
+		foundPath, found := LookupClosestBounded(subDir, boundary, "target.txt")
+		require.False(t, found)
+		require.Empty(t, foundPath)
+	})
+
+	t.Run("empty boundary searches only starting directory", func(t *testing.T) {
+		parent := t.TempDir()
+
+		require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("test"), 0o644))
+
+		subDir := filepath.Join(parent, "subdir")
+		require.NoError(t, os.Mkdir(subDir, 0o755))
+
+		foundPath, found := LookupClosestBounded(subDir, "", "target.txt")
+		require.False(t, found)
+		require.Empty(t, foundPath)
+	})
+
+	t.Run("empty boundary still finds in starting directory", func(t *testing.T) {
+		testDir := t.TempDir()
+
+		targetFile := filepath.Join(testDir, "target.txt")
+		require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644))
+
+		foundPath, found := LookupClosestBounded(testDir, "", "target.txt")
+		require.True(t, found)
+		require.Equal(t, targetFile, foundPath)
+	})
+}
+
+func TestLookupBounded(t *testing.T) {
+	t.Run("returns matches at and below boundary", func(t *testing.T) {
+		boundary := t.TempDir()
+
+		subDir := filepath.Join(boundary, "subdir")
+		require.NoError(t, os.Mkdir(subDir, 0o755))
+
+		atBoundary := filepath.Join(boundary, "target.txt")
+		atSub := filepath.Join(subDir, "target.txt")
+		require.NoError(t, os.WriteFile(atBoundary, []byte("a"), 0o644))
+		require.NoError(t, os.WriteFile(atSub, []byte("b"), 0o644))
+
+		found, err := LookupBounded(subDir, boundary, "target.txt")
+		require.NoError(t, err)
+		require.Len(t, found, 2)
+		require.Contains(t, found, atBoundary)
+		require.Contains(t, found, atSub)
+	})
+
+	t.Run("ignores matches above boundary", func(t *testing.T) {
+		parent := t.TempDir()
+
+		require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("nope"), 0o644))
+
+		boundary := filepath.Join(parent, "project")
+		require.NoError(t, os.Mkdir(boundary, 0o755))
+
+		subDir := filepath.Join(boundary, "subdir")
+		require.NoError(t, os.Mkdir(subDir, 0o755))
+
+		// Target lives only above the boundary.
+		found, err := LookupBounded(subDir, boundary, "target.txt")
+		require.NoError(t, err)
+		require.Empty(t, found)
+	})
+
+	t.Run("empty boundary searches only starting directory", func(t *testing.T) {
+		parent := t.TempDir()
+
+		require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("nope"), 0o644))
+
+		subDir := filepath.Join(parent, "subdir")
+		require.NoError(t, os.Mkdir(subDir, 0o755))
+
+		found, err := LookupBounded(subDir, "", "target.txt")
+		require.NoError(t, err)
+		require.Empty(t, found)
+	})
+
+	t.Run("no targets returns nil", func(t *testing.T) {
+		dir := t.TempDir()
+		found, err := LookupBounded(dir, dir)
+		require.NoError(t, err)
+		require.Empty(t, found)
+	})
+}
+
 func TestProbeEnt(t *testing.T) {
 	t.Run("existing file with correct owner", func(t *testing.T) {
 		tempDir := t.TempDir()

internal/swagger/docs.go 🔗

@@ -2939,7 +2939,7 @@ const docTemplate = `{
                     }
                 },
                 "data_directory": {
-                    "description": "Relative to the cwd",
+                    "description": "DataDirectory is where Crush keeps per-project state such as the SQLite database and workspace overrides. Relative paths are resolved against the working directory; absolute paths are used verbatim. After defaulting the stored value is always absolute.",
                     "type": "string"
                 },
                 "debug": {

internal/swagger/swagger.json 🔗

@@ -2932,7 +2932,7 @@
                     }
                 },
                 "data_directory": {
-                    "description": "Relative to the cwd",
+                    "description": "DataDirectory is where Crush keeps per-project state such as the SQLite database and workspace overrides. Relative paths are resolved against the working directory; absolute paths are used verbatim. After defaulting the stored value is always absolute.",
                     "type": "string"
                 },
                 "debug": {

internal/swagger/swagger.yaml 🔗

@@ -287,7 +287,11 @@ definitions:
           type: string
         type: array
       data_directory:
-        description: Relative to the cwd
+        description: |-
+          DataDirectory is where Crush keeps per-project state such as the SQLite
+          database and workspace overrides. Relative paths are resolved against
+          the working directory; absolute paths are used verbatim. After
+          defaulting the stored value is always absolute.
         type: string
       debug:
         type: boolean

schema.json 🔗

@@ -423,7 +423,7 @@
         },
         "data_directory": {
           "type": "string",
-          "description": "Directory for storing application data (relative to working directory)",
+          "description": "Directory for storing application data. Relative paths are resolved against the working directory; absolute paths are used as-is.",
           "default": ".crush",
           "examples": [
             ".crush"