Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
157 changes: 154 additions & 3 deletions internal/sandbox/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,161 @@ 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 {
return []string{
filepath.Join(root, ".git", "hooks"),
filepath.Join(root, ".git", "config"),
// 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).
gitPath := filepath.Join(root, ".git")
info, err := os.Stat(gitPath)
if err != nil || info.IsDir() {
return []string{
filepath.Join(gitPath, "hooks"),
filepath.Join(gitPath, "config"),
}
}

// .git is a file. Following gitdir/commondir is only safe when the
// real resolved directory stays inside this write root. An escaping
// pointer is untrusted input: do not carve outside the workspace, and
// do not fall back to <root>/.git/{hooks,config} (that path has no
// mkdir-able parent under a .git file and would crash bwrap). Missing
// carveouts drop git metadata protection; unbounded carveouts drop
// confinement.
//
// Always write-deny the .git pointer file itself so a sandboxed command
// cannot rewrite gitdir: between PermissionProfileFromPolicy calls.
gitDir := resolveGitDir(root)
if gitDir == "" || !pathContainedInRoot(root, gitDir) {
return []string{gitPath}
}
common := resolveGitCommonDir(gitDir)
if common == "" || !pathContainedInRoot(root, common) {
return []string{gitPath}
}
// Emit carveouts under the caller's lexical write root so a symlink
// workspace still matches the path the sandbox binds.
gitDir = pathUnderRoot(root, gitDir)
common = pathUnderRoot(root, common)
if gitDir == "" || common == "" {
return []string{gitPath}
}
carveouts := []string{
gitPath,
filepath.Join(common, "hooks"),
filepath.Join(common, "config"),
}
if gitDir != common {
carveouts = append(carveouts,
filepath.Join(gitDir, "hooks"),
filepath.Join(gitDir, "config"),
)
}
return carveouts
}

// resolveGitDir returns the real git directory for a workspace root when .git
// is a gitdir: pointer file. Returns "" for absent, malformed, or unreadable
// pointers so callers can refuse carveouts instead of pointing at a bogus path.
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 ""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
line := ""
for _, candidate := range strings.Split(string(data), "\n") {
candidate = strings.TrimSpace(candidate)
if candidate != "" {
line = candidate
break
}
}
const prefix = "gitdir:"
if !strings.HasPrefix(strings.ToLower(line), prefix) {
return ""
}
dir := strings.TrimSpace(line[len(prefix):])
if dir == "" {
return ""
}
// Worktree gitdir pointers are often relative to the worktree root.
if !filepath.IsAbs(dir) {
dir = filepath.Join(root, dir)
}
dir = filepath.Clean(dir)
info, err = os.Stat(dir)
if err != nil || !info.IsDir() {
return ""
}
resolved, err := filepath.EvalSymlinks(dir)
if err != nil {
return ""
}
return resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// 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. An unreadable or empty
// commondir keeps gitDir. A present but non-directory commondir is refused.
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)
}
common = filepath.Clean(common)
info, err := os.Stat(common)
if err != nil || !info.IsDir() {
return ""
}
resolved, err := filepath.EvalSymlinks(common)
if err != nil {
return ""
}
return resolved
}

func pathContainedInRoot(root, target string) bool {
return pathUnderRoot(root, target) != ""
}

func pathUnderRoot(root, target string) string {
root = filepath.Clean(root)
target = filepath.Clean(target)
evalRoot := root
if resolved, err := filepath.EvalSymlinks(root); err == nil {
evalRoot = resolved
}
evalTarget := target
if resolved, err := filepath.EvalSymlinks(target); err == nil {
evalTarget = resolved
}
rel, err := filepath.Rel(evalRoot, evalTarget)
if err != nil {
return ""
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return ""
}
if rel == "." {
return root
}
return filepath.Join(root, rel)
}

func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile {
Expand Down
218 changes: 218 additions & 0 deletions internal/sandbox/profile_worktree_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package sandbox

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

func writeGitFile(t *testing.T, path, contents string) {
t.Helper()
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
}

func mustEqualCarveouts(t *testing.T, got, want []string) {
t.Helper()
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])
}
}
}

// 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) {
root := t.TempDir()
commonGit := filepath.Join(root, ".git-common")
worktreeGit := filepath.Join(root, ".git-wt")
if err := os.MkdirAll(worktreeGit, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(commonGit, 0o755); err != nil {
t.Fatal(err)
}
writeGitFile(t, filepath.Join(worktreeGit, "commondir"), filepath.Join("..", ".git-common")+"\n")
writeGitFile(t, filepath.Join(root, ".git"), "gitdir: "+worktreeGit+"\n")

got := gitMetadataWriteCarveouts(root)
want := []string{
filepath.Join(root, ".git"),
filepath.Join(commonGit, "hooks"),
filepath.Join(commonGit, "config"),
filepath.Join(worktreeGit, "hooks"),
filepath.Join(worktreeGit, "config"),
}
mustEqualCarveouts(t, got, want)
bogus := filepath.Join(root, ".git", "hooks")
for _, c := range got {
if c == bogus {
t.Fatalf("carveout points under .git file: %q", c)
}
}
}

func TestGitMetadataWriteCarveoutsOutsideWorktreeGitdirReturnsNone(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)
}
writeGitFile(t, filepath.Join(worktreeGit, "commondir"), "../..\n")
worktree := t.TempDir()
writeGitFile(t, filepath.Join(worktree, ".git"), "gitdir: "+worktreeGit+"\n")
got := gitMetadataWriteCarveouts(worktree)
want := []string{filepath.Join(worktree, ".git")}
mustEqualCarveouts(t, got, want)
}

func TestGitMetadataWriteCarveoutsPlainCheckout(t *testing.T) {
root := t.TempDir()
want := []string{
filepath.Join(root, ".git", "hooks"),
filepath.Join(root, ".git", "config"),
}
got := gitMetadataWriteCarveouts(root)
mustEqualCarveouts(t, got, want)

if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
got = gitMetadataWriteCarveouts(root)
mustEqualCarveouts(t, got, want)
}

func TestGitMetadataWriteCarveoutsRejectsEscapingGitdir(t *testing.T) {
outside := t.TempDir()
if err := os.MkdirAll(filepath.Join(outside, "git"), 0o755); err != nil {
t.Fatal(err)
}
root := t.TempDir()
writeGitFile(t, filepath.Join(root, ".git"), "gitdir: "+filepath.Join(outside, "git")+"\n")
got := gitMetadataWriteCarveouts(root)
mustEqualCarveouts(t, got, []string{filepath.Join(root, ".git")})
}

func TestGitMetadataWriteCarveoutsRejectsRelativeEscapingGitdir(t *testing.T) {
parent := t.TempDir()
outside := filepath.Join(parent, "elsewhere")
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
root := filepath.Join(parent, "ws")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
writeGitFile(t, filepath.Join(root, ".git"), "gitdir: ../elsewhere\n")
got := gitMetadataWriteCarveouts(root)
mustEqualCarveouts(t, got, []string{filepath.Join(root, ".git")})
}

func TestGitMetadataWriteCarveoutsRejectsEscapingCommondir(t *testing.T) {
root := t.TempDir()
gitDir := filepath.Join(root, ".git-real")
if err := os.MkdirAll(gitDir, 0o755); err != nil {
t.Fatal(err)
}
outside := t.TempDir()
writeGitFile(t, filepath.Join(gitDir, "commondir"), outside+"\n")
writeGitFile(t, filepath.Join(root, ".git"), "gitdir: "+gitDir+"\n")
got := gitMetadataWriteCarveouts(root)
mustEqualCarveouts(t, got, []string{filepath.Join(root, ".git")})
}

func TestGitMetadataWriteCarveoutsCommondirReadFailureKeepsGitDir(t *testing.T) {
root := t.TempDir()
gitDir := filepath.Join(root, ".git-real")
if err := os.MkdirAll(gitDir, 0o755); err != nil {
t.Fatal(err)
}
writeGitFile(t, filepath.Join(root, ".git"), "gitdir: "+gitDir+"\n")
got := gitMetadataWriteCarveouts(root)
want := []string{
filepath.Join(root, ".git"),
filepath.Join(gitDir, "hooks"),
filepath.Join(gitDir, "config"),
}
mustEqualCarveouts(t, got, want)
}

func TestGitMetadataWriteCarveoutsStaleGitdirReturnsNone(t *testing.T) {
root := t.TempDir()
writeGitFile(t, filepath.Join(root, ".git"), "gitdir: missing-target\n")
got := gitMetadataWriteCarveouts(root)
mustEqualCarveouts(t, got, []string{filepath.Join(root, ".git")})
}

func TestGitMetadataWriteCarveoutsRejectsNonDirectoryTarget(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "not-a-dir")
writeGitFile(t, target, "nope\n")
writeGitFile(t, filepath.Join(root, ".git"), "gitdir: "+target+"\n")
got := gitMetadataWriteCarveouts(root)
mustEqualCarveouts(t, got, []string{filepath.Join(root, ".git")})
}

func TestGitMetadataWriteCarveoutsRejectsSymlinkGitdir(t *testing.T) {
outside := t.TempDir()
root := t.TempDir()
alias := filepath.Join(root, "alias")
if err := os.Symlink(outside, alias); err != nil {
t.Fatal(err)
}
writeGitFile(t, filepath.Join(root, ".git"), "gitdir: "+alias+"\n")
got := gitMetadataWriteCarveouts(root)
mustEqualCarveouts(t, got, []string{filepath.Join(root, ".git")})
}

func TestGitMetadataWriteCarveoutsResolvesWorktreeUnderSymlinkRoot(t *testing.T) {
realRoot := t.TempDir()
commonGit := filepath.Join(realRoot, ".git-common")
worktreeGit := filepath.Join(realRoot, ".git-wt")
if err := os.MkdirAll(worktreeGit, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(commonGit, 0o755); err != nil {
t.Fatal(err)
}
writeGitFile(t, filepath.Join(worktreeGit, "commondir"), filepath.Join("..", ".git-common")+"\n")
writeGitFile(t, filepath.Join(realRoot, ".git"), "gitdir: "+worktreeGit+"\n")
parent := t.TempDir()
alias := filepath.Join(parent, "ws")
if err := os.Symlink(realRoot, alias); err != nil {
t.Fatal(err)
}
got := gitMetadataWriteCarveouts(alias)
want := []string{
filepath.Join(alias, ".git"),
filepath.Join(alias, ".git-common", "hooks"),
filepath.Join(alias, ".git-common", "config"),
filepath.Join(alias, ".git-wt", "hooks"),
filepath.Join(alias, ".git-wt", "config"),
}
mustEqualCarveouts(t, got, want)
}

func TestGitMetadataWriteCarveoutsParsesFirstGitdirLine(t *testing.T) {
root := t.TempDir()
gitDir := filepath.Join(root, ".git-real")
if err := os.MkdirAll(gitDir, 0o755); err != nil {
t.Fatal(err)
}
writeGitFile(t, filepath.Join(root, ".git"), "gitdir: "+gitDir+"\n# comment\n")
got := gitMetadataWriteCarveouts(root)
want := []string{
filepath.Join(root, ".git"),
filepath.Join(gitDir, "hooks"),
filepath.Join(gitDir, "config"),
}
mustEqualCarveouts(t, got, want)
}
Loading