Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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
67 changes: 61 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,42 @@ 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" {
credentialHome := emptyHome
if resolved, err := filepath.EvalSymlinks(emptyHome); err == nil {
credentialHome = resolved
}
wantDenyRead = []string{
filepath.Join(credentialHome, ".aws"),
filepath.Join(credentialHome, ".config", "gcloud"),
filepath.Join(credentialHome, ".azure"),
filepath.Join(credentialHome, ".config", "zero"),
}
}
if gotDenyRead := jsonStringSlice(fileSystem["denyReadIfExists"]); !reflect.DeepEqual(gotDenyRead, wantDenyRead) {
t.Fatalf("manager credential deny baseline = %#v, want %#v", gotDenyRead, wantDenyRead)
}
wantCarveouts := []string(nil)
wantEnsureDirs := []string(nil)
if runtime.GOOS != "windows" {
zeroDir := wantDenyRead[len(wantDenyRead)-1]
wantCarveouts = []string{
filepath.Join(zeroDir, "plugins"),
filepath.Join(zeroDir, "specialists"),
filepath.Join(zeroDir, "commands"),
}
wantEnsureDirs = []string{zeroDir}
}
if gotCarveouts := jsonStringSlice(fileSystem["denyReadCarveouts"]); !reflect.DeepEqual(gotCarveouts, wantCarveouts) {
t.Fatalf("manager credential carveouts = %#v, want %#v", gotCarveouts, wantCarveouts)
}
if gotEnsureDirs := jsonStringSlice(fileSystem["ensureDenyReadDirs"]); !reflect.DeepEqual(gotEnsureDirs, wantEnsureDirs) {
t.Fatalf("manager credential ensure dirs = %#v, want %#v", gotEnsureDirs, wantEnsureDirs)
}
delete(fileSystem, "denyReadIfExists")
delete(fileSystem, "denyReadCarveouts")
delete(fileSystem, "ensureDenyReadDirs")
fileSystem["readRoots"] = filterJSONStringRoots(fileSystem["readRoots"], tempRoots)
fileSystem["writeRoots"] = filterJSONWriteRoots(fileSystem["writeRoots"], tempRoots)
normalized, err := json.MarshalIndent(value, "", " ")
Expand All @@ -558,6 +597,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
11 changes: 4 additions & 7 deletions internal/oauth/encrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,14 @@ 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")
// The encryption key is published exactly like the token blob: through the
// protected per-store directory, under a random name a sandboxed process
// cannot guess, wait for, or rename away.
tmp, tmpPath, err := createPublicationFile(path)
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()
return nil, fmt.Errorf("oauth: chmod token secret temp file: %w", err)
}
if _, werr := tmp.Write(secret); werr != nil {
_ = tmp.Close()
return nil, fmt.Errorf("oauth: write token secret: %w", werr)
Expand Down
48 changes: 43 additions & 5 deletions internal/oauth/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,15 +409,53 @@ 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 {
temp, tempPath, err := createPublicationFile(b.path)
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)
}

// PublicationDirSuffix names the per-store directory a token store publishes new
// contents through. Sandbox profiles deny it by name (see
// internal/sandbox.credentialPublicationDir), which is why the directory name is
// derived from the store path while the file inside it is randomly named: the
// deterministic part is what a deny rule can reference, and the random part is
// what stops a same-user process from waiting for the plaintext to appear at a
// path it can open or rename away.
const PublicationDirSuffix = ".publish"

// PublicationDir returns the publication directory for a store path.
func PublicationDir(path string) string { return path + PublicationDirSuffix }

// createPublicationFile creates the randomly-named 0600 file that path's next
// contents are written to before being renamed into place. It lives in
// PublicationDir(path) — same filesystem as path, so the rename stays atomic —
// and the directory is created 0700 if it does not exist yet.
func createPublicationFile(path string) (*os.File, string, error) {
dir := PublicationDir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, "", err
}
temp, err := os.CreateTemp(dir, "publish-*")
if err != nil {
return nil, "", err
}
if err := temp.Chmod(0o600); err != nil {
name := temp.Name()
_ = temp.Close()
_ = os.Remove(name)
return nil, "", err
}
return temp, temp.Name(), nil
}

func (b fileBlob) withLock(now func() time.Time, fn func() error) error {
Expand Down
54 changes: 54 additions & 0 deletions internal/oauth/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,60 @@ func TestStoreFileMode0600(t *testing.T) {
}
}

// TestStorePublishesThroughProtectedDirectory pins the publication contract the
// sandbox profile relies on: the plaintext blob is never written to a path a
// same-user process can predict (a fixed `.tmp` sibling), only to a random name
// inside the directory the profile denies by name, and nothing is left behind.
func TestStorePublishesThroughProtectedDirectory(t *testing.T) {
s, path := newTestStore(t)
if err := os.WriteFile(path+".tmp", []byte("stale"), 0o600); err != nil {
t.Fatal(err)
}
if err := s.Save(ProviderKey("x"), Token{AccessToken: "secret"}); err != nil {
t.Fatalf("Save: %v", err)
}
// A predictable sibling is never used, so a pre-existing one is untouched
// rather than becoming the file the plaintext passes through.
if data, err := os.ReadFile(path + ".tmp"); err != nil || string(data) != "stale" {
t.Fatalf("fixed sibling data = %q, err = %v, want the untouched placeholder", data, err)
}
assertEmptyPublicationDir(t, path)
}

func TestEncryptedStorePublishesSecretThroughProtectedDirectory(t *testing.T) {
path := filepath.Join(t.TempDir(), "tokens.json")
if err := os.WriteFile(path+".secret.tmp", []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 data, err := os.ReadFile(path + ".secret.tmp"); err != nil || string(data) != "stale" {
t.Fatalf("fixed secret sibling data = %q, err = %v, want the untouched placeholder", data, err)
}
assertEmptyPublicationDir(t, path)
assertEmptyPublicationDir(t, path+".secret")
}

// assertEmptyPublicationDir asserts the store's publication directory exists
// (so the sandbox has a mount target to mask on Linux) and holds no leftover
// copy of the secret it published.
func assertEmptyPublicationDir(t *testing.T, storePath string) {
t.Helper()
dir := PublicationDir(storePath)
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read publication dir %s: %v", dir, err)
}
if len(entries) != 0 {
t.Fatalf("publication dir %s = %v, want empty after publish", dir, entries)
}
}

func TestStoreMalformedFailsClosed(t *testing.T) {
s, path := newTestStore(t)
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
Expand Down
7 changes: 6 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 @@ -58,7 +59,11 @@ 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)
credentials := finalizeCredentialDenyPaths(credentialDenyReadPaths(policy, "", os.Environ()), fs.DenyRead)
fs.DenyReadIfExists = credentials.Paths
fs.DenyReadCarveouts = credentials.Carveouts
fs.EnsureDenyReadDirs = credentials.EnsureDirs
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
64 changes: 60 additions & 4 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 @@ -277,7 +277,24 @@
args = appendReadOnlyLinuxPathArgs(args, path)
}
for _, path := range fs.DenyRead {
args = appendUnreadableLinuxPathArgs(args, path)
args = appendUnreadableLinuxPathArgs(args, path, fs.DenyReadCarveouts)
}
// The profile includes only trusted, process-environment-derived directories
// here. Command-controlled credential roots remain deny-if-present and must
// never cause host filesystem mutations before sandbox launch.
ensureLinuxDenyReadDirs(fs.EnsureDenyReadDirs)
for _, path := range fs.DenyReadIfExists {
if !pathExists(path) {
// A baseline credential path is emitted for every run, so an absent
// entry is the common case on a fresh machine — a third-party store
// such as ~/.aws that Zero must not create. 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, fs.DenyReadCarveouts)
}
return linuxBwrapFilesystemPlan{
Args: args,
Expand Down Expand Up @@ -353,15 +370,54 @@
return append(args, "--perms", "555", "--tmpfs", path, "--remount-ro", path)
}

func appendUnreadableLinuxPathArgs(args []string, path string) []string {
path = strings.TrimSpace(path)
func appendUnreadableLinuxPathArgs(args []string, path string, carveouts []string) []string {
path = normalizeProfilePath(path)
if path == "" {
return args
}
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return append(args, "--ro-bind", "/dev/null", path)
}
return append(args, "--perms", "000", "--tmpfs", path, "--remount-ro", path)
nested := nestedCarveoutPaths(path, carveouts)
if len(nested) == 0 {
return append(args, "--perms", "000", "--tmpfs", path, "--remount-ro", path)
}
// A carveout has to stay reachable, and traversing into a directory needs the
// execute bit, so the mask is 111 (--x--x--x) instead of 000: the directory's
// contents remain unlistable and unreadable, while an explicitly re-bound
// subpath below it can still be resolved. The binds must precede the
// --remount-ro, which is what freezes the tmpfs.
args = append(args, "--perms", "111", "--tmpfs", path)
for _, carveout := range nested {
if info, err := os.Lstat(carveout); err == nil && info.IsDir() {
args = append(args, "--ro-bind", carveout, carveout)
}
}
return append(args, "--remount-ro", path)
}

// nestedCarveoutPaths returns the carveouts that sit strictly inside root,
// shallowest first so a parent bind is created before a nested one.
func nestedCarveoutPaths(root string, carveouts []string) []string {
if len(carveouts) == 0 {
return nil
}
out := credentialCarveoutPaths([]string{root}, carveouts)
sort.SliceStable(out, func(i, j int) bool { return pathDepth(out[i]) < pathDepth(out[j]) })
return dedupeStrings(out)
}

// ensureLinuxDenyReadDirs creates trusted Zero-process directories a deny mask
// needs to exist for. Best effort: a failure leaves the path unmasked and never
// blocks the command.
func ensureLinuxDenyReadDirs(dirs []string) {
for _, dir := range dirs {
dir = strings.TrimSpace(dir)
if dir == "" || pathExists(dir) {
continue
}
_ = os.MkdirAll(dir, 0o700)
}
}

func shouldUnshareLinuxNetwork(policy NetworkPolicy) bool {
Expand Down
Loading
Loading