| 19 | type_key = "shell" |
| 20 | |
| 21 | def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: |
| 22 | run_cmd = config.get("run", "") |
| 23 | if isinstance(run_cmd, str) and "{{" in run_cmd: |
| 24 | run_cmd = evaluate_expression(run_cmd, context) |
| 25 | run_cmd = str(run_cmd) |
| 26 | |
| 27 | cwd = context.project_root or "." |
| 28 | |
| 29 | # NOTE: shell=True is required to support pipes, redirects, and |
| 30 | # multi-command expressions in workflow YAML. Workflow authors |
| 31 | # control commands; catalog-installed workflows should be reviewed |
| 32 | # before use (see PUBLISHING.md for security guidance). |
| 33 | try: |
| 34 | proc = subprocess.run( # noqa: S602 -- intentional shell=True (see NOTE above) |
| 35 | run_cmd, |
| 36 | shell=True, |
| 37 | capture_output=True, |
| 38 | text=True, |
| 39 | cwd=cwd, |
| 40 | timeout=300, |
| 41 | ) |
| 42 | output = { |
| 43 | "exit_code": proc.returncode, |
| 44 | "stdout": proc.stdout, |
| 45 | "stderr": proc.stderr, |
| 46 | } |
| 47 | if proc.returncode != 0: |
| 48 | return StepResult( |
| 49 | status=StepStatus.FAILED, |
| 50 | error=f"Shell command exited with code {proc.returncode}.", |
| 51 | output=output, |
| 52 | ) |
| 53 | if config.get("output_format") == "json": |
| 54 | # Opt-in structured output: expose the parsed stdout under |
| 55 | # ``output.data`` so later steps can consume typed values |
| 56 | # (e.g. a fan-out's ``items:``). A parse failure fails the |
| 57 | # step — declaring ``output_format: json`` is a contract. |
| 58 | try: |
| 59 | output["data"] = json.loads(proc.stdout) |
| 60 | except json.JSONDecodeError as exc: |
| 61 | return StepResult( |
| 62 | status=StepStatus.FAILED, |
| 63 | error=( |
| 64 | f"Shell step {config.get('id', '?')!r} declared " |
| 65 | f"output_format: json but stdout is not valid " |
| 66 | f"JSON: {exc}" |
| 67 | ), |
| 68 | output=output, |
| 69 | ) |
| 70 | return StepResult( |
| 71 | status=StepStatus.COMPLETED, |
| 72 | output=output, |
| 73 | ) |
| 74 | except subprocess.TimeoutExpired: |
| 75 | return StepResult( |
| 76 | status=StepStatus.FAILED, |
| 77 | error="Shell command timed out after 300 seconds.", |
| 78 | output={"exit_code": -1, "stdout": "", "stderr": "timeout"}, |