mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -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.
115 lines
4.0 KiB
Go
115 lines
4.0 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
)
|
|
|
|
// nativeBackendStub reports as an active wrapping sandbox so shellSandboxActive
|
|
// is true. Its executable does not exist, so the command itself fails to launch
|
|
// — that is fine: these tests assert the *permission gate*, not execution.
|
|
func nativeBackendStub() sandbox.Backend {
|
|
return sandbox.Backend{
|
|
Name: sandbox.BackendBubblewrap,
|
|
Available: true,
|
|
Executable: "/nonexistent/bwrap-stub",
|
|
CommandWrapping: true,
|
|
NativeIsolation: true,
|
|
}
|
|
}
|
|
|
|
func autoAllowBashPolicy(autoAllow bool) sandbox.Policy {
|
|
policy := sandbox.DefaultPolicy()
|
|
policy.AutoAllowBashWhenSandboxed = autoAllow
|
|
// Network deny so no proxy is started for these gate-only tests.
|
|
return policy
|
|
}
|
|
|
|
const permissionRequiredFragment = "Permission required for bash"
|
|
|
|
// TestBashAutoAllowedWhenSandboxActive: flag on + active sandbox => the bash
|
|
// permission gate is bypassed (no "Permission required" error). The command
|
|
// itself fails to exec (stub backend), which is the expected non-gate outcome.
|
|
func TestBashAutoAllowedWhenSandboxActive(t *testing.T) {
|
|
root := t.TempDir()
|
|
registry := NewRegistry()
|
|
registry.Register(NewBashTool(root))
|
|
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
|
WorkspaceRoot: root,
|
|
Policy: autoAllowBashPolicy(true),
|
|
Backend: nativeBackendStub(),
|
|
})
|
|
|
|
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
|
"command": "echo hi",
|
|
}, RunOptions{
|
|
PermissionGranted: false,
|
|
Sandbox: engine,
|
|
PermissionMode: string(sandbox.PermissionModeAsk),
|
|
Autonomy: string(sandbox.AutonomyHigh),
|
|
})
|
|
|
|
if strings.Contains(result.Output, permissionRequiredFragment) {
|
|
t.Fatalf("bash was gated despite auto-allow: %q", result.Output)
|
|
}
|
|
if result.SandboxDecision == nil || result.SandboxDecision.Action != sandbox.ActionAllow || !result.SandboxDecision.AutoAllowed {
|
|
t.Fatalf("sandbox decision = %#v, want auto-allowed allow", result.SandboxDecision)
|
|
}
|
|
}
|
|
|
|
// TestBashStillPromptsWithoutAutoAllow: flag off + active sandbox => the normal
|
|
// prompt policy stands; the bash gate blocks an ungranted command.
|
|
func TestBashStillPromptsWithoutAutoAllow(t *testing.T) {
|
|
root := t.TempDir()
|
|
registry := NewRegistry()
|
|
registry.Register(NewBashTool(root))
|
|
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
|
WorkspaceRoot: root,
|
|
Policy: autoAllowBashPolicy(false),
|
|
Backend: nativeBackendStub(),
|
|
})
|
|
|
|
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
|
"command": "echo hi",
|
|
}, RunOptions{
|
|
PermissionGranted: false,
|
|
Sandbox: engine,
|
|
PermissionMode: string(sandbox.PermissionModeAsk),
|
|
Autonomy: string(sandbox.AutonomyHigh),
|
|
})
|
|
|
|
if result.Status != StatusError || !strings.Contains(result.Output, "Sandbox approval required for bash") {
|
|
t.Fatalf("expected bash to be gated when auto-allow off, got %s: %q", result.Status, result.Output)
|
|
}
|
|
}
|
|
|
|
// TestBashAutoAllowIgnoredWithoutActiveSandbox: flag on but the backend is
|
|
// policy-only (no native isolation), so the flag must be ignored and the bash
|
|
// gate blocks the ungranted command.
|
|
func TestBashAutoAllowIgnoredWithoutActiveSandbox(t *testing.T) {
|
|
root := t.TempDir()
|
|
registry := NewRegistry()
|
|
registry.Register(NewBashTool(root))
|
|
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
|
WorkspaceRoot: root,
|
|
Policy: autoAllowBashPolicy(true),
|
|
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly},
|
|
})
|
|
|
|
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
|
"command": "echo hi",
|
|
}, RunOptions{
|
|
PermissionGranted: false,
|
|
Sandbox: engine,
|
|
PermissionMode: string(sandbox.PermissionModeAsk),
|
|
Autonomy: string(sandbox.AutonomyHigh),
|
|
})
|
|
|
|
if result.Status != StatusError || !strings.Contains(result.Output, "Sandbox approval required for bash") {
|
|
t.Fatalf("expected bash to be gated when sandbox inactive, got %s: %q", result.Status, result.Output)
|
|
}
|
|
}
|