Coverage for mcp/cli_runner.py: 100%
22 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 15:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 15:07 +0000
1"""
2CLI runner for the GCO MCP server.
4Provides ``_run_cli()`` which shells out to the ``gco`` CLI with
5``--output json`` and returns the result. All arguments are passed as
6separate list elements (shell=False) to prevent command injection.
7"""
9import json
10import subprocess
11from pathlib import Path
13PROJECT_ROOT = Path(__file__).parent.parent
16def _run_cli(*args: str) -> str:
17 """Run a gco CLI command and return its output.
19 All args are passed as separate list elements to subprocess (shell=False),
20 so shell metacharacters in user-provided values are treated as literals
21 and cannot cause command injection. Path arguments are validated to prevent
22 traversal outside the project root.
23 """
24 # Validate any path-like arguments to prevent directory traversal.
25 for arg in args:
26 if arg.startswith("-"):
27 continue # flag, not a path
28 if ".." in arg.split("/"):
29 return json.dumps({"error": f"Invalid argument: path traversal not allowed: {arg}"})
31 cmd = ["gco", "--output", "json", *args]
32 try:
33 result = subprocess.run( # nosemgrep: dangerous-subprocess-use-audit - shell=False; args are validated above and passed as literal argv elements
34 cmd,
35 capture_output=True,
36 text=True,
37 timeout=120,
38 cwd=str(PROJECT_ROOT),
39 )
40 output = result.stdout.strip()
41 if result.returncode != 0:
42 error = result.stderr.strip() or output
43 return json.dumps({"error": error, "exit_code": result.returncode})
44 return output if output else json.dumps({"status": "ok"})
45 except subprocess.TimeoutExpired:
46 return json.dumps({"error": "Command timed out after 120 seconds"})
47 except FileNotFoundError:
48 return json.dumps({"error": "gco CLI not found. Install with: pipx install -e ."})