Files
zero/internal/sandbox/linux_helper.go
Anandan 644f61fa58 Fix shared temp roots in sandbox scope (#314)
Treat platform temp directories as default writable roots so file tools, shell commands, and sandbox profiles share the same temp view.

On Linux, bind host temp roots instead of mounting an isolated tmpfs at /tmp so artifacts created under /tmp remain visible across tool and shell calls.

Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/cli ./internal/agent
2026-06-25 09:47:55 +05:30

390 lines
12 KiB
Go

package sandbox
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
)
const LinuxSandboxHelperName = "zero-linux-sandbox"
const linuxSandboxBackendEnv = BackendLinuxBwrap
type LinuxSandboxCommandArgsOptions struct {
SandboxPolicyCWD string
CommandCWD string
PermissionProfile PermissionProfile
UseLandlock bool
ApplySeccompThenExec bool
BlockUnixSockets bool
NoProc bool
Command []string
}
type LinuxSandboxHelperConfig struct {
SandboxPolicyCWD string
CommandCWD string
PermissionProfile PermissionProfile
UseLandlock bool
ApplySeccompThenExec bool
BlockUnixSockets bool
NoProc bool
Command []string
}
type LinuxSandboxHelperCommand struct {
Name string
ArgsPrefix []string
Dir string
}
type LinuxSandboxBwrapOptions struct {
Config LinuxSandboxHelperConfig
HelperPath string
}
var linuxSandboxHelperCommand = findLinuxSandboxHelperCommand
func BuildLinuxSandboxCommandArgs(options LinuxSandboxCommandArgsOptions) ([]string, error) {
sandboxPolicyCWD := strings.TrimSpace(options.SandboxPolicyCWD)
if sandboxPolicyCWD == "" {
return nil, errors.New("linux sandbox helper requires sandbox policy cwd")
}
commandCWD := strings.TrimSpace(options.CommandCWD)
if commandCWD == "" {
commandCWD = sandboxPolicyCWD
}
if len(options.Command) == 0 {
return nil, errors.New("linux sandbox helper requires command")
}
profileJSON, err := json.Marshal(options.PermissionProfile)
if err != nil {
return nil, fmt.Errorf("marshal linux sandbox permission profile: %w", err)
}
args := []string{
"--sandbox-policy-cwd", sandboxPolicyCWD,
"--command-cwd", commandCWD,
"--permission-profile", string(profileJSON),
}
if options.UseLandlock {
args = append(args, "--use-landlock")
}
if options.ApplySeccompThenExec {
args = append(args, "--apply-seccomp-then-exec")
}
if options.BlockUnixSockets {
args = append(args, "--block-unix-sockets")
}
if options.NoProc {
args = append(args, "--no-proc")
}
args = append(args, "--")
args = append(args, options.Command...)
return args, nil
}
func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error) {
var config LinuxSandboxHelperConfig
var profileJSON string
flags := flag.NewFlagSet(LinuxSandboxHelperName, flag.ContinueOnError)
flags.SetOutput(io.Discard)
flags.StringVar(&config.SandboxPolicyCWD, "sandbox-policy-cwd", "", "sandbox policy cwd")
flags.StringVar(&config.CommandCWD, "command-cwd", "", "command cwd")
flags.StringVar(&profileJSON, "permission-profile", "", "permission profile JSON")
flags.BoolVar(&config.UseLandlock, "use-landlock", false, "use Landlock backend")
flags.BoolVar(&config.ApplySeccompThenExec, "apply-seccomp-then-exec", false, "apply seccomp before exec")
flags.BoolVar(&config.BlockUnixSockets, "block-unix-sockets", false, "block AF_UNIX sockets before exec")
flags.BoolVar(&config.NoProc, "no-proc", false, "skip proc mount")
if err := flags.Parse(args); err != nil {
return LinuxSandboxHelperConfig{}, err
}
config.SandboxPolicyCWD = strings.TrimSpace(config.SandboxPolicyCWD)
if config.SandboxPolicyCWD == "" {
return LinuxSandboxHelperConfig{}, errors.New("missing --sandbox-policy-cwd")
}
config.CommandCWD = strings.TrimSpace(config.CommandCWD)
if config.CommandCWD == "" {
config.CommandCWD = config.SandboxPolicyCWD
}
profileJSON = strings.TrimSpace(profileJSON)
if profileJSON == "" {
return LinuxSandboxHelperConfig{}, errors.New("missing --permission-profile")
}
if err := json.Unmarshal([]byte(profileJSON), &config.PermissionProfile); err != nil {
return LinuxSandboxHelperConfig{}, fmt.Errorf("invalid --permission-profile: %w", err)
}
config.Command = flags.Args()
if len(config.Command) == 0 {
return LinuxSandboxHelperConfig{}, errors.New("missing command after --")
}
return config, nil
}
func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, error) {
config := options.Config
if config.ApplySeccompThenExec {
return nil, errors.New("inner seccomp stage cannot be wrapped by bubblewrap again")
}
if config.UseLandlock {
return nil, errors.New("linux landlock helper mode is not implemented yet")
}
helperPath := strings.TrimSpace(options.HelperPath)
if helperPath == "" {
return nil, errors.New("linux sandbox helper path is required")
}
commandCWD := strings.TrimSpace(config.CommandCWD)
if commandCWD == "" {
commandCWD = config.SandboxPolicyCWD
}
innerArgs, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
SandboxPolicyCWD: config.SandboxPolicyCWD,
CommandCWD: commandCWD,
PermissionProfile: config.PermissionProfile,
ApplySeccompThenExec: true,
BlockUnixSockets: config.BlockUnixSockets,
NoProc: config.NoProc,
Command: config.Command,
})
if err != nil {
return nil, err
}
args := []string{
"--new-session",
"--die-with-parent",
}
args = append(args, linuxBwrapFilesystemArgs(config.PermissionProfile)...)
if pathExists(helperPath) {
args = append(args, "--ro-bind", helperPath, helperPath)
}
args = append(args,
"--unshare-user",
"--unshare-pid",
)
// Keep IPC and UTS shared for compatibility with the host CLI environment;
// network isolation is still applied below when the policy denies egress.
if shouldUnshareLinuxNetwork(config.PermissionProfile.Network) {
args = append(args, "--unshare-net")
}
if !config.NoProc {
args = append(args, "--proc", "/proc")
}
args = append(args, "--chdir", commandCWD)
for _, env := range linuxHelperSandboxEnvironmentOverrides(config.PermissionProfile) {
key, value, ok := strings.Cut(env, "=")
if ok {
args = append(args, "--setenv", key, value)
}
}
args = append(args, "--", helperPath)
args = append(args, innerArgs...)
return args, nil
}
func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
fs := profile.FileSystem
if fs.Kind == FileSystemUnrestricted {
// Disabled filesystem policy means no write jail: expose the host root
// read-write, including the host /dev tree, rather than synthesizing a
// restricted bubblewrap filesystem.
args := []string{"--bind", "/", "/"}
for _, root := range fs.WriteRoots {
if pathExists(root.Root) {
args = append(args, "--bind", root.Root, root.Root)
}
}
return args
}
args := []string{}
if linuxProfileHasFullReadRoot(fs) {
args = append(args, "--ro-bind", "/", "/", "--dev", "/dev")
} else {
args = append(args, "--tmpfs", "/", "--dev", "/dev")
if fs.IncludePlatformRoots {
for _, root := range linuxPlatformReadRoots() {
args = append(args, "--ro-bind", root, root)
}
}
for _, root := range fs.ReadRoots {
if pathExists(root) {
args = append(args, "--ro-bind", root, root)
}
}
}
if fs.AllowTemp {
fs.WriteRoots = linuxWriteRootsWithTemp(fs)
}
for _, root := range linuxSortedWriteRoots(fs.WriteRoots) {
if !pathExists(root.Root) {
continue
}
args = append(args, "--bind", root.Root, root.Root)
for _, subpath := range root.ReadOnlySubpaths {
args = appendReadOnlyLinuxPathArgs(args, subpath)
}
for _, name := range root.ProtectedMetadataNames {
args = appendReadOnlyLinuxPathArgs(args, filepath.Join(root.Root, name))
}
}
for _, path := range fs.DenyWrite {
args = appendReadOnlyLinuxPathArgs(args, path)
}
for _, path := range fs.DenyRead {
args = appendUnreadableLinuxPathArgs(args, path)
}
return args
}
func linuxWriteRootsWithTemp(fs FileSystemPolicy) []WritableRoot {
roots := append([]WritableRoot{}, fs.WriteRoots...)
for _, tempRoot := range defaultTempWriteRoots() {
found := false
for _, root := range roots {
if filepath.Clean(root.Root) == filepath.Clean(tempRoot) {
found = true
break
}
}
if !found {
roots = append(roots, WritableRoot{Root: tempRoot})
}
}
return roots
}
func linuxSortedWriteRoots(roots []WritableRoot) []WritableRoot {
sorted := append([]WritableRoot{}, roots...)
sort.SliceStable(sorted, func(i, j int) bool {
return pathDepth(sorted[i].Root) < pathDepth(sorted[j].Root)
})
return sorted
}
func pathDepth(path string) int {
cleaned := filepath.Clean(path)
if cleaned == "" || filepath.Dir(cleaned) == cleaned {
return 0
}
trimmed := strings.Trim(cleaned, string(filepath.Separator))
if trimmed == "" {
return 0
}
return strings.Count(trimmed, string(filepath.Separator)) + 1
}
func linuxProfileHasFullReadRoot(fs FileSystemPolicy) bool {
for _, root := range fs.ReadRoots {
if filepath.Clean(root) == string(filepath.Separator) {
return true
}
}
return false
}
func linuxPlatformReadRoots() []string {
candidates := []string{"/bin", "/sbin", "/usr", "/etc", "/lib", "/lib64", "/nix/store", "/run/current-system/sw"}
roots := make([]string, 0, len(candidates))
for _, candidate := range candidates {
if pathExists(candidate) {
roots = append(roots, candidate)
}
}
return roots
}
func appendReadOnlyLinuxPathArgs(args []string, path string) []string {
path = strings.TrimSpace(path)
if path == "" {
return args
}
if pathExists(path) {
return append(args, "--ro-bind", path, path)
}
return append(args, "--perms", "555", "--tmpfs", path, "--remount-ro", path)
}
func appendUnreadableLinuxPathArgs(args []string, path string) []string {
path = strings.TrimSpace(path)
if path == "" {
return args
}
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return append(args, "--ro-bind", "/dev/null", path)
}
return append(args, "--perms", "000", "--tmpfs", path, "--remount-ro", path)
}
func shouldUnshareLinuxNetwork(policy NetworkPolicy) bool {
return NormalizeNetworkMode(policy.Mode) == NetworkDeny
}
func linuxHelperSandboxEnvironment(profile PermissionProfile, base []string) []string {
return upsertEnvList(base, linuxHelperSandboxEnvironmentOverrides(profile)...)
}
func linuxHelperSandboxEnvironmentOverrides(profile PermissionProfile) []string {
return []string{
EnvSandboxBackend + "=" + string(linuxSandboxBackendEnv),
"ZERO_SANDBOX_NETWORK=" + string(profile.Network.Mode),
EnvSandboxed + "=1",
}
}
func pathExists(path string) bool {
if strings.TrimSpace(path) == "" {
return false
}
_, err := os.Stat(path)
return err == nil
}
func findLinuxSandboxHelperCommand() (LinuxSandboxHelperCommand, error) {
if exe, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exe), LinuxSandboxHelperName)
if executableRegularFile(candidate) {
return LinuxSandboxHelperCommand{Name: candidate}, nil
}
}
if path, err := exec.LookPath(LinuxSandboxHelperName); err == nil && path != "" {
return LinuxSandboxHelperCommand{Name: path}, nil
}
if root := linuxSandboxRepoRoot(); root != "" {
mainPath := filepath.Join(root, "cmd", LinuxSandboxHelperName, "main.go")
if _, err := os.Stat(mainPath); err == nil {
if goPath, lookErr := exec.LookPath("go"); lookErr == nil && goPath != "" {
return LinuxSandboxHelperCommand{
Name: goPath,
ArgsPrefix: []string{"run", "./cmd/" + LinuxSandboxHelperName},
Dir: root,
}, nil
}
}
}
return LinuxSandboxHelperCommand{}, errors.New("zero-linux-sandbox helper is not available")
}
func executableRegularFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0
}
func linuxSandboxRepoRoot() string {
_, file, _, ok := runtime.Caller(0)
if !ok {
return ""
}
root := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil {
return ""
}
return root
}