From 78373e1877ca009788495655ef002f29269b112b 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 | 5 ++-- .../test_integration_subcommand.py | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 07a62efeed..ec06b78a2e 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 @@ -206,7 +207,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) @@ -217,7 +218,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 a6a3807498..909e9c7acb 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -2733,6 +2733,30 @@ def test_unbalanced_quote_exits_cleanly(self, capsys): assert excinfo.value.exit_code == 1 assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out + def test_bad_option_token_with_rich_markup_exits_cleanly(self): + """A bad option token carrying Rich markup must exit cleanly, not crash. + + The token is user-controlled and gets interpolated into console.print. + A value like '[/red]foo' parses fine through shlex but is an unexpected + value / unknown option — and an unbalanced Rich tag would raise + rich.errors.MarkupError inside console.print, leaking a traceback + instead of the intended typer.Exit(1). The token must be escaped.""" + 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 + + # Unexpected value token carrying markup. + with pytest.raises(typer.Exit): + _parse_integration_options(integration, "[/red]foo") + + # Unknown option token carrying markup. + with pytest.raises(typer.Exit): + _parse_integration_options(integration, "--[/red]bad") + class TestUninstallNoManifestClearsInitOptions: def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path): From 8655efe407cd78a94b74c32afb147146b391966b 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. --- .../test_integration_subcommand.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 909e9c7acb..75db6142c3 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -2757,6 +2757,31 @@ def test_bad_option_token_with_rich_markup_exits_cleanly(self): with pytest.raises(typer.Exit): _parse_integration_options(integration, "--[/red]bad") + 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):