Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
51b2ea2
fix(sandbox): deny reads of zero credential stores
PierrunoYT Jul 14, 2026
0547046
fix(sandbox): address PR review comments
PierrunoYT Jul 14, 2026
43e81fd
fix(sandbox): deny the whole Zero config dir, not itemized filenames
PierrunoYT Jul 15, 2026
5b4f743
fix(sandbox): address macOS deny-path test and config parity
cursoragent Jul 17, 2026
750328c
fix(sandbox): harden credential deny paths
PierrunoYT Jul 18, 2026
c92e0ed
test(cli): normalize credential deny baseline
PierrunoYT Jul 18, 2026
63adeb4
fix(sandbox): close credential path gaps
PierrunoYT Jul 18, 2026
5b4c216
test(sandbox): normalize command credential path
PierrunoYT Jul 18, 2026
9f8147e
fix(sandbox): handle absent credential paths safely
PierrunoYT Jul 19, 2026
b8e7a99
fix(sandbox): protect MCP OAuth siblings
PierrunoYT Jul 19, 2026
800de12
fix(sandbox): protect OAuth lock siblings
PierrunoYT Jul 20, 2026
662770c
docs(sandbox): clarify Linux future-path limits
PierrunoYT Jul 20, 2026
841088d
Merge remote-tracking branch 'upstream/main' into agent/deny-zero-cre…
PierrunoYT Jul 25, 2026
46d071d
test(sandbox): probe credential store reads in the Linux smoke test
PierrunoYT Jul 25, 2026
0f6c87a
fix(sandbox): keep the credential deny usable, current, and unguessable
PierrunoYT Jul 26, 2026
07f99bf
Merge remote-tracking branch 'upstream/main' into agent/deny-zero-cre…
ampagent Jul 26, 2026
a62bd8c
fix(sandbox): close credential deny review gaps
ampagent Jul 26, 2026
e79b46b
fix(sandbox): normalize credential carveout paths
ampagent Jul 26, 2026
083d6f0
fix(sandbox): canonicalize backend credential masks
ampagent Jul 26, 2026
497aeb5
test(sandbox): normalize backend mask expectations
ampagent Jul 26, 2026
bf75c60
Merge upstream/main into credential deny-read hardening
ampagent Jul 27, 2026
f25b3e7
fix(sandbox): fail closed for replaceable token stores
ampagent Jul 28, 2026
64ca22e
Merge upstream/main into credential deny-read hardening, with review …
PierrunoYT Jul 29, 2026
e106d06
fix(sandbox): allow keyring token path overrides
ampagent Aug 1, 2026
56fa645
fix(sandbox): harden command credential roots
ampagent Aug 1, 2026
b14e659
fix(sandbox): close credential deny review gaps
ampagent Aug 2, 2026
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
44 changes: 38 additions & 6 deletions internal/cli/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -483,14 +484,16 @@ 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.
// Point all user-config roots at an empty directory so the platform-specific
// credential deny baseline can be asserted without leaking host paths into
// the platform-neutral golden comparison.
emptyHome := t.TempDir()
t.Setenv("HOME", emptyHome)
t.Setenv("USERPROFILE", emptyHome)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(emptyHome, ".config"))
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", "")
t.Setenv("ZERO_MCP_OAUTH_TOKENS_PATH", "")
store := newSandboxTestStore(t)
workspace := t.TempDir()
deps := appDeps{
Expand Down Expand Up @@ -518,7 +521,7 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
got := stdout.String()
got = replacePathToken(got, workspace, "$WORKSPACE")
got = replacePathToken(got, store.FilePath(), "$GRANTS")
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace)
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace, emptyHome)
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_unavailable.golden.json"))
if err != nil {
t.Fatalf("read golden: %v", err)
Expand All @@ -528,7 +531,7 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
}
}

func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string) []byte {
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string, emptyHome string) []byte {
t.Helper()
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
Expand All @@ -549,6 +552,19 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp
plan, _ := value.(map[string]any)["plan"].(map[string]any)
profile, _ := plan["permissionProfile"].(map[string]any)
fileSystem, _ := profile["fileSystem"].(map[string]any)
wantDenyRead := []string(nil)
if runtime.GOOS != "windows" {
wantDenyRead = []string{
filepath.Join(emptyHome, ".aws"),
filepath.Join(emptyHome, ".config", "gcloud"),
filepath.Join(emptyHome, ".azure"),
filepath.Join(emptyHome, ".config", "zero"),
}
}
if gotDenyRead := jsonStringSlice(fileSystem["denyReadIfExists"]); !reflect.DeepEqual(gotDenyRead, wantDenyRead) {
t.Fatalf("manager credential deny baseline = %#v, want %#v", gotDenyRead, wantDenyRead)
}
delete(fileSystem, "denyReadIfExists")
fileSystem["readRoots"] = filterJSONStringRoots(fileSystem["readRoots"], tempRoots)
fileSystem["writeRoots"] = filterJSONWriteRoots(fileSystem["writeRoots"], tempRoots)
normalized, err := json.MarshalIndent(value, "", " ")
Expand All @@ -558,6 +574,22 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp
return append(normalized, '\n')
}

func jsonStringSlice(value any) []string {
values, ok := value.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok {
return nil
}
out = append(out, text)
}
return out
}

func filterJSONStringRoots(value any, excluded map[string]struct{}) any {
roots, ok := value.([]any)
if !ok {
Expand Down
6 changes: 3 additions & 3 deletions internal/oauth/encrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,12 @@ func writeNewSecretFile(path string) ([]byte, error) {
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
return nil, fmt.Errorf("oauth: generate token secret: %w", err)
}
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
tmpPath := path + ".tmp"
_ = os.Remove(tmpPath)
tmp, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return nil, fmt.Errorf("oauth: create token secret temp file: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
Expand Down
18 changes: 13 additions & 5 deletions internal/oauth/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,15 +409,23 @@ func (b fileBlob) write(data []byte) error {
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
return err
}
tempPath := fmt.Sprintf("%s.tmp-%d-%d", b.path, os.Getpid(), time.Now().UnixNano())
if err := os.WriteFile(tempPath, data, 0o600); err != nil {
// Saves are serialized by withLock, so a fixed sibling remains collision-free
// while allowing sandbox profiles to protect it without masking the parent.
tempPath := b.path + ".tmp"
_ = os.Remove(tempPath)
temp, err := os.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if err := os.Rename(tempPath, b.path); err != nil {
_ = os.Remove(tempPath)
defer os.Remove(tempPath)
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return err
}
return nil
if err := temp.Close(); err != nil {
return err
}
return os.Rename(tempPath, b.path)
}

func (b fileBlob) withLock(now func() time.Time, fn func() error) error {
Expand Down
33 changes: 33 additions & 0 deletions internal/oauth/store_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package oauth

import (
"errors"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -91,6 +92,38 @@ func TestStoreFileMode0600(t *testing.T) {
}
}

func TestStoreUsesFixedProtectedPublicationSibling(t *testing.T) {
s, path := newTestStore(t)
tempPath := path + ".tmp"
if err := os.WriteFile(tempPath, []byte("stale"), 0o600); err != nil {
t.Fatal(err)
}
if err := s.Save(ProviderKey("x"), Token{AccessToken: "secret"}); err != nil {
t.Fatalf("Save: %v", err)
}
if _, err := os.Stat(tempPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("publication sibling remains after save: %v", err)
}
}

func TestEncryptedStoreUsesFixedProtectedSecretSibling(t *testing.T) {
path := filepath.Join(t.TempDir(), "tokens.json")
secretTempPath := path + ".secret.tmp"
if err := os.WriteFile(secretTempPath, []byte("stale"), 0o600); err != nil {
t.Fatal(err)
}
s, err := NewStore(StoreOptions{FilePath: path, Storage: "encrypted-file"})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := s.Save(ProviderKey("x"), Token{AccessToken: "secret"}); err != nil {
t.Fatalf("Save: %v", err)
}
if _, err := os.Stat(secretTempPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("secret publication sibling remains after save: %v", err)
}
}

func TestStoreMalformedFailsClosed(t *testing.T) {
s, path := newTestStore(t)
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
Expand Down
5 changes: 4 additions & 1 deletion internal/sandbox/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package sandbox

import (
"os"
"path/filepath"
"strings"
)
Expand Down Expand Up @@ -45,6 +46,7 @@ func sandboxExecProfile(writeRoots []string, policy Policy, denialTag string) st
}

func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy) PermissionProfile {
credentialBaseDir, _ := os.Getwd()
fs := FileSystemPolicy{
Kind: FileSystemUnrestricted,
ReadRoots: []string{string(filepath.Separator)},
Expand All @@ -58,7 +60,8 @@ func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy)
fs.WriteRoots = append(fs.WriteRoots, WritableRoot{Root: root})
}
}
fs.DenyRead = dedupeStrings(append(normalizeProfilePaths(policy.DenyRead), credentialDenyReadPaths(policy)...))
fs.DenyRead = normalizeProfilePaths(policy.DenyRead)
fs.DenyReadIfExists = credentialDenyReadPaths(policy, credentialBaseDir, os.Environ())
fs.DenyWrite = normalizeProfilePaths(policy.DenyWrite)
return PermissionProfile{
FileSystem: fs,
Expand Down
2 changes: 1 addition & 1 deletion internal/sandbox/landlock_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func validateLandlockProfile(profile PermissionProfile) error {
if fs.Kind != FileSystemRestricted {
return nil
}
if len(fs.DenyRead) > 0 {
if len(fs.DenyRead) > 0 || len(fs.DenyReadIfExists) > 0 {
return errors.New("deny-read paths require the bubblewrap helper mode")
}
if len(fs.DenyWrite) > 0 {
Expand Down
12 changes: 12 additions & 0 deletions internal/sandbox/linux_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
return config, nil
}

func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, error) {

Check failure on line 148 in internal/sandbox/linux_helper.go

View workflow job for this annotation

GitHub Actions / Security & code health

unreachable func: BuildLinuxSandboxBwrapArgs
plan, err := buildLinuxSandboxBwrapPlan(options)
if err != nil {
return nil, err
Expand Down Expand Up @@ -217,7 +217,7 @@
}, nil
}

func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {

Check failure on line 220 in internal/sandbox/linux_helper.go

View workflow job for this annotation

GitHub Actions / Security & code health

unreachable func: linuxBwrapFilesystemArgs
return buildLinuxBwrapFilesystemPlan(profile).Args
}

Expand Down Expand Up @@ -279,6 +279,18 @@
for _, path := range fs.DenyRead {
args = appendUnreadableLinuxPathArgs(args, path)
}
for _, path := range fs.DenyReadIfExists {
if !pathExists(path) {
// Baseline credential paths are emitted for every run, so an absent
// entry is the common case on a fresh machine. The read-all profile
// starts from a read-only host-root bind where bubblewrap cannot
// create a missing mount destination, and masking the nearest
// existing parent could hide HOME, /tmp, or the workspace. Path-based
// backends (seatbelt) still deny these paths before they exist.
continue
}
args = appendUnreadableLinuxPathArgs(args, path)
}
return linuxBwrapFilesystemPlan{
Args: args,
ProtectedCreateTargets: dedupeStrings(protectedCreateTargets),
Expand Down
32 changes: 32 additions & 0 deletions internal/sandbox/linux_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sandbox

import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
Expand Down Expand Up @@ -265,6 +266,37 @@ func TestLinuxBwrapFilesystemPlanPreservesMissingProtectedMetadata(t *testing.T)
}
}

func TestLinuxBwrapSkipsMissingCredentialBaselines(t *testing.T) {
root := t.TempDir()
missingCredential := filepath.Join(root, "home", ".config", "zero")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{Root: root}},
DenyReadIfExists: []string{missingCredential},
},
Network: NetworkPolicy{Mode: NetworkDeny},
}

plan := buildLinuxBwrapFilesystemPlan(profile)
if stringSliceContains(plan.Args, missingCredential) {
t.Fatalf("absent credential baseline must not become a mount target: %#v", plan.Args)
}
if stringSliceContains(plan.ProtectedCreateTargets, missingCredential) {
t.Fatalf("credential baselines are not workspace metadata create targets: %#v", plan.ProtectedCreateTargets)
}
if _, err := os.Stat(filepath.Dir(missingCredential)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("building the plan materialized a host path: %v", err)
}

if err := os.MkdirAll(missingCredential, 0o700); err != nil {
t.Fatalf("MkdirAll credential dir: %v", err)
}
plan = buildLinuxBwrapFilesystemPlan(profile)
assertArgsContainSequence(t, plan.Args, "--perms", "000", "--tmpfs", missingCredential, "--remount-ro", missingCredential)
}

func TestLinuxBwrapUnrestrictedFilesystemUsesWritableHostRoot(t *testing.T) {
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Expand Down
Loading
Loading