Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
423f7ee
fix(sandbox): deny SSH private keys and the GPG keyring
cairn-intern Aug 28, 2026
64907f1
fix(sandbox): expand SSH %d and keep lexical credential denies
cairn-intern Aug 28, 2026
470dbc0
fix(sandbox): keep lexical credential denies through bwrap and Seatbelt
cairn-intern Aug 28, 2026
4571410
fix(sandbox): follow SSH config symlinks and keep lexical dir dests
cairn-intern Aug 28, 2026
29d560b
fix(sandbox): dual-add lexical dests only when a symlink is involved
cairn-intern Aug 28, 2026
571dfde
fix(sandbox): bound SSH walks and honor nested GPG allowRead
cairn-intern Aug 28, 2026
dc9c168
fix(sandbox): sniff .pub keys, expand ${HOME}, mask symlink dests
cairn-intern Aug 28, 2026
b76002e
fix(sandbox): skip overlaid file binds and sniff named keys
cairn-intern Aug 28, 2026
5f7f6b4
fix(sandbox): bind when overlay fails; sniff named IdentityFiles
cairn-intern Aug 28, 2026
b875b61
fix(sandbox): deny GNUPGHOME and unify bwrap dest spellings
cairn-intern Sep 1, 2026
bd0589c
fix(sandbox): remove unused appendUnreadableLinuxPathArgs helper
cairn-intern Sep 1, 2026
4beb32b
fix(sandbox): normalize dangling symlink assertion and narrow tmpfs p…
cairn-intern Sep 1, 2026
a6f8531
fix(sandbox): traverse directory symlinks, support OpenSSH escape/env…
cairn-intern Sep 1, 2026
5677bf8
test(sandbox): skip symlink tests gracefully when symlinks are unperm…
cairn-intern Sep 2, 2026
a68d043
Preserve parent denies on nested grants and canonicalize test paths
euxaristia Sep 5, 2026
29fac7c
Normalize policy golden baseline for lexical credential symlinks
euxaristia Sep 5, 2026
99d4bcb
Refuse sandbox execution when credential protection is incomplete.
euxaristia Sep 7, 2026
29addbd
Release degraded command plan leases in metadata test. Refs #815
euxaristia Sep 7, 2026
be8d5b3
Page SSH discovery to preserve protection in large directories.
euxaristia Sep 7, 2026
5076e9b
fix(sandbox): resolve SSH config path variables from command environment
euxaristia Sep 12, 2026
ac80d1f
fix(sandbox): address review findings on SSH key discovery and config…
euxaristia Sep 15, 2026
2dc8dcb
fix(sandbox): support symlinked SSH dirs and canonicalize test candid…
euxaristia Sep 15, 2026
b7bb06b
fix(sandbox): bound directory-symlink traversal in SSH key discovery
euxaristia Sep 18, 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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,21 @@ zero sandbox policy
zero sandbox grants list
```

On Unix, the credential baseline discovers SSH private keys and GPG stores.
SSH discovery reads directories in pages and walks nested directories with cycle
detection. Large or deeply nested directories do not trigger a discovery limit.
Exceeding a config-size or Include limit, or failing to inspect a required input,
refuses sandboxed execution rather than using a partial list of protected keys.

Linux's mount-based backend refuses selective SSH-key denies, including key paths
that do not exist yet, and credential denies through mutable symlinks. This also
applies on machines without SSH keys: a key created later must remain protected.
An explicit deny of an existing containing directory can cover the keys, but also
hides that directory's public files,
including SSH configuration and known hosts. Explicit Linux `denyRead` paths
must already exist. macOS uses pathname rules; automatic credential discovery
remains disabled on Windows.

## Web And Local Control

Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
Expand Down
50 changes: 47 additions & 3 deletions internal/cli/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -561,14 +561,22 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp
profile, _ := plan["permissionProfile"].(map[string]any)
fileSystem, _ := profile["fileSystem"].(map[string]any)
wantDenyRead := []string(nil)
credentialHome := emptyHome
wantSSHFiles := []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, ".azure"),
filepath.Join(credentialHome, ".gnupg"),
filepath.Join(credentialHome, ".ssh", "id_rsa"),
filepath.Join(credentialHome, ".ssh", "id_dsa"),
filepath.Join(credentialHome, ".ssh", "id_ecdsa"),
filepath.Join(credentialHome, ".ssh", "id_ed25519"),
filepath.Join(credentialHome, ".ssh", "id_ecdsa_sk"),
filepath.Join(credentialHome, ".ssh", "id_ed25519_sk"),
// git's cleartext credential stores, in both the home and XDG
// layouts (#816). Listed here so the exported policy JSON is what
// catches a regression: this baseline is the contract a user reads
Expand All @@ -583,16 +591,45 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp
filepath.Join(credentialHome, ".config", "gcloud"),
filepath.Join(credentialHome, ".config", "zero"),
}
for _, path := range wantDenyRead {
if filepath.Dir(path) == filepath.Join(credentialHome, ".ssh") {
wantSSHFiles = append(wantSSHFiles, path)
}
}
if emptyHome != credentialHome {
for _, rel := range []string{
".git-credentials",
".gnupg",
filepath.Join(".ssh", "id_rsa"),
filepath.Join(".ssh", "id_dsa"),
filepath.Join(".ssh", "id_ecdsa"),
filepath.Join(".ssh", "id_ed25519"),
filepath.Join(".ssh", "id_ecdsa_sk"),
filepath.Join(".ssh", "id_ed25519_sk"),
} {
wantDenyRead = append(wantDenyRead, filepath.Join(emptyHome, rel))
}
}
}
gotDenyRead := jsonStringSlice(fileSystem["denyReadIfExists"])
sort.Strings(gotDenyRead)
sort.Strings(wantDenyRead)
if !reflect.DeepEqual(gotDenyRead, wantDenyRead) {
t.Fatalf("manager credential deny baseline = %#v, want %#v", gotDenyRead, wantDenyRead)
}
gotSSHFiles := jsonStringSlice(fileSystem["sshDenyReadFiles"])
sort.Strings(gotSSHFiles)
sort.Strings(wantSSHFiles)
if !reflect.DeepEqual(gotSSHFiles, wantSSHFiles) {
t.Fatalf("manager absent SSH key protection = %#v, want %#v", gotSSHFiles, wantSSHFiles)
}
wantCarveouts := []string(nil)
wantEnsureDirs := []string(nil)
if runtime.GOOS != "windows" {
credentialHome := emptyHome
if resolved, err := filepath.EvalSymlinks(emptyHome); err == nil {
credentialHome = resolved
}
zeroDir := filepath.Join(credentialHome, ".config", "zero")
wantCarveouts = []string{
filepath.Join(zeroDir, "plugins"),
Expand All @@ -601,13 +638,20 @@ func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, worksp
}
wantEnsureDirs = []string{zeroDir}
}
if gotCarveouts := jsonStringSlice(fileSystem["denyReadCarveouts"]); !reflect.DeepEqual(gotCarveouts, wantCarveouts) {
gotCarveouts := jsonStringSlice(fileSystem["denyReadCarveouts"])
sort.Strings(gotCarveouts)
sort.Strings(wantCarveouts)
if !reflect.DeepEqual(gotCarveouts, wantCarveouts) {
t.Fatalf("manager credential carveouts = %#v, want %#v", gotCarveouts, wantCarveouts)
}
if gotEnsureDirs := jsonStringSlice(fileSystem["ensureDenyReadDirs"]); !reflect.DeepEqual(gotEnsureDirs, wantEnsureDirs) {
gotEnsureDirs := jsonStringSlice(fileSystem["ensureDenyReadDirs"])
sort.Strings(gotEnsureDirs)
sort.Strings(wantEnsureDirs)
if !reflect.DeepEqual(gotEnsureDirs, wantEnsureDirs) {
t.Fatalf("manager credential ensure dirs = %#v, want %#v", gotEnsureDirs, wantEnsureDirs)
}
delete(fileSystem, "denyReadIfExists")
delete(fileSystem, "sshDenyReadFiles")
delete(fileSystem, "denyReadCarveouts")
delete(fileSystem, "ensureDenyReadDirs")
fileSystem["readRoots"] = filterJSONStringRoots(fileSystem["readRoots"], tempRoots)
Expand Down
4 changes: 3 additions & 1 deletion internal/sandbox/architecture_baseline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func TestCommandPlanCarriesSandboxMetadata(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{
WorkspaceRoot: root,
Policy: DefaultPolicy(),
Policy: testPolicyWithSSHDirectoryDeny(t),
Backend: Backend{
Name: BackendLinuxBwrap,
Available: true,
Expand All @@ -90,6 +90,7 @@ func TestCommandPlanCarriesSandboxMetadata(t *testing.T) {
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
t.Cleanup(plan.Cleanup)

if plan.TargetBackend != BackendLinuxBwrap || !plan.Wrapped || plan.EnforcementLevel != EnforcementNative || plan.DowngradeReason != "" {
t.Fatalf("wrapped command metadata = %#v, want native linux-bwrap", plan)
Expand All @@ -107,6 +108,7 @@ func TestCommandPlanCarriesSandboxMetadata(t *testing.T) {
if err != nil {
t.Fatalf("BuildCommandPlan unavailable auto plan: %v", err)
}
t.Cleanup(degraded.Cleanup)
if degraded.Wrapped || degraded.EnforcementLevel != EnforcementDegraded || degraded.DowngradeReason != "native sandbox unavailable" {
t.Fatalf("unavailable command metadata = %#v, want degraded direct plan", degraded)
}
Expand Down
34 changes: 34 additions & 0 deletions internal/sandbox/command_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package sandbox

import (
"os"
"path/filepath"
"testing"
)

// testPolicyWithSSHDirectoryDeny gives command-planning tests a maskable SSH
// policy so they reach their intended network, runtime, or other credential
// checks. Supplied homes must belong to the test; otherwise create an isolated
// home and redirect all credential roots before constructing the profile.
func testPolicyWithSSHDirectoryDeny(t *testing.T, homes ...string) Policy {
t.Helper()
if len(homes) == 0 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
home := t.TempDir()
homes = []string{home}
for _, name := range []string{"HOME", "USERPROFILE", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME", "APPDATA", "LOCALAPPDATA"} {
t.Setenv(name, home)
}
}
for _, name := range []string{"ZERO_OAUTH_TOKENS_PATH", "ZERO_MCP_OAUTH_TOKENS_PATH", "GNUPGHOME", "ZERO_OAUTH_STORAGE", "CLOUDSDK_CONFIG", "GH_CONFIG_DIR", "DOCKER_CONFIG", "KUBECONFIG", "NETRC", "GOOGLE_APPLICATION_CREDENTIALS", "NPM_CONFIG_USERCONFIG", "npm_config_userconfig"} {
t.Setenv(name, "")
}
policy := DefaultPolicy()
for _, home := range homes {
sshDir := filepath.Join(home, ".ssh")
if err := os.MkdirAll(sshDir, 0700); err != nil {
t.Fatal(err)
}
policy.DenyRead = append(policy.DenyRead, sshDir)
}
return policy
}
10 changes: 4 additions & 6 deletions internal/sandbox/git_credential_deny_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,10 @@ import (
// git's credential store holds host passwords and personal access tokens in
// cleartext, in one of two locations depending on whether the user is on the
// XDG layout. Neither was denied, so a sandboxed command could read them
// (#815).
//
// Scoped to the credential files on purpose. Denying ~/.ssh as well would stop
// a sandboxed git push over SSH from working, which is a functional trade that
// issue tracks separately; these two cost nothing, because git reads them for
// authentication rather than identity.
// (#815). #816 closed this half: the stores are denied as files, not the
// surrounding git config directory. SSH private keys and the GPG keyring are
// the remaining #815 scope and are covered in ssh_gpg_deny_test.go (key
// material, not the whole of ~/.ssh).
func TestCredentialDenyReadPathsCoversGitCredentialStores(t *testing.T) {
home := t.TempDir()
configHome := filepath.Join(home, ".config")
Expand Down
Loading
Loading