Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/cli/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,14 @@ func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
}

func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
// Point HOME at an empty directory so the default credential-store
// deny-read entries (which depend on what exists in the real home, e.g.
// ~/.aws on the macOS CI image) cannot leak host paths into the golden
// comparison.
emptyHome := t.TempDir()
t.Setenv("HOME", emptyHome)
t.Setenv("USERPROFILE", emptyHome)
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "")
store := newSandboxTestStore(t)
workspace := t.TempDir()
deps := appDeps{
Expand Down
8 changes: 4 additions & 4 deletions internal/contextreport/contextreport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) {
Model: "gpt-4.1",
},
Registry: registry,
ContextWindow: 10_000,
ContextWindow: 20_000,
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
Expand All @@ -46,8 +46,8 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) {
if report.ProviderName != "openai" || report.ModelID != "gpt-4.1" || report.APIModel != "gpt-4.1" {
t.Fatalf("provider metadata = %#v", report)
}
if report.ContextWindow != 10_000 {
t.Fatalf("ContextWindow = %d, want 10000", report.ContextWindow)
if report.ContextWindow != 20_000 {
t.Fatalf("ContextWindow = %d, want 20000", report.ContextWindow)
}
if report.ProjectGuidelineFile != "AGENTS.md" {
t.Fatalf("ProjectGuidelineFile = %q, want AGENTS.md", report.ProjectGuidelineFile)
Expand All @@ -59,7 +59,7 @@ func TestBuildCountsProjectGuidelinesAndFreeBudget(t *testing.T) {
t.Fatalf("UsedTokens = %d, want > 0", report.UsedTokens)
}
if report.FreeTokens <= 0 {
t.Fatalf("FreeTokens = %d, want > 0", report.FreeTokens)
t.Fatalf("FreeTokens = %d, UsedTokens = %d, ContextWindow = %d, want > 0", report.FreeTokens, report.UsedTokens, report.ContextWindow)
}
if report.FreeTokens+report.UsedTokens != report.ContextWindow {
t.Fatalf("free + used = %d, want %d", report.FreeTokens+report.UsedTokens, report.ContextWindow)
Expand Down
8 changes: 7 additions & 1 deletion internal/cron/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"path/filepath"
"strings"
"time"

"github.com/Gitlawb/zero/internal/fsutil"
)

const (
Expand Down Expand Up @@ -144,7 +146,11 @@ func (s *Store) writeJob(job Job) error {
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, filepath.Join(dir, "metadata.json"))
if err := fsutil.RenameWithRetry(tmp, filepath.Join(dir, "metadata.json"), nil); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}

func (s *Store) Get(id string) (Job, error) {
Expand Down
45 changes: 45 additions & 0 deletions internal/fsutil/rename.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Package fsutil provides small filesystem helpers shared across packages.
package fsutil

import (
"errors"
"os"
"runtime"
"syscall"
"time"
)

// RenameWithRetry renames src to dst, retrying briefly on Windows when the
// destination is transiently locked (antivirus scanners, search indexers, or
// a concurrent reader holding the file open). rename overrides os.Rename so
// tests can exercise the retry path; pass nil to use os.Rename.
func RenameWithRetry(src, dst string, rename func(src, dst string) error) error {
if rename == nil {
rename = os.Rename
}
var err error
for i := 0; i < 10; i++ {
err = rename(src, dst)
if err == nil {
return nil
}
if runtime.GOOS == "windows" {
if os.IsPermission(err) || isWindowsSharingOrLockViolation(err) {
time.Sleep(10 * time.Millisecond)
continue
}
}
break
}
return err
}

func isWindowsSharingOrLockViolation(err error) bool {
var errno syscall.Errno
if errors.As(err, &errno) {
const ERROR_SHARING_VIOLATION syscall.Errno = 32
const ERROR_LOCK_VIOLATION syscall.Errno = 33
return errno == ERROR_SHARING_VIOLATION || errno == ERROR_LOCK_VIOLATION
}
return false
}
47 changes: 47 additions & 0 deletions internal/fsutil/rename_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package fsutil

import (
"errors"
"runtime"
"syscall"
"testing"
)

func TestRenameWithRetryRetriesOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("RenameWithRetry only retries on Windows")
}
var attempts int
err := RenameWithRetry("src", "dst", func(src, dst string) error {
attempts++
switch attempts {
case 1:
return syscall.Errno(32) // ERROR_SHARING_VIOLATION
case 2:
return syscall.Errno(33) // ERROR_LOCK_VIOLATION
default:
return nil
}
})
if err != nil {
t.Fatalf("expected success after retries, got: %v", err)
}
if attempts != 3 {
t.Errorf("expected 3 attempts, got %d", attempts)
}
}

func TestRenameWithRetryNonRetryableError(t *testing.T) {
sentinel := errors.New("disk on fire")
var attempts int
err := RenameWithRetry("src", "dst", func(src, dst string) error {
attempts++
return sentinel
})
if !errors.Is(err, sentinel) {
t.Fatalf("expected sentinel error, got: %v", err)
}
if attempts != 1 {
t.Errorf("expected only 1 attempt for non-retryable error, got %d", attempts)
}
}
52 changes: 50 additions & 2 deletions internal/sandbox/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,11 @@ func TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile(t *testing.T) {
t.Fatalf("read-only subpaths = %#v, want git metadata carveout %q", profile.FileSystem.WriteRoots[0].ReadOnlySubpaths, want)
}
}
if len(profile.FileSystem.DenyRead) != 1 || len(profile.FileSystem.DenyWrite) != 1 {
t.Fatalf("deny paths = %#v / %#v, want one each", profile.FileSystem.DenyRead, profile.FileSystem.DenyWrite)
// DenyRead may also carry default credential-store entries when the host
// has them, so assert containment rather than an exact count. Compare the
// normalized (symlink-resolved) form the profile stores.
if !stringSliceContains(profile.FileSystem.DenyRead, normalizeProfilePaths([]string{denyRead})[0]) || len(profile.FileSystem.DenyWrite) != 1 {
t.Fatalf("deny paths = %#v / %#v, want configured entries present", profile.FileSystem.DenyRead, profile.FileSystem.DenyWrite)
}
if profile.Network.Mode != NetworkDeny {
t.Fatalf("network profile = %#v, want deny", profile.Network)
Expand Down Expand Up @@ -370,3 +373,48 @@ func mkdirAll(paths ...string) error {
}
return nil
}

func TestCredentialDenyReadPathsIn(t *testing.T) {
home := t.TempDir()
awsDir := filepath.Join(home, ".aws")
gcloudDir := filepath.Join(home, ".config", "gcloud")
if err := mkdirAll(awsDir, gcloudDir); err != nil {
t.Fatal(err)
}
keyFile := filepath.Join(home, "sa-key.json")
if err := os.WriteFile(keyFile, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}

paths := credentialDenyReadPathsIn(home, keyFile, nil)
for _, want := range normalizeProfilePaths([]string{awsDir, gcloudDir, keyFile}) {
if !stringSliceContains(paths, want) {
t.Errorf("credential deny paths = %#v, want %q included", paths, want)
}
}

// A path the host does not have is dropped, not emitted blind.
if stringSliceContains(paths, filepath.Join(home, ".azure")) {
t.Errorf("credential deny paths = %#v, must not include the absent ~/.azure", paths)
}

// An explicit AllowRead entry covering a store is an opt-out.
optedOut := credentialDenyReadPathsIn(home, keyFile, []string{awsDir})
if stringSliceContains(optedOut, normalizeProfilePaths([]string{awsDir})[0]) {
t.Errorf("credential deny paths = %#v, want AllowRead opt-out to drop ~/.aws", optedOut)
}
if !stringSliceContains(optedOut, normalizeProfilePaths([]string{keyFile})[0]) {
t.Errorf("credential deny paths = %#v, want unrelated entries kept after opt-out", optedOut)
}

if got := credentialDenyReadPathsIn(" ", "", nil); len(got) != 0 {
t.Errorf("credential deny paths for blank home = %#v, want none", got)
}

// The GOOGLE_APPLICATION_CREDENTIALS target stays protected even when no
// home directory is resolvable.
homeless := credentialDenyReadPathsIn("", keyFile, nil)
if !stringSliceContains(homeless, normalizeProfilePaths([]string{keyFile})[0]) {
t.Errorf("credential deny paths without home = %#v, want key file included", homeless)
}
}
63 changes: 62 additions & 1 deletion internal/sandbox/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package sandbox
import (
"os"
"path/filepath"
"runtime"
"strings"
)

Expand Down Expand Up @@ -100,7 +101,7 @@ func PermissionProfileFromPolicy(workspaceRoot string, policy Policy, scope *Sco
Kind: FileSystemRestricted,
ReadRoots: readRoots,
WriteRoots: writeRoots,
DenyRead: normalizeProfilePaths(policy.DenyRead),
DenyRead: dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...)),
DenyWrite: normalizeProfilePaths(policy.DenyWrite),
IncludePlatformRoots: true,
AllowTemp: true,
Expand Down Expand Up @@ -146,6 +147,66 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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
Expand Down
1 change: 1 addition & 0 deletions internal/sandbox/reentrancy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func TestSandboxEnvironmentPreservesCallerEnv(t *testing.T) {
},
DefaultPolicy(),
BackendMacOSSeatbelt,
"",
)

for _, want := range []string{
Expand Down
Loading
Loading