chatflow-agent 0.3.2__tar.gz → 0.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/.github/workflows/publish.yml +0 -3
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/CHANGELOG.md +10 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/PKG-INFO +1 -1
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/pyproject.toml +1 -1
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/__init__.py +1 -1
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/engines/openai_compatible.py +24 -3
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_multi_engine.py +96 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/.github/workflows/test.yml +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/.gitignore +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/LICENSE +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/README.md +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/examples/cli_multiagent.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/examples/local_gemma_cli.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/examples/multi_provider_swarm.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/examples/telegram_persistent_bot.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/examples/telegram_triage.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/examples/webhook_api_service.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/examples/whatsapp_persistent_bot.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/examples/whatsapp_service.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/channels/__init__.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/channels/base.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/channels/cli.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/channels/telegram.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/channels/whatsapp.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/__init__.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/agent.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/engine.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/engines/__init__.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/engines/anthropic.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/engines/base.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/engines/factory.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/engines/gemini.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/memory.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/runner.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/tools.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/exceptions.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/memory.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/py.typed +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/types.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/__init__.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_agent.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_cli_channel.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_memory.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_memory_integrity.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_memory_sqlite.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_resilience.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_runner.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_runner_rollback.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_runner_security.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_telegram.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_tools.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_types.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_whatsapp.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_whatsapp_background.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_whatsapp_resilience_outbound.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_whatsapp_security.py +0 -0
- {chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/tests/test_whatsapp_split.py +0 -0
|
@@ -14,6 +14,11 @@ Versioning / Versionado: [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.3.3] - 2026-10-19
|
|
18
|
+
|
|
19
|
+
### Security
|
|
20
|
+
- **Strict Local Endpoint Hostname Validation:** `OpenAIEngine` now validates `base_url` structure, HTTP/HTTPS scheme, port range, and parsed hostname via `urllib.parse.urlsplit` when identifying local endpoints (`localhost`, `127.0.0.1`, `::1`, and local bind compatibility address `0.0.0.0`), replacing substring matching (Issue #74).
|
|
21
|
+
|
|
17
22
|
## [0.3.2] - 2026-09-29
|
|
18
23
|
|
|
19
24
|
### Security
|
|
@@ -69,6 +74,11 @@ If you are upgrading from v0.2.0:
|
|
|
69
74
|
|
|
70
75
|
## [Unreleased]
|
|
71
76
|
|
|
77
|
+
## [0.3.3] - 2026-10-19
|
|
78
|
+
|
|
79
|
+
### Seguridad
|
|
80
|
+
- **Validación Estricta de Hostname en Endpoints Locales:** `OpenAIEngine` ahora valida la estructura de `base_url`, el esquema HTTP/HTTPS, el rango de puerto y el hostname real mediante `urllib.parse.urlsplit` al identificar endpoints locales (`localhost`, `127.0.0.1`, `::1` y la dirección de compatibilidad local `0.0.0.0`), reemplazando la coincidencia por subcadenas (Issue #74).
|
|
81
|
+
|
|
72
82
|
## [0.3.2] - 2026-09-29
|
|
73
83
|
|
|
74
84
|
### Seguridad
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: chatflow-agent
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Lightweight, async multi-agent framework with native handoffs for Gemini, Gemma, Grok, OpenAI & Claude.
|
|
5
5
|
Project-URL: Homepage, https://github.com/GustavoBaranda/chatflow-agent
|
|
6
6
|
Project-URL: Source Code, https://github.com/GustavoBaranda/chatflow-agent
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "chatflow-agent"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
description = "Lightweight, async multi-agent framework with native handoffs for Gemini, Gemma, Grok, OpenAI & Claude."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
{chatflow_agent-0.3.2 → chatflow_agent-0.3.3}/src/chatflow_agent/core/engines/openai_compatible.py
RENAMED
|
@@ -10,6 +10,7 @@ import json
|
|
|
10
10
|
import logging
|
|
11
11
|
import os
|
|
12
12
|
from typing import TYPE_CHECKING, Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
13
14
|
|
|
14
15
|
import httpx
|
|
15
16
|
|
|
@@ -22,6 +23,28 @@ logger = logging.getLogger("chatflow_agent.engine.openai")
|
|
|
22
23
|
if TYPE_CHECKING:
|
|
23
24
|
from chatflow_agent.core.agent import Agent
|
|
24
25
|
|
|
26
|
+
_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
27
|
+
# Explicit compatibility exception for local dev servers bound to 0.0.0.0 (not a loopback address)
|
|
28
|
+
_COMPAT_LOCAL_BIND_HOSTS: frozenset[str] = frozenset({"0.0.0.0"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_local_base_url(base_url: str) -> bool:
|
|
32
|
+
"""Return True only when base_url is a well-formed HTTP(S) URL targeting a local host."""
|
|
33
|
+
try:
|
|
34
|
+
parsed = urlsplit(base_url)
|
|
35
|
+
_ = parsed.port
|
|
36
|
+
except ValueError:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:
|
|
40
|
+
return False
|
|
41
|
+
if parsed.username is not None or parsed.password is not None:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
hostname = (parsed.hostname or "").lower().rstrip(".")
|
|
45
|
+
return hostname in _LOOPBACK_HOSTS or hostname in _COMPAT_LOCAL_BIND_HOSTS
|
|
46
|
+
|
|
47
|
+
|
|
25
48
|
# Preset configurations for common OpenAI-compatible providers
|
|
26
49
|
PROVIDER_PRESETS: dict[str, dict[str, str]] = {
|
|
27
50
|
"openai": {
|
|
@@ -100,9 +123,7 @@ class OpenAIEngine(BaseEngine):
|
|
|
100
123
|
p["base_url"].rstrip("/") for p in PROVIDER_PRESETS.values()
|
|
101
124
|
}
|
|
102
125
|
is_official = self.base_url in official_base_urls
|
|
103
|
-
is_local =
|
|
104
|
-
host in self.base_url for host in ("localhost", "127.0.0.1", "0.0.0.0")
|
|
105
|
-
)
|
|
126
|
+
is_local = _is_local_base_url(self.base_url)
|
|
106
127
|
|
|
107
128
|
env_key_name = preset.get("env_key", "OPENAI_API_KEY")
|
|
108
129
|
|
|
@@ -279,6 +279,102 @@ def test_custom_base_url_rejects_fallback_to_global_api_key(monkeypatch: pytest.
|
|
|
279
279
|
assert engine_official.api_key == "sk-secret-prod-key-12345"
|
|
280
280
|
|
|
281
281
|
|
|
282
|
+
@pytest.mark.parametrize(
|
|
283
|
+
"spoofed_or_malformed_url",
|
|
284
|
+
[
|
|
285
|
+
"https://localhost.untrusted.example.com/v1",
|
|
286
|
+
"https://sub.localhost.example.com/v1",
|
|
287
|
+
"https://my-localhost.example.com/v1",
|
|
288
|
+
"https://untrusted.example.com/localhost/v1",
|
|
289
|
+
"https://untrusted.example.com/v1?origin=localhost",
|
|
290
|
+
"https://localhost@untrusted.example.com/v1",
|
|
291
|
+
"http://user:pass@localhost:11434/v1",
|
|
292
|
+
"https://127.0.0.1.untrusted.example.com/v1",
|
|
293
|
+
"https://untrusted.example.com/127.0.0.1/v1",
|
|
294
|
+
"https://0.0.0.0.untrusted.example.com/v1",
|
|
295
|
+
"ftp://localhost:11434/v1",
|
|
296
|
+
"localhost:11434/v1",
|
|
297
|
+
"http:///localhost/v1",
|
|
298
|
+
"http://[::1:11434/v1",
|
|
299
|
+
"http://localhost:badport/v1",
|
|
300
|
+
"http://localhost:99999/v1",
|
|
301
|
+
],
|
|
302
|
+
)
|
|
303
|
+
def test_spoofed_or_malformed_local_base_url_is_rejected(
|
|
304
|
+
monkeypatch: pytest.MonkeyPatch, spoofed_or_malformed_url: str
|
|
305
|
+
) -> None:
|
|
306
|
+
"""External or malformed URLs containing local substrings must not bypass SEC-07 or leak env keys."""
|
|
307
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret-prod-key")
|
|
308
|
+
monkeypatch.setenv("OLLAMA_API_KEY", "ollama-secret-token")
|
|
309
|
+
|
|
310
|
+
for provider in ("openai", "ollama", "gemma"):
|
|
311
|
+
with pytest.raises(ValueError, match="A custom or unofficial base_url was provided"):
|
|
312
|
+
OpenAIEngine(provider=provider, base_url=spoofed_or_malformed_url)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@pytest.mark.parametrize(
|
|
316
|
+
"spoofed_url",
|
|
317
|
+
[
|
|
318
|
+
"https://localhost.untrusted.example.com/v1",
|
|
319
|
+
"https://untrusted.example.com/localhost/v1",
|
|
320
|
+
"https://127.0.0.1.untrusted.example.com/v1",
|
|
321
|
+
"https://0.0.0.0.untrusted.example.com/v1",
|
|
322
|
+
"https://localhost@untrusted.example.com/v1",
|
|
323
|
+
],
|
|
324
|
+
)
|
|
325
|
+
def test_spoofed_local_base_url_from_env_vars_is_rejected(
|
|
326
|
+
monkeypatch: pytest.MonkeyPatch, spoofed_url: str
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Spoofed URLs provided via OPENAI_BASE_URL or OLLAMA_BASE_URL without explicit base_url must be rejected."""
|
|
329
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret-prod-key")
|
|
330
|
+
monkeypatch.setenv("OLLAMA_API_KEY", "ollama-secret-token")
|
|
331
|
+
|
|
332
|
+
monkeypatch.setenv("OPENAI_BASE_URL", spoofed_url)
|
|
333
|
+
with pytest.raises(ValueError, match="A custom or unofficial base_url was provided"):
|
|
334
|
+
OpenAIEngine(provider="openai")
|
|
335
|
+
|
|
336
|
+
monkeypatch.setenv("OLLAMA_BASE_URL", spoofed_url)
|
|
337
|
+
with pytest.raises(ValueError, match="A custom or unofficial base_url was provided"):
|
|
338
|
+
OpenAIEngine(provider="ollama")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@pytest.mark.parametrize(
|
|
342
|
+
"local_url",
|
|
343
|
+
[
|
|
344
|
+
"http://localhost:11434/v1",
|
|
345
|
+
"http://LOCALHOST:11434/v1/",
|
|
346
|
+
"http://localhost.:11434/v1",
|
|
347
|
+
"https://localhost:8443/v1",
|
|
348
|
+
"http://127.0.0.1:11434/v1",
|
|
349
|
+
"http://[::1]:11434/v1",
|
|
350
|
+
"http://0.0.0.0:8000/v1",
|
|
351
|
+
],
|
|
352
|
+
)
|
|
353
|
+
def test_valid_local_base_urls_are_accepted_and_protect_cloud_keys(
|
|
354
|
+
monkeypatch: pytest.MonkeyPatch, local_url: str
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Valid loopback (localhost, 127.0.0.1, ::1) and compatibility 0.0.0.0 URLs work without leaking cloud keys."""
|
|
357
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret-prod-key")
|
|
358
|
+
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
|
359
|
+
|
|
360
|
+
# provider='openai' pointed at local server must NOT leak OPENAI_API_KEY
|
|
361
|
+
engine_openai = OpenAIEngine(provider="openai", base_url=local_url)
|
|
362
|
+
assert engine_openai.api_key == "ollama"
|
|
363
|
+
|
|
364
|
+
# provider='ollama' / 'gemma' without OLLAMA_API_KEY defaults to 'ollama'
|
|
365
|
+
engine_ollama = OpenAIEngine(provider="ollama", base_url=local_url)
|
|
366
|
+
assert engine_ollama.api_key == "ollama"
|
|
367
|
+
engine_gemma = OpenAIEngine(provider="gemma", base_url=local_url)
|
|
368
|
+
assert engine_gemma.api_key == "ollama"
|
|
369
|
+
|
|
370
|
+
# provider='ollama' / 'gemma' with OLLAMA_API_KEY honors it on genuine local URLs
|
|
371
|
+
monkeypatch.setenv("OLLAMA_API_KEY", "local-ollama-token")
|
|
372
|
+
engine_ollama_auth = OpenAIEngine(provider="ollama", base_url=local_url)
|
|
373
|
+
assert engine_ollama_auth.api_key == "local-ollama-token"
|
|
374
|
+
engine_gemma_auth = OpenAIEngine(provider="gemma", base_url=local_url)
|
|
375
|
+
assert engine_gemma_auth.api_key == "local-ollama-token"
|
|
376
|
+
|
|
377
|
+
|
|
282
378
|
@pytest.mark.asyncio
|
|
283
379
|
async def test_anthropic_retry_on_529_overloaded() -> None:
|
|
284
380
|
"""Test AnthropicEngine retries on HTTP 529 Overloaded and succeeds."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|