diff --git a/libs/cli/README.md b/libs/cli/README.md index cac7808bb..82016c0b9 100644 --- a/libs/cli/README.md +++ b/libs/cli/README.md @@ -103,6 +103,8 @@ The CLI uses a `langgraph.json` configuration file with these key settings: } ``` +Git dependencies must use credential-free URLs. The CLI rejects HTTP Git URLs with userinfo because generated Dockerfiles and image layers can retain embedded usernames or tokens. For private dependencies, provide short-lived credentials through your build environment's secret-backed Git credential helper. Do not store credentials in `langgraph.json`, requirement or lock files, or a `pip_config_file` copied into the image. + See the [full documentation](https://reference.langchain.com/python/langgraph-cli) for detailed configuration options. ## Development diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 57b225187..ecf3cc894 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -36,6 +36,7 @@ DISALLOWED_BUILD_COMMAND_CHARS = [ # This blocks background execution (cmd &) while allowing command # chaining (cmd1 && cmd2) which is common in build commands. _SINGLE_AMPERSAND_RE = re.compile(r"(?[^/\s]+)", re.I) _API_VERSION_PATTERN = re.compile( r"^(?P\d+)" r"(?:\.(?P\d+))?" @@ -78,6 +79,14 @@ def has_disallowed_build_command_content(command: str) -> bool: return False +def _has_git_http_url_userinfo(dependency: str) -> bool: + """Check whether a Git HTTP URL contains userinfo.""" + return any( + "@" in match.group("authority") + for match in _GIT_HTTP_AUTHORITY_RE.finditer(dependency) + ) + + MIN_PYTHON_VERSION = "3.11" DEFAULT_PYTHON_VERSION = "3.11" @@ -415,6 +424,18 @@ def validate_config(config: Config) -> Config: ' "source": {"kind": "uv", "root": ".."}' ) + if any( + isinstance(dependency, str) and _has_git_http_url_userinfo(dependency) + for dependency in config["dependencies"] + ): + raise click.UsageError( + "Git dependency URLs must not contain credentials or other URL " + "userinfo because generated Dockerfiles and image layers can retain " + "them. Use a credential-free Git URL and provide short-lived " + "credentials through your build environment's secret-backed Git " + "credential helper." + ) + source = config.get("source") source_kind = _get_source_kind(config) if source is not None and not isinstance(source, dict): diff --git a/libs/cli/langgraph_cli/schemas.py b/libs/cli/langgraph_cli/schemas.py index 5a00fc673..6678d7d67 100644 --- a/libs/cli/langgraph_cli/schemas.py +++ b/libs/cli/langgraph_cli/schemas.py @@ -689,6 +689,9 @@ class Config(TypedDict, total=False): - "." or "./src" if you have a local Python package - str (aka "anthropic") for a PyPI package - "git+https://github.com/org/repo.git@main" for a Git-based package + Git HTTP URLs must not contain userinfo such as a username or token. For private + dependencies, provide short-lived credentials through the build environment's + secret-backed Git credential helper. Defaults to an empty list, meaning no additional packages installed beyond your base environment. This field is not supported when `source.kind` is `uv`. diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index c8b24020c..3d34f415d 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -255,6 +255,50 @@ def test_validate_config(): ) +@pytest.mark.parametrize( + "dependency", + [ + "git+https://user:secret-token@github.com/org/private.git@main", + "private-package @ git+http://token@github.com/org/private.git", + "git+HTTPS://user%40example.com:secret%2Ftoken@github.com/org/private.git", + ], +) +def test_validate_config_rejects_git_http_url_userinfo(dependency: str): + with pytest.raises(click.UsageError) as exc_info: + validate_config( + { + "python_version": "3.11", + "dependencies": [dependency], + "graphs": {"agent": "./agent.py:graph"}, + } + ) + + message = str(exc_info.value) + assert "must not contain credentials or other URL userinfo" in message + assert "secret-token" not in message + assert "secret%2Ftoken" not in message + + +@pytest.mark.parametrize( + "dependency", + [ + "git+https://github.com/org/public.git@main", + "private-package @ git+https://github.com/org/private.git@main", + "git+ssh://git@github.com/org/private.git@main", + ], +) +def test_validate_config_allows_git_urls_without_http_userinfo(dependency: str): + config = validate_config( + { + "python_version": "3.11", + "dependencies": [dependency], + "graphs": {"agent": "./agent.py:graph"}, + } + ) + + assert config["dependencies"] == [dependency] + + def test_validate_config_image_distro(): """Test validation of image_distro field.""" # Valid image_distro values should work