// Package security provides command validation and limit helpers for the // runtime. The goal for the MVP is a "safe-enough" guard for verified MCP // servers and short-lived sandboxes, not perfect isolation. package security import ( "fmt" "path/filepath" "sort" "strings" ) // allowedPrefixes are the only program names a start command may invoke. var allowedPrefixes = map[string]bool{ "npx": true, "uvx": true, "pipx": true, "python": true, "python3": true, "node": true, } // blockedTokens are exact tokens that may never appear in a command. var blockedTokens = map[string]bool{ "sudo": true, "docker": true, "apt": true, "apt-get": true, "apk": true, "yum": true, "dnf": true, "systemctl": true, "mkfs": true, "mount": true, "curl": true, "wget": true, "sh": true, "bash": true, "zsh": true, "eval": true, } // shellMetacharacters are forbidden anywhere in the raw command string. They // enable chaining, redirects, command substitution or backgrounding. const shellMetacharacters = "&|;<>`$()\n\r" // ValidateCommand checks a raw start command and returns its tokens when // accepted. It rejects shell chaining, redirects, backgrounding and any // program outside the allow-list. func ValidateCommand(raw string) ([]string, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return nil, fmt.Errorf("empty start command") } if i := strings.IndexAny(trimmed, shellMetacharacters); i >= 0 { return nil, fmt.Errorf("command contains forbidden shell metacharacter %q", trimmed[i]) } tokens, err := SplitCommand(trimmed) if err != nil { return nil, err } if len(tokens) == 0 { return nil, fmt.Errorf("empty start command") } base := filepath.Base(tokens[0]) if !allowedPrefixes[base] { return nil, fmt.Errorf("program %q is not allowed (permitted: npx, uvx, pipx, python, python3, node)", base) } for _, t := range tokens { if blockedTokens[filepath.Base(t)] { return nil, fmt.Errorf("command contains blocked token %q", t) } } return tokens, nil } // SplitCommand tokenises a command line, honouring single and double quotes. // It is intentionally simple: shell metacharacters are rejected upstream, so // no escaping, expansion or globbing is performed. func SplitCommand(s string) ([]string, error) { var tokens []string var cur strings.Builder var quote rune inToken := false flush := func() { if inToken { tokens = append(tokens, cur.String()) cur.Reset() inToken = false } } for _, r := range s { switch { case quote != 0: if r == quote { quote = 0 } else { cur.WriteRune(r) } case r == '\'' || r == '"': quote = r inToken = true case r == ' ' || r == '\t': flush() default: inToken = true cur.WriteRune(r) } } if quote != 0 { return nil, fmt.Errorf("unbalanced quote in command") } flush() return tokens, nil } // AllowedPrograms returns the sorted list of permitted start-command programs. func AllowedPrograms() []string { out := make([]string, 0, len(allowedPrefixes)) for k := range allowedPrefixes { out = append(out, k) } sort.Strings(out) return out } // BlockedTokens returns the sorted list of tokens refused anywhere in a command. func BlockedTokens() []string { out := make([]string, 0, len(blockedTokens)) for k := range blockedTokens { out = append(out, k) } sort.Strings(out) return out }