diff --git a/config/config.go b/config/config.go index 98a3e578..922ee5a2 100644 --- a/config/config.go +++ b/config/config.go @@ -1237,9 +1237,12 @@ func (c *Config) ValidatePathInSandbox(path string) error { // The skills carve-out is gated on agent.skills.enabled: when skills are off // (the default) the directory is not allowed and falls through to the - // .infer/ protected-path check below. The tmp/plans carve-out stays unconditional. + // .infer/ protected-path check below. The tmp/plans carve-out stays + // unconditional and checks both the project-relative ConfigDirName and the + // resolved config dir (GetConfigDir) so plans written to the userspace + // ~/.infer/plans stay readable when the config was loaded from there. carveOut := (c.Agent.Skills.Enabled && isWithinSkillsDir(absPath)) || - isWithinConfigSubdir(absPath, "tmp", "plans") || + c.isWithinConfigSubdir(absPath, "tmp", "plans") || isWithinMemoryDir(absPath, c.Memory) if err := c.checkProtectedPaths(path, carveOut); err != nil { @@ -1311,17 +1314,26 @@ func isWithinSkillsDir(absPath string) bool { } // isWithinConfigSubdir reports whether absPath lives inside one of the named -// subdirectories of the project config dir (./.infer/). These are -// operational areas - tmp scratch, persisted plans - that stay reachable even -// though the rest of .infer/ is protected as a whole. -func isWithinConfigSubdir(absPath string, names ...string) bool { +// subdirectories of the config dir. It checks both the project-relative +// ConfigDirName (./.infer/) and the resolved config dir +// (GetConfigDir()/) so that operational areas - tmp scratch, persisted +// plans - stay reachable even when the config was loaded from the userspace +// location (~/.infer). This keeps the rest of .infer/ protected as a whole. +func (c *Config) isWithinConfigSubdir(absPath string, names ...string) bool { + configDirs := []string{ConfigDirName} + if resolved := c.GetConfigDir(); resolved != "" && resolved != ConfigDirName { + configDirs = append(configDirs, resolved) + } + for _, name := range names { - dir, err := filepath.Abs(filepath.Join(ConfigDirName, name)) - if err != nil { - continue - } - if absPath == dir || strings.HasPrefix(absPath, dir+string(filepath.Separator)) { - return true + for _, base := range configDirs { + dir, err := filepath.Abs(filepath.Join(base, name)) + if err != nil { + continue + } + if absPath == dir || strings.HasPrefix(absPath, dir+string(filepath.Separator)) { + return true + } } } return false diff --git a/config/config_test.go b/config/config_test.go index fa189cf4..403ec8ef 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1048,3 +1048,45 @@ func TestValidatePathInSandbox_ConfigDir(t *testing.T) { }) } } + +// TestValidatePathInSandbox_ConfigDirUserspace locks in that the tmp/plans +// carve-out also covers the resolved userspace config dir (~/.infer). When the +// config is loaded from the userspace location, GetConfigDir() returns an +// absolute home path and plans are written there - the sandbox must still +// allow the agent to read them back. +func TestValidatePathInSandbox_ConfigDirUserspace(t *testing.T) { + cfg := DefaultConfig() + + homeDir, err := os.UserHomeDir() + if err != nil { + t.Skipf("cannot determine home dir: %v", err) + } + + userspaceConfigDir := filepath.Join(homeDir, ConfigDirName) + cfg.SetConfigDir(userspaceConfigDir) + + allowed := []string{ + filepath.Join(userspaceConfigDir, "tmp", "scratch.txt"), + filepath.Join(userspaceConfigDir, "plans", "2026-06-01-do-thing.md"), + } + for _, p := range allowed { + t.Run("allow "+p, func(t *testing.T) { + if err := cfg.ValidatePathInSandbox(p); err != nil { + t.Fatalf("expected %s allowed, got %v", p, err) + } + }) + } + + denied := []string{ + filepath.Join(userspaceConfigDir, "config.yaml"), + filepath.Join(userspaceConfigDir, "agents.yaml"), + filepath.Join(userspaceConfigDir, "tmp", "leaked.env"), + } + for _, p := range denied { + t.Run("deny "+p, func(t *testing.T) { + if err := cfg.ValidatePathInSandbox(p); err == nil { + t.Fatalf("expected %s to be denied", p) + } + }) + } +}