Files
beardthelionandKevin Codex a880ce80a6 feat: gate project-scoped hooks, plugins, and MCP servers behind workspace trust (#529)
* 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]>
2026-07-09 10:51:09 +08:00

531 lines
17 KiB
Go

package plugins
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseManifestNormalizesExtensionsAndPaths(t *testing.T) {
root := filepath.Join(t.TempDir(), "plugins")
pluginDir := filepath.Join(root, "zero-demo")
manifestPath := filepath.Join(pluginDir, "plugin.json")
plugin, err := ParseManifest(map[string]any{
"schemaVersion": float64(1),
"id": "zero.demo",
"name": "Zero Demo",
"version": "0.1.0",
"description": "Demo plugin",
"tools": []any{map[string]any{
"name": "lookup",
"description": "Lookup docs",
"command": "node",
"args": []any{"tools/lookup.mjs"},
"inputSchema": map[string]any{"type": "object"},
"permission": "prompt",
}},
"prompts": []any{map[string]any{"name": "review", "path": "prompts/review.md"}},
"skills": []any{map[string]any{"name": "ts-review", "path": "skills/ts-review/SKILL.md"}},
"hooks": []any{map[string]any{
"name": "pre-tool",
"event": "beforeTool",
"command": "node",
"args": []any{"hooks/pre-tool.mjs"},
}},
}, ParseManifestOptions{
Source: SourceProject,
Root: root,
PluginDir: pluginDir,
ManifestPath: manifestPath,
})
if err != nil {
t.Fatalf("ParseManifest returned error: %v", err)
}
if plugin.ID != "zero.demo" || plugin.Name != "Zero Demo" || !plugin.Enabled {
t.Fatalf("unexpected plugin metadata: %#v", plugin)
}
if plugin.Tools[0].Permission != PermissionPrompt || plugin.Tools[0].Args[0] != "tools/lookup.mjs" {
t.Fatalf("unexpected tool normalization: %#v", plugin.Tools[0])
}
if plugin.Prompts[0].Path != filepath.Join(pluginDir, "prompts", "review.md") {
t.Fatalf("prompt path = %q", plugin.Prompts[0].Path)
}
if plugin.Skills[0].Path != filepath.Join(pluginDir, "skills", "ts-review", "SKILL.md") {
t.Fatalf("skill path = %q", plugin.Skills[0].Path)
}
if plugin.Hooks[0].Event != HookBeforeTool || plugin.Hooks[0].Args[0] != "hooks/pre-tool.mjs" {
t.Fatalf("unexpected hook normalization: %#v", plugin.Hooks[0])
}
}
func TestParseManifestReadsOptionalMetadata(t *testing.T) {
root := filepath.Join(t.TempDir(), "plugins")
pluginDir := filepath.Join(root, "zero-demo")
manifestPath := filepath.Join(pluginDir, "plugin.json")
plugin, err := ParseManifest(map[string]any{
"schemaVersion": float64(1),
"id": "zero.demo",
"name": "Zero Demo",
"version": "0.1.0",
"author": map[string]any{
"name": "OpenAI",
"email": "[email protected]",
"url": "https://openai.com/",
},
"license": "Proprietary",
"keywords": []any{"automation", "macos"},
"homepage": "https://openai.com/",
"interface": map[string]any{
"displayName": "Computer Use",
"category": "Productivity",
"brandColor": "#0F172A",
"defaultPrompt": []any{"Play a playlist", "Build my project"},
},
}, ParseManifestOptions{
Source: SourceProject,
Root: root,
PluginDir: pluginDir,
ManifestPath: manifestPath,
})
if err != nil {
t.Fatalf("ParseManifest returned error: %v", err)
}
if plugin.Author.Name != "OpenAI" || plugin.Author.Email != "[email protected]" || plugin.Author.URL != "https://openai.com/" {
t.Fatalf("author = %#v", plugin.Author)
}
if plugin.License != "Proprietary" {
t.Fatalf("license = %q", plugin.License)
}
if len(plugin.Keywords) != 2 || plugin.Keywords[0] != "automation" {
t.Fatalf("keywords = %#v", plugin.Keywords)
}
if plugin.Homepage != "https://openai.com/" {
t.Fatalf("homepage = %q", plugin.Homepage)
}
if plugin.Interface.DisplayName != "Computer Use" || plugin.Interface.Category != "Productivity" || plugin.Interface.BrandColor != "#0F172A" {
t.Fatalf("interface = %#v", plugin.Interface)
}
if len(plugin.Interface.DefaultPrompts) != 2 || plugin.Interface.DefaultPrompts[0] != "Play a playlist" {
t.Fatalf("default prompts = %#v", plugin.Interface.DefaultPrompts)
}
}
func TestParseManifestWithoutOptionalMetadataLeavesZeroValues(t *testing.T) {
root := filepath.Join(t.TempDir(), "plugins")
pluginDir := filepath.Join(root, "zero-bare")
manifestPath := filepath.Join(pluginDir, "plugin.json")
plugin, err := ParseManifest(map[string]any{
"schemaVersion": float64(1),
"id": "zero.bare",
"name": "Bare",
"version": "0.1.0",
}, ParseManifestOptions{
Source: SourceUser,
Root: root,
PluginDir: pluginDir,
ManifestPath: manifestPath,
})
if err != nil {
t.Fatalf("ParseManifest returned error: %v", err)
}
if plugin.Author != nil {
t.Fatalf("author should be nil when absent, got %#v", plugin.Author)
}
if plugin.License != "" || plugin.Homepage != "" {
t.Fatalf("license/homepage should be empty, got %q / %q", plugin.License, plugin.Homepage)
}
if len(plugin.Keywords) != 0 {
t.Fatalf("keywords should be empty, got %#v", plugin.Keywords)
}
if plugin.Interface != nil {
t.Fatalf("interface should be nil when absent, got %#v", plugin.Interface)
}
}
func TestFormatListSurfacesOptionalMetadata(t *testing.T) {
output := FormatList([]LoadedPlugin{{
SchemaVersion: 1,
ID: "zero.demo",
Name: "Zero Demo",
Version: "0.1.0",
Enabled: true,
Source: SourceUser,
Author: &PluginAuthor{Name: "OpenAI"},
License: "MIT",
Keywords: []string{"automation", "macos"},
}}, nil)
for _, want := range []string{"author: OpenAI", "license: MIT", "keywords: automation, macos"} {
if !strings.Contains(output, want) {
t.Fatalf("FormatList output missing %q:\n%s", want, output)
}
}
}
func TestParseManifestClampsAutoApprovalByDefault(t *testing.T) {
root := t.TempDir()
pluginDir := filepath.Join(root, "zero-demo")
manifest := map[string]any{
"schemaVersion": float64(1),
"id": "zero.demo",
"name": "Zero Demo",
"version": "0.1.0",
"tools": []any{map[string]any{
"name": "lookup",
"command": "node",
"permission": "allow",
}},
}
options := ParseManifestOptions{
Source: SourceProject,
Root: root,
PluginDir: pluginDir,
ManifestPath: filepath.Join(pluginDir, "plugin.json"),
}
plugin, err := ParseManifest(manifest, options)
if err != nil {
t.Fatalf("ParseManifest returned error: %v", err)
}
if got := plugin.Tools[0].Permission; got != PermissionPrompt {
t.Fatalf("permission = %s, want prompt", got)
}
options.AllowManifestToolAutoApproval = true
plugin, err = ParseManifest(manifest, options)
if err != nil {
t.Fatalf("ParseManifest returned error: %v", err)
}
if got := plugin.Tools[0].Permission; got != PermissionAllow {
t.Fatalf("permission = %s, want allow", got)
}
}
func TestParseManifestRejectsUnsafePluginLocalPaths(t *testing.T) {
root := t.TempDir()
pluginDir := filepath.Join(root, "bad")
options := ParseManifestOptions{
Source: SourceProject,
Root: root,
PluginDir: pluginDir,
ManifestPath: filepath.Join(pluginDir, "plugin.json"),
}
for _, path := range []string{"../outside.md", "/tmp/escape.md", `C:\Windows\escape.md`} {
t.Run(path, func(t *testing.T) {
_, err := ParseManifest(map[string]any{
"schemaVersion": float64(1),
"id": "zero.bad",
"name": "Bad",
"version": "0.1.0",
"prompts": []any{map[string]any{"name": "escape", "path": path}},
}, options)
if err == nil || !strings.Contains(err.Error(), "must stay inside the plugin directory") {
t.Fatalf("expected unsafe path error, got %v", err)
}
})
}
}
func TestParseManifestRejectsSymlinkEscapes(t *testing.T) {
root := t.TempDir()
pluginDir := filepath.Join(root, "bad")
outside := filepath.Join(t.TempDir(), "outside")
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(outside, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "escape.md"), []byte("outside"), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(pluginDir, "link")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("symlink unavailable on this platform: %v", err)
}
_, err := ParseManifest(map[string]any{
"schemaVersion": float64(1),
"id": "zero.bad",
"name": "Bad",
"version": "0.1.0",
"prompts": []any{map[string]any{"name": "escape", "path": filepath.Join("link", "escape.md")}},
}, ParseManifestOptions{
Source: SourceProject,
Root: root,
PluginDir: pluginDir,
ManifestPath: filepath.Join(pluginDir, "plugin.json"),
})
if err == nil || !strings.Contains(err.Error(), "must stay inside the plugin directory") {
t.Fatalf("expected symlink escape error, got %v", err)
}
}
func TestParseManifestRejectsSymlinkEscapesWithMissingLeaf(t *testing.T) {
root := t.TempDir()
pluginDir := filepath.Join(root, "bad")
outside := filepath.Join(t.TempDir(), "outside")
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(outside, 0o700); err != nil {
t.Fatal(err)
}
link := filepath.Join(pluginDir, "link")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("symlink unavailable on this platform: %v", err)
}
_, err := ParseManifest(map[string]any{
"schemaVersion": float64(1),
"id": "zero.bad",
"name": "Bad",
"version": "0.1.0",
"prompts": []any{map[string]any{"name": "escape", "path": filepath.Join("link", "missing.md")}},
}, ParseManifestOptions{
Source: SourceProject,
Root: root,
PluginDir: pluginDir,
ManifestPath: filepath.Join(pluginDir, "plugin.json"),
})
if err == nil || !strings.Contains(err.Error(), "must stay inside the plugin directory") {
t.Fatalf("expected missing-leaf symlink escape error, got %v", err)
}
}
func TestFormatListIncludesDiagnosticLocations(t *testing.T) {
output := FormatList(nil, []Diagnostic{{
Kind: DiagnosticSchema,
Message: "bad prompt path",
ManifestPath: "/plugins/bad/plugin.json",
FieldPath: "prompts.escape.path",
}})
for _, want := range []string{"[schema] bad prompt path", "manifestPath=/plugins/bad/plugin.json", "fieldPath=prompts.escape.path"} {
if !strings.Contains(output, want) {
t.Fatalf("diagnostic output missing %q: %s", want, output)
}
}
}
func TestToDiagnosticClassifiesManifestAndIOErrors(t *testing.T) {
root := Root{Source: SourceProject}
schemaDiagnostic := toDiagnostic(ManifestError{FieldPath: "prompts.0.path", Message: "bad path"}, root, "/plugins", "/plugins/bad", "/plugins/bad/plugin.json")
if schemaDiagnostic.Kind != DiagnosticSchema || schemaDiagnostic.FieldPath != "prompts.0.path" {
t.Fatalf("manifest error diagnostic = %#v", schemaDiagnostic)
}
ioDiagnostic := toDiagnostic(errors.New("read failed"), root, "/plugins", "/plugins/bad", "/plugins/bad/plugin.json")
if ioDiagnostic.Kind != DiagnosticIO || ioDiagnostic.Message != "read failed" {
t.Fatalf("io error diagnostic = %#v", ioDiagnostic)
}
}
func TestLoadPluginsDiscoversDiagnosticsAndProjectPrecedence(t *testing.T) {
dir := t.TempDir()
userRoot := filepath.Join(dir, "user-plugins")
projectRoot := filepath.Join(dir, "project-plugins")
writePluginManifest(t, filepath.Join(userRoot, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "User Demo",
"version": "0.1.0",
})
writePluginManifest(t, filepath.Join(projectRoot, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Project Demo",
"version": "0.2.0",
"enabled": false,
})
writePluginManifest(t, filepath.Join(projectRoot, "docs"), map[string]any{
"schemaVersion": 1,
"id": "zero.docs",
"name": "Docs",
"version": "1.0.0",
})
if err := os.MkdirAll(filepath.Join(projectRoot, "bad"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projectRoot, "bad", "plugin.json"), []byte("{ invalid json }"), 0o600); err != nil {
t.Fatal(err)
}
result, err := Load(LoadOptions{
Roots: []Root{
{Source: SourceUser, Path: userRoot},
{Source: SourceProject, Path: projectRoot},
},
})
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if got := []string{result.Plugins[0].ID + ":" + result.Plugins[0].Name, result.Plugins[1].ID + ":" + result.Plugins[1].Name}; got[0] != "zero.demo:Project Demo" || got[1] != "zero.docs:Docs" {
t.Fatalf("unexpected plugins: %#v", result.Plugins)
}
if result.Plugins[0].Enabled {
t.Fatalf("project plugin should remain disabled")
}
if !hasPluginDiagnostic(result.Diagnostics, DiagnosticDuplicate, "zero.demo") {
t.Fatalf("missing duplicate diagnostic: %#v", result.Diagnostics)
}
if !hasPluginDiagnostic(result.Diagnostics, DiagnosticJSON, "") {
t.Fatalf("missing json diagnostic: %#v", result.Diagnostics)
}
}
func TestLoadDiscoversProjectPluginWhenNotExcluded(t *testing.T) {
dir := t.TempDir()
writePluginManifest(t, filepath.Join(dir, ".zero", "plugins", "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.project",
"name": "Project Plugin",
"version": "0.1.0",
})
result, err := Load(LoadOptions{
Cwd: dir,
Env: map[string]string{"XDG_CONFIG_HOME": filepath.Join(dir, "xdg")},
})
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if !hasPlugin(result.Plugins, "zero.project") {
t.Fatalf("expected project plugin to be discovered, got %#v", result.Plugins)
}
}
func TestLoadExcludesProjectPlugin(t *testing.T) {
dir := t.TempDir()
writePluginManifest(t, filepath.Join(dir, ".zero", "plugins", "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.project",
"name": "Project Plugin",
"version": "0.1.0",
})
result, err := Load(LoadOptions{
Cwd: dir,
Env: map[string]string{"XDG_CONFIG_HOME": filepath.Join(dir, "xdg")},
ExcludeProject: true,
})
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if hasPlugin(result.Plugins, "zero.project") {
t.Fatalf("project plugin should be excluded, got %#v", result.Plugins)
}
}
func TestLoadKeepsUserPluginWhenProjectExcluded(t *testing.T) {
dir := t.TempDir()
// User plugin lives under the resolved XDG config home; project plugin under ./.zero.
writePluginManifest(t, filepath.Join(dir, "xdg", "zero", "plugins", "user"), map[string]any{
"schemaVersion": 1,
"id": "zero.user",
"name": "User Plugin",
"version": "0.1.0",
})
writePluginManifest(t, filepath.Join(dir, ".zero", "plugins", "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.project",
"name": "Project Plugin",
"version": "0.1.0",
})
result, err := Load(LoadOptions{
Cwd: dir,
Env: map[string]string{"XDG_CONFIG_HOME": filepath.Join(dir, "xdg")},
ExcludeProject: true,
})
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if !hasPlugin(result.Plugins, "zero.user") {
t.Fatalf("user plugin should still be discovered, got %#v", result.Plugins)
}
if hasPlugin(result.Plugins, "zero.project") {
t.Fatalf("project plugin should be excluded, got %#v", result.Plugins)
}
}
func TestLoadExcludeProjectFiltersExplicitProjectRoots(t *testing.T) {
dir := t.TempDir()
projectRoot := filepath.Join(dir, "project-plugins")
writePluginManifest(t, filepath.Join(projectRoot, "demo"), map[string]any{
"schemaVersion": 1,
"id": "zero.project",
"name": "Project Plugin",
"version": "0.1.0",
})
result, err := Load(LoadOptions{
Roots: []Root{{Source: SourceProject, Path: projectRoot}},
ExcludeProject: true,
})
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if len(result.Plugins) != 0 {
t.Fatalf("explicit project root should be filtered when ExcludeProject is set, got %#v", result.Plugins)
}
}
func TestResolveRootsUsesConfigHomeAndProjectRoot(t *testing.T) {
dir := t.TempDir()
roots, err := ResolveRoots(ResolveRootOptions{
Cwd: dir,
Env: map[string]string{"XDG_CONFIG_HOME": filepath.Join(dir, "xdg")},
})
if err != nil {
t.Fatalf("ResolveRoots returned error: %v", err)
}
if roots[0].Path != filepath.Join(dir, "xdg", "zero", "plugins") {
t.Fatalf("user root = %q", roots[0].Path)
}
if roots[1].Path != filepath.Join(dir, ".zero", "plugins") {
t.Fatalf("project root = %q", roots[1].Path)
}
}
func writePluginManifest(t *testing.T, pluginDir string, manifest map[string]any) {
t.Helper()
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), data, 0o600); err != nil {
t.Fatal(err)
}
}
func hasPlugin(plugins []LoadedPlugin, pluginID string) bool {
for _, plugin := range plugins {
if plugin.ID == pluginID {
return true
}
}
return false
}
func hasPluginDiagnostic(diagnostics []Diagnostic, kind DiagnosticKind, pluginID string) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Kind != kind {
continue
}
if pluginID == "" || diagnostic.PluginID == pluginID {
return true
}
}
return false
}