Repository navigation
[Bug]: Workflow commands crash with rich.errors.MarkupError when user or catalog text contains square brackets #5076
Description
Bug Description
Several specify workflow commands print user input, catalog data or workflow YAML fields with console.print without escaping Rich markup. Text containing a closing tag such as [/x] makes Rich raise MarkupError, so the command exits with a traceback. Text such as [red] is silently removed from the output. The sibling workflow search and workflow info commands do escape this text.
Confirmed (each one is reproduced by the script below):
specify workflow step search "[/x]": the "no matches" message prints the query unescaped (src/specify_cli/workflows/step/command_search.py:28-30). The process crashes with MarkupError.specify workflow step search "a[red]b": printsNo step types found matching 'ab'.specify workflow step searchagainst a step catalog that repeats the ID[/evil]: the catalog error is printed unescaped (step/command_search.py:23). The process crashes with MarkupError.specify workflow catalog add "https://[/x]": the validation error, which includes the URL, is printed unescaped (catalog/command_add.py:22). The process crashes with MarkupError.specify workflow step catalog add "https://[/x]": the same failure (step/catalog/command_add.py:23).specify workflow run wf.yml, where the workflow name isBuild [/x] pipeline: the "Running workflow" banner prints the name unescaped (command_run.py:126). The process crashes with MarkupError before the run starts. With the nameRelease [bold]notes, the banner showsRelease notes.
The same pattern also appears at these sites, which were not run:
catalog/command_list.py:20,catalog/command_remove.py:23step/catalog/command_list.py:20,step/catalog/command_remove.py:24step/command_info.py:77,step/command_info.py:115command_status.py:73,command_status.py:92
All paths are under src/specify_cli/workflows/.
Steps to Reproduce
Save the script below as repro.py in a fresh clone (after uv sync) and run uv run python -I repro.py. It serves two small step catalogs and an empty workflow catalog from a local 127.0.0.1 HTTP server.
import functools, http.server, json, os, subprocess, tempfile, threading
from pathlib import Path
proj = Path(tempfile.mkdtemp())
(proj / ".specify").mkdir()
site = proj / "site"
site.mkdir()
(site / "empty-steps.json").write_text(json.dumps({"steps": {}}))
(site / "dup-steps.json").write_text(json.dumps({"steps": [{"id": "[/evil]"}, {"id": "[/evil]"}]}))
(site / "wf.json").write_text(json.dumps({"workflows": {}}))
class Quiet(http.server.SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
handler = functools.partial(Quiet, directory=str(site))
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_address[1]}"
(proj / "wf.yml").write_text(
'schema_version: "1.0"\nworkflow:\n id: "demo"\n name: "Build [/x] pipeline"\n'
' version: "1.0.0"\nsteps:\n - id: s1\n type: shell\n run: "true"\n')
(proj / "wf2.yml").write_text(
(proj / "wf.yml").read_text().replace("Build [/x] pipeline", "Release [bold]notes"))
def specify(*args, **env):
e = {k: v for k, v in os.environ.items() if not k.startswith("SPECKIT_")}
r = subprocess.run(["specify", *args], cwd=proj, env={**e, **env},
capture_output=True, text=True)
last = (r.stderr.strip().splitlines() or [""])[-1]
print(f"$ specify {' '.join(args)}\n exit={r.returncode} stdout={r.stdout.strip()!r}\n last stderr line: {last}")
specify("workflow", "step", "search", "[/x]", SPECKIT_STEP_CATALOG_URL=base + "/empty-steps.json")
specify("workflow", "search", "[/x]", SPECKIT_WORKFLOW_CATALOG_URL=base + "/wf.json")
specify("workflow", "step", "search", "a[red]b", SPECKIT_STEP_CATALOG_URL=base + "/empty-steps.json")
specify("workflow", "step", "search", SPECKIT_STEP_CATALOG_URL=base + "/dup-steps.json")
specify("workflow", "catalog", "add", "https://[/x]")
specify("workflow", "step", "catalog", "add", "https://[/x]")
specify("workflow", "run", str(proj / "wf.yml"))
specify("workflow", "run", str(proj / "wf2.yml"))Expected Behavior
Bracketed text is printed literally, and an invalid input gives a clean Error: line with exit code 1, as specify workflow search "[/x]" already does. src/specify_cli/workflows/command_info.py:64-69 states that every workflow.yml field is untrusted and must be escaped because console.print has Rich markup enabled.
Actual Behavior
$ specify workflow step search [/x]
exit=1 stdout=''
last stderr line: MarkupError: closing tag '[/x]' at position 38 doesn't match any open tag
$ specify workflow search [/x]
exit=0 stdout='No workflows found.'
last stderr line:
$ specify workflow step search a[red]b
exit=0 stdout="No step types found matching 'ab'."
last stderr line:
$ specify workflow step search
exit=1 stdout=''
last stderr line: MarkupError: closing tag '[/evil]' at position 37 doesn't match any open tag
$ specify workflow catalog add https://[/x]
exit=1 stdout=''
last stderr line: MarkupError: closing tag '[/x]' at position 52 doesn't match any open tag
$ specify workflow step catalog add https://[/x]
exit=1 stdout=''
last stderr line: MarkupError: closing tag '[/x]' at position 52 doesn't match any open tag
$ specify workflow run /tmp/tmp_deawjx0/wf.yml
exit=1 stdout=''
last stderr line: MarkupError: closing tag '[/x]' at position 48 doesn't match any open tag
$ specify workflow run /tmp/tmp_deawjx0/wf2.yml
exit=0 stdout='Running workflow: Release notes (demo)\nVersion: 1.0.0\n\n ▸ [s1] shell …\n\nStatus: completed\nRun ID: 417da8a3'
last stderr line:
Specify CLI Version
1.2.4.dev0 (main at 27d14e2)
AI Agent
Not applicable
Operating System
Linux 6.18 (Ubuntu container)
Python Version
Python 3.12.3
Error Logs
rich.errors.MarkupError: closing tag '[/x]' at position 38 doesn't match any open tag
rich.errors.MarkupError: closing tag '[/evil]' at position 37 doesn't match any open tag
rich.errors.MarkupError: closing tag '[/x]' at position 52 doesn't match any open tag
rich.errors.MarkupError: closing tag '[/x]' at position 48 doesn't match any open tag
Additional Context
Wrapping the interpolated values in _escape_markup, as the neighbouring commands do, would fix each site.
Also reproduced on macOS (Darwin 25.6, arm64) with Python 3.14.4 and uv run python -I repro.py against 27d14e2; the output matches the above.
AI Disclosure
Found and reproduced with Claude Code (model: Claude Opus 4.8), run autonomously under human supervision. The human reporter re-ran every reproduction above before filing.
Activity