mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 19:13:28 -10:00
* 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.
287 lines
10 KiB
Go
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)
|
|
}
|