Files
zero/internal/cli/mcp_oauth.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

247 lines
7.3 KiB
Go

package cli
import (
"context"
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/redaction"
)
func runMCPOAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "mcp oauth subcommand required. Use `zero mcp oauth status`.")
}
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
if err := writeMCPOAuthHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
switch args[0] {
case "login":
return runMCPOAuthLogin(args[1:], stdout, stderr, deps)
case "logout":
return runMCPOAuthLogout(args[1:], stdout, stderr, deps)
case "status":
return runMCPOAuthStatus(args[1:], stdout, stderr, deps)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown mcp oauth subcommand %q", args[0]))
}
}
func runMCPOAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
opts, positional, help, err := parseMCPPositionalCommand(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeMCPOAuthHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if opts.json {
// login is an interactive flow (prints a URL, waits for the callback); a
// machine-readable mode would be misleading, so reject it rather than
// accepting the flag and ignoring it.
return writeExecUsageError(stderr, "zero mcp oauth login does not support --json")
}
if len(positional) != 1 {
return writeExecUsageError(stderr, "usage: zero mcp oauth login <server>")
}
serverName := positional[0]
server, err := resolveOAuthServer(deps, serverName)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
store, err := deps.newMCPTokenStore()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
// The opener prints the authorization URL so headless environments can copy
// it into a browser. The URL carries no token material.
opener := func(authURL string) error {
_, err := fmt.Fprintf(stdout, "Open this URL in your browser to authorize %s:\n\n %s\n\nWaiting for the authorization callback...\n", serverName, authURL)
return err
}
token, err := mcp.Login(context.Background(), mcp.LoginOptions{
ServerName: serverName,
ServerURL: server.URL,
Config: oauthConfigForServer(server),
OpenBrowser: opener,
Now: deps.now,
})
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if err := store.Save(serverName, token); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if _, err := fmt.Fprintf(stdout, "Stored OAuth credentials for %s.\n", serverName); err != nil {
return exitCrash
}
return exitSuccess
}
func runMCPOAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, positional, help, err := parseMCPPositionalCommand(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeMCPOAuthHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if len(positional) != 1 {
return writeExecUsageError(stderr, "usage: zero mcp oauth logout <server>")
}
serverName := positional[0]
store, err := deps.newMCPTokenStore()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
removed, err := store.Delete(serverName)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if options.json {
payload := struct {
ServerName string `json:"serverName"`
Removed bool `json:"removed"`
}{ServerName: serverName, Removed: removed}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
if removed {
if _, err := fmt.Fprintf(stdout, "Removed OAuth credentials for %s.\n", serverName); err != nil {
return exitCrash
}
} else if _, err := fmt.Fprintf(stdout, "No OAuth credentials stored for %s.\n", serverName); err != nil {
return exitCrash
}
return exitSuccess
}
func runMCPOAuthStatus(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, positional, help, err := parseMCPPositionalCommand(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeMCPOAuthHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if len(positional) > 1 {
return writeExecUsageError(stderr, "usage: zero mcp oauth status [server]")
}
store, err := deps.newMCPTokenStore()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
statuses, err := store.Status()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if len(positional) == 1 {
statuses = filterTokenStatuses(statuses, positional[0])
}
if options.json {
// TokenStatus is redaction-safe by construction: it carries presence and
// expiry metadata but never the access or refresh token, so it is emitted
// directly. Routing time.Time through reflective redaction would mangle the
// expiry timestamp.
payload := struct {
Tokens []mcp.TokenStatus `json:"tokens"`
}{Tokens: statuses}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, mcp.FormatTokenStatuses(statuses)); err != nil {
return exitCrash
}
return exitSuccess
}
func filterTokenStatuses(statuses []mcp.TokenStatus, serverName string) []mcp.TokenStatus {
filtered := make([]mcp.TokenStatus, 0, 1)
for _, status := range statuses {
if status.ServerName == serverName {
filtered = append(filtered, status)
}
}
return filtered
}
// resolveOAuthServer loads the workspace MCP config and returns the named server
// after verifying it declares OAuth authentication.
func resolveOAuthServer(deps appDeps, serverName string) (mcp.Server, error) {
if err := mcp.ValidateServerName(serverName); err != nil {
return mcp.Server{}, err
}
cwd, err := deps.getwd()
if err != nil {
return mcp.Server{}, fmt.Errorf("failed to resolve workspace: %w", err)
}
cfg, err := deps.resolveMCPConfig(cwd)
if err != nil {
return mcp.Server{}, err
}
servers, err := mcp.NormalizeConfig(cfg)
if err != nil {
return mcp.Server{}, err
}
for _, server := range servers {
if server.Name != serverName {
continue
}
if !strings.EqualFold(server.Auth, mcp.ServerAuthOAuth) {
return mcp.Server{}, fmt.Errorf("MCP server %q does not declare auth: \"oauth\"", serverName)
}
return server, nil
}
return mcp.Server{}, fmt.Errorf("MCP server %q is not configured", serverName)
}
func oauthConfigForServer(server mcp.Server) mcp.OAuthConfig {
if server.OAuth != nil {
return *server.OAuth
}
return mcp.OAuthConfig{}
}
func writeMCPOAuthHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero mcp oauth <command>
Commands:
login <server> Run the OAuth flow and store credentials for a server
logout <server> Delete stored OAuth credentials for a server
status [server] Show credential presence and expiry (never the token)
Flags:
--json Print command result as JSON
-h, --help Show this help
`)
return err
}