Files
zero/internal/sandbox/profile.go
euxaristia 6fc1220f6a fix(sandbox): scrub sensitive credentials from sandbox environment (#660)
* fix(windows): retry atomic file renames on Access is denied / Sharing violation

* test(swarm): add unit tests for rename retry and non-retryable errors

* test(swarm): skip TestMailboxRenameRetry on non-Windows platforms

* fix(sandbox): scrub sensitive provider credentials from sandbox env

* refactor(sandbox): unify environment creation and reduce lines of code

* fix(sandbox): forward workspaceRoot through sandboxEnvironment, gate Windows env on backend

* style(sandbox,swarm): remove stray blank lines flagged by gofmt

* refactor(fsutil): extract shared rename retry helper, derive scrub list from provider catalog

Address CodeRabbit review feedback:

- Add internal/fsutil.RenameWithRetry with an injectable rename override and
  move the four duplicated renameWithRetry/isWindowsSharingViolation copies
  (cron, sessions, sandbox, swarm) onto it. Mailbox keeps its rename test
  seam by passing its field through. Add fsutil tests for the Windows retry
  and non-retryable paths.
- Remove the orphaned metadata.json.tmp in cron writeJob when the rename
  permanently fails, matching the sibling writers.
- Derive scrubSensitiveEnv keys from providercatalog AuthEnvVars plus a
  static list of non-catalog secrets, so new providers are scrubbed
  automatically. AWS_PROFILE and GOOGLE_APPLICATION_CREDENTIALS stay
  because they are pointers, not secrets.

* fix(sandbox): deny sandboxed reads of cloud credential stores by default

Address CodeRabbit finding that scrubbed env pointers still left the
underlying credential files readable under the read-all workspace posture:

- Add credentialDenyReadPaths: default profile-level deny-read entries for
  ~/.aws, ~/.config/gcloud, ~/.azure, and the GOOGLE_APPLICATION_CREDENTIALS
  target file, wired into both permission profile builders so the seatbelt
  and Linux helper backends enforce them. Entries are stat-filtered and a
  store nested under a user AllowRead entry is dropped, keeping an explicit
  opt-out.
- Keep the defaults out of Policy.DenyRead, whose emptiness gates escalated
  execution, and skip Windows so the profile deny list does not flip the
  runner off the WRITE_RESTRICTED token path.
- Scrub GOOGLE_APPLICATION_CREDENTIALS from the sandbox env; keep AWS_PROFILE
  since the deny-read rule on ~/.aws is the real protection and the SDKs
  fall back to the default profile regardless.
- Add credential deny path tests, extend the scrub test, relax the profile
  test's exact deny count to containment.

* test(cli): pin HOME in sandbox policy golden test

The default credential-store deny-read entries depend on what exists in the
real home directory (the macOS CI image ships ~/.aws), which leaked host
paths into the policy JSON golden comparison. Point HOME at an empty temp
directory so the golden stays hermetic.

* fix(sandbox): protect GOOGLE_APPLICATION_CREDENTIALS without a home, retry lock violations

Address CodeRabbit review feedback:

- credentialDenyReadPaths no longer returns early when the home lookup
  fails; home-based candidates become conditional and the
  GOOGLE_APPLICATION_CREDENTIALS target is always considered.
- fsutil.RenameWithRetry also retries ERROR_LOCK_VIOLATION (errno 33),
  matching the mcp lock handling; helper renamed to
  isWindowsSharingOrLockViolation.
- Extend tests: homeless GOOGLE_APPLICATION_CREDENTIALS case and a lock
  violation step in the retry sequence.

* fix(sandbox): tighten env-scrub test assertion and add missing secret keys

TestScrubSensitiveEnv only checked an allowlist plus a length count, so a
duplicate allowed key silently replacing a required one would still pass.
Compare the exact expected key/value map instead.

Also add ZERO_WEBSEARCH_API_KEY and ZERO_DAEMON_REMOTE_TOKEN to
scrubSensitiveEnv's sensitiveKeys list: both are real bearer secrets read
from the environment but were missing from the scrub list, leaving them
readable by sandboxed commands when set in the parent shell.
2026-07-14 21:46:10 +08:00

287 lines
10 KiB
Go

package sandbox
import (
"os"
"path/filepath"
"runtime"
"strings"
)
type FileSystemPolicyKind string
const (
FileSystemRestricted FileSystemPolicyKind = "restricted"
FileSystemUnrestricted FileSystemPolicyKind = "unrestricted"
FileSystemExternal FileSystemPolicyKind = "external"
)
type PermissionProfile struct {
FileSystem FileSystemPolicy `json:"fileSystem"`
Network NetworkPolicy `json:"network"`
}
type FileSystemPolicy struct {
Kind FileSystemPolicyKind `json:"kind"`
ReadRoots []string `json:"readRoots,omitempty"`
WriteRoots []WritableRoot `json:"writeRoots,omitempty"`
DenyRead []string `json:"denyRead,omitempty"`
DenyWrite []string `json:"denyWrite,omitempty"`
IncludePlatformRoots bool `json:"includePlatformRoots,omitempty"`
AllowTemp bool `json:"allowTemp,omitempty"`
}
type WritableRoot struct {
Root string `json:"root"`
ReadOnlySubpaths []string `json:"readOnlySubpaths,omitempty"`
ProtectedMetadataNames []string `json:"protectedMetadataNames,omitempty"`
}
type NetworkPolicy struct {
Mode NetworkMode `json:"mode"`
}
// protectedMetadataNames marks control-plane directories where the app-level
// auto-allow gate (see relativePathTouchesProtectedMetadata in engine.go)
// always requires a prompt for direct file-tool writes (write_file, edit_file,
// apply_patch): hand-editing git's objects/refs/index or Zero's own state
// bypasses git's and Zero's own consistency checks, regardless of subpath.
var protectedMetadataNames = []string{".git", ".zero", ".agents"}
// sandboxFullyProtectedMetadataNames are the metadata directories the OS-level
// sandbox write-denies in full for shell-executed commands. .git is
// deliberately excluded here: git subprocesses (fetch, commit, add, merge,
// pull, stash, ...) need to write objects, refs, the index, and FETCH_HEAD,
// and those writes go through git's own invariants, unlike a raw file-tool
// write. Only .git/hooks (auto-executing scripts) and .git/config (remote
// URLs, credential.helper, core.hooksPath) stay write-denied, via
// gitMetadataWriteCarveouts below.
var sandboxFullyProtectedMetadataNames = []string{".zero", ".agents"}
// gitMetadataWriteCarveouts returns the .git subpaths that stay write-denied
// under the OS-level sandbox even though the rest of .git is writable to git
// subprocesses. Nonexistent paths are harmless no-ops in every backend's
// enforcement (seatbelt regex, bwrap ro-bind, Windows ACL deny entry).
func gitMetadataWriteCarveouts(root string) []string {
return []string{
filepath.Join(root, ".git", "hooks"),
filepath.Join(root, ".git", "config"),
}
}
func DefaultPermissionProfile(workspaceRoot string) PermissionProfile {
return PermissionProfileFromPolicy(workspaceRoot, DefaultPolicy(), nil)
}
func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Scope) PermissionProfile {
if policy.Mode == "" {
policy = DefaultPolicy()
}
if policy.Mode == ModeDisabled {
return PermissionProfile{
FileSystem: FileSystemPolicy{Kind: FileSystemUnrestricted, IncludePlatformRoots: true, AllowTemp: true},
Network: NetworkPolicy{Mode: NetworkAllow},
}
}
roots := permissionProfileRoots(workspaceRoot, scope)
if extra := normalizeProfileDirs(policy.AllowWrite); len(extra) > 0 {
roots = dedupeStrings(append(roots, extra...))
}
readRoots := permissionProfileReadRoots(workspaceRoot, policy, scope, roots)
writeRoots := make([]WritableRoot, 0, len(roots))
for _, root := range roots {
writeRoots = append(writeRoots, WritableRoot{
Root: root,
ReadOnlySubpaths: gitMetadataWriteCarveouts(root),
ProtectedMetadataNames: append([]string{}, sandboxFullyProtectedMetadataNames...),
})
}
return PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: readRoots,
WriteRoots: writeRoots,
DenyRead: dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...)),
DenyWrite: normalizeProfilePaths(policy.DenyWrite),
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NormalizeNetworkMode(policy.Network)},
}
}
func (profile PermissionProfile) RequiresPlatformSandbox() bool {
if profile.FileSystem.Kind == FileSystemRestricted {
return true
}
return NormalizeNetworkMode(profile.Network.Mode) == NetworkDeny
}
func permissionProfileRoots(workspaceRoot string, scope *Scope) []string {
if scope != nil {
return scope.Roots()
}
var roots []string
if root := normalizeProfilePath(workspaceRoot); root != "" {
roots = append(roots, root)
}
roots = append(roots, defaultTempWriteRoots()...)
return dedupeStrings(roots)
}
func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scope, writeRoots []string) []string {
// Workspace-write follows the upstream sandbox model: full disk is readable,
// while writes are narrowed to workspace/extra roots below. This is a
// deliberate read-all/write-jail posture; callers that must hide secrets use
// DenyRead to carve them out.
readRoots := []string{profileRootPath()}
readRoots = append(readRoots, writeRoots...)
if scope != nil {
readRoots = dedupeStrings(append(readRoots, scope.ReadRoots()...))
} else if root := normalizeProfilePath(workspaceRoot); root != "" {
readRoots = dedupeStrings(append(readRoots, root))
}
if extra := normalizeProfileDirs(policy.AllowRead); len(extra) > 0 {
readRoots = dedupeStrings(append(readRoots, extra...))
}
return dedupeStrings(readRoots)
}
// credentialDenyReadPaths returns default deny-read entries for well-known
// credential stores (~/.aws, ~/.config/gcloud, ~/.azure, and the file
// GOOGLE_APPLICATION_CREDENTIALS points to) so sandboxed commands cannot read
// cloud secrets under the read-all workspace posture. Two deliberate limits:
//
// - Windows is skipped: a non-empty profile DenyRead switches the Windows
// runner onto the capability-SID/ACL deny path and away from the
// WRITE_RESTRICTED token, which the unelevated tier depends on. Revisit
// once the Windows deny-read model is settled.
// - A candidate nested under a user-configured AllowRead entry is dropped,
// so `allowRead: ["~/.aws"]` remains an explicit opt-out.
//
// These are profile-level rules only; they are intentionally NOT merged into
// Policy.DenyRead, whose emptiness gates escalated (unsandboxed) execution and
// must keep reflecting user configuration alone.
func credentialDenyReadPaths(policy Policy) []string {
if runtime.GOOS == "windows" {
return nil
}
// A failed home lookup only drops the home-based candidates; the
// GOOGLE_APPLICATION_CREDENTIALS target must be protected regardless.
home, _ := os.UserHomeDir()
return credentialDenyReadPathsIn(home, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), policy.AllowRead)
}
// credentialDenyReadPathsIn is the pure core of credentialDenyReadPaths,
// separated so tests can exercise it against a synthetic home directory.
func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead []string) []string {
var candidates []string
if home = strings.TrimSpace(home); home != "" {
candidates = append(candidates,
filepath.Join(home, ".aws"),
filepath.Join(home, ".config", "gcloud"),
filepath.Join(home, ".azure"),
)
}
if target := strings.TrimSpace(googleCredentials); target != "" {
candidates = append(candidates, target)
}
allowRoots := normalizeProfilePaths(allowRead)
out := make([]string, 0, len(candidates))
for _, path := range normalizeProfilePaths(candidates) {
// Only stores that actually exist on this host need a deny rule.
if _, err := os.Stat(path); err != nil {
continue
}
reincluded := false
for _, allow := range allowRoots {
if pathWithinRoot(allow, path) {
reincluded = true
break
}
}
if !reincluded {
out = append(out, path)
}
}
return out
}
// userGitConfigReadPaths returns the user's global git config FILES so a
// sandboxed git can read identity and config (user.name/email, aliases) instead
// of failing with "unable to access ~/.gitconfig". It is deliberately the config
// files only — not the ~/.config/git directory, which can hold an XDG credential
// store — so credentials and the rest of HOME stay unreadable. Granted at the
// macOS-seatbelt read rule (not the cross-platform PermissionProfile) so the
// HOME-dependent paths don't leak into the platform-agnostic policy snapshot.
func userGitConfigReadPaths() []string {
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
return nil
}
return []string{
filepath.Join(home, ".gitconfig"),
filepath.Join(home, ".config", "git", "config"),
}
}
func profileRootPath() string {
return filepath.Clean(string(filepath.Separator))
}
func normalizeProfileDirs(entries []string) []string {
paths := normalizeProfilePaths(entries)
if len(paths) == 0 {
return nil
}
out := make([]string, 0, len(paths))
for _, path := range paths {
if info, err := os.Stat(path); err == nil && info.IsDir() && filepath.Dir(path) != path {
out = append(out, path)
}
}
return out
}
func normalizeProfilePaths(entries []string) []string {
if len(entries) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(entries))
for _, entry := range entries {
path := normalizeProfilePath(entry)
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
out = append(out, path)
}
return out
}
func normalizeProfilePath(entry string) string {
trimmed := strings.TrimSpace(entry)
if trimmed == "" {
return ""
}
if trimmed == "~" || strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, "~"+string(filepath.Separator)) {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
trimmed = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(trimmed[1:], "/"), string(filepath.Separator)))
}
absolute, err := filepath.Abs(trimmed)
if err != nil {
return ""
}
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
return resolved
}
return filepath.Clean(absolute)
}