From 3183fd1bbaeaeb5e9e84a9a8ee689284f847fe8a Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Jul 2026 21:14:16 +0500 Subject: [PATCH 1/2] fix(integrations): exit cleanly on malformed --integration-options quoting _parse_integration_options called shlex.split(raw_options) unguarded. an unbalanced quote (e.g. --integration-options='--commands-dir "foo') makes shlex raise ValueError('No closing quotation'), so a raw traceback escaped instead of the typer.Exit(1) error every other bad-input path in this function produces. reachable from specify init and every integration install/switch/ upgrade/migrate that accepts --integration-options. wrap the split and convert ValueError into the same clean CLI error. added a regression test; confirmed it fails on the pre-fix code (raw ValueError). --- src/specify_cli/integrations/_helpers.py | 13 ++++++++++++- .../test_integration_subcommand.py | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index d1bf051f77..60d718a104 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -190,7 +190,18 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, """ import shlex parsed: dict[str, Any] = {} - tokens = shlex.split(raw_options) + try: + tokens = shlex.split(raw_options) + except ValueError as error: + # shlex raises on malformed input (e.g. an unbalanced quote in + # --integration-options='--commands-dir "foo'). Surface the clean CLI + # error every other bad-input path here produces instead of leaking a + # raw ValueError traceback. + console.print( + f"[red]Error:[/red] Could not parse integration options " + f"'{raw_options}': {error}." + ) + raise typer.Exit(1) from error declared_options = list(integration.options()) declared = {opt.name.lstrip("-"): opt for opt in declared_options} allowed = ", ".join(sorted(opt.name for opt in declared_options)) diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 56f338cc82..a3bd75c31c 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -2675,6 +2675,24 @@ def test_equals_form_parsed(self): assert result_space["commands_dir"] == "./mydir" assert result_equals["commands_dir"] == "./mydir" + def test_malformed_quoting_exits_cleanly(self): + """An unbalanced quote must raise a clean typer.Exit, not a ValueError. + + shlex.split raises ValueError('No closing quotation') on malformed + input. Every other bad-input path in this function exits via + typer.Exit(1); a raw ValueError traceback would be inconsistent and + user-hostile for a plain CLI-flag typo.""" + import typer + + from specify_cli.integrations._commands import _parse_integration_options + from specify_cli.integrations import get_integration + + integration = get_integration("generic") + assert integration is not None + + with pytest.raises(typer.Exit): + _parse_integration_options(integration, '--commands-dir "foo') + class TestUninstallNoManifestClearsInitOptions: def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path): From aa810ba65101d10e41b488d43d97964dc8b70e1f Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Tue, 14 Jul 2026 01:53:28 +0500 Subject: [PATCH 2/2] escape user-controlled values in integration-options error messages the malformed-quoting handler (and the unexpected/unknown option branches) interpolate raw_options/token into console.print. a value carrying an unbalanced rich tag like '--commands-dir "[/red]foo' first trips the intended shlex ValueError, but the error print then raises rich.errors.MarkupError and leaks a traceback anyway. escape all three before printing so the clean typer.Exit survives. added a regression covering both the shlex path and a bare markup token. --- src/specify_cli/integrations/_helpers.py | 11 +++++--- .../test_integration_subcommand.py | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 60d718a104..65dbeb944e 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -6,6 +6,7 @@ from typing import Any, Callable import typer +from rich.markup import escape from .._agent_config import SCRIPT_TYPE_CHOICES from .._console import console @@ -196,10 +197,12 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, # shlex raises on malformed input (e.g. an unbalanced quote in # --integration-options='--commands-dir "foo'). Surface the clean CLI # error every other bad-input path here produces instead of leaking a - # raw ValueError traceback. + # raw ValueError traceback. raw_options is user-controlled, so escape + # it before interpolating — an unbalanced Rich tag like '[/red]' would + # otherwise raise MarkupError inside console.print and leak a traceback. console.print( f"[red]Error:[/red] Could not parse integration options " - f"'{raw_options}': {error}." + f"'{escape(raw_options)}': {escape(str(error))}." ) raise typer.Exit(1) from error declared_options = list(integration.options()) @@ -209,7 +212,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, while i < len(tokens): token = tokens[i] if not token.startswith("-"): - console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.") + console.print(f"[red]Error:[/red] Unexpected integration option value '{escape(token)}'.") if allowed: console.print(f"Allowed options: {allowed}") raise typer.Exit(1) @@ -220,7 +223,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, name, value = name.split("=", 1) opt = declared.get(name) if not opt: - console.print(f"[red]Error:[/red] Unknown integration option '{token}'.") + console.print(f"[red]Error:[/red] Unknown integration option '{escape(token)}'.") if allowed: console.print(f"Allowed options: {allowed}") raise typer.Exit(1) diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index a3bd75c31c..8ec8706bdc 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -2693,6 +2693,31 @@ def test_malformed_quoting_exits_cleanly(self): with pytest.raises(typer.Exit): _parse_integration_options(integration, '--commands-dir "foo') + def test_malformed_quoting_with_rich_markup_exits_cleanly(self): + """A malformed value carrying Rich markup must still exit cleanly. + + raw_options is user-controlled. A value like '--commands-dir "[/red]foo' + first trips the shlex ValueError path, but the error message then + interpolates the raw value into console.print — an unbalanced Rich tag + such as '[/red]' would raise rich.errors.MarkupError there and leak a + traceback anyway. The value must be escaped so the clean typer.Exit + survives.""" + import typer + + from specify_cli.integrations._commands import _parse_integration_options + from specify_cli.integrations import get_integration + + integration = get_integration("generic") + assert integration is not None + + # Unbalanced quote (shlex path) + markup injection in one value. + with pytest.raises(typer.Exit): + _parse_integration_options(integration, '--commands-dir "[/red]foo') + + # Markup injection in a token that parses but is unexpected/unknown. + with pytest.raises(typer.Exit): + _parse_integration_options(integration, "[/red]foo") + class TestUninstallNoManifestClearsInitOptions: def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):