mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6d43c62d9 |
No files matched your search
+10
-1
@@ -724,10 +724,19 @@ func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfi
|
||||
policy = applyConfiguredAutonomyCeiling(policy, cfg.MaxAutonomy)
|
||||
if network := strings.TrimSpace(cfg.Network); network != "" {
|
||||
switch sandbox.NetworkMode(network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny, sandbox.NetworkScoped:
|
||||
policy.Network = sandbox.NetworkMode(network)
|
||||
}
|
||||
}
|
||||
// Scoped egress allow/deny lists from (global) config; the engine consults
|
||||
// these only under NetworkScoped and falls closed to deny on an empty
|
||||
// allowlist or a backend that can't enforce scoping.
|
||||
if len(cfg.NetworkAllowedDomains) > 0 {
|
||||
policy.AllowedDomains = append([]string(nil), cfg.NetworkAllowedDomains...)
|
||||
}
|
||||
if len(cfg.NetworkDeniedDomains) > 0 {
|
||||
policy.DeniedDomains = append([]string(nil), cfg.NetworkDeniedDomains...)
|
||||
}
|
||||
// Opt-in hardening/diagnostic flags: only ever turn a feature ON from
|
||||
// config, never off, so a programmatic default can't be silently disabled by
|
||||
// an omitted key.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// The central config->policy translation must carry scoped mode + the egress
|
||||
// allow/deny lists into the engine policy (this path feeds exec, the TUI app,
|
||||
// and the sandbox CLI commands).
|
||||
func TestApplyConfiguredSandboxPolicyWiresScopedNetwork(t *testing.T) {
|
||||
cfg := config.SandboxConfig{
|
||||
Network: "scoped",
|
||||
NetworkAllowedDomains: []string{"github.com", "pypi.org"},
|
||||
NetworkDeniedDomains: []string{"evil.example"},
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), cfg)
|
||||
if policy.Network != sandbox.NetworkScoped {
|
||||
t.Fatalf("Network = %q, want scoped", policy.Network)
|
||||
}
|
||||
if len(policy.AllowedDomains) != 2 || policy.AllowedDomains[0] != "github.com" {
|
||||
t.Fatalf("AllowedDomains = %#v", policy.AllowedDomains)
|
||||
}
|
||||
if len(policy.DeniedDomains) != 1 || policy.DeniedDomains[0] != "evil.example" {
|
||||
t.Fatalf("DeniedDomains = %#v", policy.DeniedDomains)
|
||||
}
|
||||
}
|
||||
@@ -83,8 +83,14 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
if network := strings.TrimSpace(cfg.Sandbox.Network); network != "" {
|
||||
switch sandbox.NetworkMode(network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
case sandbox.NetworkScoped:
|
||||
// Scoped with no allowlist would collapse to deny in the engine; reject
|
||||
// it loudly so the operator isn't surprised by a silent downgrade.
|
||||
if !hasNonEmptyDomain(cfg.Sandbox.NetworkAllowedDomains) {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: scoped requires a non-empty sandbox.networkAllowedDomains", network)
|
||||
}
|
||||
default:
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: expected allow or deny", network)
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: expected allow, deny, or scoped", network)
|
||||
}
|
||||
}
|
||||
if mode := strings.TrimSpace(cfg.Notify.Mode); mode != "" {
|
||||
@@ -171,6 +177,11 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
// Scoped egress allow/deny lists are a GRANT, layered here (global user
|
||||
// config) like AdditionalWriteRoots and likewise NOT taken from project
|
||||
// config (see mergeProjectConfig).
|
||||
dst.Sandbox.NetworkAllowedDomains = unionStrings(dst.Sandbox.NetworkAllowedDomains, src.Sandbox.NetworkAllowedDomains)
|
||||
dst.Sandbox.NetworkDeniedDomains = unionStrings(dst.Sandbox.NetworkDeniedDomains, src.Sandbox.NetworkDeniedDomains)
|
||||
dst.Sandbox.AdditionalWriteRoots = unionStrings(dst.Sandbox.AdditionalWriteRoots, src.Sandbox.AdditionalWriteRoots)
|
||||
if src.Sandbox.BlockUnixSockets {
|
||||
dst.Sandbox.BlockUnixSockets = true
|
||||
@@ -211,10 +222,11 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
mergeProvider(dst, provider)
|
||||
}
|
||||
mergeMCPConfig(&dst.MCP, src.MCP)
|
||||
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
|
||||
// config: a cloned repo's .zero/config.json must not be able to grant
|
||||
// itself write access outside the workspace. Global config and CLI flags
|
||||
// are the only config sources for write roots.
|
||||
// Sandbox.AdditionalWriteRoots and Sandbox.NetworkAllowedDomains/
|
||||
// NetworkDeniedDomains are intentionally NOT merged from project config: a
|
||||
// cloned repo's .zero/config.json must not be able to grant itself write
|
||||
// access outside the workspace, nor widen its own network egress allowlist.
|
||||
// Global config (and CLI flags) are the only sources for those grants.
|
||||
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
dst.Sandbox.MaxAutonomy = maxAutonomy
|
||||
}
|
||||
@@ -631,6 +643,18 @@ func hasProviderFields(profile ProviderProfile) bool {
|
||||
// base, preserving order. Used for additive config keys like
|
||||
// sandbox.additionalWriteRoots where a later layer must not erase earlier
|
||||
// grants.
|
||||
// hasNonEmptyDomain reports whether domains contains at least one entry that is
|
||||
// non-empty after trimming — used to reject a scoped network policy that has no
|
||||
// usable allowlist.
|
||||
func hasNonEmptyDomain(domains []string) bool {
|
||||
for _, domain := range domains {
|
||||
if strings.TrimSpace(domain) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unionStrings(base []string, extra []string) []string {
|
||||
seen := make(map[string]struct{}, len(base))
|
||||
for _, value := range base {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAcceptsScopedNetworkWithAllowlist(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"sandbox": {"network": "scoped", "networkAllowedDomains": ["github.com", "pypi.org"], "networkDeniedDomains": ["evil.example"]}
|
||||
}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Network != "scoped" {
|
||||
t.Fatalf("Network = %q, want scoped", resolved.Sandbox.Network)
|
||||
}
|
||||
if len(resolved.Sandbox.NetworkAllowedDomains) != 2 || resolved.Sandbox.NetworkAllowedDomains[0] != "github.com" {
|
||||
t.Fatalf("NetworkAllowedDomains = %#v", resolved.Sandbox.NetworkAllowedDomains)
|
||||
}
|
||||
if len(resolved.Sandbox.NetworkDeniedDomains) != 1 {
|
||||
t.Fatalf("NetworkDeniedDomains = %#v", resolved.Sandbox.NetworkDeniedDomains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsScopedNetworkWithoutAllowlist(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"sandbox": {"network": "scoped"}}`)
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "scoped requires a non-empty sandbox.networkAllowedDomains") {
|
||||
t.Fatalf("expected scoped-without-allowlist error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsUnknownNetworkMode(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"sandbox": {"network": "bogus"}}`)
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "expected allow, deny, or scoped") {
|
||||
t.Fatalf("expected unknown-mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Security: a project config (a cloned repo) must NOT be able to add to the
|
||||
// scoped egress allowlist — only the global user config can.
|
||||
func TestResolveProjectCannotWidenNetworkAllowlist(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"sandbox": {"network": "scoped", "networkAllowedDomains": ["github.com"]}}`)
|
||||
projectPath := writeConfig(t, `{"sandbox": {"networkAllowedDomains": ["exfil.example"]}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
for _, d := range resolved.Sandbox.NetworkAllowedDomains {
|
||||
if d == "exfil.example" {
|
||||
t.Fatalf("project config widened the egress allowlist: %#v", resolved.Sandbox.NetworkAllowedDomains)
|
||||
}
|
||||
}
|
||||
if len(resolved.Sandbox.NetworkAllowedDomains) != 1 || resolved.Sandbox.NetworkAllowedDomains[0] != "github.com" {
|
||||
t.Fatalf("allowlist = %#v, want [github.com] only", resolved.Sandbox.NetworkAllowedDomains)
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,22 @@ func HasProviderProfile(profile ProviderProfile) bool {
|
||||
type SandboxConfig struct {
|
||||
MaxAutonomy string `json:"maxAutonomy,omitempty"`
|
||||
// Network controls whether shell commands classified as network-touching
|
||||
// (curl, git push, package installs, …) are allowed: "allow" or "deny".
|
||||
// Empty keeps the built-in default (deny). Without this knob the engine's
|
||||
// hard-coded NetworkDeny was unreachable from any config surface.
|
||||
// (curl, git push, package installs, …) are allowed: "allow", "deny", or
|
||||
// "scoped". Empty keeps the built-in default (deny). "scoped" permits egress
|
||||
// only to NetworkAllowedDomains (minus NetworkDeniedDomains) and REQUIRES a
|
||||
// non-empty NetworkAllowedDomains; it falls closed to "deny" on a backend that
|
||||
// cannot enforce scoped egress.
|
||||
Network string `json:"network,omitempty"`
|
||||
// NetworkAllowedDomains is the egress allowlist consulted only when Network is
|
||||
// "scoped": the sandboxed process may reach only these domains (minus
|
||||
// NetworkDeniedDomains) and nothing else. Ignored for "allow"/"deny". Like
|
||||
// AdditionalWriteRoots this is a GRANT, so it is honored from the GLOBAL user
|
||||
// config only — never project config — so a cloned repo cannot widen its own
|
||||
// network egress.
|
||||
NetworkAllowedDomains []string `json:"networkAllowedDomains,omitempty"`
|
||||
// NetworkDeniedDomains subtracts from NetworkAllowedDomains under "scoped".
|
||||
// Global-user-config only, for the same reason as NetworkAllowedDomains.
|
||||
NetworkDeniedDomains []string `json:"networkDeniedDomains,omitempty"`
|
||||
// AdditionalWriteRoots lists directories outside the workspace the sandbox
|
||||
// allows writes in. Each entry must be an existing directory; entries are
|
||||
// normalized (~-expanded, absolutized, symlink-resolved) at startup and an
|
||||
|
||||
@@ -121,9 +121,15 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
|
||||
func doctorSandboxPolicy(cfg config.SandboxConfig) sandbox.Policy {
|
||||
policy := sandbox.DefaultPolicy()
|
||||
switch sandbox.NetworkMode(cfg.Network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny, sandbox.NetworkScoped:
|
||||
policy.Network = sandbox.NetworkMode(cfg.Network)
|
||||
}
|
||||
if len(cfg.NetworkAllowedDomains) > 0 {
|
||||
policy.AllowedDomains = append([]string(nil), cfg.NetworkAllowedDomains...)
|
||||
}
|
||||
if len(cfg.NetworkDeniedDomains) > 0 {
|
||||
policy.DeniedDomains = append([]string(nil), cfg.NetworkDeniedDomains...)
|
||||
}
|
||||
policy.MonitorDenials = cfg.MonitorDenials
|
||||
return policy
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestDoctorSandboxPolicyWiresScopedNetwork(t *testing.T) {
|
||||
cfg := config.SandboxConfig{Network: "scoped", NetworkAllowedDomains: []string{"github.com"}}
|
||||
policy := doctorSandboxPolicy(cfg)
|
||||
if policy.Network != sandbox.NetworkScoped {
|
||||
t.Fatalf("Network = %q, want scoped", policy.Network)
|
||||
}
|
||||
if len(policy.AllowedDomains) != 1 || policy.AllowedDomains[0] != "github.com" {
|
||||
t.Fatalf("AllowedDomains = %#v", policy.AllowedDomains)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user