mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* fix(sandbox): deny reads of zero credential stores * fix(sandbox): address PR review comments - Deny reads of mcp-oauth-tokens.json.secret in the default Zero config candidates: file-backed oauth stores keep their encryption secret in a sibling .secret file, so the default MCP token store needs the same protection as the override paths (CodeRabbit). - Clarify that explicit credential-file overrides are still filtered by on-disk existence like every other candidate (Copilot). - Align the zeroUserConfigDir doc comment with config.UserConfigDir: macOS honors XDG_CONFIG_HOME before falling back to ~/.config (Copilot). Co-Authored-By: Claude Fable 5 <[email protected]> * fix(sandbox): deny the whole Zero config dir, not itemized filenames Address code review on PR #681: - Deny credentialDenyReadPaths' Zero-config candidate as the containing directory instead of an itemized filename list. The token/credential/ config stores each publish through a randomly-named .tmp-<pid>-<nanos> sibling before their atomic rename, and the legacy MCP token store leaves a mcp-oauth-tokens.json.migrated backup after importing it — none of those names were covered by the old itemized list, so a sandboxed command could read them under the read-all posture. - Stop dropping default candidates that don't exist yet at profile-build time. A store created later in a long-lived sandboxed session (e.g. a concurrent ) previously got no deny rule at all; every backend already treats a deny rule over a not-yet-existing path as a harmless no-op that still takes effect once the path appears. - Resolve ZERO_OAUTH_TOKENS_PATH / ZERO_MCP_OAUTH_TOKENS_PATH overrides the same way the token stores resolve them (relative-to-cwd, no ~ expansion) instead of through normalizeProfilePath, which tilde-expands and so could derive a deny path different from where the store actually writes. Adds regression coverage for the directory-wide deny (including the migrated backup and synthetic temp-file siblings), for building the profile before the store directory exists, and for the tilde-override resolution mismatch. Co-Authored-By: Claude Sonnet 5 <[email protected]> * fix(sandbox): address macOS deny-path test and config parity Recompute the expected Zero config deny path after MkdirAll and resolve the temp base with EvalSymlinks so macOS /var -> /private/var does not flake TestPermissionProfileDeniesZeroCredentialFiles. Add a parity test that sandbox.zeroUserConfigDir stays aligned with config.UserConfigDir. Co-authored-by: PierrunoYT <[email protected]> * fix(sandbox): harden credential deny paths * test(cli): normalize credential deny baseline * fix(sandbox): close credential path gaps * test(sandbox): normalize command credential path * fix(sandbox): handle absent credential paths safely * fix(sandbox): protect MCP OAuth siblings * fix(sandbox): protect OAuth lock siblings * docs(sandbox): clarify Linux future-path limits * test(sandbox): probe credential store reads in the Linux smoke test The smoke test wired HOME/XDG_CONFIG_HOME at credential directories but never attempted a read through the sandbox, so it could not catch a real-bwrap regression for the paths this change protects. Write a secret into ~/.aws and $XDG_CONFIG_HOME/zero and add both to the deny-read table. * fix(sandbox): keep the credential deny usable, current, and unguessable Four problems with the credential deny baseline, all in the same mechanism. The whole-directory deny over $XDG_CONFIG_HOME/zero also hid the supported user plugin, specialist, and command roots, whose commands and scripts are executed through this sandbox, so an installed user plugin could no longer start. Narrowing the deny to itemized files was not an option: the stores publish through temporary names and a concurrent login can add a store that did not exist when the profile was built, and only a directory rule covers both. The directory deny therefore stays, and the non-secret subtrees are carved back out through a new DenyReadCarveouts field: seatbelt re-allows them after the deny (SBPL is last-match-wins) plus ancestor metadata so path resolution can reach them, and bubblewrap masks the directory with --perms 111 instead of 000 -- unlistable but traversable -- and re-binds the carveout read-only before the tmpfs is remounted read-only. A relative ZERO_OAUTH_TOKENS_PATH, ZERO_MCP_OAUTH_TOKENS_PATH, or XDG_CONFIG_HOME was resolved against the command directory only, while oauth.ResolveStorePath and mcp.ResolveTokenStorePath call filepath.Abs, i.e. resolve against the Zero process working directory. A command run from a subdirectory therefore had the wrong path denied and the real store stayed readable under the read-all posture. Relative candidates now resolve against both directories, since the process that resolves the value is not necessarily the one that writes the store. The regression test runs the command from a different directory than the process, which the previous tests did not. bubblewrap cannot mount over a path that is absent when the namespace is assembled, so a store created mid-session stayed readable through the live read-only host-root bind for the rest of a long-lived sandbox session. The directories Zero owns -- its config directory and the per-store publication directories -- are now reported as EnsureDenyReadDirs and created (0700) before the namespace is assembled, so their mask always exists. Third-party stores such as ~/.aws are still skipped when absent: Zero must not create them, and that limitation stays documented on the field. The fixed <store>.tmp publication sibling made the plaintext token, and via the same path its encryption key, observable to a same-user sandboxed process with write access to an override parent: it could wait for the known name and rename the opened inode into an allowed path. Both stores now publish through a per-store <store>.publish directory with a randomly-named file inside. The directory name is derived from the store path, so the profile can deny it up front, while the random name inside is what cannot be waited for or renamed away. A test keeps the duplicated suffix in step with oauth.PublicationDirSuffix. Also adds the seatbelt render coverage that was missing: dropping DenyReadIfExists from denyReadRules made every credential store readable again on macOS without failing a single test. * fix(sandbox): close credential deny review gaps Preserve process credential roots alongside command overrides while limiting host precreation and read carveouts to trusted process-derived paths. Canonicalize future credential roots through existing path aliases, reject symlink carveouts, and collapse automatic masks already covered by user or automatic directory denies. Normalize the policy golden and add regression coverage across profile, Seatbelt, and bubblewrap rendering. Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0 Co-authored-by: Pierre Bruno <[email protected]> * fix(sandbox): normalize credential carveout paths Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0 Co-authored-by: Pierre Bruno <[email protected]> * fix(sandbox): canonicalize backend credential masks Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0 Co-authored-by: Pierre Bruno <[email protected]> * test(sandbox): normalize backend mask expectations Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0 Co-authored-by: Pierre Bruno <[email protected]> * fix(sandbox): fail closed for replaceable token stores Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8 Co-authored-by: Pierre Bruno <[email protected]> * fix(sandbox): allow keyring token path overrides Amp-Thread-ID: https://ampcode.com/threads/T-019fbdfa-8469-731e-87c0-b26b7641cf59 Co-authored-by: Pierre Bruno <[email protected]> * fix(sandbox): harden command credential roots Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-c723-74d9-a323-026040b94436 Co-authored-by: Pierre Bruno <[email protected]> * fix(sandbox): close credential deny review gaps Reapply credential denies after Seatbelt extension carveouts and model the legacy MCP token path as a migration input rather than an atomic writer. Retain Bubblewrap fail-closed markers for OAuth outputs exposed through readable carveouts, and add regressions for both platform policies. Amp-Thread-ID: https://ampcode.com/threads/T-019fbf55-f6b0-71fc-b384-652ba32b9645 Co-authored-by: Pierre Bruno <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]> Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: PierrunoYT <[email protected]> Co-authored-by: Amp <[email protected]>
515 lines
17 KiB
Go
515 lines
17 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")
|
|
}
|
|
if err := validateLinuxBwrapPermissionProfile(config.PermissionProfile); err != nil {
|
|
return linuxSandboxBwrapPlan{}, err
|
|
}
|
|
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 validateLinuxBwrapPermissionProfile(profile PermissionProfile) error {
|
|
if files := profile.FileSystem.ProcessTrustedDenyReadFiles; len(files) > 0 {
|
|
return fmt.Errorf("bubblewrap cannot securely deny credential files outside the Zero config directory across atomic replacement: %s; move the store under $XDG_CONFIG_HOME/zero or add its path to sandbox allowRead", strings.Join(files, ", "))
|
|
}
|
|
// The same limitation, for a token store named by the command's own
|
|
// environment. A /dev/null bind over the pathname is detached by the store's
|
|
// next atomic rename, and an absent one is skipped entirely — so plaintext
|
|
// published during the run stays readable. Refusing the command is the only
|
|
// honest answer bubblewrap can give; a pathname-policy backend enforces
|
|
// these without help. The path is not created or mutated to make the mask
|
|
// work: doing that for a command-supplied value would let a command steer
|
|
// Zero into creating host directories.
|
|
if files := profile.FileSystem.CommandDenyReadFinalFiles; len(files) > 0 {
|
|
return fmt.Errorf("bubblewrap cannot securely deny command-supplied credential files outside the Zero config directory across atomic replacement: %s; move the store under $XDG_CONFIG_HOME/zero or add its path to sandbox allowRead", strings.Join(files, ", "))
|
|
}
|
|
for _, dir := range profile.FileSystem.CommandDenyReadDirs {
|
|
info, err := os.Stat(dir)
|
|
if err != nil || !info.IsDir() {
|
|
return fmt.Errorf("bubblewrap cannot securely deny command-supplied credential directory created after launch: %s; create the directory before running the command, move it outside the command environment, or add its path to sandbox allowRead", dir)
|
|
}
|
|
}
|
|
return 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, fs.DenyReadCarveouts)
|
|
}
|
|
// The profile includes only trusted, process-environment-derived directories
|
|
// here. Command-controlled credential roots remain deny-if-present and must
|
|
// never cause host filesystem mutations before sandbox launch.
|
|
ensureLinuxDenyReadDirs(fs.EnsureDenyReadDirs)
|
|
for _, path := range fs.DenyReadIfExists {
|
|
if !pathExists(path) {
|
|
// A baseline credential path is emitted for every run, so an absent
|
|
// entry is the common case on a fresh machine — a third-party store
|
|
// such as ~/.aws that Zero must not create. The read-all profile starts
|
|
// from a read-only host-root bind where bubblewrap cannot create a
|
|
// missing mount destination, and masking the nearest existing parent
|
|
// could hide HOME, /tmp, or the workspace. Path-based backends
|
|
// (seatbelt) still deny these paths before they exist.
|
|
continue
|
|
}
|
|
args = appendUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts)
|
|
}
|
|
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, carveouts []string) []string {
|
|
path = normalizeProfilePath(path)
|
|
if path == "" {
|
|
return args
|
|
}
|
|
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
|
return append(args, "--ro-bind", "/dev/null", path)
|
|
}
|
|
nested := nestedCarveoutPaths(path, carveouts)
|
|
if len(nested) == 0 {
|
|
return append(args, "--perms", "000", "--tmpfs", path, "--remount-ro", path)
|
|
}
|
|
// A carveout has to stay reachable, and traversing into a directory needs the
|
|
// execute bit, so the mask is 111 (--x--x--x) instead of 000: the directory's
|
|
// contents remain unlistable and unreadable, while an explicitly re-bound
|
|
// subpath below it can still be resolved. The binds must precede the
|
|
// --remount-ro, which is what freezes the tmpfs.
|
|
args = append(args, "--perms", "111", "--tmpfs", path)
|
|
for _, carveout := range nested {
|
|
if info, err := os.Lstat(carveout); err == nil && info.IsDir() {
|
|
args = append(args, "--ro-bind", carveout, carveout)
|
|
}
|
|
}
|
|
return append(args, "--remount-ro", path)
|
|
}
|
|
|
|
// nestedCarveoutPaths returns the carveouts that sit strictly inside root,
|
|
// shallowest first so a parent bind is created before a nested one.
|
|
func nestedCarveoutPaths(root string, carveouts []string) []string {
|
|
if len(carveouts) == 0 {
|
|
return nil
|
|
}
|
|
out := credentialCarveoutPaths([]string{root}, carveouts)
|
|
sort.SliceStable(out, func(i, j int) bool { return pathDepth(out[i]) < pathDepth(out[j]) })
|
|
return dedupeStrings(out)
|
|
}
|
|
|
|
// ensureLinuxDenyReadDirs creates trusted Zero-process directories a deny mask
|
|
// needs to exist for. Best effort: a failure leaves the path unmasked and never
|
|
// blocks the command.
|
|
func ensureLinuxDenyReadDirs(dirs []string) {
|
|
for _, dir := range dirs {
|
|
dir = strings.TrimSpace(dir)
|
|
if dir == "" || pathExists(dir) {
|
|
continue
|
|
}
|
|
_ = os.MkdirAll(dir, 0o700)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|