mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
* 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.
238 lines
6.3 KiB
Go
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, ¶ms); 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}})
|
|
}
|