Files
zero/internal/mcp/server.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

238 lines
6.3 KiB
Go

package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/tools"
)
const defaultProtocolVersion = "2024-11-05"
const (
jsonRPCMethodNotFound = -32601
jsonRPCInvalidParams = -32602
jsonRPCInternalError = -32603
)
type ServeOptions struct {
Name string
Version string
PermissionGranted bool
// WorkspaceRoot is the directory whose files are exposed as MCP resources.
// Empty means the process working directory. Resource reads are confined to
// this root (and any extra Scope roots).
WorkspaceRoot string
// Scope, when set, widens the resource roots beyond WorkspaceRoot using the
// same multi-root scoping the sandbox/file tools use. nil means
// workspace-only.
Scope tools.PathScope
}
func Serve(ctx context.Context, input io.Reader, output io.Writer, registry *tools.Registry, options ServeOptions) error {
if registry == nil {
return errors.New("MCP server tool registry is required")
}
reader := newMessageReader(input)
writer := newMessageWriter(output)
resolvedOptions := options.withDefaults()
server := toolServer{
registry: registry,
options: resolvedOptions,
writer: writer,
workspaceRoot: resolvedOptions.WorkspaceRoot,
scope: resolvedOptions.Scope,
}
// Run the blocking reads on a goroutine and select on ctx so a
// non-responsive peer cannot hang shutdown: a cancelled ctx returns
// immediately instead of blocking on the next read.
type readResult struct {
message rpcMessage
err error
}
reads := make(chan readResult)
go func() {
defer close(reads)
for {
message, err := reader.read()
select {
case reads <- readResult{message: message, err: err}:
case <-ctx.Done():
return
}
if err != nil {
return
}
}
}()
for {
select {
case <-ctx.Done():
return ctx.Err()
case result, ok := <-reads:
if !ok {
return nil
}
if result.err != nil {
if errors.Is(result.err, io.EOF) {
return nil
}
return result.err
}
if err := server.handle(ctx, result.message); err != nil {
return err
}
}
}
}
func (options ServeOptions) withDefaults() ServeOptions {
if strings.TrimSpace(options.Name) == "" {
options.Name = "zero"
}
if strings.TrimSpace(options.Version) == "" {
options.Version = "dev"
}
if strings.TrimSpace(options.WorkspaceRoot) == "" {
if cwd, err := os.Getwd(); err == nil {
options.WorkspaceRoot = cwd
}
}
return options
}
type toolServer struct {
registry *tools.Registry
options ServeOptions
writer *messageWriter
workspaceRoot string
scope tools.PathScope
}
func (server toolServer) handle(ctx context.Context, message rpcMessage) error {
if message.ID == nil && strings.HasPrefix(message.Method, "notifications/") {
return nil
}
if message.ID == nil {
return nil
}
switch message.Method {
case "initialize":
return server.writeResult(message.ID, map[string]any{
"protocolVersion": defaultProtocolVersion,
"capabilities": map[string]any{
"tools": map[string]any{},
"resources": map[string]any{},
"prompts": map[string]any{},
},
"serverInfo": map[string]any{
"name": server.options.Name,
"version": server.options.Version,
},
})
case "tools/list":
return server.writeResult(message.ID, map[string]any{
"tools": server.remoteTools(),
})
case "tools/call":
result, err := server.callTool(ctx, message.Params)
if err != nil {
return server.writeError(message.ID, jsonRPCInvalidParams, err.Error())
}
return server.writeResult(message.ID, result)
case "resources/list":
return server.writeResult(message.ID, map[string]any{
"resources": server.listResources(),
})
case "resources/read":
contents, code, err := server.readResource(message.Params)
if err != nil {
return server.writeError(message.ID, code, err.Error())
}
return server.writeResult(message.ID, map[string]any{
"contents": contents,
})
case "prompts/list":
return server.writeResult(message.ID, map[string]any{
"prompts": listPrompts(),
})
case "prompts/get":
result, code, err := getPrompt(message.Params)
if err != nil {
return server.writeError(message.ID, code, err.Error())
}
return server.writeResult(message.ID, result)
case "ping":
// MCP requires servers to answer ping with an empty result; replying
// method-not-found makes liveness-checking clients drop the session.
return server.writeResult(message.ID, map[string]any{})
default:
return server.writeError(message.ID, jsonRPCMethodNotFound, "method not found")
}
}
func (server toolServer) remoteTools() []RemoteTool {
registered := server.registry.All()
sort.Slice(registered, func(left int, right int) bool {
return registered[left].Name() < registered[right].Name()
})
remote := make([]RemoteTool, 0, len(registered))
for _, tool := range registered {
remote = append(remote, RemoteTool{
Name: tool.Name(),
Description: tool.Description(),
InputSchema: SchemaToMCP(tool.Parameters()),
})
}
return remote
}
func (server toolServer) callTool(ctx context.Context, rawParams json.RawMessage) (CallToolResult, error) {
var params struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if len(rawParams) > 0 {
if err := json.Unmarshal(rawParams, &params); err != nil {
return CallToolResult{}, fmt.Errorf("invalid tools/call params: %w", err)
}
}
params.Name = strings.TrimSpace(params.Name)
if params.Name == "" {
return CallToolResult{}, errors.New("tools/call requires a tool name")
}
if params.Arguments == nil {
params.Arguments = map[string]any{}
}
result := server.registry.RunWithOptions(ctx, params.Name, params.Arguments, tools.RunOptions{
PermissionGranted: server.options.PermissionGranted,
})
return CallToolResult{
Content: []Content{{Type: "text", Text: result.Output}},
IsError: result.Status != tools.StatusOK,
}, nil
}
func (server toolServer) writeResult(id any, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return server.writeError(id, jsonRPCInternalError, err.Error())
}
return server.writer.write(rpcMessage{ID: id, Result: raw})
}
func (server toolServer) writeError(id any, code int, message string) error {
return server.writer.write(rpcMessage{ID: id, Error: &rpcError{Code: code, Message: message}})
}