mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 13:11:30 -10:00
* feat(workspacetrust): add exact-match trust store for project config gating * feat(hooks): add ExcludeProject option to LoadConfig * feat(plugins): add ExcludeProject option to Load * feat(cli): gate project hooks and plugins behind workspace trust Trust check lives inside newHookDispatcherWithExtra and activatePlugins (fail-closed by construction); trustRoot is the original launch dir so a --worktree run inherits the source repo's trust. Callers OR the skip reports and emit one combined stderr notice. * feat(cli): add zero trust command (trust/list/remove) * fix(review): key spec-draft trust on the original launch dir; cover trust CLI edge branches Spec-draft runs (zero exec --use-spec --worktree) passed the reassigned worktree path as trustRoot, dropping a trusted repo's project hooks (fail-closed, but breaks worktree-inheritance). Thread the captured pre-worktree trustRoot into execSpecDraftRun. Add tests for the trust 'remove' too-many-args and --help branches, and a resolveTrust comment noting the per-chokepoint check must not be hoisted to callers. * test(cli): end-to-end trust-gate coverage through agent.Run and exec --worktree Drives the real agent loop with a fake tool-calling provider so a project beforeTool hook actually fires (or is gated away) through production code: gate blocks a tool call via agent.Run, and --worktree / --use-spec --worktree key trust on the original launch dir. All three are load-bearing (RED when the gate or the trustRoot threading regresses). * style(cli): gofmt struct alignment in exec_spec.go * fix(workspacetrust): compare stored trust entries literally, no symlink re-resolution External adversarial review (codex) found a false positive: IsTrusted re-normalized stored entries on every check (Abs+EvalSymlinks), so a trusted path later replaced by a symlink to attacker content re-resolved to the new target and matched. Stored entries are already canonical (normalized once at Trust time); compare them as literals and only normalize the incoming query. Add a regression test for the symlink-retarget case. * feat(mcp): gate project-scoped MCP servers behind workspace trust Closes the P0 an external adversarial review (codex) found: project ./.zero/config.json stdio MCP servers spawned before the hook/plugin gate, an ungated arbitrary-execution path that could also self-trust the workspace. Add ExcludeProject to config.ResolveMCP (skips the project layer) and thread a fail-closed trust check into every MCP spawn site (exec, TUI setup+refresh, mcp tools list, mcp check); reporting sites stay ungated. Verified the sole server-command exec (mcp/client.go) is reachable only through the now-gated RegisterTools paths. * test(cli): cover default-mode beforeTool firing through the trust gate Assert a project beforeTool hook fires in the default auto and ask permission modes (not only under unsafe), and is blocked when the workspace is untrusted, so the gate is verified in the mode a normal run actually uses. * docs(cli): note MCP servers in zero trust help text and doc comment The gate covers project hooks, plugins, and MCP servers, but the help string and runTrust doc comment listed only hooks and plugins. * test(cli): make workspace-trust tests portable on macOS and Windows setTrustConfigRoot set HOME on darwin, but config.UserConfigDir is XDG-first there (=$HOME/.config), so tests wrote trust.json and user hooks.json where the code never read them; set XDG_CONFIG_HOME on every non-Windows platform instead. writeMarkerHook shells out to /bin/sh, absent on Windows, so its trusted-case marker never appeared; skip it there like writeMarkerHookScript already does. TestRunTrustAddThenList compared `trust list` output (canonicalized via EvalSymlinks) against the raw cwd, which diverges on the Windows runner's 8.3 short names (RUNNER~1 -> runneradmin); compare the resolved path, and add TestRunTrustListShowsCanonicalPath to exercise that via a symlink on any platform. * feat(cli): surface the project MCP skip in the workspace-trust notice Project MCP servers were dropped silently in an untrusted workspace: the trust notice covered hooks and plugins but not MCP, and app.go discarded the resolveTrust error. registerMCPToolsForWorkspace now returns a trustSkip, gated on a new projectMCPConfigExists check plus the previously-dropped store-read error, and emitTrustNotice is variadic so the exec, spec-draft, interactive, and `mcp tools list` paths all fold in the MCP skip. The skip drives the notice only; the gate stays on resolveTrust/excludeProject, so a trusted workspace is unaffected and an untrusted one still fails closed. * fix(cli): print zero trust --help to stdout with a success exit zero trust -h/--help/help routed usage to stderr with the usage exit code, unlike every other subcommand (mcp, sandbox, skills, cron, ...) which prints help to stdout and returns success. Align it; the unknown-subcommand path still writes to stderr with the usage exit code. * fix(cli): gate oauth MCP login and surface mcp-check trust notice Two project-scoped MCP activation sites were left outside the workspace trust gate. zero mcp oauth login loaded a project-defined OAuth server from an untrusted workspace and ran the full discovery/registration/token-exchange flow against its configured URL. Gate resolveOAuthServer with resolveTrust so an untrusted clone's ./.zero/config.json OAuth server is dropped before login, and emit the same one-line trust notice the other spawn sites use. zero mcp check already gated the project layer but discarded the trust-check result and never emitted the notice, so a gated project server read as a bare "not configured". Emit the notice on the not-found branch. The three remaining ungated resolveMCPConfig(cwd, false) sites (backends doctor/snapshot and the extensions listing) are enumeration-only and stay ungated. * test(cli): adapt MCP startup-skip tests to two-arg resolveMCPConfig --------- Co-authored-by: Kevin Codex <[email protected]>
666 lines
20 KiB
Go
666 lines
20 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/hooks"
|
|
"github.com/Gitlawb/zero/internal/mcp"
|
|
"github.com/Gitlawb/zero/internal/plugins"
|
|
"github.com/Gitlawb/zero/internal/redaction"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
)
|
|
|
|
type pluginListOptions struct {
|
|
json bool
|
|
}
|
|
|
|
type hookListOptions struct {
|
|
json bool
|
|
}
|
|
|
|
type mcpCommandOptions struct {
|
|
json bool
|
|
confirm bool
|
|
}
|
|
|
|
type mcpLegacyListOptions struct {
|
|
json bool
|
|
tools bool
|
|
}
|
|
|
|
func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
if len(args) == 0 {
|
|
return writeExecUsageError(stderr, "plugins subcommand required. Use `zero plugins list`.")
|
|
}
|
|
switch args[0] {
|
|
case "-h", "--help", "help":
|
|
if err := writePluginsHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "list":
|
|
options, help, err := parsePluginListArgs(args[1:])
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writePluginsListHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
cwd, err := deps.getwd()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
|
|
}
|
|
result, err := deps.loadPlugins(plugins.LoadOptions{Cwd: cwd})
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if options.json {
|
|
payload := struct {
|
|
Plugins []plugins.LoadedPlugin `json:"plugins"`
|
|
Diagnostics []plugins.Diagnostic `json:"diagnostics"`
|
|
}{Plugins: result.Plugins, Diagnostics: result.Diagnostics}
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
output := redaction.RedactString(plugins.FormatList(result.Plugins, result.Diagnostics), redaction.Options{})
|
|
if _, err := fmt.Fprintln(stdout, output); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "add":
|
|
return runPluginAdd(args[1:], deps.pluginsDir(), stdout, stderr)
|
|
case "remove", "rm":
|
|
return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr)
|
|
default:
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown plugins subcommand %q", args[0]))
|
|
}
|
|
}
|
|
|
|
func runHooks(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
if len(args) == 0 {
|
|
return writeExecUsageError(stderr, "hooks subcommand required. Use `zero hooks list`.")
|
|
}
|
|
switch args[0] {
|
|
case "-h", "--help", "help":
|
|
if err := writeHooksHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "list":
|
|
options, help, err := parseHookListArgs(args[1:])
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeHooksListHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
cwd, err := deps.getwd()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
|
|
}
|
|
result, err := deps.loadHooks(hooks.LoadOptions{Cwd: cwd})
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if options.json {
|
|
payload := struct {
|
|
Hooks hooks.Config `json:"hooks"`
|
|
Diagnostics []hooks.Diagnostic `json:"diagnostics"`
|
|
}{Hooks: result.Config, Diagnostics: result.Diagnostics}
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
output := redaction.RedactString(hooks.FormatList(result.Config, result.Diagnostics), redaction.Options{})
|
|
if _, err := fmt.Fprintln(stdout, output); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "add":
|
|
return runHooksAdd(args[1:], stdout, stderr, deps)
|
|
case "remove", "rm":
|
|
return runHooksRemove(args[1:], stdout, stderr, deps)
|
|
case "enable":
|
|
return runHooksToggle(args[1:], stdout, stderr, deps, false)
|
|
case "disable":
|
|
return runHooksToggle(args[1:], stdout, stderr, deps, true)
|
|
default:
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown hooks subcommand %q", args[0]))
|
|
}
|
|
}
|
|
|
|
func runMCP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
return runMCPWithContext(context.Background(), args, stdout, stderr, deps)
|
|
}
|
|
|
|
func runMCPWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if len(args) == 0 {
|
|
return writeExecUsageError(stderr, "mcp subcommand required. Use `zero mcp permissions list`.")
|
|
}
|
|
switch args[0] {
|
|
case "-h", "--help", "help":
|
|
if err := writeMCPHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "add":
|
|
return runMCPAdd(args[1:], stdout, stderr, deps)
|
|
case "remove", "rm":
|
|
return runMCPRemove(args[1:], stdout, stderr, deps)
|
|
case "enable":
|
|
return runMCPToggle(args[1:], stdout, stderr, deps, false)
|
|
case "disable":
|
|
return runMCPToggle(args[1:], stdout, stderr, deps, true)
|
|
case "check":
|
|
return runMCPCheck(ctx, args[1:], stdout, stderr, deps)
|
|
case "permissions":
|
|
return runMCPPermissions(args[1:], stdout, stderr, deps)
|
|
case "tools":
|
|
return runMCPTools(ctx, args[1:], stdout, stderr, deps)
|
|
case "oauth":
|
|
return runMCPOAuth(args[1:], stdout, stderr, deps)
|
|
case "list":
|
|
return runMCPLegacyList(args[1:], stdout, stderr, deps)
|
|
default:
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown mcp subcommand %q", args[0]))
|
|
}
|
|
}
|
|
|
|
func runMCPLegacyList(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseMCPLegacyListArgs(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeMCPLegacyListHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if options.tools {
|
|
forwarded := []string{"list"}
|
|
if options.json {
|
|
forwarded = append(forwarded, "--json")
|
|
}
|
|
return runMCPTools(context.Background(), forwarded, stdout, stderr, deps)
|
|
}
|
|
|
|
cwd, err := deps.getwd()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
|
|
}
|
|
// Enumeration for the extensions listing, never spawns a server, so it is left
|
|
// ungated (excludeProject=false) like the other doctor/status report sites.
|
|
cfg, err := deps.resolveMCPConfig(cwd, false)
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if cfg.Servers == nil {
|
|
cfg.Servers = map[string]config.MCPServerConfig{}
|
|
}
|
|
if options.json {
|
|
// Env and header VALUES are credentials by construction (API keys,
|
|
// bearer tokens) and must never reach stdout: pattern-based redaction
|
|
// only catches recognizable token shapes, so the values are replaced
|
|
// structurally — keys stay visible for triage.
|
|
payload := struct {
|
|
Servers map[string]config.MCPServerConfig `json:"servers"`
|
|
}{Servers: redactMCPServerConfigs(cfg.Servers)}
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, redaction.RedactString(formatMCPServerList(cfg.Servers), redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
// redactMCPServerConfigs deep-copies the server map with every env and header
|
|
// value replaced by a redaction marker.
|
|
func redactMCPServerConfigs(servers map[string]config.MCPServerConfig) map[string]config.MCPServerConfig {
|
|
redacted := make(map[string]config.MCPServerConfig, len(servers))
|
|
for name, server := range servers {
|
|
clone := server
|
|
if len(server.Env) > 0 {
|
|
clone.Env = make(map[string]string, len(server.Env))
|
|
for key := range server.Env {
|
|
clone.Env[key] = "[REDACTED]"
|
|
}
|
|
}
|
|
if len(server.Headers) > 0 {
|
|
clone.Headers = make(map[string]string, len(server.Headers))
|
|
for key := range server.Headers {
|
|
clone.Headers[key] = "[REDACTED]"
|
|
}
|
|
}
|
|
clone.URL = redactMCPURL(clone.URL, "[REDACTED]")
|
|
redacted[name] = clone
|
|
}
|
|
return redacted
|
|
}
|
|
|
|
func runMCPTools(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if len(args) == 0 {
|
|
return writeExecUsageError(stderr, "mcp tools subcommand required. Use `zero mcp tools list`.")
|
|
}
|
|
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
|
if err := writeMCPToolsHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
switch args[0] {
|
|
case "list":
|
|
options, help, err := parseMCPCommandOptions(args[1:])
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeMCPToolsHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
cwd, err := deps.getwd()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
|
|
}
|
|
registry := tools.NewRegistry()
|
|
// `mcp tools list` connects to (spawns) each server to enumerate its live tools,
|
|
// so it is a spawn site and gates the project layer behind the trust check. No
|
|
// --worktree reassignment on this command path, so trustRoot == cwd.
|
|
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(ctx, cwd, registry, deps, mcp.AutonomyLow, cwd)
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
defer closeMCPRuntime(stderr, mcpRuntime)
|
|
// Surface the trust skip on stderr so an empty (or short) list in an untrusted
|
|
// workspace is explained rather than read as "nothing configured". The notice
|
|
// goes to stderr, leaving the list (text or --json) on stdout intact.
|
|
emitTrustNotice(stderr, mcpSkip)
|
|
// Surface the trust skip on stderr so an empty (or short) list in an untrusted
|
|
// workspace is explained rather than read as "nothing configured". The notice
|
|
// goes to stderr, leaving the list (text or --json) on stdout intact.
|
|
items := mcpToolList(registry)
|
|
if options.json {
|
|
payload := struct {
|
|
Tools []mcpToolListItem `json:"tools"`
|
|
}{Tools: items}
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, redaction.RedactString(formatMCPToolList(items), redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
default:
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown mcp tools subcommand %q", args[0]))
|
|
}
|
|
}
|
|
|
|
func runMCPPermissions(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
if len(args) == 0 {
|
|
return writeExecUsageError(stderr, "mcp permissions subcommand required. Use `zero mcp permissions list`.")
|
|
}
|
|
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
|
if err := writeMCPPermissionsHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
switch args[0] {
|
|
case "list":
|
|
options, help, err := parseMCPCommandOptions(args[1:])
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeMCPPermissionsHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
store, err := deps.newMCPStore()
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
permissions, err := store.List()
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if options.json {
|
|
payload := struct {
|
|
Permissions []mcp.PermissionGrant `json:"permissions"`
|
|
}{Permissions: permissions}
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, mcp.FormatPermissionList(permissions)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
case "revoke":
|
|
return runMCPPermissionsRevoke(args[1:], stdout, stderr, deps)
|
|
case "clear":
|
|
return runMCPPermissionsClear(args[1:], stdout, stderr, deps)
|
|
default:
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown mcp permissions subcommand %q", args[0]))
|
|
}
|
|
}
|
|
|
|
func runMCPPermissionsRevoke(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 := writeMCPPermissionsHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if len(positional) == 0 || len(positional) > 2 {
|
|
return writeExecUsageError(stderr, "usage: zero mcp permissions revoke <server> [<tool>] [--json]")
|
|
}
|
|
|
|
store, err := deps.newMCPStore()
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
serverName := positional[0]
|
|
if len(positional) == 2 {
|
|
revoked, err := store.RevokeTool(serverName, positional[1])
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if options.json {
|
|
payload := struct {
|
|
Revoked int `json:"revoked"`
|
|
Scope string `json:"scope"`
|
|
ServerName string `json:"serverName"`
|
|
ToolName string `json:"toolName"`
|
|
}{Revoked: revoked, Scope: string(mcp.ScopeTool), ServerName: serverName, ToolName: positional[1]}
|
|
if err := writePrettyJSON(stdout, payload); err != nil {
|
|
return exitCrash
|
|
}
|
|
} else if _, err := fmt.Fprintf(stdout, "Revoked %d MCP tool permission grant(s) for %s/%s.\n", revoked, serverName, positional[1]); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
revoked, err := store.RevokeServer(serverName)
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if options.json {
|
|
payload := struct {
|
|
Revoked int `json:"revoked"`
|
|
Scope string `json:"scope"`
|
|
ServerName string `json:"serverName"`
|
|
}{Revoked: revoked, Scope: string(mcp.ScopeServer), ServerName: serverName}
|
|
if err := writePrettyJSON(stdout, payload); err != nil {
|
|
return exitCrash
|
|
}
|
|
} else if _, err := fmt.Fprintf(stdout, "Revoked %d MCP server permission grant(s) for %s.\n", revoked, serverName); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runMCPPermissionsClear(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseMCPCommandOptions(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeMCPPermissionsHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if !options.confirm {
|
|
return writeExecUsageError(stderr, "Pass --confirm to clear all MCP permission grants.")
|
|
}
|
|
store, err := deps.newMCPStore()
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
cleared, err := store.Clear()
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if options.json {
|
|
payload := struct {
|
|
Cleared int `json:"cleared"`
|
|
}{Cleared: cleared}
|
|
if err := writePrettyJSON(stdout, payload); err != nil {
|
|
return exitCrash
|
|
}
|
|
} else if _, err := fmt.Fprintf(stdout, "Cleared %d MCP permission grant(s).\n", cleared); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func parsePluginListArgs(args []string) (pluginListOptions, bool, error) {
|
|
options := pluginListOptions{}
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "-h", "--help", "help":
|
|
return options, true, nil
|
|
case "--json":
|
|
options.json = true
|
|
default:
|
|
return options, false, execUsageError{fmt.Sprintf("unknown plugins list flag %q", arg)}
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func parseHookListArgs(args []string) (hookListOptions, bool, error) {
|
|
options := hookListOptions{}
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "-h", "--help", "help":
|
|
return options, true, nil
|
|
case "--json":
|
|
options.json = true
|
|
default:
|
|
return options, false, execUsageError{fmt.Sprintf("unknown hooks list flag %q", arg)}
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func parseMCPCommandOptions(args []string) (mcpCommandOptions, bool, error) {
|
|
options := mcpCommandOptions{}
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "-h", "--help", "help":
|
|
return options, true, nil
|
|
case "--json":
|
|
options.json = true
|
|
case "--confirm":
|
|
options.confirm = true
|
|
default:
|
|
return options, false, execUsageError{fmt.Sprintf("unknown mcp flag %q", arg)}
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func parseMCPLegacyListArgs(args []string) (mcpLegacyListOptions, bool, error) {
|
|
options := mcpLegacyListOptions{}
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "-h", "--help", "help":
|
|
return options, true, nil
|
|
case "--json":
|
|
options.json = true
|
|
case "--tools":
|
|
options.tools = true
|
|
default:
|
|
return options, false, execUsageError{fmt.Sprintf("unknown mcp list flag %q", arg)}
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func parseMCPPositionalCommand(args []string) (mcpCommandOptions, []string, bool, error) {
|
|
options := mcpCommandOptions{}
|
|
positional := []string{}
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "-h", "--help", "help":
|
|
return options, positional, true, nil
|
|
case "--json":
|
|
options.json = true
|
|
case "--confirm":
|
|
options.confirm = true
|
|
default:
|
|
if strings.HasPrefix(arg, "-") {
|
|
return options, positional, false, execUsageError{fmt.Sprintf("unknown mcp permissions flag %q", arg)}
|
|
}
|
|
positional = append(positional, arg)
|
|
}
|
|
}
|
|
return options, positional, false, nil
|
|
}
|
|
|
|
func writePluginsHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero plugins <command>
|
|
|
|
Commands:
|
|
list List local Zero plugins
|
|
add <git-url|path> Install a plugin (manifest-validated, pinned in plugins.lock)
|
|
remove <id> Remove an installed plugin and its lockfile entry
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writePluginsListHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero plugins list [flags]
|
|
|
|
Flags:
|
|
--json Print local plugin data as JSON
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeHooksHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero hooks <command>
|
|
|
|
Commands:
|
|
list List configured Zero hooks
|
|
add Add or update a hook
|
|
remove Remove a hook by id
|
|
enable Enable a hook by id
|
|
disable Disable a hook by id
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeHooksListHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero hooks list [flags]
|
|
|
|
Flags:
|
|
--json Print hook config as JSON
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeMCPHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero mcp <command>
|
|
|
|
Commands:
|
|
add <server> Add or update an MCP server in user config
|
|
remove <server> Remove an MCP server from user config
|
|
enable <server> Enable a user-configured MCP server
|
|
disable <server> Disable a user-configured MCP server
|
|
check <server> Connect to one MCP server and list its tools
|
|
list List configured MCP servers, or tools with --tools
|
|
oauth Manage OAuth credentials for remote MCP servers
|
|
permissions Manage persistent MCP tool permissions
|
|
tools Inspect configured MCP tools
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeMCPLegacyListHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero mcp list [flags]
|
|
|
|
Flags:
|
|
--json Print MCP servers or tools as JSON
|
|
--tools Connect to enabled servers and list MCP tools
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeMCPToolsHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero mcp tools <command>
|
|
|
|
Commands:
|
|
list List configured MCP tools
|
|
|
|
Flags:
|
|
--json Print MCP tools as JSON
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeMCPPermissionsHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero mcp permissions <command>
|
|
|
|
Commands:
|
|
list List all persistent MCP permissions
|
|
revoke <server> [<tool>] Revoke an MCP server or tool permission
|
|
clear --confirm Clear all persistent MCP permissions
|
|
|
|
Flags:
|
|
--json Print command result as JSON
|
|
--confirm Confirm destructive clear operation
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|