// Package config defines the configuration types and loading for greywall. package config import ( "encoding/json" "errors" "fmt" "net/url" "os" "path/filepath" "slices" "strings" "github.com/tidwall/jsonc" ) // Config is the main configuration for greywall. type Config struct { Extends string `json:"extends,omitempty"` Network NetworkConfig `json:"network"` Filesystem FilesystemConfig `json:"filesystem"` Command CommandConfig `json:"command"` SSH SSHConfig `json:"ssh"` AllowPty bool `json:"allowPty,omitempty"` } // NetworkConfig defines network restrictions. type NetworkConfig struct { ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080) DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153) AllowUnixSockets []string `json:"allowUnixSockets,omitempty"` AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"` AllowLocalBinding bool `json:"allowLocalBinding,omitempty"` AllowLocalOutbound *bool `json:"allowLocalOutbound,omitempty"` // If nil, defaults to AllowLocalBinding value } // FilesystemConfig defines filesystem restrictions. type FilesystemConfig struct { DefaultDenyRead *bool `json:"defaultDenyRead,omitempty"` // If nil or true, deny reads by default except system paths, CWD, and AllowRead AllowRead []string `json:"allowRead"` // Paths to allow reading (used when DefaultDenyRead is true) DenyRead []string `json:"denyRead"` AllowWrite []string `json:"allowWrite"` DenyWrite []string `json:"denyWrite"` AllowGitConfig bool `json:"allowGitConfig,omitempty"` } // IsDefaultDenyRead returns whether deny-by-default read mode is enabled. // Defaults to true when not explicitly set (nil). func (f *FilesystemConfig) IsDefaultDenyRead() bool { return f.DefaultDenyRead == nil || *f.DefaultDenyRead } // CommandConfig defines command restrictions. type CommandConfig struct { Deny []string `json:"deny"` Allow []string `json:"allow"` UseDefaults *bool `json:"useDefaults,omitempty"` } // SSHConfig defines SSH command restrictions. // SSH commands are filtered using an allowlist by default for security. type SSHConfig struct { AllowedHosts []string `json:"allowedHosts"` // Host patterns to allow SSH to (supports wildcards like *.example.com) DeniedHosts []string `json:"deniedHosts"` // Host patterns to deny SSH to (checked before allowed) AllowedCommands []string `json:"allowedCommands"` // Commands allowed over SSH (allowlist mode) DeniedCommands []string `json:"deniedCommands"` // Commands denied over SSH (checked before allowed) AllowAllCommands bool `json:"allowAllCommands,omitempty"` // If true, use denylist mode instead of allowlist InheritDeny bool `json:"inheritDeny,omitempty"` // If true, also apply global command.deny rules } // DefaultDeniedCommands returns commands that are blocked by default. // These are system-level dangerous commands that are rarely needed by AI agents. var DefaultDeniedCommands = []string{ // System control - can crash/reboot the machine "shutdown", "reboot", "halt", "poweroff", "init 0", "init 6", "systemctl poweroff", "systemctl reboot", "systemctl halt", // Kernel/module manipulation "insmod", "rmmod", "modprobe", "kexec", // Disk/partition manipulation (including common variants) "mkfs", "mkfs.ext2", "mkfs.ext3", "mkfs.ext4", "mkfs.xfs", "mkfs.btrfs", "mkfs.vfat", "mkfs.ntfs", "fdisk", "parted", "dd if=", // Container escape vectors "docker run -v /:/", "docker run --privileged", // Chroot/namespace escape "chroot", "unshare", "nsenter", } // Default returns the default configuration with all network blocked (no proxy = no network). func Default() *Config { return &Config{ Network: NetworkConfig{}, Filesystem: FilesystemConfig{ DenyRead: []string{}, AllowWrite: []string{}, DenyWrite: []string{}, }, Command: CommandConfig{ Deny: []string{}, Allow: []string{}, // UseDefaults defaults to true (nil = true) }, SSH: SSHConfig{ AllowedHosts: []string{}, DeniedHosts: []string{}, AllowedCommands: []string{}, DeniedCommands: []string{}, }, } } // DefaultConfigPath returns the default config file path. // Uses the OS-preferred config directory (XDG on Linux, ~/Library/Application Support on macOS). // Falls back to ~/.greywall.json if the new location doesn't exist but the legacy one does. func DefaultConfigPath() string { // Try OS-preferred config directory first configDir, err := os.UserConfigDir() if err == nil { newPath := filepath.Join(configDir, "greywall", "greywall.json") if _, err := os.Stat(newPath); err == nil { return newPath } // Check if parent directory exists (user has set up the new location) // If so, prefer this even if config doesn't exist yet if _, err := os.Stat(filepath.Dir(newPath)); err == nil { return newPath } } // Fall back to legacy path if it exists home, err := os.UserHomeDir() if err != nil { return "greywall.json" } legacyPath := filepath.Join(home, ".greywall.json") if _, err := os.Stat(legacyPath); err == nil { return legacyPath } // Neither exists, prefer new XDG-compliant path if configDir != "" { return filepath.Join(configDir, "greywall", "greywall.json") } return filepath.Join(home, ".config", "greywall", "greywall.json") } // Load loads configuration from a file path. func Load(path string) (*Config, error) { data, err := os.ReadFile(path) //nolint:gosec // user-provided config path - intentional if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("failed to read config file: %w", err) } // Handle empty file if len(strings.TrimSpace(string(data))) == 0 { return nil, nil } var cfg Config if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil { return nil, fmt.Errorf("invalid JSON in config file: %w", err) } if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid configuration: %w", err) } return &cfg, nil } // Validate validates the configuration. func (c *Config) Validate() error { if c.Network.ProxyURL != "" { if err := validateProxyURL(c.Network.ProxyURL); err != nil { return fmt.Errorf("invalid network.proxyUrl %q: %w", c.Network.ProxyURL, err) } } if c.Network.DnsAddr != "" { if err := validateHostPort(c.Network.DnsAddr); err != nil { return fmt.Errorf("invalid network.dnsAddr %q: %w", c.Network.DnsAddr, err) } } if slices.Contains(c.Filesystem.AllowRead, "") { return errors.New("filesystem.allowRead contains empty path") } if slices.Contains(c.Filesystem.DenyRead, "") { return errors.New("filesystem.denyRead contains empty path") } if slices.Contains(c.Filesystem.AllowWrite, "") { return errors.New("filesystem.allowWrite contains empty path") } if slices.Contains(c.Filesystem.DenyWrite, "") { return errors.New("filesystem.denyWrite contains empty path") } if slices.Contains(c.Command.Deny, "") { return errors.New("command.deny contains empty command") } if slices.Contains(c.Command.Allow, "") { return errors.New("command.allow contains empty command") } // SSH config for _, host := range c.SSH.AllowedHosts { if err := validateHostPattern(host); err != nil { return fmt.Errorf("invalid ssh.allowedHosts %q: %w", host, err) } } for _, host := range c.SSH.DeniedHosts { if err := validateHostPattern(host); err != nil { return fmt.Errorf("invalid ssh.deniedHosts %q: %w", host, err) } } if slices.Contains(c.SSH.AllowedCommands, "") { return errors.New("ssh.allowedCommands contains empty command") } if slices.Contains(c.SSH.DeniedCommands, "") { return errors.New("ssh.deniedCommands contains empty command") } return nil } // UseDefaultDeniedCommands returns whether to use the default deny list. func (c *CommandConfig) UseDefaultDeniedCommands() bool { return c.UseDefaults == nil || *c.UseDefaults } // validateProxyURL validates a SOCKS5 proxy URL. func validateProxyURL(proxyURL string) error { u, err := url.Parse(proxyURL) if err != nil { return fmt.Errorf("invalid URL: %w", err) } if u.Scheme != "socks5" && u.Scheme != "socks5h" { return errors.New("proxy URL must use socks5:// or socks5h:// scheme") } if u.Hostname() == "" { return errors.New("proxy URL must include a hostname") } if u.Port() == "" { return errors.New("proxy URL must include a port") } return nil } // validateHostPort validates a host:port address. func validateHostPort(addr string) error { // Must contain a colon separating host and port host, port, found := strings.Cut(addr, ":") if !found || host == "" || port == "" { return errors.New("must be in host:port format (e.g. localhost:3153)") } return nil } // validateHostPattern validates an SSH host pattern. // Host patterns are more permissive than domain patterns: // - Can contain wildcards anywhere (e.g., prod-*.example.com, *.example.com) // - Can be IP addresses // - Can be simple hostnames without dots func validateHostPattern(pattern string) error { if pattern == "" { return errors.New("empty host pattern") } // Reject patterns with protocol or path if strings.Contains(pattern, "://") || strings.Contains(pattern, "/") { return errors.New("host pattern cannot contain protocol or path") } // Reject patterns with port (user@host:port style) // But allow colons for IPv6 addresses if strings.Contains(pattern, ":") && !strings.Contains(pattern, "::") && !isIPv6Pattern(pattern) { return errors.New("host pattern cannot contain port; specify port in SSH command instead") } // Reject patterns with @ (should be just the host, not user@host) if strings.Contains(pattern, "@") { return errors.New("host pattern should not contain username; specify just the host") } return nil } // isIPv6Pattern checks if a pattern looks like an IPv6 address. func isIPv6Pattern(pattern string) bool { // IPv6 addresses contain multiple colons colonCount := strings.Count(pattern, ":") return colonCount >= 2 } // MatchesHost checks if a hostname matches an SSH host pattern. // SSH host patterns support wildcards anywhere in the pattern. func MatchesHost(hostname, pattern string) bool { hostname = strings.ToLower(hostname) pattern = strings.ToLower(pattern) // "*" matches all hosts if pattern == "*" { return true } // If pattern contains no wildcards, do exact match if !strings.Contains(pattern, "*") { return hostname == pattern } // Convert glob pattern to a simple matcher // Split pattern by * and check each part return matchGlob(hostname, pattern) } // matchGlob performs simple glob matching with * wildcards. func matchGlob(s, pattern string) bool { // Handle edge cases if pattern == "*" { return true } if pattern == "" { return s == "" } // Split pattern by * and match parts parts := strings.Split(pattern, "*") // Check prefix (before first *) if !strings.HasPrefix(s, parts[0]) { return false } s = s[len(parts[0]):] // Check suffix (after last *) if len(parts) > 1 { last := parts[len(parts)-1] if !strings.HasSuffix(s, last) { return false } s = s[:len(s)-len(last)] } // Check middle parts (between *s) for i := 1; i < len(parts)-1; i++ { part := parts[i] if part == "" { continue } idx := strings.Index(s, part) if idx < 0 { return false } s = s[idx+len(part):] } return true } // Merge combines a base config with an override config. // Values in override take precedence. Slice fields are appended (base + override). // The Extends field is cleared in the result since inheritance has been resolved. func Merge(base, override *Config) *Config { if base == nil { if override == nil { return Default() } result := *override result.Extends = "" return &result } if override == nil { result := *base result.Extends = "" return &result } result := &Config{ // AllowPty: true if either config enables it AllowPty: base.AllowPty || override.AllowPty, Network: NetworkConfig{ // ProxyURL/DnsAddr: override wins if non-empty ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL), DnsAddr: mergeString(base.Network.DnsAddr, override.Network.DnsAddr), // Append slices (base first, then override additions) AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets), // Boolean fields: override wins if set, otherwise base AllowAllUnixSockets: base.Network.AllowAllUnixSockets || override.Network.AllowAllUnixSockets, AllowLocalBinding: base.Network.AllowLocalBinding || override.Network.AllowLocalBinding, // Pointer fields: override wins if set, otherwise base AllowLocalOutbound: mergeOptionalBool(base.Network.AllowLocalOutbound, override.Network.AllowLocalOutbound), }, Filesystem: FilesystemConfig{ // Pointer field: override wins if set, otherwise base (nil = deny-by-default) DefaultDenyRead: mergeOptionalBool(base.Filesystem.DefaultDenyRead, override.Filesystem.DefaultDenyRead), // Append slices AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead), DenyRead: mergeStrings(base.Filesystem.DenyRead, override.Filesystem.DenyRead), AllowWrite: mergeStrings(base.Filesystem.AllowWrite, override.Filesystem.AllowWrite), DenyWrite: mergeStrings(base.Filesystem.DenyWrite, override.Filesystem.DenyWrite), // Boolean fields: override wins if set AllowGitConfig: base.Filesystem.AllowGitConfig || override.Filesystem.AllowGitConfig, }, Command: CommandConfig{ // Append slices Deny: mergeStrings(base.Command.Deny, override.Command.Deny), Allow: mergeStrings(base.Command.Allow, override.Command.Allow), // Pointer field: override wins if set UseDefaults: mergeOptionalBool(base.Command.UseDefaults, override.Command.UseDefaults), }, SSH: SSHConfig{ // Append slices AllowedHosts: mergeStrings(base.SSH.AllowedHosts, override.SSH.AllowedHosts), DeniedHosts: mergeStrings(base.SSH.DeniedHosts, override.SSH.DeniedHosts), AllowedCommands: mergeStrings(base.SSH.AllowedCommands, override.SSH.AllowedCommands), DeniedCommands: mergeStrings(base.SSH.DeniedCommands, override.SSH.DeniedCommands), // Boolean fields: true if either enables it AllowAllCommands: base.SSH.AllowAllCommands || override.SSH.AllowAllCommands, InheritDeny: base.SSH.InheritDeny || override.SSH.InheritDeny, }, } return result } // mergeStrings appends two string slices, removing duplicates. func mergeStrings(base, override []string) []string { if len(base) == 0 { return override } if len(override) == 0 { return base } seen := make(map[string]bool, len(base)) result := make([]string, 0, len(base)+len(override)) for _, s := range base { if !seen[s] { seen[s] = true result = append(result, s) } } for _, s := range override { if !seen[s] { seen[s] = true result = append(result, s) } } return result } // mergeOptionalBool returns override if non-nil, otherwise base. func mergeOptionalBool(base, override *bool) *bool { if override != nil { return override } return base } // mergeString returns override if non-empty, otherwise base. func mergeString(base, override string) string { if override != "" { return override } return base }