Skip to content

[Bug]: Workflow commands crash with rich.errors.MarkupError when user or catalog text contains square brackets #5076

Open

Description

@hnipps

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": prints No step types found matching 'ab'.
  • specify workflow step search against 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 is Build [/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 name Release [bold]notes, the banner shows Release notes.

The same pattern also appears at these sites, which were not run:

  • catalog/command_list.py:20, catalog/command_remove.py:23
  • step/catalog/command_list.py:20, step/catalog/command_remove.py:24
  • step/command_info.py:77, step/command_info.py:115
  • command_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

  1. added
    triage-must-haveVerdict: high-value, important work for Spec Kit — do first
    on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    triage-must-haveVerdict: high-value, important work for Spec Kit — do first

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions