mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
* fix(sandbox): preserve user config and retry denied writes Keep user identity and configuration paths discoverable while retaining managed cache and temporary storage. Classify native sandbox failures as typed recoverable denials so approved commands can retry outside the sandbox. Add regression coverage for global Git ignores, typed denial handling, and permission retry behavior. * fix(sandbox): tighten denial retry safeguards * test(sandbox): assert denial contract * fix(sandbox): trust typed denials and restore Cargo state Stop promoting command output keywords into recoverable unrestricted denials, so only structured adapter facts can enter the approved unsandboxed retry path. Restore the managed CARGO_HOME directory and environment override so Cargo remains writable while the caller's real home stays read-only. * fix(sandbox): mask preserved credential configs Preserving caller configuration made credential-bearing tool files discoverable under Linux's read-all sandbox profile. Add default and environment-overridden npm, GitHub CLI, netrc, Docker, Kubernetes, and Zero config paths to the credential deny set, with regression coverage. Clarify the typed-denial retry and platform read-model comments without changing the approval behavior. * fix(sandbox): protect custom gcloud config CLOUDSDK_CONFIG can relocate gcloud credentials outside the XDG-derived default. Include the configured directory in the credential deny profile and cover the override with a regression test. * fix(sandbox): deny zero credential directory * test(sandbox): keep zero credential directory deny path expectations
229 lines
6.9 KiB
Go
229 lines
6.9 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var sandboxUserCacheDir = os.UserCacheDir
|
|
var sandboxRuntimeNow = time.Now
|
|
|
|
const (
|
|
sandboxRuntimeMaxAge = 30 * 24 * time.Hour
|
|
sandboxRuntimeMaxRoots = 64
|
|
)
|
|
|
|
var fallbackSandboxRuntimes = struct {
|
|
sync.Mutex
|
|
roots map[string]string
|
|
}{roots: make(map[string]string)}
|
|
|
|
type SandboxRuntime struct {
|
|
Root string `json:"root,omitempty"`
|
|
Cache string `json:"cache,omitempty"`
|
|
Data string `json:"data,omitempty"`
|
|
Temp string `json:"temp,omitempty"`
|
|
}
|
|
|
|
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
|
|
workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot))
|
|
if workspaceRoot == "" || workspaceRoot == "." {
|
|
return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root")
|
|
}
|
|
cacheRoot, err := sandboxUserCacheDir()
|
|
if err != nil {
|
|
return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err)
|
|
}
|
|
cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot))
|
|
if cacheRoot == "" || cacheRoot == "." {
|
|
return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable")
|
|
}
|
|
digest := sha256.Sum256([]byte(workspaceRoot))
|
|
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
|
|
if pathWithinRoot(workspaceRoot, root) {
|
|
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
|
|
if err != nil {
|
|
return SandboxRuntime{}, nil, err
|
|
}
|
|
}
|
|
lease, err := prepareSandboxRuntimeLease(root)
|
|
if err != nil {
|
|
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
|
|
if err != nil {
|
|
return SandboxRuntime{}, nil, err
|
|
}
|
|
lease, err = prepareSandboxRuntimeLease(root)
|
|
if err != nil {
|
|
return SandboxRuntime{}, nil, err
|
|
}
|
|
}
|
|
prepared := false
|
|
defer func() {
|
|
if !prepared {
|
|
lease.release()
|
|
}
|
|
}()
|
|
runtimeState := SandboxRuntime{
|
|
Root: root,
|
|
Cache: filepath.Join(root, "cache"),
|
|
Data: filepath.Join(root, "data"),
|
|
Temp: filepath.Join(root, "tmp"),
|
|
}
|
|
directories := []string{
|
|
runtimeState.Root,
|
|
runtimeState.Cache,
|
|
runtimeState.Data,
|
|
runtimeState.Temp,
|
|
filepath.Join(runtimeState.Cache, "npm"),
|
|
filepath.Join(runtimeState.Cache, "yarn"),
|
|
filepath.Join(runtimeState.Cache, "corepack"),
|
|
filepath.Join(runtimeState.Cache, "pip"),
|
|
filepath.Join(runtimeState.Cache, "go-build"),
|
|
filepath.Join(runtimeState.Data, "go-mod"),
|
|
filepath.Join(runtimeState.Data, "cargo"),
|
|
}
|
|
for _, directory := range directories {
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
|
return SandboxRuntime{}, nil, fmt.Errorf("create sandbox runtime directory %s: %w", directory, err)
|
|
}
|
|
if err := os.Chmod(directory, 0o700); err != nil {
|
|
return SandboxRuntime{}, nil, fmt.Errorf("secure sandbox runtime directory %s: %w", directory, err)
|
|
}
|
|
}
|
|
now := sandboxRuntimeNow()
|
|
if err := os.Chtimes(runtimeState.Root, now, now); err != nil {
|
|
return SandboxRuntime{}, nil, fmt.Errorf("touch sandbox runtime root: %w", err)
|
|
}
|
|
cleanupSandboxRuntimeRoots(filepath.Dir(runtimeState.Root), runtimeState.Root, now)
|
|
prepared = true
|
|
return runtimeState, lease.release, nil
|
|
}
|
|
|
|
func prepareSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
|
|
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
|
|
return nil, fmt.Errorf("create sandbox runtime parent: %w", err)
|
|
}
|
|
return acquireSandboxRuntimeLease(root)
|
|
}
|
|
|
|
// cleanupSandboxRuntimeRoots applies a conservative age/count policy. Cleanup
|
|
// is best-effort and never removes the runtime selected for the current
|
|
// command, so cache maintenance cannot turn a valid execution into a failure.
|
|
func cleanupSandboxRuntimeRoots(parent, current string, now time.Time) {
|
|
entries, err := os.ReadDir(parent)
|
|
if err != nil {
|
|
return
|
|
}
|
|
type candidate struct {
|
|
path string
|
|
modTime time.Time
|
|
}
|
|
var candidates []candidate
|
|
for _, entry := range entries {
|
|
path := filepath.Join(parent, entry.Name())
|
|
if !entry.IsDir() || filepath.Clean(path) == filepath.Clean(current) {
|
|
continue
|
|
}
|
|
info, infoErr := entry.Info()
|
|
if infoErr != nil {
|
|
continue
|
|
}
|
|
if now.Sub(info.ModTime()) > sandboxRuntimeMaxAge {
|
|
removeSandboxRuntimeRootIfUnused(path)
|
|
continue
|
|
}
|
|
candidates = append(candidates, candidate{path: path, modTime: info.ModTime()})
|
|
}
|
|
if len(candidates) < sandboxRuntimeMaxRoots {
|
|
return
|
|
}
|
|
sort.Slice(candidates, func(i, j int) bool { return candidates[i].modTime.Before(candidates[j].modTime) })
|
|
for len(candidates) >= sandboxRuntimeMaxRoots {
|
|
removeSandboxRuntimeRootIfUnused(candidates[0].path)
|
|
candidates = candidates[1:]
|
|
}
|
|
}
|
|
|
|
func removeSandboxRuntimeRootIfUnused(root string) {
|
|
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
|
if err != nil || inUse {
|
|
return
|
|
}
|
|
defer lease.release()
|
|
_ = os.RemoveAll(root)
|
|
}
|
|
|
|
func combineSandboxCleanups(cleanups ...func()) func() {
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
for _, cleanup := range cleanups {
|
|
if cleanup != nil {
|
|
cleanup()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) {
|
|
fallbackSandboxRuntimes.Lock()
|
|
defer fallbackSandboxRuntimes.Unlock()
|
|
if root := fallbackSandboxRuntimes.roots[workspaceRoot]; root != "" {
|
|
return root, nil
|
|
}
|
|
parent, err := os.MkdirTemp("", "zero-runtime-")
|
|
if err != nil {
|
|
return "", fmt.Errorf("create fallback sandbox runtime: %w", err)
|
|
}
|
|
root := filepath.Join(parent, "runtime")
|
|
if pathWithinRoot(workspaceRoot, root) {
|
|
_ = os.RemoveAll(parent)
|
|
return "", fmt.Errorf("fallback sandbox runtime root %q must be outside workspace %q", root, workspaceRoot)
|
|
}
|
|
fallbackSandboxRuntimes.roots[workspaceRoot] = root
|
|
return root, nil
|
|
}
|
|
|
|
func sandboxRuntimeEnvironment(env []string, runtimeState *SandboxRuntime) []string {
|
|
if runtimeState == nil || strings.TrimSpace(runtimeState.Root) == "" {
|
|
return env
|
|
}
|
|
overrides := []string{
|
|
"XDG_CACHE_HOME=" + runtimeState.Cache,
|
|
"TMPDIR=" + runtimeState.Temp,
|
|
"TMP=" + runtimeState.Temp,
|
|
"TEMP=" + runtimeState.Temp,
|
|
"npm_config_cache=" + filepath.Join(runtimeState.Cache, "npm"),
|
|
"YARN_CACHE_FOLDER=" + filepath.Join(runtimeState.Cache, "yarn"),
|
|
"COREPACK_HOME=" + filepath.Join(runtimeState.Cache, "corepack"),
|
|
"PIP_CACHE_DIR=" + filepath.Join(runtimeState.Cache, "pip"),
|
|
"GOCACHE=" + filepath.Join(runtimeState.Cache, "go-build"),
|
|
"GOMODCACHE=" + filepath.Join(runtimeState.Data, "go-mod"),
|
|
"CARGO_HOME=" + filepath.Join(runtimeState.Data, "cargo"),
|
|
}
|
|
return upsertEnvList(env, overrides...)
|
|
}
|
|
|
|
func permissionProfileWithRuntime(profile PermissionProfile, runtimeState SandboxRuntime) PermissionProfile {
|
|
profile.Runtime = &runtimeState
|
|
if profile.FileSystem.Kind != FileSystemRestricted || runtimeState.Root == "" {
|
|
return profile
|
|
}
|
|
for _, root := range profile.FileSystem.WriteRoots {
|
|
if filepath.Clean(root.Root) == filepath.Clean(runtimeState.Root) {
|
|
return profile
|
|
}
|
|
}
|
|
profile.FileSystem.WriteRoots = append(profile.FileSystem.WriteRoots, WritableRoot{Root: runtimeState.Root})
|
|
return profile
|
|
}
|