diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 915bb92be..4ab3dcaa7 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -63,10 +63,72 @@ var sandboxFullyProtectedMetadataNames = []string{".zero", ".agents"} // subprocesses. Nonexistent paths are harmless no-ops in every backend's // enforcement (seatbelt regex, bwrap ro-bind, Windows ACL deny entry). func gitMetadataWriteCarveouts(root string) []string { + // Worktrees/submodules store .git as a *file* ("gitdir: "), so + // .git/hooks has no parent dir — a --tmpfs carveout there makes bwrap fail + // ("Can't mkdir parents ... Not a directory") and blocks every sandboxed + // tool. Resolve the real (common) git dir in that case; otherwise keep the + // plain-checkout paths (harmless no-ops when .git is absent). + gitDir := filepath.Join(root, ".git") + if info, err := os.Stat(gitDir); err == nil && !info.IsDir() { + if real := resolveGitDir(root); real != "" { + gitDir = resolveGitCommonDir(real) + } + } return []string{ - filepath.Join(root, ".git", "hooks"), - filepath.Join(root, ".git", "config"), + filepath.Join(gitDir, "hooks"), + filepath.Join(gitDir, "config"), + } +} + +// resolveGitDir returns the real git directory for a workspace root, handling +// both regular checkouts (.git is a directory) and worktrees/submodules (.git +// is a file containing "gitdir: "). Returns "" when .git is absent, so +// carveouts collapse to no-ops instead of pointing at a bogus path — a bogus +// path under a .git *file* makes bwrap fail ("Can't mkdir parents ... Not a +// directory") and blocks every sandboxed tool. +func resolveGitDir(root string) string { + gitPath := filepath.Join(root, ".git") + info, err := os.Stat(gitPath) + if err != nil { + return "" + } + if info.IsDir() { + return gitPath + } + data, err := os.ReadFile(gitPath) + if err != nil { + return "" + } + dir := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(data)), "gitdir:")) + if dir == "" { + return "" + } + // Worktree gitdir pointers are often relative to the worktree root. + if !filepath.IsAbs(dir) { + dir = filepath.Join(root, dir) + } + if _, err := os.Stat(dir); err != nil { + return "" + } + return dir +} + +// resolveGitCommonDir returns the shared git dir for a (possibly worktree) +// gitDir. Worktree gitdirs carry a "commondir" pointer (usually "../.."); plain +// checkouts have none and are their own common dir. +func resolveGitCommonDir(gitDir string) string { + data, err := os.ReadFile(filepath.Join(gitDir, "commondir")) + if err != nil { + return gitDir + } + common := strings.TrimSpace(string(data)) + if common == "" { + return gitDir + } + if !filepath.IsAbs(common) { + common = filepath.Join(gitDir, common) } + return filepath.Clean(common) } func DefaultPermissionProfile(workspaceRoot string) PermissionProfile { diff --git a/internal/sandbox/profile_worktree_test.go b/internal/sandbox/profile_worktree_test.go new file mode 100644 index 000000000..f67fecf32 --- /dev/null +++ b/internal/sandbox/profile_worktree_test.go @@ -0,0 +1,75 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" +) + +// Worktree/submodule checkouts store .git as a *file* ("gitdir: "). The +// hooks/config carveout must resolve the real (common) git dir instead of the +// bogus /.git/hooks under that file — a --tmpfs there makes bwrap fail +// ("Can't mkdir parents ... Not a directory") and blocks every sandboxed tool. +func TestGitMetadataWriteCarveoutsResolvesWorktree(t *testing.T) { + main := t.TempDir() + commonGit := filepath.Join(main, ".git") + worktreeGit := filepath.Join(commonGit, "worktrees", "wt") + if err := os.MkdirAll(worktreeGit, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(worktreeGit, "commondir"), []byte("../..\n"), 0o644); err != nil { + t.Fatal(err) + } + + worktree := t.TempDir() + if err := os.WriteFile(filepath.Join(worktree, ".git"), []byte("gitdir: "+worktreeGit+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + got := gitMetadataWriteCarveouts(worktree) + want := []string{ + filepath.Join(commonGit, "hooks"), + filepath.Join(commonGit, "config"), + } + if len(got) != len(want) { + t.Fatalf("carveouts = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("carveout[%d] = %q, want %q", i, got[i], want[i]) + } + } + // Must never point under the .git *file* — that path has no mkdir-able parent. + bogus := filepath.Join(worktree, ".git", "hooks") + for _, c := range got { + if c == bogus { + t.Fatalf("carveout points under .git file: %q", c) + } + } +} + +// Plain checkouts (.git is a dir) and .git-absent roots keep the literal +// /.git/{hooks,config} carveouts. +func TestGitMetadataWriteCarveoutsPlainCheckout(t *testing.T) { + root := t.TempDir() + want := []string{ + filepath.Join(root, ".git", "hooks"), + filepath.Join(root, ".git", "config"), + } + got := gitMetadataWriteCarveouts(root) + for i := range want { + if got[i] != want[i] { + t.Fatalf("carveout[%d] = %q, want %q", i, got[i], want[i]) + } + } + + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + got = gitMetadataWriteCarveouts(root) + for i := range want { + if got[i] != want[i] { + t.Fatalf("dir carveout[%d] = %q, want %q", i, got[i], want[i]) + } + } +}