@@ -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