Search code examples
pythonterminalsubprocesstty

Filter out command that needs a terminal in Python subprocess module


I am developing a robot that accepts commands from network (XMPP) and uses subprocess module in Python to execute them and sends back the output of commands. Essentially it is an SSH-like XMPP-based non-interactive shell.

The robot only executes commands from authenticated trusted sources, so arbitrary shell commands are allowed (shell=True).

However, when I accidentally send some command that needs a tty, the robot is stuck.

For example:

subprocess.check_output(['vim'], shell=False)
subprocess.check_output('vim', shell=True)

Should each of the above commands is received, the robot is stuck, and the terminal from which the robot is run, is broken.

Though the robot only receives commands from authenticated trusted sources, human errs. How could I make the robot filter out those commands that will break itself? I know there is os.isatty but how could I utilize it? Is there a way to detect those "bad" commands and refuse to execute them?

TL;DR:

Say, there are two kinds of commands:

  • Commands like ls: does not need a tty to run.
  • Commands like vim: needs a tty; breaks subprocess if no tty is given.

How could I tell a command is ls-like or is vim-like and refuses to run the command if it is vim-like?


Solution

  • What you expect is a function that receives command as input, and returns meaningful output by running the command.

    Since the command is arbitrary, requirement for tty is just one of many bad cases may happen (other includes running a infinite loop), your function should only concern about its running period, in other words, a command is “bad” or not should be determined by if it ends in a limited time or not, and since subprocess is asynchronous by nature, you can just run the command and handle it in a higher vision.

    Demo code to play, you can change the cmd value to see how it performs differently:

    #!/usr/bin/env python
    # coding: utf-8
    
    import time
    import subprocess
    from subprocess import PIPE
    
    
    #cmd = ['ls']
    #cmd = ['sleep', '3']
    cmd = ['vim', '-u', '/dev/null']
    
    print 'call cmd'
    p = subprocess.Popen(cmd, shell=True,
                         stdin=PIPE, stderr=PIPE, stdout=PIPE)
    print 'called', p
    
    time_limit = 2
    timer = 0
    time_gap = 0.2
    
    ended = False
    while True:
        time.sleep(time_gap)
    
        returncode = p.poll()
        print 'process status', returncode
    
        timer += time_gap
        if timer >= time_limit:
            print 'timeout, kill process'
            p.kill()
            break
    
        if returncode is not None:
            ended = True
            break
    
    if ended:
        print 'process ended by', returncode
    
        print 'read'
        out, err = p.communicate()
        print 'out', repr(out)
        print 'error', repr(err)
    else:
        print 'process failed'
    

    Three points are notable in the above code:

    1. We use Popen instead of check_output to run the command, unlike check_output which will wait for the process to end, Popen returns immediately, thus we can do further things to control the process.

    2. We implement a timer to check for the process's status, if it runs for too long, we killed it manually because we think a process is not meaningful if it could not end in a limited time. In this way your original problem will be solved, as vim will never end and it will definitely being killed as an “unmeaningful” command.

    3. After the timer helps us filter out bad commands, we can get stdout and stderr of the command by calling communicate method of the Popen object, after that its your choice to determine what to return to the user.

    Conclusion

    tty simulation is not needed, we should run the subprocess asynchronously, then control it by a timer to determine whether it should be killed or not, for those ended normally, its safe and easy to get the output.