Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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/<name>). 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/<name>) and the resolved config dir
// (GetConfigDir()/<name>) 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
Expand Down
42 changes: 42 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}