mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -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]>
467 lines
17 KiB
Go
467 lines
17 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/hooks"
|
|
"github.com/Gitlawb/zero/internal/plugins"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/workspacetrust"
|
|
)
|
|
|
|
// setTrustConfigRoot redirects both the workspace-trust store and the user-level
|
|
// hooks/plugins config to a fresh temp dir with a GOOS-aware env switch, mirroring
|
|
// setUserConfigRoot in internal/config/paths_test.go, and returns that dir so tests
|
|
// can build the exact paths the code resolves to.
|
|
//
|
|
// XDG_CONFIG_HOME is the lever on macOS as well as Linux: config.UserConfigDir is
|
|
// XDG-first there (it only falls back to $HOME/.config when XDG_CONFIG_HOME is
|
|
// unset), and the hooks loader resolves its user layer from XDG_CONFIG_HOME too. An
|
|
// earlier version set HOME on darwin and returned the raw root; that left the store
|
|
// at <root>/.config/zero while the tests wrote to <root>/zero, so the store-error
|
|
// and user-hook cases silently missed. Only Windows needs its own variable (APPDATA,
|
|
// what os.UserConfigDir consults). XDG_DATA_HOME is redirected so the hook audit
|
|
// store never touches the user's real data dir.
|
|
func setTrustConfigRoot(t *testing.T) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
t.Setenv("APPDATA", root)
|
|
default:
|
|
t.Setenv("XDG_CONFIG_HOME", root)
|
|
}
|
|
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
|
return root
|
|
}
|
|
|
|
// writeMarkerHookScript writes an executable script that creates markerPath when
|
|
// run, and returns its absolute path. The hook dispatcher runs commands directly
|
|
// (no shell), so a real executable is needed to observe execution.
|
|
func writeMarkerHookScript(t *testing.T, markerPath string) string {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("marker-hook script is POSIX-shell based")
|
|
}
|
|
dir := t.TempDir()
|
|
script := filepath.Join(dir, "mark.sh")
|
|
body := "#!/bin/sh\n: > " + shellQuote(markerPath) + "\n"
|
|
if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
|
|
t.Fatalf("write marker hook script: %v", err)
|
|
}
|
|
return script
|
|
}
|
|
|
|
func shellQuote(path string) string {
|
|
return "'" + path + "'"
|
|
}
|
|
|
|
// writeProjectHooks writes a project ./.zero/hooks.json under repo with the given
|
|
// hooks and top-level enabled flag.
|
|
func writeProjectHooks(t *testing.T, repo string, enabled bool, defs ...hooks.Definition) {
|
|
t.Helper()
|
|
path := filepath.Join(repo, ".zero", "hooks.json")
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
t.Fatalf("mkdir project .zero: %v", err)
|
|
}
|
|
if err := hooks.WriteConfig(path, hooks.Config{Enabled: enabled, Hooks: defs}); err != nil {
|
|
t.Fatalf("write project hooks.json: %v", err)
|
|
}
|
|
}
|
|
|
|
// writeUserHooks writes a user-level hooks.json into the redirected config root.
|
|
func writeUserHooks(t *testing.T, configRoot string, enabled bool, defs ...hooks.Definition) {
|
|
t.Helper()
|
|
path := filepath.Join(configRoot, "zero", "hooks.json")
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
t.Fatalf("mkdir user config dir: %v", err)
|
|
}
|
|
if err := hooks.WriteConfig(path, hooks.Config{Enabled: enabled, Hooks: defs}); err != nil {
|
|
t.Fatalf("write user hooks.json: %v", err)
|
|
}
|
|
}
|
|
|
|
func dispatchBeforeTool(dispatcher *hooks.Dispatcher) hooks.DispatchOutcome {
|
|
return dispatcher.Dispatch(context.Background(), hooks.DispatchInput{
|
|
Event: hooks.EventBeforeTool,
|
|
ToolName: "bash",
|
|
})
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// TestHookGateUntrustedExcludesProjectLayer proves R1: an untrusted trustRoot with
|
|
// an enabled project beforeTool hook runs nothing and writes no marker.
|
|
func TestHookGateUntrustedExcludesProjectLayer(t *testing.T) {
|
|
setTrustConfigRoot(t)
|
|
repo := t.TempDir()
|
|
marker := filepath.Join(t.TempDir(), "ran")
|
|
script := writeMarkerHookScript(t, marker)
|
|
writeProjectHooks(t, repo, true, hooks.Definition{
|
|
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
|
|
})
|
|
|
|
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, repo)
|
|
outcome := dispatchBeforeTool(dispatcher)
|
|
|
|
if outcome.Ran != 0 {
|
|
t.Fatalf("untrusted workspace should run 0 project hooks, Ran=%d", outcome.Ran)
|
|
}
|
|
if fileExists(marker) {
|
|
t.Fatalf("untrusted workspace must not execute the project hook (marker exists)")
|
|
}
|
|
if !skip.excludedProjectConfig {
|
|
t.Fatalf("skip report should flag the excluded project hooks file")
|
|
}
|
|
if skip.trustCheckErrored {
|
|
t.Fatalf("a clean untrusted verdict is not a store-read error")
|
|
}
|
|
}
|
|
|
|
// TestHookGateTrustedRunsProjectHook proves R3: after Trust(repo) the project hook
|
|
// runs and the marker appears.
|
|
func TestHookGateTrustedRunsProjectHook(t *testing.T) {
|
|
setTrustConfigRoot(t)
|
|
repo := t.TempDir()
|
|
marker := filepath.Join(t.TempDir(), "ran")
|
|
script := writeMarkerHookScript(t, marker)
|
|
writeProjectHooks(t, repo, true, hooks.Definition{
|
|
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
|
|
})
|
|
|
|
if err := workspacetrust.Trust(repo); err != nil {
|
|
t.Fatalf("Trust(repo): %v", err)
|
|
}
|
|
|
|
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, repo)
|
|
outcome := dispatchBeforeTool(dispatcher)
|
|
|
|
if outcome.Ran != 1 {
|
|
t.Fatalf("trusted workspace should run the project hook, Ran=%d", outcome.Ran)
|
|
}
|
|
if !fileExists(marker) {
|
|
t.Fatalf("trusted workspace must execute the project hook (marker missing)")
|
|
}
|
|
if skip.excludedProjectConfig {
|
|
t.Fatalf("a trusted workspace must not report the project layer as excluded")
|
|
}
|
|
}
|
|
|
|
// TestHookGateEmptyTrustRootFailsClosed proves the fail-closed-by-construction
|
|
// guard: a caller that forgot to resolve trustRoot (empty) still excludes the
|
|
// project layer, so no marker appears.
|
|
func TestHookGateEmptyTrustRootFailsClosed(t *testing.T) {
|
|
setTrustConfigRoot(t)
|
|
repo := t.TempDir()
|
|
marker := filepath.Join(t.TempDir(), "ran")
|
|
script := writeMarkerHookScript(t, marker)
|
|
writeProjectHooks(t, repo, true, hooks.Definition{
|
|
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
|
|
})
|
|
// Even trusting the repo must not help when the caller passes an empty root.
|
|
if err := workspacetrust.Trust(repo); err != nil {
|
|
t.Fatalf("Trust(repo): %v", err)
|
|
}
|
|
|
|
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, "")
|
|
outcome := dispatchBeforeTool(dispatcher)
|
|
|
|
if outcome.Ran != 0 {
|
|
t.Fatalf("empty trustRoot must fail closed, Ran=%d", outcome.Ran)
|
|
}
|
|
if fileExists(marker) {
|
|
t.Fatalf("empty trustRoot must not execute the project hook (marker exists)")
|
|
}
|
|
if !skip.excludedProjectConfig {
|
|
t.Fatalf("empty trustRoot should report the project layer excluded")
|
|
}
|
|
}
|
|
|
|
// TestHookGateFailClosedOnStoreError proves R5: a real IsTrusted error (trust.json
|
|
// created as a directory, per U1) is treated as untrusted, the project hook does
|
|
// not run, and the skip report marks the store-read error.
|
|
func TestHookGateFailClosedOnStoreError(t *testing.T) {
|
|
configRoot := setTrustConfigRoot(t)
|
|
// Create the trust store path as a DIRECTORY so os.ReadFile fails with a
|
|
// non-ErrNotExist error, forcing IsTrusted to return (false, non-nil).
|
|
trustPath := filepath.Join(configRoot, "zero", "trust.json")
|
|
if err := os.MkdirAll(trustPath, 0o700); err != nil {
|
|
t.Fatalf("create trust.json as a directory: %v", err)
|
|
}
|
|
|
|
repo := t.TempDir()
|
|
marker := filepath.Join(t.TempDir(), "ran")
|
|
script := writeMarkerHookScript(t, marker)
|
|
writeProjectHooks(t, repo, true, hooks.Definition{
|
|
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
|
|
})
|
|
|
|
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, repo)
|
|
outcome := dispatchBeforeTool(dispatcher)
|
|
|
|
if outcome.Ran != 0 {
|
|
t.Fatalf("a store-read error must fail closed, Ran=%d", outcome.Ran)
|
|
}
|
|
if fileExists(marker) {
|
|
t.Fatalf("a store-read error must not execute the project hook (marker exists)")
|
|
}
|
|
if !skip.trustCheckErrored {
|
|
t.Fatalf("skip report must mark the store-read error")
|
|
}
|
|
if !skip.excludedProjectConfig {
|
|
t.Fatalf("the project layer must be reported excluded on the error path")
|
|
}
|
|
}
|
|
|
|
// TestHookGateUserHookStillRunsWhenProjectExcluded proves R4/R10: with the project
|
|
// layer excluded, a user-level hook still fires, and a project hooks.json that sets
|
|
// global enabled:false or defines a same-ID hook cannot disable or override it.
|
|
func TestHookGateUserHookStillRunsWhenProjectExcluded(t *testing.T) {
|
|
configRoot := setTrustConfigRoot(t)
|
|
repo := t.TempDir()
|
|
marker := filepath.Join(t.TempDir(), "ran")
|
|
script := writeMarkerHookScript(t, marker)
|
|
|
|
// User hook that writes the marker.
|
|
writeUserHooks(t, configRoot, true, hooks.Definition{
|
|
ID: "shared.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
|
|
})
|
|
// Project config that would, if loaded, disable the whole surface (enabled:false)
|
|
// AND override the user hook by ID with a disabled no-op. The gate drops the
|
|
// whole project layer, so neither takes effect.
|
|
writeProjectHooks(t, repo, false, hooks.Definition{
|
|
ID: "shared.mark", Event: hooks.EventBeforeTool, Command: "true", Enabled: false,
|
|
})
|
|
|
|
// Untrusted: the project layer is excluded.
|
|
dispatcher, _ := newHookDispatcherWithExtra(repo, nil, repo)
|
|
outcome := dispatchBeforeTool(dispatcher)
|
|
|
|
if outcome.Ran != 1 {
|
|
t.Fatalf("user hook must still run when the project layer is excluded, Ran=%d", outcome.Ran)
|
|
}
|
|
if !fileExists(marker) {
|
|
t.Fatalf("user hook must fire (marker missing); project config must not disable/override it")
|
|
}
|
|
}
|
|
|
|
// TestHookGateWorktreeInheritsTrust proves the worktree case: with repo trusted,
|
|
// calling the chokepoint with a different workspaceRoot (the worktree path) but
|
|
// trustRoot=repo loads the project hooks, so trust keys on trustRoot not
|
|
// workspaceRoot.
|
|
func TestHookGateWorktreeInheritsTrust(t *testing.T) {
|
|
setTrustConfigRoot(t)
|
|
repo := t.TempDir()
|
|
worktree := t.TempDir() // a distinct, never-trusted path
|
|
marker := filepath.Join(t.TempDir(), "ran")
|
|
script := writeMarkerHookScript(t, marker)
|
|
// The project hooks live under the worktree (that is where config is read from).
|
|
writeProjectHooks(t, worktree, true, hooks.Definition{
|
|
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
|
|
})
|
|
|
|
if err := workspacetrust.Trust(repo); err != nil {
|
|
t.Fatalf("Trust(repo): %v", err)
|
|
}
|
|
|
|
// workspaceRoot is the worktree, but trustRoot is the original repo.
|
|
dispatcher, skip := newHookDispatcherWithExtra(worktree, nil, repo)
|
|
outcome := dispatchBeforeTool(dispatcher)
|
|
|
|
if outcome.Ran != 1 {
|
|
t.Fatalf("a worktree of a trusted repo should load project hooks, Ran=%d", outcome.Ran)
|
|
}
|
|
if !fileExists(marker) {
|
|
t.Fatalf("worktree run should execute the project hook (marker missing)")
|
|
}
|
|
if skip.excludedProjectConfig {
|
|
t.Fatalf("worktree of a trusted repo must not report the project layer excluded")
|
|
}
|
|
}
|
|
|
|
// TestPluginGateUntrustedExcludesProject proves R2/R5: an untrusted trustRoot makes
|
|
// activatePlugins load with ExcludeProject=true and report the skip.
|
|
func TestPluginGateUntrustedExcludesProject(t *testing.T) {
|
|
setTrustConfigRoot(t)
|
|
repo := t.TempDir()
|
|
// A ./.zero/plugins dir so the skip report flags an excluded project config.
|
|
if err := os.MkdirAll(filepath.Join(repo, ".zero", "plugins"), 0o700); err != nil {
|
|
t.Fatalf("mkdir project plugins dir: %v", err)
|
|
}
|
|
|
|
var gotExclude bool
|
|
deps := appDeps{
|
|
loadPlugins: func(opts plugins.LoadOptions) (plugins.LoadResult, error) {
|
|
gotExclude = opts.ExcludeProject
|
|
return plugins.LoadResult{}, nil
|
|
},
|
|
skillsDir: func() string { return t.TempDir() },
|
|
}
|
|
registry := tools.NewRegistry()
|
|
var stderr bytes.Buffer
|
|
activation := activatePlugins(repo, registry, deps, &stderr, repo)
|
|
|
|
if !gotExclude {
|
|
t.Fatalf("untrusted workspace must pass ExcludeProject=true to loadPlugins")
|
|
}
|
|
if !activation.excludedProjectConfig {
|
|
t.Fatalf("skip report should flag the excluded ./.zero/plugins dir")
|
|
}
|
|
if activation.trustCheckErrored {
|
|
t.Fatalf("a clean untrusted verdict is not a store-read error")
|
|
}
|
|
}
|
|
|
|
// TestPluginGateTrustedIncludesProject proves R3: after Trust(repo), activatePlugins
|
|
// loads with ExcludeProject=false.
|
|
func TestPluginGateTrustedIncludesProject(t *testing.T) {
|
|
setTrustConfigRoot(t)
|
|
repo := t.TempDir()
|
|
if err := workspacetrust.Trust(repo); err != nil {
|
|
t.Fatalf("Trust(repo): %v", err)
|
|
}
|
|
|
|
var gotExclude bool
|
|
deps := appDeps{
|
|
loadPlugins: func(opts plugins.LoadOptions) (plugins.LoadResult, error) {
|
|
gotExclude = opts.ExcludeProject
|
|
return plugins.LoadResult{}, nil
|
|
},
|
|
skillsDir: func() string { return t.TempDir() },
|
|
}
|
|
registry := tools.NewRegistry()
|
|
var stderr bytes.Buffer
|
|
activation := activatePlugins(repo, registry, deps, &stderr, repo)
|
|
|
|
if gotExclude {
|
|
t.Fatalf("trusted workspace must pass ExcludeProject=false to loadPlugins")
|
|
}
|
|
if activation.excludedProjectConfig {
|
|
t.Fatalf("trusted workspace must not report the project config excluded")
|
|
}
|
|
}
|
|
|
|
// TestPluginGateFailClosedOnStoreError proves R5 for plugins: a store-read error
|
|
// forces ExcludeProject=true and marks the error.
|
|
func TestPluginGateFailClosedOnStoreError(t *testing.T) {
|
|
configRoot := setTrustConfigRoot(t)
|
|
trustPath := filepath.Join(configRoot, "zero", "trust.json")
|
|
if err := os.MkdirAll(trustPath, 0o700); err != nil {
|
|
t.Fatalf("create trust.json as a directory: %v", err)
|
|
}
|
|
repo := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(repo, ".zero", "plugins"), 0o700); err != nil {
|
|
t.Fatalf("mkdir project plugins dir: %v", err)
|
|
}
|
|
|
|
var gotExclude bool
|
|
deps := appDeps{
|
|
loadPlugins: func(opts plugins.LoadOptions) (plugins.LoadResult, error) {
|
|
gotExclude = opts.ExcludeProject
|
|
return plugins.LoadResult{}, nil
|
|
},
|
|
skillsDir: func() string { return t.TempDir() },
|
|
}
|
|
registry := tools.NewRegistry()
|
|
var stderr bytes.Buffer
|
|
activation := activatePlugins(repo, registry, deps, &stderr, repo)
|
|
|
|
if !gotExclude {
|
|
t.Fatalf("a store-read error must fail closed (ExcludeProject=true)")
|
|
}
|
|
if !activation.trustCheckErrored {
|
|
t.Fatalf("skip report must mark the store-read error")
|
|
}
|
|
}
|
|
|
|
// TestEmitTrustNoticeOneLineWhenSkipped proves R6: exactly one notice when either
|
|
// surface skips project config, none when trusted, and distinct error text on a
|
|
// store-read failure.
|
|
func TestEmitTrustNoticeOneLineWhenSkipped(t *testing.T) {
|
|
t.Run("both surfaces skipped yields one line", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
emitTrustNotice(&buf,
|
|
trustSkip{excludedProjectConfig: true},
|
|
trustSkip{excludedProjectConfig: true})
|
|
lines := nonEmptyLines(buf.String())
|
|
if len(lines) != 1 {
|
|
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
|
|
}
|
|
if !bytes.Contains(buf.Bytes(), []byte("zero trust")) {
|
|
t.Fatalf("notice should point at 'zero trust', got %q", buf.String())
|
|
}
|
|
})
|
|
|
|
t.Run("trusted yields no notice", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
emitTrustNotice(&buf, trustSkip{}, trustSkip{})
|
|
if buf.Len() != 0 {
|
|
t.Fatalf("a trusted session must emit no notice, got %q", buf.String())
|
|
}
|
|
})
|
|
|
|
t.Run("store-read error uses distinct text", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
emitTrustNotice(&buf,
|
|
trustSkip{excludedProjectConfig: true, trustCheckErrored: true},
|
|
trustSkip{})
|
|
lines := nonEmptyLines(buf.String())
|
|
if len(lines) != 1 {
|
|
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
|
|
}
|
|
if !bytes.Contains(buf.Bytes(), []byte("could not be read")) {
|
|
t.Fatalf("error-path notice should name the store read failure, got %q", buf.String())
|
|
}
|
|
})
|
|
|
|
t.Run("only one surface skipped still yields one line", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
emitTrustNotice(&buf, trustSkip{excludedProjectConfig: true}, trustSkip{})
|
|
lines := nonEmptyLines(buf.String())
|
|
if len(lines) != 1 {
|
|
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
|
|
}
|
|
})
|
|
|
|
t.Run("mcp skip alone yields one line naming mcp", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
// Hooks and plugins clean; only the MCP surface (third arg) dropped project config.
|
|
emitTrustNotice(&buf, trustSkip{}, trustSkip{}, trustSkip{excludedProjectConfig: true})
|
|
lines := nonEmptyLines(buf.String())
|
|
if len(lines) != 1 {
|
|
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
|
|
}
|
|
if !bytes.Contains(buf.Bytes(), []byte("MCP")) {
|
|
t.Fatalf("notice should name MCP when the MCP surface is skipped, got %q", buf.String())
|
|
}
|
|
})
|
|
|
|
t.Run("store error with nothing excluded yields no notice", func(t *testing.T) {
|
|
// trustCheckErrored but no surface had project config to skip (excludedProjectConfig
|
|
// false): there is nothing to warn about, so the excluded-gate wins over the error.
|
|
var buf bytes.Buffer
|
|
emitTrustNotice(&buf, trustSkip{trustCheckErrored: true}, trustSkip{trustCheckErrored: true})
|
|
if buf.Len() != 0 {
|
|
t.Fatalf("a store error with no skipped project config must emit nothing, got %q", buf.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func nonEmptyLines(s string) []string {
|
|
out := []string{}
|
|
for _, line := range bytes.Split([]byte(s), []byte("\n")) {
|
|
if len(bytes.TrimSpace(line)) > 0 {
|
|
out = append(out, string(line))
|
|
}
|
|
}
|
|
return out
|
|
}
|