-
Notifications
You must be signed in to change notification settings - Fork 104
fix(sandbox): deny reads of Zero credential stores #681
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -148,16 +148,23 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop | |
| } | ||
|
|
||
| // 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: | ||
| // cloud credential stores, the file GOOGLE_APPLICATION_CREDENTIALS points to, | ||
| // and Zero's own config/credential/token directory so sandboxed commands | ||
| // cannot read secrets under the read-all workspace posture. Three 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. | ||
| // - Candidates are emitted whether or not they currently exist on disk: a | ||
| // rule installed only for stores present at profile-build time would miss | ||
| // a store created later in a long-lived sandboxed session (e.g. `zero | ||
| // auth login` run concurrently), and every backend already treats a deny | ||
| // rule over a not-yet-existing path as a harmless no-op that still takes | ||
| // effect once the path appears. | ||
| // | ||
| // These are profile-level rules only; they are intentionally NOT merged into | ||
| // Policy.DenyRead, whose emptiness gates escalated (unsandboxed) execution and | ||
|
|
@@ -166,33 +173,60 @@ 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. | ||
| // Failed home/config lookups only drop their derived candidates; explicit | ||
| // credential-file overrides are still submitted as candidates (and, like | ||
| // every candidate, filtered by on-disk existence below). | ||
| home, _ := os.UserHomeDir() | ||
| return credentialDenyReadPathsIn(home, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), policy.AllowRead) | ||
| configDir, _ := zeroUserConfigDir() | ||
| return credentialDenyReadPathsIn(credentialPathOptions{ | ||
| Home: home, | ||
| GoogleCredentials: os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), | ||
| ZeroConfigDir: configDir, | ||
| OAuthTokens: os.Getenv("ZERO_OAUTH_TOKENS_PATH"), | ||
| MCPOAuthTokens: os.Getenv("ZERO_MCP_OAUTH_TOKENS_PATH"), | ||
| }, policy.AllowRead) | ||
| } | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P3] |
||
| type credentialPathOptions struct { | ||
| Home string | ||
| GoogleCredentials string | ||
| ZeroConfigDir string | ||
| OAuthTokens string | ||
| MCPOAuthTokens string | ||
| } | ||
|
|
||
| // 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 { | ||
| func credentialDenyReadPathsIn(options credentialPathOptions, allowRead []string) []string { | ||
| var candidates []string | ||
| if home = strings.TrimSpace(home); home != "" { | ||
| if home := strings.TrimSpace(options.Home); home != "" { | ||
| candidates = append(candidates, | ||
| filepath.Join(home, ".aws"), | ||
| filepath.Join(home, ".config", "gcloud"), | ||
| filepath.Join(home, ".azure"), | ||
| ) | ||
| } | ||
| if target := strings.TrimSpace(googleCredentials); target != "" { | ||
| if target := strings.TrimSpace(options.GoogleCredentials); target != "" { | ||
| candidates = append(candidates, target) | ||
| } | ||
| if configDir := strings.TrimSpace(options.ZeroConfigDir); configDir != "" { | ||
| // Deny the whole directory rather than an itemized file list. Zero's | ||
| // credential/token/config stores each publish through a randomly-named | ||
| // sibling before an atomic rename (oauth-tokens.json.tmp-<pid>-<nanos>, | ||
| // credentials.{enc,json}.*.tmp, *.secret.*.tmp, .zero-config-*.tmp), and | ||
| // the legacy MCP token store leaves a mcp-oauth-tokens.json.migrated | ||
| // backup behind after importing it — an itemized list can never keep up | ||
| // with those names. Nothing else has a legitimate reason to live here. | ||
| candidates = append(candidates, filepath.Join(configDir, "zero")) | ||
| } | ||
| for _, override := range []string{options.OAuthTokens, options.MCPOAuthTokens} { | ||
| if tokenPath := resolveCredentialOverridePath(override); tokenPath != "" { | ||
| candidates = append(candidates, tokenPath, tokenPath+".secret") | ||
| } | ||
| } | ||
| 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) { | ||
|
|
@@ -207,6 +241,50 @@ func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead | |
| return out | ||
| } | ||
|
|
||
| // resolveCredentialOverridePath mirrors the token stores' own override | ||
| // resolution (oauth.ResolveStorePath, mcp.ResolveTokenStorePath — duplicated | ||
| // here rather than imported, the same tradeoff zeroUserConfigDir makes, | ||
| // because internal/mcp depends on this package): a relative override is | ||
| // resolved literally against the process working directory, NOT tilde- | ||
| // expanded the way normalizeProfilePath expands other candidates. Using | ||
| // normalizeProfilePath here would derive a deny path that doesn't match | ||
| // where the store actually writes — e.g. ZERO_OAUTH_TOKENS_PATH=~/x resolves | ||
| // to <cwd>/~/x on disk (the store never expands "~"), but normalizeProfilePath | ||
| // would deny $HOME/x instead, leaving the real file unprotected. | ||
| func resolveCredentialOverridePath(override string) string { | ||
| override = strings.TrimSpace(override) | ||
| if override == "" { | ||
| return "" | ||
| } | ||
| if filepath.IsAbs(override) { | ||
| return filepath.Clean(override) | ||
| } | ||
| abs, err := filepath.Abs(override) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| return filepath.Clean(abs) | ||
| } | ||
|
|
||
| // zeroUserConfigDir mirrors config.UserConfigDir without importing config | ||
| // (config depends on sandbox). On macOS Zero deliberately honors | ||
| // XDG_CONFIG_HOME when set and falls back to ~/.config, instead of the | ||
| // os.UserConfigDir default (~/Library/Application Support); everywhere else | ||
| // it uses os.UserConfigDir. | ||
| func zeroUserConfigDir() (string, error) { | ||
| if runtime.GOOS != "darwin" { | ||
| return os.UserConfigDir() | ||
| } | ||
| if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { | ||
| return xdg, nil | ||
| } | ||
| home, err := os.UserHomeDir() | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| return filepath.Join(home, ".config"), nil | ||
| } | ||
|
|
||
| // 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1]
TestPermissionProfileDeniesZeroCredentialFilesfails on macOS (darwin) — blocks CIwantis computed here, beforeos.MkdirAll(zeroDir)creates the dir (line 513), then reused for the second profile build after the dir exists.normalizeProfilePathcallsfilepath.EvalSymlinks, which on macOS:/var/.../001/zerowhenzerodoesn't exist) and falls back tofilepath.Clean, leaving/varunresolved;/var->/private/var.I confirmed this with a standalone probe:
EvalSymlinks("/var/.../zero")returnslstat /private/var/.../zero: no such file or directorybefore mkdir, and/private/var/.../zeroafter. Result: firstwantis/var/.../zero, second profile's DenyRead is/private/var/.../zero-> mismatch at the second assertion. Fix: recomputewantafterMkdirAll, orEvalSymlinksthe base temp dir, or build the expected path from a non-symlinked temp base.