Files
zero/internal/cli/mcp_oauth_test.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.5 KiB
Go

package cli
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
)
// syncBuffer is a goroutine-safe writer used when a background goroutine reads
// CLI output while the command is still writing it.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func TestRunMCPOAuthStatusReportsPresenceWithoutToken(t *testing.T) {
store, err := mcp.NewTokenStore(mcp.TokenStoreOptions{
FilePath: filepath.Join(t.TempDir(), "mcp-oauth-tokens.json"),
})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
expiry := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
if err := store.Save("remote", mcp.StoredToken{
AccessToken: "super-secret-access",
RefreshToken: "super-secret-refresh",
ExpiresAt: expiry,
}); err != nil {
t.Fatalf("Save() error = %v", err)
}
deps := appDeps{newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil }}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"mcp", "oauth", "status", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
out := stdout.String()
if strings.Contains(out, "super-secret-access") || strings.Contains(out, "super-secret-refresh") {
t.Fatalf("status leaked token material: %s", out)
}
var payload struct {
Tokens []mcp.TokenStatus `json:"tokens"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode status JSON: %v\n%s", err, out)
}
if len(payload.Tokens) != 1 {
t.Fatalf("tokens = %#v, want one", payload.Tokens)
}
if !payload.Tokens[0].HasToken || !payload.Tokens[0].HasRefreshToken {
t.Fatalf("status = %#v, want present token", payload.Tokens[0])
}
}
func TestRunMCPOAuthLogout(t *testing.T) {
store, err := mcp.NewTokenStore(mcp.TokenStoreOptions{
FilePath: filepath.Join(t.TempDir(), "mcp-oauth-tokens.json"),
})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
if err := store.Save("remote", mcp.StoredToken{AccessToken: "a", RefreshToken: "r"}); err != nil {
t.Fatalf("Save() error = %v", err)
}
deps := appDeps{newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil }}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"mcp", "oauth", "logout", "remote", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), `"removed": true`) {
t.Fatalf("logout output = %s", stdout.String())
}
if _, ok, _ := store.Load("remote"); ok {
t.Fatal("token still present after logout")
}
}
func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-final",
"refresh_token": "refresh-final",
"token_type": "Bearer",
"expires_in": 3600,
})
}))
defer tokenServer.Close()
store, err := mcp.NewTokenStore(mcp.TokenStoreOptions{
FilePath: filepath.Join(t.TempDir(), "mcp-oauth-tokens.json"),
})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
cwd := t.TempDir()
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {
Type: "http",
URL: "https://remote.invalid/mcp",
Auth: "oauth",
OAuth: &config.MCPOAuthConfig{
ClientID: "client-123",
AuthorizationEndpoint: "https://remote.invalid/authorize",
TokenEndpoint: tokenServer.URL,
Scopes: []string{"read"},
},
},
}}, nil
},
now: time.Now,
}
// Drive the loopback redirect as soon as the authorization URL is printed.
stdout := &syncBuffer{}
stderr := &syncBuffer{}
go func() {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
callbackURL := extractCallbackURL(stdout.String())
if callbackURL != "" {
_, _ = http.Get(callbackURL)
return
}
time.Sleep(5 * time.Millisecond)
}
}()
// Run the command (which blocks waiting for the callback) off-goroutine and
// bound it: if the callback is never driven, fail fast instead of hanging
// until the package test timeout.
done := make(chan int, 1)
go func() {
done <- runWithDeps([]string{"mcp", "oauth", "login", "remote"}, stdout, stderr, deps)
}()
var exitCode int
select {
case exitCode = <-done:
case <-time.After(10 * time.Second):
t.Fatalf("login did not complete within 10s; stderr=%s stdout=%s", stderr.String(), stdout.String())
}
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
}
token, ok, err := store.Load("remote")
if err != nil || !ok {
t.Fatalf("Load() ok=%v err=%v", ok, err)
}
if token.AccessToken != "access-final" || token.RefreshToken != "refresh-final" {
t.Fatalf("stored token = %#v", token)
}
// The login output must never echo the issued tokens.
if strings.Contains(stdout.String(), "access-final") || strings.Contains(stdout.String(), "refresh-final") {
t.Fatalf("login stdout leaked token: %s", stdout.String())
}
}
func TestRunMCPOAuthLoginRejectsNonOAuthServer(t *testing.T) {
cwd := t.TempDir()
store, err := mcp.NewTokenStore(mcp.TokenStoreOptions{
FilePath: filepath.Join(t.TempDir(), "mcp-oauth-tokens.json"),
})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"plain": {Type: "http", URL: "https://plain.invalid/mcp"},
}}, nil
},
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"mcp", "oauth", "login", "plain"}, &stdout, &stderr, deps)
if exitCode == exitSuccess {
t.Fatal("login on non-oauth server should fail")
}
if !strings.Contains(stderr.String(), "oauth") {
t.Fatalf("stderr = %q, want oauth guidance", stderr.String())
}
}
func TestRunMCPOAuthUnknownSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"mcp", "oauth", "bogus"}, &stdout, &stderr, appDeps{})
if exitCode != exitUsage {
t.Fatalf("exitCode = %d, want usage error", exitCode)
}
}
// extractCallbackURL pulls the printed authorization URL and rewrites it into a
// loopback callback hit carrying the code and state.
func extractCallbackURL(output string) string {
const marker = "https://remote.invalid/authorize"
index := strings.Index(output, marker)
if index < 0 {
return ""
}
rest := output[index:]
if end := strings.IndexAny(rest, " \n"); end >= 0 {
rest = rest[:end]
}
parsed, err := url.Parse(rest)
if err != nil {
return ""
}
state := parsed.Query().Get("state")
if state == "" {
return ""
}
redirect := parsed.Query().Get("redirect_uri")
if redirect == "" {
return ""
}
return redirect + "?code=auth-code&state=" + url.QueryEscape(state)
}