Files
zero/internal/sandbox/manager.go
PatrickNoFilter 0f69d995e9 fix: Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs (#509)
* fix(tui): TracerPid PRoot detection, Ctrl+U/D direction, composer guard

- Replace Termux env var detection with isRunningUnderPRoot()
  via /proc/self/status TracerPid, cached with sync.OnceValue
- Mouse mode falls back to CellMotion (not AllMotion) under PRoot
  to fix broken touch scrolling
- Add Shift+Up/Shift+Down before plain KeyDown/KeyUp for transcript
  scroll with permission/askuser cursor + composer guard
- Add Ctrl+U (scroll up) and Ctrl+D (scroll down) with proper
  direction: Ctrl+U=move up, Ctrl+D=move down
- Add guards for active modals (wizard, picker, spec review, etc.)
  to avoid intercepting their Ctrl+U/D keybindings
- Tests: TestPermissionCursorCtrlU/D (direction regression),
  TestShiftUp/DownComposerGuard (composer guard)
- References issue #507

* fix(sandbox): avoid faccessat2 for Termux/Android portability

Replace exec.LookPath with lookupExecutable that uses os.Stat
instead of the faccessat2 syscall. The faccessat2 syscall (439)
is blocked by the Samsung seccomp filter on Android, causing
SIGSYS termination. os.Stat uses the newfstatat/statx syscalls
which are universally permitted.

This is part of broader Termux/Android support for Zero.

* docs(install): add Termux/Android build and configuration guide

Document how to build Zero for native Android/Termux: GOOS=android
to avoid the SIGSYS crash, proot bind-mount for DNS resolution,
PRoot scroll fix, and free-tier provider setup.

* fix(sandbox): skip empty PATH segments, use platform-aware executable check
2026-07-05 21:48:34 +08:00

341 lines
13 KiB
Go

package sandbox
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
)
// errWindowsSandboxNotInitialized is returned only to a caller that explicitly
// REQUIRED the sandbox (--sandbox require) on Windows before `zero sandbox setup`
// has run. The default (auto) preference degrades instead — see BuildExecutionRequest.
var errWindowsSandboxNotInitialized = errors.New(
"the Windows sandbox is not initialized — run `zero sandbox setup` from an elevated (Administrator) terminal, " +
"or drop `--sandbox require` to run with workspace path-confinement and per-command approval")
// windowsSetupDowngradeReason is surfaced when Windows falls back to the
// in-process policy gate because the OS sandbox has not been set up.
const windowsSetupDowngradeReason = "Windows OS sandbox inactive — commands run with workspace path-confinement and " +
"per-command approval only. Run `zero sandbox setup` from an elevated (Administrator) terminal for full filesystem and network isolation."
// windowsUnelevatedDowngradeReason is surfaced when Windows runs the sandbox in
// the unelevated tier: the restricted-token write-jail is enforced (the command
// runner applies the workspace ACLs itself, which needs no Administrator
// rights), but the WFP network filters are Administrator-only and absent.
const windowsUnelevatedDowngradeReason = "Windows sandbox running unelevated — the filesystem write-jail is enforced, but network " +
"isolation still relies on per-command approval. Run `zero sandbox setup` from an elevated (Administrator) terminal for OS-level network enforcement."
// windowsSandboxInitialized reports whether the per-host Windows sandbox setup
// marker exists. A missing marker is the common fresh-install state, so the
// caller degrades rather than bricking the command. Indirected through a var so
// tests can drive both the initialized and not-initialized paths.
var windowsSandboxInitialized = func() bool {
home, err := ResolveWindowsSandboxHome(nil)
if err != nil || home == "" {
return false
}
_, statErr := os.Stat(WindowsSandboxSetupMarkerPath(home))
return statErr == nil
}
type SandboxPreference string
const (
SandboxPreferenceAuto SandboxPreference = "auto"
SandboxPreferenceRequire SandboxPreference = "require"
SandboxPreferenceForbid SandboxPreference = "forbid"
)
type SandboxManagerOptions struct {
GOOS string
LookupExecutable func(string) (string, error)
Backend Backend
}
type SandboxManager struct {
goos string
backend Backend
}
type SandboxManagerRequest struct {
WorkspaceRoot string
Command CommandSpec
Policy Policy
Scope *Scope
Profile PermissionProfile
Preference SandboxPreference
ValidateExecution bool
}
type SandboxExecutionRequest struct {
Command CommandSpec `json:"command"`
WorkspaceRoot string `json:"workspaceRoot"`
PermissionProfile PermissionProfile `json:"permissionProfile"`
Backend Backend `json:"backend"`
TargetBackend BackendName `json:"targetBackend"`
CommandWrapped bool `json:"commandWrapped"`
SandboxEnvMarkers []string `json:"sandboxEnvMarkers,omitempty"`
EnforcementLevel EnforcementLevel `json:"enforcementLevel"`
DowngradeReason string `json:"downgradeReason,omitempty"`
SupportLevel BackendSupportLevel `json:"supportLevel"`
RequiresPlatformSandbox bool `json:"requiresPlatformSandbox"`
}
func NewSandboxManager(options SandboxManagerOptions) SandboxManager {
backend := options.Backend
if backend.Name != "" && backend.Platform == "" {
backend.Platform = platformForBackendName(backend.Name)
}
goos := options.GOOS
if goos == "" {
goos = backend.Platform
}
if goos == "" {
goos = runtime.GOOS
}
if backend.Name == "" {
backend = selectPlatformBackend(goos, options.LookupExecutable)
}
if backend.Platform == "" {
backend.Platform = goos
}
backend = inferBackendCapabilities(backend)
return SandboxManager{goos: goos, backend: backend}
}
func platformForBackendName(name BackendName) string {
switch name {
case BackendMacOSSeatbelt:
return "darwin"
case BackendLinuxBwrap, BackendLinuxLandlock, BackendWSL:
return "linux"
case BackendWindowsRestrictedToken, BackendWindowsElevated:
return "windows"
default:
return ""
}
}
func (manager SandboxManager) Backend() Backend {
return manager.backend
}
// isExecutable checks whether a file is executable. On Unix, this checks the
// execute permission bits. On Windows, Go's os.FileMode does not set execute
// bits, so we check for common executable extensions instead.
func isExecutable(fi os.FileInfo) bool {
if !fi.Mode().IsRegular() {
return false
}
if runtime.GOOS == "windows" {
name := strings.ToLower(fi.Name())
return strings.HasSuffix(name, ".exe") ||
strings.HasSuffix(name, ".com") ||
strings.HasSuffix(name, ".bat") ||
strings.HasSuffix(name, ".cmd")
}
return fi.Mode()&0o111 != 0
}
// lookupExecutable checks whether a named binary exists and is executable,
// using os.Stat instead of exec.LookPath. exec.LookPath internally uses the
// faccessat2 syscall, which is blocked by Android's seccomp filter (SIGSYS).
func lookupExecutable(name string) (string, error) {
if !strings.Contains(name, string(os.PathSeparator)) {
path := os.Getenv("PATH")
for _, dir := range strings.Split(path, string(os.PathListSeparator)) {
if dir == "" {
continue // skip empty entries (e.g. trailing colon)
}
candidate := filepath.Join(dir, name)
if fi, err := os.Stat(candidate); err == nil {
if isExecutable(fi) {
return candidate, nil
}
}
}
return "", errors.New("executable file not found in $PATH")
}
fi, err := os.Stat(name)
if err != nil {
return "", err
}
if isExecutable(fi) {
return name, nil
}
return "", errors.New("executable file not found")
}
func selectPlatformBackend(goos string, lookup func(string) (string, error)) Backend {
if lookup == nil {
lookup = lookupExecutable
}
switch goos {
case "linux":
if helper, err := lookup(LinuxSandboxHelperName); err == nil && helper != "" {
if _, bwrapErr := lookup("bwrap"); bwrapErr != nil {
return unavailableBackend(goos, "bubblewrap is not installed")
}
return nativeBackend(goos, BackendLinuxBwrap, helper, "Linux sandbox helper available")
}
if info := detectWSL(); info.IsWSL {
return wslBackend(goos, info)
}
return unavailableBackend(goos, "Linux sandbox helper is not available")
case "darwin":
if path, err := lookup("sandbox-exec"); err == nil && path != "" {
return nativeBackend(goos, BackendMacOSSeatbelt, path, "macOS Seatbelt backend available")
}
return unavailableBackend(goos, "sandbox-exec is not available")
case "windows":
runner := findWindowsSandboxCommandRunner(lookup)
setup := findWindowsSandboxSetupHelper(lookup)
if runner.Available() && setup.Available() {
backend := nativeBackend(goos, BackendWindowsRestrictedToken, runner.Name, "Windows sandbox command runner and setup helper available")
backend.ExecutableArgsPrefix = runner.ArgsPrefix
return backend
}
if runner.Available() {
return unavailableBackend(goos, "Windows sandbox setup helper is not available")
}
return unavailableBackend(goos, "Windows sandbox command runner is not available")
default:
return unavailableBackend(goos, "no platform sandbox adapter is available for "+goos)
}
}
func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerRequest) (SandboxExecutionRequest, error) {
policy := request.Policy
if policy.Mode == "" {
policy = DefaultPolicy()
}
preference := request.Preference
if preference == "" {
preference = SandboxPreferenceAuto
}
profile := request.Profile
if permissionProfileUnset(profile) {
profile = PermissionProfileFromPolicy(request.WorkspaceRoot, policy, request.Scope)
}
requiresPlatformSandbox := profile.RequiresPlatformSandbox() && preference != SandboxPreferenceForbid
backend := manager.backend
enforcementLevel := backend.EnforcementLevel(policy)
if preference == SandboxPreferenceForbid || policy.Mode == ModeDisabled || !requiresPlatformSandbox {
enforcementLevel = EnforcementDisabled
}
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
}
// Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup`
// (it applies WFP network filters + workspace ACLs and writes a marker).
// Without it, a restricted-filesystem profile can still run in the UNELEVATED
// tier: the command runner applies the workspace ACL plan itself (DACL edits
// on user-owned roots need no Administrator rights) and wraps the command in
// the write-restricted token, so the filesystem write-jail holds. Only the
// WFP network filters are Administrator-only, so network stays with the
// in-process approval gate at that tier. A profile that needs the sandbox
// solely for network isolation gains nothing from the unelevated tier and
// DEGRADES to the policy gate as before. A strict caller (--sandbox require)
// still gets a clear error pointing at setup.
windowsNeedsSetup := false
if manager.goos == "windows" && requiresPlatformSandbox && enforcementLevel == EnforcementNative && !windowsSandboxInitialized() {
if preference == SandboxPreferenceRequire {
return SandboxExecutionRequest{}, errWindowsSandboxNotInitialized
}
windowsNeedsSetup = true
if profile.FileSystem.Kind == FileSystemRestricted {
enforcementLevel = EnforcementUnelevated
} else {
enforcementLevel = EnforcementDegraded
}
}
targetBackend := manager.targetBackend(preference, policy, requiresPlatformSandbox)
downgradeReason := ""
if requiresPlatformSandbox && enforcementLevel == EnforcementDegraded {
downgradeReason = backend.DowngradeReason(policy)
if windowsNeedsSetup {
downgradeReason = windowsSetupDowngradeReason
}
}
if enforcementLevel == EnforcementUnelevated {
downgradeReason = windowsUnelevatedDowngradeReason
}
wrapped := backend.CommandWrapping && backend.Available &&
(enforcementLevel == EnforcementNative || enforcementLevel == EnforcementUnelevated)
markers := backend.SandboxEnvMarkers(policy)
if !wrapped && backend.Name != BackendWSL {
markers = nil
}
return SandboxExecutionRequest{
Command: request.Command,
WorkspaceRoot: request.WorkspaceRoot,
PermissionProfile: profile,
Backend: backend,
TargetBackend: targetBackend,
CommandWrapped: wrapped,
SandboxEnvMarkers: markers,
EnforcementLevel: enforcementLevel,
DowngradeReason: downgradeReason,
SupportLevel: backend.SupportLevel(),
RequiresPlatformSandbox: requiresPlatformSandbox,
}, nil
}
func (manager SandboxManager) BuildCommandPlan(request SandboxManagerRequest) (CommandPlan, error) {
execRequest, err := manager.BuildExecutionRequest(request)
if err != nil {
return CommandPlan{}, err
}
policy := request.Policy
if policy.Mode == "" {
policy = DefaultPolicy()
}
return buildPlatformCommandPlan(execRequest, policy)
}
func (manager SandboxManager) targetBackend(preference SandboxPreference, policy Policy, requiresPlatformSandbox bool) BackendName {
if preference == SandboxPreferenceForbid || policy.Mode == ModeDisabled || !requiresPlatformSandbox {
return BackendNone
}
if manager.backend.Name == BackendWSL {
return BackendLinuxBwrap
}
return manager.backend.TargetBackend()
}
func (request SandboxExecutionRequest) BackendPlan(policy Policy) BackendPlan {
return BackendPlan{
Backend: request.Backend,
TargetBackend: request.TargetBackend,
WorkspaceRoot: request.WorkspaceRoot,
Policy: policy,
PermissionProfile: request.PermissionProfile,
CommandWrapped: request.CommandWrapped,
SandboxEnvMarkers: request.SandboxEnvMarkers,
EnforcementLevel: request.EnforcementLevel,
DowngradeReason: request.DowngradeReason,
SupportLevel: request.SupportLevel,
RequiresPlatformSandbox: request.RequiresPlatformSandbox,
Capabilities: request.Backend.Capabilities(policy),
Restrictions: request.Backend.restrictions(policy),
Warnings: request.Backend.Warnings(),
}
}
func permissionProfileUnset(profile PermissionProfile) bool {
return profile.FileSystem.Kind == "" && profile.Network.Mode == ""
}
func inferBackendCapabilities(backend Backend) Backend {
if backend.Available && backend.Executable != "" {
switch backend.Name {
case BackendLinuxBwrap, BackendMacOSSeatbelt, BackendWindowsRestrictedToken:
backend.CommandWrapping = true
backend.NativeIsolation = true
}
}
return backend
}