From 4264c696dd845611770c6f20a867d4859afbc63b Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Tue, 20 Oct 2026 09:05:28 -0400 Subject: [PATCH 1/3] fix(plugin): refuse machine proxy execution pending secret handoff (D-0651) --- internal/plugin/router.go | 6 ++++++ internal/plugin/router_machine_test.go | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 internal/plugin/router_machine_test.go diff --git a/internal/plugin/router.go b/internal/plugin/router.go index 0fd01ab42..a881940ff 100644 --- a/internal/plugin/router.go +++ b/internal/plugin/router.go @@ -151,6 +151,12 @@ func ProxyBinaryAt(candidate, rootDir, slug string, args []string) error { // project settings the plugin's manifest declares, and the plugin's exit // status surfaced as ExitCodeError (silent: the child already reported). func execPluginBinary(path, slug string, args []string) error { + // The CLI has already restored machine-invoked secrets before Cobra runs. + // Until plugins support the secret-bundle protocol, forwarding these args + // would expose secrets through the plugin process's argv (D-0651). + if os.Getenv("NSELF_INVOKED_BY") != "" { + return errs.New("E421", "machine-invoked plugin execution is unavailable until plugins support secret handoff") + } // Prepare the command. // // The plugin inherits this process's environment, plus whichever project diff --git a/internal/plugin/router_machine_test.go b/internal/plugin/router_machine_test.go new file mode 100644 index 000000000..b8b0f49bc --- /dev/null +++ b/internal/plugin/router_machine_test.go @@ -0,0 +1,22 @@ +package plugin + +import ( + "errors" + "testing" + + "github.com/nself-org/cli/internal/errs" +) + +func TestMachinePluginExecutionRefused(t *testing.T) { + for _, by := range []string{"mcp", "automation"} { + t.Run(by, func(t *testing.T) { + t.Setenv("NSELF_INVOKED_BY", by) + // An invalid executable ensures the machine boundary is checked before exec. + err := execPluginBinary("missing-plugin-binary", "demo", []string{"--token=fixture"}) + var ce *errs.CLIError + if !errors.As(err, &ce) || ce.Code != "E421" { + t.Fatalf("expected E421 before plugin execution, got %v", err) + } + }) + } +} From 750947e35f4658fe3fcba46e2f6299e11ee37479 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 12:07:50 -0400 Subject: [PATCH 2/3] fix(plugin): narrow machine-execution guard to restored secret bundles (D-0651) - Guard in execPluginBinary now checks invoke.SecretsRestored() instead of NSELF_INVOKED_BY alone; machine-invoked plugin calls without secrets run. - Add SecretsRestored/TestingSetSecretsRestored in internal/invoke to track whether RestoreSecretArgv replaced placeholders in this process. - Update E421 text and MCP docs to describe the secret-bundle gate at exec time while keeping plugin tools advertised on machine surfaces. - Add tests for machine-invoked plugin execution without secrets, with secrets, ProxyBinaryAt containment, and plugin advertisement. --- .github/wiki/MCP-Server.md | 5 +++ .github/wiki/MCP-Tools.md | 1 + internal/errs/codes_invoke.go | 16 +++---- internal/invoke/argv_test.go | 8 ++++ internal/invoke/secretenv.go | 13 ++++++ internal/plugin/router.go | 12 +++--- internal/plugin/router_machine_test.go | 58 +++++++++++++++++++++----- 7 files changed, 89 insertions(+), 24 deletions(-) diff --git a/.github/wiki/MCP-Server.md b/.github/wiki/MCP-Server.md index 825e7bde4..15d21691c 100644 --- a/.github/wiki/MCP-Server.md +++ b/.github/wiki/MCP-Server.md @@ -24,6 +24,7 @@ After the path matches, the child rebuilds the registry from its mounted command Running as a child keeps global state, `os.Exit` and the project operation lock identical to a typed command. There is no shell anywhere: every request value is exactly one argv element. The **machine registry** is the v1.5 registry of the running binary with installed plugin commands mounted. It is the same in both compat modes ([[Compat-V15]]), so tool names and routes are the v1.5 shape in 1.4.x too. The plugin set is read when the server starts; install a plugin, then restart the server. +Plugin commands are listed as tools like any other command, but a call that arrives while a secret bundle was restored is refused with `E421` because plugins do not yet support the secret-handoff protocol (D-0651). A machine-invoked plugin call that carries no secrets runs normally. ## Exposure rules @@ -50,6 +51,7 @@ A request is a JSON object. Every member is optional. Member names are matched e | `args` | array of strings | positional values, in declaration order | | `flags` | object | flag name to boolean, integer, number, string or array of strings | | `argv` | array of strings | arguments for a plugin command that declares no args and no flags and has no subcommands (a leaf); items naming a root flag such as `--no-monorepo` are `E420` because the plugin proxy would drop them | +| `argv` | array of strings | arguments for a plugin command that declares no args and no flags and has no subcommands (a leaf); items naming a root flag such as `--no-monorepo` are `E420` because the plugin proxy would drop them. A plugin call that arrives while a secret bundle was restored is `E421`. | | `confirm` | string, 64 hex characters | a plan id or a server-issued nonce (commands with a `confirm` block only) | The params schema of each command (JSON Schema 2020-12, `additionalProperties: false`) is derived from the registry: `prefixItems` per declared argument, one typed property per exposed flag, defaults from the registry. @@ -71,12 +73,14 @@ Example, flags and a value that looks like a flag: becomes ` --json --quiet=true --tags=a --tags=b -- -x`. Flags are sorted by name, a boolean is always `--name=true` or `--name=false` (never a bare `--name`: a mounted plugin node parses no flags, and cobra's command lookup would read a bare unknown flag as taking the next word, swallow the `--` and run the first positional as a subcommand), and a list is one `--name=value` per item. Positional values always come after `--`, so none of them can be read as a flag. A `stringSlice` flag is split on commas by the child, so an item that holds a comma, a quote or a newline is refused instead of being silently changed. `argv` on a plugin command that has subcommands is `E421`: a mounted plugin node parses no flags, so the child would read the first word as a subcommand and run a node the exposure rules never checked. Its params schema does not offer `argv`. For a leaf, `argv` is the plugin's own input: the exposure decision covers the command at the requested path, not what the plugin binary does with its words. Accepted risk: free-form `argv` can carry the plugin's own undeclared flags (for example `--force`), which the side-effect classification cannot see, so a plugin leaf declared `read` that takes undeclared mutating flags can write. Plugin authors declare every flag with its side effect in the manifest to get the gate. +`argv` on a plugin command that has subcommands is `E421`: a mounted plugin node parses no flags, so the child would read the first word as a subcommand and run a node the exposure rules never checked. Its params schema does not offer `argv`. For a leaf, `argv` is the plugin's own input: the exposure decision covers the command at the requested path, not what the plugin binary does with its words. Accepted risk: free-form `argv` can carry the plugin's own undeclared flags (for example `--force`), which the side-effect classification cannot see, so a plugin leaf declared `read` that takes undeclared mutating flags can write. Plugin authors declare every flag with its side effect in the manifest to get the gate. A plugin leaf invoked while a secret bundle was restored is also `E421`, even when `argv` itself carries no secret placeholder, because the process argv may still hold values the invoker restored for this request. Refused with `E420`, naming the flag but never echoing its value: an unknown or unexposed flag, a value of the wrong type (`1.5` for an integer), too few or too many args, a NUL byte anywhere, an element over 32 KiB or an argv over 128 KiB. ## Request id The request id names a request in logs. It is the lowercase hex SHA-256 of the canonical JSON `{"args":[...],"argv":[...],"command":"","flags":{...}}`: keys sorted, no whitespace, `confirm` left out, missing members empty, and every secret argument and flag value replaced by `"[REDACTED]"` first. Redaction fails closed: a positional past the declared args and a flag the command does not declare are replaced too. Free-form plugin `argv` items cannot be marked secret, so every one is replaced by `"[REDACTED]"` before hashing (the id keeps their count) and scrubbed from `E422` stderr tails. Two requests that differ only in a secret value share an id, and no id is derived from a secret. The id is not a confirmation: anyone can compute it. +A plugin call refused under the secret-bundle guard shares the id it would have had if it ran; the refusal reason is in the envelope error, not in the id. ## Child environment @@ -221,6 +225,7 @@ The adversarial suite runs with `go test`: `TestMCPGenAdversarial` (`internal/mc |---|---| | Name | `nself_` + the path words joined by `_`, `-` becoming `_` (`config secrets rotate` is `nself_config_secrets_rotate`). A name over 64 characters or a collision fails the start. | | Which commands | every command the [exposure rules](#exposure-rules) serve over MCP, installed plugin commands included. A plugin subcommand that declares no side effect counts as destructive and has no confirm block, so it is not listed. | +| Which commands | every command the [exposure rules](#exposure-rules) serve over MCP, installed plugin commands included. A plugin subcommand that declares no side effect counts as destructive and has no confirm block, so it is not listed. Plugin commands are listed even though a call that arrives while a secret bundle was restored is refused with `E421`; the tool is advertised, the gate is enforced at exec time. | | Input | the machine request schema (`args`, `flags`, `argv`, `confirm`), `additionalProperties: false`. Flags with the `output: stream` override are not offered; a call that sets one is `E421`. | | Output | the command's committed data schema (`schemas/commands/`) as `outputSchema`; a command with none gets `{"type":"object"}`. A command that can ask for confirmation allows the confirmation shape too. Data that is not an object is wrapped as `{"value": ...}` in the schema and in the result. | | Annotations | `readOnlyHint` for read, `destructiveHint` for destructive, `openWorldHint` for remote, taken at the worst class a flag can reach. | diff --git a/.github/wiki/MCP-Tools.md b/.github/wiki/MCP-Tools.md index 0ba4be01a..6c42572fb 100644 --- a/.github/wiki/MCP-Tools.md +++ b/.github/wiki/MCP-Tools.md @@ -312,3 +312,4 @@ Deprecated names from v1.4, removed in v1.6.0. Each maps its parameters onto the | `sentry_status` | Deprecated: use nself_sentry_status; removed in v1.6.0. Read the public ɳSentry status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29190) | **Plugin commands.** Installed plugins add tools by the same `ToolName` rule. This page is generated from the builtin registry alone, so it never lists them; each plugin's wiki page lists its own in its machine-surface region, for example the [CI plugin page](https://github.com/nself-org/plugins/wiki/CI). +**Plugin commands.** Installed plugins add tools by the same `ToolName` rule. This page is generated from the builtin registry alone, so it never lists them; each plugin's wiki page lists its own in its machine-surface region, for example the [CI plugin page](https://github.com/nself-org/plugins/wiki/CI). Plugin tools are advertised on the MCP surface like any other command, but a call that arrives while a secret bundle was restored is refused with `E421` because plugins do not yet support the secret-handoff protocol (D-0651); a machine-invoked plugin call that carries no secrets runs normally. diff --git a/internal/errs/codes_invoke.go b/internal/errs/codes_invoke.go index 4ea4c3388..fb2899c1e 100644 --- a/internal/errs/codes_invoke.go +++ b/internal/errs/codes_invoke.go @@ -15,14 +15,14 @@ func init() { DocsPath: "reference/error-codes#e420", Exit: 1, }, - CodeEntry{ - Code: "E421", - Category: "cli", - Summary: "Command not exposed on machine surfaces", - DefaultWhy: "This command is not served over MCP or HTTP, or the secret values passed to a machine-invoked child are unusable (bad bundle, an unused or reused value); the reason is in the cause.", - DefaultFix: "Run the command from a terminal, or call a command that is exposed.", - DocsPath: "reference/error-codes#e421", - Exit: 1, +CodeEntry{ +Code: "E421", +Category: "cli", +Summary: "Command not exposed on machine surfaces", + DefaultWhy: "This command is not served over MCP or HTTP, the secret values passed to a machine-invoked child are unusable (bad bundle, an unused or reused value), or a plugin was invoked while a secret bundle was restored; the reason is in the cause.", +DefaultFix: "Run the command from a terminal, or call a command that is exposed.", +DocsPath: "reference/error-codes#e421", +Exit: 1, }, CodeEntry{ Code: "E422", diff --git a/internal/invoke/argv_test.go b/internal/invoke/argv_test.go index 19487ba8a..ce3d0d336 100644 --- a/internal/invoke/argv_test.go +++ b/internal/invoke/argv_test.go @@ -246,6 +246,14 @@ func TestExposure(t *testing.T) { {"fx echo", TransportHTTP, []string{"follow"}, false, "NDJSON"}, {"fx echo", TransportHTTPStream, []string{"follow"}, true, ""}, } + // Plugin commands are advertised on machine surfaces; the secret-bundle + // guard is enforced at exec time, not at exposure time (D-0651 review). + cases = append(cases, struct { + path, transport string + set []string + ok bool + reason string + }{"demo", TransportMCP, nil, true, "plugin leaf advertised"}) for _, c := range cases { set := map[string]bool{} for _, n := range c.set { diff --git a/internal/invoke/secretenv.go b/internal/invoke/secretenv.go index 074818a35..5def0e7be 100644 --- a/internal/invoke/secretenv.go +++ b/internal/invoke/secretenv.go @@ -35,6 +35,18 @@ import ( // placeholderPrefix starts every secret placeholder. const placeholderPrefix = "nself-invoke-secret:" +// secretsRestored is true after RestoreSecretArgv has replaced at least one +// placeholder with a real value in this process. Plugin execution reads it to +// decide whether argv carries secrets that would leak through exec (D-0651). +var secretsRestored bool + +// SecretsRestored reports whether RestoreSecretArgv restored any value. +func SecretsRestored() bool { return secretsRestored } + +// TestingSetSecretsRestored is for tests only: it sets the package flag that +// RestoreSecretArgv would set when a bundle was actually restored. +func TestingSetSecretsRestored(v bool) { secretsRestored = v } + // SecretBundle is the secret values of one child run: Values[n] is what the // placeholder with index n stands for. type SecretBundle struct { @@ -149,5 +161,6 @@ func RestoreSecretArgv(argv []string) (out []string, values []string, err error) return fail("a secret value was passed but no argument uses it") } } + secretsRestored = len(b.Values) > 0 return out, b.Values, nil } diff --git a/internal/plugin/router.go b/internal/plugin/router.go index a881940ff..76930dcdb 100644 --- a/internal/plugin/router.go +++ b/internal/plugin/router.go @@ -9,6 +9,7 @@ import ( "github.com/nself-org/cli/internal/compat" "github.com/nself-org/cli/internal/errs" + "github.com/nself-org/cli/internal/invoke" "github.com/nself-org/cli/internal/plugin/catalog" "github.com/nself-org/cli/internal/pluginname" "github.com/nself-org/cli/internal/pluginperm" @@ -151,11 +152,12 @@ func ProxyBinaryAt(candidate, rootDir, slug string, args []string) error { // project settings the plugin's manifest declares, and the plugin's exit // status surfaced as ExitCodeError (silent: the child already reported). func execPluginBinary(path, slug string, args []string) error { - // The CLI has already restored machine-invoked secrets before Cobra runs. - // Until plugins support the secret-bundle protocol, forwarding these args - // would expose secrets through the plugin process's argv (D-0651). - if os.Getenv("NSELF_INVOKED_BY") != "" { - return errs.New("E421", "machine-invoked plugin execution is unavailable until plugins support secret handoff") + // The CLI restores machine-invoked secrets into os.Args before Cobra runs. + // Until plugins support the secret-bundle protocol, forwarding a restored + // bundle would expose secrets through the plugin process's argv (D-0651). + // A machine-invoked call that carries no secrets is safe and must run. + if invoke.SecretsRestored() { + return errs.New("E421", "plugin execution is unavailable when a secret bundle was restored; plugins do not yet support secret handoff") } // Prepare the command. // diff --git a/internal/plugin/router_machine_test.go b/internal/plugin/router_machine_test.go index b8b0f49bc..62dec022c 100644 --- a/internal/plugin/router_machine_test.go +++ b/internal/plugin/router_machine_test.go @@ -2,21 +2,57 @@ package plugin import ( "errors" + "os" + "path/filepath" + "runtime" "testing" + "github.com/nself-org/cli/internal/invoke" "github.com/nself-org/cli/internal/errs" ) -func TestMachinePluginExecutionRefused(t *testing.T) { - for _, by := range []string{"mcp", "automation"} { - t.Run(by, func(t *testing.T) { - t.Setenv("NSELF_INVOKED_BY", by) - // An invalid executable ensures the machine boundary is checked before exec. - err := execPluginBinary("missing-plugin-binary", "demo", []string{"--token=fixture"}) - var ce *errs.CLIError - if !errors.As(err, &ce) || ce.Code != "E421" { - t.Fatalf("expected E421 before plugin execution, got %v", err) - } - }) +func TestMachinePluginExecutionWithoutSecretsRuns(t *testing.T) { + // A machine-invoked plugin call that carries no restored secret bundle + // must still run: the guard protects secrets, not the transport. + t.Setenv("NSELF_INVOKED_BY", "mcp") + // execPluginBinary will reach os/exec; use /bin/sh -c true as a portable + // no-op that exists on every POSIX system (and skip on Windows). + if runtime.GOOS == "windows" { + t.Skip("POSIX-only test") + } + if err := execPluginBinary("/bin/sh", "demo", []string{"-c", "true"}); err != nil { + t.Fatalf("machine-invoked plugin without secrets should run, got %v", err) + } +} + +func TestMachinePluginExecutionWithSecretsRefused(t *testing.T) { + // When a secret bundle was restored, argv carries real values and the + // plugin must be refused until it supports the handoff protocol (D-0651). + t.Setenv("NSELF_INVOKED_BY", "mcp") + // RestoreSecretArgv sets the package flag when it replaces placeholders; + // simulate that state directly because constructing a real bundle here + // would duplicate the secretenv tests. + invoke.TestingSetSecretsRestored(true) + t.Cleanup(func() { invoke.TestingSetSecretsRestored(false) }) + err := execPluginBinary("missing-plugin-binary", "demo", []string{"--token=fixture"}) + var ce *errs.CLIError + if !errors.As(err, &ce) || ce.Code != "E421" { + t.Fatalf("expected E421 when secrets were restored, got %v", err) + } +} + +func TestProxyBinaryAtRejectsEscape(t *testing.T) { + // ProxyBinaryAt must refuse a candidate that resolves outside rootDir; + // this is the containment check that prevents symlink escapes. + root := t.TempDir() + outside := filepath.Join(os.TempDir(), "nself-proxy-binary-at-test-outside") + if err := os.WriteFile(outside, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write outside binary: %v", err) + } + t.Cleanup(func() { os.Remove(outside) }) + err := ProxyBinaryAt(outside, root, "demo", nil) + var ce *errs.CLIError + if !errors.As(err, &ce) || ce.Code != "E406" { + t.Fatalf("expected E406 for escape, got %v", err) } } From e9c979e1cacf952ea327ff6810953cca387f3bba Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 19:04:49 -0400 Subject: [PATCH 3/3] fix(invoke): drop the plugin router secret check main already carries and gofmt the error codes --- internal/errs/codes_invoke.go | 16 +++---- internal/plugin/router.go | 8 ---- internal/plugin/router_machine_test.go | 58 -------------------------- 3 files changed, 8 insertions(+), 74 deletions(-) delete mode 100644 internal/plugin/router_machine_test.go diff --git a/internal/errs/codes_invoke.go b/internal/errs/codes_invoke.go index fb2899c1e..d978d3d43 100644 --- a/internal/errs/codes_invoke.go +++ b/internal/errs/codes_invoke.go @@ -15,14 +15,14 @@ func init() { DocsPath: "reference/error-codes#e420", Exit: 1, }, -CodeEntry{ -Code: "E421", -Category: "cli", -Summary: "Command not exposed on machine surfaces", - DefaultWhy: "This command is not served over MCP or HTTP, the secret values passed to a machine-invoked child are unusable (bad bundle, an unused or reused value), or a plugin was invoked while a secret bundle was restored; the reason is in the cause.", -DefaultFix: "Run the command from a terminal, or call a command that is exposed.", -DocsPath: "reference/error-codes#e421", -Exit: 1, + CodeEntry{ + Code: "E421", + Category: "cli", + Summary: "Command not exposed on machine surfaces", + DefaultWhy: "This command is not served over MCP or HTTP, the secret values passed to a machine-invoked child are unusable (bad bundle, an unused or reused value), or a plugin was invoked while a secret bundle was restored; the reason is in the cause.", + DefaultFix: "Run the command from a terminal, or call a command that is exposed.", + DocsPath: "reference/error-codes#e421", + Exit: 1, }, CodeEntry{ Code: "E422", diff --git a/internal/plugin/router.go b/internal/plugin/router.go index 76930dcdb..0fd01ab42 100644 --- a/internal/plugin/router.go +++ b/internal/plugin/router.go @@ -9,7 +9,6 @@ import ( "github.com/nself-org/cli/internal/compat" "github.com/nself-org/cli/internal/errs" - "github.com/nself-org/cli/internal/invoke" "github.com/nself-org/cli/internal/plugin/catalog" "github.com/nself-org/cli/internal/pluginname" "github.com/nself-org/cli/internal/pluginperm" @@ -152,13 +151,6 @@ func ProxyBinaryAt(candidate, rootDir, slug string, args []string) error { // project settings the plugin's manifest declares, and the plugin's exit // status surfaced as ExitCodeError (silent: the child already reported). func execPluginBinary(path, slug string, args []string) error { - // The CLI restores machine-invoked secrets into os.Args before Cobra runs. - // Until plugins support the secret-bundle protocol, forwarding a restored - // bundle would expose secrets through the plugin process's argv (D-0651). - // A machine-invoked call that carries no secrets is safe and must run. - if invoke.SecretsRestored() { - return errs.New("E421", "plugin execution is unavailable when a secret bundle was restored; plugins do not yet support secret handoff") - } // Prepare the command. // // The plugin inherits this process's environment, plus whichever project diff --git a/internal/plugin/router_machine_test.go b/internal/plugin/router_machine_test.go deleted file mode 100644 index 62dec022c..000000000 --- a/internal/plugin/router_machine_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package plugin - -import ( - "errors" - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/nself-org/cli/internal/invoke" - "github.com/nself-org/cli/internal/errs" -) - -func TestMachinePluginExecutionWithoutSecretsRuns(t *testing.T) { - // A machine-invoked plugin call that carries no restored secret bundle - // must still run: the guard protects secrets, not the transport. - t.Setenv("NSELF_INVOKED_BY", "mcp") - // execPluginBinary will reach os/exec; use /bin/sh -c true as a portable - // no-op that exists on every POSIX system (and skip on Windows). - if runtime.GOOS == "windows" { - t.Skip("POSIX-only test") - } - if err := execPluginBinary("/bin/sh", "demo", []string{"-c", "true"}); err != nil { - t.Fatalf("machine-invoked plugin without secrets should run, got %v", err) - } -} - -func TestMachinePluginExecutionWithSecretsRefused(t *testing.T) { - // When a secret bundle was restored, argv carries real values and the - // plugin must be refused until it supports the handoff protocol (D-0651). - t.Setenv("NSELF_INVOKED_BY", "mcp") - // RestoreSecretArgv sets the package flag when it replaces placeholders; - // simulate that state directly because constructing a real bundle here - // would duplicate the secretenv tests. - invoke.TestingSetSecretsRestored(true) - t.Cleanup(func() { invoke.TestingSetSecretsRestored(false) }) - err := execPluginBinary("missing-plugin-binary", "demo", []string{"--token=fixture"}) - var ce *errs.CLIError - if !errors.As(err, &ce) || ce.Code != "E421" { - t.Fatalf("expected E421 when secrets were restored, got %v", err) - } -} - -func TestProxyBinaryAtRejectsEscape(t *testing.T) { - // ProxyBinaryAt must refuse a candidate that resolves outside rootDir; - // this is the containment check that prevents symlink escapes. - root := t.TempDir() - outside := filepath.Join(os.TempDir(), "nself-proxy-binary-at-test-outside") - if err := os.WriteFile(outside, []byte("#!/bin/sh\n"), 0o755); err != nil { - t.Fatalf("write outside binary: %v", err) - } - t.Cleanup(func() { os.Remove(outside) }) - err := ProxyBinaryAt(outside, root, "demo", nil) - var ce *errs.CLIError - if !errors.As(err, &ce) || ce.Code != "E406" { - t.Fatalf("expected E406 for escape, got %v", err) - } -}