Repository: Sceptre/sceptre

Issue:
!cmd hook runs in `sh`, which is not my default shell
### Subject of the issue
When I use a `!cmd` hook, it appears to be run by `sh` because I receive this error: 

```shell
/bin/sh: 1: [[: not found
```

Is there a way to force the underlying `subprocess.run()` call to use Python? I know I can do this in Python by using arguments that look like `["bash", "-c", "your command goes here"]`, and, in fact, if I simply pass in `["your command goes here"]` in Python, it will run in bash because it is my default shell. Is `sceptre` really running my hook using `sh`? If so, how can I make it use `bash` instead?

### Your environment
* version of sceptre (sceptre --version) 
**Sceptre, version 2.4.0**
* version of python (python --version) 
**Python 3.8.10**
* which OS/distro
**Ubuntu 20.04.3 LTS on 4.4.0-19041-Microsoft #1237-Microsoft x86_64** 

### Steps to reproduce
Tell us how to reproduce this issue. Please provide sceptre project files if possible,
you can use https://plnkr.co/edit/ANFHm61Ilt4mQVgF as a base.

Here is a hook that causes the behavior:
```yaml
hooks:
    before_update:
        - !cmd 'if [[ "{{ var.update_lambdas | default("false") }}" == "true" ]]; then lambdas/deploy-lambdas.sh -u "$(./get-stack-output.sh "{{ environment }}/{{ region }}/LambdaS3Bucket.yaml" LambdaBucketName)" HandleServiceAccountAppGitHubWebhook; fi;'
```

### Expected behaviour
The hook should run successfully using my default shell, which is `bash`.

### Actual behaviour
I see the error `/bin/sh: 1: [[: not found` instead.


Discussion:
iainelder: See my [sceptre-dev-notes](https://github.com/iainelder/sceptre-dev-notes/blob/main/dev-notes/cmd-hook-options.md) repo for the notes on the devleopment and testing. If any of that is useful to put in the main repo I can add it here.
iainelder: Well, tox gives me some failing tests, so I guess I should start here!

```text
Results (15.28s):
     635 passed
       2 failed
         - tests/test_hooks/test_cmd.py:18 TestCmd.test_run_with_non_str_argument
         - tests/test_hooks/test_cmd.py:23 TestCmd.test_run_with_str_argument
ERROR: InvocationError for command /home/isme/.local/bin/poetry run pytest --cov=sceptre --cov-append --cov-report=term-missing --junitxml=test-reports/junit-py310.xml (exited with code 1)
```
iainelder: I've addressed all the feedback about the code. Now I need to add documentation for the new features.

Patch:
diff --git a/docs/_source/docs/hooks.rst b/docs/_source/docs/hooks.rst
index a1eeef6..9a3bc82 100644
--- a/docs/_source/docs/hooks.rst
+++ b/docs/_source/docs/hooks.rst
@@ -44,23 +44,105 @@ Available Hooks
 cmd
 ~~~
 
-Executes the argument string in the shell as a Python subprocess.
-
-For more information about how this works, see the `subprocess documentation`_
+Executes a command through the shell.
 
 Syntax:
 
 .. code-block:: yaml
 
+   # Default shell.
    <hook_point>:
      - !cmd <shell_command>
 
-Example:
+   # Another shell.
+   <hook_point>:
+     - !cmd
+         run: <shell_command>
+         shell: <shell_name_or_path>
+
+Pass the command string as the only argument to use the default shell.
+
+On POSIX the default shell is ``sh``. On Windows it's usually ``cmd.exe``, but ``%ComSpec%`` may
+override that.
+
+Write the command string as you would type it at the shell prompt. This includes quotes and
+backslashes to escape filenames with spaces in them. To minimize escaping, you can use YAML plain
+strings like the following examples.
+
+.. code-block:: yaml
+
+   hooks:
+     before_update:
+       - !cmd echo "Hello, world!"
+
+A successful command prints its standard output messages between status messages from Sceptre.
+
+.. code-block::
+
+   [2023-09-03 01:06:28] - test - Launching Stack
+   [2023-09-03 01:06:29] - test - Stack is in the CREATE_COMPLETE state
+   Hello, world!
+   [2023-09-03 01:06:31] - test - Updating Stack
+   [2023-09-03 01:06:31] - test - No updates to perform.
+
+Pass named arguments to use a different shell. Here the command string is called ``run`` and the
+shell executable is called ``shell``.
+
+Write the executable name as you would type it at an interactive prompt to start the shell. For
+example, if Bash is in the system path, you can write ``bash``; otherwise, you need to write the
+absolute path such as ``/bin/bash``.
+
+.. code-block:: yaml
+
+   hooks:
+     before_update:
+       - !cmd
+           run: echo "Hello, $0!"
+           shell: bash
+
+.. code-block:: text
+
+   [2023-09-04 00:29:42] - test - Launching Stack
+   [2023-09-04 00:29:43] - test - Stack is in the CREATE_COMPLETE state
+   Hello, bash!
+   [2023-09-04 00:29:43] - test - Updating Stack
+   [2023-09-04 00:29:43] - test - No updates to perform.
+
+You can use PowerShell in the same way.
+
+.. code-block:: yaml
+
+   hooks:
+     before_update:
+       - !cmd
+           run: Write-Output "Hello, Posh!"
+           shell: pwsh
+
+.. code-block:: text
+
+   [2023-09-04 00:44:32] - test - Launching Stack
+   [2023-09-04 00:44:33] - test - Stack is in the CREATE_COMPLETE state
+   Hello, Posh!
+   [2023-09-04 00:44:34] - test - Updating Stack
+   [2023-09-04 00:44:34] - test - No updates to perform.
+
+If the shell command fails, so does Sceptre. Its output sits between Sceptre's status
+messages and a Python traceback.
 
 .. code-block:: yaml
 
-   before_create:
-     - !cmd "echo hello"
+   hooks:
+     before_update:
+       - !cmd missing_command
+
+.. code-block:: text
+
+   [2023-09-04 00:46:25] - test - Launching Stack
+   [2023-09-04 00:46:26] - test - Stack is in the CREATE_COMPLETE state
+   /bin/sh: 1: missing_command: not found
+   Traceback (most recent call last):
+   <snip>
+   subprocess.CalledProcessError: Command 'missing_command' returned non-zero exit status 127.
 
 asg_scaling_processes
 ~~~~~~~~~~~~~~~~~~~~~
diff --git a/sceptre/hooks/cmd.py b/sceptre/hooks/cmd.py
index 5ec3216..8292e87 100644
--- a/sceptre/hooks/cmd.py
+++ b/sceptre/hooks/cmd.py
@@ -13,16 +13,37 @@ class Cmd(Hook):
 
     def run(self):
         """
-        Runs the argument string in a subprocess.
+        Executes a command through the shell.
 
-        :raises: sceptre.exceptions.InvalidTaskArgumentTypeException
-        :raises: subprocess.CalledProcessError
+        See hooks documentation for details.
+
+        :raises: sceptre.exceptions.InvalidHookArgumentTypeError invalid input
+        :raises: CalledProcessError failed command
+        :raises: FileNotFoundError missing shell
+        :raises: PermissionError non-executable shell
         """
         envs = self.stack.connection_manager.create_session_environment_variables()
-        try:
-            subprocess.check_call(self.argument, shell=True, env=envs)
-        except TypeError:
+
+        if isinstance(self.argument, str) and self.argument != "":
+            command_to_run = self.argument
+            shell = None
+
+        elif (
+            isinstance(self.argument, dict)
+            and set(self.argument) == {"run", "shell"}
+            and isinstance(self.argument["run"], str)
+            and isinstance(self.argument["shell"], str)
+            and self.argument["run"] != ""
+            and self.argument["shell"] != ""
+        ):
+            command_to_run = self.argument["run"]
+            shell = self.argument["shell"]
+
+        else:
             raise InvalidHookArgumentTypeError(
-                'The argument "{0}" is the wrong type - cmd hooks require '
-                "arguments of type string.".format(self.argument)
+                "A cmd hook requires either a string argument or an object with "
+                "`run` and `shell` keys with string values. "
+                f"You gave `{self.argument!r}`."
             )
+
+        subprocess.check_call(command_to_run, shell=True, env=envs, executable=shell)


Test patch:
diff --git a/tests/test_hooks/test_cmd.py b/tests/test_hooks/test_cmd.py
index 3f15a7f..d65b313 100644
--- a/tests/test_hooks/test_cmd.py
+++ b/tests/test_hooks/test_cmd.py
@@ -1,37 +1,174 @@
 # -*- coding: utf-8 -*-
-import subprocess
-from unittest.mock import patch, Mock
-
+from subprocess import CalledProcessError
+from unittest.mock import Mock
 import pytest
 
 from sceptre.exceptions import InvalidHookArgumentTypeError
 from sceptre.hooks.cmd import Cmd
 from sceptre.stack import Stack
 
+ERROR_MESSAGE = (
+    r"^A cmd hook requires either a string argument or an object with `run` and "
+    r"`shell` keys with string values\. You gave `{0}`\.$"
+)
+
+
+@pytest.fixture()
+def stack():
+    stack = Stack(
+        "stack1",
+        "project1",
+        "region1",
+        template_handler_config={"template": "path.yaml"},
+    )
+
+    # Otherwise the test works only when the environment variables already set a
+    # valid AWS session.
+    stack.connection_manager.create_session_environment_variables = Mock(
+        return_value={}
+    )
+
+    return stack
+
+
+def test_null_input_raises_exception(stack):
+    message = ERROR_MESSAGE.format(r"None")
+    with pytest.raises(InvalidHookArgumentTypeError, match=message):
+        Cmd(None, stack).run()
+
+
+def test_empty_string_raises_exception(stack):
+    message = ERROR_MESSAGE.format(r"''")
+    with pytest.raises(InvalidHookArgumentTypeError, match=message):
+        Cmd("", stack).run()
+
+
+def test_list_raises_exception(stack):
+    message = ERROR_MESSAGE.format(r"\['echo', 'hello'\]")
+    with pytest.raises(InvalidHookArgumentTypeError, match=message):
+        Cmd(["echo", "hello"], stack).run()
+
+
+def test_dict_without_shell_raises_exception(stack):
+    message = ERROR_MESSAGE.format(r"\{'run': 'echo hello'\}")
+    with pytest.raises(InvalidHookArgumentTypeError, match=message):
+        Cmd({"run": "echo hello"}, stack).run()
+
+
+def test_dict_without_run_raises_exception(stack):
+    message = ERROR_MESSAGE.format(r"\{'shell': '/bin/bash'\}")
+    with pytest.raises(InvalidHookArgumentTypeError, match=message):
+        Cmd({"shell": "/bin/bash"}, stack).run()
+
+
+def test_dict_with_list_run_raises_exception(stack):
+    message = ERROR_MESSAGE.format(
+        r"\{'run': \['echo', 'hello'\], 'shell': '/bin/bash'\}"
+    )
+    with pytest.raises(InvalidHookArgumentTypeError, match=message):
+        Cmd({"run": ["echo", "hello"], "shell": "/bin/bash"}, stack).run()
+
+
+def test_dict_with_empty_run_raises_exception(stack):
+    message = ERROR_MESSAGE.format(r"\{'run': '', 'shell': '/bin/bash'\}")
+    with pytest.raises(InvalidHookArgumentTypeError, match=message):
+        Cmd({"run": "", "shell": "/bin/bash"}, stack).run()
+
+
+def test_dict_with_null_run_raises_exception(stack):
+    message = ERROR_MESSAGE.format(r"\{'run': None, 'shell': '/bin/bash'\}")
+    with pytest.raises(InvalidHookArgumentTypeError, match=message):
+        Cmd({"run": None, "shell": "/bin/bash"}, stack).run()
+
+
+def test_dict_with_list_shell_raises_exception(stack):
+    message = ERROR_MESSAGE.format(r"\{'run': 'echo hello', 'shell': \['/bin/bash'\]\}")
+    with pytest.raises(InvalidHookArgumentTypeError, match=message):
+        Cmd({"run": "echo hello", "shell": ["/bin/bash"]}, stack).run()
+
+
+def test_dict_with_typo_shell_raises_exception(stack):
+    import platform
+
+    if platform.python_version().startswith("3.7."):
+        message = r"^\[Errno 2\] No such file or directory: '/bin/bsah': '/bin/bsah'$"
+    else:
+        message = r"^\[Errno 2\] No such file or directory: '/bin/bsah'$"
+    with pytest.raises(FileNotFoundError, match=message):
+        typo = "/bin/bsah"
+        Cmd({"run": "echo hello", "shell": typo}, stack).run()
+
+
+def test_dict_with_non_executable_shell_raises_exception(stack):
+    message = r"^\[Errno 13\] Permission denied: '/'$"
+    with pytest.raises(PermissionError, match=message):
+        Cmd({"run": "echo hello", "shell": "/"}, stack).run()
+
+
+def test_dict_with_empty_shell_raises_exception(stack):
+    message = ERROR_MESSAGE.format(r"\{'run': 'echo hello', 'shell': ''\}")
+    with pytest.raises(InvalidHookArgumentTypeError, match=message):
+        Cmd({"run": "echo hello", "shell": ""}, stack).run()
+
+
+def test_dict_with_null_shell_raises_exception(stack):
+    message = ERROR_MESSAGE.format(r"\{'run': 'echo hello', 'shell': None\}")
+    with pytest.raises(InvalidHookArgumentTypeError, match=message):
+        Cmd({"run": "echo hello", "shell": None}, stack).run()
+
+
+def test_input_exception_reprs_input(stack):
+    import datetime
+
+    exception_message = ERROR_MESSAGE.format(r"datetime.date\(2023, 8, 31\)")
+    with pytest.raises(InvalidHookArgumentTypeError, match=exception_message):
+        Cmd(datetime.date(2023, 8, 31), stack).run()
+
+
+def test_zero_exit_returns(stack):
+    Cmd("exit 0", stack).run()
+
+
+def test_nonzero_exit_raises_exception(stack):
+    message = r"Command 'exit 1' returned non-zero exit status 1\."
+    with pytest.raises(CalledProcessError, match=message):
+        Cmd("exit 1", stack).run()
+
+
+def test_hook_writes_to_stdout(stack, capfd):
+    Cmd("echo hello", stack).run()
+    cap = capfd.readouterr()
+    assert cap.out.strip() == "hello"
+    assert cap.err.strip() == ""
+
+
+def test_hook_writes_to_stderr(stack, capfd):
+    with pytest.raises(Exception):
+        Cmd("missing_command", stack).run()
+    cap = capfd.readouterr()
+    assert cap.out.strip() == ""
+    assert cap.err.strip() == "/bin/sh: 1: missing_command: not found"
+
+
+def test_default_shell_is_sh(stack, capfd):
+    Cmd("echo $0", stack).run()
+    cap = capfd.readouterr()
+    assert cap.out.strip() == "/bin/sh"
+    assert cap.err.strip() == ""
+
+
+def test_shell_parameter_sets_the_shell(stack, capfd):
+    Cmd({"run": "echo $0", "shell": "/bin/bash"}, stack).run()
+    cap = capfd.readouterr()
+    assert cap.out.strip() == "/bin/bash"
+    assert cap.err.strip() == ""
+
 
-class TestCmd(object):
-    def setup_method(self, test_method):
-        self.stack = Mock(Stack)
-        self.stack.name = "my/stack.yaml"
-        self.cmd = Cmd(stack=self.stack)
-
-    def test_run_with_non_str_argument(self):
-        self.cmd.argument = None
-        with pytest.raises(InvalidHookArgumentTypeError):
-            self.cmd.run()
-
-    @patch("sceptre.hooks.cmd.subprocess.check_call")
-    def test_run_with_str_argument(self, mock_call):
-        self.cmd.argument = "echo hello"
-        self.cmd.run()
-        expected_envs = (
-            self.stack.connection_manager.create_session_environment_variables.return_value
-        )
-        mock_call.assert_called_once_with("echo hello", shell=True, env=expected_envs)
-
-    @patch("sceptre.hooks.cmd.subprocess.check_call")
-    def test_run_with_erroring_command(self, mock_call):
-        mock_call.side_effect = subprocess.CalledProcessError(1, "echo")
-        self.cmd.argument = "echo hello"
-        with pytest.raises(subprocess.CalledProcessError):
-            self.cmd.run()
+def test_shell_has_session_environment_variables(stack, capfd):
+    stack.connection_manager.create_session_environment_variables = Mock(
+        return_value={"AWS_PROFILE": "sceptre_profile"}
+    )
+    Cmd("echo $AWS_PROFILE", stack).run()
+    cap = capfd.readouterr()
+    assert cap.out.strip() == "sceptre_profile"
+    assert cap.err.strip() == ""