From de4ad9247f73daa4094dd9eef2e7a801a8e56070 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Fri, 11 Sep 2026 21:14:44 +0500 Subject: [PATCH] fix(workflows): cap gate show_file line width, not just its line count `_read_show_file` bounds the number of lines with `MAX_SHOW_FILE_LINES`, but nothing bounds a single line's length. A file with no newlines -- minified JSON, a lockfile, a base64 blob, all plausible review material for a gate -- is one line of arbitrary size, so the cap never triggers and the entire file floods the prompt, which is precisely what the cap exists to prevent. Reproduced on main: many short lines : returned 201 lines, total chars=1323 (cap=200) one long line : returned 1 lines, total chars=400008 <-- unbounded With the per-line cap: many short lines : 201 lines, 1323 chars (unchanged) one long line : 1 lines, 536 chars short file : ['hello', 'world'] (untouched) Co-Authored-By: Claude Opus 5 (1M context) --- .../workflows/steps/gate/__init__.py | 13 ++++++++- tests/test_workflows.py | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/steps/gate/__init__.py b/src/specify_cli/workflows/steps/gate/__init__.py index 5aac060c0f..7d7e6b0212 100644 --- a/src/specify_cli/workflows/steps/gate/__init__.py +++ b/src/specify_cli/workflows/steps/gate/__init__.py @@ -35,6 +35,10 @@ class GateStep(StepBase): #: Maximum number of ``show_file`` lines rendered at the prompt, so a #: large file cannot flood the terminal before the choice. MAX_SHOW_FILE_LINES = 200 + # A line cap alone does not bound the output: a file with no newlines -- + # minified JSON, a lockfile, a base64 blob -- is a single line of arbitrary + # length and floods the prompt exactly as the line cap exists to prevent. + MAX_SHOW_FILE_LINE_CHARS = 500 def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: message = config.get("message", "Review required.") @@ -283,7 +287,14 @@ def _read_show_file(show_file: str) -> list[str]: if len(lines) >= GateStep.MAX_SHOW_FILE_LINES: truncated = True break - lines.append(_CONTROL_CHARS.sub("", line.rstrip("\n"))) + clean = _CONTROL_CHARS.sub("", line.rstrip("\n")) + if len(clean) > GateStep.MAX_SHOW_FILE_LINE_CHARS: + clean = ( + clean[: GateStep.MAX_SHOW_FILE_LINE_CHARS] + + f"… (line truncated at " + f"{GateStep.MAX_SHOW_FILE_LINE_CHARS} characters)" + ) + lines.append(clean) except (OSError, UnicodeDecodeError, ValueError) as exc: # ``exc`` echoes the (possibly hostile) path, so strip it too. return [_CONTROL_CHARS.sub("", f"(could not read file: {exc})")] diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 2c7141e954..e369839168 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -3079,6 +3079,34 @@ def test_read_show_file_truncates_large_file(self, tmp_path): assert len(rendered) == GateStep.MAX_SHOW_FILE_LINES + 1 assert "truncated" in rendered[-1] + def test_read_show_file_truncates_a_single_enormous_line(self, tmp_path): + """The line cap alone does not bound the output. + + A file with no newlines — minified JSON, a lockfile, a base64 blob — is + a single line of arbitrary length, so `MAX_SHOW_FILE_LINES` never + triggers and the whole file floods the gate prompt, which is exactly + what that cap exists to prevent. + """ + from specify_cli.workflows.steps.gate import GateStep + + blob = tmp_path / "min.json" + blob.write_text('{"k":"' + "A" * 400_000 + '"}', encoding="utf-8") + + rendered = GateStep._read_show_file(str(blob)) + + assert len(rendered) == 1 + assert len(rendered[0]) < GateStep.MAX_SHOW_FILE_LINE_CHARS + 100 + assert "line truncated" in rendered[0] + + def test_read_show_file_leaves_short_lines_untouched(self, tmp_path): + """Lines within the cap are rendered verbatim, with no notice.""" + from specify_cli.workflows.steps.gate import GateStep + + path = tmp_path / "short.md" + path.write_text("hello\nworld\n", encoding="utf-8") + + assert GateStep._read_show_file(str(path)) == ["hello", "world"] + def test_read_show_file_invalid_path_does_not_raise(self): from specify_cli.workflows.steps.gate import GateStep