Skip to content

Commit 0e7ab2d

Browse files
committed
fix(mcp): preserve argv boundaries in Exec wrapper
The Exec handler wrapped commands as `env [-C cwd] [KEY=val ...] -- <argv>` and passed the result as a multi-element []string to the backend. The SSH backend space-joins argv before sending to the remote, where the shell re-tokenizes — silently splitting any element containing whitespace or shell metacharacters. bash -c "nohup node x &" -> bash sees only `nohup`, exits 0 bash -c "rm -f /tmp/foo" -> bash sees only `rm`, "missing operand" Env={X:"a; rm -rf /"} -> env-injection vector shellescape.QuoteCommand the wrapped argv into a single element so the remote shell tokenizes back to the original argv. Adds tests covering background jobs, multi-word -c scripts, env values with metacharacters and embedded single quotes, and cwd+env combined. Fixes #17
1 parent 3f582ab commit 0e7ab2d

2 files changed

Lines changed: 206 additions & 12 deletions

File tree

internal/mcp/tools.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"sync"
1515
"time"
1616

17+
"al.essio.dev/pkg/shellescape"
18+
1719
"github.com/deevus/pixels/internal/config"
1820
"github.com/deevus/pixels/sandbox"
1921
)
@@ -573,20 +575,26 @@ func (t *Tools) Exec(ctx context.Context, in ExecIn) (ExecOut, error) {
573575

574576
// Wrap the command in `env [-C cwd] [KEY=val ...] -- <command>` so cwd and
575577
// env take effect regardless of how the backend handles ExecOpts.Env.
576-
prefix := []string{"env"}
578+
argv := []string{"env"}
577579
if in.Cwd != "" {
578-
prefix = append(prefix, "-C", in.Cwd)
580+
argv = append(argv, "-C", in.Cwd)
579581
}
580582
keys := make([]string, 0, len(in.Env))
581583
for k := range in.Env {
582584
keys = append(keys, k)
583585
}
584586
sort.Strings(keys)
585587
for _, k := range keys {
586-
prefix = append(prefix, k+"="+in.Env[k])
588+
argv = append(argv, k+"="+in.Env[k])
587589
}
588-
prefix = append(prefix, "--")
589-
cmd := append(prefix, in.Command...)
590+
argv = append(argv, "--")
591+
argv = append(argv, in.Command...)
592+
593+
// Backends like SSH space-join argv before sending — the remote shell then
594+
// re-tokenizes, which silently splits any element containing whitespace or
595+
// shell metacharacters (e.g. `bash -c "rm -f /path"`). Shell-escape and
596+
// collapse to a single element so re-tokenization recovers the original argv.
597+
cmd := []string{shellescape.QuoteCommand(argv)}
590598

591599
var stdout, stderr strings.Builder
592600
exit, err := t.Backend.Run(ctx, sb.Name, sandbox.ExecOpts{

internal/mcp/tools_test.go

Lines changed: 193 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -551,9 +551,13 @@ func TestExecAppliesCwd(t *testing.T) {
551551
t.Fatal(err)
552552
}
553553

554+
if len(capturedCmd) != 1 {
555+
t.Fatalf("expected single shell-escaped element, got %d: %v", len(capturedCmd), capturedCmd)
556+
}
554557
want := []string{"env", "-C", "/tmp", "--", "pwd"}
555-
if !reflect.DeepEqual(capturedCmd, want) {
556-
t.Errorf("exec argv = %v, want %v", capturedCmd, want)
558+
got := retokenize(t, capturedCmd[0])
559+
if !reflect.DeepEqual(got, want) {
560+
t.Errorf("re-tokenized argv = %v, want %v", got, want)
557561
}
558562
}
559563

@@ -576,12 +580,15 @@ func TestExecAppliesEnv(t *testing.T) {
576580
t.Fatal(err)
577581
}
578582

579-
if got, want := capturedCmd[0], "env"; got != want {
580-
t.Errorf("argv[0] = %q, want %q", got, want)
583+
if len(capturedCmd) != 1 {
584+
t.Fatalf("expected single shell-escaped element, got %d: %v", len(capturedCmd), capturedCmd)
585+
}
586+
tokens := retokenize(t, capturedCmd[0])
587+
if len(tokens) == 0 || tokens[0] != "env" {
588+
t.Errorf("argv[0] = %v, want env", tokens)
581589
}
582-
// Assert "X=hello" is somewhere in argv before "--".
583590
foundX := false
584-
for _, a := range capturedCmd {
591+
for _, a := range tokens {
585592
if a == "--" {
586593
break
587594
}
@@ -590,7 +597,186 @@ func TestExecAppliesEnv(t *testing.T) {
590597
}
591598
}
592599
if !foundX {
593-
t.Errorf("env var X=hello missing from argv: %v", capturedCmd)
600+
t.Errorf("env var X=hello missing from argv: %v", tokens)
601+
}
602+
}
603+
604+
// retokenize re-splits a shell-escaped command string the way a remote POSIX
605+
// shell would, so tests can assert the wrapped argv survives SSH joining.
606+
// shellescape.Quote emits single-quoted spans plus a `"'"` double-quoted
607+
// segment to escape literal apostrophes (e.g. `a'b` -> `'a'"'"'b'`); both
608+
// quoting forms are handled here, plus backslash escapes outside quotes.
609+
func retokenize(t *testing.T, s string) []string {
610+
t.Helper()
611+
var out []string
612+
var cur strings.Builder
613+
var inSingle, inDouble, started bool
614+
flush := func() {
615+
if started {
616+
out = append(out, cur.String())
617+
cur.Reset()
618+
started = false
619+
}
620+
}
621+
for i := 0; i < len(s); i++ {
622+
c := s[i]
623+
switch {
624+
case inSingle:
625+
if c == '\'' {
626+
inSingle = false
627+
continue
628+
}
629+
cur.WriteByte(c)
630+
case inDouble:
631+
if c == '"' {
632+
inDouble = false
633+
continue
634+
}
635+
if c == '\\' && i+1 < len(s) {
636+
cur.WriteByte(s[i+1])
637+
i++
638+
continue
639+
}
640+
cur.WriteByte(c)
641+
case c == '\'':
642+
inSingle = true
643+
started = true
644+
case c == '"':
645+
inDouble = true
646+
started = true
647+
case c == '\\' && i+1 < len(s):
648+
cur.WriteByte(s[i+1])
649+
i++
650+
started = true
651+
case c == ' ' || c == '\t' || c == '\n':
652+
flush()
653+
default:
654+
cur.WriteByte(c)
655+
started = true
656+
}
657+
}
658+
if inSingle || inDouble {
659+
t.Fatalf("retokenize: unterminated quote in %q", s)
660+
}
661+
flush()
662+
return out
663+
}
664+
665+
// TestExecPreservesArgvBoundaries asserts that args containing spaces or shell
666+
// metacharacters (e.g. `bash -c "nohup node server.js &"`) reach the remote
667+
// shell as a single argv element. Without shell-escaping at the MCP boundary,
668+
// the local ssh client space-joins argv and the remote shell re-tokenizes,
669+
// which would split the bash -c argument and silently drop the trailing words.
670+
func TestExecPreservesArgvBoundaries(t *testing.T) {
671+
cases := []struct {
672+
name string
673+
argv []string
674+
}{
675+
{
676+
name: "bash -c with background job",
677+
argv: []string{"bash", "-c", "nohup node server.js &"},
678+
},
679+
{
680+
name: "bash -c with multi-word script",
681+
argv: []string{"bash", "-c", "rm -f /tmp/foo"},
682+
},
683+
{
684+
name: "single token argv",
685+
argv: []string{"true"},
686+
},
687+
}
688+
for _, tc := range cases {
689+
t.Run(tc.name, func(t *testing.T) {
690+
tt, fb := newTestTools(t)
691+
out, _ := tt.CreateSandbox(context.Background(), CreateSandboxIn{})
692+
693+
var captured []string
694+
fb.runHook = func(name string, opts sandbox.ExecOpts) (int, error) {
695+
captured = opts.Cmd
696+
return 0, nil
697+
}
698+
699+
if _, err := tt.Exec(context.Background(), ExecIn{
700+
Name: out.Name,
701+
Command: tc.argv,
702+
}); err != nil {
703+
t.Fatal(err)
704+
}
705+
706+
if len(captured) != 1 {
707+
t.Fatalf("expected single shell-escaped element so SSH cannot re-tokenize, got %d: %v", len(captured), captured)
708+
}
709+
710+
want := append([]string{"env", "--"}, tc.argv...)
711+
got := retokenize(t, captured[0])
712+
if !reflect.DeepEqual(got, want) {
713+
t.Errorf("re-tokenized argv = %v, want %v", got, want)
714+
}
715+
})
716+
}
717+
}
718+
719+
// TestExecPreservesEnvAndCwd guards env-value and cwd quoting. Env values like
720+
// `hello world; rm -rf /` must not split into multiple argv tokens or escape
721+
// the env wrapper, single quotes inside env values must round-trip through
722+
// shellescape's `'\''` pattern, and Cwd + Env set together must both reach
723+
// the remote shell intact.
724+
func TestExecPreservesEnvAndCwd(t *testing.T) {
725+
cases := []struct {
726+
name string
727+
in ExecIn
728+
want []string
729+
}{
730+
{
731+
name: "env value with shell metacharacters",
732+
in: ExecIn{
733+
Command: []string{"sh", "-c", "echo $X"},
734+
Env: map[string]string{"X": "hello world; rm -rf /"},
735+
},
736+
want: []string{"env", "X=hello world; rm -rf /", "--", "sh", "-c", "echo $X"},
737+
},
738+
{
739+
name: "env value with embedded single quote",
740+
in: ExecIn{
741+
Command: []string{"true"},
742+
Env: map[string]string{"Y": "a'b"},
743+
},
744+
want: []string{"env", "Y=a'b", "--", "true"},
745+
},
746+
{
747+
name: "cwd and env together",
748+
in: ExecIn{
749+
Command: []string{"sh", "-c", "echo $X"},
750+
Cwd: "/var/lib/some path",
751+
Env: map[string]string{"X": "v"},
752+
},
753+
want: []string{"env", "-C", "/var/lib/some path", "X=v", "--", "sh", "-c", "echo $X"},
754+
},
755+
}
756+
for _, tc := range cases {
757+
t.Run(tc.name, func(t *testing.T) {
758+
tt, fb := newTestTools(t)
759+
out, _ := tt.CreateSandbox(context.Background(), CreateSandboxIn{})
760+
tc.in.Name = out.Name
761+
762+
var captured []string
763+
fb.runHook = func(name string, opts sandbox.ExecOpts) (int, error) {
764+
captured = opts.Cmd
765+
return 0, nil
766+
}
767+
768+
if _, err := tt.Exec(context.Background(), tc.in); err != nil {
769+
t.Fatal(err)
770+
}
771+
772+
if len(captured) != 1 {
773+
t.Fatalf("expected single shell-escaped element, got %d: %v", len(captured), captured)
774+
}
775+
got := retokenize(t, captured[0])
776+
if !reflect.DeepEqual(got, tc.want) {
777+
t.Errorf("re-tokenized argv = %v, want %v", got, tc.want)
778+
}
779+
})
594780
}
595781
}
596782

0 commit comments

Comments
 (0)