From 3412084131258a83b733af7601f01db6d0d6c34f Mon Sep 17 00:00:00 2001 From: Kapil Agnihotri Date: Fri, 21 Aug 2026 12:48:16 +0200 Subject: [PATCH] fix: reject unknown subcommands on the host instead of forwarding them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root command needs arbitrary args so `code-vm -- ` can forward a command to the guest, but cobra.ArbitraryArgs also handed any word it did not recognize as a subcommand straight into the VM. A typo, or a binary older than the subcommand being invoked, then surfaced as the guest shell's `exec: profile: not found` and exit 127 — after the VM had already been booted and entered — naming neither the real problem nor the host. Validate args instead: ArgsLenAtDash reports how many args preceded `--` (-1 when absent), so 0 is exactly the passthrough form. A bare invocation and `code-vm -- ` are unchanged; anything else fails on the host before the config is loaded, before Status, before boot. Co-Authored-By: Claude Opus 5 --- README.md | 6 ++++++ internal/cli/root.go | 23 ++++++++++++++++++++- internal/cli/root_test.go | 43 +++++++++++++++++++++++++++++++++++++++ test-vm-sandbox.sh | 9 ++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 93a98ac..9de0e0f 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,12 @@ code-vm # interactive shell | `code-vm secrets` | List secrets/vars the active profiles declare, mapped or not | | `code-vm doctor` | Check host prerequisites | +Guest passthrough needs the `--`. Anything else is an unknown command, +refused on the host before the VM is touched: `code-vm claude -p ...` is a +typo, `code-vm -- claude -p ...` is the command you meant. That also means +invoking a subcommand your installed binary is too old to have fails with +`unknown command`, instead of being forwarded to the guest to fail there. + ## Configuration `~/.config/code-vm/config.yaml`: diff --git a/internal/cli/root.go b/internal/cli/root.go index 1df754e..2878884 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -23,7 +23,7 @@ func NewRootCmd() *cobra.Command { "directory: that directory becomes the working directory in the guest.", SilenceUsage: true, SilenceErrors: true, - Args: cobra.ArbitraryArgs, + Args: rejectUnknownCommand, RunE: func(cmd *cobra.Command, args []string) error { return runDefault(cmd.Context(), args) }, @@ -43,6 +43,27 @@ func NewRootCmd() *cobra.Command { return root } +// rejectUnknownCommand accepts the two forms the root command really has — a +// bare invocation (interactive shell) and `code-vm -- ...` (run +// as the agent) — and refuses anything else on the host. +// +// Passthrough forces the root command to accept arbitrary args, which used to +// mean cobra handed any word it did not recognize as a subcommand straight to +// the guest. A typo, or a binary older than the subcommand being invoked, then +// failed as the guest shell's `exec: profile: not found` (exit 127) after the +// VM had already been booted and entered — an error naming neither the real +// problem nor the host. ArgsLenAtDash reports how many args preceded `--` +// (-1 when there is no `--`), so 0 is exactly the passthrough form. +func rejectUnknownCommand(cmd *cobra.Command, args []string) error { + if len(args) == 0 || cmd.ArgsLenAtDash() == 0 { + return nil + } + path := cmd.CommandPath() + return fmt.Errorf("unknown command %q for %q\n"+ + "Run %q for the command list, or %q to run it in the sandbox", + args[0], path, path+" --help", path+" -- "+args[0]+" ...") +} + // Execute runs the CLI. func Execute() error { return NewRootCmd().Execute() diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 3d2f20b..3fda8f9 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -76,3 +76,46 @@ func TestLoadConfigCanonicalizesSymlinkedConfigDir(t *testing.T) { t.Errorf("loadConfig error = %v, want a MountsExclude refusal", err) } } + +// The root command takes arbitrary args because a bare `code-vm -- ` +// forwards them to the guest. That must not extend to args with no `--` in +// front of them: cobra would hand any unrecognized word to the guest, so a +// typo — or a stale binary that predates a subcommand the caller expects — +// surfaced as the guest shell's `exec: profile: not found` and exit 127 +// instead of a host-side "unknown command". Nothing may reach the VM. +func TestRootRejectsUnknownSubcommandBeforeTouchingTheVM(t *testing.T) { + root := NewRootCmd() + setupShellFixture(t) + + r := installFakeClient(t, "") + root.SetArgs([]string{"profile-typo", "add", "git@example.com:x/y.git"}) + err := root.Execute() + if err == nil { + t.Fatal("unknown subcommand = nil error, want a host-side refusal") + } + if !strings.Contains(err.Error(), `unknown command "profile-typo"`) { + t.Errorf("error = %v, want it to name the unknown command", err) + } + if !strings.Contains(err.Error(), "--") { + t.Errorf("error = %v, want it to point at `--` for sandbox passthrough", err) + } + if len(r.calls) != 0 { + t.Errorf("nothing may reach the VM for an unknown command, calls=%v", r.calls) + } +} + +// The counterpart: an explicit `--` still forwards everything after it to the +// guest verbatim, including words that collide with host subcommand names. +func TestRootPassesArgsAfterDoubleDashToTheGuest(t *testing.T) { + root := NewRootCmd() + setupShellFixture(t) + + r := installFakeClient(t, "Running") + root.SetArgs([]string{"--", "claude", "login"}) + if err := root.Execute(); err != nil { + t.Fatalf("`-- claude login` = %v, want passthrough", err) + } + if !ranAny(r.calls, "claude login") { + t.Errorf("guest command not forwarded, calls=%v", r.calls) + } +} diff --git a/test-vm-sandbox.sh b/test-vm-sandbox.sh index 5fb25d3..887860f 100644 --- a/test-vm-sandbox.sh +++ b/test-vm-sandbox.sh @@ -469,6 +469,15 @@ else fail "allow rejects malformed input (got: $MALFORMED_OUT)" fi +# Host-side, no VM needed: an unrecognized word must not be forwarded into the +# guest. Captured, not piped: code-vm exits non-zero here by design. +UNKNOWN_OUT=$("${CODE_VM_ARGS[@]}" profile-typo add x 2>&1) +if echo "$UNKNOWN_OUT" | grep -q 'unknown command "profile-typo"'; then + pass "an unknown subcommand is refused on the host" +else + fail "an unknown subcommand is refused on the host (got: $UNKNOWN_OUT)" +fi + # Removing the domain from the config must take effect, or a revoked domain # would stay allowed for the VM's lifetime. cp "$CONFIG_FILE.suite-backup" "$CONFIG_FILE"