mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -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.
247 lines
7.5 KiB
Go
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)
|
|
}
|