Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions design/workflow-step.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ automatically validate a definition passed to `execute()`. Guard invalid
configurations in `execute()` too, returning a failed result rather than a
successful default or an unhandled exception.

The built-in shell step preserves raw `run` interpolation by default.
Per-step `strict: true` rejects expression markers in `run` during validation
and execution. Its optional `env` mapping evaluates string templates separately,
requires NUL-free string results encodable for the process environment on
POSIX, and overrides only the child environment.
`SPECKIT_WORKFLOW_DIR` remains engine-owned. This prevents workflow expression
values from becoming shell source in strict mode; it is not a sandbox or a
guarantee that an authored script handles environment data safely.
Environment bindings and inherited settings can intentionally affect executable
lookup, module loading, and runtime startup. Strict mode does not filter them;
workflow source review must cover both `run` and `env` and referenced code.

New runs persist an execution tree. Resume replays completed occurrences into
their expression contexts without calling their implementations, then continues
at the unfinished occurrence. Selected branches, custom expansions, loop
Expand Down
5 changes: 5 additions & 0 deletions docs/reference/bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,11 @@ non-default source.

> **Vet both the bundle source and its component catalogs.** A project can supply its own bundle catalog and `install-allowed` component catalogs (extension, preset, workflow, and step) under `.specify/`; that project configuration existing is not evidence anything in it was reviewed. Before installing a bundle from an unfamiliar project, run `specify bundle catalog list` — and the equivalent `extension`/`preset`/`workflow catalog list` and `specify workflow step catalog list` commands for the components it pulls in — and treat any source you didn't add yourself as unvetted until you've reviewed it.

For workflow components, inspect shell `run` and `env` fields and their
referenced code, including changes introduced by active overlays. This applies
even to `strict: true` steps: environment bindings can change executable lookup
or runtime loading. See [Reviewing shell execution](workflows.md#reviewing-shell-execution).

### List the Catalog Stack

```bash
Expand Down
88 changes: 85 additions & 3 deletions docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ referents, so a pre-existing cache-file symlink can be replaced without changing
its target. If a cache cannot be safely written, a warning is logged and
discovery continues using the fetched catalog.

> **A project's `.specify/workflow-catalogs.yml` can point `add` and `search` at a catalog you didn't choose.** Before running a workflow from an unfamiliar project, run `specify workflow catalog list` (and `specify workflow step catalog list` for the step catalogs its steps can pull in) — a project supplying that config is not evidence its workflows or steps were vetted. Maintainers do not audit `run` fields; read a workflow's shell steps yourself before running it (see [Who maintains workflows?](#who-maintains-workflows)).
> **A project's `.specify/workflow-catalogs.yml` can point `add` and `search` at a catalog you didn't choose.** Before running a workflow from an unfamiliar project, run `specify workflow catalog list` (and `specify workflow step catalog list` for the step catalogs its steps can pull in) — a project supplying that config is not evidence its workflows or steps were vetted. Maintainers do not audit workflow code; review both `run` and `env` in its shell steps yourself, including strict steps (see [Reviewing shell execution](#reviewing-shell-execution) and [Who maintains workflows?](#who-maintains-workflows)).

### List Catalogs

Expand Down Expand Up @@ -782,6 +782,32 @@ qualified `step_id`. A step emits completion after its checkpoint; containers
emit before their children, while replayed completed occurrences emit no step
events.

### Shell step

`type: shell` executes `run` through the platform shell, retaining support for
pipes, redirects, and multiple commands. Set `strict: true` to reject `{{`
expression markers anywhere in `run`, including inside quotes. This is checked
both during validation and immediately before execution. `strict` must be a
boolean; omitted or `false` preserves existing raw interpolation behavior.

Use an `env` mapping to bind dynamic values without inserting them into shell
source. For example, on POSIX:

```yaml
- id: display-result
type: shell
strict: true
env:
RESULT: "{{ steps.plan.output.stdout }}"
run: 'printf "%s\n" "$RESULT"'
```

For cross-platform scripts, read the environment in the program itself, such
as `os.environ["RESULT"]` in Python. See
[Shell Step Environment Variables](#shell-step-environment-variables) for the
binding contract and [Interpolation and shell safety](#interpolation-and-shell-safety)
for the remaining trust boundary.

> **Security note:** a `shell` step runs a local command with **your** privileges. There is no capability sandbox — `requires` is an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does **not** restrict what a step can do. In particular there is no `requires.permissions` capability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use a `gate` step to require explicit approval before sensitive or destructive shell commands.

### GitHub step
Expand Down Expand Up @@ -1237,9 +1263,11 @@ If an interpolated value can contain characters like `;`, `|`, `&`, `$( )`, back
- **Workflow `inputs.*`** — supplied by whoever runs the workflow.
- **A prior step's output**, e.g. `{{ steps.plan.output.stdout }}` — for a `command` or `prompt` step with [`capture: true`](#capturing-command-and-prompt-output) this is **text produced by the AI agent**, which can in turn be influenced by files, tickets, or web content the agent read. Treat agent output as untrusted when it flows into a `shell` step.

There is **no shell-escaping filter** in the expression language and **no sandbox** around a `shell` step, so none of the practices below can be treated as a guarantee that a hostile value is neutralised. The only reliable control is to constrain what an interpolated value *can* be, and to keep values you cannot constrain out of `run` fields entirely. Scrutinise every `run` field that interpolates a value you do not control, and at minimum:
There is **no shell-escaping filter** in the expression language and **no sandbox** around a `shell` step. Prefer [`strict: true`](#shell-step) with `env` bindings to keep dynamic values out of shell source. Strict mode does not sanitise data, inspect commands, or prevent an authored script from interpreting data as code. Do not use `eval`, build another `sh -c` command from a variable, or pass untrusted values through shell startup variables such as `BASH_ENV`. On Windows, `cmd.exe` expansion of `%VALUE%` can introduce shell syntax; read untrusted values inside a program instead. On POSIX, quote variable expansions and use the target program's argument validation or `--` where appropriate to prevent option injection.

- **Constrain the value at the source with `enum`/an allowlist.** When `inputs.*` feeds a `run` field, restrict it to a fixed set of known-safe values so a caller cannot supply arbitrary shell text at all. This is the strongest control the engine offers — prefer it over any downstream mitigation.
For compatibility, raw interpolation remains enabled when `strict` is omitted or `false`. Its injection risk remains unchanged. Keep values you cannot constrain out of `run` fields entirely. Scrutinise every non-strict `run` field that interpolates a value you do not control, and at minimum:

- **Constrain the value at the source with `enum`/an allowlist.** When `inputs.*` feeds a non-strict `run` field, restrict it to a fixed set of known-safe values so a caller cannot supply arbitrary shell text at all.

```yaml
inputs:
Expand All @@ -1262,6 +1290,60 @@ Shell steps automatically receive the following environment variables:
| -------- | ----------- |
| `SPECKIT_WORKFLOW_DIR` | Resolved absolute path to the workflow source directory (same value as `{{ context.workflow_dir }}`). Not set when the workflow has no source path. |

An optional per-step `env` mapping is available in both strict and non-strict
mode. Names must match `[A-Za-z_][A-Za-z0-9_]*`; names are literal, not
expressions. Values must be strings and may contain workflow expressions.
Each value is evaluated once and must resolve to a string without NUL
characters. On POSIX, the string must also be encodable for the process
environment using the filesystem encoding and its error handler.
Invalid bindings fail the step before launching a process.

Single-expression bindings preserve the expression's result type:
`COUNT: "{{ inputs.count }}"` rejects a number, boolean, null, list, or mapping
instead of converting it to text. Mixed templates use standard expression
interpolation: `COUNT: "n={{ inputs.count }}"` produces `n=42` for integer
`42`; embedded null becomes empty text, and other values use their string
representation. To serialize structured data explicitly, use `to_json`.

Bindings override inherited environment values for that subprocess only;
they do not modify the parent process or subsequent steps. An empty string is
valid. `SPECKIT_WORKFLOW_DIR` is reserved (case-insensitively) and cannot be
overridden. Keep environment names and executable-loading settings under the
workflow author's control: environment binding is a data channel, not a sandbox.

### Reviewing shell execution

Before installing or running an unfamiliar workflow, audit each shell step's
`run` **and** `env`, including nested branches and fan-out templates. Review
referenced scripts and resources, called workflows, and active overlay YAML
that can replace or insert steps. Apply the same review to workflow components
of bundles, and repeat it when definitions, overlays, or referenced code change.
Custom step packages require their own source review because loading them
executes Python code.

An environment value can control execution rather than merely supply data.
For example, `PATH` selects executables, `PYTHONPATH` affects Python imports,
`BASH_ENV` can select Bash startup code, and `NODE_OPTIONS` can change Node.js
startup behavior. Native loaders and other programs have their own settings;
these examples are not an exhaustive list. A fixed `run` can therefore execute
different code even with `strict: true` and no `eval`. Strict mode neither
filters execution-controlling bindings nor cleans the inherited environment.
Keep those settings literal and author-vetted, or constrain dynamic values to
an approved set of trusted paths/options. Use unconstrained input or agent
output only for application-data variables whose consumers handle it as data.

When a shell step invokes Specify itself, catalog overrides such as
`SPECKIT_WORKFLOW_CATALOG_URL` can also change where child commands discover
or install components. Vet those bindings alongside the
[active catalog sources](catalogs.md#discovery-and-trust).

Workflow validation checks configuration, not whether environment settings or
commands are safe. `specify workflow info` shows metadata and step identities;
`specify workflow resolve` shows layer and step attribution. Neither displays
the complete `run`/`env` configuration or replaces reading the contributing
source files. Catalog install permission, bundle installation, and strict mode
are not security endorsements.

## Input Types

| Type | Coercion |
Expand Down
103 changes: 87 additions & 16 deletions src/specify_cli/workflows/step/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import locale
import math
import os
import re
import subprocess
from typing import Any

Expand All @@ -23,6 +24,13 @@ class ShellStep(StepBase):
type_key = "shell"

def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
binding_errors = self._binding_errors(config)
if binding_errors:
return StepResult(
status=StepStatus.FAILED,
error="; ".join(binding_errors),
output={"exit_code": -1, "stdout": "", "stderr": "invalid shell bindings"},
)
run_cmd = config.get("run", "")
if isinstance(run_cmd, str) and "{{" in run_cmd:
run_cmd = evaluate_expression(run_cmd, context)
Expand All @@ -45,15 +53,33 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
)

env = {**os.environ}
for name, template in config.get("env", {}).items():
try:
value = evaluate_expression(template, context)
except ValueError as exc:
return StepResult(
status=StepStatus.FAILED,
error=f"Shell step {config.get('id', '?')!r}: env {name!r}: {exc}",
output={"exit_code": -1, "stdout": "", "stderr": "invalid environment expression"},
)
value_error = self._environment_value_error(name, value)
if value_error is not None:
return StepResult(
status=StepStatus.FAILED,
error=f"Shell step {config.get('id', '?')!r}: {value_error}",
output={"exit_code": -1, "stdout": "", "stderr": "invalid environment value"},
)
env[name.upper() if os.name == "nt" else name] = value
if context.workflow_dir:
env["SPECKIT_WORKFLOW_DIR"] = context.workflow_dir
else:
env.pop("SPECKIT_WORKFLOW_DIR", None)

# NOTE: shell=True is required to support pipes, redirects, and
# multi-command expressions in workflow YAML. Workflow authors
# control commands; catalog-installed workflows should be reviewed
# before use (see PUBLISHING.md for security guidance).
# control commands. strict=True rejects expression interpolation in
# run; dynamic data can instead be bound through env without becoming
# shell source. Neither mode sandboxes authored shell commands.
try:
proc = subprocess.run( # noqa: S602 -- intentional shell=True (see NOTE above)
run_cmd,
Expand Down Expand Up @@ -121,6 +147,51 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
output={"exit_code": -1, "stdout": "", "stderr": str(exc)},
)

@staticmethod
def _environment_value_error(name: str, value: Any) -> str | None:
if not isinstance(value, str):
return f"env {name!r} must be a string, got {type(value).__name__}."
if "\x00" in value:
return f"env {name!r} must not contain NUL characters."
if os.name == "posix":
try:
os.fsencode(value)
except UnicodeEncodeError:
return f"env {name!r} cannot be encoded for the process environment."
return None

@classmethod
def _binding_errors(cls, config: dict[str, Any]) -> list[str]:
errors: list[str] = []
prefix = f"Shell step {config.get('id', '?')!r}: "
strict = config.get("strict", False)
if not isinstance(strict, bool):
errors.append(prefix + "'strict' must be a boolean.")
elif strict:
run = config.get("run")
if not isinstance(run, str):
errors.append(prefix + "'run' must be a string in strict mode.")
elif "{{" in run:
errors.append(
prefix + "strict mode forbids expressions in 'run'; "
"bind dynamic values through 'env' instead."
)

bindings = config.get("env", {})
if not isinstance(bindings, dict):
errors.append(prefix + "'env' must be a mapping.")
return errors
for name, template in bindings.items():
if not isinstance(name, str) or re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None:
errors.append(prefix + f"invalid environment variable name {name!r}.")
continue
if name.upper() == "SPECKIT_WORKFLOW_DIR":
errors.append(prefix + f"environment variable {name!r} is reserved.")
value_error = cls._environment_value_error(name, template)
if value_error is not None:
errors.append(prefix + value_error)
return errors

@staticmethod
def _timeout_error(config: dict[str, Any]) -> str | None:
"""Return an error message if ``config['timeout']`` is invalid, else None.
Expand Down Expand Up @@ -157,20 +228,20 @@ def _timeout_error(config: dict[str, Any]) -> str | None:

def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "run" not in config:
errors.append(
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
)
elif not isinstance(config["run"], str):
# execute() str()-coerces run and invokes it under shell=True, so a
# null or list 'run' would run the Python repr ('None', "['echo']")
# as a command. Reject non-strings at validation, mirroring the
# command-step input/options and gate options type checks. An
# expression like "{{ ... }}" is still a str, so it stays valid.
errors.append(
f"Shell step {config.get('id', '?')!r}: 'run' must be a string, "
f"got {type(config['run']).__name__}."
)
errors.extend(self._binding_errors(config))
# Strict run errors are already reported by _binding_errors().
if config.get("strict") is not True:
if "run" not in config:
errors.append(
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
)
elif not isinstance(config["run"], str):
# Legacy execution coerces run to shell source; validation
# must still reject non-strings without changing that path.
errors.append(
f"Shell step {config.get('id', '?')!r}: 'run' must be a string, "
f"got {type(config['run']).__name__}."
)
output_format = config.get("output_format")
if output_format is not None and output_format != "json":
errors.append(
Expand Down
Loading
Loading