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
13 changes: 11 additions & 2 deletions internal/sandboxrun/backend_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func CheckPlatform() error {
// harness binary (e.g. claude, codex) must have its directory granted
// as readable — (deny default) blocks exec otherwise. We resolve the
// binary on the host PATH and add its dir (and symlink-resolved dir)
// to ReadPaths before generating the SBPL, mirroring backend_linux.go.
// to the per-launch read rules before generating the SBPL, mirroring Linux.
//
// argv[0] is handed to sandbox-exec as an absolute path (see
// absoluteInnerArgv). A bare name would make sandbox-exec's execvp redo
Expand All @@ -55,7 +55,16 @@ func BuildChildArgv(g *Grants, innerArgv []string) ([]string, error) {
return nil, fmt.Errorf("sandbox home %q is not a directory", home)
}
gz.HomeDir = home
gz.ReadPaths = append(append([]string{}, g.ReadPaths...), resolveInnerBinaryDirs(innerArgv)...)
// The derivation is bounded by the grants (derivedGrantPolicyForGrants):
// the inner command frequently lives where the agent can write (the
// workdir, a temp dir), and granting read access picked by whatever the
// agent last wrote there would let the agent choose what the sandbox can
// read. Crossing the boundary fails the launch instead.
derived, err := resolveInnerBinaryDirs(innerArgv, derivedGrantPolicyForGrants(&gz))
if err != nil {
return nil, err
}
gz.derivedReadPaths = derived
profile := GenerateSBPL(&gz)
argv := []string{sandboxExecPath, "-p", profile, "--"}
argv = append(argv, absoluteInnerArgv(innerArgv)...)
Expand Down
26 changes: 24 additions & 2 deletions internal/sandboxrun/backend_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,18 @@ import (
// from a HOST-side lookup; handing sandbox-exec the path that lookup produced
// is what keeps the two from disagreeing.
func TestBuildChildArgvPassesTheInnerBinaryAsAnAbsolutePath(t *testing.T) {
dir := t.TempDir()
// Staged below the package directory, outside every shared temp root:
// BuildChildArgv bounds the derived grants by the writable surface, and
// a t.TempDir() fixture (under TMPDIR) would count as agent-writable
// and the derivation would be refused.
dir, err := os.MkdirTemp(".", "omac-darwin-argv-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
if dir, err = filepath.Abs(dir); err != nil {
t.Fatal(err)
}
exe := filepath.Join(dir, "opencode")
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -132,7 +143,18 @@ func TestBuildChildArgvHomeOutsideWorkdir(t *testing.T) {
// covers this layout — both the PATH-entry dir and the Cellar real dir must be
// granted read AND map-executable, and the emitted argv must be absolute.
func TestBuildChildArgvHomeRootedHomebrewPrefix(t *testing.T) {
home := t.TempDir()
// Staged below the package directory, outside every shared temp root:
// BuildChildArgv bounds the derived grants by the writable surface, and
// a t.TempDir() home (under TMPDIR) would count as agent-writable and
// the derivation would be refused.
home, err := os.MkdirTemp(".", "omac-darwin-home-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(home) })
if home, err = filepath.Abs(home); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
cellarBin := filepath.Join(home, "homebrew", "Cellar", "opencode", "1.18.15", "bin")
if err := os.MkdirAll(cellarBin, 0o755); err != nil {
Expand Down
24 changes: 15 additions & 9 deletions internal/sandboxrun/backend_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,18 +257,24 @@ func BuildChildArgv(g *Grants, innerArgv []string) ([]string, error) {
// host PATH (the same lookup `which opencode` performs) and grant both
// the PATH-entry (shim) directory and the symlink-resolved real
// directory read-only, so the shim is found on PATH and its target plus
// sibling files (shared libs, node runtime) are reachable too. Redundant
// grants are harmless (bwrap re-binding under a parent bind is idempotent;
// BuildBwrapArgv's dedupe drops only exact-duplicate paths, not overlaps).
gz.ReadPaths = append(gz.ReadPaths, resolveInnerBinaryDirs(innerArgv)...)
// sibling files (shared libs, node runtime) are reachable too.
//
// The derivation is bounded by the grants (derivedGrantPolicyForGrants):
// the inner command frequently lives where the agent can write (the
// workdir, a temp dir), and granting read access picked by whatever the
// agent last wrote there would let the agent choose what the sandbox can
// read. Crossing the boundary fails the launch instead.
derived, names, err := resolveInnerBinaryPlan(innerArgv, derivedGrantPolicyForGrants(&gz))
if err != nil {
return nil, err
}
gz.derivedReadPaths = derived
gz.derivedReadDirs = names

// Rewrite argv[0] to the symlink-resolved real path so stage2 execs
// the real binary directly instead of following a symlink chain inside
// the sandbox. This avoids granting read access to intermediate
// directories in the chain (e.g. ~/.codex/packages/standalone/current/
// when ~/.local/bin/codex -> ~/.codex/.../current/bin/codex ->
// ~/.codex/.../releases/.../bin/codex). Only the binary's final
// directory is granted (via resolveInnerBinaryDirs above).
// the sandbox. The checked derivation above covers any additional
// directories required by the original path.
resolvedArgv := make([]string, len(innerArgv))
copy(resolvedArgv, innerArgv)
if p := resolveInnerBinaryPath(innerArgv); p != "" {
Expand Down
35 changes: 27 additions & 8 deletions internal/sandboxrun/backend_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,24 @@ import (
)

func TestResolveInnerBinaryDirs(t *testing.T) {
// The zero policy keeps this a resolution-only test: with no grants to
// check against, nothing is refused and the output is unchanged. The
// backends own the policy enforcement.
resolve := func(argv []string) []string {
dirs, _ := resolveInnerBinaryDirs(argv, derivedGrantPolicy{})
return dirs
}

// Empty / blank argv resolves to nothing.
if got := resolveInnerBinaryDirs(nil); got != nil {
if got := resolve(nil); got != nil {
t.Errorf("nil argv = %v, want nil", got)
}
if got := resolveInnerBinaryDirs([]string{""}); got != nil {
if got := resolve([]string{""}); got != nil {
t.Errorf("blank argv[0] = %v, want nil", got)
}

// A name not on PATH resolves to nothing rather than guessing.
if got := resolveInnerBinaryDirs([]string{"definitely-not-a-real-binary-xyz"}); got != nil {
if got := resolve([]string{"definitely-not-a-real-binary-xyz"}); got != nil {
t.Errorf("missing binary = %v, want nil", got)
}

Expand Down Expand Up @@ -60,7 +68,7 @@ func TestResolveInnerBinaryDirs(t *testing.T) {
wantBoth := append([]string{shimDir, realDir}, interpDirs...)

// Resolve via an absolute path to the shim (uses host PATH for sh).
if got := resolveInnerBinaryDirs([]string{shim}); !reflect.DeepEqual(got, wantBoth) {
if got := resolve([]string{shim}); !reflect.DeepEqual(got, wantBoth) {
t.Errorf("symlinked binary dirs = %v, want %v", got, wantBoth)
}

Expand All @@ -69,7 +77,7 @@ func TestResolveInnerBinaryDirs(t *testing.T) {
// path so the interpreter is still found via os.Stat, not PATH.
t.Setenv("PATH", shimDir)
wantPath := append([]string{shimDir, realDir}, interpDirs...)
if got := resolveInnerBinaryDirs([]string{"opencode"}); !reflect.DeepEqual(got, wantPath) {
if got := resolve([]string{"opencode"}); !reflect.DeepEqual(got, wantPath) {
t.Errorf("PATH-resolved binary dirs = %v, want %v", got, wantPath)
}

Expand All @@ -83,7 +91,7 @@ func TestResolveInnerBinaryDirs(t *testing.T) {
t.Fatal(err)
}
wantPlain := append([]string{plainDir}, interpDirs...)
if got := resolveInnerBinaryDirs([]string{plainBin}); !reflect.DeepEqual(got, wantPlain) {
if got := resolve([]string{plainBin}); !reflect.DeepEqual(got, wantPlain) {
t.Errorf("plain binary dirs = %v, want %v", got, wantPlain)
}

Expand All @@ -92,7 +100,7 @@ func TestResolveInnerBinaryDirs(t *testing.T) {
// command's dirs must still be granted, or a bun/npm-installed opencode
// fails to exec inside the sandbox. Grant the shim via PATH lookup.
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+"/usr/bin:/bin")
got := resolveInnerBinaryDirs([]string{"env", "FOO=bar", "BAR=baz", "opencode"})
got := resolve([]string{"env", "FOO=bar", "BAR=baz", "opencode"})
for _, want := range []string{shimDir, realDir} {
if !slices.Contains(got, want) {
t.Errorf("env-wrapped dirs = %v, missing wrapped-command dir %q", got, want)
Expand All @@ -111,7 +119,18 @@ func TestBuildChildArgvGrantsEnvWrappedHarnessDir(t *testing.T) {

// Shim in one dir, symlink target ("harness") in a different subtree,
// mirroring bun (~/.bun/bin/opencode -> …/opencode-ai/bin/opencode.exe).
root := t.TempDir()
// Staged below the package directory, outside every shared temp root:
// BuildChildArgv bounds the derived grants by the writable surface, and
// a t.TempDir() fixture (under TMPDIR) would count as agent-writable
// and the derivation would be refused.
root, err := os.MkdirTemp(".", "omac-harness-dir-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(root) })
if root, err = filepath.Abs(root); err != nil {
t.Fatal(err)
}
realDir := filepath.Join(root, "pkg", "bin")
if err := os.MkdirAll(realDir, 0o755); err != nil {
t.Fatal(err)
Expand Down
95 changes: 95 additions & 0 deletions internal/sandboxrun/binary_namespace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package sandboxrun

import (
"fmt"
"os"
"path/filepath"
"sort"
)

// namespaceSymlink joins two sandbox pathnames. Neither name is a new host
// bind source: this cannot expose data outside the mounts already admitted.
type namespaceSymlink struct {
path string
target string
}

// binaryNamespaceLinks restores execution spellings without rebinding mutable
// host aliases. In particular, a granted alias's host target need not itself
// be mounted, while an ungranted interpreter alias still needs its original
// spelling for the kernel's shebang lookup.
func binaryNamespaceLinks(dirs []derivedReadDir, mounts []*mount) ([]namespaceSymlink, error) {
dirs = append([]derivedReadDir(nil), dirs...)
sort.Slice(dirs, func(i, j int) bool { return dirs[i].path < dirs[j].path })
var links []namespaceSymlink
for _, d := range dirs {
if namespaceHasDirectory(d.path, d.real, mounts, links) {
continue
}
// --dir/--symlink must only modify the fresh namespace, never a
// host-backed tree or a parent of an existing mount/link.
conflict := innermostMount(d.path, mounts) != nil || aboveAnyMount(d.path, mounts)
for _, link := range links {
conflict = conflict || isUnder(d.path, link.path) || isUnder(link.path, d.path)
}
if conflict {
return nil, fmt.Errorf("cannot restore command pathname %q inside an existing sandbox mount; grant the execution path explicitly", d.path)
}
target := ""
if namespaceHasDirectory(d.real, d.real, mounts, links) {
target = d.real
} else {
for _, m := range mounts {
root := canonicalPath(m.path)
if root == "" {
continue
}
if rel, ok := directoryRelativeTo(d.real, root); ok {
candidate := filepath.Join(m.path, rel)
if namespaceHasDirectory(candidate, d.real, mounts, links) {
target = candidate
break
}
}
}
}
if target == "" {
return nil, fmt.Errorf("checked command directory %q has no visible sandbox path; grant its execution path explicitly", d.real)
}
links = append(links, namespaceSymlink{path: d.path, target: target})
}
return links, nil
}

func namespaceHasDirectory(path, real string, mounts []*mount, links []namespaceSymlink) bool {
resolved, ok := sandboxResolveWithLinks(path, mounts, links)
if !ok || innermostMount(resolved, mounts) == nil {
return false
}
a, aerr := os.Stat(resolved)
b, berr := os.Stat(real)
return aerr == nil && berr == nil && a.IsDir() && os.SameFile(a, b)
}

// directoryRelativeTo preserves case-sensitive identities, including when
// two spellings identify the same directory on a case-insensitive volume.
func directoryRelativeTo(path, root string) (string, bool) {
if isUnder(path, root) {
rel, err := filepath.Rel(root, path)
return rel, err == nil
}
info, err := os.Stat(root)
if err != nil {
return "", false
}
rel := "."
for p := path; ; p = filepath.Dir(p) {
if pi, err := os.Stat(p); err == nil && os.SameFile(pi, info) {
return rel, true
}
if p == filepath.Dir(p) {
return "", false
}
rel = filepath.Join(filepath.Base(p), rel)
}
}
Loading
Loading