Files
zero/internal/sandbox/request_permissions.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

420 lines
12 KiB
Go

package sandbox
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
type PermissionGrantScope string
const (
PermissionGrantScopeTurn PermissionGrantScope = "turn"
PermissionGrantScopeSession PermissionGrantScope = "session"
)
type RequestPermissionProfile struct {
Network *NetworkPermissions `json:"network,omitempty"`
FileSystem *FileSystemPermissions `json:"file_system,omitempty"`
}
type RequestPermissionsResponse struct {
Permissions RequestPermissionProfile `json:"permissions"`
Scope PermissionGrantScope `json:"scope"`
StrictAutoReview bool `json:"strict_auto_review,omitempty"`
}
type NetworkPermissions struct {
Enabled *bool `json:"enabled,omitempty"`
}
type FileSystemPermissions struct {
Read []string `json:"read,omitempty"`
Write []string `json:"write,omitempty"`
DenyRead []string `json:"deny_read,omitempty"`
GlobScanMaxDepth *int `json:"glob_scan_max_depth,omitempty"`
Entries []FileSystemSandboxEntry `json:"entries,omitempty"`
}
type FileSystemSandboxEntry struct {
Path FileSystemPath `json:"path"`
Access FileSystemAccessMode `json:"access"`
}
type FileSystemAccessMode string
const (
FileSystemAccessRead FileSystemAccessMode = "read"
FileSystemAccessWrite FileSystemAccessMode = "write"
FileSystemAccessDeny FileSystemAccessMode = "deny"
)
type FileSystemPath struct {
Kind string `json:"kind,omitempty"`
Type string `json:"type,omitempty"`
Path string `json:"path,omitempty"`
Pattern string `json:"pattern,omitempty"`
}
func (path *FileSystemPath) UnmarshalJSON(data []byte) error {
var rawString string
if err := json.Unmarshal(data, &rawString); err == nil {
path.Kind = "path"
path.Type = "path"
path.Path = rawString
return nil
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
_ = json.Unmarshal(raw["kind"], &path.Kind)
_ = json.Unmarshal(raw["type"], &path.Type)
_ = json.Unmarshal(raw["path"], &path.Path)
_ = json.Unmarshal(raw["pattern"], &path.Pattern)
return nil
}
func (profile RequestPermissionProfile) Empty() bool {
networkEmpty := profile.Network == nil || profile.Network.Enabled == nil || !*profile.Network.Enabled
fs := profile.FileSystem
fileSystemEmpty := fs == nil ||
(len(fs.Read) == 0 && len(fs.Write) == 0 && len(fs.DenyRead) == 0 && len(fs.Entries) == 0)
return networkEmpty && fileSystemEmpty
}
func NormalizeRequestPermissionProfile(profile RequestPermissionProfile, basePath string) (RequestPermissionProfile, error) {
var normalized RequestPermissionProfile
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled {
enabled := true
normalized.Network = &NetworkPermissions{Enabled: &enabled}
}
if profile.FileSystem != nil {
fs, err := normalizeFileSystemPermissions(*profile.FileSystem, basePath)
if err != nil {
return RequestPermissionProfile{}, err
}
if len(fs.Read) > 0 || len(fs.Write) > 0 || len(fs.DenyRead) > 0 {
normalized.FileSystem = &fs
}
}
return normalized, nil
}
// RequestPermissionGrantProfile returns the effective grant profile the engine
// can enforce. File-like read/write requests become their normalized parent
// directory roots, matching GrantRequestPermissions and native sandbox profiles.
func RequestPermissionGrantProfile(profile RequestPermissionProfile) (RequestPermissionProfile, error) {
var grant RequestPermissionProfile
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled {
enabled := true
grant.Network = &NetworkPermissions{Enabled: &enabled}
}
if profile.FileSystem != nil {
read, err := grantPermissionRoots(profile.FileSystem.Read)
if err != nil {
return RequestPermissionProfile{}, err
}
write, err := grantPermissionRoots(profile.FileSystem.Write)
if err != nil {
return RequestPermissionProfile{}, err
}
fs := FileSystemPermissions{
Read: read,
Write: write,
DenyRead: append([]string{}, profile.FileSystem.DenyRead...),
}
if len(fs.Read) > 0 || len(fs.Write) > 0 || len(fs.DenyRead) > 0 {
grant.FileSystem = &fs
}
}
return grant, nil
}
func grantPermissionRoots(paths []string) ([]string, error) {
roots := make([]string, 0, len(paths))
for _, path := range paths {
if strings.TrimSpace(path) == "" {
continue
}
root, err := normalizeScopeRoot(permissionRoot(path))
if err != nil {
return nil, err
}
roots = append(roots, root)
}
return dedupeStrings(roots), nil
}
func normalizeFileSystemPermissions(fs FileSystemPermissions, basePath string) (FileSystemPermissions, error) {
var out FileSystemPermissions
var err error
if out.Read, err = normalizePermissionPaths(fs.Read, basePath); err != nil {
return FileSystemPermissions{}, err
}
if out.Write, err = normalizePermissionPaths(fs.Write, basePath); err != nil {
return FileSystemPermissions{}, err
}
if out.DenyRead, err = normalizePermissionPaths(fs.DenyRead, basePath); err != nil {
return FileSystemPermissions{}, err
}
for _, entry := range fs.Entries {
path, ok := entry.Path.pathString()
if !ok {
continue
}
normalized, err := normalizePermissionPath(path, basePath)
if err != nil {
return FileSystemPermissions{}, err
}
switch normalizeFileSystemAccess(entry.Access) {
case FileSystemAccessRead:
out.Read = append(out.Read, normalized)
case FileSystemAccessWrite:
out.Write = append(out.Write, normalized)
case FileSystemAccessDeny:
out.DenyRead = append(out.DenyRead, normalized)
}
}
out.Read = dedupeStrings(out.Read)
out.Write = dedupeStrings(out.Write)
out.DenyRead = dedupeStrings(out.DenyRead)
return out, nil
}
func (path FileSystemPath) pathString() (string, bool) {
kind := strings.TrimSpace(firstNonEmpty(path.Type, path.Kind))
if kind == "" || kind == "path" {
value := strings.TrimSpace(path.Path)
return value, value != ""
}
return "", false
}
func normalizeFileSystemAccess(access FileSystemAccessMode) FileSystemAccessMode {
switch strings.ToLower(strings.TrimSpace(string(access))) {
case "read":
return FileSystemAccessRead
case "write":
return FileSystemAccessWrite
case "deny", "none":
return FileSystemAccessDeny
default:
return ""
}
}
func normalizePermissionPaths(paths []string, basePath string) ([]string, error) {
out := make([]string, 0, len(paths))
for _, path := range paths {
normalized, err := normalizePermissionPath(path, basePath)
if err != nil {
return nil, err
}
if normalized != "" {
out = append(out, normalized)
}
}
return dedupeStrings(out), nil
}
func normalizePermissionPath(path string, basePath string) (string, error) {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return "", nil
}
if !filepath.IsAbs(trimmed) {
base := strings.TrimSpace(basePath)
if base == "" {
var err error
base, err = os.Getwd()
if err != nil {
return "", err
}
}
trimmed = filepath.Join(base, trimmed)
}
absolute, err := filepath.Abs(trimmed)
if err != nil {
return "", err
}
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
return filepath.Clean(resolved), nil
}
return filepath.Clean(absolute), nil
}
func (engine *Engine) GrantRequestPermissions(profile RequestPermissionProfile, scope PermissionGrantScope) (func(), error) {
if engine == nil {
return nil, errors.New("sandbox engine is not configured")
}
if scope == "" {
scope = PermissionGrantScopeTurn
}
var cleanups []func()
cleanup := func() {
for i := len(cleanups) - 1; i >= 0; i-- {
cleanups[i]()
}
}
if engine.scope != nil && profile.FileSystem != nil {
for _, path := range profile.FileSystem.Read {
root := permissionRoot(path)
var undo func()
var err error
if scope == PermissionGrantScopeSession {
_, err = engine.scope.AddRead(root)
} else {
_, undo, err = engine.scope.AddTemporaryRead(root)
if undo != nil {
cleanups = append(cleanups, undo)
}
}
if err != nil {
cleanup()
return nil, fmt.Errorf("grant read permission %s: %w", path, err)
}
}
for _, path := range profile.FileSystem.Write {
root := permissionRoot(path)
var undo func()
var err error
if scope == PermissionGrantScopeSession {
_, err = engine.scope.Add(root)
} else {
_, undo, err = engine.scope.AddTemporaryWrite(root)
if undo != nil {
cleanups = append(cleanups, undo)
}
}
if err != nil {
cleanup()
return nil, fmt.Errorf("grant write permission %s: %w", path, err)
}
}
}
switch scope {
case PermissionGrantScopeSession:
engine.sessionProfiles.add(profile)
return func() {}, nil
case PermissionGrantScopeTurn:
remove := engine.turnProfiles.add(profile)
cleanups = append(cleanups, remove)
return cleanup, nil
default:
cleanup()
return nil, fmt.Errorf("unsupported permission grant scope %q", scope)
}
}
func permissionRoot(path string) string {
clean := filepath.Clean(path)
if info, err := os.Stat(clean); err == nil && info.IsDir() {
return clean
}
return filepath.Dir(clean)
}
func (engine *Engine) effectivePolicy(policy Policy) Policy {
if engine == nil {
return policy
}
for _, profile := range engine.sessionProfiles.list() {
policy = applyRequestPermissionProfile(policy, profile)
}
for _, profile := range engine.turnProfiles.list() {
policy = applyRequestPermissionProfile(policy, profile)
}
return policy
}
func applyRequestPermissionProfile(policy Policy, profile RequestPermissionProfile) Policy {
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled {
policy.Network = NetworkAllow
}
if profile.FileSystem != nil {
policy.AllowRead = dedupeStrings(append(policy.AllowRead, profile.FileSystem.Read...))
policy.AllowWrite = dedupeStrings(append(policy.AllowWrite, profile.FileSystem.Write...))
policy.DenyRead = dedupeStrings(append(policy.DenyRead, profile.FileSystem.DenyRead...))
}
return policy
}
type memoryGrantSet struct {
mu sync.Mutex
grants map[string][]Grant
}
func newMemoryGrantSet() *memoryGrantSet {
return &memoryGrantSet{grants: map[string][]Grant{}}
}
func (set *memoryGrantSet) add(grant Grant) {
if set == nil {
return
}
set.mu.Lock()
defer set.mu.Unlock()
bucket := set.grants[grant.ToolName]
for index := range bucket {
if bucket[index].Scope == grant.Scope && bucket[index].ScopeKind == grant.ScopeKind {
bucket[index] = grant
set.grants[grant.ToolName] = bucket
return
}
}
set.grants[grant.ToolName] = append(bucket, grant)
}
func (set *memoryGrantSet) lookup(toolName string, reqScope string) GrantLookup {
if set == nil {
return GrantLookup{}
}
set.mu.Lock()
defer set.mu.Unlock()
return lookupGrantBucket(set.grants[strings.TrimSpace(toolName)], reqScope)
}
type permissionProfileGrantSet struct {
mu sync.Mutex
nextID int
profiles map[int]RequestPermissionProfile
}
func newPermissionProfileGrantSet() *permissionProfileGrantSet {
return &permissionProfileGrantSet{profiles: map[int]RequestPermissionProfile{}}
}
func (set *permissionProfileGrantSet) add(profile RequestPermissionProfile) func() {
if set == nil {
return func() {}
}
set.mu.Lock()
set.nextID++
id := set.nextID
set.profiles[id] = profile
set.mu.Unlock()
return func() {
set.mu.Lock()
delete(set.profiles, id)
set.mu.Unlock()
}
}
func (set *permissionProfileGrantSet) list() []RequestPermissionProfile {
if set == nil {
return nil
}
set.mu.Lock()
defer set.mu.Unlock()
out := make([]RequestPermissionProfile, 0, len(set.profiles))
for _, profile := range set.profiles {
out = append(out, profile)
}
return out
}