Skip to content

Commit d1c1be7

Browse files
authored
fix: allow agent to read plans from userspace config dir (#748)
## Summary Fixes #746 When the config is loaded from the userspace location (`~/.infer`), `GetConfigDir()` returns an absolute home path and plans are written to `~/.infer/plans/`. However, the sandbox carve-out in `isWithinConfigSubdir` only checked the project-relative `ConfigDirName` (`./.infer/plans`), so the agent's Read tool rejected the plan path with a sandbox violation — the agent could write the plan but couldn't read it back. ## Root Cause `isWithinConfigSubdir` was a free function that hardcoded `ConfigDirName` (`.infer`) to build the carve-out paths. It did not account for the resolved config dir (`GetConfigDir()`), which can be the userspace `~/.infer` when the config is loaded from there. ## Fix Convert `isWithinConfigSubdir` from a free function to a method on `Config` so it can access `GetConfigDir()`. It now checks both: - The project-relative `ConfigDirName` (`./.infer/<name>`) - The resolved config dir (`GetConfigDir()/<name>`) This keeps the rest of `.infer/` protected while allowing the operational `tmp/plans` subdirs to be read regardless of which config dir was resolved. File-level protections (e.g. `*.env`, `.git/`) still apply within the carve-out. ## Testing - Added `TestValidatePathInSandbox_ConfigDirUserspace` which verifies that `~/.infer/plans/` and `~/.infer/tmp/` paths are allowed when `configDir` is set to the userspace location, while sensitive files (`config.yaml`, `agents.yaml`, `*.env`) remain denied. - All existing sandbox tests continue to pass. - Pre-commit hooks (lint, format, etc.) pass.
1 parent 164ffaf commit d1c1be7

2 files changed

Lines changed: 66 additions & 12 deletions

File tree

config/config.go

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,9 +1237,12 @@ func (c *Config) ValidatePathInSandbox(path string) error {
12371237

12381238
// The skills carve-out is gated on agent.skills.enabled: when skills are off
12391239
// (the default) the directory is not allowed and falls through to the
1240-
// .infer/ protected-path check below. The tmp/plans carve-out stays unconditional.
1240+
// .infer/ protected-path check below. The tmp/plans carve-out stays
1241+
// unconditional and checks both the project-relative ConfigDirName and the
1242+
// resolved config dir (GetConfigDir) so plans written to the userspace
1243+
// ~/.infer/plans stay readable when the config was loaded from there.
12411244
carveOut := (c.Agent.Skills.Enabled && isWithinSkillsDir(absPath)) ||
1242-
isWithinConfigSubdir(absPath, "tmp", "plans") ||
1245+
c.isWithinConfigSubdir(absPath, "tmp", "plans") ||
12431246
isWithinMemoryDir(absPath, c.Memory)
12441247

12451248
if err := c.checkProtectedPaths(path, carveOut); err != nil {
@@ -1311,17 +1314,26 @@ func isWithinSkillsDir(absPath string) bool {
13111314
}
13121315

13131316
// isWithinConfigSubdir reports whether absPath lives inside one of the named
1314-
// subdirectories of the project config dir (./.infer/<name>). These are
1315-
// operational areas - tmp scratch, persisted plans - that stay reachable even
1316-
// though the rest of .infer/ is protected as a whole.
1317-
func isWithinConfigSubdir(absPath string, names ...string) bool {
1317+
// subdirectories of the config dir. It checks both the project-relative
1318+
// ConfigDirName (./.infer/<name>) and the resolved config dir
1319+
// (GetConfigDir()/<name>) so that operational areas - tmp scratch, persisted
1320+
// plans - stay reachable even when the config was loaded from the userspace
1321+
// location (~/.infer). This keeps the rest of .infer/ protected as a whole.
1322+
func (c *Config) isWithinConfigSubdir(absPath string, names ...string) bool {
1323+
configDirs := []string{ConfigDirName}
1324+
if resolved := c.GetConfigDir(); resolved != "" && resolved != ConfigDirName {
1325+
configDirs = append(configDirs, resolved)
1326+
}
1327+
13181328
for _, name := range names {
1319-
dir, err := filepath.Abs(filepath.Join(ConfigDirName, name))
1320-
if err != nil {
1321-
continue
1322-
}
1323-
if absPath == dir || strings.HasPrefix(absPath, dir+string(filepath.Separator)) {
1324-
return true
1329+
for _, base := range configDirs {
1330+
dir, err := filepath.Abs(filepath.Join(base, name))
1331+
if err != nil {
1332+
continue
1333+
}
1334+
if absPath == dir || strings.HasPrefix(absPath, dir+string(filepath.Separator)) {
1335+
return true
1336+
}
13251337
}
13261338
}
13271339
return false

config/config_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,3 +1048,45 @@ func TestValidatePathInSandbox_ConfigDir(t *testing.T) {
10481048
})
10491049
}
10501050
}
1051+
1052+
// TestValidatePathInSandbox_ConfigDirUserspace locks in that the tmp/plans
1053+
// carve-out also covers the resolved userspace config dir (~/.infer). When the
1054+
// config is loaded from the userspace location, GetConfigDir() returns an
1055+
// absolute home path and plans are written there - the sandbox must still
1056+
// allow the agent to read them back.
1057+
func TestValidatePathInSandbox_ConfigDirUserspace(t *testing.T) {
1058+
cfg := DefaultConfig()
1059+
1060+
homeDir, err := os.UserHomeDir()
1061+
if err != nil {
1062+
t.Skipf("cannot determine home dir: %v", err)
1063+
}
1064+
1065+
userspaceConfigDir := filepath.Join(homeDir, ConfigDirName)
1066+
cfg.SetConfigDir(userspaceConfigDir)
1067+
1068+
allowed := []string{
1069+
filepath.Join(userspaceConfigDir, "tmp", "scratch.txt"),
1070+
filepath.Join(userspaceConfigDir, "plans", "2026-06-01-do-thing.md"),
1071+
}
1072+
for _, p := range allowed {
1073+
t.Run("allow "+p, func(t *testing.T) {
1074+
if err := cfg.ValidatePathInSandbox(p); err != nil {
1075+
t.Fatalf("expected %s allowed, got %v", p, err)
1076+
}
1077+
})
1078+
}
1079+
1080+
denied := []string{
1081+
filepath.Join(userspaceConfigDir, "config.yaml"),
1082+
filepath.Join(userspaceConfigDir, "agents.yaml"),
1083+
filepath.Join(userspaceConfigDir, "tmp", "leaked.env"),
1084+
}
1085+
for _, p := range denied {
1086+
t.Run("deny "+p, func(t *testing.T) {
1087+
if err := cfg.ValidatePathInSandbox(p); err == nil {
1088+
t.Fatalf("expected %s to be denied", p)
1089+
}
1090+
})
1091+
}
1092+
}

0 commit comments

Comments
 (0)