diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index cf066876a..cf2c1429c 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -20,6 +20,7 @@ from langgraph_cli.docker import DockerCapabilities from langgraph_cli.exec import Runner, subp_exec from langgraph_cli.progress import Progress from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new +from langgraph_cli.util import warn_non_wolfi_distro from langgraph_cli.version import __version__ OPT_DOCKER_COMPOSE = click.option( @@ -375,6 +376,7 @@ def build( if shutil.which("docker") is None: raise click.UsageError("Docker not installed") from None config_json = langgraph_cli.config.validate_config_file(config) + warn_non_wolfi_distro(config_json) _build( runner, set, config, config_json, base_image, pull, tag, docker_build_args ) @@ -466,6 +468,7 @@ def dockerfile( save_path = pathlib.Path(save_path).absolute() secho(f"🔍 Validating configuration at path: {config}", fg="yellow") config_json = langgraph_cli.config.validate_config_file(config) + warn_non_wolfi_distro(config_json) secho("✅ Configuration validated!", fg="green") secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow") @@ -791,6 +794,7 @@ def prepare( ) -> tuple[list[str], str]: """Prepare the arguments and stdin for running the LangGraph API server.""" config_json = langgraph_cli.config.validate_config_file(config_path) + warn_non_wolfi_distro(config_json) # pull latest images if pull: runner.run( diff --git a/libs/cli/langgraph_cli/util.py b/libs/cli/langgraph_cli/util.py index 79b67a2c9..61c2ae8b8 100644 --- a/libs/cli/langgraph_cli/util.py +++ b/libs/cli/langgraph_cli/util.py @@ -1,2 +1,25 @@ +import click + + def clean_empty_lines(input_str: str): return "\n".join(filter(None, input_str.splitlines())) + + +def warn_non_wolfi_distro(config_json: dict) -> None: + """Show warning if image_distro is not set to 'wolfi'.""" + image_distro = config_json.get("image_distro", "debian") # Default is debian + if image_distro != "wolfi": + click.secho( + "⚠️ Security Recommendation: Consider switching to Wolfi Linux for enhanced security.", + fg="yellow", + bold=True, + ) + click.secho( + " Wolfi is a security-oriented, minimal Linux distribution designed for containers.", + fg="yellow", + ) + click.secho( + " To switch, add '\"image_distro\": \"wolfi\"' to your langgraph.json config file.", + fg="yellow", + ) + click.secho("") # Empty line for better readability diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 02eec1560..b9101fe76 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -438,3 +438,91 @@ def test_dockerfile_command_with_bad_config() -> None: # Assert command was successful assert result.exit_code == 2 assert "conf.json' does not exist" in result.output + + +def test_dockerfile_command_shows_wolfi_warning() -> None: + """Test the 'dockerfile' command shows warning when image_distro is not wolfi.""" + runner = CliRunner() + config_content = { + "python_version": "3.11", + "graphs": {"agent": "agent.py:graph"}, + "dependencies": ["."], + # No image_distro specified - should default to debian and show warning + } + + with temporary_config_folder(config_content) as temp_dir: + save_path = temp_dir / "Dockerfile" + agent_path = temp_dir / "agent.py" + agent_path.touch() + + result = runner.invoke( + cli, + ["dockerfile", str(save_path), "--config", str(temp_dir / "config.json")], + ) + + # Assert command was successful + assert result.exit_code == 0, result.output + + # Check that warning is shown + assert "Security Recommendation" in result.output + assert "Wolfi Linux" in result.output + assert "image_distro" in result.output + assert "wolfi" in result.output + + +def test_dockerfile_command_no_wolfi_warning_when_wolfi_set() -> None: + """Test the 'dockerfile' command does NOT show warning when image_distro is wolfi.""" + runner = CliRunner() + config_content = { + "python_version": "3.11", + "graphs": {"agent": "agent.py:graph"}, + "dependencies": ["."], + "image_distro": "wolfi", # Explicitly set to wolfi - should not show warning + } + + with temporary_config_folder(config_content) as temp_dir: + save_path = temp_dir / "Dockerfile" + agent_path = temp_dir / "agent.py" + agent_path.touch() + + result = runner.invoke( + cli, + ["dockerfile", str(save_path), "--config", str(temp_dir / "config.json")], + ) + + # Assert command was successful + assert result.exit_code == 0, result.output + + # Check that warning is NOT shown + assert "Security Recommendation" not in result.output + assert "Wolfi Linux" not in result.output + + +def test_build_command_shows_wolfi_warning() -> None: + """Test the 'build' command shows warning when image_distro is not wolfi.""" + runner = CliRunner() + config_content = { + "python_version": "3.11", + "graphs": {"agent": "agent.py:graph"}, + "dependencies": ["."], + # No image_distro specified - should default to debian and show warning + } + + with temporary_config_folder(config_content) as temp_dir: + agent_path = temp_dir / "agent.py" + agent_path.touch() + + # Mock docker command since we don't want to actually build + with runner.isolated_filesystem(): + result = runner.invoke( + cli, + ["build", "--tag", "test-image", "--config", str(temp_dir / "config.json")], + catch_exceptions=True, + ) + + # The command will fail because docker isn't available or we're mocking, + # but we should still see the warning before it fails + assert "Security Recommendation" in result.output + assert "Wolfi Linux" in result.output + assert "image_distro" in result.output + assert "wolfi" in result.output diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index e3aed1bc2..6acdb00ca 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -1091,4 +1091,3 @@ def test_docker_tag_different_node_versions_with_distro(): }) tag = docker_tag(config) assert tag == expected_tag, f"Failed for Node.js {node_version}" -