Files
zero/internal/mcp/config.go
KRATOS 59c332638d wedge M2: MCP server resources/prompts + sandbox scoped egress + MCP OAuth (#179)
* wedge stage 06: MCP server resources + prompts

Advertise resources and prompts capabilities in the initialize response and
handle resources/list, resources/read, prompts/list, and prompts/get alongside
the existing tools methods.

Resources enumerate in-scope workspace files via the shared workspace scanner
(reusing its gitignore-style exclusions) and read them read-only with strict
scope enforcement: traversal and out-of-scope absolute paths are rejected with
a JSON-RPC error and no contents, and binary files come back as base64 blobs.

Prompts expose a curated set of templates with simple {{arg}} substitution;
unknown prompt names return an error.

* wedge stage 07: sandbox scoped egress + auto-allow-bash

Add a middle-ground network mode between full allow and full deny:

- types.go: NetworkScoped network mode; Policy.AllowedDomains /
  Policy.DeniedDomains; Policy.AutoAllowBashWhenSandboxed plus its
  ZERO_SANDBOX_AUTO_ALLOW_BASH env surface (off by default).
- egress.go: in-process loopback (127.0.0.1:0) filtering proxy handling
  HTTP CONNECT (HTTPS tunnel) and plain HTTP. Allows only AllowedDomains
  minus DeniedDomains via exact-or-subdomain matching (github.com matches
  api.github.com but not notgithub.com or github.com.evil.com); deny wins.
  Denied targets get 403 / refused CONNECT. Decisions are logged through
  the repo redaction so query tokens never hit the audit trail. Fails
  closed: empty allowlist is rejected at construction.
- runner.go: NetworkScoped starts the proxy and routes the sandboxed
  process through it (HTTP(S)_PROXY/ALL_PROXY + NO_PROXY into the sandbox
  env; Linux keeps --unshare-net; macOS profile denies general network
  but permits localhost:proxyPort). Empty-allowlist scoped collapses to
  deny (effectiveNetwork). Any proxy-start error denies the build rather
  than falling back to open network. NetworkAllow/NetworkDeny unchanged.
- engine.go / registry.go / bash.go: honor AutoAllowBashWhenSandboxed -
  auto-allow bash without a prompt only when a wrapping sandbox is active
  for the command; ignore the flag when the sandbox is inactive. Preflight
  network gate now uses effectiveNetwork so a misconfigured scoped policy
  still fails closed. Bash defers plan.Cleanup() to shut the proxy down.

Default behaviour is unchanged for existing NetworkAllow / NetworkDeny
users and for the off-by-default auto-allow flag.

* wedge stage 08: MCP OAuth client

Add an OAuth 2.0 + PKCE authorization-code flow so ZERO can connect to
remote MCP servers that require OAuth.

- internal/mcp/oauth.go: authorization-server metadata discovery with
  config fallback/override, S256 PKCE, loopback redirect listener, state
  (CSRF) validation, code+verifier token exchange, refresh, and optional
  dynamic client registration. Tokens are never logged.
- internal/mcp/oauth_store.go: per-server token persistence in a 0600
  JSON file under the config dir, with a redaction-safe status summary
  that omits the token material.
- internal/mcp/network_client.go: OAuth-configured servers get a round
  tripper that attaches the bearer token and, on 401, refreshes once and
  retries; refresh failure surfaces an actionable re-login message.
- internal/mcp/config.go + internal/config: an MCP server entry may
  declare auth: "oauth" with optional client registration / explicit
  endpoints. Non-OAuth servers are unaffected.
- zero mcp oauth {login,logout,status} wired into the CLI surface;
  status reports presence/expiry, never the token.

* wedge M2: address review — 2 security fixes, portability, robustness (#179)

Security (Critical):
- resources: resolve symlinks BEFORE the scope check (and resolve roots) so an
  in-workspace symlink to an out-of-scope file (e.g. link -> /etc/passwd) is no
  longer readable via resources/read. resources/read echoes the requested URI
  (not the resolved path) so list/read stay consistent.
- sandbox egress: authorize the dialed URL host, not the client Host header, so
  'GET http://denied/ ... Host: allowed' can't bypass the allowlist.

Robustness/correctness:
- prompts: reject prompts/get that omits a required argument; tokenized
  substitution so {{...}} inside supplied values is preserved (not stripped).
- resources: percent-encode/decode file URIs via net/url (spaces, #, %).
- oauth: one timeout-scoped context bounds discovery + registration + callback +
  exchange; joinWellKnown preserves issuer path segments (RFC 8414).
- cli: 'mcp oauth login' rejects --json instead of silently ignoring it; the
  login test is bounded so a missed callback fails fast instead of hanging.

Portability: XDG token-store path test uses t.TempDir() so it passes on Windows
(fixes the Windows Smoke failure).

Adds regression tests for the symlink escape, Host-header bypass, required-arg
rejection, and placeholder preservation. Full go test ./... + -race green.

* wedge M2: fail closed on unenforceable scoped egress (review #179)

Addresses the two P1 scoped-egress enforcement gaps from review:

1. Policy-only fallback ran NetworkScoped with unrestricted networking. On Windows / any host without a native backend, 'network: scoped' with allowedDomains silently got open host networking because Evaluate permitted network-risk tools (expecting a proxy) while BuildCommandPlan ran a direct plan with no proxy. Evaluate now collapses scoped to deny when the active backend cannot enforce egress, so network-risk tools fail closed.

2. Bubblewrap 'scoped egress' could not reach the proxy. --unshare-net isolates the netns and there is no bridge to the host-loopback proxy (the comments claimed one; no code added it), so allowlisted egress never worked. Bubblewrap no longer starts an unreachable proxy: scoped collapses to deny (--unshare-net, no proxy env), matching Evaluate. Comments corrected; a real relay (e.g. slirp4netns) is left as future work.

Mechanism: new Backend.ScopedEgress capability (true only for sandbox-exec, which shares the host net under a seatbelt profile restricting outbound to the proxy port) + Backend.EnforcesScopedEgress(). sandbox-exec scoped egress is unchanged. Regression tests cover enforcing vs non-enforcing backends in Evaluate and the bubblewrap fail-closed plan.
2026-06-12 20:41:59 +05:30

220 lines
6.3 KiB
Go

package mcp
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/config"
)
type ServerType string
const (
ServerTypeStdio ServerType = "stdio"
ServerTypeHTTP ServerType = "http"
ServerTypeSSE ServerType = "sse"
)
type Server struct {
Name string
Type ServerType
Command string
Args []string
Env map[string]string
URL string
Headers map[string]string
Auth string
OAuth *OAuthConfig
Identity string
}
func NormalizeConfig(cfg config.MCPConfig) ([]Server, error) {
names := make([]string, 0, len(cfg.Servers))
for name := range cfg.Servers {
names = append(names, name)
}
sort.Strings(names)
servers := make([]Server, 0, len(names))
for _, name := range names {
raw := cfg.Servers[name]
if raw.Disabled {
continue
}
server, err := normalizeServer(name, raw)
if err != nil {
return nil, err
}
servers = append(servers, server)
}
return servers, nil
}
func normalizeServer(name string, raw config.MCPServerConfig) (Server, error) {
name = strings.TrimSpace(name)
if err := ValidateServerName(name); err != nil {
return Server{}, err
}
serverType := ServerType(strings.ToLower(strings.TrimSpace(raw.Type)))
if serverType == "" {
// When type is omitted, a URL makes the server use HTTP validation; otherwise
// command-based stdio remains the default.
if strings.TrimSpace(raw.URL) != "" {
serverType = ServerTypeHTTP
} else {
serverType = ServerTypeStdio
}
}
auth := strings.ToLower(strings.TrimSpace(raw.Auth))
server := Server{
Name: name,
Type: serverType,
Command: strings.TrimSpace(raw.Command),
Args: trimStringSlice(raw.Args),
Env: copyStringMap(raw.Env),
URL: strings.TrimSpace(raw.URL),
Headers: copyStringMap(raw.Headers),
Auth: auth,
OAuth: normalizeOAuthConfig(raw.OAuth),
}
switch server.Type {
case ServerTypeStdio:
if server.Command == "" {
return Server{}, fmt.Errorf("MCP server %s requires command for stdio transport", server.Name)
}
if server.URL != "" {
return Server{}, fmt.Errorf("MCP server %s url is only supported for http or sse transports", server.Name)
}
if len(server.Headers) > 0 {
return Server{}, fmt.Errorf("MCP server %s headers are only supported for http or sse transports", server.Name)
}
if server.Auth != "" {
return Server{}, fmt.Errorf("MCP server %s auth is only supported for http or sse transports", server.Name)
}
case ServerTypeHTTP, ServerTypeSSE:
if server.URL == "" {
return Server{}, fmt.Errorf("MCP server %s requires url for %s transport", server.Name, server.Type)
}
if server.Command != "" || len(server.Args) > 0 {
return Server{}, fmt.Errorf("MCP server %s command and args are only supported for stdio transport", server.Name)
}
if len(server.Env) > 0 {
return Server{}, fmt.Errorf("MCP server %s env is only supported for stdio transport", server.Name)
}
if err := validateHTTPURL(server.Name, server.URL); err != nil {
return Server{}, err
}
default:
return Server{}, fmt.Errorf("MCP server %s has unsupported type %q", server.Name, raw.Type)
}
if server.Auth != "" && server.Auth != ServerAuthOAuth {
return Server{}, fmt.Errorf("MCP server %s has unsupported auth %q", server.Name, raw.Auth)
}
server.Identity = computeServerIdentity(server)
return server, nil
}
// normalizeOAuthConfig converts a raw config OAuth block into the mcp package's
// OAuthConfig, trimming endpoint and identifier fields. Credential values are
// preserved verbatim.
func normalizeOAuthConfig(raw *config.MCPOAuthConfig) *OAuthConfig {
if raw == nil {
return nil
}
return &OAuthConfig{
ClientID: strings.TrimSpace(raw.ClientID),
ClientSecret: raw.ClientSecret,
Scopes: trimStringSlice(raw.Scopes),
AuthorizationEndpoint: strings.TrimSpace(raw.AuthorizationEndpoint),
TokenEndpoint: strings.TrimSpace(raw.TokenEndpoint),
RegistrationEndpoint: strings.TrimSpace(raw.RegistrationEndpoint),
IssuerURL: strings.TrimSpace(raw.IssuerURL),
}
}
func validateHTTPURL(serverName string, value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" {
return fmt.Errorf("MCP server %s url must be a valid http or https URL", serverName)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("MCP server %s url must use http or https", serverName)
}
return nil
}
func computeServerIdentity(server Server) string {
canonical := struct {
Type ServerType `json:"type"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Auth string `json:"auth,omitempty"`
OAuth *OAuthConfig `json:"oauth,omitempty"`
}{
Type: server.Type,
Command: server.Command,
Args: append([]string{}, server.Args...),
Env: copyStringMap(server.Env),
URL: server.URL,
Headers: copyStringMap(server.Headers),
Auth: server.Auth,
OAuth: server.OAuth,
}
// Marshal cannot fail for this canonical shape because it only contains
// JSON-serializable primitive, slice, and map fields.
data, _ := json.Marshal(canonical)
sum := sha256.Sum256(data)
// The first 32 hex characters give a stable 128-bit identity while keeping
// permission records compact.
return hex.EncodeToString(sum[:])[:32]
}
func trimStringSlice(values []string) []string {
if values == nil {
return nil
}
trimmed := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
trimmed = append(trimmed, value)
}
return trimmed
}
// copyStringMap trims and filters keys while preserving values verbatim. Env
// and header values may intentionally contain surrounding whitespace, unlike
// scalar fields such as Command and URL.
func copyStringMap(values map[string]string) map[string]string {
if len(values) == 0 {
return nil
}
copied := make(map[string]string, len(values))
for key, value := range values {
key = strings.TrimSpace(key)
if key == "" {
continue
}
copied[key] = value
}
if len(copied) == 0 {
return nil
}
return copied
}