mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* feat(sandbox): unify command execution and enforcement * test(sandbox): make platform contracts portable * fix(sandbox): close execution lifecycle races Bound retained interactive output, preserve interruption state, and start workspace observation before process launch. Protect active runtime roots with cross-platform leases, capture transient protected-path creation events, and restore process attributes after PTY fallback. * fix(sandbox): restore strict macOS isolation Remove Seatbelt host-local network allowances after native validation showed localhost filters can reach host interfaces. Track process reap completion so retained sessions cannot signal a stale numeric PID.
432 lines
13 KiB
Go
432 lines
13 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
|
|
PolicyReportPath string
|
|
Command []string
|
|
}
|
|
|
|
type LinuxSandboxHelperConfig struct {
|
|
SandboxPolicyCWD string
|
|
CommandCWD string
|
|
PermissionProfile PermissionProfile
|
|
UseLandlock bool
|
|
ApplySeccompThenExec bool
|
|
BlockUnixSockets bool
|
|
NoProc bool
|
|
PolicyReportPath string
|
|
Command []string
|
|
}
|
|
|
|
type LinuxSandboxHelperCommand struct {
|
|
Name string
|
|
ArgsPrefix []string
|
|
Dir string
|
|
}
|
|
|
|
type LinuxSandboxBwrapOptions struct {
|
|
Config LinuxSandboxHelperConfig
|
|
HelperPath string
|
|
}
|
|
|
|
type linuxSandboxBwrapPlan struct {
|
|
Args []string
|
|
ProtectedCreateTargets []string
|
|
}
|
|
|
|
type linuxBwrapFilesystemPlan struct {
|
|
Args []string
|
|
ProtectedCreateTargets []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")
|
|
}
|
|
if strings.TrimSpace(options.PolicyReportPath) != "" {
|
|
args = append(args, "--policy-report-path", options.PolicyReportPath)
|
|
}
|
|
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")
|
|
flags.StringVar(&config.PolicyReportPath, "policy-report-path", "", "structured policy report path")
|
|
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()
|
|
config.PolicyReportPath = strings.TrimSpace(config.PolicyReportPath)
|
|
if len(config.Command) == 0 {
|
|
return LinuxSandboxHelperConfig{}, errors.New("missing command after --")
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, error) {
|
|
plan, err := buildLinuxSandboxBwrapPlan(options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return plan.Args, nil
|
|
}
|
|
|
|
func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxBwrapPlan, error) {
|
|
config := options.Config
|
|
if config.ApplySeccompThenExec {
|
|
return linuxSandboxBwrapPlan{}, errors.New("inner seccomp stage cannot be wrapped by bubblewrap again")
|
|
}
|
|
if config.UseLandlock {
|
|
return linuxSandboxBwrapPlan{}, errors.New("linux landlock helper mode is not implemented yet")
|
|
}
|
|
helperPath := strings.TrimSpace(options.HelperPath)
|
|
if helperPath == "" {
|
|
return linuxSandboxBwrapPlan{}, 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 linuxSandboxBwrapPlan{}, err
|
|
}
|
|
args := []string{
|
|
"--new-session",
|
|
"--die-with-parent",
|
|
}
|
|
filesystemPlan := buildLinuxBwrapFilesystemPlan(config.PermissionProfile)
|
|
args = append(args, filesystemPlan.Args...)
|
|
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 linuxSandboxBwrapPlan{
|
|
Args: args,
|
|
ProtectedCreateTargets: filesystemPlan.ProtectedCreateTargets,
|
|
}, nil
|
|
}
|
|
|
|
func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
|
|
return buildLinuxBwrapFilesystemPlan(profile).Args
|
|
}
|
|
|
|
func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesystemPlan {
|
|
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 linuxBwrapFilesystemPlan{Args: args}
|
|
}
|
|
|
|
args := []string{}
|
|
protectedCreateTargets := []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 {
|
|
path := filepath.Join(root.Root, name)
|
|
if pathExists(path) {
|
|
args = appendReadOnlyLinuxPathArgs(args, path)
|
|
} else {
|
|
protectedCreateTargets = append(protectedCreateTargets, path)
|
|
}
|
|
}
|
|
}
|
|
for _, path := range fs.DenyWrite {
|
|
args = appendReadOnlyLinuxPathArgs(args, path)
|
|
}
|
|
for _, path := range fs.DenyRead {
|
|
args = appendUnreadableLinuxPathArgs(args, path)
|
|
}
|
|
return linuxBwrapFilesystemPlan{
|
|
Args: args,
|
|
ProtectedCreateTargets: dedupeStrings(protectedCreateTargets),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|