Files
zero/internal/sandbox/landlock_linux.go
Anandan ac6addaa83 Align sandbox permission blocks and backend states (#253)
* Prompt for shell network permissions

Route network-risk shell calls through the existing permission prompt instead of hard-denying them at sandbox preflight. Approved network prompts apply the current turn/session request-permissions overlay so the effective sandbox policy is widened only after user approval.

Move network command detection into the shell AST analyzer for package installers, gh/git network subcommands, and python http.server so quoted process patterns such as pkill -f "python3 -m http.server 8000" are not misclassified as network activity.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestEngineClassifiesNetworkAndDestructiveShellCommands|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBash|TestRegistryRunsBashThroughSandboxEngine'

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Auto-allow native-sandboxed shell commands

Remove the old hidden bash auto-allow environment/config path. Ordinary shell commands now use the active native sandbox as the permission boundary by default, while network, destructive, and workspace policy checks still run first.

Policy-only and disabled sandbox backends continue to require approval for shell commands, so unsandboxed execution is not silently allowed.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxedBash|TestShellSandboxActive|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBashAuto|TestBashStillPromptsWithoutActiveSandbox|TestRegistryRunsBashThroughSandboxEngine'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestApplyConfiguredSandboxPolicy|TestApplyConfiguredAutonomyCeiling'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/daemon -run 'TestScrubWorkerEnvRemovesReentrancyMarkers'

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Prompt for destructive shell approval

Remove the separate DenyDestructiveShell policy layer from active sandbox decisions. Destructive shell classification now produces an approval prompt in ask/auto flows instead of a sandbox hard-deny, while no-approval paths remain blocked.

Drop the removed destructive-shell policy field from sandbox policy output, backend capabilities, snapshots, and Windows golden data.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsForDestructiveShellInsteadOfSandboxDeny|TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineClassifiesNetworkAndDestructiveShellCommands|TestBackendCapabilitiesReflectDisabledPolicy'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicy|TestEffectiveSandboxPolicy'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/zerocommands -run 'TestSandboxPolicySnapshotFromPolicyFillsEffectiveMode'

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Remove web-tool network policy switch

Remove the old EnforceToolNetwork policy option and the NetworkHostAllowed per-tool gate so sandbox network policy only controls sandboxed shell egress.

Keep web_search and web_fetch on their own tool permission and URL safety paths, with tests proving deny/scoped shell network policy no longer blocks in-process web tools.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy|TestEngineAllowsNetworkSideEffectWhenShellPolicyBlocksNetwork|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestScopedNetworkGateInEvaluate'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestWebSearchRunWithSandbox|TestWebFetchRunWithSandbox|TestRegistryRoutesWebFetchThroughSandboxPath'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant'

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

Tested: git diff --check

* Prompt for outside-workspace file access

Convert recoverable outside-workspace file path violations into permission prompts instead of hard-denying in ask/auto modes.

Approved prompts now grant the requested filesystem scope through the existing request_permissions overlay for the current turn or session, so the follow-up sandbox evaluation and scoped file tools use the same policy path.

Keep unsafe mode, apply_patch escapes, symlink traversal, and non-recoverable path cases hard-denied.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineAutoAllowsWorkspaceFileMutationTools|TestEngineDeniesOutOfWorkspacePaths|TestGrantRequestPermissionsReadDoesNotGrantWrite|TestGrantRequestPermissionsSessionPersists'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsAndAllowsOutsideWorkspaceWrite|TestRunSessionAllowsLaterOutsideWorkspaceWrite|TestRunAppliesSandboxEvenInUnsafeMode'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryAppliesSandboxBeforeToolExecution|TestRegistrySandboxGatesPathAliasKeys|TestRegistryAllowsPromptToolWithPersistentSandboxGrant'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite'

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

Tested: git diff --check

* Fix Windows network prompt test fixture

Use a Windows curl.cmd shim and cmd.exe PATH syntax for TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant while keeping the POSIX fake curl fixture for Linux and macOS.

This keeps the test focused on network prompt approval and turn network grants instead of relying on a POSIX shell script being executable on Windows.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant|TestRunPersistentCommandPrefixStillPromptsForNetwork'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestClassifyASTCatchesNetworkProgramsRegexMisses|TestClassifyFlagsPipedInstallerVariants'

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

Tested: git diff --check

* Remove sandbox autonomy ceilings

Sandbox permission decisions now match grants by tool and scope only, without MaxAutonomy or request autonomy checks. Persistent/session grants, permission approvals, workspace-write auto-allow, and native-sandbox shell auto-allow no longer route through an autonomy ceiling.

Remove the sandbox.maxAutonomy config and CLI surfaces, including grant --auto, policy output, sandbox check autonomy input, and sandbox grant snapshots.

MCP permission autonomy is intentionally unchanged because it is a separate MCP permission model.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/zerocommands ./internal/config ./internal/cli ./internal/tui

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Fail closed without native sandbox

Remove AllowPolicyOnlyRunner from sandbox policy and snapshots. A restricted/managed command plan now errors when native sandbox support is unavailable instead of running through a policy-only direct runner.

Policy-only backend data remains as diagnostics for capability/reporting paths, but it no longer authorizes command execution when a platform sandbox is required.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/zerocommands ./internal/cli ./internal/tui

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Remove scoped shell network policy

Drop the domain-scoped shell network mode and the local scoped-egress proxy path from the active sandbox model.

Shell network policy now serializes and evaluates only deny or allow. Request-permissions network grants widen to allow without domain lists, and platform profiles consume the binary mode directly.

The Linux helper no longer accepts proxy-route flags, Windows network hashes only the binary mode, and web tool tests assert they remain separate from shell network policy.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/notify

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Align permission grant ordering and prompt text

Move persistent and session grant matching ahead of recoverable sandbox prompts while keeping explicit denies and non-recoverable path violations fail-closed.

Clean TUI permission transcript/card text so normal prompts show action, reason, scope, and readable block details instead of raw permission/risk/violation key-value metadata.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tui ./internal/agent ./internal/tools

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

* Remove policy-only sandbox terminology

Replace the unavailable native-backend diagnostic path with explicit unavailable support/backend states, and stop presenting missing native isolation as a policy-only fallback.

Rename structured sandbox denial metadata from violation to block across permission events, stream-json, snapshots, TUI rendering, and sandbox-check output so user-facing flow matches blocked action language.

Update README, work split docs, smoke test selectors, and Windows sandbox policy golden output for the new backend/support names and block payloads.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/tui ./internal/cli ./internal/doctor ./internal/zerocommands ./internal/streamjson

Tested: GOCACHE=/tmp/zero-go-cache go test ./...

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Fix sandbox review follow-ups

Fail closed for unknown network modes, keep network prompts ahead of bash allow grants, preserve v1 grant-file reads, and classify unparseable commands with obvious network programs as network-risk.

Bump stream-json schema to v2 and document the block metadata field after the violation-to-block rename.

Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/streamjson ./internal/specialist ./internal/cli -run 'TestExec.*Stream|TestStreamJSON|TestExecProtocol|TestExecReadsStreamJSONImages|TestExecAcceptsImageOnlyStreamJSONMessage|TestExecInputFormatStreamJSON'

Tests: GOCACHE=/tmp/zero-go-cache go test ./...

Tests: scripts/sandbox-smoke.sh

Tests: git diff --check
2026-06-19 19:50:12 +05:30

246 lines
6.4 KiB
Go

//go:build linux
package sandbox
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"unsafe"
"golang.org/x/sys/unix"
)
// ApplyLandlockFilesystemProfile applies the helper's fallback Linux filesystem
// sandbox to the current process. The caller must exec the final command after
// this returns.
func ApplyLandlockFilesystemProfile(profile PermissionProfile, cwd string) error {
if err := validateLandlockProfile(profile); err != nil {
return err
}
needsFilesystemRules := profile.FileSystem.Kind != FileSystemUnrestricted
needsNetworkDeny := profile.Network.Mode == NetworkDeny
if !needsFilesystemRules && !needsNetworkDeny {
return nil
}
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
return fmt.Errorf("set no_new_privs: %w", err)
}
if needsNetworkDeny {
if err := ApplyLinuxNetworkDeny(); err != nil {
return err
}
}
if !needsFilesystemRules {
return nil
}
writeRoots := landlockWritableRoots(profile.FileSystem, cwd)
if len(writeRoots) == 0 {
return errors.New("Landlock requires at least one writable root")
}
return installLandlockFilesystemRules(writeRoots)
}
func validateLandlockProfile(profile PermissionProfile) error {
fs := profile.FileSystem
if fs.Kind != FileSystemRestricted {
return nil
}
if len(fs.DenyRead) > 0 {
return errors.New("deny-read paths require the bubblewrap helper mode")
}
if len(fs.DenyWrite) > 0 {
return errors.New("deny-write paths require the bubblewrap helper mode")
}
for _, root := range fs.WriteRoots {
if len(root.ReadOnlySubpaths) > 0 || len(root.ProtectedMetadataNames) > 0 {
return errors.New("read-only write-root subpaths require the bubblewrap helper mode")
}
}
if !landlockHasFullReadAccess(fs) {
return errors.New("restricted read roots are not supported by Landlock helper mode")
}
return nil
}
func landlockHasFullReadAccess(fs FileSystemPolicy) bool {
if fs.Kind == FileSystemUnrestricted {
return true
}
for _, root := range fs.ReadRoots {
if filepath.Clean(root) == string(filepath.Separator) {
return true
}
}
return false
}
func landlockWritableRoots(fs FileSystemPolicy, cwd string) []string {
roots := make([]string, 0, len(fs.WriteRoots)+1)
add := func(root string) {
root = strings.TrimSpace(root)
if root == "" {
return
}
if abs, err := filepath.Abs(root); err == nil {
root = abs
}
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if pathExists(root) {
roots = append(roots, filepath.Clean(root))
}
}
add(cwd)
for _, root := range fs.WriteRoots {
add(root.Root)
}
return dedupeStrings(roots)
}
func installLandlockFilesystemRules(writeRoots []string) error {
abi, err := queryLandlockABI()
if err != nil {
return err
}
handledAccess := landlockHandledFilesystemAccess(abi)
if handledAccess == 0 {
return errors.New("kernel did not report a usable Landlock filesystem ABI")
}
rulesetAttr := unix.LandlockRulesetAttr{Access_fs: handledAccess}
rulesetFD, err := landlockCreateRuleset(&rulesetAttr, unsafe.Sizeof(rulesetAttr), 0)
if err != nil {
return fmt.Errorf("create ruleset: %w", err)
}
defer unix.Close(rulesetFD)
readAccess := landlockReadFilesystemAccess(abi)
if err := landlockAddPathRule(rulesetFD, string(filepath.Separator), readAccess); err != nil {
return err
}
if pathExists("/dev/null") {
if err := landlockAddPathRule(rulesetFD, "/dev/null", landlockFileReadWriteAccess(abi)); err != nil {
return err
}
}
for _, root := range writeRoots {
if err := landlockAddPathRule(rulesetFD, root, handledAccess); err != nil {
return err
}
}
if err := landlockRestrictSelf(rulesetFD); err != nil {
return fmt.Errorf("restrict self: %w", err)
}
return nil
}
func queryLandlockABI() (int, error) {
abi, err := landlockCreateRuleset(nil, 0, unix.LANDLOCK_CREATE_RULESET_VERSION)
if err != nil {
return 0, fmt.Errorf("query ABI: %w", err)
}
if abi <= 0 {
return 0, fmt.Errorf("query ABI returned %d", abi)
}
return abi, nil
}
func landlockCreateRuleset(attr *unix.LandlockRulesetAttr, size uintptr, flags int) (int, error) {
var attrPtr uintptr
if attr != nil {
attrPtr = uintptr(unsafe.Pointer(attr))
}
fd, _, errno := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, attrPtr, size, uintptr(flags))
if errno != 0 {
return -1, errno
}
return int(fd), nil
}
func landlockAddPathRule(rulesetFD int, path string, allowedAccess uint64) error {
pathFD, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("open rule path %s: %w", path, err)
}
defer unix.Close(pathFD)
rule := unix.LandlockPathBeneathAttr{
Allowed_access: allowedAccess,
Parent_fd: int32(pathFD),
}
_, _, errno := unix.Syscall6(
unix.SYS_LANDLOCK_ADD_RULE,
uintptr(rulesetFD),
uintptr(unix.LANDLOCK_RULE_PATH_BENEATH),
uintptr(unsafe.Pointer(&rule)),
0,
0,
0,
)
if errno != 0 {
return fmt.Errorf("add path rule %s: %w", path, errno)
}
return nil
}
func landlockRestrictSelf(rulesetFD int) error {
_, _, errno := unix.Syscall(unix.SYS_LANDLOCK_RESTRICT_SELF, uintptr(rulesetFD), 0, 0)
if errno != 0 {
return errno
}
return nil
}
func landlockHandledFilesystemAccess(abi int) uint64 {
access := uint64(unix.LANDLOCK_ACCESS_FS_EXECUTE |
unix.LANDLOCK_ACCESS_FS_WRITE_FILE |
unix.LANDLOCK_ACCESS_FS_READ_FILE |
unix.LANDLOCK_ACCESS_FS_READ_DIR |
unix.LANDLOCK_ACCESS_FS_REMOVE_DIR |
unix.LANDLOCK_ACCESS_FS_REMOVE_FILE |
unix.LANDLOCK_ACCESS_FS_MAKE_CHAR |
unix.LANDLOCK_ACCESS_FS_MAKE_DIR |
unix.LANDLOCK_ACCESS_FS_MAKE_REG |
unix.LANDLOCK_ACCESS_FS_MAKE_SOCK |
unix.LANDLOCK_ACCESS_FS_MAKE_FIFO |
unix.LANDLOCK_ACCESS_FS_MAKE_BLOCK |
unix.LANDLOCK_ACCESS_FS_MAKE_SYM)
if abi >= 2 {
access |= unix.LANDLOCK_ACCESS_FS_REFER
}
if abi >= 3 {
access |= unix.LANDLOCK_ACCESS_FS_TRUNCATE
}
if abi >= 5 {
access |= unix.LANDLOCK_ACCESS_FS_IOCTL_DEV
}
return access
}
func landlockReadFilesystemAccess(abi int) uint64 {
access := uint64(unix.LANDLOCK_ACCESS_FS_EXECUTE |
unix.LANDLOCK_ACCESS_FS_READ_FILE |
unix.LANDLOCK_ACCESS_FS_READ_DIR)
if abi >= 5 {
access |= unix.LANDLOCK_ACCESS_FS_IOCTL_DEV
}
return access
}
func landlockFileReadWriteAccess(abi int) uint64 {
access := uint64(unix.LANDLOCK_ACCESS_FS_READ_FILE |
unix.LANDLOCK_ACCESS_FS_WRITE_FILE)
if abi >= 3 {
access |= unix.LANDLOCK_ACCESS_FS_TRUNCATE
}
if abi >= 5 {
access |= unix.LANDLOCK_ACCESS_FS_IOCTL_DEV
}
return access
}