Skip to content
Open
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
66 changes: 64 additions & 2 deletions internal/sandbox/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: <path>"), 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)
}
Comment on lines +72 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Return no carveouts when a .git file cannot be resolved.

If resolveGitDir returns "" for a stale, malformed, or unreadable .git file, this leaves gitDir as <root>/.git and returns descendants of that file. That recreates the documented bwrap “Not a directory” failure and blocks sandboxed tools. Keep the literal fallback only when .git is absent; return no carveouts for an unresolved non-directory .git, with a regression test for a stale gitdir: pointer.

Proposed fix
 	if info, err := os.Stat(gitDir); err == nil && !info.IsDir() {
-		if real := resolveGitDir(root); real != "" {
-			gitDir = resolveGitCommonDir(real)
+		real := resolveGitDir(root)
+		if real == "" {
+			return nil
 		}
+		gitDir = resolveGitCommonDir(real)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if info, err := os.Stat(gitDir); err == nil && !info.IsDir() {
if real := resolveGitDir(root); real != "" {
gitDir = resolveGitCommonDir(real)
}
if info, err := os.Stat(gitDir); err == nil && !info.IsDir() {
real := resolveGitDir(root)
if real == "" {
return nil
}
gitDir = resolveGitCommonDir(real)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/profile.go` around lines 72 - 75, Update the gitDir
resolution flow around resolveGitDir so an unresolved non-directory .git file
does not retain the literal root/.git fallback; return no carveouts for stale,
malformed, or unreadable gitdir pointers. Preserve the literal fallback only
when .git is absent, and add a regression test covering a stale gitdir: pointer.

}
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: <path>"). 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 {
Expand Down
75 changes: 75 additions & 0 deletions internal/sandbox/profile_worktree_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package sandbox

import (
"os"
"path/filepath"
"testing"
)

// Worktree/submodule checkouts store .git as a *file* ("gitdir: <path>"). The
// hooks/config carveout must resolve the real (common) git dir instead of the
// bogus <root>/.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
// <root>/.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])
}
}
}
Loading