From 2adde509a500f75b21ab4e100e572a1dbe93c7a7 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Sun, 16 Aug 2026 19:24:56 +0000 Subject: [PATCH] fix(sdk/python): add AGENTFIELD_SKIP_IP_DETECTION opt-out for container IP probing _build_callback_candidates() calls _detect_container_ip() unconditionally whenever _is_running_in_container() is true, regardless of whether an explicit callback URL is already known. That function fires blocking requests to cloud-metadata endpoints (169.254.169.254, metadata.google.internal) and falls back to the third-party api.ipify.org, with no way to turn it off. In a Kubernetes NetworkPolicy-restricted environment this floods deny logs and leaks pod egress to a third party for a best-effort callback candidate that is often unused. Add AGENTFIELD_SKIP_IP_DETECTION (default false, same boolean-env-var convention used elsewhere in this module) and gate the probe behind it. Default behavior is unchanged. Fixes #624 --- docs/ENVIRONMENT_VARIABLES.md | 1 + sdk/python/agentfield/agent.py | 12 ++++-- sdk/python/tests/test_agent_networking.py | 47 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 57e2d11ac..49b083019 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -144,6 +144,7 @@ The same concept applies to **Docker**: - `AGENTFIELD_URL` (recommended): Control plane base URL. - `AGENT_NODE_ID` (optional): Node id. - `AGENT_CALLBACK_URL` (recommended in Docker/Kubernetes): URL the control plane will call back to (examples: `http://my-agent:8001`, or for host-run agents with Dockerized control plane: `http://host.docker.internal:8001`). +- `AGENTFIELD_SKIP_IP_DETECTION` (optional, default `false`): set to `true` to skip the automatic cloud-metadata and third-party IP lookups (`169.254.169.254`, `metadata.google.internal`, `api.ipify.org`) that always run when a container is detected, regardless of whether `AGENT_CALLBACK_URL` is already set. Set this when those probes generate egress traffic or network-policy-deny log noise you don't want. Many Python examples also require model provider credentials (for example `OPENAI_API_KEY`), depending on the `AIConfig` you choose. diff --git a/sdk/python/agentfield/agent.py b/sdk/python/agentfield/agent.py index 004164e57..04a53429b 100644 --- a/sdk/python/agentfield/agent.py +++ b/sdk/python/agentfield/agent.py @@ -175,6 +175,11 @@ def _docstring_summary(func: Callable) -> str: aiohttp = None +def _ip_detection_disabled() -> bool: + """Whether cloud-metadata / third-party IP probing is opted out via env var.""" + return os.getenv("AGENTFIELD_SKIP_IP_DETECTION", "false").lower() == "true" + + def _detect_container_ip() -> Optional[str]: """ Detect the external IP address when running in a containerized environment. @@ -367,9 +372,10 @@ def add_candidate(raw: Optional[str]): if railway_service_name and railway_environment: add_candidate(f"http://{railway_service_name}.railway.internal:{port}") - external_ip = _detect_container_ip() - if external_ip: - add_candidate(f"http://{external_ip}:{port}") + if not _ip_detection_disabled(): + external_ip = _detect_container_ip() + if external_ip: + add_candidate(f"http://{external_ip}:{port}") # 4. Local network hints local_ip = _detect_local_ip() diff --git a/sdk/python/tests/test_agent_networking.py b/sdk/python/tests/test_agent_networking.py index a97348ab3..e4e6fc992 100644 --- a/sdk/python/tests/test_agent_networking.py +++ b/sdk/python/tests/test_agent_networking.py @@ -115,6 +115,53 @@ def test_resolve_callback_url_uses_first_candidate(monkeypatch): assert resolved == "http://from-env:7777" +def test_build_callback_candidates_skips_ip_detection_when_disabled(monkeypatch): + """AGENTFIELD_SKIP_IP_DETECTION=true must stop _detect_container_ip from being + called at all, not just from being used (issue #624: the cloud-metadata/ipify + probes fire unconditionally on every container start with no opt-out, flooding + NetworkPolicy deny logs and leaking pod egress for a best-effort candidate).""" + monkeypatch.setattr(agent_mod, "_is_running_in_container", lambda: True) + monkeypatch.setattr(agent_mod, "_detect_local_ip", lambda: None) + monkeypatch.setenv("AGENTFIELD_SKIP_IP_DETECTION", "true") + + calls = [] + monkeypatch.setattr( + agent_mod, "_detect_container_ip", lambda: calls.append(1) or "203.0.113.10" + ) + + candidates = _build_callback_candidates(None, 9090) + + assert calls == [] + assert not any("203.0.113.10" in candidate for candidate in candidates) + + +def test_build_callback_candidates_runs_ip_detection_by_default(monkeypatch): + """Default behavior (no env var set) is unchanged: the probe still runs.""" + monkeypatch.setattr(agent_mod, "_is_running_in_container", lambda: True) + monkeypatch.setattr(agent_mod, "_detect_local_ip", lambda: None) + monkeypatch.delenv("AGENTFIELD_SKIP_IP_DETECTION", raising=False) + monkeypatch.setattr(agent_mod, "_detect_container_ip", lambda: "203.0.113.10") + + candidates = _build_callback_candidates(None, 9090) + + assert any(candidate.startswith("http://203.0.113.10") for candidate in candidates) + + +@pytest.mark.parametrize("value", ["true", "True", "TRUE"]) +def test_ip_detection_disabled_is_case_insensitive(monkeypatch, value): + monkeypatch.setenv("AGENTFIELD_SKIP_IP_DETECTION", value) + assert agent_mod._ip_detection_disabled() is True + + +@pytest.mark.parametrize("value", [None, "false", "0", "garbage"]) +def test_ip_detection_disabled_defaults_to_false(monkeypatch, value): + if value is None: + monkeypatch.delenv("AGENTFIELD_SKIP_IP_DETECTION", raising=False) + else: + monkeypatch.setenv("AGENTFIELD_SKIP_IP_DETECTION", value) + assert agent_mod._ip_detection_disabled() is False + + def test_build_callback_discovery_payload_marks_container(monkeypatch): agent, _ = create_test_agent(monkeypatch) agent.callback_candidates = ["http://first:7000", "http://second:7000"]